完善邮件验证与密码重置安全流程

This commit is contained in:
Cheng Zhou
2026-06-30 09:44:24 +08:00
parent 6c2bcc59b2
commit b25055775e
27 changed files with 4437 additions and 526 deletions
+107 -2
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import File, UploadFile
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
@@ -12,7 +12,19 @@ from app.core.security import create_access_token, decode_token_allow_expired, o
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
from app.models.user import UserStatus
from app.schemas.email_settings import (
EmailCodeResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyRequest,
PasswordResetCodeVerifyResponse,
PasswordResetLinkSendRequest,
PasswordResetRequest,
PasswordResetTokenRequest,
RegisterEmailCodeSendRequest,
RegisterEmailCodeVerifyRequest,
)
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from app.services import email_service
from fastapi.responses import FileResponse
@@ -39,6 +51,14 @@ class ExtendResponse(BaseModel):
expiresAt: datetime
class EmailAvailabilityResponse(BaseModel):
available: bool
class EmailDomainsResponse(BaseModel):
items: list[str]
router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -108,6 +128,90 @@ def ensure_user_active(db_user) -> None:
)
@router.get("/email-domains", response_model=EmailDomainsResponse)
async def read_email_domains(
db: AsyncSession = Depends(get_db_session),
) -> EmailDomainsResponse:
rows = await email_service.list_email_settings(db)
return EmailDomainsResponse(items=[row.register_domain for row in rows])
@router.get("/register/email-availability", response_model=EmailAvailabilityResponse)
async def check_register_email_availability(
email: EmailStr = Query(...),
db: AsyncSession = Depends(get_db_session),
) -> EmailAvailabilityResponse:
existing = await user_crud.get_by_email(db, str(email))
return EmailAvailabilityResponse(available=existing is None)
@router.post("/register/email-code/send", response_model=EmailCodeResponse)
async def send_register_email_code(
payload: RegisterEmailCodeSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_register_code(db, str(payload.email))
return EmailCodeResponse(message="验证码已发送")
@router.post("/register/email-code/verify", response_model=EmailCodeVerifyResponse)
async def verify_register_email_code(
payload: RegisterEmailCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeVerifyResponse:
await email_service.verify_register_code(db, str(payload.email), payload.code)
return EmailCodeVerifyResponse(verified=True)
@router.post("/password-reset/email-code/send", response_model=EmailCodeResponse)
async def send_password_reset_email_code(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_code(db, str(payload.email))
return EmailCodeResponse(message="验证码发送成功,请查收邮箱")
@router.post("/password-reset/email-code/verify", response_model=PasswordResetCodeVerifyResponse)
async def verify_password_reset_email_code(
payload: PasswordResetCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> PasswordResetCodeVerifyResponse:
reset_token = await email_service.verify_password_reset_code(db, str(payload.email), payload.code)
return PasswordResetCodeVerifyResponse(verified=True, reset_token=reset_token)
@router.post("/password-reset-link/send", response_model=EmailCodeResponse)
async def send_password_reset_link(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_link(
db,
str(payload.email),
frontend_origin=settings.FRONTEND_PUBLIC_URL,
)
return EmailCodeResponse(message="如果账号存在,重置链接已发送")
@router.post("/password-reset", response_model=EmailCodeResponse)
async def reset_password(
payload: PasswordResetRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_code(db, str(payload.email), payload.code, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/password-reset-link", response_model=EmailCodeResponse)
async def reset_password_with_link(
payload: PasswordResetTokenRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_token(db, payload.token, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
@@ -116,6 +220,7 @@ async def register(
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await email_service.ensure_register_email_verified(db, str(payload.email))
await user_crud.create_registered_user(db, payload)
return {"message": "注册成功,请登录"}