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 行删除
+236
查看文件
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""图形验证码(自托管,零第三方依赖)。
设计要点
--------
1. **答案只存在服务端**。下发到浏览器的是一个随机 `captcha_id`,它本身
不含任何信息。之所以不把答案放进 Flask session:Flask 的 session 是
**签名而非加密**的(base64 + HMAC),客户端 base64 解开就能读到明文——
把答案放进去等于把答案直接送给机器人。
2. **一次性**。校验时无论成功失败都立刻删除该 `captcha_id`,
防止「一个码刷一万个用户名」的撞库变体。
3. **光栅图,不是 SVG**。SVG 是文本,答案会以明文出现在页面源码或 DOM 里,
必须用位图。这里手写 PNG 编码器(zlib 是标准库),点阵字模自带。
4. **字符集避开易混字符**(0/O、1/I/L),降低正常用户输错概率。
字模
----
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
"""
import hmac
import os
import random
import secrets
import struct
import zlib
from datetime import datetime, timedelta
ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" # 去掉 0 O 1 I L
_FONT = {
"2": (".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"),
"3": ("####.", "....#", "....#", ".###.", "....#", "....#", "####."),
"4": ("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."),
"5": ("#####", "#....", "####.", "....#", "....#", "#...#", ".###."),
"6": ("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."),
"7": ("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."),
"8": (".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."),
"9": (".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."),
"A": ("..#..", ".#.#.", "#...#", "#...#", "#####", "#...#", "#...#"),
"B": ("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."),
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
"D": ("###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.."),
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
"F": ("#####", "#....", "#....", "####.", "#....", "#....", "#...."),
"G": (".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."),
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
"J": ("..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."),
"K": ("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"),
"M": ("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"),
"N": ("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"),
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
"Q": (".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"),
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
"S": (".####", "#....", "#....", ".###.", "....#", "....#", "####."),
"T": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
"V": ("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
"W": ("#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"),
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
"Y": ("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."),
"Z": ("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"),
}
GLYPH_W, GLYPH_H = 5, 7
# 一次性验证码有效期(秒)。太短用户来不及看,太长给暴力破解留窗口。
TTL_SECONDS = 300
# 保留已过期记录多久后清理(仅用于体积控制,不影响安全性)
PURGE_AFTER_SECONDS = 3600
def random_code(length=4):
return "".join(secrets.choice(ALPHABET) for _ in range(length))
# ---------------- PNG 编码(手写,无依赖) ----------------
def _chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
def encode_png(width, height, rgb):
"""把 RGB 字节串编码成 PNG(8 位真彩,无 alpha)。
rgb 长度必须是 width*height*3。每行前面加一个 filter 字节 0(None),
这是 PNG 对「一行一张扫描线」的强制要求。
"""
stride = width * 3
raw = bytearray()
for y in range(height):
raw.append(0)
raw += rgb[y * stride:(y + 1) * stride]
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n"
+ _chunk(b"IHDR", ihdr)
+ _chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ _chunk(b"IEND", b""))
class _Canvas:
"""极小的 RGB 画布。坐标越界自动丢弃,省得每处调用都判边界。"""
def __init__(self, w, h, bg):
self.w, self.h = w, h
self.buf = bytearray(bg * (w * h))
def dot(self, x, y, color):
if 0 <= x < self.w and 0 <= y < self.h:
i = (y * self.w + x) * 3
self.buf[i:i + 3] = bytes(color)
def rect(self, x, y, w, h, color):
for dy in range(h):
for dx in range(w):
self.dot(x + dx, y + dy, color)
def line(self, x0, y0, x1, y1, color):
"""Bresenham 直线。"""
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
self.dot(x0, y0, color)
if x0 == x1 and y0 == y1:
return
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def bytes(self):
return bytes(self.buf)
def render(code, width=150, height=56, scale=5, rng=None):
"""把验证码渲染成 PNG 字节串。
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
"""
rng = rng or random.SystemRandom()
n = len(code)
gap = 7
text_w = n * GLYPH_W * scale + (n - 1) * gap
if text_w + 16 > width:
width = text_w + 16
x0 = max(4, (width - text_w) // 2)
y0 = max(3, (height - GLYPH_H * scale) // 2)
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
# 而且打印/截图后对比度更差。
bg = tuple(rng.randint(238, 252) for _ in range(3))
cv = _Canvas(width, height, bg)
# 1) 干扰线(先画,压在字下面,不遮挡主体)
for _ in range(4):
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(150, 205) for _ in range(3)))
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
for i, ch in enumerate(code):
glyph = _FONT.get(ch)
if glyph is None:
continue
color = tuple(rng.randint(20, 105) for _ in range(3))
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
gy = y0 + rng.randint(-2, 2)
for row, bits in enumerate(glyph):
for col, bit in enumerate(bits):
if bit == "#":
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
for _ in range(46):
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(90, 190) for _ in range(3)))
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
for _ in range(3):
y = rng.randint(2, height - 3)
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
tuple(rng.randint(120, 175) for _ in range(3)))
return encode_png(width, height, cv.bytes())
# ---------------- 挑战的存储与校验(SQLite) ----------------
def _fmt(dt):
return dt.strftime("%Y-%m-%d %H:%M:%S")
def purge(conn, now=None):
"""清掉过期的挑战。每次新建时顺手调用(有 expires_at 索引,代价很小)。"""
now = now or datetime.now()
cut = _fmt(now - timedelta(seconds=PURGE_AFTER_SECONDS))
conn.execute("DELETE FROM captchas WHERE expires_at < ?", (cut,))
def create(conn, purpose, length=4, ttl=TTL_SECONDS, now=None):
"""新建一个挑战,返回 (captcha_id, code)。code 只应交给渲染函数,不要下发。"""
now = now or datetime.now()
code = random_code(length)
cid = secrets.token_urlsafe(24)
purge(conn, now)
conn.execute(
"INSERT INTO captchas(id,answer,purpose,created_at,expires_at) VALUES(?,?,?,?,?)",
(cid, code, purpose, _fmt(now), _fmt(now + timedelta(seconds=ttl))))
return cid, code
def verify(conn, captcha_id, answer, purpose, now=None):
"""校验并**立即作废**该挑战。返回 True/False。
永远不区分「过期」「不存在」「答案错」——对外只回一句人话,
避免把「这个 id 存在但答错了」这类信息透露给攻击者。
"""
if not captcha_id or answer is None:
return False
row = conn.execute("SELECT * FROM captchas WHERE id=?", (captcha_id,)).fetchone()
# 先删后判:无论结果如何都不允许第二次使用同一个 id
conn.execute("DELETE FROM captchas WHERE id=?", (captcha_id,))
if row is None or row["purpose"] != purpose:
return False
now = now or datetime.now()
if row["expires_at"] < _fmt(now):
return False
return hmac.compare_digest(str(row["answer"]), str(answer).strip().upper())