Files
Auto_Bangumi/backend/src/module/api/passkey.py
EstrellaXD d2cfd9b150 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>
2026-01-23 14:57:00 +01:00

230 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Passkey 管理 API
用于注册、列表、删除 Passkey 凭证
"""
import logging
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from module.database import Database
from module.models import APIResponse
from module.models.passkey import (
PasskeyAuthFinish,
PasskeyAuthStart,
PasskeyCreate,
PasskeyDelete,
PasskeyList,
)
from module.security.api import active_user, get_current_user
from module.security.auth_strategy import PasskeyAuthStrategy
from module.security.jwt import create_access_token
from module.security.webauthn import get_webauthn_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/passkey", tags=["passkey"])
def _get_webauthn_from_request(request: Request):
"""
从请求中构造 WebAuthnService
根据 Host header 动态确定 RP ID 和 origin
"""
host = request.headers.get("host", "localhost:7892")
rp_id = host.split(":")[0] # 去掉端口
# 判断协议
forwarded_proto = request.headers.get("x-forwarded-proto")
if forwarded_proto:
scheme = forwarded_proto
else:
scheme = request.url.scheme
if scheme == "https":
origin = f"https://{host}"
else:
origin = f"http://{host}"
return get_webauthn_service(rp_id, "AutoBangumi", origin)
# ============ 注册流程 ============
@router.post("/register/options", response_model=dict)
async def get_registration_options(
request: Request,
username: str = Depends(get_current_user),
):
"""
生成 Passkey 注册选项
前端调用 navigator.credentials.create() 时使用
"""
webauthn = _get_webauthn_from_request(request)
async with Database() as db:
try:
user = await db.user.get_user(username)
existing_passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
options = webauthn.generate_registration_options(
username=username,
user_id=user.id,
existing_passkeys=existing_passkeys,
)
return options
except Exception as e:
logger.error(f"Failed to generate registration options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/register/verify", response_model=APIResponse)
async def verify_registration(
passkey_data: PasskeyCreate,
request: Request,
username: str = Depends(get_current_user),
):
"""
验证 Passkey 注册响应并保存
"""
webauthn = _get_webauthn_from_request(request)
async with Database() as db:
try:
user = await db.user.get_user(username)
# 验证 WebAuthn 响应
passkey = webauthn.verify_registration(
username=username,
credential=passkey_data.attestation_response,
device_name=passkey_data.name,
)
# 设置 user_id 并保存
passkey.user_id = user.id
await db.passkey.create_passkey(passkey)
return JSONResponse(
status_code=200,
content={
"msg_en": f"Passkey '{passkey_data.name}' registered successfully",
"msg_zh": f"Passkey '{passkey_data.name}' 注册成功",
},
)
except ValueError as e:
logger.warning(f"Registration verification failed for {username}: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to register passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============ 认证流程 ============
@router.post("/auth/options", response_model=dict)
async def get_passkey_login_options(
auth_data: PasskeyAuthStart,
request: Request,
):
"""
生成 Passkey 登录选项challenge
前端先调用此接口,再调用 navigator.credentials.get()
"""
webauthn = _get_webauthn_from_request(request)
async with Database() as db:
try:
user = await db.user.get_user(auth_data.username)
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
if not passkeys:
raise HTTPException(
status_code=400, detail="No passkeys registered for this user"
)
options = webauthn.generate_authentication_options(
auth_data.username, passkeys
)
return options
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate login options: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/auth/verify", response_model=dict)
async def login_with_passkey(
auth_data: PasskeyAuthFinish,
response: Response,
request: Request,
):
"""
使用 Passkey 登录(替代密码登录)
"""
webauthn = _get_webauthn_from_request(request)
strategy = PasskeyAuthStrategy(webauthn)
resp = await strategy.authenticate(auth_data.username, auth_data.credential)
if resp.status:
token = create_access_token(
data={"sub": auth_data.username}, expires_delta=timedelta(days=1)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
if auth_data.username not in active_user:
active_user.append(auth_data.username)
return {"access_token": token, "token_type": "bearer"}
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
# ============ Passkey 管理 ============
@router.get("/list", response_model=list[PasskeyList])
async def list_passkeys(username: str = Depends(get_current_user)):
"""获取用户的所有 Passkey"""
async with Database() as db:
try:
user = await db.user.get_user(username)
passkeys = await db.passkey.get_passkeys_by_user_id(user.id)
return [db.passkey.to_list_model(pk) for pk in passkeys]
except Exception as e:
logger.error(f"Failed to list passkeys: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete", response_model=APIResponse)
async def delete_passkey(
delete_data: PasskeyDelete,
username: str = Depends(get_current_user),
):
"""删除 Passkey"""
async with Database() as db:
try:
user = await db.user.get_user(username)
await db.passkey.delete_passkey(delete_data.passkey_id, user.id)
return JSONResponse(
status_code=200,
content={
"msg_en": "Passkey deleted successfully",
"msg_zh": "Passkey 删除成功",
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete passkey: {e}")
raise HTTPException(status_code=500, detail=str(e))