refactor: 重构邮箱验证模块并修复代码审查问题

- 重构: 将 verification 模块重命名为 email,目录结构更清晰
- 新增: 独立的邮件配置管理页面 (EmailSettings.vue)
- 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览)
- 新增: 查询验证状态 API,支持页面刷新后恢复验证流程
- 新增: 注册邮箱后缀白名单/黑名单限制功能
- 修复: 统一密码最小长度为 6 位(前后端一致)
- 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起
- 修复: 邮件模板变量添加 HTML 转义,防止 XSS
- 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件
- 优化: RegisterDialog 重写验证码输入组件,提升用户体验
- 优化: Input 组件支持 disableAutofill 属性
This commit is contained in:
fawney19
2026-01-01 02:10:19 +08:00
parent 11ded575d5
commit cddc22d2b3
21 changed files with 2373 additions and 808 deletions

View File

@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
from src.database import get_db
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
@@ -126,6 +127,52 @@ async def test_smtp(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 邮件模板 API --------
@router.get("/email/templates")
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
"""获取所有邮件模板(管理员)"""
adapter = AdminGetEmailTemplatesAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/email/templates/{template_type}")
async def get_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""获取指定类型的邮件模板(管理员)"""
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/email/templates/{template_type}")
async def update_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""更新邮件模板(管理员)"""
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/preview")
async def preview_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""预览邮件模板(管理员)"""
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/email/templates/{template_type}/reset")
async def reset_email_template(
template_type: str, request: Request, db: Session = Depends(get_db)
):
"""重置邮件模板为默认值(管理员)"""
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 系统设置适配器 --------
@@ -203,10 +250,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
class AdminGetSystemConfigAdapter(AdminApiAdapter):
key: str
# 敏感配置项,不返回实际值
SENSITIVE_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override]
value = SystemConfigService.get_config(context.db, self.key)
if value is None:
raise NotFoundException(f"配置项 '{self.key}' 不存在")
# 对敏感配置,只返回是否已设置的标志,不返回实际值
if self.key in self.SENSITIVE_KEYS:
return {"key": self.key, "value": None, "is_set": bool(value)}
return {"key": self.key, "value": value}
@@ -214,18 +267,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
class AdminSetSystemConfigAdapter(AdminApiAdapter):
key: str
# 需要加密存储的配置项
ENCRYPTED_KEYS = {"smtp_password"}
async def handle(self, context): # type: ignore[override]
payload = context.ensure_json_body()
value = payload.get("value")
# 对敏感配置进行加密
if self.key in self.ENCRYPTED_KEYS and value:
from src.core.crypto import crypto_service
value = crypto_service.encrypt(value)
config = SystemConfigService.set_config(
context.db,
self.key,
payload.get("value"),
value,
payload.get("description"),
)
# 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
return {
"key": config.key,
"value": config.value,
"value": display_value,
"description": config.description,
"updated_at": config.updated_at.isoformat(),
}
@@ -1096,28 +1162,40 @@ class AdminImportUsersAdapter(AdminApiAdapter):
class AdminTestSmtpAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""测试 SMTP 连接"""
from src.services.system.config import ConfigService
from src.services.verification.email_sender import EmailSenderService
from src.core.crypto import crypto_service
from src.services.system.config import SystemConfigService
from src.services.email.email_sender import EmailSenderService
db = context.db
payload = context.ensure_json_body() or {}
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
smtp_password = payload.get("smtp_password")
if not smtp_password:
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
if encrypted_password:
try:
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
except Exception:
# 解密失败,可能是旧的未加密密码
smtp_password = encrypted_password
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
config = {
"smtp_host": payload.get("smtp_host") or ConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port") or ConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user") or ConfigService.get_config(db, "smtp_user"),
"smtp_password": payload.get("smtp_password") or ConfigService.get_config(db, "smtp_password"),
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": payload.get("smtp_use_tls")
if payload.get("smtp_use_tls") is not None
else ConfigService.get_config(db, "smtp_use_tls", default=True),
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": payload.get("smtp_use_ssl")
if payload.get("smtp_use_ssl") is not None
else ConfigService.get_config(db, "smtp_use_ssl", default=False),
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": payload.get("smtp_from_email")
or ConfigService.get_config(db, "smtp_from_email"),
or SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": payload.get("smtp_from_name")
or ConfigService.get_config(db, "smtp_from_name", default="Aether"),
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
}
# 验证必要配置
@@ -1144,10 +1222,200 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
else:
return {
"success": False,
"message": f"SMTP 连接测试失败: {error_msg}"
"message": error_msg
}
except Exception as e:
return {
"success": False,
"message": f"SMTP 连接测试失败: {str(e)}"
"message": str(e)
}
# -------- 邮件模板适配器 --------
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
"""获取所有邮件模板"""
async def handle(self, context): # type: ignore[override]
db = context.db
templates = []
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
# 获取自定义模板或默认模板
template = EmailTemplate.get_template(db, template_type)
default_template = EmailTemplate.get_default_template(template_type)
# 检查是否使用了自定义模板
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
templates.append(
{
"type": template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
}
)
return {"templates": templates}
@dataclass
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
"""获取指定类型的邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
template = EmailTemplate.get_template(db, self.template_type)
default_template = EmailTemplate.get_default_template(self.template_type)
is_custom = (
template["subject"] != default_template["subject"]
or template["html"] != default_template["html"]
)
return {
"type": self.template_type,
"name": type_info["name"],
"variables": type_info["variables"],
"subject": template["subject"],
"html": template["html"],
"is_custom": is_custom,
"default_subject": default_template["subject"],
"default_html": default_template["html"],
}
@dataclass
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
"""更新邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body()
subject = payload.get("subject")
html = payload.get("html")
# 至少需要提供一个字段
if subject is None and html is None:
raise InvalidRequestException("请提供 subject 或 html")
# 保存模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
if subject is not None:
if subject:
SystemConfigService.set_config(db, subject_key, subject)
else:
# 空字符串表示删除自定义值,恢复默认
SystemConfigService.delete_config(db, subject_key)
if html is not None:
if html:
SystemConfigService.set_config(db, html_key, html)
else:
SystemConfigService.delete_config(db, html_key)
return {"message": "模板保存成功"}
@dataclass
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
"""预览邮件模板"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
payload = context.ensure_json_body() or {}
# 获取模板 HTML优先使用请求体中的否则使用数据库中的
html = payload.get("html")
if not html:
template = EmailTemplate.get_template(db, self.template_type)
html = template["html"]
# 获取预览变量
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
# 构建预览变量,使用请求中的值或默认示例值
preview_variables = {}
default_values = {
"app_name": SystemConfigService.get_config(db, "email_app_name")
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
"code": "123456",
"expire_minutes": "30",
"email": "example@example.com",
"reset_link": "https://example.com/reset?token=abc123",
}
for var in type_info["variables"]:
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
# 渲染模板
rendered_html = EmailTemplate.render_template(html, preview_variables)
return {
"html": rendered_html,
"variables": preview_variables,
}
@dataclass
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
"""重置邮件模板为默认值"""
template_type: str
async def handle(self, context): # type: ignore[override]
# 验证模板类型
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
db = context.db
# 删除自定义模板
subject_key = f"email_template_{self.template_type}_subject"
html_key = f"email_template_{self.template_type}_html"
SystemConfigService.delete_config(db, subject_key)
SystemConfigService.delete_config(db, html_key)
# 返回默认模板
default_template = EmailTemplate.get_default_template(self.template_type)
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
return {
"message": "模板已重置为默认值",
"template": {
"type": self.template_type,
"name": type_info["name"],
"subject": default_template["subject"],
"html": default_template["html"],
},
}

View File

@@ -2,7 +2,7 @@
认证相关API端点
"""
from typing import Optional
from typing import Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -26,6 +26,8 @@ from src.models.api import (
RegistrationSettingsResponse,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerificationStatusRequest,
VerificationStatusResponse,
VerifyEmailRequest,
VerifyEmailResponse,
)
@@ -33,12 +35,57 @@ 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.system.config import SystemConfigService
from src.services.user.service import UserService
from src.services.verification import EmailSenderService, EmailVerificationService
from src.services.email import EmailSenderService, EmailVerificationService
from src.utils.request_utils import get_client_ip, get_user_agent
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
"""
验证邮箱后缀是否允许注册
Args:
db: 数据库会话
email: 邮箱地址
Returns:
(是否允许, 错误信息)
"""
# 获取邮箱后缀限制配置
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
if mode == "none":
return True, None
# 获取邮箱后缀列表
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
if not suffix_list:
# 没有配置后缀列表时,不限制
return True, None
# 确保 suffix_list 是列表类型
if isinstance(suffix_list, str):
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
# 获取邮箱后缀
if "@" not in email:
return False, "邮箱格式无效"
email_suffix = email.split("@")[1].lower()
if mode == "whitelist":
# 白名单模式:只允许列出的后缀
if email_suffix not in suffix_list:
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
elif mode == "blacklist":
# 黑名单模式:拒绝列出的后缀
if email_suffix in suffix_list:
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
return True, None
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer()
pipeline = ApiRequestPipeline()
@@ -103,6 +150,13 @@ async def verify_email(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verification-status", response_model=VerificationStatusResponse)
async def verification_status(request: Request, db: Session = Depends(get_db)):
"""查询邮箱验证状态"""
adapter = AuthVerificationStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 适配器实现 ==============
@@ -242,16 +296,12 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
"""公开返回注册相关配置"""
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
)
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
return RegistrationSettingsResponse(
enable_registration=bool(enable_registration),
require_email_verification=bool(require_verification),
verification_code_expire_minutes=expire_minutes,
).model_dump()
@@ -287,8 +337,26 @@ class AuthRegisterAdapter(AuthPublicAdapter):
db.commit()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
# 检查邮箱后缀是否允许
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
if not suffix_allowed:
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
AuditService.log_event(
db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
ip_address=client_ip,
user_agent=user_agent,
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
)
db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
# 检查是否需要邮箱验证
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
if require_verification:
# 检查邮箱是否已验证
@@ -318,12 +386,15 @@ class AuthRegisterAdapter(AuthPublicAdapter):
metadata={"email": user.email, "username": user.username, "role": user.role.value},
)
# 注册成功后清除验证状态 - 在 commit 之前清理,避免竞态条件
if require_verification:
await EmailVerificationService.clear_verification(register_request.email)
db.commit()
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
if require_verification:
try:
await EmailVerificationService.clear_verification(register_request.email)
except Exception as e:
logger.warning(f"清理验证状态失败: {e}")
return RegisterResponse(
user_id=user.id,
email=user.email,
@@ -373,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
user = context.user
if not user.verify_password(old_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
if len(new_password) < 8:
raise InvalidRequestException("密码长度至少8")
if len(new_password) < 6:
raise InvalidRequestException("密码长度至少6")
user.set_password(new_password)
context.db.commit()
logger.info(f"用户修改密码: {user.email}")
@@ -447,25 +518,26 @@ class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
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,
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
)
# 生成并发送验证码
# 检查邮箱后缀是否允许
suffix_allowed, suffix_error = validate_email_suffix(db, email)
if not suffix_allowed:
logger.warning(f"邮箱后缀不允许: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
# 生成并发送验证码(使用服务中的默认配置)
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
email, expire_minutes=expire_minutes
email
)
if not success:
@@ -476,6 +548,7 @@ class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
)
# 发送邮件
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
email_success, email_error = await EmailSenderService.send_verification_code(
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
)
@@ -537,3 +610,54 @@ class AuthVerifyEmailAdapter(AuthPublicAdapter):
logger.info(f"邮箱验证成功: {email}")
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
class AuthVerificationStatusAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
"""查询邮箱验证状态"""
payload = context.ensure_json_body()
try:
status_request = VerificationStatusRequest.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 = status_request.email
# IP 速率限制检查验证状态查询20次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_status", limit=20
)
if not allowed:
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 获取验证状态
status_data = await EmailVerificationService.get_verification_status(email)
# 计算冷却剩余时间
cooldown_remaining = None
if status_data.get("has_pending_code") and status_data.get("created_at"):
from datetime import datetime, timezone
created_at = datetime.fromisoformat(status_data["created_at"])
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
if cooldown > 0:
cooldown_remaining = cooldown
return VerificationStatusResponse(
email=email,
has_pending_code=status_data.get("has_pending_code", False),
is_verified=status_data.get("is_verified", False),
cooldown_remaining=cooldown_remaining,
code_expires_in=status_data.get("code_expires_in"),
).model_dump()