""" 邮箱验证码路由。 接口(统一响应格式 {success, data, message}): - POST /api/email/send-code 向邮箱发送验证码(10 分钟有效,60 秒内不可重复发送; 另有按邮箱/按 IP 的小时限流,防止被当作垃圾邮件中继) - POST /api/email/verify-code 校验验证码(校验成功后即作废,一次性使用; 同一邮箱尝试超过 5 次自动作废验证码并要求重发) 注意: - issue_code / verify_code 被密码重置路由复用,限流逻辑集中在两个函数内。 """ import hmac import os import secrets from datetime import timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from ..database import get_db from ..email import send_verification_code from ..models import PURPOSE_REGISTER, PURPOSE_RESET, EmailCode, utcnow from ..schemas import SendCodeRequest, UnifiedResponse, VerifyCodeRequest from ..security import ( get_client_ip, is_valid_email, send_code_email_limiter, send_code_ip_limiter, verify_code_limiter, ) router = APIRouter(prefix="/api/email", tags=["email"]) # 验证码有效期与同一邮箱的重发间隔(可通过 .env 调整) CODE_TTL_MINUTES = int(os.getenv("EMAIL_CODE_TTL_MINUTES") or "10") RESEND_INTERVAL_SECONDS = int(os.getenv("EMAIL_RESEND_INTERVAL_SECONDS") or "60") def issue_code(email: str, purpose: str, db: Session, request: Optional[Request] = None) -> None: """生成验证码并发送邮件(send-code 与忘记密码复用);发送失败回滚并抛错。""" now = utcnow() # 限流:同一邮箱每小时最多 5 封;若带请求对象,再按 IP 每小时最多 10 封 if send_code_email_limiter.is_blocked(email): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="该邮箱发送过于频繁,请稍后再试") if request is not None: client_ip = get_client_ip(request) if send_code_ip_limiter.is_blocked(client_ip): raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="发送过于频繁,请稍后再试") latest = ( db.query(EmailCode) .filter(EmailCode.email == email, EmailCode.purpose == purpose) .order_by(EmailCode.created_time.desc()) .first() ) if latest is not None and latest.created_time > now - timedelta(seconds=RESEND_INTERVAL_SECONDS): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发送过于频繁,请稍后再试") code = f"{secrets.randbelow(1000000):06d}" # 作废该邮箱同用途的历史验证码,只保留最新一条 db.query(EmailCode).filter( EmailCode.email == email, EmailCode.purpose == purpose, ).update({EmailCode.used: True}) record = EmailCode( email=email, code=code, purpose=purpose, expires_at=now + timedelta(minutes=CODE_TTL_MINUTES), ) db.add(record) db.flush() try: send_verification_code(email, code) except Exception: db.rollback() raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="验证码发送失败,请稍后再试") db.commit() # 发送成功:记录限流次数,并重置该校验尝试计数(新验证码重新计数) send_code_email_limiter.hit(email) if request is not None: send_code_ip_limiter.hit(get_client_ip(request)) verify_code_limiter.reset(email) def verify_code(payload: VerifyCodeRequest, db: Session) -> None: """校验验证码:正确则标记为已使用(一次性);失败过多则作废验证码。""" email = payload.email.strip().lower() now = utcnow() # 尝试限流:同一邮箱尝试超过 5 次即作废当前验证码,必须重新发送 if verify_code_limiter.is_blocked(email): db.query(EmailCode).filter( EmailCode.email == email, EmailCode.purpose == payload.purpose, EmailCode.used.is_(False), ).update({EmailCode.used: True}) db.commit() raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码尝试次数过多,请重新发送", ) record = ( db.query(EmailCode) .filter( EmailCode.email == email, EmailCode.purpose == payload.purpose, EmailCode.used.is_(False), EmailCode.expires_at > now, ) .order_by(EmailCode.created_time.desc()) .first() ) # 恒定时间比较,避免通过响应时间差枚举验证码 if record is None or not hmac.compare_digest(record.code, payload.code): verify_code_limiter.hit(email) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期") record.used = True verify_code_limiter.reset(email) db.commit() @router.post("/send-code", response_model=UnifiedResponse) def send_code( payload: SendCodeRequest, request: Request, db: Session = Depends(get_db), ) -> UnifiedResponse: """向指定邮箱发送验证码(带邮箱与 IP 双重限流)。""" email = payload.email.strip().lower() if not is_valid_email(email): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确") if payload.purpose not in (PURPOSE_REGISTER, PURPOSE_RESET): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用途不合法") issue_code(email, payload.purpose, db, request) return UnifiedResponse( success=True, data={"email": email, "purpose": payload.purpose}, message="验证码已发送,请查收邮件", ) @router.post("/verify-code", response_model=UnifiedResponse) def verify_code_route(payload: VerifyCodeRequest, db: Session = Depends(get_db)) -> UnifiedResponse: """校验验证码:正确则标记为已使用(一次性)。""" email = payload.email.strip().lower() verify_code(payload, db) return UnifiedResponse( success=True, data={"email": email, "purpose": payload.purpose}, message="验证码校验通过", )