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 同步
这个提交包含在:
+303
-80
@@ -2,117 +2,211 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""密码哈希、登录装饰器、CSRF。
|
||||
"""鉴权、口令策略、图形验证码、限速、CSRF、安全响应头。
|
||||
|
||||
局域网可访问 ⇒ 必须有鉴权。这里用 Werkzeug 自带的 PBKDF2,不引第三方依赖。
|
||||
多用户下的三条红线
|
||||
------------------
|
||||
1. **Cookie(账号凭证)是按 user_id 分作用域存的**,读取一律走
|
||||
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
|
||||
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
|
||||
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
|
||||
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
|
||||
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
|
||||
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
|
||||
"""
|
||||
import functools
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from flask import (current_app, flash, jsonify, redirect, render_template, request,
|
||||
session, url_for)
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||
request, session, url_for)
|
||||
|
||||
from . import config, db
|
||||
from . import captcha, config, db
|
||||
|
||||
# 简易失败计数(内存即可:单进程部署,重启清零可接受)
|
||||
_fails = {} # ip -> [count, first_ts]
|
||||
_FAILS_MAX_IPS = 4096 # 上限,防止大量来源 IP 把字典撑爆
|
||||
_FAILS_TTL = 3600 # 超过 1 小时无更新的条目会被清理
|
||||
# ---------------- 失败计数(内存即可) ----------------
|
||||
# 单进程部署(见 README 的部署约束),重启清零可接受;
|
||||
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
|
||||
_fails = {} # key -> [count, last_ts]
|
||||
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
|
||||
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
|
||||
|
||||
CAPTCHA_SESSION_PREFIX = "cap_"
|
||||
|
||||
|
||||
def _prune_fails(now=None):
|
||||
"""清掉过期条目;条目数超上限时按时间淘汰最旧的。"""
|
||||
now = now or time.time()
|
||||
dead = [ip for ip, c in _fails.items() if now - c[1] > _FAILS_TTL]
|
||||
for ip in dead:
|
||||
_fails.pop(ip, None)
|
||||
if len(_fails) > _FAILS_MAX_IPS:
|
||||
for ip, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_IPS]:
|
||||
_fails.pop(ip, None)
|
||||
dead = [k for k, c in _fails.items() if now - c[1] > _FAILS_TTL]
|
||||
for k in dead:
|
||||
_fails.pop(k, None)
|
||||
if len(_fails) > _FAILS_MAX_KEYS:
|
||||
for k, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_KEYS]:
|
||||
_fails.pop(k, None)
|
||||
|
||||
|
||||
def _ip_key(ip):
|
||||
return "ip:" + (ip or "")
|
||||
|
||||
|
||||
def _user_key(username):
|
||||
return "user:" + (username or "").strip().lower()
|
||||
|
||||
|
||||
def note_fail(key):
|
||||
now = time.time()
|
||||
_prune_fails(now)
|
||||
c = _fails.get(key)
|
||||
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
|
||||
_fails[key] = [1, now]
|
||||
return 1
|
||||
c[0] += 1
|
||||
c[1] = now
|
||||
return c[0]
|
||||
|
||||
|
||||
def is_locked(key):
|
||||
c = _fails.get(key)
|
||||
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||
return False
|
||||
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
|
||||
|
||||
|
||||
def clear_fail(key):
|
||||
_fails.pop(key, None)
|
||||
|
||||
|
||||
def lock_left(key):
|
||||
c = _fails.get(key)
|
||||
if not c:
|
||||
return 0
|
||||
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||
|
||||
|
||||
def fail_count(key):
|
||||
c = _fails.get(key)
|
||||
return c[0] if c else 0
|
||||
|
||||
|
||||
def auth_locked(ip, username=""):
|
||||
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
|
||||
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
|
||||
|
||||
|
||||
def note_auth_fail(ip, username=""):
|
||||
n1 = note_fail(_ip_key(ip))
|
||||
n2 = note_fail(_user_key(username)) if username else 0
|
||||
return max(n1, n2)
|
||||
|
||||
|
||||
def clear_auth_fail(ip, username=""):
|
||||
clear_fail(_ip_key(ip))
|
||||
if username:
|
||||
clear_fail(_user_key(username))
|
||||
|
||||
|
||||
# ---------------- 口令 / 用户名策略 ----------------
|
||||
_USERNAME_RE = re.compile(config.USERNAME_RE)
|
||||
|
||||
|
||||
def hash_password(p):
|
||||
from werkzeug.security import generate_password_hash
|
||||
return generate_password_hash(p, method="pbkdf2:sha256:200000")
|
||||
|
||||
|
||||
def verify_password(hashed, p):
|
||||
from werkzeug.security import check_password_hash
|
||||
try:
|
||||
return check_password_hash(hashed, p)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def username_problem(name):
|
||||
"""校验用户名。开放注册后这是第一个入口,必须收紧。"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return "用户名必填"
|
||||
if not _USERNAME_RE.match(name):
|
||||
return "用户名需 3~32 位,以字母或数字开头,只能用字母、数字、下划线、点、连字符"
|
||||
if name.lower() in ("admin", "administrator", "root", "system", "guest", "null"):
|
||||
return "该用户名为系统保留字,请换一个"
|
||||
return None
|
||||
|
||||
|
||||
def password_problem(new, new2=None, username=None):
|
||||
"""口令强度:8 位以上,且至少包含两类字符。
|
||||
|
||||
比原来的「只要 6 位」严格——因为现在任何人都能自助注册,
|
||||
弱口令直接决定了整个实例的抗爆破能力。
|
||||
"""
|
||||
new = new or ""
|
||||
if len(new) < config.PASSWORD_MIN:
|
||||
return "密码至少 %d 位" % config.PASSWORD_MIN
|
||||
if len(new) > config.PASSWORD_MAX:
|
||||
return "密码过长(上限 %d 位)" % config.PASSWORD_MAX
|
||||
classes = sum(bool(re.search(p, new)) for p in
|
||||
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
|
||||
if classes < 2:
|
||||
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
|
||||
if new2 is not None and new2 != new:
|
||||
return "两次输入的新密码不一致"
|
||||
if username and new.lower() == str(username).lower():
|
||||
return "密码不能与用户名相同"
|
||||
return None
|
||||
|
||||
|
||||
# 兼容旧名(原来的 api.py 内部函数)
|
||||
_check_password = password_problem
|
||||
|
||||
|
||||
# ---------------- 登录 ----------------
|
||||
def login_ok(conn, username, password):
|
||||
"""校验口令。返回 (user_row, error_message)。
|
||||
|
||||
停用账号与口令错误返回**同一句话**,避免探测哪些用户名存在
|
||||
(不过自助注册本身就暴露了用户名唯一性,这里只是不打额外的广告)。
|
||||
"""
|
||||
username = (username or "").strip()
|
||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row is None or not verify_password(row["password_hash"], password):
|
||||
return None
|
||||
return None, "用户名或密码不正确"
|
||||
if (row["status"] or "active") != "active":
|
||||
return None, "该账号已被停用,请联系管理员"
|
||||
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
|
||||
(db.now_str(), row["id"]))
|
||||
return row
|
||||
|
||||
|
||||
# ---------------- 跳转目标白名单(防开放重定向) ----------------
|
||||
def safe_next(target, fallback="/"):
|
||||
"""只允许站内相对路径。
|
||||
|
||||
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
|
||||
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
|
||||
"""
|
||||
if not target:
|
||||
return fallback
|
||||
t = str(target).strip()
|
||||
if not t.startswith("/"):
|
||||
return fallback
|
||||
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
|
||||
return fallback
|
||||
# 去重斜杠后仍以 // 开头的(如 "/\t/evil")一并拒绝
|
||||
if t.lstrip("/").startswith("//"):
|
||||
return fallback
|
||||
if "\r" in t or "\n" in t:
|
||||
return fallback
|
||||
return t
|
||||
|
||||
|
||||
# ---------------- 登录失败限速 ----------------
|
||||
def note_fail(ip):
|
||||
now = time.time()
|
||||
_prune_fails(now)
|
||||
c = _fails.get(ip)
|
||||
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
|
||||
_fails[ip] = [1, now]
|
||||
return 1
|
||||
c[0] += 1
|
||||
return c[0]
|
||||
|
||||
|
||||
def is_locked(ip):
|
||||
c = _fails.get(ip)
|
||||
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||
return False
|
||||
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
|
||||
|
||||
|
||||
def clear_fail(ip):
|
||||
_fails.pop(ip, None)
|
||||
|
||||
|
||||
def lock_left(ip):
|
||||
c = _fails.get(ip)
|
||||
if not c:
|
||||
return 0
|
||||
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||
return row, None
|
||||
|
||||
|
||||
# ---------------- 会话 ----------------
|
||||
def current_user():
|
||||
"""当前登录用户(dict)或 None。
|
||||
|
||||
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
|
||||
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
|
||||
"""
|
||||
if "wb_user" in g:
|
||||
return g.wb_user
|
||||
uid = session.get("uid")
|
||||
if not uid:
|
||||
return None
|
||||
return {"id": uid, "username": session.get("uname"), "display_name": session.get("dname"),
|
||||
"is_admin": bool(session.get("adm", 1))}
|
||||
user = None
|
||||
if uid:
|
||||
try:
|
||||
row = db.get_db().execute(
|
||||
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
|
||||
(uid,)).fetchone()
|
||||
except Exception: # noqa: BLE001 (无请求上下文等)
|
||||
row = None
|
||||
if row is None or (row["status"] or "active") != "active":
|
||||
session.clear()
|
||||
else:
|
||||
user = {"id": row["id"], "username": row["username"],
|
||||
"display_name": row["display_name"] or row["username"],
|
||||
"is_admin": bool(row["is_admin"])}
|
||||
# 个人信息(显示名)改过之后立即生效,不必重新登录
|
||||
session["dname"] = user["display_name"]
|
||||
session["adm"] = 1 if user["is_admin"] else 0
|
||||
g.wb_user = user
|
||||
return user
|
||||
|
||||
|
||||
def is_admin():
|
||||
@@ -121,14 +215,17 @@ def is_admin():
|
||||
|
||||
|
||||
def login_session(user):
|
||||
"""建立登录会话。
|
||||
|
||||
`session.clear()` 是必须的:既清掉前一次的残留,
|
||||
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
|
||||
"""
|
||||
session.clear()
|
||||
session["uid"] = user["id"]
|
||||
session["uname"] = user["username"]
|
||||
session["dname"] = user["display_name"] or user["username"]
|
||||
try:
|
||||
session["adm"] = 1 if user["is_admin"] else 0
|
||||
except (KeyError, IndexError, TypeError):
|
||||
session["adm"] = 1
|
||||
session["adm"] = 1 if user["is_admin"] else 0
|
||||
session["login_at"] = db.now_str()
|
||||
session.permanent = True
|
||||
|
||||
|
||||
@@ -167,6 +264,100 @@ def admin_required(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
# ---------------- 跳转目标白名单(防开放重定向) ----------------
|
||||
def safe_next(target, fallback="/"):
|
||||
"""只允许站内相对路径。
|
||||
|
||||
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
|
||||
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
|
||||
"""
|
||||
if not target:
|
||||
return fallback
|
||||
t = str(target).strip()
|
||||
if not t.startswith("/"):
|
||||
return fallback
|
||||
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
|
||||
return fallback
|
||||
if t.lstrip("/").startswith("//"):
|
||||
return fallback
|
||||
if "\r" in t or "\n" in t:
|
||||
return fallback
|
||||
return t
|
||||
|
||||
|
||||
# ---------------- 图形验证码 ----------------
|
||||
def captcha_required(conn, ip, username=""):
|
||||
"""按 captcha_policy 决定本次是否需要验证码。"""
|
||||
policy = (db.get_setting(conn, "captcha_policy", "always") or "always").strip().lower()
|
||||
if policy == "off":
|
||||
return False
|
||||
if policy == "adaptive":
|
||||
# 「自适应」= 这个来源出过问题才要求,日常登录不打扰
|
||||
return (fail_count(_ip_key(ip)) >= 2
|
||||
or (username and fail_count(_user_key(username)) >= 2))
|
||||
return True # always(默认)
|
||||
|
||||
|
||||
def issue_captcha(conn, purpose):
|
||||
"""新建验证码并把 id 记进会话,返回 PNG 字节。答案绝不离开服务端。"""
|
||||
try:
|
||||
length = int(db.get_setting(conn, "captcha_length", 4) or 4)
|
||||
except (TypeError, ValueError):
|
||||
length = 4
|
||||
length = max(4, min(6, length))
|
||||
cid, code = captcha.create(conn, purpose, length=length)
|
||||
session[CAPTCHA_SESSION_PREFIX + purpose] = cid
|
||||
return captcha.render(code, width=150 if length <= 4 else 150 + (length - 4) * 32)
|
||||
|
||||
|
||||
def consume_captcha(conn, purpose, answer):
|
||||
"""校验并作废本次验证码。会话里的 id 一并丢掉,逼迫下次换一张新图。"""
|
||||
cid = session.pop(CAPTCHA_SESSION_PREFIX + purpose, None)
|
||||
return captcha.verify(conn, cid, (answer or "").strip().upper(), purpose)
|
||||
|
||||
|
||||
# 验证码出图限速:出图本身要做点阵渲染 + zlib,不设限就是一条廉价的
|
||||
# CPU/带宽放大路径(有人拿它当免费的图片生成器刷)。
|
||||
_cap_fetch = {} # ip -> [count, window_started_at]
|
||||
_CAP_FETCH_MAX = 40 # 每窗口最多出图张数
|
||||
_CAP_FETCH_WINDOW = 60 # 窗口长度(秒)
|
||||
|
||||
|
||||
def captcha_fetch_allowed(ip):
|
||||
now = time.time()
|
||||
cur = _cap_fetch.get(ip)
|
||||
if cur is None or now - cur[1] > _CAP_FETCH_WINDOW:
|
||||
if len(_cap_fetch) > _FAILS_MAX_KEYS:
|
||||
_cap_fetch.clear()
|
||||
_cap_fetch[ip] = [1, now]
|
||||
return True
|
||||
cur[0] += 1
|
||||
return cur[0] <= _CAP_FETCH_MAX
|
||||
|
||||
|
||||
def audit_login_fail(conn, username, detail, ip):
|
||||
"""登录失败审计。
|
||||
|
||||
`user_id` 留 0:此时还不能确定是谁(可能是有人在撞别人的账号),
|
||||
但 `actor` 记下被尝试的用户名,便于事后按人名检索。
|
||||
"""
|
||||
db.audit(conn, "login_failed", username or "-", detail, ip, 0)
|
||||
|
||||
|
||||
# ---------------- 注册开关与配额 ----------------
|
||||
def register_allowed(conn):
|
||||
return db.get_bool(conn, "allow_register", True)
|
||||
|
||||
|
||||
def register_quota(conn, ip):
|
||||
"""同一 IP 当天的注册配额。返回 (是否允许, 已注册数, 上限)。"""
|
||||
limit = db.get_int(conn, "register_max_per_ip", 3)
|
||||
today = db.now_str()[:10]
|
||||
n = conn.execute("SELECT COUNT(*) FROM users WHERE register_ip=?"
|
||||
" AND substr(COALESCE(created_at,''),1,10)=?", (ip, today)).fetchone()[0]
|
||||
return n < limit, n, limit
|
||||
|
||||
|
||||
# ---------------- CSRF ----------------
|
||||
def csrf_token():
|
||||
t = session.get("_csrf")
|
||||
@@ -182,11 +373,39 @@ def check_csrf():
|
||||
sent = request.form.get("_csrf") or request.headers.get("X-CSRF-Token") or ""
|
||||
if not sent or not hmac.compare_digest(sent, session.get("_csrf", "")):
|
||||
if wants_json():
|
||||
return jsonify({"ok": False, "error": "csrf", "message": "CSRF 校验失败,请刷新页面"}), 400
|
||||
return jsonify({"ok": False, "error": "csrf",
|
||||
"message": "CSRF 校验失败,请刷新页面"}), 400
|
||||
return "CSRF 校验失败,请刷新页面后重试", 400
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- 安全响应头 ----------------
|
||||
# 这些头是「纵深防御」:本项目的输出都过了 Jinja 自动转义 + app.js 手动转义,
|
||||
# 但多一层 nosniff / frame-ancestors 能让「某处漏转义」不至于直接变成可利用的 XSS。
|
||||
CSP = ("default-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"connect-src 'self'; "
|
||||
"font-src 'self' data:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'")
|
||||
|
||||
|
||||
def apply_security_headers(resp):
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
# 不让站内 URL(可能含 next=、run= 等参数)随外链 referer 泄漏出去
|
||||
resp.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
resp.headers.setdefault("Content-Security-Policy", CSP)
|
||||
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
|
||||
resp.headers.setdefault("Cache-Control", "no-store")
|
||||
return resp
|
||||
|
||||
|
||||
def init_app(app):
|
||||
app.jinja_env.globals["csrf_token"] = csrf_token
|
||||
app.jinja_env.globals["current_user"] = current_user
|
||||
@@ -194,3 +413,7 @@ def init_app(app):
|
||||
@app.before_request
|
||||
def _guard():
|
||||
return check_csrf()
|
||||
|
||||
@app.after_request
|
||||
def _headers(resp):
|
||||
return apply_security_headers(resp)
|
||||
|
||||
在新工单中引用
屏蔽一个用户