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 行删除
+165 -20
查看文件
@@ -21,13 +21,17 @@
from __future__ import annotations
import argparse
import base64
import http.cookiejar
import json
import os
import re
import sqlite3
import sys
import urllib.error
import urllib.parse
import urllib.request
import zlib
from datetime import datetime
OK = 0
@@ -40,6 +44,31 @@ def _d(s: str):
return datetime.strptime(s, "%Y-%m-%d")
def decode_session(cj) -> dict:
"""从 Flask 会话 cookie 里解出那份**未加密**的载荷。
Flask 的会话是「签名 + base64,**不加密**」的 —— 也就是说持有 cookie 的人
就能读到里面的内容。本项目因此把验证码答案放在服务端 captchas 表里,
会话里只留一个随机 id;本函数存在的意义就是取出那个 id,
好让自动化验收能跨过验证码这一关(顺便也验证了「答案不在会话里」)。
"""
for c in cj:
if not c.name.startswith("workbuddy_portal_sid"):
continue
seg = urllib.parse.unquote(c.value).split(".")[0]
seg += "=" * (-len(seg) % 4)
try:
raw = base64.urlsafe_b64decode(seg)
try:
raw = zlib.decompress(raw) # 某些版本的 itsdangerous 会压
except zlib.error:
pass
return json.loads(raw.decode("utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def chk(name: str, cond: bool, extra: str = "") -> None:
global OK, FAIL
if cond:
@@ -59,9 +88,10 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
class Live:
def __init__(self, base: str, timeout: int = 20):
def __init__(self, base: str, timeout: int = 20, db_path: str | None = None):
self.base = base.rstrip("/")
self.timeout = timeout
self.db_path = db_path
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
@@ -114,6 +144,62 @@ class Live:
st, body = self.get(path)
return json.loads(body) if st == 200 else {}
def raw(self, path: str):
"""返回 (status, headers, bytes)——验证码/响应头这类要原始字节的场景用。"""
try:
r = self.op.open(urllib.request.Request(self.base + path), timeout=self.timeout)
return r.status, r.headers, r.read()
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read()
def form_csrf(self, path: str) -> str:
"""取某个页面里的 CSRF 隐藏域(该页面必须与当前会话同源)。"""
_, html = self.get(path)
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else ""
# ---- 验证码辅助(仅验收脚本用)----
def solve_captcha(self, purpose: str):
"""取一张图 -> 从会话里读 id -> 从本地库里取答案。返回 (答案, 会话载荷)。"""
self.raw("/captcha.png?purpose=" + purpose)
sess = decode_session(self.cj)
cid = sess.get("cap_" + purpose)
if not cid or not self.db_path or not os.path.exists(self.db_path):
return None, sess
try:
con = sqlite3.connect(self.db_path)
try:
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None, sess
return (row[0] if row else None), sess
def login(self, user: str, pwd: str, nxt: str = "", follow: bool = True):
"""完整登录(验证码策略为 always 时自动解)。
follow=False 时返回原始 (status, Location),用于验证跳转目标是否安全。
返回 (status, location, need_captcha, session_payload)。
"""
html = self.get("/login")[1]
need_cap = 'name="captcha"' in html
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
data = {"username": user, "password": pwd, "_csrf": m.group(1) if m else ""}
if nxt:
data["next"] = nxt
sess = {}
if need_cap:
ans, sess = self.solve_captcha("login")
if ans is None:
return None, None, True, sess
data["captcha"] = ans
if follow:
st, _ = self.post("/login", data)
return st, None, need_cap, sess
st, loc = self.post_raw("/login", data)
return st, loc, need_cap, sess
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
print("== 1. 未登录访问受保护资源 ==")
@@ -123,13 +209,15 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
st, _ = L.get(p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
print("== 2. 登录(含 CSRF) ==")
print("== 2. 登录(含 CSRF;验证码策略为 always 时自动解) ==")
st, html = L.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
chk("登录页含 CSRF 隐藏域", bool(m))
st, _ = L.post("/login", {"username": user, "password": pwd,
"_csrf": m.group(1) if m else ""})
chk("登录页含 CSRF 隐藏域", bool(re.search(r'name="_csrf"\s+value="([^"]+)"', html)))
st, _, need_cap, sess = L.login(user, pwd)
chk("登录成功", st in (200, 302), "status=%s" % st)
if need_cap:
# 会话里只应有 id,不该有答案本身
chk("会话里只存验证码 id(不是答案)", bool(sess.get("cap_login")),
"cap_login=%s" % (sess.get("cap_login") or "无"))
st, html = L.get("/")
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
@@ -212,11 +300,25 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
print("== 7. 凭据不外泄 ==")
stj = L.jget("/api/settings")
chk("settings 无 cookie 明文字段", "cookie" not in stj, "keys=%s" % list(stj.keys()))
# 契约:settings 里 cookie 这个键**必须为空**(db.get_settings 统一置空),
# 真正的状态只通过 cookie_hint / cookie_broken 这两个派生字段暴露。
chk("settings 里 cookie 字段为空串",
"cookie" in stj and not str(stj.get("cookie") or "").strip(),
"cookie=%r" % stj.get("cookie"))
chk("settings 用 cookie_hint/cookie_broken 代替明文",
"cookie_hint" in stj and "cookie_broken" in stj)
chk("settings 仅回 cookie_hint 掩码",
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
"hint=%s" % stj.get("cookie_hint"))
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
cipher_re = re.compile(r"v1\.[A-Za-z0-9+/=]{8,}\.[A-Za-z0-9+/=]{8,}\.")
for p in ("/config", "/profile", "/"):
chk("%-9s HTML 里没有 Cookie 密文" % p, not cipher_re.search(L.get(p)[1]))
print("== 8. 错误处理 ==")
for p in ("/api/nope", "/nope"):
@@ -254,20 +356,13 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
print("== 10. 安全:开放重定向与凭证外泄 ==")
L2 = Live(L.base) # 全新会话,避免已登录被直跳
st, html = L2.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
csrf = m.group(1) if m else ""
st, loc = L2.post_raw("/login", {"username": user, "password": pwd,
"_csrf": csrf, "next": "//evil.com"})
L2 = Live(L.base, L.timeout, L.db_path) # 全新会话,避免已登录被直跳
st, loc, _, _ = L2.login(user, pwd, nxt="//evil.com", follow=False)
chk("next=//evil.com 被拒(不出现协议相对跳转)",
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
"status=%s Location=%s" % (st, loc))
L3 = Live(L.base)
st, html = L3.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
st, loc = L3.post_raw("/login", {"username": user, "password": pwd,
"_csrf": m.group(1) if m else "", "next": "/records"})
L3 = Live(L.base, L.timeout, L.db_path)
st, loc, _, _ = L3.login(user, pwd, nxt="/records", follow=False)
chk("next=/records 站内路径正常放行", st == 302 and loc == "/records",
"status=%s Location=%s" % (st, loc))
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
@@ -277,6 +372,50 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
st, html = L.get("/")
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
print("== 11. 多用户:注册入口 / 验证码 / 安全响应头 ==")
L4 = Live(L.base, L.timeout, L.db_path) # 全新未登录会话
st, html = L4.get("/register")
chk("GET /register 可达", st == 200 and "注册" in html, "status=%s" % st)
chk("注册页带验证码图", "capimg" in html and "/captcha.png" in html)
chk("注册页带 CSRF 隐藏域", bool(L4.form_csrf("/register")))
st, html = L4.get("/login")
chk("登录页带验证码图", "capimg" in html and 'name="captcha"' in html)
chk("登录页带自助注册链接", "/register" in html)
# 出图:真实字节 + 禁缓存 + 确实每次都不一样
shots = {}
for purpose in ("login", "register"):
code, hdr, data = L4.raw("/captcha.png?purpose=" + purpose)
chk("GET /captcha.png?purpose=%-8s 出 PNG" % purpose,
code == 200 and data[:4] == b"\x89PNG" and len(data) > 200,
"status=%s len=%d" % (code, len(data)))
chk(" └ 禁缓存 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
chk(" └ 类型 image/png", (hdr.get("Content-Type") or "").startswith("image/png"))
shots[purpose] = data
_, _, again = L4.raw("/captcha.png?purpose=login")
chk("两次取图内容不同(不是一张静态图)", again != shots["login"])
chk("login 与 register 的图互不相同", shots["login"] != shots["register"])
# 图必须由服务端单独下发,不能把答案内联进页面
st, html = L4.get("/login")
chk("登录页没有内联 data: 图片(答案不走页面源码)",
"data:image" not in html and "base64," not in html)
# 安全响应头
code, hdr, _ = L4.raw("/login")
for name, want in (("X-Content-Type-Options", "nosniff"),
("X-Frame-Options", "DENY"),
("Referrer-Policy", "same-origin")):
chk("响应头 %-24s" % name, (hdr.get(name) or "") == want, "=%s" % hdr.get(name))
chk("响应头含 CSP 且 frame-ancestors 'none'",
"frame-ancestors 'none'" in (hdr.get("Content-Security-Policy") or ""))
code, hdr, _ = L4.raw("/captcha.png?purpose=login")
chk("/captcha 路径带 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
# 路径穿越式 purpose 必须被收敛到已知用途,而不是 500
code, _, data = L4.raw("/captcha.png?purpose=../../etc/passwd")
chk("非法 purpose 不报 500", code == 200 and data[:4] == b"\x89PNG",
"status=%s" % code)
def main() -> int:
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
@@ -285,11 +424,17 @@ def main() -> int:
ap.add_argument("-p", "--password", default="admin123", help="登录密码")
ap.add_argument("--from", dest="frm", default="2026-09-08", help="验收窗口起")
ap.add_argument("--to", dest="to", default="2026-09-14", help="验收窗口止")
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite)。"
"验证码策略为 always 时用它取答案以完成自动登录;"
"指向不存在的文件则跳过需要验证码的登录")
ap.add_argument("--timeout", type=int, default=20)
a = ap.parse_args()
print("目标:%s 窗口:%s ~ %s\n" % (a.base, a.frm, a.to))
run(Live(a.base, a.timeout), a.user, a.password, a.frm, a.to)
db_path = a.db or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if FAILS:
print("失败项:%s" % "、".join(FAILS))
+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()
+98 -29
查看文件
@@ -6,33 +6,47 @@
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
用法:
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
python tools/shots.py --base http://127.0.0.1:8849
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
python tools/shots.py --base http://127.0.0.1:8849 --db data/demo/usage.sqlite
python tools/shots.py --full # 整页长图(默认只截首屏)
产物:data/shots/*.png(已被 .gitignore 之外的目录,可直接删)。
产物:data/shots/*.png(该目录在 .gitignore 内,可直接删)。
为什么不用 headless chrome 直出:本项目的页面都要登录态,
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
验证码:默认策略是 always,所以本脚本会**从本地库里取答案**(取的是会话里的
captcha id,答案只存在于服务端),这样自动化能跨过验证码这一关。
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sqlite3
import sys
import urllib.parse
import zlib
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
PAGES = [
("login", "/login", "登录页"),
("overview", "/", "概览"),
("records", "/records", "数据明细"),
("tasks", "/tasks", "任务管理"),
("config", "/config", "配置管理"),
("logs", "/logs", "日志管理"),
("users", "/users", "用户管理"),
("dashboard", "/dashboard", "用量大屏"),
# 文件名刻意与原有编号保持一致(docs/USER-GUIDE.md 里就是按这些名字引用的),
# 新增页面排在后面,避免为了两张新图去改一堆文档链接。
CAPTURES = [
# (文件名, 路径, 标签, 是否需登录)
("00-login.png", "/login", "登录页 (含图形验证码)", False),
("09-register.png", "/register", "注册页", False),
("01-overview.png", "/", "概览", True),
("02-records.png", "/records", "数据明细", True),
("03-tasks.png", "/tasks", "任务管理", True),
("04-config.png", "/config", "配置管理", True),
("05-logs.png", "/logs", "日志管理", True),
("06-users.png", "/users", "用户管理", True),
("07-dashboard.png", "/dashboard", "用量大屏", True),
("10-profile.png", "/profile", "个人中心", True),
]
@@ -61,18 +75,59 @@ def _find_browser() -> str | None:
return None
def _session_payload(ctx) -> dict:
"""解出 Flask 会话 cookie 里的载荷,只为拿 captcha 的 id。
Flask 会话是「签名 + base64,不加密」的,所以这里面能读出内容 ——
正因如此,验证码答案绝不能放进去(本项目只放一个随机 id)。
"""
for c in ctx.cookies():
if not c.get("name", "").startswith("workbuddy_portal_sid"):
continue
seg = urllib.parse.unquote(c.get("value", "")).split(".")[0]
seg += "=" * (-len(seg) % 4)
try:
raw = base64.urlsafe_b64decode(seg)
try:
raw = zlib.decompress(raw)
except zlib.error:
pass
return json.loads(raw.decode("utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def _captcha_answer(ctx, db_path: str, purpose: str) -> str | None:
cid = _session_payload(ctx).get("cap_" + purpose)
if not cid or not db_path or not os.path.exists(db_path):
return None
try:
con = sqlite3.connect(db_path)
try:
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None
return row[0] if row else None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8849")
ap.add_argument("-u", "--user", default="admin")
ap.add_argument("-p", "--password", default="admin123")
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
ap.add_argument("--full", action="store_true", help="截整页长图")
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
ap.add_argument("--width", type=int, default=1440)
ap.add_argument("--height", type=int, default=900)
a = ap.parse_args()
db_path = a.db or os.path.join(BASE, "data", "usage.sqlite")
from playwright.sync_api import sync_playwright
exe = a.browser or _find_browser()
@@ -93,14 +148,35 @@ def main() -> int:
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
# 1) 先截未登录的登录页
page.goto(a.base + "/login", wait_until="networkidle")
page.screenshot(path=os.path.join(a.out, "00-login.png"), full_page=a.full)
print("[ok] 00-login.png")
def shoot(name, path, label):
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
page.screenshot(path=os.path.join(a.out, name), full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %-22s %s%s" % (name, label,
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
# 2) 登录
# 1) 未登录的两页
for name, path, label, need_auth in CAPTURES:
if need_auth:
break
shoot(name, path, label)
# 2) 登录(策略为 always 时自动解验证码)
page.goto(a.base + "/login", wait_until="networkidle")
page.fill('input[name=username]', a.user)
page.fill('input[name=password]', a.password)
if page.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
br.close()
return 1
page.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码")
page.click('button[type=submit]')
page.wait_for_load_state("networkidle")
if "/login" in page.url:
@@ -108,18 +184,11 @@ def main() -> int:
br.close()
return 1
# 3) 逐页截图
for i, (slug, path, label) in enumerate(PAGES[1:], start=1):
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
page.screenshot(path=os.path.join(a.out, "%02d-%s.png" % (i, slug)),
full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
flag = "" if not js_err else " [JS错误] " + " | ".join(js_err[:3])
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %02d-%s.png %s%s" % (i, slug, label, flag))
# 3) 登录后的页面
for name, path, label, need_auth in CAPTURES:
if not need_auth:
continue
shoot(name, path, label)
# 4) 大屏页再点几个交互,确认控件联动不炸
page.goto(a.base + "/dashboard", wait_until="networkidle")
+293 -55
查看文件
@@ -13,9 +13,14 @@
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见下 REGRESSIONS)
4. CSV 导出可被标准 csv 解析、列数一致
5. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
3. 历史缺陷防回归(见 4. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
5. CSV 导出可被标准 csv 解析、列数一致
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
不会改动任何用量数据。
用法:
cd workbuddy-portal
@@ -28,7 +33,9 @@ import csv
import io
import json
import os
import random
import re
import string
import sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -57,14 +64,19 @@ def note(msg: str) -> None:
print(" [note] %s" % msg)
def login(cli, admin=True):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。"""
def login(cli, uid: int, csrf: str = "smoke-csrf-token"):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。
只有 `uid` 是真正生效的键 —— `security.current_user()` 每请求回查
users 表(这样做是为了「停用账号立即失效」),所以 `uname/dname/adm`
只是写给自己看的标记,改不了权限。
"""
with cli.session_transaction() as s:
s["uid"] = 1
s["uname"] = "admin" if admin else "viewer"
s["dname"] = "管理员" if admin else "只读账号"
s["adm"] = 1 if admin else 0
s["_csrf"] = "smoke-csrf-token"
s["uid"] = uid
s["uname"] = "smoke-%s" % uid
s["dname"] = "smoke"
s["adm"] = 0
s["_csrf"] = csrf
def page(cli, path, method="GET", **kw):
@@ -72,36 +84,59 @@ def page(cli, path, method="GET", **kw):
return r.status_code, r.get_data(as_text=True)
def _rand(n=8):
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def run() -> None:
from workbuddy_portal import create_app, db, query
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==")
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
chk("create_app 成功", app is not None)
chk("路由数量 >= 35", n_routes >= 35, "routes=%d" % n_routes)
chk("路由数量 >= 40", n_routes >= 40, "routes=%d" % n_routes)
# 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect()
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone()
if admin_row is None:
print("\n[FATAL] 库里没有启用的管理员账号,先跑 python manage.py init")
return
ADMIN = admin_row["id"]
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users"):
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
# 只认 POST 的接口:GET 应当 405(而不是落到 401 或被 GET 直接执行)
# 未登录的 POST 会被 before_request 里的 CSRF 先拦下(400)——
# 这比先鉴权更好:没有会话就不该被允许碰任何写接口。
for p in ("/api/profile", "/api/captcha"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=405" % p, st == 405, "status=%s" % st)
st, _ = page(cli, p, method="POST")
chk("POST %-13s 无 CSRF=400" % p, st == 400, "status=%s" % st)
st, html = page(cli, "/login")
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
chk("登录页含验证码图", "capimg" in html and 'name="captcha"' in html)
chk("登录页含自助注册入口", "/register" in html)
# ---------------- 2. 管理员:全页面渲染 ----------------
print("== 2. 管理员:全页面渲染 ==")
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
pages = [
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
("/dashboard", "<html"),
("/profile", "个人中心"), ("/dashboard", "<html"),
]
for p, kw in pages:
st, html = page(cli, p)
@@ -112,24 +147,44 @@ def run() -> None:
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
st, html = page(cli, "/users")
chk("用户管理页列出账号", 'data-uid=' in html, "含行内编辑按钮")
chk("用户管理页列出账号", 'data-uid=' in html)
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
chk("用户管理页含审计表", "用户操作审计" in html)
chk("用户管理页含账号操作审计", "账号操作审计" in html)
chk("用户管理页含状态列", "status" in html and "停用" in html)
chk("用户管理页含邮箱列", "邮箱" in html)
# 配置页的维护按钮 + 大屏回后台入口
st, cfg = page(cli, "/config")
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
chk("配置页含实例级设置区", "仅管理员可改" in cfg)
chk("配置页显示凭证状态而非明文", "cookie_hint" in cfg or "字符" in cfg)
st, rec = page(cli, "/records")
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
st, pf = page(cli, "/profile")
chk("个人中心含改密表单", "/api/password" in pf or "password" in pf)
chk("个人中心说明凭证归属本人", "本人凭证" in pf or "我的 Cookie" in pf)
# 防回归:base.html 里曾用 {% set me = current_user() %},把子模板的 me
# 覆盖掉了 —— current_user() 只有 id/username/display_name/is_admin,
# 于是「注册于」渲染成空。变量已改名 cur,这里把两处都盯住。
lead = re.search(r'<p class="lead">(.*?)</p>', pf, re.S)
lead_txt = " ".join(lead.group(1).split()) if lead else ""
chk("个人中心「注册于」有真实时间",
bool(re.search(r"注册于\s+\d{4}-\d{2}-\d{2}", lead_txt)), lead_txt[:80])
chk("个人中心「最近登录」不是空占位",
bool(re.search(r"最近登录\s+\S", lead_txt)), lead_txt[:80])
chk("base.html 未再用 me 作局部变量(防覆盖子模板)",
"set me = " not in open(os.path.join(
BASE, "workbuddy_portal", "web", "templates", "base.html"),
encoding="utf-8").read())
# ---------------- 2b. 静态资源引用可解析 ----------------
print("== 2b. 页面引用的静态资源全部可达 ==")
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
with app.test_client() as cli:
login(cli, admin=True)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/dashboard"):
login(cli, ADMIN)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users",
"/profile", "/dashboard"):
_, html = page(cli, p)
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
# 不剥会把示例当真实引用误报。
@@ -150,25 +205,205 @@ def run() -> None:
chk("%-11s 资源引用全部 200" % p, not bad,
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
# ---------------- 3. 非管理员:权限边界 ----------------
print("== 3. 非管理员:/users 必须 403,导航不出现该入口 ==")
with app.test_client() as cli:
login(cli, admin=False)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
for p in ("/", "/records", "/tasks", "/logs"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# ---------------- 3. 多用户:隔离 / 保密 / 注册与验证码 ----------------
print("== 3. 多用户:数据隔离 / 凭证保密 / 注册与验证码 ==")
viewer = "smoke_v_%s" % _rand()
regged = "smoke_r_%s" % _rand()
created: list[str] = [viewer]
saved_ua_inst = None
# ---------------- 4. 历史缺陷防回归 ----------------
print("== 4. 历史缺陷防回归 ==")
def _mk_user(name):
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
"status,created_at) VALUES(?,?,?,0,'active',?)",
(name, security.hash_password("Smoke-Pass1"), "冒烟账号", db.now_str()))
return conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()["id"]
try:
VIEWER = _mk_user(viewer)
# ① 凭证密文入库
row = conn.execute("SELECT value FROM settings WHERE key='cookie' AND user_id=?",
(ADMIN,)).fetchone()
if row and row["value"]:
chk("① Cookie 以密文入库(v1. 前缀)", crypto.is_encrypted(row["value"]),
"head=%s" % row["value"][:12])
chk("① 密文不含明文片段",
crypto.is_encrypted(row["value"]) and ";" not in row["value"][:4])
plain = db.get_secret(conn, "cookie", ADMIN)
chk("① 解回来长度合理(>100 字符)", len(plain) > 100, "chars=%d" % len(plain))
else:
note("库里没有 Cookie,跳过密文断言")
# ② 凭证绝不跨账号回落
chk("② NO_FALLBACK_KEYS 含 cookie/user_agent",
{"cookie", "user_agent"} <= db.NO_FALLBACK_KEYS)
chk("② 新账号读不到别人的 Cookie", db.get_secret(conn, "cookie", VIEWER) == "")
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
saved_ua_inst = db.get_setting(conn, "user_agent", "", 0)
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
chk("② 实例级放哨兵后,新账号仍看不到它",
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
db.get_setting(conn, "user_agent", "", 0) == sentinel)
db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原
chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst)
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
chk("② 普通配置仍回落实例级",
db.get_setting(conn, "page_size", None, VIEWER) ==
db.get_setting(conn, "page_size", None, 0))
# ③ /api/settings 只给掩码,绝不给明文
with app.test_client() as cli:
login(cli, ADMIN)
st, body = page(cli, "/api/settings")
j = json.loads(body)
chk("③ /api/settings 200", st == 200, "status=%s" % st)
plain = db.get_secret(conn, "cookie", ADMIN)
chk("③ 响应体不含 Cookie 明文", not plain or plain not in body)
chk("③ cookie 字段被置空", not (j.get("cookie") or "").strip())
chk("③ 只给 cookie_hint 掩码", "cookie_hint" in j and "cookie_broken" in j)
chk("③ 标注实例级键清单", isinstance(j.get("_globalKeys"), list) and j["_globalKeys"])
chk("③ 管理员 _canEditGlobal=True", j.get("_canEditGlobal") is True)
chk("③ 无 slot:* 内部键", "slot:" not in body)
# ④ 验证码:不落 session、一次性、出图禁缓存
with app.test_client() as cli:
r = cli.get("/captcha.png?purpose=login")
chk("④ /captcha.png=200", r.status_code == 200, "status=%s" % r.status_code)
chk("④ 是 PNG 字节流",
r.headers.get("Content-Type", "").startswith("image/png")
and r.get_data()[:8] == b"\x89PNG\r\n\x1a\n")
chk("④ 出图禁缓存", "no-store" in r.headers.get("Cache-Control", ""))
with cli.session_transaction() as s:
cid = s.get("cap_login")
chk("④ 会话里只存验证码 id", bool(cid), "id=%s" % (cid or "无"))
ans = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("④ 答案只存在服务端 captchas 表",
ans is not None and len(ans["answer"]) >= 4 and ans["purpose"] == "login")
if ans:
st, html = page(cli, "/login")
chk("④ 页面 HTML 里搜不到答案", ans["answer"] not in html)
chk("④ 页面 JS 里也搜不到会话密钥", cid not in html)
# 一次性:同一个 id 用两次,第二次必须失败
if ans:
chk("④ 首次校验通过",
captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 同 id 二次校验失败(已消费)",
not captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 消费后记录已删除",
conn.execute("SELECT COUNT(*) FROM captchas WHERE id=?",
(cid,)).fetchone()[0] == 0)
# ⑤ 自助注册全链路(取答案 -> POST /register -> 账号可用)
with app.test_client() as cli:
cli.get("/register")
# 验证码图是浏览器去取的,test_client 不会自动加载 <img>,
# 所以这里显式打一次 —— 这一步正是「注册页有没有发挑战」的验证
r = cli.get("/captcha.png?purpose=register")
chk("⑤ 注册页的验证码接口可用", r.status_code == 200
and r.get_data()[:4] == b"\x89PNG", "status=%s" % r.status_code)
with cli.session_transaction() as s:
cid = s.get("cap_register")
# 这个客户端没有走 login() 注入固定 token,所以要取真实值;
# 顺手也证明了 /register 的 CSRF 校验确实在生效
csrf = s.get("_csrf")
a2 = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("⑤ 注册用的挑战落在 register 用途下",
a2 is not None and a2["purpose"] == "register")
if a2:
st, _ = page(cli, "/register", method="POST", data={
"username": regged, "display_name": "冒烟注册", "email": "",
"password": "Smoke-Pass1", "password2": "Smoke-Pass1",
"captcha": a2["answer"]},
headers={"X-CSRF-Token": csrf or ""})
chk("⑤ 注册成功=302", st == 302, "status=%s" % st)
created.append(regged)
u = conn.execute("SELECT id,is_admin,status,display_name,last_login_ip"
" FROM users WHERE username=?", (regged,)).fetchone()
chk("⑤ 建出的是普通账号",
u is not None and u["is_admin"] == 0 and u["status"] == "active")
chk("⑤ 注册即登录(会话已建立)",
u is not None and u["id"] == db.user_by_name(conn, regged)["id"])
# 缺 CSRF 必须 400
st, _ = page(cli, "/register", method="POST", data={"username": "x" * 3})
chk("⑤ 注册缺 CSRF=400", st == 400, "status=%s" % st)
# ⑥ 权限边界:普通账号改不了实例级配置
with app.test_client() as cli:
login(cli, VIEWER)
st, j = page(cli, "/api/settings")
chk("⑥ 非管理员 _canEditGlobal=False", json.loads(j).get("_canEditGlobal") is False)
evil = "http://evil.invalid"
st, _ = page(cli, "/api/settings", method="POST", json={"api_base": evil},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 非管理员改实例级配置=400", st == 400, "status=%s" % st)
chk("⑥ 且确实没写进去",
db.get_setting(conn, "api_base", "", VIEWER) != evil
and db.get_setting(conn, "api_base", "", 0) != evil)
# ⑦ 数据隔离:所有查询函数都必须显式带 uid
try:
query.daily(conn)
chk("⑦ query.daily 漏传 uid 会报错", False, "居然没报错")
except TypeError:
chk("⑦ query.daily 漏传 uid 会报错", True)
d_admin = query.daily(conn, ADMIN)
d_viewer = query.daily(conn, VIEWER)
chk("⑦ 不同账号的 daily 互不相同",
not d_admin or d_viewer != d_admin or len(d_viewer) == 0)
chk("⑦ 新账号 totals 为空", query.totals(conn, VIEWER)["records"] == 0)
t_admin = query.totals(conn, ADMIN)
chk("⑦ 管理员 totals 有数据", t_admin["records"] > 0, "records=%d" % t_admin["records"])
chk("⑦ totals(uid=0) 不含任何人的数据",
query.totals(conn, 0)["records"] == 0)
finally:
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)
if saved_ua_inst is not None:
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
for name in created:
conn.execute("DELETE FROM users WHERE username=?", (name,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)")
# 确认清理干净
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
).fetchone()[0]
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
# ---------------- 4. 普通账号的权限边界 ----------------
print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==")
viewer2 = "smoke_w_%s" % _rand()
try:
V2 = _mk_user(viewer2)
with app.test_client() as cli:
login(cli, V2)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# 日志尾部是管理员专属
st, _ = page(cli, "/logs/tail?lines=10")
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
finally:
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.close()
# ---------------- 5. 历史缺陷防回归 ----------------
print("== 5. 历史缺陷防回归 ==")
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
# ① 非法日期曾 500
st, body = page(cli, "/api/summary?from=abc&to=def")
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
@@ -222,8 +457,8 @@ def run() -> None:
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
# ⑪ 非管理员调用户管理 API
st, _ = page(cli, "/api/users/1/delete", method="POST", json={},
# ⑪ 管理员不能删自己
st, _ = page(cli, "/api/users/%d/delete" % ADMIN, method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
# ⑫ CSRF 缺失必须 400
@@ -251,10 +486,10 @@ def run() -> None:
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
"命中=%d 混入=%s" % (len(tags), others))
# ---------------- 5. 数据自洽 ----------------
print("== 5. 数据自洽(只读) ==")
# ---------------- 6. 数据自洽 ----------------
print("== 6. 数据自洽(只读) ==")
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
mf = json.loads(page(cli, "/api/manifest")[1])
src = (mf.get("sources") or [{}])[0]
chk("manifest 存档条数 == 数据源条数",
@@ -267,29 +502,32 @@ def run() -> None:
chk("summary 全量 credits 自洽",
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
d = query.daily(db.get_db())
d = query.daily(db.get_db(), ADMIN)
chk("daily 逐日积分求和 == 存档总额",
abs(round(sum(float(x["c"]) for x in d), 2)
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
chk("daily 逐日 h[24] 求和 == 当日积分",
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
note("存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
note("管理员存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
# ---------------- 6. class 名与 CSS 选择器对账 ----------------
print("== 6. 页面 class 与 app.css 选择器对账 ==")
# ---------------- 7. class 名与 CSS 选择器对账 ----------------
print("== 7. 页面 class 与 app.css 选择器对账 ==")
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
encoding="utf-8").read()
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
anon = ("/login", "/register")
auth = ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile")
used: set[str] = set()
for p in anon:
with app.test_client() as c2:
html = page(c2, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
with app.test_client() as cli:
login(cli, admin=True)
used: set[str] = set()
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/login"):
if p == "/login":
with app.test_client() as c2:
html = page(c2, p)[1]
else:
html = page(cli, p)[1]
login(cli, ADMIN)
for p in auth:
html = page(cli, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
# 允许的无样式类:JS 钩子、第三方/语义标记