feat: 实现邮箱验证注册功能

添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制:
  - 新增邮箱验证服务模块(email_sender, email_template, email_verification)
  - 更新认证API支持邮箱验证注册流程
  - 添加注册对话框和验证码输入组件
  - 完善IP限流器支持邮箱验证场景
  - 修复前端类型定义问题,升级esbuild依赖

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

  Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
RWDai
2025-12-30 17:15:48 +08:00
parent b89a4af0cf
commit 6bd8cdb9cf
21 changed files with 2464 additions and 404 deletions

View File

@@ -23,12 +23,19 @@ from src.models.api import (
RefreshTokenResponse,
RegisterRequest,
RegisterResponse,
RegistrationSettingsResponse,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerifyEmailRequest,
VerifyEmailResponse,
)
from src.models.database import AuditEventType, User, UserRole
from src.services.auth.service import AuthService
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import ConfigService
from src.services.user.service import UserService
from src.services.verification import EmailSenderService, EmailVerificationService
from src.utils.request_utils import get_client_ip, get_user_agent
@@ -38,6 +45,13 @@ pipeline = ApiRequestPipeline()
# API端点
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
async def registration_settings(request: Request, db: Session = Depends(get_db)):
"""公开获取注册相关配置"""
adapter = AuthRegistrationSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/login", response_model=LoginResponse)
async def login(request: Request, db: Session = Depends(get_db)):
adapter = AuthLoginAdapter()
@@ -75,6 +89,20 @@ async def logout(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(request: Request, db: Session = Depends(get_db)):
"""发送邮箱验证码"""
adapter = AuthSendVerificationCodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verify-email", response_model=VerifyEmailResponse)
async def verify_email(request: Request, db: Session = Depends(get_db)):
"""验证邮箱验证码"""
adapter = AuthVerifyEmailAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 适配器实现 ==============
@@ -209,6 +237,24 @@ class AuthRefreshAdapter(AuthPublicAdapter):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""公开返回注册相关配置"""
db = context.db
enable_registration = ConfigService.get_config(db, "enable_registration", default=False)
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
expire_minutes = ConfigService.get_config(
db, "verification_code_expire_minutes", default=30
)
return RegistrationSettingsResponse(
enable_registration=bool(enable_registration),
require_email_verification=bool(require_verification),
verification_code_expire_minutes=expire_minutes,
).model_dump()
class AuthRegisterAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
from src.models.database import SystemConfig
@@ -241,6 +287,19 @@ class AuthRegisterAdapter(AuthPublicAdapter):
db.commit()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
# 检查是否需要邮箱验证
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
if require_verification:
# 检查邮箱是否已验证
is_verified = await EmailVerificationService.is_email_verified(register_request.email)
if not is_verified:
logger.warning(f"注册失败:邮箱未验证: {register_request.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
)
try:
user = UserService.create_user(
db=db,
@@ -258,7 +317,13 @@ class AuthRegisterAdapter(AuthPublicAdapter):
user_agent=user_agent,
metadata={"email": user.email, "username": user.username, "role": user.role.value},
)
# 注册成功后清除验证状态 - 在 commit 之前清理,避免竞态条件
if require_verification:
await EmailVerificationService.clear_verification(register_request.email)
db.commit()
return RegisterResponse(
user_id=user.id,
email=user.email,
@@ -351,3 +416,124 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter):
else:
logger.warning(f"用户登出失败Redis不可用: {user.email}")
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""发送邮箱验证码"""
db = context.db
payload = context.ensure_json_body()
try:
send_request = SendVerificationCodeRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = send_request.email
# IP 速率限制检查验证码发送3次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_send"
)
if not allowed:
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 获取验证码过期时间配置
expire_minutes = ConfigService.get_config(
db, "verification_code_expire_minutes", default=30
)
# 检查邮箱是否已注册 - 静默处理,不暴露邮箱注册状态
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
# 不发送验证码,但返回成功信息,防止邮箱枚举攻击
logger.warning(f"尝试为已注册邮箱发送验证码: {email}")
return SendVerificationCodeResponse(
success=True,
message="验证码已发送",
expire_minutes=expire_minutes,
)
# 生成并发送验证码
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
email, expire_minutes=expire_minutes
)
if not success:
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail or code_or_error,
)
# 发送邮件
email_success, email_error = await EmailSenderService.send_verification_code(
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
)
if not email_success:
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="发送验证码失败,请稍后重试",
)
logger.info(f"验证码已发送: {email}")
return SendVerificationCodeResponse(
message="验证码已发送,请查收邮件",
success=True,
expire_minutes=expire_minutes,
).model_dump()
class AuthVerifyEmailAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""验证邮箱验证码"""
db = context.db
payload = context.ensure_json_body()
try:
verify_request = VerifyEmailRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = verify_request.email
code = verify_request.code
# IP 速率限制检查验证码验证10次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_verify"
)
if not allowed:
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 验证验证码
success, message = await EmailVerificationService.verify_code(email, code)
if not success:
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
logger.info(f"邮箱验证成功: {email}")
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()