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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user