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 同步
这个提交包含在:
+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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户