""" MFA (Multi-Factor Authentication) API 端点 包含 OTP 和 PassKey 相关功能 """ import json from typing import Any, Annotated, Optional from fastapi import Depends, HTTPException, Body, Request, Response from app.schemas.mcp import BaseModel as _SchemaBaseModel from app.schemas.mcp import JsonData as _SchemaJsonData from app.schemas.mfa import MfaStatusData as _SchemaMfaStatusData from app.schemas.mfa import OtpGenerateData as _SchemaOtpGenerateData from app.schemas.mfa import PasskeyInfo as _SchemaPasskeyInfo from app.schemas.mfa import PasskeyStartData as _SchemaPasskeyStartData from app.schemas.response import Response as _SchemaResponse from app.schemas.token import Token as _SchemaToken from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.adapters.web.security.access import set_or_refresh_resource_token_cookie from app.application.security.token import verify_password from app.application.security.auth import get_configured_auth_service from app.application.security.user import UserService from app.application.security.user import ( get_configured_user_id_lookup, get_configured_user_name_lookup, ) from app.application.security.passkeys import ( PasskeyService, ) from app.api.principal import ApiPrincipal from app.api.dependencies.auth import ( get_current_active_user, get_current_active_user_async, get_user_service, get_passkey_service, ) from app.application.security.passkey import ( PassKeyHelper, PassKeyRegistrationOriginMismatchError, PassKeyRegistrationVerificationError, PasskeyChallengeStore, ) from app.runtime.log import logger from app.application.security.otp import OtpUtils router = ResponseAPIRouter() # ==================== 辅助函数 ==================== def _build_credential_list(passkeys: list[Any]) -> list[dict[str, Any]]: """ 构建凭证列表 :param passkeys: PassKey 列表 :return: 凭证字典列表 """ return ( [ {"credential_id": pk.credential_id, "transports": pk.transports} for pk in passkeys ] if passkeys else [] ) def _extract_and_standardize_credential_id(credential: dict) -> str: """ 从凭证中提取并标准化 credential_id :param credential: 凭证字典 :return: 标准化后的 credential_id :raises ValueError: 如果凭证无效 """ credential_id_raw = credential.get("id") or credential.get("rawId") if not credential_id_raw: raise ValueError("无效的凭证") return PassKeyHelper.standardize_credential_id(credential_id_raw) def _verify_passkey_and_update( credential: dict, challenge: str, passkey: Any, service: PasskeyService, ) -> tuple[bool, int]: """ 验证 PassKey 并更新使用时间和签名计数 :param credential: 凭证字典 :param challenge: 挑战值 :param passkey: PassKey 对象 :return: (验证是否成功, 新的签名计数) """ success, new_sign_count = PassKeyHelper.verify_authentication_response( credential=credential, expected_challenge=challenge, credential_public_key=passkey.public_key, credential_current_sign_count=passkey.sign_count, ) if success: service.update_last_used(passkey, new_sign_count) return success, new_sign_count # ==================== 请求模型 ==================== class OtpVerifyRequest(_SchemaBaseModel): """OTP验证请求""" uri: str otpPassword: str class OtpDisableRequest(_SchemaBaseModel): """OTP禁用请求""" password: str class PassKeyDeleteRequest(_SchemaBaseModel): """PassKey删除请求""" passkey_id: int password: str # ==================== 通用 MFA 接口 ==================== @router.get( "/status/{username}", summary="判断用户是否开启二次验证", response_model=_SchemaResponse[_SchemaMfaStatusData], ) async def mfa_status( username: str, service: UserService = Depends(get_user_service), ) -> Any: """ 检查指定用户是否启用了二次验证 """ user = await service.get_by_name(username) if not user: return _SchemaResponse(success=False, message="用户不存在") # 检查是否启用了OTP has_otp = user.is_otp return _SchemaResponse(success=True, data={"enabled": bool(has_otp)}) # ==================== OTP 相关接口 ==================== @router.post( "/otp/generate", summary="生成 OTP 验证 URI", response_model=_SchemaResponse[_SchemaOtpGenerateData], ) def otp_generate( current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], ) -> Any: """生成 OTP 密钥及对应的 URI""" secret, uri = OtpUtils.generate_secret_key(current_user.name) return _SchemaResponse(success=secret != "", data={"secret": secret, "uri": uri}) @router.post("/otp/verify", summary="绑定并验证 OTP", response_model=_SchemaResponse[None]) async def otp_verify( data: OtpVerifyRequest, service: UserService = Depends(get_user_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证""" if not OtpUtils.is_legal(data.uri, data.otpPassword): return _SchemaResponse(success=False, message="验证码错误") await service.update_otp( current_user.name, True, OtpUtils.get_secret(data.uri), ) return _SchemaResponse(success=True) @router.post( "/otp/disable", summary="关闭当前用户的 OTP 验证", response_model=_SchemaResponse[None], ) async def otp_disable( data: OtpDisableRequest, service: UserService = Depends(get_user_service), current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """关闭当前用户的 OTP 验证功能""" # 验证密码 if not verify_password(data.password, str(current_user.hashed_password)): return _SchemaResponse(success=False, message="密码错误") await service.update_otp(current_user.name, False, "") return _SchemaResponse(success=True) # ==================== PassKey 相关接口 ==================== class PassKeyRegistrationStart(_SchemaBaseModel): """PassKey注册开始请求""" name: str = "通行密钥" class PassKeyRegistrationFinish(_SchemaBaseModel): """PassKey注册完成请求""" credential: dict[str, _SchemaJsonData] transaction_token: str name: str = "通行密钥" class PassKeyAuthenticationStart(_SchemaBaseModel): """PassKey认证开始请求""" username: Optional[str] = None class PassKeyAuthenticationFinish(_SchemaBaseModel): """PassKey认证完成请求""" credential: dict[str, _SchemaJsonData] transaction_token: str @router.post( "/passkey/register/start", summary="开始注册 PassKey", response_model=_SchemaResponse[_SchemaPasskeyStartData], ) def passkey_register_start( current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], service: PasskeyService = Depends(get_passkey_service), ) -> Any: """开始注册 PassKey - 生成注册选项""" try: # 获取用户已有的PassKey existing_passkeys = service.list_by_user_id(current_user.id) existing_credentials = ( _build_credential_list(existing_passkeys) if existing_passkeys else None ) # 生成注册选项 options_json, challenge = PassKeyHelper.generate_registration_options( user_id=current_user.id, username=current_user.name, display_name=current_user.settings.get("nickname") if current_user.settings else None, existing_credentials=existing_credentials, ) transaction_token = PasskeyChallengeStore.issue( challenge=challenge, purpose="registration", user_id=current_user.id, ) return _SchemaResponse( success=True, data={ "options": json.loads(options_json), "transaction_token": transaction_token, }, ) except Exception as e: logger.error(f"生成PassKey注册选项失败: {e}") return _SchemaResponse(success=False, message=f"生成注册选项失败: {str(e)}") @router.post( "/passkey/register/finish", summary="完成注册 PassKey", response_model=_SchemaResponse[None], ) def passkey_register_finish( passkey_req: PassKeyRegistrationFinish, current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], service: PasskeyService = Depends(get_passkey_service), ) -> Any: """完成注册 PassKey - 验证并保存凭证""" try: challenge_state = PasskeyChallengeStore.consume( transaction_token=passkey_req.transaction_token, purpose="registration", ) if not challenge_state or challenge_state.user_id != current_user.id: return _SchemaResponse( success=False, message="注册请求已失效,请重新发起注册", ) # 验证注册响应 credential_id, public_key, sign_count, aaguid = ( PassKeyHelper.verify_registration_response( credential=passkey_req.credential, expected_challenge=challenge_state.challenge, ) ) # 提取transports transports = None if ( "response" in passkey_req.credential and "transports" in passkey_req.credential["response"] ): transports = ",".join(passkey_req.credential["response"]["transports"]) # 保存到数据库 service.create({ "user_id": current_user.id, "credential_id": credential_id, "public_key": public_key, "sign_count": sign_count, "name": passkey_req.name or "通行密钥", "aaguid": aaguid, "transports": transports, }) logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}") return _SchemaResponse(success=True, message="通行密钥注册成功") except PassKeyRegistrationOriginMismatchError: return _SchemaResponse( success=False, message="访问域名与系统配置不一致,请使用配置的域名重试", ) except PassKeyRegistrationVerificationError: return _SchemaResponse( success=False, message="通行密钥注册验证失败,请重新发起注册后重试", ) except Exception as e: logger.error(f"注册PassKey失败: {e}") return _SchemaResponse(success=False, message="通行密钥注册失败,请稍后重试") @router.post( "/passkey/authenticate/start", summary="开始 PassKey 认证", response_model=_SchemaResponse[_SchemaPasskeyStartData], ) def passkey_authenticate_start( passkey_req: PassKeyAuthenticationStart = Body(...), service: PasskeyService = Depends(get_passkey_service), ) -> Any: """开始 PassKey 认证 - 生成认证选项""" try: existing_credentials = None user_id = None # 如果指定了用户名,只允许该用户的PassKey if passkey_req.username: user = get_configured_user_name_lookup()(passkey_req.username) existing_passkeys = ( service.list_by_user_id(user.id) if user else None ) if not user or not existing_passkeys: return _SchemaResponse(success=False, message="认证失败") existing_credentials = _build_credential_list(existing_passkeys) user_id = user.id # 生成认证选项 options_json, challenge = PassKeyHelper.generate_authentication_options( existing_credentials=existing_credentials ) transaction_token = PasskeyChallengeStore.issue( challenge=challenge, purpose="authentication", user_id=user_id, ) return _SchemaResponse( success=True, data={ "options": json.loads(options_json), "transaction_token": transaction_token, }, ) except Exception as e: logger.error(f"生成PassKey认证选项失败: {e}") return _SchemaResponse(success=False, message="认证失败") @router.post( "/passkey/authenticate/finish", summary="完成 PassKey 认证", response_model=_SchemaToken, openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True}, ) def passkey_authenticate_finish( request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish, service: PasskeyService = Depends(get_passkey_service), ) -> Any: """完成 PassKey 认证 - 验证凭证并返回 token""" try: challenge_state = PasskeyChallengeStore.consume( transaction_token=passkey_req.transaction_token, purpose="authentication", ) if not challenge_state: raise HTTPException(status_code=401, detail="认证请求已失效") # 提取并标准化凭证ID try: credential_id = _extract_and_standardize_credential_id( passkey_req.credential ) except ValueError as e: logger.warning(f"PassKey认证失败,提供的凭证无效: {e}") raise HTTPException(status_code=401, detail="认证失败") # 查找PassKey并获取用户 passkey = service.get_by_credential_id(credential_id) user = get_configured_user_id_lookup()(passkey.user_id) if passkey else None if not passkey or not user or not user.is_active: raise HTTPException(status_code=401, detail="认证失败") if challenge_state.user_id is not None and challenge_state.user_id != user.id: raise HTTPException(status_code=401, detail="认证失败") # 验证认证响应并更新 success, _ = _verify_passkey_and_update( credential=passkey_req.credential, challenge=challenge_state.challenge, passkey=passkey, service=service, ) if not success: raise HTTPException(status_code=401, detail="认证失败") logger.info(f"用户 {user.name} 通过PassKey认证成功") # 生成token token = get_configured_auth_service().build_token_response(user) set_or_refresh_resource_token_cookie( request, response, _SchemaTokenPayload( sub=user.id, username=user.name, super_user=user.is_superuser, level=token.level, purpose="authentication", ), ) return token except HTTPException: raise except Exception as e: logger.error(f"PassKey认证失败: {e}") raise HTTPException(status_code=401, detail="认证失败") @router.get( "/passkey/list", summary="获取当前用户的 PassKey 列表", response_model=_SchemaResponse[list[_SchemaPasskeyInfo]], ) def passkey_list( current_user: Annotated[ApiPrincipal, Depends(get_current_active_user)], service: PasskeyService = Depends(get_passkey_service), ) -> Any: """获取当前用户的所有 PassKey""" try: passkeys = service.list_by_user_id(current_user.id) key_list = ( [ { "id": pk.id, "name": pk.name, "created_at": pk.created_at.isoformat() if pk.created_at else None, "last_used_at": pk.last_used_at.isoformat() if pk.last_used_at else None, "aaguid": pk.aaguid, "transports": pk.transports, } for pk in passkeys ] if passkeys else [] ) return _SchemaResponse(success=True, data=key_list) except Exception as e: logger.error(f"获取PassKey列表失败: {e}") return _SchemaResponse(success=False, message=f"获取列表失败: {str(e)}") @router.post( "/passkey/delete", summary="删除 PassKey", response_model=_SchemaResponse[None], ) async def passkey_delete( data: PassKeyDeleteRequest, current_user: ApiPrincipal = Depends(get_current_active_user_async), service: PasskeyService = Depends(get_passkey_service), ) -> Any: """删除指定的 PassKey""" try: # 验证密码 if not verify_password( data.password, str(current_user.hashed_password) ): return _SchemaResponse(success=False, message="密码错误") success = service.delete_by_id(data.passkey_id, current_user.id) if success: logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}") return _SchemaResponse(success=True, message="通行密钥已删除") else: return _SchemaResponse(success=False, message="通行密钥不存在或无权删除") except Exception as e: logger.error(f"删除PassKey失败: {e}") return _SchemaResponse(success=False, message=f"删除失败: {str(e)}")