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

@@ -5,6 +5,7 @@
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional, Tuple
@@ -17,10 +18,22 @@ except ImportError:
AIOSMTPLIB_AVAILABLE = False
aiosmtplib = None
def _create_ssl_context() -> ssl.SSLContext:
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
try:
import certifi
context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
context = ssl.create_default_context()
return context
from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.services.system.config import ConfigService
from src.services.system.config import SystemConfigService
from .email_template import EmailTemplate
@@ -28,6 +41,9 @@ from .email_template import EmailTemplate
class EmailSenderService:
"""邮件发送服务"""
# SMTP 超时配置(秒)
SMTP_TIMEOUT = 30
@staticmethod
def _get_smtp_config(db: Session) -> dict:
"""
@@ -39,15 +55,25 @@ class EmailSenderService:
Returns:
SMTP 配置字典
"""
# 获取加密的密码并解密
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
smtp_password = None
if encrypted_password:
try:
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
except Exception:
# 解密失败,可能是旧的未加密密码,直接使用
smtp_password = encrypted_password
config = {
"smtp_host": ConfigService.get_config(db, "smtp_host"),
"smtp_port": ConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": ConfigService.get_config(db, "smtp_user"),
"smtp_password": ConfigService.get_config(db, "smtp_password"),
"smtp_use_tls": ConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": ConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": ConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": ConfigService.get_config(db, "smtp_from_name", default="Aether"),
"smtp_host": SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_from_email": SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
}
return config
@@ -96,16 +122,18 @@ class EmailSenderService:
return False, error
# 生成邮件内容
app_name = ConfigService.get_config(db, "smtp_from_name", default="Aether")
support_email = ConfigService.get_config(db, "smtp_support_email")
# 优先使用 email_app_name否则回退到 smtp_from_name
app_name = SystemConfigService.get_config(db, "email_app_name", default=None)
if not app_name:
app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether")
html_body = EmailTemplate.get_verification_code_html(
code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
)
text_body = EmailTemplate.get_verification_code_text(
code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
)
subject = EmailTemplate.get_subject("verification")
subject = EmailTemplate.get_subject("verification", db=db)
# 发送邮件
return await EmailSenderService._send_email(
@@ -179,14 +207,17 @@ class EmailSenderService:
message.attach(MIMEText(html_body, "html", "utf-8"))
# 发送邮件
ssl_context = _create_ssl_context()
if config["smtp_use_ssl"]:
await aiosmtplib.send(
message,
hostname=config["smtp_host"],
port=config["smtp_port"],
use_tls=True,
tls_context=ssl_context,
username=config["smtp_user"],
password=config["smtp_password"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
await aiosmtplib.send(
@@ -194,8 +225,10 @@ class EmailSenderService:
hostname=config["smtp_host"],
port=config["smtp_port"],
start_tls=config["smtp_use_tls"],
tls_context=ssl_context if config["smtp_use_tls"] else None,
username=config["smtp_user"],
password=config["smtp_password"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
logger.info(f"验证码邮件发送成功: {to_email}")
@@ -270,13 +303,23 @@ class EmailSenderService:
# 连接 SMTP 服务器
server = None
ssl_context = _create_ssl_context()
try:
if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"])
server = smtplib.SMTP_SSL(
config["smtp_host"],
config["smtp_port"],
context=ssl_context,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
server = smtplib.SMTP(config["smtp_host"], config["smtp_port"])
server = smtplib.SMTP(
config["smtp_host"],
config["smtp_port"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
if config["smtp_use_tls"]:
server.starttls()
server.starttls(context=ssl_context)
# 登录
if config["smtp_user"] and config["smtp_password"]:
@@ -326,18 +369,24 @@ class EmailSenderService:
return False, error
try:
ssl_context = _create_ssl_context()
if AIOSMTPLIB_AVAILABLE:
# 使用异步方式测试
# 注意: use_tls=True 表示隐式 SSL (端口 465)
# start_tls=True 表示 STARTTLS (端口 587)
use_ssl = config["smtp_use_ssl"]
use_starttls = config["smtp_use_tls"] and not use_ssl
smtp = aiosmtplib.SMTP(
hostname=config["smtp_host"],
port=config["smtp_port"],
use_tls=config["smtp_use_ssl"],
use_tls=use_ssl,
start_tls=use_starttls,
tls_context=ssl_context if (use_ssl or use_starttls) else None,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
await smtp.connect()
if config["smtp_use_tls"] and not config["smtp_use_ssl"]:
await smtp.starttls()
if config["smtp_user"] and config["smtp_password"]:
await smtp.login(config["smtp_user"], config["smtp_password"])
@@ -345,11 +394,20 @@ class EmailSenderService:
else:
# 使用同步方式测试
if config["smtp_use_ssl"]:
server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"])
server = smtplib.SMTP_SSL(
config["smtp_host"],
config["smtp_port"],
context=ssl_context,
timeout=EmailSenderService.SMTP_TIMEOUT,
)
else:
server = smtplib.SMTP(config["smtp_host"], config["smtp_port"])
server = smtplib.SMTP(
config["smtp_host"],
config["smtp_port"],
timeout=EmailSenderService.SMTP_TIMEOUT,
)
if config["smtp_use_tls"]:
server.starttls()
server.starttls(context=ssl_context)
if config["smtp_user"] and config["smtp_password"]:
server.login(config["smtp_user"], config["smtp_password"])
@@ -360,6 +418,56 @@ class EmailSenderService:
return True, None
except Exception as e:
error_msg = f"SMTP 连接测试失败: {str(e)}"
logger.error(error_msg)
error_msg = _translate_smtp_error(str(e))
logger.error(f"SMTP 连接测试失败: {error_msg}")
return False, error_msg
def _translate_smtp_error(error: str) -> str:
"""将 SMTP 错误信息转换为用户友好的中文提示"""
error_lower = error.lower()
# 认证相关错误
if "username and password not accepted" in error_lower:
return "用户名或密码错误,请检查 SMTP 凭据"
if "authentication failed" in error_lower:
return "认证失败,请检查用户名和密码"
if "invalid credentials" in error_lower or "badcredentials" in error_lower:
return "凭据无效,请检查用户名和密码"
if "smtp auth extension is not supported" in error_lower:
return "服务器不支持认证,请尝试使用 TLS 或 SSL 加密"
# 连接相关错误
if "connection refused" in error_lower:
return "连接被拒绝,请检查服务器地址和端口"
if "connection timed out" in error_lower or "timed out" in error_lower:
return "连接超时,请检查网络或服务器地址"
if "name or service not known" in error_lower or "getaddrinfo failed" in error_lower:
return "无法解析服务器地址,请检查 SMTP 服务器地址"
if "network is unreachable" in error_lower:
return "网络不可达,请检查网络连接"
# SSL/TLS 相关错误
if "certificate verify failed" in error_lower:
return "SSL 证书验证失败,请检查服务器证书或尝试其他加密方式"
if "ssl" in error_lower and "wrong version" in error_lower:
return "SSL 版本不匹配,请尝试其他加密方式"
if "starttls" in error_lower:
return "STARTTLS 握手失败,请检查加密设置"
# 其他常见错误
if "sender address rejected" in error_lower:
return "发件人地址被拒绝,请检查发件人邮箱设置"
if "relay access denied" in error_lower:
return "中继访问被拒绝,请检查 SMTP 服务器配置"
# 返回原始错误(简化格式)
# 去掉错误码前缀,如 "(535, '5.7.8 ..."
if error.startswith("(") and "'" in error:
# 提取引号内的内容
start = error.find("'") + 1
end = error.rfind("'")
if start > 0 and end > start:
return error[start:end].replace("\\n", " ").strip()
return error

View File

@@ -0,0 +1,442 @@
"""
邮件模板
提供验证码邮件的 HTML 和纯文本模板,支持从数据库加载自定义模板
"""
import html
import re
from html.parser import HTMLParser
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from src.services.system.config import SystemConfigService
class HTMLToTextParser(HTMLParser):
"""HTML 转纯文本解析器"""
def __init__(self):
super().__init__()
self.text_parts = []
self.skip_data = False
def handle_starttag(self, tag, attrs): # noqa: ARG002
if tag in ("script", "style", "head"):
self.skip_data = True
elif tag == "br":
self.text_parts.append("\n")
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
self.text_parts.append("\n")
def handle_endtag(self, tag):
if tag in ("script", "style", "head"):
self.skip_data = False
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "td"):
self.text_parts.append("\n")
def handle_data(self, data):
if not self.skip_data:
text = data.strip()
if text:
self.text_parts.append(text)
class EmailTemplate:
"""邮件模板类"""
# 模板类型定义
TEMPLATE_VERIFICATION = "verification"
TEMPLATE_PASSWORD_RESET = "password_reset"
# 支持的模板类型及其变量
TEMPLATE_TYPES = {
TEMPLATE_VERIFICATION: {
"name": "注册验证码",
"variables": ["app_name", "code", "expire_minutes", "email"],
"default_subject": "验证码",
},
TEMPLATE_PASSWORD_RESET: {
"name": "找回密码",
"variables": ["app_name", "reset_link", "expire_minutes", "email"],
"default_subject": "密码重置",
},
}
# Literary Tech 主题色 - 与网页保持一致
PRIMARY_COLOR = "#c96442" # book-cloth
PRIMARY_LIGHT = "#e4b2a0" # kraft
BG_WARM = "#faf9f5" # ivory-light
BG_MEDIUM = "#e9e6dc" # ivory-medium / cloud-medium
TEXT_DARK = "#3d3929" # slate-dark
TEXT_MUTED = "#6c695c" # slate-medium
BORDER_COLOR = "rgba(61, 57, 41, 0.12)"
@staticmethod
def get_default_verification_html() -> str:
"""获取默认的验证码邮件 HTML 模板 - Literary Tech 风格"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证码</title>
</head>
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
<!-- Header -->
<tr>
<td style="padding: 0 0 32px; text-align: center;">
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
{{app_name}}
</div>
</td>
</tr>
<!-- Main Card -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
<!-- Content -->
<tr>
<td style="padding: 48px 40px;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
验证码
</h1>
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
您正在注册账户,请使用以下验证码完成验证。
</p>
<!-- Code Box -->
<div style="background-color: #faf9f5; border: 1px solid rgba(61, 57, 41, 0.08); border-radius: 4px; padding: 32px 20px; text-align: center; margin-bottom: 32px;">
<div style="font-size: 40px; font-weight: 500; color: #c96442; letter-spacing: 12px; font-family: 'SF Mono', Monaco, 'Courier New', monospace;">
{{code}}
</div>
</div>
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
验证码将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 32px 0 0; text-align: center;">
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
如果这不是您的操作,请忽略此邮件。
</p>
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
此邮件由系统自动发送,请勿回复
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
@staticmethod
def get_default_password_reset_html() -> str:
"""获取默认的密码重置邮件 HTML 模板 - Literary Tech 风格"""
return """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>密码重置</title>
</head>
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
<!-- Header -->
<tr>
<td style="padding: 0 0 32px; text-align: center;">
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
{{app_name}}
</div>
</td>
</tr>
<!-- Main Card -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
<!-- Content -->
<tr>
<td style="padding: 48px 40px;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
重置密码
</h1>
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
您正在重置账户密码,请点击下方按钮完成操作。
</p>
<!-- Button -->
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 36px; background-color: #c96442; color: #ffffff; text-decoration: none; border-radius: 4px; font-size: 15px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
重置密码
</a>
</div>
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
链接将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 32px 0 0; text-align: center;">
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
如果您没有请求重置密码,请忽略此邮件。
</p>
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
此邮件由系统自动发送,请勿回复
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
@staticmethod
def get_default_template(template_type: str) -> Dict[str, str]:
"""
获取默认模板
Args:
template_type: 模板类型
Returns:
包含 subject 和 html 的字典
"""
if template_type == EmailTemplate.TEMPLATE_VERIFICATION:
return {
"subject": "验证码",
"html": EmailTemplate.get_default_verification_html(),
}
elif template_type == EmailTemplate.TEMPLATE_PASSWORD_RESET:
return {
"subject": "密码重置",
"html": EmailTemplate.get_default_password_reset_html(),
}
else:
return {"subject": "通知", "html": ""}
@staticmethod
def get_template(db: Session, template_type: str) -> Dict[str, str]:
"""
从数据库获取模板,如果不存在则返回默认模板
Args:
db: 数据库会话
template_type: 模板类型
Returns:
包含 subject 和 html 的字典
"""
default = EmailTemplate.get_default_template(template_type)
# 从数据库获取自定义模板
subject_key = f"email_template_{template_type}_subject"
html_key = f"email_template_{template_type}_html"
custom_subject = SystemConfigService.get_config(db, subject_key, default=None)
custom_html = SystemConfigService.get_config(db, html_key, default=None)
return {
"subject": custom_subject if custom_subject else default["subject"],
"html": custom_html if custom_html else default["html"],
}
@staticmethod
def render_template(template_html: str, variables: Dict[str, Any]) -> str:
"""
渲染模板,替换 {{variable}} 格式的变量
Args:
template_html: HTML 模板
variables: 变量字典
Returns:
渲染后的 HTML
"""
result = template_html
for key, value in variables.items():
# HTML 转义变量值,防止 XSS
escaped_value = html.escape(str(value))
# 替换 {{key}} 格式的变量
pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
result = re.sub(pattern, escaped_value, result)
return result
@staticmethod
def html_to_text(html: str) -> str:
"""
从 HTML 提取纯文本
Args:
html: HTML 内容
Returns:
纯文本内容
"""
parser = HTMLToTextParser()
parser.feed(html)
text = " ".join(parser.text_parts)
# 清理多余空白
text = re.sub(r"\n\s*\n", "\n\n", text)
text = re.sub(r" +", " ", text)
return text.strip()
@staticmethod
def get_verification_code_html(
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取验证码邮件 HTML
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
db: 数据库会话(用于获取自定义模板)
**kwargs: 其他模板变量
Returns:
渲染后的 HTML
"""
app_name = kwargs.get("app_name", "Aether")
email = kwargs.get("email", "")
# 获取模板
if db:
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_VERIFICATION)
else:
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_VERIFICATION)
# 渲染变量
variables = {
"app_name": app_name,
"code": code,
"expire_minutes": expire_minutes,
"email": email,
}
return EmailTemplate.render_template(template["html"], variables)
@staticmethod
def get_verification_code_text(
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取验证码邮件纯文本(从 HTML 自动生成)
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
纯文本邮件内容
"""
html = EmailTemplate.get_verification_code_html(code, expire_minutes, db, **kwargs)
return EmailTemplate.html_to_text(html)
@staticmethod
def get_password_reset_html(
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取密码重置邮件 HTML
Args:
reset_link: 重置链接
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
渲染后的 HTML
"""
app_name = kwargs.get("app_name", "Aether")
email = kwargs.get("email", "")
# 获取模板
if db:
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_PASSWORD_RESET)
else:
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_PASSWORD_RESET)
# 渲染变量
variables = {
"app_name": app_name,
"reset_link": reset_link,
"expire_minutes": expire_minutes,
"email": email,
}
return EmailTemplate.render_template(template["html"], variables)
@staticmethod
def get_password_reset_text(
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
) -> str:
"""
获取密码重置邮件纯文本(从 HTML 自动生成)
Args:
reset_link: 重置链接
expire_minutes: 过期时间(分钟)
db: 数据库会话
**kwargs: 其他模板变量
Returns:
纯文本邮件内容
"""
html = EmailTemplate.get_password_reset_html(reset_link, expire_minutes, db, **kwargs)
return EmailTemplate.html_to_text(html)
@staticmethod
def get_subject(
template_type: str = "verification", db: Optional[Session] = None
) -> str:
"""
获取邮件主题
Args:
template_type: 模板类型
db: 数据库会话
Returns:
邮件主题
"""
if db:
template = EmailTemplate.get_template(db, template_type)
return template["subject"]
default_subjects = {
"verification": "验证码",
"welcome": "欢迎加入",
"password_reset": "密码重置",
}
return default_subjects.get(template_type, "通知")

View File

@@ -9,22 +9,23 @@ from datetime import datetime, timezone
from typing import Optional, Tuple
from src.clients.redis_client import get_redis_client
from src.config.settings import Config
from src.core.logger import logger
# 从环境变量加载配置
_config = Config()
class EmailVerificationService:
"""邮箱验证码服务"""
# Redis key 前缀
VERIFICATION_PREFIX = "email:verification:"
SEND_LIMIT_PREFIX = "email:send_limit:"
VERIFIED_PREFIX = "email:verified:"
# 默认配置
DEFAULT_CODE_EXPIRE_MINUTES = 30
DEFAULT_MAX_ATTEMPTS = 5
SEND_COOLDOWN_SECONDS = 60
SEND_LIMIT_PER_HOUR = 5
# 从环境变量读取配置
DEFAULT_CODE_EXPIRE_MINUTES = _config.verification_code_expire_minutes
SEND_COOLDOWN_SECONDS = _config.verification_send_cooldown
@staticmethod
def _generate_code() -> str:
@@ -40,7 +41,8 @@ class EmailVerificationService:
@staticmethod
async def send_verification_code(
email: str, expire_minutes: Optional[int] = None
email: str,
expire_minutes: Optional[int] = None,
) -> Tuple[bool, str, Optional[str]]:
"""
发送验证码生成并存储到 Redis
@@ -59,16 +61,6 @@ class EmailVerificationService:
return False, "系统错误", "Redis 服务不可用"
try:
# 检查发送频率限制
send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}"
send_count = await redis_client.get(send_limit_key)
if send_count:
send_count = int(send_count)
if send_count >= EmailVerificationService.SEND_LIMIT_PER_HOUR:
logger.warning(f"邮箱 {email} 发送验证码次数超限: {send_count}")
return False, "发送次数过多", "每小时最多发送 5 次验证码"
# 检查冷却时间
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
existing_data = await redis_client.get(verification_key)
@@ -90,7 +82,6 @@ class EmailVerificationService:
# 存储验证码数据
verification_data = {
"code": code,
"attempts": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
@@ -99,12 +90,6 @@ class EmailVerificationService:
verification_key, expire_time * 60, json.dumps(verification_data)
)
# 更新发送计数器1 小时过期)- 使用原子操作
current_count = await redis_client.incr(send_limit_key)
# 如果是第一次设置,需要设置过期时间
if current_count == 1:
await redis_client.expire(send_limit_key, 3600)
logger.info(f"验证码已生成并存储: {email}, 有效期: {expire_time} 分钟")
return True, code, None
@@ -140,25 +125,10 @@ class EmailVerificationService:
data = json.loads(data_str)
# 检查尝试次数
if data["attempts"] >= EmailVerificationService.DEFAULT_MAX_ATTEMPTS:
logger.warning(f"验证码尝试次数过多: {email}")
await redis_client.delete(verification_key)
return False, "验证码尝试次数过多,请重新发送"
# 增加尝试次数
data["attempts"] += 1
# 验证码比对 - 使用常量时间比较防止时序攻击
if not secrets.compare_digest(code, data["code"]):
# 更新尝试次数
ttl = await redis_client.ttl(verification_key)
if ttl > 0:
await redis_client.setex(verification_key, ttl, json.dumps(data))
remaining_attempts = EmailVerificationService.DEFAULT_MAX_ATTEMPTS - data["attempts"]
logger.warning(f"验证码错误: {email}, 剩余尝试次数: {remaining_attempts}")
return False, f"验证码错误,剩余尝试次数: {remaining_attempts}"
logger.warning(f"验证码错误: {email}")
return False, "验证码错误"
# 验证成功:删除验证码,标记邮箱已验证
await redis_client.delete(verification_key)
@@ -251,12 +221,10 @@ class EmailVerificationService:
try:
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}"
# 获取各个状态
verification_data = await redis_client.get(verification_key)
is_verified = await redis_client.exists(verified_key)
send_count = await redis_client.get(send_limit_key)
verification_ttl = await redis_client.ttl(verification_key)
verified_ttl = await redis_client.ttl(verified_key)
@@ -264,14 +232,12 @@ class EmailVerificationService:
"email": email,
"has_pending_code": bool(verification_data),
"is_verified": bool(is_verified),
"send_count_this_hour": int(send_count) if send_count else 0,
"code_expires_in": verification_ttl if verification_ttl > 0 else None,
"verified_expires_in": verified_ttl if verified_ttl > 0 else None,
}
if verification_data:
data = json.loads(verification_data)
status["attempts"] = data.get("attempts", 0)
status["created_at"] = data.get("created_at")
return status

View File

@@ -168,19 +168,28 @@ class SystemConfigService:
db, "default_provider", provider_name, "系统默认提供商,当用户未设置个人提供商时使用"
)
@staticmethod
def get_all_configs(db: Session) -> list:
# 敏感配置项,不返回实际值
SENSITIVE_KEYS = {"smtp_password"}
@classmethod
def get_all_configs(cls, db: Session) -> list:
"""获取所有系统配置"""
configs = db.query(SystemConfig).all()
return [
{
result = []
for config in configs:
item = {
"key": config.key,
"value": config.value,
"description": config.description,
"updated_at": config.updated_at.isoformat(),
}
for config in configs
]
# 对敏感配置,只返回是否已设置的标志,不返回实际值
if config.key in cls.SENSITIVE_KEYS:
item["value"] = None
item["is_set"] = bool(config.value)
else:
item["value"] = config.value
result.append(item)
return result
@classmethod
def delete_config(cls, db: Session, key: str) -> bool:

View File

@@ -1,238 +0,0 @@
"""
邮件模板
提供验证码邮件的 HTML 和纯文本模板
"""
from typing import Dict
class EmailTemplate:
"""邮件模板类"""
@staticmethod
def get_verification_code_html(code: str, expire_minutes: int = 30, **kwargs) -> str:
"""
获取验证码邮件 HTML 模板
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
**kwargs: 其他模板变量
Returns:
HTML 邮件内容
"""
app_name = kwargs.get("app_name", "Aether")
support_email = kwargs.get("support_email", "")
html = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱验证码</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
margin: 0;
padding: 0;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
padding: 30px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 28px;
font-weight: 600;
}}
.content {{
padding: 40px 30px;
}}
.greeting {{
font-size: 18px;
margin-bottom: 20px;
color: #333;
}}
.message {{
font-size: 16px;
color: #666;
margin-bottom: 30px;
line-height: 1.8;
}}
.code-container {{
background-color: #f8f9fa;
border: 2px dashed #667eea;
border-radius: 8px;
padding: 30px;
text-align: center;
margin: 30px 0;
}}
.code-label {{
font-size: 14px;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}}
.code {{
font-size: 36px;
font-weight: 700;
color: #667eea;
letter-spacing: 8px;
font-family: 'Courier New', Courier, monospace;
margin: 10px 0;
}}
.expire-info {{
font-size: 14px;
color: #999;
margin-top: 10px;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
font-size: 14px;
color: #856404;
}}
.footer {{
background-color: #f8f9fa;
padding: 20px 30px;
text-align: center;
font-size: 14px;
color: #999;
}}
.footer a {{
color: #667eea;
text-decoration: none;
}}
.divider {{
height: 1px;
background-color: #e9ecef;
margin: 30px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{app_name}</h1>
</div>
<div class="content">
<div class="greeting">您好!</div>
<div class="message">
感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程:
</div>
<div class="code-container">
<div class="code-label">验证码</div>
<div class="code">{code}</div>
<div class="expire-info">
验证码有效期:{expire_minutes} 分钟
</div>
</div>
<div class="warning">
<strong>安全提示:</strong>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>请勿将此验证码透露给任何人</li>
<li>如果您没有请求此验证码,请忽略此邮件</li>
<li>验证码在 {expire_minutes} 分钟后自动失效</li>
</ul>
</div>
<div class="divider"></div>
<div class="message" style="font-size: 14px;">
如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。
</div>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
{f'<p>需要帮助?联系我们:<a href="mailto:{support_email}">{support_email}</a></p>' if support_email else ''}
<p>&copy; {app_name}. All rights reserved.</p>
</div>
</div>
</body>
</html>
"""
return html.strip()
@staticmethod
def get_verification_code_text(code: str, expire_minutes: int = 30, **kwargs) -> str:
"""
获取验证码邮件纯文本模板
Args:
code: 验证码
expire_minutes: 过期时间(分钟)
**kwargs: 其他模板变量
Returns:
纯文本邮件内容
"""
app_name = kwargs.get("app_name", "Aether")
support_email = kwargs.get("support_email", "")
text = f"""
{app_name} - 邮箱验证码
{'=' * 50}
您好!
感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程:
验证码:{code}
验证码有效期:{expire_minutes} 分钟
{'=' * 50}
安全提示:
- 请勿将此验证码透露给任何人
- 如果您没有请求此验证码,请忽略此邮件
- 验证码在 {expire_minutes} 分钟后自动失效
{'=' * 50}
如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。
{f'联系邮箱:{support_email}' if support_email else ''}
此邮件由系统自动发送,请勿直接回复。
&copy; {app_name}. All rights reserved.
"""
return text.strip()
@staticmethod
def get_subject(template_type: str = "verification") -> str:
"""
获取邮件主题
Args:
template_type: 模板类型
Returns:
邮件主题
"""
subjects = {
"verification": "邮箱验证码 - 请完成验证",
"welcome": "欢迎加入 Aether",
"password_reset": "密码重置验证码",
}
return subjects.get(template_type, "Aether 通知")