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标签中使用用户名而不是邮箱"), 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[ enable_email_verification: Annotated[
bool, bool,
Field(default=False, description="是否启用邮件验证功能"), Field(default=False, description="是否启用邮件验证功能"),

View File

@@ -33,6 +33,7 @@ from app.models.oauth import (
from app.models.score import GameMode from app.models.score import GameMode
from app.service.login_log_service import LoginLogService from app.service.login_log_service import LoginLogService
from app.service.password_reset_service import password_reset_service from app.service.password_reset_service import password_reset_service
from app.service.turnstile_service import turnstile_service
from app.service.verification_service import ( from app.service.verification_service import (
EmailVerificationService, EmailVerificationService,
LoginSessionService, LoginSessionService,
@@ -84,7 +85,19 @@ async def register_user(
user_password: Annotated[str, Form(..., alias="user[password]", description="密码")], user_password: Annotated[str, Form(..., alias="user[password]", description="密码")],
geoip: GeoIPService, geoip: GeoIPService,
client_ip: IPAddress, 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) username_errors = validate_username(user_username)
email_errors = validate_email(user_email) email_errors = validate_email(user_email)
password_errors = validate_password(user_password) password_errors = validate_password(user_password)
@@ -203,7 +216,25 @@ async def oauth_token(
password: Annotated[str | None, Form(description="密码(仅密码模式需要)")] = None, password: Annotated[str | None, Form(description="密码(仅密码模式需要)")] = None,
refresh_token: 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, 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(" ") scopes = scope.split(" ")
client = ( client = (
@@ -560,18 +591,31 @@ async def request_password_reset(
email: Annotated[str, Form(..., description="邮箱地址")], email: Annotated[str, Form(..., description="邮箱地址")],
redis: Redis, redis: Redis,
ip_address: IPAddress, 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( success, message = await password_reset_service.request_password_reset(
email=email.lower().strip(), email=email.lower().strip(),
ip_address=ip_address, ip_address=ip_address,
user_agent=user_agent, user_agent=user_agent_str,
redis=redis, redis=redis,
) )

View File

@@ -2,12 +2,15 @@ from typing import Annotated
from app.auth import ( from app.auth import (
authenticate_user, authenticate_user,
check_totp_backup_code,
get_password_hash, get_password_hash,
validate_password, 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.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.dependencies.user import ClientUser
from app.log import log from app.log import log
@@ -30,24 +33,76 @@ logger = log("Auth")
async def change_password( async def change_password(
current_user: ClientUser, current_user: ClientUser,
session: Database, session: Database,
current_password: Annotated[str, Form(..., description="当前密码")], redis: Redis,
new_password: Annotated[str, Form(..., description="新密码")], 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 次 速率限制: 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): if errors := validate_password(new_password):
raise HTTPException(status_code=400, detail="; ".join(errors)) 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) 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(TrustedDevice).where(col(TrustedDevice.user_id) == user_id))
await session.execute(delete(LoginSession).where(col(LoginSession.user_id) == current_user.id)) await session.execute(delete(LoginSession).where(col(LoginSession.user_id) == user_id))
await session.execute(delete(OAuthToken).where(col(OAuthToken.user_id) == current_user.id)) await session.execute(delete(OAuthToken).where(col(OAuthToken.user_id) == user_id))
logger.info(f"User {current_user.id} changed password and sessions revoked")
await session.commit() 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()