Skip to content

Commit bfba010

Browse files
EstrellaXDclaude
andcommitted
feat: add WebAuthn passkey authentication support
- Add passkey login as alternative authentication method - Support multiple passkeys per user with custom names - Backend: WebAuthn service, auth strategy pattern, API endpoints - Frontend: passkey management UI in settings, login option - Fix: convert downloader check from sync requests to async httpx to prevent blocking the event loop when downloader unavailable Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b4d90e2 commit bfba010

23 files changed

Lines changed: 1607 additions & 87 deletions

File tree

backend/pyproject.toml

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,64 @@
1+
[project]
2+
name = "auto-bangumi"
3+
version = "3.1.0"
4+
description = "AutoBangumi - Automated anime download manager"
5+
requires-python = ">=3.10"
6+
dependencies = [
7+
"anyio>=4.0.0",
8+
"beautifulsoup4>=4.12.0",
9+
"certifi>=2023.5.7",
10+
"charset-normalizer>=3.1.0",
11+
"click>=8.1.3",
12+
"fastapi>=0.109.0",
13+
"h11>=0.14.0",
14+
"idna>=3.4",
15+
"pydantic>=2.0.0",
16+
"sniffio>=1.3.0",
17+
"soupsieve>=2.4.1",
18+
"typing_extensions>=4.0.0",
19+
"urllib3>=2.0.3",
20+
"uvicorn>=0.27.0",
21+
"Jinja2>=3.1.2",
22+
"python-dotenv>=1.0.0",
23+
"python-jose>=3.3.0",
24+
"passlib>=1.7.4",
25+
"bcrypt>=4.0.1,<4.1",
26+
"python-multipart>=0.0.6",
27+
"sqlmodel>=0.0.14",
28+
"sse-starlette>=1.6.5",
29+
"semver>=3.0.1",
30+
"openai>=1.54.3",
31+
"httpx>=0.25.0",
32+
"httpx-socks>=0.9.0",
33+
"aiosqlite>=0.19.0",
34+
"sqlalchemy[asyncio]>=2.0.0",
35+
"webauthn>=2.0.0",
36+
]
37+
38+
[project.optional-dependencies]
39+
dev = [
40+
"pytest>=8.0.0",
41+
"pytest-asyncio>=0.23.0",
42+
"ruff>=0.1.0",
43+
"black>=24.0.0",
44+
]
45+
46+
[tool.pytest.ini_options]
47+
testpaths = ["src/test"]
48+
asyncio_mode = "auto"
49+
150
[tool.ruff]
251
select = [
352
# pycodestyle(E): https://beta.ruff.rs/docs/rules/#pycodestyle-e-w
4-
"E",
53+
"E",
554
# Pyflakes(F): https://beta.ruff.rs/docs/rules/#pyflakes-f
6-
"F",
55+
"F",
756
# isort(I): https://beta.ruff.rs/docs/rules/#isort-i
857
"I"
958
]
1059
ignore = [
1160
# E501: https://beta.ruff.rs/docs/rules/line-too-long/
12-
'E501',
61+
'E501',
1362
# F401: https://beta.ruff.rs/docs/rules/unused-import/
1463
# avoid unused imports lint in `__init__.py`
1564
'F401',

backend/requirements.txt

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
1-
anyio==3.7.0
1+
anyio>=4.0.0
22
bs4==0.0.1
3-
certifi==2023.5.7
4-
charset-normalizer==3.1.0
5-
click==8.1.3
6-
fastapi==0.97.0
7-
h11==0.14.0
8-
idna==3.4
9-
pydantic~=1.10
10-
PySocks==1.7.1
11-
qbittorrent-api==2023.9.53
12-
requests==2.31.0
13-
six==1.16.0
14-
sniffio==1.3.0
15-
soupsieve==2.4.1
16-
typing_extensions
17-
urllib3==2.0.3
18-
uvicorn==0.22.0
19-
attrdict==2.0.1
20-
Jinja2==3.1.2
21-
python-dotenv==1.0.0
22-
python-jose==3.3.0
23-
passlib==1.7.4
24-
bcrypt==4.0.1
25-
python-multipart==0.0.6
26-
sqlmodel==0.0.8
27-
sse-starlette==1.6.5
28-
semver==3.0.1
29-
openai==1.54.3
3+
certifi>=2023.5.7
4+
charset-normalizer>=3.1.0
5+
click>=8.1.3
6+
fastapi>=0.109.0
7+
h11>=0.14.0
8+
idna>=3.4
9+
pydantic>=2.0.0
10+
six>=1.16.0
11+
sniffio>=1.3.0
12+
soupsieve>=2.4.1
13+
typing_extensions>=4.0.0
14+
urllib3>=2.0.3
15+
uvicorn>=0.27.0
16+
Jinja2>=3.1.2
17+
python-dotenv>=1.0.0
18+
python-jose>=3.3.0
19+
passlib>=1.7.4
20+
bcrypt>=4.0.1
21+
python-multipart>=0.0.6
22+
sqlmodel>=0.0.14
23+
sse-starlette>=1.6.5
24+
semver>=3.0.1
25+
openai>=1.54.3
26+
httpx>=0.25.0
27+
httpx-socks>=0.9.0
28+
aiosqlite>=0.19.0
29+
sqlalchemy[asyncio]>=2.0.0
30+
webauthn>=2.0.0

backend/src/module/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .bangumi import router as bangumi_router
55
from .config import router as config_router
66
from .log import router as log_router
7+
from .passkey import router as passkey_router
78
from .program import router as program_router
89
from .rss import router as rss_router
910
from .search import router as search_router
@@ -13,6 +14,7 @@
1314
# API 1.0
1415
v1 = APIRouter(prefix="/v1")
1516
v1.include_router(auth_router)
17+
v1.include_router(passkey_router)
1618
v1.include_router(log_router)
1719
v1.include_router(program_router)
1820
v1.include_router(bangumi_router)

backend/src/module/api/passkey.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""
2+
Passkey 管理 API
3+
用于注册、列表、删除 Passkey 凭证
4+
"""
5+
import logging
6+
from datetime import timedelta
7+
8+
from fastapi import APIRouter, Depends, HTTPException, Request
9+
from fastapi.responses import JSONResponse, Response
10+
11+
from module.database import Database
12+
from module.models import APIResponse
13+
from module.models.passkey import (
14+
PasskeyAuthFinish,
15+
PasskeyAuthStart,
16+
PasskeyCreate,
17+
PasskeyDelete,
18+
PasskeyList,
19+
)
20+
from module.security.api import active_user, get_current_user
21+
from module.security.auth_strategy import PasskeyAuthStrategy
22+
from module.security.jwt import create_access_token
23+
from module.security.webauthn import get_webauthn_service
24+
25+
logger = logging.getLogger(__name__)
26+
router = APIRouter(prefix="/passkey", tags=["passkey"])
27+
28+
29+
def _get_webauthn_from_request(request: Request):
30+
"""
31+
从请求中构造 WebAuthnService
32+
根据 Host header 动态确定 RP ID 和 origin
33+
"""
34+
host = request.headers.get("host", "localhost:7892")
35+
rp_id = host.split(":")[0] # 去掉端口
36+
37+
# 判断协议
38+
forwarded_proto = request.headers.get("x-forwarded-proto")
39+
if forwarded_proto:
40+
scheme = forwarded_proto
41+
else:
42+
scheme = request.url.scheme
43+
44+
if scheme == "https":
45+
origin = f"https://{host}"
46+
else:
47+
origin = f"http://{host}"
48+
49+
return get_webauthn_service(rp_id, "AutoBangumi", origin)
50+
51+
52+
# ============ 注册流程 ============
53+
54+
55+
@router.post("/register/options", response_model=dict)
56+
async def get_registration_options(
57+
request: Request,
58+
username: str = Depends(get_current_user),
59+
):
60+
"""
61+
生成 Passkey 注册选项
62+
前端调用 navigator.credentials.create() 时使用
63+
"""
64+
webauthn = _get_webauthn_from_request(request)
65+
66+
async with Database() as db:
67+
try:
68+
user = await db.user.get_user(username)
69+
existing_passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
70+
71+
options = webauthn.generate_registration_options(
72+
username=username,
73+
user_id=user.id,
74+
existing_passkeys=existing_passkeys,
75+
)
76+
77+
return options
78+
79+
except Exception as e:
80+
logger.error(f"Failed to generate registration options: {e}")
81+
raise HTTPException(status_code=500, detail=str(e))
82+
83+
84+
@router.post("/register/verify", response_model=APIResponse)
85+
async def verify_registration(
86+
passkey_data: PasskeyCreate,
87+
request: Request,
88+
username: str = Depends(get_current_user),
89+
):
90+
"""
91+
验证 Passkey 注册响应并保存
92+
"""
93+
webauthn = _get_webauthn_from_request(request)
94+
95+
async with Database() as db:
96+
try:
97+
user = await db.user.get_user(username)
98+
99+
# 验证 WebAuthn 响应
100+
passkey = webauthn.verify_registration(
101+
username=username,
102+
credential=passkey_data.attestation_response,
103+
device_name=passkey_data.name,
104+
)
105+
106+
# 设置 user_id 并保存
107+
passkey.user_id = user.id
108+
await db.passkey.create_passkey(passkey)
109+
110+
return JSONResponse(
111+
status_code=200,
112+
content={
113+
"msg_en": f"Passkey '{passkey_data.name}' registered successfully",
114+
"msg_zh": f"Passkey '{passkey_data.name}' 注册成功",
115+
},
116+
)
117+
118+
except ValueError as e:
119+
logger.warning(f"Registration verification failed for {username}: {e}")
120+
raise HTTPException(status_code=400, detail=str(e))
121+
except Exception as e:
122+
logger.error(f"Failed to register passkey: {e}")
123+
raise HTTPException(status_code=500, detail=str(e))
124+
125+
126+
# ============ 认证流程 ============
127+
128+
129+
@router.post("/auth/options", response_model=dict)
130+
async def get_passkey_login_options(
131+
auth_data: PasskeyAuthStart,
132+
request: Request,
133+
):
134+
"""
135+
生成 Passkey 登录选项(challenge)
136+
前端先调用此接口,再调用 navigator.credentials.get()
137+
"""
138+
webauthn = _get_webauthn_from_request(request)
139+
140+
async with Database() as db:
141+
try:
142+
user = await db.user.get_user(auth_data.username)
143+
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
144+
145+
if not passkeys:
146+
raise HTTPException(
147+
status_code=400, detail="No passkeys registered for this user"
148+
)
149+
150+
options = webauthn.generate_authentication_options(
151+
auth_data.username, passkeys
152+
)
153+
return options
154+
155+
except HTTPException:
156+
raise
157+
except Exception as e:
158+
logger.error(f"Failed to generate login options: {e}")
159+
raise HTTPException(status_code=500, detail=str(e))
160+
161+
162+
@router.post("/auth/verify", response_model=dict)
163+
async def login_with_passkey(
164+
auth_data: PasskeyAuthFinish,
165+
response: Response,
166+
request: Request,
167+
):
168+
"""
169+
使用 Passkey 登录(替代密码登录)
170+
"""
171+
webauthn = _get_webauthn_from_request(request)
172+
173+
strategy = PasskeyAuthStrategy(webauthn)
174+
resp = await strategy.authenticate(auth_data.username, auth_data.credential)
175+
176+
if resp.status:
177+
token = create_access_token(
178+
data={"sub": auth_data.username}, expires_delta=timedelta(days=1)
179+
)
180+
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
181+
if auth_data.username not in active_user:
182+
active_user.append(auth_data.username)
183+
return {"access_token": token, "token_type": "bearer"}
184+
185+
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
186+
187+
188+
# ============ Passkey 管理 ============
189+
190+
191+
@router.get("/list", response_model=list[PasskeyList])
192+
async def list_passkeys(username: str = Depends(get_current_user)):
193+
"""获取用户的所有 Passkey"""
194+
async with Database() as db:
195+
try:
196+
user = await db.user.get_user(username)
197+
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
198+
199+
return [db.passkey.to_list_model(pk) for pk in passkeys]
200+
201+
except Exception as e:
202+
logger.error(f"Failed to list passkeys: {e}")
203+
raise HTTPException(status_code=500, detail=str(e))
204+
205+
206+
@router.post("/delete", response_model=APIResponse)
207+
async def delete_passkey(
208+
delete_data: PasskeyDelete,
209+
username: str = Depends(get_current_user),
210+
):
211+
"""删除 Passkey"""
212+
async with Database() as db:
213+
try:
214+
user = await db.user.get_user(username)
215+
await db.passkey.delete_passkey(delete_data.passkey_id, user.id)
216+
217+
return JSONResponse(
218+
status_code=200,
219+
content={
220+
"msg_en": "Passkey deleted successfully",
221+
"msg_zh": "Passkey 删除成功",
222+
},
223+
)
224+
225+
except HTTPException:
226+
raise
227+
except Exception as e:
228+
logger.error(f"Failed to delete passkey: {e}")
229+
raise HTTPException(status_code=500, detail=str(e))

0 commit comments

Comments
 (0)