Files
planet/docs/plans/user-registration-email-verification-plan.md
2026-05-12 17:15:02 +08:00

8.0 KiB
Raw Permalink Blame History

用户公开注册与邮箱验证计划

状态:待实施 创建日期2026-05-12 核心目标:给 Planet 增加公开注册流程 + 邮箱验证码 + 忘记密码,让客户无需管理员介入就能开通账号;同时把 SMTP 邮件作为可复用基础服务接入 /settings

背景

当前认证只暴露 /auth/login/auth/refresh/auth/logout/auth/mebackend/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(系统已有 Redistoken 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_settingssmtp 命名空间读配置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_allowedissue_codesend_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_hostsmtp_portsmtp_usernamesmtp_passwordsmtp_fromsmtp_from_namesmtp_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自动跳 /admin
  • frontend/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-email
  • frontend/src/pages/Settings/Settings.tsx — 新增 SMTP 子 tabhost/port/username/password/from/TLS + 测试发送按钮),用工作区里新建的 frontend/src/components/ConnectionTestInput/ 做连通测试输入
  • 路由表(frontend/src/App.tsxfrontend/src/router/*)— 加 /register/forgot-password/verify-email

关键文件清单

后端:

  • backend/app/models/user.py — 加字段
  • backend/app/schemas/user.py — 新增 UserRegisterVerifyCodeResetPasswordRequest schema
  • backend/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.tsx
  • frontend/src/pages/Settings/Settings.tsx
  • 路由文件

实施顺序

  1. 后端User 模型字段 + DDL 兜底
  2. 后端:services/otp.py(先纯单测跑通)
  3. 后端:services/email.py(用 MailHog 本地试发)
  4. 后端:/auth/register + /auth/verify-email + /auth/resend-code + 登录拦截
  5. 后端:/auth/forgot-password + /auth/reset-password
  6. 后端:/settings/smtp 命名空间 + 测试发送
  7. 前端:Settings.tsx 加 SMTP 子 tab
  8. 前端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 → 测试发送