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 行删除
+96 -40
查看文件
@@ -14,18 +14,21 @@
--------
| 表 | 说明 |
|---|---|
| `users` | 明示例两个账号:`admin`(管理员)与 `demo`(普通账号),各有自己的数据 |
| `usage_records` | 约 900 条合成记录,跨 30 天,含假模型名 / 假 Prompt / 偏斜的积分分布 |
| `collect_runs` | 14 条采集历史,含 ok / warn / error 三种状态 |
| `settings` | 走项目默认值(`config.DEFAULTS`),**不写入任何凭据** |
| `audit_log` | 30 条操作审计 |
| `users` | 由 `db.init_db()` 建一个管理员 |
| `collect_runs` | 采集历史,含 ok / warn / error 三种状态 |
| `settings` | 走项目默认值(`config.DEFAULTS`),凭证只写**一眼可辨的假值** |
| `audit_log` | 操作审计(按账号归属) |
刻意造两个账号,是因为「数据按账号隔离」在多用户版里是最该被截进文档的性质:
只有一个账号的话,用户管理页和「我的数据」列都看不出区别。
用法
----
# 默认写到 data/demo/(该目录在 .gitignore 内,不会误提交)
python tools/demo_data.py
# 指定目录与管理员口令,然后起服务看效果
# 指定目录与口令,然后起服务看效果
python tools/demo_data.py --out data/demo --admin-password demo123
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
@@ -61,7 +64,19 @@ CLIENTS = [("vscode", 74), ("webconsole", 18), ("sdk", 8)]
# 写进 settings 的假 Cookie。刻意用重复串,一眼就能看出不是真凭据;
# 作用只是让概览页的健康指示灯是绿的(首装状态是「缺 Cookie」告警)。
# 两个账号给不同的值,这样「各自持有自己的凭证」在截图里看得出来。
DEMO_COOKIE = "wb_demo_session=" + "deadbeef" * 15
DEMO_COOKIE_2 = "wb_demo_session=" + "cafef00d" * 15
# 第二个账号(普通用户)用的模型:刻意与管理员**不重名**,
# 这样「按账号隔离」在按模型的图上立刻可见。
MODELS_2 = [
("demo-lite", 30, 0.55),
("demo-nano", 26, 0.14),
("demo-flash", 22, 0.32),
("demo-vision", 12, 5.90),
("demo-pro", 10, 2.05),
]
PROMPTS_SHORT = [
"帮我解释一下这段代码的作用",
@@ -144,51 +159,74 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
os.environ.setdefault("WB_LOG_DIR", os.path.join(out_dir, "logs"))
os.environ["WB_DB"] = db_file
from workbuddy_portal import db # noqa: E402
from workbuddy_portal import db, security # noqa: E402
conn = db.connect()
try:
db.init_db(conn, create_admin=True, admin_user=admin_user,
admin_password=admin_password)
# 写入一个**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
# ---------- 账号 ----------
admin_row = db.user_by_name(conn, admin_user)
admin_uid = admin_row["id"]
demo_user = "demo"
if not db.user_by_name(conn, demo_user):
conn.execute(
"INSERT INTO users(username,password_hash,display_name,email,is_admin,"
"status,created_at) VALUES(?,?,?,?,0,'active',?)",
(demo_user, security.hash_password(admin_password), "演示账号",
"demo@example.invalid", db.now_str()))
db_row = db.user_by_name(conn, demo_user)
demo_uid = db_row["id"]
# 写入**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
# 而不是首装的「缺 Cookie」告警——演示与截图应当呈现「配置完成」后的样子。
# 这个值不会被任何真实服务接受,也不含任何真实凭据。
db.set_setting(conn, "cookie", DEMO_COOKIE)
# 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。
db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid)
db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid)
# 两个账号各有一套调度时刻,界面上能看出「每人可改自己的」
db.set_setting(conn, "schedule_times", "09:00,17:00", admin_uid)
db.set_setting(conn, "schedule_times", "08:30,20:00", demo_uid)
rng = random.Random(seed)
now = datetime.now().replace(second=0, microsecond=0)
today0 = now.replace(hour=0, minute=0, second=0)
model_pairs = [(m, w) for m, w, _ in MODELS]
medians = {m: md for m, _, md in MODELS}
model_pairs_2 = [(m, w) for m, w, _ in MODELS_2]
medians_2 = {m: md for m, _, md in MODELS_2}
client_pairs = list(CLIENTS)
# ---------- usage_records ----------
rows = []
for d in range(days - 1, -1, -1):
day0 = today0 - timedelta(days=d)
for _ in range(rng.randint(14, 46)):
# 工作时间加权:9-19 点更密
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
minute = rng.randint(0, 59)
sec = rng.randint(0, 59)
ts = day0 + timedelta(hours=hour, minutes=minute, seconds=sec)
if ts > now:
continue
model = _weighted(rng, model_pairs)
client = _weighted(rng, client_pairs)
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
rows.append((
rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
_credits(rng, medians[model]), _prompt(rng),
stamp, stamp, stamp,
))
# ---------- usage_records(两个账号各生成一份)----------
def _gen_records(uid, pairs, med, lo=14, hi=46):
rows = []
for d in range(days - 1, -1, -1):
day0 = today0 - timedelta(days=d)
for _ in range(rng.randint(lo, hi)):
# 工作时间加权:9-19 点更密
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
ts = day0 + timedelta(hours=hour, minutes=rng.randint(0, 59),
seconds=rng.randint(0, 59))
if ts > now:
continue
model = _weighted(rng, pairs)
client = _weighted(rng, client_pairs)
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
rows.append((
uid, rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
_credits(rng, med[model]), _prompt(rng), stamp, stamp, stamp,
))
return rows
rows = _gen_records(admin_uid, model_pairs, medians)
# 普通账号只给大约三分之二的量:列表里一眼能分出主次
rows += _gen_records(demo_uid, model_pairs_2, medians_2, lo=9, hi=31)
conn.execute("BEGIN")
conn.executemany(
"INSERT OR REPLACE INTO usage_records"
"(request_id,ts,day,hour,model,client,credits,prompt,"
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
"(user_id,request_id,ts,day,hour,model,client,credits,prompt,"
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", rows)
conn.execute("COMMIT")
# ---------- collect_runs ----------
@@ -212,7 +250,7 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
fetched = dup = added = 0
trigger = "schedule" if i % 3 else "manual"
runs.append((
trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
admin_uid, trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
(started + timedelta(milliseconds=rng.randint(180, 1400))
).strftime("%Y-%m-%d %H:%M:%S"),
rng.randint(180, 1400),
@@ -221,11 +259,20 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
fetched, added, dup, total, 0, exit_code, msg,
"[%s] %s" % (status, msg),
))
# 普通账号也给两条,让「日志只显示自己的」在截图里成立
for k, (st, msg) in enumerate((("ok", "新增 6 条,重复 4 条,存档共 192 条"),
("ok", "新增 3 条,重复 5 条,存档共 186 条"))):
at = now - timedelta(hours=5 + k * 9)
runs.append((demo_uid, "schedule", st, at.strftime("%Y-%m-%d %H:%M:%S"),
at.strftime("%Y-%m-%d %H:%M:%S"), 420,
(at - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
at.strftime("%Y-%m-%d %H:%M:%S"), 10 - k, 6 - k * 3, 4, 192, 0, 0,
msg, "[%s] %s" % (st, msg)))
conn.execute("BEGIN")
conn.executemany(
"INSERT INTO collect_runs(trigger,status,started_at,finished_at,duration_ms,"
"INSERT INTO collect_runs(user_id,trigger,status,started_at,finished_at,duration_ms,"
"win_from,win_to,fetched,added,dup,total,conflicts,exit_code,message,detail)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
conn.execute("COMMIT")
# ---------- audit_log ----------
@@ -235,18 +282,21 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
actions = [
("login", "登录成功", "192.0.2.10"),
("login", "登录成功", "192.0.2.10"),
("settings", "修改:调度时刻 09:00,17:00", "192.0.2.10"),
("settings", "修改:分页大小 200", "192.0.2.10"),
("settings", "修改:schedule_times", "192.0.2.10"),
("settings", "修改:page_size", "192.0.2.10"),
("maintenance.count", "存档当前 %d 条记录" % total, "192.0.2.10"),
("settings_rejected", "参数错误:page_size 必须是数字(条/页)", "192.0.2.10"),
]
for i in range(30):
act, detail, ip = actions[i % len(actions)]
at = now - timedelta(hours=i * 3 + rng.randint(0, 2))
audits.append((at.strftime("%Y-%m-%d %H:%M:%S"), admin_user, act, detail, ip))
uid = admin_uid if i % 3 else demo_uid
actor = admin_user if i % 3 else demo_user
audits.append((uid, at.strftime("%Y-%m-%d %H:%M:%S"), actor, act, detail, ip))
conn.execute("BEGIN")
conn.executemany(
"INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)", audits)
"INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(?,?,?,?,?,?)", audits)
conn.execute("COMMIT")
# 统计一下,便于打印
@@ -255,7 +305,13 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
d = conn.execute("SELECT COUNT(DISTINCT day) FROM usage_records").fetchone()[0]
print("示例库:%s" % db_file)
print(" 记录 %d 条 / 积分 %s / 覆盖 %d 天" % (n, c, d))
print(" 管理员 %s,口令 %s" % (admin_user, admin_password))
for r in conn.execute(
"SELECT u.id,u.username,u.is_admin,"
" (SELECT COUNT(*) FROM usage_records x WHERE x.user_id=u.id) AS n"
" FROM users u ORDER BY u.id"):
print(" 账号 #%s %-8s %-6s %d 条"
% (r["id"], r["username"], "管理员" if r["is_admin"] else "普通", r["n"]))
print(" 口令都是 %s(仅供本地演示)" % admin_password)
print(" 该目录在 .gitignore 内,不会被提交")
finally:
conn.close()