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 行删除
+159 -13
查看文件
@@ -21,6 +21,22 @@ Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8
2. 服务器防火墙有没有放行该端口;
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
### Q:登录成功却立刻又跳回登录页(循环)
99% 是 `WB_COOKIE_SECURE` 被开成了 `1`,而你在用 **HTTP** 访问。
会话 Cookie 加了 `Secure` 属性后,浏览器**只在 HTTPS 下才回传它**——于是服务端每次收到
请求都看不到会话,判定未登录,再把你送回登录页。日志里看起来是「一直在登录」。
```bash
# .env 里改回 0(纯局域网 HTTP 部署的正确值),然后重建容器
WB_COOKIE_SECURE=0
docker compose up -d
```
> 只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
> 注意 `TZ`、`WB_COOKIE_SECURE` 都是**环境变量**,`docker compose restart` 不生效,要 `up -d`。
### Q:容器 `unhealthy` 但 `Up`
```bash
@@ -89,10 +105,26 @@ python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.r
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
填进「配置管理 → 凭证」,保存后按区间补采。
填进「配置管理 → **我的云端凭证**」,保存后按区间补采。
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
### Q:采集被跳过,日志写 `no_cookie`
这个账号**还没配 Cookie**。多用户下每个账号要各自配一次——系统**不会**拿别人的 Cookie
替你采集(那会把两个人的数据混在一起)。到「配置管理 → 我的云端凭证」粘贴一份即可。
### Q:日志写 `cookie_broken` / 页面显示「无法解密」
数据库里的 Cookie 密文,用当前实例主密钥解不开了。通常是 `data/instance.json`
(存着 `cookie_key`)被删、被替换,或从别的机器拷了库过来。
**动作**:重新粘贴一次该账号的 Cookie,历史数据不受影响。
**预防**:备份时把 `data/instance.json` 和数据一起备份,别在容器之间混用。
> 这是**静态加密的正确行为**——密钥换了就该解不开;如果它「解不开也照样能用」,
> 那说明根本没加密。
### Q:采集成功但「新增 0 条」
大概率正常。看那一次的 `抓取` 条数:
@@ -195,6 +227,39 @@ docker compose exec portal python -c "from workbuddy_portal import db; print(db.
## 五、账号与权限
### Q:怎么开放/关闭自助注册
「配置管理 → 实例级设置 → 开放自助注册」(**仅管理员可见**)。
打开后登录页会出现「自助注册」链接,任何人填表即可建号;关掉后只能由管理员在
「用户管理 → 新建账号」里建。默认是**开放**。
### Q:注册被拒 / 提示来源已达上限
同一个 IP 每天默认只能注册 3 个账号(`register_max_per_ip`,范围 1 ~ 50)。
这条限制是防批量刷号用的;换个来源,或由管理员调大。
### Q:验证码一直不对
按这个顺序排查:
| 现象 | 原因 |
|---|---|
| 刚才还能用,第二次就错 | 验证码**一次性**,用一次即废;输错也要重新取图 |
| 输得慢一点就错 | 有效期 **5 分钟**,过期即失效 |
| 拿登录页的码去注册 | 两个 `purpose` 的验证码**互不通用** |
| 明明对了还是被拒 | 该来源已被锁定(连续失败 5 次 → 锁 10 分钟) |
**动作**:点验证码图片换一张重来。想少费眼力,让管理员把「验证码位数」保持在 4 位。
> 验证码答案只存在服务端 `captchas` 表:下发到浏览器的是一个随机 `captcha_id`,
> 校验后无论成败都立刻删除。所以**在网页源码里搜不到答案**,抓图也拿不到复用的码。
### Q:验证码能关掉吗
「配置管理 → 实例级设置 → 验证码策略」有 `always` / `adaptive` / `off` 三档。
`adaptive` 只在同一来源**连续失败 2 次后**才要验证码,对天天登录的人更友好。
`off` 会显著放大撞库与批量注册的风险,**只有在前面已有可信网关时才考虑**。
### Q:忘记管理员密码
```bash
@@ -207,20 +272,58 @@ docker compose exec portal python manage.py passwd admin 新密码
不传新密码时会用默认的 `admin123`——**别这么干**。
### Q:怎么给同事开只读账号
账号被停用了要顺便恢复启用,加 `--activate`:
「用户管理 → 新建账号」,**不要勾**「管理员」。
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
```bash
python manage.py passwd admin 新密码 --activate
```
想看现在有哪些账号、各自角色/状态/数据量/凭证状态:
```bash
python manage.py users
```
### Q:怎么给同事开账号
两种都行:
1. 让同事**自助注册**(需管理员开放注册);
2. 「用户管理 → 新建账号」,权限选**普通**(默认就是普通,管理员要显式选)。
普通用户能看概览/大屏/明细/任务/配置/日志,能改**自己的**凭证与采集参数、能触发采集与导出;
但看不到「用户管理」(访问 `/users` 返回 403),也改不了实例级设置(输入框置灰,接口也会拒)。
> 建完账号记得告诉同事:**要自己配一份自己的 Cookie**,否则采集不会跑(日志里是 `no_cookie`)。
### Q:管理员能看到别人的数据吗
**看不到。** 用户管理页只显示每个账号的记录条数与积分合计,点不进内容;
任何页面上 Cookie 都只回显「长度 + 结尾 4 位」。采集也只用本人凭证。
所以「把两个人的数据合起来看」要各自导出 CSV 再到外部合并——这是刻意的边界,不是缺陷。
### Q:误操作了别人账号 / 删错了人
- **停用**是可逆的:数据与 Cookie 都保留,随时可以再启用;
- **删除**不可逆:会连同该账号的用量数据与 Cookie 一起删。只能靠备份恢复
(见 [六、运维 · 备份](#q备份怎么做最稳))。
每次账号操作都会写 `audit_log`,在「用户管理 → 账号操作审计」里能查到谁在什么时候动的。
### Q:不小心把自己降级 / 删掉自己了
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
做不到。服务端有四条护栏:不能取消自己的管理员身份、不能停用自己、不能删除自己、
不能删掉最后一个启用的管理员。
### Q:所有人被踢下线了
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
> 同一个文件里还有 `cookie_key`(凭证加密主密钥),它变了会让**所有账号的 Cookie 都要重填**。
> 备份数据库时务必把 `instance.json` 一起备份。
---
## 六、运维
@@ -256,15 +359,38 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
docker compose exec portal python manage.py vacuum
```
或在「配置管理 → 维护动作 → 整理数据库」点一下。
或在「配置管理 → 维护动作 → 整理数据库」点一下(**这个按钮仅管理员可见**,
因为它动的是整库,不只你的数据)。
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
### Q:从 1.1.0 升级到 1.2.0 要做什么
**手工动作:零。** 首次启动新版本时会自动迁移:
1. 老数据整体归到**第一个账号**(也就是原来的那个唯一账号);
2. 原来明文存的 Cookie **就地加密**,日志里会记一条
`明文凭证已加密:settings[uid=1].cookie`;
3. 建 `captchas` 表、给各表补 `user_id` 列与索引。
看迁移结果:
```bash
docker compose logs portal | grep -i migrate
docker compose exec portal python manage.py users
docker compose exec portal python manage.py stats
```
**升级前务必备份**(含 `instance.json`):迁移会改主键与索引,虽然实现了回滚失败即中止,
但备份永远是第一道保险。
### Q:升级会不会丢数据
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
`docker compose up -d --build` 只重建容器,不碰卷。
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
加表加索引安全,**改列需要手工迁移**。
但**升级前依然要备份**(见上一条)。
> 迁移用 `PRAGMA user_version` 记录版本,**幂等**:重复启动不会重复迁移。
> 改列这种操作现在也由 `init_db()` 自动完成,不再是「需要手工迁移」。
### Q:日志在哪、怎么滚动
@@ -276,7 +402,9 @@ docker compose exec portal python manage.py vacuum
### Q:想改采集的接口地址(走镜像/代理)
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
「配置管理 → 采集参数」里改 `api_base` 与 `api_path`。这两个是**实例级**键,
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
改了之后记得同步确认 Cookie 是该域下的有效凭证。
---
@@ -287,14 +415,32 @@ docker compose exec portal python manage.py vacuum
**五层,前两层必须跑绿**:
```bash
python tools/smoke.py # 离线回归 99 项
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
python tools/smoke.py # 离线回归 165 项(不需要起服务)
python tools/demo_data.py # 可选:造一份合成示例库
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 83 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
```
两个新增参数值得一提:
- `check_live.py --db <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录;
- `shots.py --db <路径>`:同上,截图脚本自动解开登录页与注册页。
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
### Q:改了多用户的代码,怎么确认没越权
三个低成本自查:
1. 在 `smoke.py` 的隔离小节里加一条断言 —— 调用 `query.*` 时**故意漏掉 `uid`**,
期望它抛 `TypeError`(本项目把 `uid` 设计成「`conn` 之后的第一个位置参数、无默认值」,
漏传就炸,不会静默返回全量);
2. 临时建一个普通账号,把 `WB_DISABLE_SCHEDULER` 之类放一边,直接访问 `/users` 与
`POST /api/settings` 写实例级键,都应该是 403;
3. 写一个**哨兵值**(如 `SMOKE-SENTINEL-UA`)到实例级 `user_agent`,
断言新账号读不到它——这一招能抓到「落回落到别人配置上」的越权。
### Q:`ModuleNotFoundError: No module named 'flask'`
选错解释器了。依赖装在项目的 venv 或托管环境里: