完善邮件验证与密码重置安全流程
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import random
|
||||
import secrets
|
||||
import smtplib
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.crud import user as user_crud
|
||||
from app.models.email_settings import EmailVerificationCode, EmailVerificationPurpose, SmtpSecurity, SystemEmailSettings
|
||||
from app.schemas.email_settings import EmailSettingsRead, EmailSettingsUpdate
|
||||
|
||||
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _parse_allowed_domains(value: str) -> list[str]:
|
||||
domains: list[str] = []
|
||||
for item in (value or "").replace(",", ",").split(","):
|
||||
domain = item.strip().lower().lstrip("@")
|
||||
if domain and domain not in domains:
|
||||
domains.append(domain)
|
||||
return domains or ["huapont.cn", "qq.com"]
|
||||
|
||||
|
||||
def _normalize_domain(value: str) -> str:
|
||||
return value.strip().lower().lstrip("@")
|
||||
|
||||
|
||||
def _domain_from_email(email: str) -> str:
|
||||
parts = _normalize_email(email).rsplit("@", 1)
|
||||
return parts[1] if len(parts) == 2 else ""
|
||||
|
||||
|
||||
def _as_aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
source = settings.SETTINGS_ENCRYPTION_KEY or settings.JWT_SECRET_KEY
|
||||
try:
|
||||
return Fernet(source.encode("utf-8"))
|
||||
except Exception:
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(source.encode("utf-8")).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
return _get_fernet().encrypt(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return _get_fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="邮件授权码解密失败") from exc
|
||||
|
||||
|
||||
async def list_email_settings(db: AsyncSession) -> list[SystemEmailSettings]:
|
||||
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.register_domain.asc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_email_settings(db: AsyncSession, register_domain: str | None = None) -> SystemEmailSettings | None:
|
||||
if register_domain:
|
||||
result = await db.execute(
|
||||
select(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
result = await db.execute(select(SystemEmailSettings).order_by(SystemEmailSettings.created_at.asc()))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_email_settings_for_email(db: AsyncSession, email: str) -> SystemEmailSettings | None:
|
||||
domain = _domain_from_email(email)
|
||||
return await get_email_settings(db, domain) if domain else None
|
||||
|
||||
|
||||
def to_email_settings_read(settings_row: SystemEmailSettings | None) -> EmailSettingsRead:
|
||||
if not settings_row:
|
||||
return EmailSettingsRead()
|
||||
return EmailSettingsRead(
|
||||
register_domain=settings_row.register_domain,
|
||||
smtp_host=settings_row.smtp_host,
|
||||
smtp_port=settings_row.smtp_port,
|
||||
smtp_security=settings_row.smtp_security.value,
|
||||
smtp_username=settings_row.smtp_username,
|
||||
smtp_password_configured=bool(settings_row.smtp_password_encrypted),
|
||||
sender_email=settings_row.sender_email,
|
||||
sender_name=settings_row.sender_name,
|
||||
allowed_register_domain=settings_row.allowed_register_domain,
|
||||
verification_code_ttl_minutes=settings_row.verification_code_ttl_minutes,
|
||||
send_cooldown_seconds=settings_row.send_cooldown_seconds,
|
||||
max_verify_attempts=settings_row.max_verify_attempts,
|
||||
updated_at=settings_row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_email_settings(
|
||||
db: AsyncSession,
|
||||
register_domain: str,
|
||||
payload: EmailSettingsUpdate,
|
||||
*,
|
||||
updated_by: uuid.UUID,
|
||||
) -> SystemEmailSettings:
|
||||
register_domain = _normalize_domain(register_domain)
|
||||
settings_row = await get_email_settings(db, register_domain)
|
||||
if not settings_row:
|
||||
settings_row = SystemEmailSettings(
|
||||
register_domain=register_domain,
|
||||
smtp_host=payload.smtp_host,
|
||||
smtp_port=payload.smtp_port,
|
||||
smtp_security=SmtpSecurity(payload.smtp_security),
|
||||
smtp_username=payload.smtp_username,
|
||||
smtp_password_encrypted=encrypt_secret(payload.smtp_password) if payload.smtp_password else None,
|
||||
sender_email=str(payload.sender_email),
|
||||
sender_name=payload.sender_name,
|
||||
allowed_register_domain=register_domain,
|
||||
verification_code_ttl_minutes=payload.verification_code_ttl_minutes,
|
||||
send_cooldown_seconds=payload.send_cooldown_seconds,
|
||||
max_verify_attempts=payload.max_verify_attempts,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
db.add(settings_row)
|
||||
else:
|
||||
settings_row.smtp_host = payload.smtp_host
|
||||
settings_row.smtp_port = payload.smtp_port
|
||||
settings_row.smtp_security = SmtpSecurity(payload.smtp_security)
|
||||
settings_row.smtp_username = payload.smtp_username
|
||||
if payload.smtp_password:
|
||||
settings_row.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
|
||||
settings_row.sender_email = str(payload.sender_email)
|
||||
settings_row.sender_name = payload.sender_name
|
||||
settings_row.allowed_register_domain = register_domain
|
||||
settings_row.verification_code_ttl_minutes = payload.verification_code_ttl_minutes
|
||||
settings_row.send_cooldown_seconds = payload.send_cooldown_seconds
|
||||
settings_row.max_verify_attempts = payload.max_verify_attempts
|
||||
settings_row.updated_by = updated_by
|
||||
await db.commit()
|
||||
await db.refresh(settings_row)
|
||||
return settings_row
|
||||
|
||||
|
||||
async def create_email_domain(db: AsyncSession, register_domain: str, *, updated_by: uuid.UUID) -> SystemEmailSettings:
|
||||
register_domain = _normalize_domain(register_domain)
|
||||
existing = await get_email_settings(db, register_domain)
|
||||
if existing:
|
||||
return existing
|
||||
settings_row = SystemEmailSettings(
|
||||
register_domain=register_domain,
|
||||
smtp_host="",
|
||||
smtp_port=465,
|
||||
smtp_security=SmtpSecurity.SSL,
|
||||
smtp_username="",
|
||||
smtp_password_encrypted=None,
|
||||
sender_email="",
|
||||
sender_name="CTMS 系统",
|
||||
allowed_register_domain=register_domain,
|
||||
verification_code_ttl_minutes=10,
|
||||
send_cooldown_seconds=60,
|
||||
max_verify_attempts=5,
|
||||
updated_by=updated_by,
|
||||
)
|
||||
db.add(settings_row)
|
||||
await db.commit()
|
||||
await db.refresh(settings_row)
|
||||
return settings_row
|
||||
|
||||
|
||||
async def delete_email_domain(db: AsyncSession, register_domain: str) -> None:
|
||||
await db.execute(
|
||||
delete(SystemEmailSettings).where(SystemEmailSettings.register_domain == _normalize_domain(register_domain))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def ensure_email_settings_ready(settings_row: SystemEmailSettings | None) -> SystemEmailSettings:
|
||||
if not settings_row:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务尚未配置")
|
||||
if not settings_row.smtp_host or not settings_row.smtp_username or not settings_row.sender_email:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮件服务配置不完整")
|
||||
if not settings_row.smtp_password_encrypted:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
|
||||
return settings_row
|
||||
|
||||
|
||||
def ensure_allowed_register_email(email: str, settings_row: SystemEmailSettings) -> None:
|
||||
domain = settings_row.register_domain
|
||||
if not _normalize_email(email).endswith(f"@{domain}"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"仅允许 @{domain} 邮箱注册")
|
||||
|
||||
|
||||
def _send_email_sync(
|
||||
settings_row: SystemEmailSettings,
|
||||
*,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
password = decrypt_secret(settings_row.smtp_password_encrypted)
|
||||
if not password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="SMTP 授权码尚未配置")
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr((settings_row.sender_name or "", settings_row.sender_email))
|
||||
message["To"] = to_email
|
||||
message.set_content(body)
|
||||
|
||||
if settings_row.smtp_security == SmtpSecurity.SSL:
|
||||
with smtplib.SMTP_SSL(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
|
||||
smtp.login(settings_row.smtp_username, password)
|
||||
smtp.send_message(message)
|
||||
else:
|
||||
with smtplib.SMTP(settings_row.smtp_host, settings_row.smtp_port, timeout=15) as smtp:
|
||||
if settings_row.smtp_security == SmtpSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
smtp.login(settings_row.smtp_username, password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
async def send_email(
|
||||
settings_row: SystemEmailSettings,
|
||||
*,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(_send_email_sync, settings_row, to_email=to_email, subject=subject, body=body)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ssl.SSLError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=(
|
||||
"邮件发送失败:SMTP 安全协议或端口不匹配,"
|
||||
"请确认当前后缀的 SMTP 地址、端口和安全协议。"
|
||||
"SSL 通常使用 465,STARTTLS 通常使用 587。"
|
||||
),
|
||||
) from exc
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="邮件发送失败:SMTP 账号或授权码不正确",
|
||||
) from exc
|
||||
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPDataError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邮箱不存在或无法接收邮件,请检查邮箱地址",
|
||||
) from exc
|
||||
except (smtplib.SMTPConnectError, TimeoutError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="邮件发送失败:无法连接 SMTP 服务器,请检查 SMTP 地址、端口和网络连通性",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"邮件发送失败:{exc}") from exc
|
||||
|
||||
|
||||
async def send_test_email(db: AsyncSession, register_domain: str, recipient_email: str) -> None:
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings(db, register_domain))
|
||||
await send_email(
|
||||
settings_row,
|
||||
to_email=recipient_email,
|
||||
subject="CTMS 邮件服务测试",
|
||||
body="这是一封来自 CTMS 系统的邮件服务测试邮件。收到此邮件说明 SMTP 配置可用。",
|
||||
)
|
||||
|
||||
|
||||
async def send_register_code(db: AsyncSession, email: str) -> None:
|
||||
email = _normalize_email(email)
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
ensure_allowed_register_email(email, settings_row)
|
||||
if await user_crud.get_by_email(db, email):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||
|
||||
await _send_verification_code(
|
||||
db,
|
||||
settings_row=settings_row,
|
||||
email=email,
|
||||
purpose=EmailVerificationPurpose.REGISTER,
|
||||
subject="CTMS 注册邮箱验证码",
|
||||
body_factory=lambda code: (
|
||||
f"您的 CTMS 注册验证码是:{code}\n\n"
|
||||
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
|
||||
"如非本人操作,请忽略此邮件。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def send_password_reset_code(db: AsyncSession, email: str) -> None:
|
||||
email = _normalize_email(email)
|
||||
db_user = await user_crud.get_by_email(db, email)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该邮箱未注册")
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
await _send_verification_code(
|
||||
db,
|
||||
settings_row=settings_row,
|
||||
email=email,
|
||||
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||
subject="CTMS 密码重置验证码",
|
||||
body_factory=lambda code: (
|
||||
f"您的 CTMS 密码重置验证码是:{code}\n\n"
|
||||
f"验证码 {settings_row.verification_code_ttl_minutes} 分钟内有效。"
|
||||
"如非本人操作,请尽快联系系统管理员。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def send_password_reset_link(db: AsyncSession, email: str, *, frontend_origin: str) -> None:
|
||||
email = _normalize_email(email)
|
||||
db_user = await user_crud.get_by_email(db, email)
|
||||
if not db_user:
|
||||
return
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
now = datetime.now(timezone.utc)
|
||||
latest_result = await db.execute(
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||
)
|
||||
.order_by(desc(EmailVerificationCode.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="重置邮件发送过于频繁,请稍后再试")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = _hash_reset_token(token)
|
||||
record = EmailVerificationCode(
|
||||
email=email,
|
||||
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||
code_hash=token_hash,
|
||||
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
|
||||
origin = frontend_origin.rstrip("/") if frontend_origin else ""
|
||||
reset_link = f"{origin}/forgot-password?token={token}" if origin else f"/forgot-password?token={token}"
|
||||
await send_email(
|
||||
settings_row,
|
||||
to_email=email,
|
||||
subject="CTMS 密码重置链接",
|
||||
body=(
|
||||
"请点击以下链接重置您的 CTMS 登录密码:\n\n"
|
||||
f"{reset_link}\n\n"
|
||||
f"链接 {settings_row.verification_code_ttl_minutes} 分钟内有效,且只能使用一次。"
|
||||
"如非本人操作,请忽略此邮件并尽快联系系统管理员。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _hash_reset_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _send_verification_code(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
settings_row: SystemEmailSettings,
|
||||
email: str,
|
||||
purpose: EmailVerificationPurpose,
|
||||
subject: str,
|
||||
body_factory,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
latest_result = await db.execute(
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
)
|
||||
.order_by(desc(EmailVerificationCode.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
if latest and latest.created_at and _as_aware(latest.created_at) + timedelta(seconds=settings_row.send_cooldown_seconds) > now:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="验证码发送过于频繁,请稍后再试")
|
||||
|
||||
code = f"{random.SystemRandom().randint(0, 999999):06d}"
|
||||
record = EmailVerificationCode(
|
||||
email=email,
|
||||
purpose=purpose,
|
||||
code_hash=hash_password(code),
|
||||
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
|
||||
await send_email(
|
||||
settings_row,
|
||||
to_email=email,
|
||||
subject=subject,
|
||||
body=body_factory(code),
|
||||
)
|
||||
|
||||
|
||||
async def verify_register_code(db: AsyncSession, email: str, code: str) -> bool:
|
||||
email = _normalize_email(email)
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
ensure_allowed_register_email(email, settings_row)
|
||||
await _verify_email_code(
|
||||
db,
|
||||
settings_row=settings_row,
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=EmailVerificationPurpose.REGISTER,
|
||||
mark_verified=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def verify_password_reset_code(db: AsyncSession, email: str, code: str) -> str:
|
||||
email = _normalize_email(email)
|
||||
if not await user_crud.get_by_email(db, email):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
await _verify_email_code(
|
||||
db,
|
||||
settings_row=settings_row,
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||
mark_verified=True,
|
||||
commit=False,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
EmailVerificationCode(
|
||||
email=email,
|
||||
purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||
code_hash=_hash_reset_token(token),
|
||||
expires_at=now + timedelta(minutes=settings_row.verification_code_ttl_minutes),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def reset_password_with_code(db: AsyncSession, email: str, code: str, password: str) -> None:
|
||||
email = _normalize_email(email)
|
||||
db_user = await user_crud.get_by_email(db, email)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||
settings_row = ensure_email_settings_ready(await get_email_settings_for_email(db, email))
|
||||
await _verify_email_code(
|
||||
db,
|
||||
settings_row=settings_row,
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=EmailVerificationPurpose.PASSWORD_RESET,
|
||||
mark_verified=True,
|
||||
commit=False,
|
||||
)
|
||||
db_user.password_hash = hash_password(password)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def reset_password_with_token(db: AsyncSession, token: str, password: str) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
token_hash = _hash_reset_token(token)
|
||||
result = await db.execute(
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.code_hash == token_hash,
|
||||
EmailVerificationCode.purpose == EmailVerificationPurpose.PASSWORD_RESET_LINK,
|
||||
EmailVerificationCode.verified_at.is_(None),
|
||||
)
|
||||
.order_by(desc(EmailVerificationCode.created_at))
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record or record.verified_at is not None or _as_aware(record.expires_at) < now:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||
|
||||
db_user = await user_crud.get_by_email(db, record.email)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||
|
||||
record.verified_at = now
|
||||
db_user.password_hash = hash_password(password)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _verify_email_code(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
settings_row: SystemEmailSettings,
|
||||
email: str,
|
||||
code: str,
|
||||
purpose: EmailVerificationPurpose,
|
||||
mark_verified: bool,
|
||||
commit: bool = True,
|
||||
) -> EmailVerificationCode:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
)
|
||||
.order_by(desc(EmailVerificationCode.created_at))
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if (
|
||||
not record
|
||||
or record.verified_at is not None
|
||||
or _as_aware(record.expires_at) < now
|
||||
or record.attempt_count >= settings_row.max_verify_attempts
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||
record.attempt_count += 1
|
||||
if not verify_password(code, record.code_hash):
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||
if mark_verified:
|
||||
record.verified_at = now
|
||||
if commit:
|
||||
await db.commit()
|
||||
return record
|
||||
|
||||
|
||||
async def ensure_register_email_verified(db: AsyncSession, email: str) -> None:
|
||||
email = _normalize_email(email)
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(EmailVerificationCode)
|
||||
.where(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == EmailVerificationPurpose.REGISTER,
|
||||
EmailVerificationCode.verified_at.is_not(None),
|
||||
EmailVerificationCode.expires_at >= now,
|
||||
)
|
||||
.order_by(desc(EmailVerificationCode.verified_at))
|
||||
.limit(1)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先完成邮箱验证码校验")
|
||||
Reference in New Issue
Block a user