8.0 KiB
用户公开注册与邮箱验证计划
状态:待实施
创建日期:2026-05-12
核心目标:给 Planet 增加公开注册流程 + 邮箱验证码 + 忘记密码,让客户无需管理员介入就能开通账号;同时把 SMTP 邮件作为可复用基础服务接入 /settings。
背景
当前认证只暴露 /auth/login、/auth/refresh、/auth/logout、/auth/me(backend/app/api/v1/auth.py),账号只能由 super_admin 在 /users 后台创建。User 模型 backend/app/models/user.py 没有 email_verified 字段,仓库也没有任何 SMTP/邮件发送基础设施。
客户旅程想从"打开浏览器→注册→验证→登录"开始走(见 文档受众分层重构计划),就必须先把这条链路在代码里跑通。
注册策略(已确认):
- 开放公开注册,任何人可在
/register自助开通 - 默认角色
viewer - 邮箱验证后立即可登录(无需管理员审批)
- 验证仅走 SMTP 邮件,6 位数字码,10 分钟 TTL
数据模型
backend/app/models/user.py 加两列:
email_verified = Column(Boolean, default=False, nullable=False)
pending_email = Column(String(255), nullable=True) # 改邮箱时临时落地待验证地址
迁移路径:仓库目前没看到 alembic 目录,沿用 backend/app/db/session.py 的初始化风格在启动时跑 ALTER TABLE users ADD COLUMN IF NOT EXISTS ...。先确认是否存在 alembic,若有则正规迁移。
不另建 verification_codes 表 — OTP 走 Redis(系统已有 Redis,token blacklist 也走 Redis):
key: otp:{purpose}:{email} purpose ∈ {register, verify_email, reset_password}
value: { code_hash: bcrypt, attempts: int, issued_at: ts }
TTL: 600 秒
{purpose}:{email} 同时配一个限流键 otp_rate:{purpose}:{email},TTL 60 秒,用于"60 秒内禁止重发"。
服务拆分
按项目 services/ 单职责风格拆两个:
backend/app/services/otp.py(通用 OTP 原语,未来 2FA / 手机号验证可直接复用):
async def issue_code(email: str, purpose: OtpPurpose) -> str # 生成 6 位、写 Redis、返回明码(调用方负责送达)
async def verify_code(email: str, purpose: OtpPurpose, code: str) -> bool # 校验并消耗
async def check_resend_allowed(email: str, purpose: OtpPurpose) -> None # 抛 RateLimited 异常
- 6 位数字,密码学随机
- Redis 存
bcrypt(code),不存明码 - 校验失败计数 ≥ 5 直接失效该 key
- 重发触发即失效旧 code
backend/app/services/email.py(通用 SMTP 发送,告警/摘要等后续可复用):
async def send_email(to: str, subject: str, html: str, text: str | None = None) -> None
async def send_verification_email(to: str, code: str, purpose: OtpPurpose) -> None # 模板封装
- 用
aiosmtplib异步发送 - 从
system_settings的smtp命名空间读配置(host/port/username/password/from/use_tls) - 未配置抛
EmailNotConfiguredError - 模板用简单 HTML + 纯文本双段,按
purpose切换文案
编排("签码 → 发邮件")在 api/v1/auth.py 端点里调两个服务,不在 service 内互相调用,保持单测可单独 mock。
后端端点
新增到 backend/app/api/v1/auth.py:
| 端点 | 入参 | 行为 |
|---|---|---|
POST /auth/register |
username, email, password |
用户名/邮箱查重 → 写 User is_active=True, email_verified=False, role="viewer" → 调 otp.issue_code(email, "register") → 调 email.send_verification_email |
POST /auth/verify-email |
email, code |
otp.verify_code → 置 email_verified=True → 直接返回 access/refresh token |
POST /auth/resend-code |
email, purpose |
check_resend_allowed → issue_code → send_verification_email |
POST /auth/forgot-password |
email |
即便邮箱不存在也返回 200(防枚举);存在则签 reset_password 码并发邮件 |
POST /auth/reset-password |
email, code, new_password |
verify_code(..., "reset_password") → user.set_password(new_password) |
/auth/login 改造:邮箱未验证用户登录返回 403 { code: "EMAIL_NOT_VERIFIED", email },前端拿到后跳验证页。
SMTP 设置
复用 backend/app/api/v1/settings.py 现有 setting store,新增 smtp 命名空间:
smtp_host、smtp_port、smtp_username、smtp_password、smtp_from、smtp_from_name、smtp_use_tls- 密码走与 collector 凭证相同的加密路径(看
backend/app/services/是否已有credentials_encryption之类工具,若有直接复用) POST /settings/smtp/test— 用当前未保存的入参试发一封到指定地址,不落库
未配置 SMTP 时 /auth/register 应返回明确错误 503 { code: "EMAIL_PROVIDER_NOT_CONFIGURED" },提示管理员先去 /settings 配 SMTP 或用 ./planet.sh createuser 兜底。
前端
新页面:
frontend/src/pages/Register/Register.tsx— 两步表单:(1) 用户名/邮箱/密码 (2) 6 位验证码;60s 重发冷却;验证成功写 token,自动跳/adminfrontend/src/pages/VerifyEmail/VerifyEmail.tsx— 给登录拦截EMAIL_NOT_VERIFIED时落地的页,仅"输码 + 重发"frontend/src/pages/ForgotPassword/ForgotPassword.tsx— 两步:(1) 输邮箱 (2) 输码 + 新密码
改动:
frontend/src/pages/Login/Login.tsx— 表单下加"注册账号"、"忘记密码"链接;接EMAIL_NOT_VERIFIED跳/verify-emailfrontend/src/pages/Settings/Settings.tsx— 新增 SMTP 子 tab(host/port/username/password/from/TLS + 测试发送按钮),用工作区里新建的frontend/src/components/ConnectionTestInput/做连通测试输入- 路由表(
frontend/src/App.tsx或frontend/src/router/*)— 加/register、/forgot-password、/verify-email
关键文件清单
后端:
backend/app/models/user.py— 加字段backend/app/schemas/user.py— 新增UserRegister、VerifyCode、ResetPasswordRequestschemabackend/app/api/v1/auth.py— 新端点 + 登录校验backend/app/services/otp.py(新)backend/app/services/email.py(新)backend/app/api/v1/settings.py— SMTP 命名空间 + 测试发送backend/app/core/config.py— SMTP 默认值/特性开关backend/app/db/session.py— DDL 兜底(若无 alembic)
前端:
frontend/src/pages/Register/Register.tsx(新)frontend/src/pages/VerifyEmail/VerifyEmail.tsx(新)frontend/src/pages/ForgotPassword/ForgotPassword.tsx(新)frontend/src/pages/Login/Login.tsxfrontend/src/pages/Settings/Settings.tsx- 路由文件
实施顺序
- 后端:User 模型字段 + DDL 兜底
- 后端:
services/otp.py(先纯单测跑通) - 后端:
services/email.py(用 MailHog 本地试发) - 后端:
/auth/register+/auth/verify-email+/auth/resend-code+ 登录拦截 - 后端:
/auth/forgot-password+/auth/reset-password - 后端:
/settings/smtp命名空间 + 测试发送 - 前端:
Settings.tsx加 SMTP 子 tab - 前端:Register / VerifyEmail / ForgotPassword 页 + Login 入口
文档同步在 文档受众分层重构计划 落地。
验证
- 后端单测(仿
backend/tests/test_settings_ai_provider.py):- 注册端点用户名/邮箱查重
- OTP 过期、错码计数、重发限流
- 邮箱未验证用户登录返回
EMAIL_NOT_VERIFIED - SMTP 未配置时注册端点返回
EMAIL_PROVIDER_NOT_CONFIGURED - 忘记密码对不存在邮箱仍返回 200
- 后端集测:本机起 MailHog 或 Mailtrap,把 SMTP 指到上面,跑 register → 收码 → verify → login 一遍
- 前端:
source ~/.zshrc && bun run build;启 dev server 走/register→/verify-email→/admin全流程,再试/forgot-password - 手测:新邮箱注册 → 收码 → 输错 → 重发 → 输对 → 登录 → 改密码 → 用新密码再登;管理员在
/settings改 SMTP → 测试发送