feat: 实现邮箱验证码注册/登录功能

- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出)
- 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途
- 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True
- 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式
- 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时)
- 前端: 登录页增加密码登录/验证码登录双Tab切换
- 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-09 18:49:47 +08:00
co-authored by Claude Opus 4.6
parent 864af19011
commit d4081345f7
18 changed files with 592 additions and 89 deletions
+2 -1
View File
@@ -100,6 +100,7 @@ async def create_user(
password: str,
name: str,
role: UserRole,
is_verified: bool = False,
) -> User:
"""创建用户"""
user_id = generate_id("U")
@@ -112,7 +113,7 @@ async def create_user(
name=name,
role=role,
is_active=True,
is_verified=False,
is_verified=is_verified,
)
db.add(user)
+105
View File
@@ -0,0 +1,105 @@
"""
邮件发送服务
开发环境:将验证码输出到控制台(不实际发送)。
生产环境:通过 SMTP 发送邮件。
"""
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.config import settings
logger = logging.getLogger(__name__)
def _build_verification_email(to_email: str, code: str, purpose: str) -> MIMEMultipart:
"""构建验证码邮件"""
purpose_text = {
"register": "注册账号",
"login": "登录",
"reset_password": "重置密码",
}.get(purpose, "操作")
subject = f"{settings.APP_NAME}{purpose_text}验证码"
html = f"""
<div style="max-width: 480px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
<div style="background: linear-gradient(135deg, #6366F1, #4F46E5); padding: 32px; border-radius: 12px 12px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">{settings.APP_NAME}</h1>
</div>
<div style="background: #ffffff; padding: 32px; border: 1px solid #E5E7EB; border-top: none; border-radius: 0 0 12px 12px;">
<p style="color: #374151; font-size: 16px; margin: 0 0 16px;">您好,</p>
<p style="color: #374151; font-size: 16px; margin: 0 0 24px;">
您正在{purpose_text},验证码为:
</p>
<div style="background: #F3F4F6; padding: 20px; border-radius: 8px; text-align: center; margin: 0 0 24px;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #4F46E5;">{code}</span>
</div>
<p style="color: #6B7280; font-size: 14px; margin: 0 0 8px;">
验证码 {settings.VERIFICATION_CODE_EXPIRE_MINUTES} 分钟内有效,请勿泄露给他人。
</p>
<p style="color: #9CA3AF; font-size: 12px; margin: 16px 0 0;">
如非本人操作,请忽略此邮件。
</p>
</div>
</div>
"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
msg["To"] = to_email
msg.attach(MIMEText(html, "html", "utf-8"))
return msg
def send_verification_email(to_email: str, code: str, purpose: str = "register") -> bool:
"""
发送验证码邮件。
开发环境下仅打印到控制台,不实际发送。
返回 True 表示成功。
"""
purpose_text = {
"register": "注册",
"login": "登录",
"reset_password": "重置密码",
}.get(purpose, "操作")
# 开发环境:仅打印到控制台
if settings.ENVIRONMENT == "development" or not settings.SMTP_HOST:
logger.info(
"\n"
"============================================\n"
" 邮箱验证码 (开发模式 - 未实际发送)\n"
" 收件人: %s\n"
" 用途: %s\n"
" 验证码: %s\n"
" 有效期: %d 分钟\n"
"============================================",
to_email, purpose_text, code,
settings.VERIFICATION_CODE_EXPIRE_MINUTES,
)
return True
# 生产环境:通过 SMTP 发送
try:
msg = _build_verification_email(to_email, code, purpose)
if settings.SMTP_USE_SSL:
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
else:
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.sendmail(settings.SMTP_USER, [to_email], msg.as_string())
server.quit()
logger.info("验证码邮件已发送: %s (%s)", to_email, purpose_text)
return True
except Exception:
logger.exception("发送验证码邮件失败: %s", to_email)
return False
+96
View File
@@ -0,0 +1,96 @@
"""
验证码服务
使用内存存储验证码,支持 TTL 自动过期。
生产环境建议替换为 Redis 存储。
"""
import secrets
import time
import logging
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
# 内存存储: { "email:purpose" -> (code, expire_timestamp) }
_code_store: dict[str, tuple[str, float]] = {}
# 发送频率限制: { "email:purpose" -> last_send_timestamp }
_rate_limit: dict[str, float] = {}
# 最小发送间隔(秒)
SEND_INTERVAL = 60
def _cleanup_expired() -> None:
"""清理过期的验证码"""
now = time.time()
expired_keys = [k for k, (_, exp) in _code_store.items() if now > exp]
for k in expired_keys:
del _code_store[k]
def generate_code(email: str, purpose: str = "register") -> tuple[str, Optional[str]]:
"""
生成验证码并存储。
返回 (code, error)。
error 为 None 表示成功,否则返回错误信息。
"""
_cleanup_expired()
key = f"{email}:{purpose}"
# 检查发送频率
now = time.time()
last_sent = _rate_limit.get(key, 0)
if now - last_sent < SEND_INTERVAL:
remaining = int(SEND_INTERVAL - (now - last_sent))
return "", f"发送过于频繁,请 {remaining} 秒后重试"
# 生成验证码
code = "".join(str(secrets.randbelow(10)) for _ in range(settings.VERIFICATION_CODE_LENGTH))
# 存储(带 TTL
expire_at = now + settings.VERIFICATION_CODE_EXPIRE_MINUTES * 60
_code_store[key] = (code, expire_at)
_rate_limit[key] = now
logger.info("验证码已生成: email=%s, purpose=%s", email, purpose)
return code, None
def verify_code(email: str, code: str, purpose: str = "register") -> bool:
"""
验证验证码是否正确。
验证成功后自动删除验证码(一次性使用)。
"""
_cleanup_expired()
key = f"{email}:{purpose}"
stored = _code_store.get(key)
if not stored:
return False
stored_code, expire_at = stored
# 已过期
if time.time() > expire_at:
del _code_store[key]
return False
# 验证码匹配
if stored_code == code:
del _code_store[key] # 一次性使用
return True
return False
def clear_all() -> None:
"""清除所有验证码(用于测试)"""
_code_store.clear()
_rate_limit.clear()