彻底删除数据库记录验证码

This commit is contained in:
2026-03-26 01:48:55 +08:00
parent 2335b62384
commit b18901a2d5
12 changed files with 444 additions and 417 deletions
+101 -383
View File
@@ -4,6 +4,7 @@
import json
import math
import os
import logging
from datetime import timedelta, timezone
from typing import Optional, Tuple
@@ -19,7 +20,7 @@ from app.core.security import (
verify_password,
verify_verification_code,
)
from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow
from app.models.models import AppUser, VerificationPurpose, utcnow
from app.schemas.auth_schema import (
AuthTokenResponse,
LoginCodeSendRequest,
@@ -32,9 +33,11 @@ from app.schemas.auth_schema import (
)
from app.utils.email_utils import send_html_email
from app.utils.redis_client import get_redis_client
from app.core.verification.email.verificationService import EmailVerificationService, get_verification_service, TooManyCodeRequestsError, CodeExpiredError, CodeInvalidError
router = APIRouter()
logger = logging.getLogger(__name__)
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
@@ -131,19 +134,12 @@ def _cache_code_in_redis(
"code_hash": code_hash,
"created_at": utcnow().isoformat(),
}
try:
client.set(
_redis_code_key(email, purpose),
json.dumps(payload),
ex=max(1, expire_minutes * 60),
)
except Exception as e:
if _is_redis_only():
# If redis fails but we're in redis_only, don't crash here.
# We already generated the code hash, but we won't cache it in redis.
# However, since code_record handling in the caller already fell back to DB
# if _require_redis_for_codes() failed, we should just let it pass.
pass
client.set(
_redis_code_key(email, purpose),
json.dumps(payload),
ex=max(1, expire_minutes * 60),
)
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
@@ -178,166 +174,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
pass
def _verify_code_with_redis(
email: str,
purpose: VerificationPurpose,
code: str,
*,
strict: bool = False,
) -> Optional[bool]:
"""
Redis 验证码校验。
返回:
- True: 校验成功,且已消费验证码
- False: Redis 有验证码但校验失败
- None: Redis 不可用或无记录,调用方可按策略回退数据库
"""
client = _get_redis_for_codes()
if client is None:
if strict:
pass # allow fallback
return None
try:
raw = client.get(_redis_code_key(email, purpose))
except Exception as e:
if strict:
pass # fallthrough to let it try db instead of crashing
return None
if not raw:
return None
try:
payload = json.loads(raw)
expected_hash = str(payload.get("code_hash", ""))
except Exception:
# 不要轻易清除,可能是数据格式异常
return None
if not expected_hash:
return None
if not verify_verification_code(code, expected_hash):
# 注意:校验失败时不要直接清空 Redis,可能用户只是输错了
return False
_clear_code_in_redis(email, purpose)
return True
def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用"""
db.query(EmailVerificationCode).filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.is_used.is_(False),
).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
db.commit()
def _create_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
expire_minutes: int,
) -> Tuple[EmailVerificationCode, str]:
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
code = generate_verification_code()
now = utcnow()
code_record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=hash_verification_code(code),
expires_at=now + timedelta(minutes=expire_minutes),
)
db.add(code_record)
db.commit()
return code_record, code
def _get_latest_valid_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
):
"""从数据库获取该邮箱该用途下最新且未过期、未使用的验证码记录"""
now = utcnow()
return (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.is_used.is_(False),
EmailVerificationCode.expires_at >= now,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""限制同一邮箱同一用途验证码的发送频率。"""
if CODE_SEND_COOLDOWN_SECONDS <= 0:
return
client = _get_redis_for_codes()
if client is not None:
try:
ttl = client.ttl(_redis_cooldown_key(email, purpose))
if ttl is not None and ttl > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {ttl}s before requesting another verification code",
headers={"Retry-After": str(ttl)},
)
if _is_redis_only():
return
except HTTPException:
raise
except Exception:
# redis failed during cooldown check, fallback to DB
pass
if _is_redis_only():
# Even if redis_only, we allow it to fallthrough if it's down.
# This aligns with our fallback logic.
try:
_require_redis_for_codes()
return
except HTTPException:
pass # fallback to db check
latest_record = (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
if not latest_record:
return
now = utcnow()
record_time = latest_record.created_at
if record_time.tzinfo is None:
record_time = record_time.replace(tzinfo=timezone.utc)
elapsed_seconds = (now - record_time).total_seconds()
if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS:
return
retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {retry_after_seconds}s before requesting another verification code",
headers={"Retry-After": str(retry_after_seconds)},
)
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse(
@@ -348,219 +184,115 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
@router.post("/register/send-code", response_model=MessageResponse)
async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)):
"""发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送"""
async def send_register_code(
payload: RegisterCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER)
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
)
code_hash = code_record.code_hash
else:
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.REGISTER,
code_hash=code_hash,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
try:
email_sent = await send_html_email(
code = service.send_code(email, VerificationPurpose.REGISTER)
await send_html_email(
to_email=email,
subject=f"{code}】InsightRadar 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
html_content=_build_verification_email(
code, "注册", REGISTER_CODE_EXPIRE_MINUTES
),
)
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e))
except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
# also clear cooldown if possible, so user can retry immediately
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code=500,
detail=f"Failed to send verification code: {e}",
)
if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent")
@router.post("/login/send-code", response_model=MessageResponse)
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
"""发送登录验证码:仅对已注册用户发送"""
async def send_login_code(
payload: LoginCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN)
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
)
code_hash = code_record.code_hash
else:
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email is not registered",
)
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.LOGIN,
code_hash=code_hash,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
try:
email_sent = await send_html_email(
code = service.send_code(email, VerificationPurpose.LOGIN)
await send_html_email(
to_email=email,
subject=f"{code}】InsightRadar 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
html_content=_build_verification_email(
code, "登录", LOGIN_CODE_EXPIRE_MINUTES
),
)
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=429, detail=str(e))
except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code=500,
detail=f"Failed to send verification code: {e}",
)
if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent")
@router.post(
"/register",
response_model=AuthTokenResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
async def register(
payload: RegisterRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
redis_result = _verify_code_with_redis(
email,
VerificationPurpose.REGISTER,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
)
code_record = None
if redis_result is False:
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.REGISTER,
code=payload.verification_code,
)
except CodeExpiredError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
except CodeInvalidError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
if redis_result is None:
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
if not verify_verification_code(payload.verification_code, code_record.code_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
except TooManyCodeRequestsError:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
now = utcnow()
nickname = payload.nickname or email.split("@")[0]
user = AppUser(
email=email,
password_hash=hash_password(payload.password),
@@ -569,16 +301,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
)
db.add(user)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
db.refresh(user)
return _build_auth_response(user)
@router.post("/login", response_model=AuthTokenResponse)
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""密码登录"""
@@ -595,49 +322,40 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login/code", response_model=AuthTokenResponse)
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
"""验证码登录:Redis 校验优先,失败则从数据库兜底"""
async def login_with_code(
payload: LoginWithCodeRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
redis_result = _verify_code_with_redis(
email,
VerificationPurpose.LOGIN,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
)
code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
if redis_result is None:
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
)
if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
if not verify_verification_code(payload.verification_code, code_record.code_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.LOGIN,
code=payload.verification_code,
)
except CodeExpiredError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except CodeInvalidError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except TooManyCodeRequestsError:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts",
)
return _build_auth_response(user)