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

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
+563
View File
@@ -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 通常使用 465STARTTLS 通常使用 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="请先完成邮箱验证码校验")