Files
g0v0-server/app/router/private/password.py
咕谷酱 73d25c7604 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.
2025-10-12 02:39:46 +08:00

109 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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, Redis
from app.dependencies.user import ClientUser
from app.log import log
from .router import router
from fastapi import Depends, Form, HTTPException
from fastapi_limiter.depends import RateLimiter
from sqlmodel import col, delete
logger = log("Auth")
@router.post(
"/password/change",
name="更改密码",
tags=["验证", "g0v0 API"],
status_code=204,
dependencies=[Depends(RateLimiter(times=3, minutes=5))],
)
async def change_password(
current_user: ClientUser,
session: Database,
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 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) == 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")