From 73d25c760406614433fe7a823bf506e3ccd3fb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= Date: Sun, 12 Oct 2025 02:39:46 +0800 Subject: [PATCH] Add Cloudflare Turnstile verification to auth flows Introduces Cloudflare Turnstile verification for registration, OAuth password grant, and password reset endpoints (excluding osu! client). Adds related configuration options and a new service for token validation. Also refactors password change logic to support TOTP or password-based verification, improving security for users with TOTP enabled. --- app/config.py | 15 ++++++ app/router/auth.py | 48 ++++++++++++++++- app/router/private/password.py | 75 ++++++++++++++++++++++---- app/service/turnstile_service.py | 90 ++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 app/service/turnstile_service.py diff --git a/app/config.py b/app/config.py index 7f4bd07..4559ebf 100644 --- a/app/config.py +++ b/app/config.py @@ -311,6 +311,21 @@ STORAGE_SETTINGS='{ Field(default=True, description="在TOTP标签中使用用户名而不是邮箱"), "验证服务设置", ] + enable_turnstile_verification: Annotated[ + bool, + Field(default=False, description="是否启用 Cloudflare Turnstile 验证(仅对非 osu! 客户端)"), + "验证服务设置", + ] + turnstile_secret_key: Annotated[ + str, + Field(default="", description="Cloudflare Turnstile Secret Key"), + "验证服务设置", + ] + turnstile_dev_mode: Annotated[ + bool, + Field(default=False, description="Turnstile 开发模式(跳过验证,用于本地开发)"), + "验证服务设置", + ] enable_email_verification: Annotated[ bool, Field(default=False, description="是否启用邮件验证功能"), diff --git a/app/router/auth.py b/app/router/auth.py index 9e3a568..99af7b8 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -33,6 +33,7 @@ from app.models.oauth import ( from app.models.score import GameMode from app.service.login_log_service import LoginLogService from app.service.password_reset_service import password_reset_service +from app.service.turnstile_service import turnstile_service from app.service.verification_service import ( EmailVerificationService, LoginSessionService, @@ -84,7 +85,19 @@ async def register_user( user_password: Annotated[str, Form(..., alias="user[password]", description="密码")], geoip: GeoIPService, client_ip: IPAddress, + user_agent: UserAgentInfo, + cf_turnstile_response: Annotated[ + str, Form(description="Cloudflare Turnstile 响应 token") + ] = "XXXX.DUMMY.TOKEN.XXXX", ): + # Turnstile 验证(仅对非 osu! 客户端) + if settings.enable_turnstile_verification and not user_agent.is_client: + success, error_msg = await turnstile_service.verify_token(cf_turnstile_response, client_ip) + logger.info(f"Turnstile verification result: {success}, error_msg: {error_msg}") + if not success: + errors = RegistrationRequestErrors(message=f"Verification failed: {error_msg}") + return JSONResponse(status_code=400, content={"form_error": errors.model_dump()}) + username_errors = validate_username(user_username) email_errors = validate_email(user_email) password_errors = validate_password(user_password) @@ -203,7 +216,25 @@ async def oauth_token( password: Annotated[str | None, Form(description="密码(仅密码模式需要)")] = None, refresh_token: Annotated[str | None, Form(description="刷新令牌(仅刷新令牌模式需要)")] = None, web_uuid: Annotated[str | None, Header(include_in_schema=False, alias="X-UUID")] = None, + cf_turnstile_response: Annotated[ + str, Form(description="Cloudflare Turnstile 响应 token") + ] = "XXXX.DUMMY.TOKEN.XXXX", ): + # Turnstile 验证(仅对非 osu! 客户端的密码授权模式) + if grant_type == "password" and settings.enable_turnstile_verification and not user_agent.is_client: + logger.debug( + f"Turnstile check: grant_type={grant_type}, token={cf_turnstile_response[:20]}..., " + f"enabled={settings.enable_turnstile_verification}, is_client={user_agent.is_client}" + ) + success, error_msg = await turnstile_service.verify_token(cf_turnstile_response, ip_address) + logger.info(f"Turnstile verification result: success={success}, error={error_msg}, ip={ip_address}") + if not success: + return create_oauth_error_response( + error="invalid_request", + description=f"Verification failed: {error_msg}", + hint="Invalid or expired verification token", + ) + scopes = scope.split(" ") client = ( @@ -560,18 +591,31 @@ async def request_password_reset( email: Annotated[str, Form(..., description="邮箱地址")], redis: Redis, ip_address: IPAddress, + user_agent: UserAgentInfo, + cf_turnstile_response: Annotated[ + str, Form(description="Cloudflare Turnstile 响应 token") + ] = "XXXX.DUMMY.TOKEN.XXXX", ): """ 请求密码重置 """ + # Turnstile 验证(仅对非 osu! 客户端) + if settings.enable_turnstile_verification and not user_agent.is_client: + success, error_msg = await turnstile_service.verify_token(cf_turnstile_response, ip_address) + if not success: + return JSONResponse( + status_code=400, + content={"success": False, "error": f"Verification failed: {error_msg}"}, + ) + # 获取客户端信息 - user_agent = request.headers.get("User-Agent", "") + user_agent_str = request.headers.get("User-Agent", "") # 请求密码重置 success, message = await password_reset_service.request_password_reset( email=email.lower().strip(), ip_address=ip_address, - user_agent=user_agent, + user_agent=user_agent_str, redis=redis, ) diff --git a/app/router/private/password.py b/app/router/private/password.py index 5e960d3..a32e294 100644 --- a/app/router/private/password.py +++ b/app/router/private/password.py @@ -2,12 +2,15 @@ from typing import Annotated from app.auth import ( authenticate_user, + check_totp_backup_code, get_password_hash, validate_password, + verify_totp_key_with_replay_protection, ) -from app.database.auth import OAuthToken +from app.const import BACKUP_CODE_LENGTH +from app.database.auth import OAuthToken, TotpKeys from app.database.verification import LoginSession, TrustedDevice -from app.dependencies.database import Database +from app.dependencies.database import Database, Redis from app.dependencies.user import ClientUser from app.log import log @@ -30,24 +33,76 @@ logger = log("Auth") async def change_password( current_user: ClientUser, session: Database, - current_password: Annotated[str, Form(..., description="当前密码")], - new_password: Annotated[str, Form(..., description="新密码")], + redis: Redis, + new_password: Annotated[str, Form(description="新密码")], + current_password: Annotated[str | None, Form(description="当前密码(未启用TOTP时必填)")] = None, + totp_code: Annotated[str | None, Form(description="TOTP验证码或备份码(已启用TOTP时必填)")] = None, ): """更改用户密码 + 验证方式: + - 如果用户已启用TOTP,必须提供 totp_code(6位数字验证码或备份码),优先验证TOTP + - 如果用户未启用TOTP,必须提供 current_password 进行密码验证 + 同时删除所有的已登录会话和信任设备 速率限制: 5 分钟内最多 3 次 """ - if not await authenticate_user(session, current_user.username, current_password): - raise HTTPException(status_code=403, detail="Password incorrect") + # 验证新密码格式 if errors := validate_password(new_password): raise HTTPException(status_code=400, detail="; ".join(errors)) + # 检查用户是否启用了TOTP + totp_key = await session.get(TotpKeys, current_user.id) + + if totp_key: + # 用户已启用TOTP,必须验证TOTP + if not totp_code: + raise HTTPException( + status_code=400, detail="TOTP code is required. Please provide totp_code (6-digit code or backup code)." + ) + + is_verified = False + if len(totp_code) == 6 and totp_code.isdigit(): + is_verified = await verify_totp_key_with_replay_protection( + current_user.id, totp_key.secret, totp_code, redis + ) + elif len(totp_code) == BACKUP_CODE_LENGTH: + is_verified = check_totp_backup_code(totp_key, totp_code) + if is_verified: + session.add(totp_key) + else: + raise HTTPException( + status_code=400, + detail=( + f"Invalid TOTP code format. Expected 6-digit code or {BACKUP_CODE_LENGTH}-character backup code." + ), + ) + + if not is_verified: + raise HTTPException(status_code=403, detail="Invalid TOTP code or backup code") + + logger.info(f"User {current_user.id} verified identity with TOTP for password change") + + else: + # 用户未启用TOTP,必须验证当前密码 + if not current_password: + raise HTTPException( + status_code=400, detail="Current password is required. Please provide current_password." + ) + + if not await authenticate_user(session, current_user.username, current_password): + raise HTTPException(status_code=403, detail="Current password is incorrect") + + logger.info(f"User {current_user.id} verified identity with password for password change") + + user_id = current_user.id + current_user.pw_bcrypt = get_password_hash(new_password) - await session.execute(delete(TrustedDevice).where(col(TrustedDevice.user_id) == current_user.id)) - await session.execute(delete(LoginSession).where(col(LoginSession.user_id) == current_user.id)) - await session.execute(delete(OAuthToken).where(col(OAuthToken.user_id) == current_user.id)) - logger.info(f"User {current_user.id} changed password and sessions revoked") + await session.execute(delete(TrustedDevice).where(col(TrustedDevice.user_id) == user_id)) + await session.execute(delete(LoginSession).where(col(LoginSession.user_id) == user_id)) + await session.execute(delete(OAuthToken).where(col(OAuthToken.user_id) == user_id)) + await session.commit() + logger.info(f"User {user_id} successfully changed password, all sessions revoked") diff --git a/app/service/turnstile_service.py b/app/service/turnstile_service.py new file mode 100644 index 0000000..3116213 --- /dev/null +++ b/app/service/turnstile_service.py @@ -0,0 +1,90 @@ +"""Cloudflare Turnstile 验证服务 + +负责验证 Cloudflare Turnstile token 的有效性 +""" + +from app.config import settings +from app.log import log + +import httpx + +logger = log("Turnstile") + +TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +DUMMY_TOKEN = "XXXX.DUMMY.TOKEN.XXXX" # noqa: S105 + + +class TurnstileService: + """Cloudflare Turnstile 验证服务""" + + @staticmethod + async def verify_token(token: str, remoteip: str | None = None) -> tuple[bool, str]: + """验证 Turnstile token + + Args: + token: Turnstile 响应 token + remoteip: 客户端 IP 地址(可选) + + Returns: + tuple[bool, str]: (是否成功, 错误消息) + """ + # 如果未启用 Turnstile 验证,直接返回成功 + if not settings.enable_turnstile_verification: + return True, "" + + # 开发模式:直接跳过验证 + if settings.turnstile_dev_mode: + logger.debug("Turnstile dev mode enabled, skipping verification") + return True, "" + + # 检查是否为 dummy token(仅在开发模式下接受) + if token == DUMMY_TOKEN: + logger.warning(f"Dummy token provided but dev mode is disabled (IP: {remoteip})") + return False, "Invalid verification token" + + # 检查配置 + if not settings.turnstile_secret_key: + logger.error("Turnstile secret key not configured") + return False, "Turnstile verification not configured" + + # 准备请求数据 + data = {"secret": settings.turnstile_secret_key, "response": token} + + if remoteip: + data["remoteip"] = remoteip + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(TURNSTILE_VERIFY_URL, data=data) + response.raise_for_status() + result = response.json() + + if result.get("success"): + logger.debug(f"Turnstile verification successful for IP {remoteip}") + return True, "" + else: + error_codes = result.get("error-codes", []) + logger.warning(f"Turnstile verification failed for IP {remoteip}, errors: {error_codes}") + + # 根据错误代码提供友好的错误消息 + if "timeout-or-duplicate" in error_codes: + return False, "Verification token expired or already used" + elif "invalid-input-response" in error_codes: + return False, "Invalid verification token" + elif "missing-input-response" in error_codes: + return False, "Verification token is required" + else: + return False, "Verification failed" + + except httpx.TimeoutException: + logger.error("Turnstile verification timeout") + return False, "Verification service timeout" + except httpx.HTTPError as e: + logger.error(f"Turnstile verification HTTP error: {e}") + return False, "Verification service error" + except Exception as e: # Catch any unexpected errors + logger.exception(f"Turnstile verification unexpected error: {e}") + return False, "Verification service error" + + +turnstile_service = TurnstileService()