""" 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