添加邮件验证

This commit is contained in:
咕谷酱
2025-08-22 08:19:12 +08:00
parent 42f17d0c66
commit 3bee2421fa
19 changed files with 1594 additions and 22 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
import re
from typing import Literal
from typing import Literal, Union
from app.auth import (
authenticate_user,
@@ -28,8 +28,13 @@ from app.models.oauth import (
TokenResponse,
UserRegistrationErrors,
)
from app.models.extended_auth import ExtendedTokenResponse
from app.models.score import GameMode
from app.service.login_log_service import LoginLogService
from app.service.email_verification_service import (
EmailVerificationService,
LoginSessionService
)
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import JSONResponse
@@ -198,7 +203,7 @@ async def register_user(
@router.post(
"/oauth/token",
response_model=TokenResponse,
response_model=Union[TokenResponse, ExtendedTokenResponse],
name="获取访问令牌",
description="OAuth 令牌端点,支持密码、刷新令牌和授权码三种授权方式。",
)
@@ -218,6 +223,7 @@ async def oauth_token(
None, description="刷新令牌(仅刷新令牌模式需要)"
),
redis: Redis = Depends(get_redis),
geoip: GeoIPHelper = Depends(get_geoip_helper),
):
scopes = scope.split(" ")
@@ -295,17 +301,68 @@ async def oauth_token(
# 确保用户对象与当前会话关联
await db.refresh(user)
# 记录成功的登录
# 获取用户信息和客户端信息
user_id = getattr(user, "id")
assert user_id is not None, "User ID should not be None after authentication"
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_success=True,
login_method="password",
notes=f"OAuth password grant for client {client_id}",
from app.dependencies.geoip import get_client_ip
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
# 获取国家代码
geo_info = geoip.lookup(ip_address)
country_code = geo_info.get("country_iso", "XX")
# 检查是否为新位置登录
is_new_location = await LoginSessionService.check_new_location(
db, user_id, ip_address, country_code
)
# 创建登录会话记录
login_session = await LoginSessionService.create_session(
db, redis, user_id, ip_address, user_agent, country_code, is_new_location
)
# 如果是新位置登录,需要邮件验证
if is_new_location and settings.enable_email_verification:
# 刷新用户对象以确保属性已加载
await db.refresh(user)
# 发送邮件验证码
verification_sent = await EmailVerificationService.send_verification_email(
db, redis, user_id, user.username, user.email, ip_address, user_agent
)
# 记录需要二次验证的登录尝试
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_success=True,
login_method="password_pending_verification",
notes=f"新位置登录,需要邮件验证 - IP: {ip_address}, 国家: {country_code}",
)
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:
# 新位置登录但邮件验证功能被禁用,直接标记会话为已验证
await LoginSessionService.mark_session_verified(db, user_id)
logger.debug(f"[Auth] New location login detected but email verification disabled, auto-verifying user {user_id}")
else:
# 不是新位置登录,正常登录
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_success=True,
login_method="password",
notes=f"正常登录 - IP: {ip_address}, 国家: {country_code}",
)
# 无论是否新位置登录都返回正常的token
# session_verified状态通过/me接口的session_verified字段来体现
# 生成令牌
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)

View File

