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:
co-authored by
Claude Opus 4.6
parent
864af19011
commit
d4081345f7
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user