mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-14 02:20:53 +08:00
- 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>
230 lines
7.0 KiB
Python
230 lines
7.0 KiB
Python
"""
|
||
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))
|