@@ -9,6 +9,7 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401
relationship,
room,
score,
session_verify,
user,
)
from .router import router as api_v2_router

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from app.database import User, UserResp
from app.database import User
from app.database.lazer_user import ALL_INCLUDED
from app.dependencies import get_current_user
from app.dependencies.database import Database
from app.models.score import GameMode
from app.models.api_me import APIMe
from .router import router
@@ -13,7 +14,7 @@ from fastapi import Path, Security
@router.get(
"/me/{ruleset}",
response_model=UserResp,
response_model=APIMe,
name="获取当前用户信息 (指定 ruleset)",
description="获取当前登录用户信息 (含指定 ruleset 统计)。",
tags=["用户"],
@@ -23,17 +24,18 @@ async def get_user_info_with_ruleset(
ruleset: GameMode = Path(description="指定 ruleset"),
current_user: User = Security(get_current_user, scopes=["identify"]),
):
return await UserResp.from_db(
user_resp = await APIMe.from_db(
current_user,
session,
ALL_INCLUDED,
ruleset,
)
return user_resp
@router.get(
"/me/",
response_model=UserResp,
response_model=APIMe,
name="获取当前用户信息",
description="获取当前登录用户信息。",
tags=["用户"],
@@ -42,9 +44,10 @@ async def get_user_info_default(
session: Database,
current_user: User = Security(get_current_user, scopes=["identify"]),
):
return await UserResp.from_db(
user_resp = await APIMe.from_db(
current_user,
session,
ALL_INCLUDED,
None,
)
return user_resp

View File

@@ -0,0 +1,204 @@
"""
会话验证路由 - 实现类似 osu! 的邮件验证流程 (API v2)
"""
from __future__ import annotations
from datetime import datetime, UTC
from typing import Annotated
from app.auth import authenticate_user
from app.config import settings
from app.database import User
from app.dependencies import get_current_user
from app.dependencies.database import Database, get_redis
from app.dependencies.geoip import GeoIPHelper, get_geoip_helper
from app.database.email_verification import EmailVerification, LoginSession
from app.service.email_verification_service import (
EmailVerificationService,
LoginSessionService
)
from app.service.login_log_service import LoginLogService
from app.models.extended_auth import ExtendedTokenResponse
from fastapi import Form, Depends, Request, HTTPException, status, Security
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlmodel import select
from .router import router
class SessionReissueResponse(BaseModel):
"""重新发送验证码响应"""
success: bool
message: str
@router.post(
"/session/verify",
name="验证会话",
description="验证邮件验证码并完成会话认证",
status_code=204
)
async def verify_session(
request: Request,
db: Database,
redis: Annotated[Redis, Depends(get_redis)],
verification_key: str = Form(..., description="8位邮件验证码"),
current_user: User = Security(get_current_user)
) -> Response:
"""
验证邮件验证码并完成会话认证
对应 osu! 的 session/verify 接口
成功时返回 204 No Content失败时返回 401 Unauthorized
"""
try:
from app.dependencies.geoip import get_client_ip
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "Unknown")
# 从当前认证用户获取信息
user_id = current_user.id
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户未认证"
)
# 验证邮件验证码
success, message = await EmailVerificationService.verify_code(
db, redis, user_id, verification_key
)
if success:
# 记录成功的邮件验证
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_method="email_verification",
login_success=True,
notes=f"邮件验证成功"
)
# 返回 204 No Content 表示验证成功
return Response(status_code=status.HTTP_204_NO_CONTENT)
else:
# 记录失败的邮件验证尝试
await LoginLogService.record_failed_login(
db=db,
request=request,
attempted_username=current_user.username,
login_method="email_verification",
notes=f"邮件验证失败: {message}"
)
# 返回 401 Unauthorized 表示验证失败
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=message
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的用户会话"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证过程中发生错误"
)
@router.post(
"/session/verify/reissue",
name="重新发送验证码",
description="重新发送邮件验证码",
response_model=SessionReissueResponse
)
async def reissue_verification_code(
request: Request,
db: Database,
redis: Annotated[Redis, Depends(get_redis)],
current_user: User = Security(get_current_user)
) -> SessionReissueResponse:
"""
重新发送邮件验证码
对应 osu! 的 session/verify/reissue 接口
"""
try:
from app.dependencies.geoip import get_client_ip
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "Unknown")
# 从当前认证用户获取信息
user_id = current_user.id
if not user_id:
return SessionReissueResponse(
success=False,
message="用户未认证"
)
# 重新发送验证码
success, message = await EmailVerificationService.resend_verification_code(
db, redis, user_id, current_user.username, current_user.email, ip_address, user_agent
)
return SessionReissueResponse(
success=success,
message=message
)
except ValueError:
return SessionReissueResponse(
success=False,
message="无效的用户会话"
)
except Exception as e:
return SessionReissueResponse(
success=False,
message="重新发送过程中发生错误"
)
@router.post(
"/session/check-new-location",
name="检查新位置登录",
description="检查登录是否来自新位置(内部接口)"
)
async def check_new_location(
request: Request,
db: Database,
user_id: int,
geoip: GeoIPHelper = Depends(get_geoip_helper),
):
"""
检查是否为新位置登录
这是一个内部接口,用于登录流程中判断是否需要邮件验证
"""
try:
from app.dependencies.geoip import get_client_ip
ip_address = get_client_ip(request)
geo_info = geoip.lookup(ip_address)
country_code = geo_info.get("country_iso", "XX")
is_new_location = await LoginSessionService.check_new_location(
db, user_id, ip_address, country_code
)
return {
"is_new_location": is_new_location,
"ip_address": ip_address,
"country_code": country_code
}
except Exception as e:
return {
"is_new_location": True, # 出错时默认为新位置
"error": str(e)
}