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:
68
service/redis/challenge_store.py
Normal file
68
service/redis/challenge_store.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
WebAuthn Challenge 一次性存储
|
||||
|
||||
支持 Redis(首选,使用 GETDEL 原子操作)和内存 TTLCache(降级)。
|
||||
Challenge 存储后 5 分钟过期,取出即删除(防重放)。
|
||||
"""
|
||||
from typing import ClassVar
|
||||
|
||||
from cachetools import TTLCache
|
||||
from loguru import logger as l
|
||||
|
||||
from . import RedisManager
|
||||
|
||||
# Challenge 过期时间(秒)
|
||||
_CHALLENGE_TTL: int = 300
|
||||
|
||||
|
||||
class ChallengeStore:
|
||||
"""
|
||||
WebAuthn Challenge 一次性存储管理器
|
||||
|
||||
根据 Redis 可用性自动选择存储后端:
|
||||
- Redis 可用:使用 Redis GETDEL 原子操作
|
||||
- Redis 不可用:使用内存 TTLCache(仅单实例)
|
||||
|
||||
Key 约定:
|
||||
- 注册: ``reg:{user_id}``
|
||||
- 登录: ``auth:{challenge_token}``
|
||||
"""
|
||||
|
||||
_memory_cache: ClassVar[TTLCache[str, bytes]] = TTLCache(
|
||||
maxsize=10000,
|
||||
ttl=_CHALLENGE_TTL,
|
||||
)
|
||||
"""内存缓存降级方案"""
|
||||
|
||||
@classmethod
|
||||
async def store(cls, key: str, challenge: bytes) -> None:
|
||||
"""
|
||||
存储 challenge,TTL 5 分钟。
|
||||
|
||||
:param key: 存储键(如 ``reg:{user_id}`` 或 ``auth:{token}``)
|
||||
:param challenge: challenge 字节数据
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
|
||||
if client is not None:
|
||||
redis_key = f"webauthn_challenge:{key}"
|
||||
await client.set(redis_key, challenge, ex=_CHALLENGE_TTL)
|
||||
else:
|
||||
cls._memory_cache[key] = challenge
|
||||
|
||||
@classmethod
|
||||
async def retrieve_and_delete(cls, key: str) -> bytes | None:
|
||||
"""
|
||||
一次性取出并删除 challenge(防重放)。
|
||||
|
||||
:param key: 存储键
|
||||
:return: challenge 字节数据,过期或不存在时返回 None
|
||||
"""
|
||||
client = RedisManager.get_client()
|
||||
|
||||
if client is not None:
|
||||
redis_key = f"webauthn_challenge:{key}"
|
||||
result: bytes | None = await client.getdel(redis_key)
|
||||
return result
|
||||
else:
|
||||
return cls._memory_cache.pop(key, None)
|
||||
@@ -276,52 +276,55 @@ async def _login_passkey(
|
||||
request: UnifiedLoginRequest,
|
||||
) -> User:
|
||||
"""
|
||||
Passkey/WebAuthn 登录
|
||||
Passkey/WebAuthn 登录(Discoverable Credentials 模式)
|
||||
|
||||
identifier 为 credential_id,credential 为 JSON 格式的 authenticator assertion response。
|
||||
identifier 为 challenge_token(前端从 ``POST /authn/options`` 获取),
|
||||
credential 为 JSON 格式的 authenticator assertion response。
|
||||
"""
|
||||
from webauthn import verify_authentication_response
|
||||
from webauthn.helpers.structs import AuthenticationCredential
|
||||
from webauthn.helpers import base64url_to_bytes
|
||||
|
||||
from service.redis.challenge_store import ChallengeStore
|
||||
from service.webauthn import get_rp_config
|
||||
from sqlmodels.user_authn import UserAuthn
|
||||
|
||||
if not request.credential:
|
||||
http_exceptions.raise_bad_request("WebAuthn assertion response 不能为空")
|
||||
|
||||
# 查找 AuthIdentity
|
||||
identity: AuthIdentity | None = await AuthIdentity.get(
|
||||
session,
|
||||
(AuthIdentity.provider == AuthProviderType.PASSKEY)
|
||||
& (AuthIdentity.identifier == request.identifier),
|
||||
)
|
||||
if not identity:
|
||||
http_exceptions.raise_unauthorized("Passkey 凭证未注册")
|
||||
if not request.identifier:
|
||||
http_exceptions.raise_bad_request("challenge_token 不能为空")
|
||||
|
||||
# 加载对应的 UserAuthn 记录
|
||||
from sqlmodels.user_authn import UserAuthn
|
||||
# 从 ChallengeStore 取出 challenge(一次性,防重放)
|
||||
challenge: bytes | None = await ChallengeStore.retrieve_and_delete(f"auth:{request.identifier}")
|
||||
if challenge is None:
|
||||
http_exceptions.raise_unauthorized("登录会话已过期,请重新获取 options")
|
||||
|
||||
# 从 assertion JSON 中解析 credential_id(Discoverable Credentials 模式)
|
||||
import orjson
|
||||
credential_dict: dict = orjson.loads(request.credential)
|
||||
credential_id_b64: str | None = credential_dict.get("id")
|
||||
if not credential_id_b64:
|
||||
http_exceptions.raise_bad_request("缺少凭证 ID")
|
||||
|
||||
# 查找 UserAuthn 记录
|
||||
authn: UserAuthn | None = await UserAuthn.get(
|
||||
session,
|
||||
UserAuthn.credential_id == request.identifier,
|
||||
UserAuthn.credential_id == credential_id_b64,
|
||||
)
|
||||
if not authn:
|
||||
http_exceptions.raise_unauthorized("Passkey 凭证数据不存在")
|
||||
http_exceptions.raise_unauthorized("Passkey 凭证未注册")
|
||||
|
||||
# 获取 RP ID
|
||||
site_url_setting = await Setting.get(
|
||||
session,
|
||||
(Setting.type == SettingsType.BASIC) & (Setting.name == "siteURL"),
|
||||
)
|
||||
rp_id = site_url_setting.value if site_url_setting else "localhost"
|
||||
# 获取 RP 配置
|
||||
rp_id, _rp_name, origin = await get_rp_config(session)
|
||||
|
||||
# 验证 WebAuthn assertion
|
||||
import orjson
|
||||
credential = AuthenticationCredential.model_validate(orjson.loads(request.credential))
|
||||
|
||||
try:
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
credential=request.credential,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=f"https://{rp_id}",
|
||||
expected_challenge=b"", # TODO: 从 session/cache 中获取 challenge
|
||||
credential_public_key=bytes.fromhex(authn.credential_public_key),
|
||||
expected_origin=origin,
|
||||
expected_challenge=challenge,
|
||||
credential_public_key=base64url_to_bytes(authn.credential_public_key),
|
||||
credential_current_sign_count=authn.sign_count,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -333,7 +336,7 @@ async def _login_passkey(
|
||||
await authn.save(session)
|
||||
|
||||
# 加载用户
|
||||
user: User = await User.get(session, User.id == identity.user_id, load=User.group)
|
||||
user: User = await User.get(session, User.id == authn.user_id, load=User.group)
|
||||
if not user:
|
||||
http_exceptions.raise_unauthorized("用户不存在")
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
|
||||
41
service/webauthn.py
Normal file
41
service/webauthn.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
WebAuthn RP(Relying Party)配置辅助模块
|
||||
|
||||
从数据库 Setting 中读取 siteURL / siteTitle,
|
||||
解析出 rp_id、rp_name、origin,供注册/登录流程复用。
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from sqlmodels.setting import Setting, SettingsType
|
||||
|
||||
|
||||
async def get_rp_config(session: AsyncSession) -> tuple[str, str, str]:
|
||||
"""
|
||||
获取 WebAuthn RP 配置。
|
||||
|
||||
:param session: 数据库会话
|
||||
:return: ``(rp_id, rp_name, origin)`` 元组
|
||||
|
||||
- ``rp_id``: 站点域名(从 siteURL 解析,如 ``example.com``)
|
||||
- ``rp_name``: 站点标题
|
||||
- ``origin``: 完整 origin(如 ``https://example.com``)
|
||||
"""
|
||||
site_url_setting: Setting | None = await Setting.get(
|
||||
session,
|
||||
(Setting.type == SettingsType.BASIC) & (Setting.name == "siteURL"),
|
||||
)
|
||||
site_title_setting: Setting | None = await Setting.get(
|
||||
session,
|
||||
(Setting.type == SettingsType.BASIC) & (Setting.name == "siteTitle"),
|
||||
)
|
||||
|
||||
site_url: str = site_url_setting.value if site_url_setting and site_url_setting.value else "https://localhost"
|
||||
rp_name: str = site_title_setting.value if site_title_setting and site_title_setting.value else "DiskNext"
|
||||
|
||||
parsed = urlparse(site_url)
|
||||
rp_id: str = parsed.hostname or "localhost"
|
||||
origin: str = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else site_url
|
||||
|
||||
return rp_id, rp_name, origin
|
||||
Reference in New Issue
Block a user