Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

641 lines
22 KiB
Python
Raw Permalink 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 WebAuthn 辅助工具类。
"""
import base64
import binascii
import json
import secrets
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Protocol, Tuple
from urllib.parse import urlparse
from app.application.configuration import get_api_runtime_config_snapshot
from app.runtime.log import logger
# webauthn 约 14MB,只在 Passkey 注册与登录时才用到,改为方法内导入,避免随 API 路由加载常驻
if TYPE_CHECKING:
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
PASSKEY_CHALLENGE_TTL_SECONDS = 5 * 60
PasskeyChallengePurpose = Literal["authentication", "registration"]
@dataclass(frozen=True)
class PasskeyChallenge:
"""
服务端保存的一次性 Passkey 挑战值 及其认证边界。
:param challenge: WebAuthn 服务端随机挑战值
:param purpose: 用途:authentication 用于登录,registration 用于注册
:param user_id: 挑战绑定的用户主键;无预绑定用户的登录挑战为 None
"""
challenge: str
purpose: PasskeyChallengePurpose
user_id: Optional[int]
class PasskeyChallengeCache(Protocol):
"""
PassKey 一次性挑战值 使用的严格原子缓存端口。
"""
def store(self, key: str, value: Any) -> None:
"""
持久化挑战值,失败时抛出后端异常。
:param key: 不携带认证事实的随机事务令牌
:param value: 挑战值、用途和用户绑定组成的一次性认证状态
"""
def consume(self, key: str) -> Any:
"""
原子领取挑战值,不存在时返回 None。
:param key: 签发挑战时分配的随机事务令牌
:return: 已原子删除的挑战值;不存在或过期时返回 None
"""
class PasskeyChallengeStore:
"""
使用当前缓存后端签发并原子消费短时 Passkey 挑战值。
"""
_cache: Optional[PasskeyChallengeCache] = None
@classmethod
def _get_cache(cls) -> PasskeyChallengeCache:
"""
返回已装配缓存,缺失时拒绝签发认证状态。
"""
if cls._cache is None:
raise RuntimeError("PassKey challenge 缓存尚未配置")
return cls._cache
@classmethod
def issue(
cls,
*,
challenge: str,
purpose: PasskeyChallengePurpose,
user_id: Optional[int],
) -> str:
"""
保存挑战值 并返回不携带认证事实的随机事务令牌。
"""
transaction_token = secrets.token_urlsafe(32)
cls._get_cache().store(
transaction_token,
PasskeyChallenge(
challenge=challenge,
purpose=purpose,
user_id=user_id,
),
)
return transaction_token
@classmethod
def consume(
cls,
*,
transaction_token: str,
purpose: PasskeyChallengePurpose,
) -> Optional[PasskeyChallenge]:
"""
原子领取挑战值;任何完成尝试都会使事务失效。
"""
if not transaction_token:
return None
challenge = cls._get_cache().consume(transaction_token)
if not isinstance(challenge, PasskeyChallenge):
return None
if challenge.purpose != purpose:
return None
return challenge
def configure_passkey_challenge_cache(cache: PasskeyChallengeCache) -> None:
"""
由启动组合根注入 PassKey 挑战值 的原子缓存。
"""
PasskeyChallengeStore._cache = cache
class PassKeyRegistrationVerificationError(Exception):
"""
Passkey 注册响应未通过 WebAuthn 安全校验。
"""
class PassKeyRegistrationOriginMismatchError(PassKeyRegistrationVerificationError):
"""
浏览器来源与系统配置的 Passkey 注册来源不一致。
"""
class PassKeyHelper:
"""
PassKey WebAuthn 辅助类。
"""
@staticmethod
def get_rp_id() -> str:
"""
获取 WebAuthn 依赖方标识。
"""
config = get_api_runtime_config_snapshot()
if config.app_domain:
app_domain = config.app_domain.strip()
# 确保存在协议前缀,以便 urlparse 正确解析主机和端口
if not app_domain.startswith(('http://', 'https://')):
app_domain = f'https://{app_domain}'
parsed = urlparse(app_domain)
host = parsed.hostname
if host:
return host
host = config.app_domain.replace('https://', '').replace('http://', '')
if ':' in host:
host = host.split(':')[0]
return host
# 只有在未配置 APP_DOMAIN 时,才默认为 localhost
return 'localhost'
@staticmethod
def get_rp_name() -> str:
"""
获取 Relying Party 名称。
"""
return "MoviePilot"
@staticmethod
def get_origin() -> str:
"""
获取源地址。
"""
config = get_api_runtime_config_snapshot()
if config.app_domain:
return config.app_domain.rstrip('/')
return f'http://localhost:{config.nginx_port}'
@staticmethod
def standardize_credential_id(credential_id: str) -> str:
"""
标准化凭证ID(URL 安全的 Base64)。
"""
try:
decoded = base64.urlsafe_b64decode(credential_id + '==')
return base64.urlsafe_b64encode(decoded).decode('utf-8').rstrip('=')
except (binascii.Error, TypeError, ValueError) as e:
logger.error(f"标准化凭证ID失败: {e}")
return credential_id
@staticmethod
def _base64_encode_urlsafe(data: bytes) -> str:
"""
URL 安全的 Base64 编码(不带填充)。
:param data: 要编码的字节数据
:return: URL 安全的 Base64 编码的字符串
"""
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
@staticmethod
def _base64_decode_urlsafe(data: str) -> bytes:
"""
URL 安全的 Base64 解码(自动添加填充)。
:param data: URL 安全的 Base64 编码的字符串
:return: 解码后的字节数据
"""
return base64.urlsafe_b64decode(data + '==')
@staticmethod
def _parse_credential_list(credentials: List[Dict[str, Any]]) -> List[PublicKeyCredentialDescriptor]:
"""
解析凭证列表为 PublicKeyCredentialDescriptor 列表。
:param credentials: 凭证字典列表
:return: PublicKeyCredentialDescriptor 列表
"""
from webauthn.helpers.structs import AuthenticatorTransport, PublicKeyCredentialDescriptor
result = []
for cred in credentials:
try:
result.append(
PublicKeyCredentialDescriptor(
id=PassKeyHelper._base64_decode_urlsafe(cred['credential_id']),
transports=[
AuthenticatorTransport(t) for t in cred.get('transports', '').split(',') if t
] if cred.get('transports') else None
)
)
except Exception as e:
logger.warning(f"解析凭证失败: {e}")
continue
return result
@staticmethod
def _get_user_verification_requirement(user_verification: Optional[str] = None) -> UserVerificationRequirement:
"""
获取用户验证要求。
:param user_verification: 指定的用户验证要求,如果不指定则从配置中读取
:return: WebAuthn 用户验证要求,未指定时按配置选择必须验证或优先验证
"""
from webauthn.helpers.structs import UserVerificationRequirement
if user_verification:
return UserVerificationRequirement(user_verification)
return UserVerificationRequirement.REQUIRED if get_api_runtime_config_snapshot().passkey_require_uv \
else UserVerificationRequirement.PREFERRED
@staticmethod
def _get_verification_params(
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[str, str]:
"""
获取验证参数(origin 和 rp_id)。
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: 预期浏览器来源与依赖方标识组成的元组,缺失项从部署配置补齐
"""
origin = expected_origin or PassKeyHelper.get_origin()
rp_id = expected_rp_id or PassKeyHelper.get_rp_id()
return origin, rp_id
@staticmethod
def generate_registration_options(
user_id: int,
username: str,
display_name: Optional[str] = None,
existing_credentials: Optional[List[Dict[str, Any]]] = None
) -> Tuple[str, str]:
"""
生成注册选项。
:param user_id: 用户ID
:param username: 用户名
:param display_name: 显示名称
:param existing_credentials: 已存在的凭证列表
:return: 发给浏览器的选项 JSON 与服务端保存的挑战值组成的元组
"""
from webauthn import generate_registration_options, options_to_json
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import AuthenticatorSelectionCriteria, ResidentKeyRequirement
try:
user_id_bytes = str(user_id).encode('utf-8')
exclude_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \
if existing_credentials else None
uv_requirement = PassKeyHelper._get_user_verification_requirement()
options = generate_registration_options(
rp_id=PassKeyHelper.get_rp_id(),
rp_name=PassKeyHelper.get_rp_name(),
user_id=user_id_bytes,
user_name=username,
user_display_name=display_name or username,
exclude_credentials=exclude_credentials,
authenticator_selection=AuthenticatorSelectionCriteria(
authenticator_attachment=None,
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=uv_requirement,
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
]
)
options_json = options_to_json(options)
challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)
return options_json, challenge
except Exception as e:
logger.error(f"生成注册选项失败: {e}")
raise
@staticmethod
def verify_registration_response(
credential: Dict[str, Any],
expected_challenge: str,
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[str, str, int, Optional[str]]:
"""
验证注册响应。
:param credential: 客户端返回的凭证
:param expected_challenge: 期望的challenge
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: 凭证标识、公钥、初始签名计数与认证器型号标识组成的元组
"""
from webauthn import verify_registration_response
from webauthn.helpers import parse_registration_credential_json
from webauthn.helpers.exceptions import InvalidRegistrationResponse
try:
origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)
challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)
registration_credential = parse_registration_credential_json(json.dumps(credential))
verification = verify_registration_response(
credential=registration_credential,
expected_challenge=challenge_bytes,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=get_api_runtime_config_snapshot().passkey_require_uv
)
credential_id = PassKeyHelper._base64_encode_urlsafe(verification.credential_id)
public_key = PassKeyHelper._base64_encode_urlsafe(verification.credential_public_key)
sign_count = verification.sign_count
# aaguid 可能已经是字符串格式,也可能是bytes
if verification.aaguid:
if isinstance(verification.aaguid, bytes):
aaguid = verification.aaguid.hex()
else:
aaguid = str(verification.aaguid)
else:
aaguid = None
return credential_id, public_key, sign_count, aaguid
except InvalidRegistrationResponse as e:
logger.error(f"验证注册响应失败: {e}")
if str(e).startswith("Unexpected client data origin "):
raise PassKeyRegistrationOriginMismatchError() from e
raise PassKeyRegistrationVerificationError() from e
except Exception as e:
logger.error(f"验证注册响应失败: {e}")
raise
@staticmethod
def generate_authentication_options(
existing_credentials: Optional[List[Dict[str, Any]]] = None,
user_verification: Optional[str] = None
) -> Tuple[str, str]:
"""
生成认证选项。
:param existing_credentials: 已存在的凭证列表(用于限制可用凭证)
:param user_verification: 用户验证要求,如果不指定则从配置中读取
:return: 发给浏览器的选项 JSON 与服务端保存的挑战值组成的元组
"""
from webauthn import generate_authentication_options, options_to_json
try:
allow_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \
if existing_credentials else None
uv_requirement = PassKeyHelper._get_user_verification_requirement(user_verification)
options = generate_authentication_options(
rp_id=PassKeyHelper.get_rp_id(),
allow_credentials=allow_credentials,
user_verification=uv_requirement
)
options_json = options_to_json(options)
challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)
return options_json, challenge
except Exception as e:
logger.error(f"生成认证选项失败: {e}")
raise
@staticmethod
def verify_authentication_response(
credential: Dict[str, Any],
expected_challenge: str,
credential_public_key: str,
credential_current_sign_count: int,
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[bool, int]:
"""
验证认证响应。
:param credential: 客户端返回的凭证
:param expected_challenge: 期望的challenge
:param credential_public_key: 凭证公钥
:param credential_current_sign_count: 当前签名计数
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: (验证成功, 新的签名计数)
"""
from webauthn import verify_authentication_response
from webauthn.helpers import parse_authentication_credential_json
try:
origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)
challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)
public_key_bytes = PassKeyHelper._base64_decode_urlsafe(credential_public_key)
authentication_credential = parse_authentication_credential_json(json.dumps(credential))
verification = verify_authentication_response(
credential=authentication_credential,
expected_challenge=challenge_bytes,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=public_key_bytes,
credential_current_sign_count=credential_current_sign_count,
require_user_verification=get_api_runtime_config_snapshot().passkey_require_uv
)
return True, verification.new_sign_count
except Exception as e:
logger.error(f"验证认证响应失败: {e}")
return False, credential_current_sign_count
class PasskeyRepository(Protocol):
"""
PassKey 用例需要的最小同步数据端口。
"""
def list(self) -> list[Any]:
"""
列出全部启用凭证。
:return: 全部启用凭证;没有凭证时返回空列表
"""
def list_by_user_id(
self,
user_id: int,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[Any]:
"""
按可选窗口列出指定用户凭证。
:param user_id: 目标用户的数据库主键
:param page: 页码,从 1 开始;与 count 均省略时不分页
:param count: 每页记录数量;与 page 均省略时不分页
:return: 该用户窗口内的启用凭证;没有凭证时返回空列表
"""
def count_by_user_id(self, user_id: int) -> int:
"""
返回指定用户启用凭证总数。
:param user_id: 目标用户的数据库主键
:return: 该用户的启用凭证数量
"""
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
"""
按凭证 ID 查找凭证。
:param credential_id: WebAuthn 凭证标识
:return: 对应凭证;不存在或未启用时返回 None
"""
def create(self, payload: dict[str, Any]) -> Any:
"""
创建凭证。
:param payload: 凭证所属用户、凭证标识、公钥和签名计数等持久化字段
:return: 创建后的凭证记录
"""
def compare_and_update_sign_count(
self,
passkey_id: int,
expected_sign_count: int,
sign_count: int,
) -> bool:
"""
仅在签名计数未被并发修改时记录本次认证。
:param passkey_id: 本地 Passkey 记录主键
:param expected_sign_count: 认证前读取的签名计数,用于检测并发更新
:param sign_count: 本次认证返回的新签名计数
:return: 期望计数仍匹配且更新成功时返回 True;并发冲突或记录不存在时返回 False
"""
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""
删除用户凭证。
:param passkey_id: 本地 Passkey 记录主键
:param user_id: 目标用户的数据库主键
:return: 成功删除属于该用户的凭证时返回 True,否则返回 False
"""
class PasskeyService:
"""
编排 PassKey 凭证生命周期。
"""
def __init__(self, repository: PasskeyRepository) -> None:
"""
注入 PassKey 数据端口。
"""
self._repository = repository
def list(self) -> list[Any]:
"""
列出全部启用凭证。
"""
return self._repository.list()
def list_by_user_id(
self,
user_id: int,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[Any]:
"""
按可选数据库窗口列出指定用户凭证。
"""
if page is None and count is None:
return self._repository.list_by_user_id(user_id)
return self._repository.list_by_user_id(user_id, page=page, count=count)
def count_by_user_id(self, user_id: int) -> int:
"""
返回指定用户启用凭证精确总数。
"""
return self._repository.count_by_user_id(user_id)
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
"""
按凭证 ID 查找凭证。
"""
return self._repository.get_by_credential_id(credential_id)
def create(self, payload: dict[str, Any]) -> Any:
"""
创建凭证。
"""
return self._repository.create(payload)
def compare_and_update_sign_count(
self,
passkey_id: int,
expected_sign_count: int,
sign_count: int,
) -> bool:
"""
以验证时观察到的旧计数提交本次认证。
"""
return self._repository.compare_and_update_sign_count(
passkey_id=passkey_id,
expected_sign_count=expected_sign_count,
sign_count=sign_count,
)
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""
删除用户凭证。
"""
return self._repository.delete_by_id(passkey_id, user_id)
_configured_passkey_service: Optional[PasskeyService] = None
def configure_passkey_service(service: PasskeyService) -> None:
"""
由启动组合根登记 PassKey 应用服务。
"""
global _configured_passkey_service
_configured_passkey_service = service
def reset_passkey_challenge_cache() -> None:
"""
清除当前应用生命周期 的 PassKey 挑战值 缓存。
"""
PasskeyChallengeStore._cache = None
def reset_passkey_service() -> None:
"""
清除当前应用生命周期 的 PassKey 应用服务。
"""
global _configured_passkey_service
_configured_passkey_service = None
def get_configured_passkey_service() -> PasskeyService:
"""
返回启动阶段登记的 PassKey 应用服务。
"""
if _configured_passkey_service is None:
raise RuntimeError("PassKey 服务尚未配置")
return _configured_passkey_service