添加邮件验证
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
204
app/router/v2/session_verify.py
Normal file
204
app/router/v2/session_verify.py
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user