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 同步
这个提交包含在:
+106
-25
@@ -6,8 +6,9 @@
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
|
||||
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
|
||||
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页)。唯一例外是 `/captcha.png` |
|
||||
| **数据作用域** | **所有数据接口只返回当前登录账号的数据**。`user_id` 由服务端会话决定,**不接受客户端传入** —— 传 `?user_id=1` 会被忽略 |
|
||||
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400**。未登录的 POST 也会先被 CSRF 拦成 400(这比先鉴权更保守) |
|
||||
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
|
||||
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
|
||||
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
|
||||
@@ -27,6 +28,8 @@
|
||||
| `forbidden` | 403 | 已登录但权限不足 |
|
||||
| `not_found` | 404 | 接口或资源不存在 |
|
||||
| `busy` | 409 | 已有采集在跑(单写者约束) |
|
||||
| `no_cookie` | 409 | 本账号还没配 Cookie,无法采集 |
|
||||
| `cookie_broken` | 409 | Cookie 密文解不开(`cookie_key` 换过),需重新粘贴 |
|
||||
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
|
||||
| `api` | 502 | 云端接口异常 |
|
||||
| `internal` | 500 | 服务端异常 |
|
||||
@@ -235,29 +238,82 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|
||||
### GET `/api/settings`
|
||||
|
||||
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`slot:*`)不返回。
|
||||
读**当前登录账号的有效配置**。返回的是一个**扁平字典**:配置键 → 值。
|
||||
|
||||
> **凭证只回掩码**:`cookie` 这个键固定为空串,真正的状态在 `cookie_hint` / `cookie_broken`;
|
||||
> 内部簿记键(`slot:*`)根本不返回。
|
||||
> 下例中的数字与尾号都是合成示例数据,不是任何真实实例的值。
|
||||
|
||||
```json
|
||||
{
|
||||
"values": { "page_size": "200", "schedule_times": "09:00,17:00", ... },
|
||||
"cookie_hint": "4054 字符,…09db9a660825",
|
||||
"num_settings": { "page_size": [20, 1000, "条/页"], ... },
|
||||
"bool_settings": ["schedule_enabled", "catch_up"]
|
||||
"api_base": "https://www.workbuddy.cn",
|
||||
"api_path": "/billing/meter/get-user-request-usage",
|
||||
"page_size": "200",
|
||||
"schedule_times": "09:00,17:00",
|
||||
"ssl_verify": "1",
|
||||
"cookie": "",
|
||||
"cookie_hint": "92 字符,…c0ffee",
|
||||
"cookie_broken": false,
|
||||
"user_agent": "Mozilla/5.0 (...)",
|
||||
"_globalKeys": ["allow_register", "api_base", "api_path",
|
||||
"captcha_length", "captcha_policy", "register_max_per_ip"],
|
||||
"_canEditGlobal": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 各配置键 | 有效值(`个人 → 实例 → DEFAULTS` 三级回落后的结果) |
|
||||
| `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 |
|
||||
| `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 |
|
||||
| `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie |
|
||||
| `_globalKeys` | 实例级键清单(所有账号共用一份,只有管理员能改) |
|
||||
| `_canEditGlobal` | 当前账号能否改 `_globalKeys` 里的键 |
|
||||
|
||||
### GET `/api/users`(管理员)
|
||||
|
||||
```json
|
||||
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
|
||||
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
|
||||
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员", "email": null,
|
||||
"is_admin": 1, "status": "active", "created_at": "...",
|
||||
"last_login_at": "...", "last_login_ip": "192.0.2.10", "login_count": 12 } ] }
|
||||
```
|
||||
|
||||
> **口令散列永不出现在响应里。**
|
||||
> **口令散列永不出现在响应里**(`_user_public()` 只挑安全字段)。
|
||||
|
||||
### GET `/logs/tail`
|
||||
### POST `/api/profile`
|
||||
|
||||
应用日志尾部。
|
||||
改**自己**的显示名与邮箱。
|
||||
|
||||
```json
|
||||
{ "display_name": "新显示名", "email": "me@example.com" }
|
||||
```
|
||||
|
||||
### POST `/api/captcha`
|
||||
|
||||
验证码机制的**自述**,便于排障时自检(不需要猜当前策略是什么)。
|
||||
|
||||
```json
|
||||
{ "policy": "always", "length": 4, "ttl_seconds": 300,
|
||||
"image_url": "/captcha.png",
|
||||
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。" }
|
||||
```
|
||||
|
||||
### GET `/captcha.png`
|
||||
|
||||
**唯一不需要登录的接口**,返回一张 PNG 图形验证码。
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `purpose` | `login` | `login` \| `register`;其它值一律收敛为 `login`(不会 500) |
|
||||
|
||||
- 响应头带 `Cache-Control: no-store`(缓存旧图会导致「图没变但怎么输都错」);
|
||||
- 每来源 60 秒最多 40 张,超限返回 **429**;
|
||||
- **答案不会出现在响应里,也不会出现在任何页面源码或会话中** ——
|
||||
服务端只把随机 id 写进会话(`cap_login` / `cap_register`),答案留在 `captchas` 表。
|
||||
|
||||
### GET `/logs/tail`(**仅管理员**)
|
||||
|
||||
应用日志尾部。普通账号访问返回 **403**(账号自己的采集日志请用 `/api/runs`)。
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -294,20 +350,25 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|---|---|
|
||||
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 944 条", ...}}` |
|
||||
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
|
||||
| 本账号未配 Cookie | `409 {"ok": false, "error": "no_cookie", "message": "..."}` |
|
||||
| Cookie 解不开 | `409 {"ok": false, "error": "cookie_broken", "message": "..."}` |
|
||||
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
|
||||
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
|
||||
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
|
||||
|
||||
> 采集只使用**当前账号自己的** Cookie(`collect.load_credentials(conn, uid)`),
|
||||
> 且会写一条带 `user_id` 的 `collect_runs`。多用户下不要并发触发采集(单写者约束)。
|
||||
|
||||
### POST `/api/maintenance/<action>`
|
||||
|
||||
把 CLI 维护动作搬到页面。
|
||||
|
||||
| `action` | 作用 |
|
||||
|---|---|
|
||||
| `fill-prompt` | 从云端回补缺失的 Prompt |
|
||||
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
|
||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
|
||||
| `recount` | 重新统计并返回当前条数 |
|
||||
| `action` | 作用 | 权限 |
|
||||
|---|---|---|
|
||||
| `fill-prompt` | 从云端回补缺失的 Prompt | 本人 |
|
||||
| `export-csv` | 全量导出 CSV 到 `data/exports/`(文件名带账号名) | 本人 |
|
||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` | **仅管理员** |
|
||||
| `recount` | 重新统计并返回当前条数 | 本人 |
|
||||
|
||||
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
|
||||
|
||||
@@ -321,12 +382,18 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|---|---|
|
||||
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
|
||||
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
|
||||
| 非管理员改实例级键 | `400 {"ok": false, "error": "invalid", "errors": ["以下为实例级配置,仅管理员可修改:api_base"]}` |
|
||||
|
||||
要点:
|
||||
|
||||
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
|
||||
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);写 `__clear__` 或 `-` 才是清空;
|
||||
- `cookie` 落库前会**自动加密**(`db.set_secret`),写进去的永远不是明文;
|
||||
- **实例级键**(`_globalKeys`:`api_base` / `api_path` / `allow_register` /
|
||||
`register_max_per_ip` / `captcha_policy` / `captcha_length`)非管理员**写不了**
|
||||
—— 否则任意注册用户都能把大家的数据采集指向别的服务器;
|
||||
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
|
||||
- 内部键(`slot:*`)被忽略;
|
||||
- 改了 `schedule_times` / `schedule_enabled` 会清掉**自己**的槽位标记,新时刻立即生效;
|
||||
- 每次拒绝都会写一条 `settings_rejected` 审计。
|
||||
|
||||
### POST `/api/password`
|
||||
@@ -342,22 +409,36 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
### POST `/api/users`(管理员)
|
||||
|
||||
```json
|
||||
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
|
||||
{ "username": "viewer", "display_name": "只读同事", "email": "viewer@example.com",
|
||||
"password": "…", "password2": "…", "is_admin": false }
|
||||
```
|
||||
|
||||
`is_admin` **默认 false**(多用户系统里「默认给管理员」是最常见的越权起点)。
|
||||
用户名 / 口令强度与自助注册同一套校验。
|
||||
|
||||
### POST `/api/users/<id>`(管理员)
|
||||
|
||||
```json
|
||||
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
|
||||
{ "display_name": "新名字", "email": "…", "is_admin": true,
|
||||
"status": "active", "password": "可选,重置密码" }
|
||||
```
|
||||
|
||||
**自锁护栏**(服务端强制,全部返回 400):
|
||||
|
||||
1. 不能取消自己的管理员身份;
|
||||
2. 不能停用自己的账号;
|
||||
3. 不能把最后一个**启用状态的**管理员降权或停用。
|
||||
|
||||
### POST `/api/users/<id>/delete`(管理员)
|
||||
|
||||
删除账号。**三重护栏**(服务端强制):
|
||||
删除账号。护栏:
|
||||
|
||||
1. 不能取消自己的管理员身份;
|
||||
2. 不能删除自己;
|
||||
3. 至少保留一个账号。
|
||||
1. 不能删除自己;
|
||||
2. 至少要保留一个账号;
|
||||
3. 不能删掉最后一个启用状态的管理员。
|
||||
|
||||
请求体可选 `{"keep_data": true}` 保留其用量数据;**默认连同数据与 Cookie 一起删除** ——
|
||||
留下孤儿数据既占空间,也会在有人重新注册同名账号时被看到(`user_id` 复用风险)。
|
||||
|
||||
违反返回 `400`。
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户