feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码

数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
  索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
  (漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落

凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
  encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
  secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文

注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效

页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停

修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量

验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
2026-09-15 17:32:35 +08:00
父节点 23799b4ea5
当前提交 df7db3582e
共修改 46 个文件,包含 4348 行新增和 953 行删除
+36 -5
查看文件
@@ -28,14 +28,18 @@ python manage.py serve --port 8848 # 或 docker compose up -d --
## 二、用示例数据开发,别用真实数据
`tools/demo_data.py` 会生成一份**完全合成**的数据集(假模型名、假 Prompt、偏斜的积分分布),
放在 `data/demo/` 下(该目录已在 `.gitignore` 内):
`tools/demo_data.py` 会生成一份**完全合成**的数据集(假模型名、假 Prompt、假 Cookie 串、
偏斜的积分分布),放在 `data/demo/` 下(该目录已在 `.gitignore` 内)。
它会造**两个账号**(`admin` 管理员 + `demo` 普通用户),好让你顺手验证多用户隔离:
```bash
python tools/demo_data.py # 默认 data/demo,管理员 admin/admin123
python tools/demo_data.py # 默认 data/demo;两个账号口令都是 admin123
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
```
> 目录里的 `usage.sqlite` **在容器里生成**再截图才是对的:宿主机跑会让启动日志印出
> `C:\Users\<用户名>\…`,那一行正好会出现在「日志管理」页的截图上。
这样你既能有一个「看起来像真的」的界面来调试,也不会把任何真实用量带进仓库或截图。
## 三、改动前请先跑一遍验证
@@ -45,14 +49,17 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
| # | 命令 | 覆盖什么 | 需要什么 |
|---|---|---|---|
| 1 | `python -m compileall -q workbuddy_portal manage.py tools` | 语法 | — |
| 2 | `python tools/smoke.py` | **99 项**离线断言:全页面只读渲染、模板残留检测、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848` | **56 项**真实 HTTP 断言,含登录/CSRF/开放重定向 | 一个运行中的服务 |
| 2 | `python tools/smoke.py` | **165 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848` | **83 项**真实 HTTP 断言,含登录/CSRF/开放重定向/验证码/安全响应头 | 一个运行中的服务 |
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror` | Playwright + Chromium |
| 5 | `docker compose up -d --build && docker compose ps` | 容器化路径 | Docker |
> `tools/shots.py` 是**最有价值的一层**:项目曾经出过「大屏整页全白」的 bug,
> 只有它抓到了(`smoke` 与 `check_live` 都放过了)。改前端务必跑。
`check_live.py` 与 `shots.py` 都接受 `--db <路径>`:给了之后它们会直接从库里读验证码答案,
从而**自动过掉登录页与注册页的验证码**——不然脚本会被验证码挡在门外。
这三支脚本都会打印 `RESULT: ok=N fail=0`,`fail` 不为 0 时退出码是 1,可直接接进 CI。
## 四、必须遵守的几条不变量
@@ -72,6 +79,30 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
6. **`.gitignore` 不支持行尾注释**——规则后跟 `# 注释` 会让整行失效。注释必须单独占一行,
改完用 `git check-ignore -v <file>` 逐条确认命中。
### 多用户相关的四条(v1.2.0 起)
7. **`uid` 必须是 `conn` 之后的第一个位置参数,且不给默认值。**
这是防越权的核心机制:漏传就直接 `TypeError`,而不是静默返回所有人的数据。
`query.*` / `collect.*` 全链路都遵循它。新写一个查询函数时请照做,**不要**加 `uid=0` 这种默认值。
8. **凭证不参与回落。** `db.NO_FALLBACK_KEYS = {"cookie", "user_agent"}`:
个人级没有值时**不许**落回实例级,否则等于拿别人的 Cookie 去采集(串号)。
新增任何「账号身份相关」的配置键,都要考虑是否该进这个集合。
9. **验证码答案只能放服务端。** 不要图省事塞进 `session`——Flask 的 session 是
「签名 + base64」而非加密,客户端能直接解开读到答案。下发给浏览器的只有随机 `captcha_id`;
且校验时**先删后判**(一次性)。同理,验证码图不要用 SVG 渲染,那玩意是文本。
10. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。**
`ALTER TABLE … RENAME TO` 会**把索引一起带走**,后续 `CREATE INDEX IF NOT EXISTS`
就变成空操作,新表会零索引。本项目在迁移前先调 `_drop_all_user_indexes()`,
并把 `ALTER TABLE … ADD COLUMN` 放在 `executescript` 之前。
### 加密与验证码这两块(零依赖约束)
11. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」
—— 所以 ChaCha20、HMAC、PNG 编码、点阵字模都是手写的。想引 `cryptography` 或 `Pillow`
之前先想清楚:这会让「下载即跑」的卖点消失。
12. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀
原样返回(兼容历史明文),但**校验不过就抛 `DecryptError`**。静默降级会让加密形同虚设。
## 五、代码风格
- 遵循 PEP 8;行宽 100。