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 行删除
+76 -22
查看文件
@@ -62,14 +62,17 @@ docker compose logs -f
| `TZ` | `Asia/Shanghai` | **影响「每日 09:00/17:00」与所有日期口径** |
| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) |
| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」**,见 [第九节](#登录成功却立刻又跳回登录页) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
> `TZ` 与 `WB_COOKIE_SECURE` 都是**进程环境变量**,`docker compose restart` 不生效,要 `up -d`。
### 2.4 数据落点:用命名卷,不用绑定挂载
| 容器内 | 存放位置 | 内容 |
|---|---|---|
| `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` |
| `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(`secret_key` + `cookie_key`)、`exports/` |
| `/app/logs` | Docker 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) |
**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的):
@@ -352,7 +355,7 @@ curl -s -u wangchuanli:TOKEN \
|---|---|---|
| `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
| `instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了/被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需要各自重填** |
| `exports/*.csv` | ★ | 导出快照,可再生 |
| `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 |
@@ -465,10 +468,34 @@ sudo systemctl restart workbuddy-portal
### 升级前
1. **先备份**(见第六节)——`schema.sql` 用的是 `CREATE TABLE IF NOT EXISTS`,
加表加索引是安全的,但改列需要手工迁移,所以备份是唯一保险。
1. **先备份**(见第六节)。备份要**同时包含 `usage.sqlite` 与 `instance.json`** ——
后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。
### 1.1.0 → 1.2.0(单用户 → 多用户)
**无需任何手工迁移命令。** 首次用新版启动时会自动完成,日志里能看到:
| 做了什么 | 效果 |
|---|---|
| 建 `users` 表、写入首个管理员 | 用 `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD`,或沿用 `admin` / `admin123` |
| `settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键 | 老数据整体归到**第一个账号** |
| 明文 Cookie 就地加密 | 日志记一条 `明文凭证已加密:settings[uid=1].cookie` |
| 建 `captchas` 表、补索引 | 验证码用 |
迁移由 `PRAGMA user_version` 驱动,**幂等**:重复启动不会重复执行。
校验一下:
```bash
docker compose logs portal | grep -i "迁移\|migrat"
docker compose exec portal python manage.py users # 账号 / 角色 / 数据量 / 凭证状态
docker compose exec portal python manage.py stats # 各账号条数与积分
```
> 升级后请**确认 Cookie 能解密**:登录后打开「配置管理 → 我的云端凭证」,
> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「无法解密」,说明 `instance.json`
> 不匹配,重新粘贴一次即可。
---
## 八、日常巡检
@@ -505,6 +532,23 @@ docker compose logs --tail=100
| 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` |
| 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 |
### 登录成功却立刻又跳回登录页
几乎一定是 `WB_COOKIE_SECURE` 被设成了 `1`,而你在用 **HTTP** 访问。
会话 Cookie 带 `Secure` 属性后,浏览器只在 HTTPS 下才回传;服务端每次都收不到会话,
就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」,日志里
看起来像在反复登录。
```bash
# .env
WB_COOKIE_SECURE=0
docker compose up -d # 环境变量,必须 up -d,restart 不生效
```
只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
(另:如果站点前后端域名不同,还要看第四节的反代配置。)
### `exec format error` / `no such file or directory`(entrypoint)
`docker/entrypoint.sh` 被 CRLF 污染了。仓库里有 `.gitattributes` 强制 `*.sh` 为 LF;
@@ -599,26 +643,35 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
## 十、配置项速查
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
也可以直接改表):
也可以直接改表)。表的主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用,
仅管理员可改),其余是**个人级**(每个账号一份,互不可见)。
| 键 | 默认 | 说明 |
|---|---|---|
| `schedule_enabled` | `1` | 调度总开关 |
| `schedule_times` | `09:00,17:00` | 每日时刻,逗号分隔,本地时区 |
| `catch_up` | `1` | 启动补跑开关 |
| `catch_up_grace_hours` | `12` | 补跑宽限期(小时) |
| `page_size` | `200` | 采集单页条数(20~1000) |
| `rewind_minutes` | `2` | 断点回退分钟数(0~120) |
| `drift_tolerance_minutes` | `5` | 云端时间漂移告警阈值(0~720) |
| `max_prompt` | `2048` | Prompt 入库截断长度,0 = 不截断 |
| `verify_days` | `0` | 采集后整日校验天数(0~90) |
| `timeout` | `30` | HTTP 超时秒数(5~300) |
| `ssl_verify` | `1` | 校验云端 HTTPS 证书 |
| `api_base` / `api_path` | 官方地址 | 接口地址(走镜像/代理时改) |
| `cookie` | 空 | 账号凭证(页面只回掩码) |
| `user_agent` | Chrome UA | 与 Cookie 同源更稳 |
| 键 | 默认 | 作用域 | 说明 |
|---|---|---|---|
| `api_base` / `api_path` | 官方地址 | **实例级** | 接口地址(走镜像/代理时改) |
| `allow_register` | `1` | **实例级** | 是否开放自助注册 |
| `register_max_per_ip` | `3` | **实例级** | 同一 IP 每日注册上限(1~50) |
| `captcha_policy` | `always` | **实例级** | `always` / `adaptive` / `off` |
| `captcha_length` | `4` | **实例级** | 验证码位数(4~6) |
| `cookie` | 空 | 个人级 | 账号凭证,**密文入库**;页面只回「长度 + 结尾 4 位」 |
| `user_agent` | Chrome UA | 个人级 | 与 Cookie 同源更稳。**`cookie` 与 `user_agent` 不参与实例级回落**(回落 = 串号越权) |
| `schedule_enabled` | `1` | 个人级 | 调度总开关 |
| `schedule_times` | `09:00,17:00` | 个人级 | 每日时刻,逗号分隔,本地时区 |
| `catch_up` | `1` | 个人级 | 启动补跑开关 |
| `catch_up_grace_hours` | `12` | 个人级 | 补跑宽限期(小时) |
| `page_size` | `200` | 个人级 | 采集单页条数(20~1000) |
| `rewind_minutes` | `2` | 个人级 | 断点回退分钟数(0~120) |
| `drift_tolerance_minutes` | `5` | 个人级 | 云端时间漂移告警阈值(0~720) |
| `max_prompt` | `2048` | 个人级 | Prompt 入库截断长度,0 = 不截断 |
| `verify_days` | `0` | 个人级 | 采集后整日校验天数(0~90) |
| `timeout` | `30` | 个人级 | HTTP 超时秒数(5~300) |
| `ssl_verify` | `1` | 个人级 | 校验云端 HTTPS 证书 |
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。
**读配置时有三级回落**:个人级 → 实例级 → 代码里的 `DEFAULTS`。
所以实例级的值只是「默认值」,任何账号都可以用自己的值覆盖它(`cookie` / `user_agent` 例外)。
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒——
接口不能用来往 `settings` 表里塞任意键。
环境变量(启动期,改了要重建容器):
@@ -627,6 +680,7 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `TZ` | 时区,影响所有日期口径 |
| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 |
| `WB_COOKIE_SECURE` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) |
| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) |
| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 |