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

156 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户公开注册与邮箱验证计划
**状态**:待实施
**创建日期**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**(系统已有 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 / 手机号验证可直接复用):
```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 子 tabhost/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 → 测试发送