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.
109 lines
3.9 KiB
Python
109 lines
3.9 KiB
Python
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_code(6位数字验证码或备份码),优先验证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")
|