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.
This commit is contained in:
咕谷酱
2025-10-12 02:39:46 +08:00
parent 301130df02
commit 73d25c7604
4 changed files with 216 additions and 12 deletions

View File

@@ -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="是否启用邮件验证功能"),

View File

@@ -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,
)

View File

@@ -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_code6位数字验证码或备份码优先验证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")

View File

@@ -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()