feat: implement WebAuthn credential registration, login verification, and management

Complete the WebAuthn/Passkey flow that was previously stubbed out:
- Add ChallengeStore (Redis + TTLCache fallback) for challenge lifecycle
- Add RP config helper to extract rp_id/origin from site settings
- Fix registration start (exclude_credentials, user_id, challenge storage)
- Implement registration finish (verify + create UserAuthn & AuthIdentity)
- Add authentication options endpoint for Discoverable Credentials login
- Fix passkey login to use challenge_token and base64url encoding
- Add credential management endpoints (list/rename/delete)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 12:56:46 +08:00
parent 729773cae3
commit 800c85bf8d
8 changed files with 451 additions and 59 deletions

View File

@@ -1,20 +1,30 @@
from typing import Annotated, Literal
from uuid import UUID, uuid4
import json
import jwt
from fastapi import APIRouter, Depends, HTTPException
from itsdangerous import URLSafeTimedSerializer
from loguru import logger
from webauthn import generate_registration_options
from webauthn.helpers import options_to_json_dict
from webauthn import (
generate_authentication_options,
generate_registration_options,
verify_registration_response,
)
from webauthn.helpers import bytes_to_base64url, options_to_json
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
import service
import sqlmodels
from middleware.auth import auth_required
from middleware.dependencies import SessionDep, require_captcha
from service.captcha import CaptchaScene
from service.redis.challenge_store import ChallengeStore
from service.webauthn import get_rp_config
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import UserStatus
from sqlmodels.user_authn import UserAuthn
from utils import JWT, Password, http_exceptions
from .settings import user_settings_router
@@ -445,7 +455,7 @@ async def router_user_storage(
async def router_user_authn_start(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> sqlmodels.ResponseBase:
) -> dict:
"""
Passkey 注册初始化(需要登录)
@@ -456,43 +466,159 @@ async def router_user_authn_start(
"""
authn_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == "authn") & (sqlmodels.Setting.name == "authn_enabled")
(sqlmodels.Setting.type == sqlmodels.SettingsType.AUTHN)
& (sqlmodels.Setting.name == "authn_enabled"),
)
if not authn_setting or authn_setting.value != "1":
raise HTTPException(status_code=400, detail="Passkey 未启用")
http_exceptions.raise_bad_request("Passkey 未启用")
site_url_setting = await sqlmodels.Setting.get(
rp_id, rp_name, _origin = await get_rp_config(session)
# 查询用户已注册凭证,用于 exclude_credentials
existing_authns: list[UserAuthn] = await UserAuthn.get(
session,
(sqlmodels.Setting.type == "basic") & (sqlmodels.Setting.name == "siteURL")
)
site_title_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == "basic") & (sqlmodels.Setting.name == "siteTitle")
UserAuthn.user_id == user.id,
fetch_mode="all",
)
exclude_credentials: list[PublicKeyCredentialDescriptor] = [
PublicKeyCredentialDescriptor(
id=authn.credential_id,
transports=authn.transports.split(",") if authn.transports else [],
)
for authn in existing_authns
]
options = generate_registration_options(
rp_id=site_url_setting.value if site_url_setting else "",
rp_name=site_title_setting.value if site_title_setting else "",
rp_id=rp_id,
rp_name=rp_name,
user_id=user.id.bytes,
user_name=user.email or str(user.id),
user_display_name=user.nickname or user.email or str(user.id),
exclude_credentials=exclude_credentials if exclude_credentials else None,
)
return sqlmodels.ResponseBase(data=options_to_json_dict(options))
# 存储 challenge
await ChallengeStore.store(f"reg:{user.id}", options.challenge)
return json.loads(options_to_json(options))
@user_router.put(
path='/authn/finish',
summary='注册 Passkey 凭证(完成)',
description='Finish Passkey registration for a user.',
dependencies=[Depends(auth_required)],
status_code=201,
)
def router_user_authn_finish() -> sqlmodels.ResponseBase:
async def router_user_authn_finish(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
request: sqlmodels.AuthnFinishRequest,
) -> sqlmodels.AuthnDetailResponse:
"""
Passkey 注册完成(需要登录)
接收前端 navigator.credentials.create() 返回的凭证数据,
创建 UserAuthn 行 + AuthIdentity(provider=passkey)。
验证后创建 UserAuthn 行 + AuthIdentity(provider=passkey)。
Returns:
dict: A dictionary containing Passkey registration information.
请求体:
- credential: navigator.credentials.create() 返回的 JSON 字符串
- name: 凭证名称(可选)
错误处理:
- 400: challenge 已过期或无效 / 验证失败
"""
http_exceptions.raise_not_implemented()
# 取出 challenge一次性
challenge: bytes | None = await ChallengeStore.retrieve_and_delete(f"reg:{user.id}")
if challenge is None:
http_exceptions.raise_bad_request("注册会话已过期,请重新开始")
rp_id, _rp_name, origin = await get_rp_config(session)
# 验证注册响应
try:
verification = verify_registration_response(
credential=request.credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
)
except Exception as e:
logger.warning(f"WebAuthn 注册验证失败: {e}")
http_exceptions.raise_bad_request("Passkey 验证失败")
# 编码为 base64url 存储
credential_id_b64: str = bytes_to_base64url(verification.credential_id)
credential_public_key_b64: str = bytes_to_base64url(verification.credential_public_key)
# 提取 transports
credential_dict: dict = json.loads(request.credential)
response_dict: dict = credential_dict.get("response", {})
transports_list: list[str] = response_dict.get("transports", [])
transports_str: str | None = ",".join(transports_list) if transports_list else None
# 创建 UserAuthn 记录
authn = UserAuthn(
credential_id=credential_id_b64,
credential_public_key=credential_public_key_b64,
sign_count=verification.sign_count,
credential_device_type=verification.credential_device_type,
credential_backed_up=verification.credential_backed_up,
transports=transports_str,
name=request.name,
user_id=user.id,
)
authn = await authn.save(session)
# 创建 AuthIdentityprovider=passkeyidentifier=credential_id_b64
identity = AuthIdentity(
provider=AuthProviderType.PASSKEY,
identifier=credential_id_b64,
is_primary=False,
is_verified=True,
user_id=user.id,
)
await identity.save(session)
return authn.to_detail_response()
@user_router.post(
path='/authn/options',
summary='获取 Passkey 登录 options无需登录',
description='Generate authentication options for Passkey login.',
)
async def router_user_authn_options(
session: SessionDep,
) -> dict:
"""
获取 Passkey 登录的 authentication options无需登录
前端调用此端点获取 options 后使用 navigator.credentials.get() 处理。
使用 Discoverable Credentials 模式(空 allow_credentials
由浏览器/平台决定展示哪些凭证。
返回值包含 ``challenge_token`` 字段,前端在登录请求中作为 ``identifier`` 传入。
错误处理:
- 400: Passkey 未启用
"""
authn_setting = await sqlmodels.Setting.get(
session,
(sqlmodels.Setting.type == sqlmodels.SettingsType.AUTHN)
& (sqlmodels.Setting.name == "authn_enabled"),
)
if not authn_setting or authn_setting.value != "1":
http_exceptions.raise_bad_request("Passkey 未启用")
rp_id, _rp_name, _origin = await get_rp_config(session)
options = generate_authentication_options(rp_id=rp_id)
# 生成 challenge_token 用于关联 challenge
challenge_token: str = str(uuid4())
await ChallengeStore.store(f"auth:{challenge_token}", options.challenge)
result: dict = json.loads(options_to_json(options))
result["challenge_token"] = challenge_token
return result