数据隔离
- 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 同步
237 行
9.7 KiB
Python
237 行
9.7 KiB
Python
# -*- 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())
|