feat(session-verify): 添加 TOTP 支持 (#34)

* chore(deps): add pyotp

* feat(auth): implement TOTP verification

feat(auth): implement TOTP verification and email verification services

- Added TOTP keys management with a new database model `TotpKeys`.
- Introduced `EmailVerification` and `LoginSession` models for email verification.
- Created `verification_service` to handle email verification logic and TOTP processes.
- Updated user response models to include session verification methods.
- Implemented routes for TOTP creation, verification, and fallback to email verification.
- Enhanced login session management to support new location checks and verification methods.
- Added migration script to create `totp_keys` table in the database.

* feat(config): update config example

* docs(totp): complete creating TOTP flow

* refactor(totp): resolve review

* feat(api): forbid unverified request

* fix(totp): trace session by token id to avoid other sessions are forbidden

* chore(linter): make pyright happy

* fix(totp): only mark sessions with a specified token id
This commit is contained in:
MingxuanGame
2025-09-21 19:50:11 +08:00
committed by GitHub
parent 7b4ff1224d
commit 1527e23b43
25 changed files with 684 additions and 235 deletions

View File

@@ -17,6 +17,7 @@ from app.auth import (
from app.config import settings
from app.const import BANCHOBOT_ID
from app.database import DailyChallengeStats, OAuthClient, User
from app.database.auth import TotpKeys
from app.database.statistics import UserStatistics
from app.dependencies.database import Database, get_redis
from app.dependencies.geoip import get_client_ip, get_geoip_helper
@@ -30,12 +31,12 @@ from app.models.oauth import (
UserRegistrationErrors,
)
from app.models.score import GameMode
from app.service.email_verification_service import (
from app.service.login_log_service import LoginLogService
from app.service.password_reset_service import password_reset_service
from app.service.verification_service import (
EmailVerificationService,
LoginSessionService,
)
from app.service.login_log_service import LoginLogService
from app.service.password_reset_service import password_reset_service
from app.utils import utcnow
from fastapi import APIRouter, Depends, Form, Request
@@ -287,8 +288,23 @@ async def oauth_token(
# 确保用户对象与当前会话关联
await db.refresh(user)
# 获取用户信息和客户端信息
user_id = user.id
totp_key: TotpKeys | None = await user.awaitable_attrs.totp_key
# 生成令牌
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(data={"sub": str(user_id)}, expires_delta=access_token_expires)
refresh_token_str = generate_refresh_token()
token = await store_token(
db,
user_id,
client_id,
scopes,
access_token,
refresh_token_str,
settings.access_token_expire_minutes * 60,
)
token_id = token.id
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
@@ -300,15 +316,22 @@ async def oauth_token(
# 检查是否为新位置登录
is_new_location = await LoginSessionService.check_new_location(db, user_id, ip_address, country_code)
# 创建登录会话记录
login_session = await LoginSessionService.create_session( # noqa: F841
db, redis, user_id, ip_address, user_agent, country_code, is_new_location
)
# 如果是新位置登录,需要邮件验证
if is_new_location and settings.enable_email_verification:
session_verification_method = None
if settings.enable_totp_verification and totp_key is not None:
session_verification_method = "totp"
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_success=True,
login_method="password_pending_verification",
notes="需要 TOTP 验证",
)
elif is_new_location and settings.enable_email_verification:
# 如果是新位置登录,需要邮件验证
# 刷新用户对象以确保属性已加载
await db.refresh(user)
session_verification_method = "mail"
# 发送邮件验证码
verification_sent = await EmailVerificationService.send_verification_email(
@@ -328,9 +351,9 @@ async def oauth_token(
if not verification_sent:
# 邮件发送失败,记录错误
logger.error(f"[Auth] Failed to send email verification code for user {user_id}")
elif is_new_location and not settings.enable_email_verification:
elif is_new_location:
# 新位置登录但邮件验证功能被禁用,直接标记会话为已验证
await LoginSessionService.mark_session_verified(db, user_id)
await LoginSessionService.mark_session_verified(db, redis, user_id, token_id)
logger.debug(
f"[Auth] New location login detected but email verification disabled, auto-verifying user {user_id}"
)
@@ -345,25 +368,16 @@ async def oauth_token(
notes=f"正常登录 - IP: {ip_address}, 国家: {country_code}",
)
# 无论是否新位置登录都返回正常的token
# session_verified状态通过/me接口的session_verified字段来体现
if session_verification_method:
await LoginSessionService.create_session(
db, redis, user_id, token_id, ip_address, user_agent, country_code, is_new_location, False
)
await LoginSessionService.set_login_method(user_id, token_id, session_verification_method, redis)
else:
await LoginSessionService.create_session(
db, redis, user_id, token_id, ip_address, user_agent, country_code, is_new_location, True
)
# 生成令牌
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
# 获取用户ID避免触发延迟加载
access_token = create_access_token(data={"sub": str(user_id)}, expires_delta=access_token_expires)
refresh_token_str = generate_refresh_token()
# 存储令牌
await store_token(
db,
user_id,
client_id,
scopes,
access_token,
refresh_token_str,
settings.access_token_expire_minutes * 60,
)
return TokenResponse(
access_token=access_token,
token_type="Bearer",