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 同步
这个提交包含在:
@@ -8,6 +8,84 @@
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] — 2026-09-15
|
||||
|
||||
**主题:多用户化 · Cookie 加密 · 开放注册与验证码**
|
||||
|
||||
从单用户版升级到多用户版。**数据不会丢**:`manage.py init` 会自动检测旧表结构并迁移
|
||||
(`PRAGMA user_version` 0 → 2),历史用量归到首个账号、明文 Cookie 就地加密,
|
||||
全程写一条 `schema_migrate` / `encrypt_secrets` 审计,且可重复执行。
|
||||
|
||||
### 新增
|
||||
|
||||
- **多用户与数据隔离**
|
||||
- `users` 表补齐 `email` / `status` / `register_ip` / `last_login_ip`;
|
||||
`settings` 主键改为 `(user_id, key)`,`usage_records` 改为 `(user_id, request_id)`,
|
||||
全部索引以 `user_id` 打头;`collect_runs` / `audit_log` 增加 `user_id`
|
||||
- `query.py` / `collect.py` / `scheduler.py` 全链路把 `uid` 作为 `conn` 之后的
|
||||
**第一个位置参数且无默认值** —— 漏传直接 `TypeError`,不会退化成「返回全量」
|
||||
- 配置三级回落:`个人 → 实例(user_id=0) → config.DEFAULTS`;
|
||||
新增 `NO_FALLBACK_KEYS = {cookie, user_agent}`,凭证**永不回落**(回落即串号越权)
|
||||
- `scheduler.tick()` 遍历启用账号逐个判断槽位;未配 Cookie 的账号自动跳过
|
||||
- 新增 `manage.py users` / `stats -u` / `status`(逐账号)/ 各子命令的 `-u/--user`
|
||||
- **Cookie 静态加密**(`workbuddy_portal/crypto.py`,约 190 行,**零第三方依赖**)
|
||||
- 手写 ChaCha20 块函数(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,
|
||||
密文格式 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`,已用官方测试向量逐字节验证
|
||||
- 主密钥 `cookie_key` 独立存放在 `data/instance.json`(与 `SECRET_KEY` 分开键位)
|
||||
- `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把加密键一律置空;
|
||||
`db.secret_state()` 只回 `{set, chars, tail, broken}`,绝不含明文
|
||||
- `decrypt()` 对非 `v1.` 前缀原样返回(兼容历史明文,下次写入自动升级),
|
||||
校验失败**抛异常**而不是「失败就返回原值」;升级时自动把历史明文加密
|
||||
- **开放注册**:`/register` 页 + `POST /api/users`(管理员);开关 `allow_register`、
|
||||
同 IP 每日配额 `register_max_per_ip`;用户名/密码强度校验(保留字黑名单、≥8 位且两类字符)
|
||||
- **图形验证码**(`workbuddy_portal/captcha.py`,约 250 行,零第三方依赖)
|
||||
- **手写 PNG 编码器**(zlib 压缩 IDAT)+ 5×7 点阵字模 + Bresenham 干扰线与噪点。
|
||||
刻意不用 SVG —— SVG 是文本,答案会明文出现在页面源码里
|
||||
- 答案只写服务端 `captchas` 表;会话里仅存随机 id;**一次性、5 分钟过期、按用途隔离**
|
||||
- 策略 `captcha_policy`:`always`(默认)/ `adaptive`(同来源失败 2 次后要求)/ `off`
|
||||
- 登录**先验验证码再比口令**(否则攻击者能拿「密码对不对」当信号提前跑完字典)
|
||||
- **安全加固**
|
||||
- 失败限速改为 **IP + 用户名双维度**,任一超限即锁;新增验证码出图限速(60s/40 张)
|
||||
- `current_user()` 每请求回查 `users.status` ⇒ 停用账号**立即**失效,不必等会话过期
|
||||
- 安全响应头:CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` / COOP;
|
||||
`/api/*` 与 `/captcha*` 带 `no-store`
|
||||
- 会话 cookie 显式 `HttpOnly` + `SameSite=Lax` + `Path=/`;`WB_COOKIE_SECURE=1` 可开 Secure
|
||||
- `/logs/tail` 改为**仅管理员**;管理员不能停用/降权/删除自己
|
||||
- **页面**:新增 `/login` 验证码、`/register`、`/profile`(个人中心,点右上角用户名进入);
|
||||
`/config` 增加凭证状态与 `cookie_broken` 告警、实例级设置区;`/users` 增加邮箱/状态列与启停
|
||||
|
||||
### 变更
|
||||
|
||||
- 接口新增:`GET/POST /api/profile`、`POST /api/captcha`(机制自述)、
|
||||
`POST /api/users/<id>/delete`;`GET /api/settings` 回传 `_globalKeys` / `_canEditGlobal`
|
||||
- `/api/collect` 未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken`
|
||||
(不再静默当成「未配置」)
|
||||
- `/records/export` 与 CLI `export-csv` 的默认文件名带账号名(多用户下同名会互相覆盖)
|
||||
- `WB_COOKIE` 环境变量兜底**已移除** —— 它会导致串号
|
||||
|
||||
### 修复
|
||||
|
||||
- `db.get_db()` 在流式响应里被复用导致 `Cannot operate on a closed database`
|
||||
(生成器内部改为自建连接)
|
||||
- `.dockerignore` 的 `__pycache__/` 只匹配上下文根目录,嵌套目录会被打进镜像
|
||||
- 注册成功提示与注册页说明里的 `**强调**` 字面量(HTML 不解析 Markdown)
|
||||
- **`base.html` 顶层的 `{% set me = current_user() %}` 会覆盖子模板传入的同名变量**
|
||||
—— 而 `current_user()` 只含 `id/username/display_name/is_admin`,于是个人中心把
|
||||
`me.created_at` 渲染成空(「注册于 ·」)。局部变量改名 `cur`,`smoke.py` 加 3 条防回归断言
|
||||
- `WB_COOKIE_SECURE` 没有写进 `docker-compose.yml` 的 `environment:`
|
||||
—— 在 `.env` 里设了也不生效,文档里的开关实际是哑的(已补上,并加进 `.env.example`)
|
||||
- 「配置管理 → 修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
|
||||
- 「用户管理」删除说明写「可勾选保留」,而页面只有确认框、必删数据,措辞改为与实际一致
|
||||
|
||||
### 升级提示
|
||||
|
||||
- 纯 HTTP 局域网部署**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie,
|
||||
表现为「刚登录完又被弹回登录页」
|
||||
- 迁移后请到「配置管理」确认 Cookie 状态;`secret_state.broken = true` 说明
|
||||
`data/instance.json` 里的 `cookie_key` 与写入时不一致,重新粘贴一次即可
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] — 2026-09-14
|
||||
|
||||
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
|
||||
|
||||
在新工单中引用
屏蔽一个用户