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 行删除
+62 -24
查看文件
@@ -1,15 +1,24 @@
-- WorkBuddy Portal —— SQLite 表结构
-- 设计要点:
-- * usage_records 是唯一正本,request_id 为主键,去重靠 ON CONFLICT,不再依赖内存比对
-- * 多用户:usage_records / collect_runs / audit_log 都带 user_id;
-- 每个账号只看得到自己的数据,管理员也不越过这条线(见 docs/ARCHITECTURE.md)
-- * settings 是 (user_id, key) 复合主键:user_id=0 为实例级,其余为个人级
-- * usage_records 主键是 (user_id, request_id):去重按「人 + 请求」,
-- 不同账号拿到相同 requestId 时互不覆盖
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
--
-- 升级:本文件是 DDL 的唯一来源。db._migrate() 用「改名旧表 -> 重跑本文件 ->
-- 回填数据 -> 删旧表 -> 再跑一次本文件补索引」的方式做在线迁移。
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
-- ---------------- 采集正本 ----------------
CREATE TABLE IF NOT EXISTS usage_records (
request_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(usage_records.user_id -> users.id)
request_id TEXT NOT NULL,
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 'YYYY-MM-DD'
hour INTEGER NOT NULL, -- 0..23
@@ -19,19 +28,23 @@ CREATE TABLE IF NOT EXISTS usage_records (
prompt TEXT,
first_seen TEXT NOT NULL, -- 本地首次入库时间
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
cloud_ts TEXT -- 云端最近一次返回的 requestTime
cloud_ts TEXT, -- 云端最近一次返回的 requestTime
PRIMARY KEY (user_id, request_id)
);
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(day);
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(day, hour);
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(model, day);
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(client, day);
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(credits DESC);
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(ts);
-- 索引一律以 user_id 打头:所有查询都带「归属人」这个条件,
-- 少了它会退化成全表扫描(多用户下这是最容易踩的性能坑)。
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(user_id, day);
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(user_id, day, hour);
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(user_id, model, day);
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(user_id, client, day);
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(user_id, credits DESC);
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(user_id, ts);
-- 采集运行历史(任务管理 + 日志管理的正本)
-- ---------------- 采集运行历史 ----------------
CREATE TABLE IF NOT EXISTS collect_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0, -- 哪次「谁」的采集
trigger TEXT NOT NULL, -- manual | schedule | cli | startup
status TEXT NOT NULL, -- running | ok | warn | error
started_at TEXT NOT NULL,
@@ -42,7 +55,7 @@ CREATE TABLE IF NOT EXISTS collect_runs (
fetched INTEGER DEFAULT 0, -- 云端返回条数
added INTEGER DEFAULT 0,
dup INTEGER DEFAULT 0,
total INTEGER DEFAULT 0, -- 入库后总条数
total INTEGER DEFAULT 0, -- 入库后该账号总条数
conflicts INTEGER DEFAULT 0,
exit_code INTEGER,
message TEXT, -- 一句话结论
@@ -50,34 +63,59 @@ CREATE TABLE IF NOT EXISTS collect_runs (
);
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_user ON collect_runs(user_id, id DESC);
-- 键值配置:cookie / user_agent / 调度时刻 / 采集参数 / 调度槽位去重标记
-- ---------------- 键值配置 ----------------
-- user_id = 0 : 实例级(接口基址、注册开关、验证码策略)
-- user_id > 0 : 个人级(自己填的 Cookie / UA、采集参数、调度时刻、slot:* 簿记)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
key TEXT NOT NULL,
value TEXT,
updated_at TEXT
updated_at TEXT,
PRIMARY KEY (user_id, key)
);
-- 后台登录账号(局域网访问必须)
-- ---------------- 账号 ----------------
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 1,
email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
created_at TEXT,
register_ip TEXT, -- 自助注册来源,用于每日限额
last_login_at TEXT,
last_login_ip TEXT,
login_count INTEGER NOT NULL DEFAULT 0
);
-- 操作审计(登录、改配置、手动触发等)
-- ---------------- 操作审计 ----------------
-- 刻意**不记录任何凭证**:detail 里只写「改了哪些键」,不写键的值。
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(0 = 系统 / CLI / 未登录事件)
at TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, id DESC);
-- ---------------- 图形验证码 ----------------
-- 答案只在服务端存在。下发到浏览器的只是 id,且用一次即删。
CREATE TABLE IF NOT EXISTS captchas (
id TEXT PRIMARY KEY, -- 随机 token(下发给客户端)
answer TEXT NOT NULL, -- 正确答案(绝不下发)
purpose TEXT NOT NULL, -- login | register
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);