# 用户公开注册与邮箱验证计划 **状态**:待实施 **创建日期**: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/邮件发送基础设施。 客户旅程想从"打开浏览器→注册→验证→登录"开始走(见 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md)),就必须先把这条链路在代码里跑通。 注册策略(已确认): - 开放公开注册,任何人可在 `/register` 自助开通 - 默认角色 `viewer` - 邮箱验证后立即可登录(无需管理员审批) - 验证仅走 SMTP 邮件,6 位数字码,10 分钟 TTL ## 数据模型 `backend/app/models/user.py` 加两列: ```python 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 / 手机号验证可直接复用): ```python 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 发送,告警/摘要等后续可复用): ```python 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,自动跳 `/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 子 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`、`ResetPasswordRequest` 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 入口 文档同步在 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md) 落地。 ## 验证 - **后端单测**(仿 `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 → 测试发送