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 行删除
+302 -37
查看文件
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""SQLite 访问层。
"""SQLite 访问层(多用户版)。
并发约定(重要):
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
@@ -10,18 +10,39 @@
(由 scheduler / CLI 共享的 collect.lock 保证)
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
多用户约定(改代码前务必先读):
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
的代码都不可能把凭证带出去。
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
"""
import os
import sqlite3
import threading
from datetime import datetime
from . import config
from . import config, crypto
_local = threading.local()
_init_lock = threading.Lock()
_initialized = False
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
DB_SCHEMA_VERSION = 2
# 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
class SecretUnreadable(Exception):
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -58,87 +79,331 @@ def close_thread_conn():
_local.conn = None
# ---------------- 初始化 ----------------
def _schema_sql():
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
return f.read()
# ---------------- 初始化 / 迁移 ----------------
def _table_cols(conn, table):
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
def _has_table(conn, name):
return bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
def _first_owner_uid(conn):
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
if row:
return row["id"]
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else 0
def _drop_all_user_indexes(conn):
"""删掉本项目自建的全部索引(idx_*)。
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
"""
names = [r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
for n in names:
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
return names
def _migrate(conn):
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
顺序不能变:
1. 删索引(否则改名会把索引名占住,新表建不出索引)
2. 改名主键变了的表(settings / usage_records)
3. ALTER 加列(collect_runs / audit_log / users)
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
5. 回填数据、删掉 _v1_ 旧表
"""
ver = conn.execute("PRAGMA user_version").fetchone()[0]
if ver >= DB_SCHEMA_VERSION:
return []
done = []
ts = now_str()
owner = _first_owner_uid(conn)
tset = _table_cols(conn, "settings")
tur = _table_cols(conn, "usage_records")
rebuild_settings = "user_id" not in tset
rebuild_records = "user_id" not in tur
_drop_all_user_indexes(conn)
if rebuild_settings:
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
if rebuild_records:
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
if "user_id" not in _table_cols(conn, "collect_runs"):
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
done.append("collect_runs 增加 user_id")
if "user_id" not in _table_cols(conn, "audit_log"):
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
done.append("audit_log 增加 user_id")
tusers = _table_cols(conn, "users")
for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"),
("last_login_ip", "TEXT")):
if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col)
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
if rebuild_settings:
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT 0,key,value,updated_at FROM _v1_settings"
" WHERE key NOT IN ('cookie','user_agent')")
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT ?,key,value,updated_at FROM _v1_settings"
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
(owner,))
conn.execute("DROP TABLE _v1_settings")
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
"Cookie/UA 已归属账号 #%d" % owner)
if rebuild_records:
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
"credits,prompt,first_seen,last_seen,cloud_ts)"
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
conn.execute("DROP TABLE _v1_usage_records")
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
"历史数据归属账号 #%d" % owner)
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
if done:
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
return done
def _encrypt_legacy_secrets(conn):
"""把历史**明文**凭证就地加密。幂等,可反复执行。
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
误提交、排障时的一次 dump 都会把它带出去。
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
"""
if not config.ENCRYPTED_KEYS:
return []
key = config.encryption_key()
done = []
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
if r["key"] not in config.ENCRYPTED_KEYS:
continue
raw = r["value"]
if not raw or crypto.is_encrypted(raw):
continue
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
return done
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 + 灌默认配置。可重复执行(幂等)。"""
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized
own = conn is None
conn = conn or connect()
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
conn.executescript(f.read())
# 默认配置(不覆盖已有值)
if not _has_table(conn, "users"):
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
conn.executescript(_schema_sql())
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
migrated = []
else:
migrated = _migrate(conn)
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
conn.executescript(_schema_sql())
ts = now_str()
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
for k, v in config.DEFAULTS.items():
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
(k, v, ts))
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
" VALUES(0,?,?,?)", (k, v, ts))
# 顺手把历史明文凭证加密(幂等;新库无事可做)
enc = _encrypt_legacy_secrets(conn)
if enc:
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "encrypt_secrets",
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
from .security import hash_password
pwd = admin_password or "admin123"
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,1,?)", (admin_user, hash_password(pwd), "管理员", ts))
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
_initialized = True
return migrated
finally:
if own:
conn.close()
# ---------------- 配置读写 ----------------
def get_setting(conn, key, default=None):
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if row is None or row["value"] is None:
return config.DEFAULTS.get(key, default)
return row["value"]
# ---------------- 配置读写(按作用域) ----------------
def get_setting(conn, key, default=None, uid=0):
"""取单个配置。
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
"""
if key in config.ENCRYPTED_KEYS:
return ""
uid = 0 if config.is_global_key(key) else (uid or 0)
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid, key)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
if uid and key not in NO_FALLBACK_KEYS:
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(key,)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
return config.DEFAULTS.get(key, default)
def get_settings(conn, keys=None):
rows = conn.execute("SELECT key,value FROM settings").fetchall()
got = {r["key"]: r["value"] for r in rows}
def get_settings(conn, keys=None, uid=0):
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
"""
out = dict(config.DEFAULTS)
out.update(got)
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
out[r["key"]] = r["value"]
if uid:
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
if config.is_global_key(r["key"]):
continue # 个人作用域里不该有全局键,有也不认
out[r["key"]] = r["value"]
for k in config.ENCRYPTED_KEYS:
out[k] = ""
if keys:
return {k: out.get(k) for k in keys}
return out
def set_setting(conn, key, value):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, "" if value is None else str(value), now_str()))
def set_setting(conn, key, value, uid=0):
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
uid = 0 if config.is_global_key(key) else (uid or 0)
text = "" if value is None else str(value)
if key in config.ENCRYPTED_KEYS and text:
text = crypto.encrypt(text, config.encryption_key())
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
def set_settings(conn, pairs):
def set_settings(conn, pairs, uid=0):
for k, v in pairs.items():
set_setting(conn, k, v)
set_setting(conn, k, v, uid)
def get_int(conn, key, default=0):
def get_int(conn, key, default=0, uid=0):
try:
return int(float(get_setting(conn, key, default)))
return int(float(get_setting(conn, key, default, uid)))
except (TypeError, ValueError):
return default
def get_float(conn, key, default=0.0):
def get_float(conn, key, default=0.0, uid=0):
try:
return float(get_setting(conn, key, default))
return float(get_setting(conn, key, default, uid))
except (TypeError, ValueError):
return default
def get_bool(conn, key, default=False):
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
def get_bool(conn, key, default=False, uid=0):
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
return v in ("1", "true", "yes", "on", "是")
# ---------------- 凭证(加密存储) ----------------
def get_secret(conn, key, uid=0):
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return ""
try:
return crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError as e:
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
def set_secret(conn, key, value, uid=0):
set_setting(conn, key, value, uid)
def secret_state(conn, key, uid=0):
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
try:
plain = crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError:
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
return {"set": bool(plain), "chars": len(plain),
"tail": plain[-4:] if len(plain) >= 4 else "",
"broken": False, "at": (at["updated_at"] if at else "") or ""}
# ---------------- 账号 ----------------
def user_by_id(conn, uid):
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
def user_by_name(conn, username):
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
def active_users(conn):
"""启用状态的账号(调度器按人遍历)。"""
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None):
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
(now_str(), actor, action, detail, ip))
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
# ---------------- Flask 集成 ----------------