ymq 702f27407b refactor(auth): 移除全部自造鉴权/会话/语义中间件,回归标准 ahserver+rbac
根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
 entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
  - auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
    created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
  - route_guard 的 _has_business_params 用 await request.read() 消费 POST body
    → up_login 收到 params_kw={} → password_encode(None) 崩 500

改动:
  - app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
    load_pybricks + 业务模块 load_*,零自定义中间件
  - 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
  - /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
    conf.website.startswiths 映射),不再用中间件拦截

实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
  healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
  伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
  新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
  错误密码 200 业务信封(不崩 500)

register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
2026-10-28 15:19:43 +08:00

567 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""统一鉴权门禁(存在性 → 鉴权 两段式;404 / 401 / 403 三态互不覆盖)。
覆盖缺陷族(T2 鉴权门禁族)
---------------------------
* LonNYvRxXk9EttSexkwkV:未登录 GET /scense/api/list_scense.dspy 返 200(应 401)
* LIyicannYDhJiXXkjTQ0N:不存在端点返 401 而非 404(鉴权先于路由)
* bj-n9DhoDKGz_lMHiUmbq:匿名 GET /scene/api/get_search_status.dspy 返 200+rows
* N4teiYPv2VUo2OWkoR8Fw:匿名 POST /scene/api/create_scene.dspy 进入业务分支返 200
* QuOjNlynfq3i8P7zdU2U4:匿名读 3 端点均 200 返业务数据
设计要点(为什么另起一个「统一门禁」而不是继续在旧 gate 上打补丁)
------------------------------------------------------------------
旧实现(route_guard_middleware.auth_required_middleware)依赖
`PROTECTED_DSPY_PATHS` 逐条点名 + 行为分支判定,结果是「点名的进闸、没点名的漏出去」,
于是 list_scense / get_search_status / create_scene 各自以 200 漏过,同一站内
"不存在路径 401、真实业务端点 200" 的语义自相矛盾。
本模块改为**默认拒绝(default-deny)**:
*.dspy ⇒ 除非在 PUBLIC_DSPY_PATHS(逐条精确、无通配)中,
否则一律「必须有有效会话」。
新增端点无需登记即自动纳管(get_search_* / list_* / create_* 全部覆盖)。
判定顺序(硬约束,两者不得互相覆盖):
① normalize 路径
② 后缀不是 .dspy → 放行(静态资源 / .ui 页面 / /healthz 交给既有组件)
③ 命中公开白名单(auth_exempt.is_exempt_path,精确/段边界,无通配)
→ 放行(**先于**存在性与鉴权:登录链路不得自锁)
④ 资源不存在 → 404 (先于鉴权:路径写错不应伪装成"未登录")
⑤ 无有效会话 / 会话无效 → 401 + WWW-Authenticate
⑥ 有有效会话 → 放行(后续 RBAC 权限不足由 ahserver 判 403)
403 的职责边界:本门禁只判「有没有身份」,不判「这个身份有没有该端点权限」——
后者是 RBAC(permission / rolepermission)的事,返回 403 由框架层给出。
两者分工在矩阵表里体现为 `登录态=有, 无权限 → 403(框架)`。
安装位置
--------
`install(app)` 把中间件插到 `app.middlewares` **首位(最外层)**:
aiohttp 的中间件列表首位 = 最外层,必须先于 ahserver 的鉴权层执行,
否则框架自身的 401(对不存在路径也 401)会抢在我们的 404 之前返回 -
这正是 LIyicannYDhJiXXkjTQ0N 的根因(「鉴权前置于路由判定」)。
零硬编码:白名单路径、后缀、cookie 名全部是本文件常量或从 conf/config.json 读取;
不写任何 host/port/域名。
"""
import sys
import json
import os
import importlib.util
import logging
# R52 QC#4:本文件多处 `log.error(...)` 落在 _load_contract_module / _new_style 的
# **兜底路径**上(设计意图是「宁可抛错也不静默放行」)。此前未定义 `log`、也未
# import logging,一旦真的走到兜底分支会先抛 `NameError: name 'log' is not defined`,
# 把原始异常覆盖成模块级 import 失败 → 应用启动即崩。与 get_only_405.py 对称定义。
log = logging.getLogger("scense.auth_route_gate")
# --------------------------------------------------------------------------- #
# 常量(全部逐条精确,**禁止通配符**:QC 硬门禁 #6)
# --------------------------------------------------------------------------- #
APP_DIR = os.path.dirname(os.path.abspath(__file__))
#: 关闸开关(off/0/false/no 关闭;默认开启)
GATE_ENV = "YUANJING_AUTH_ROUTE_GATE"
#: 只对 .dspy 端点做门禁(.ui 页面由会话层处理,避免把登录页自身锁死)
PROTECTED_SUFFIXES = (".dspy",)
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)──
# 修复前本文件自带一份 PUBLIC_DSPY_PATHS 副本,与 route_guard_middleware /
# auth_gate_strict / notfound_middleware 内联兜底闸门的另外几份副本**各自演化**,
# 结果「主线豁免、兜底路径不豁免」→ 登录端点在某些链路上仍 401。现在统一委托。
def _load_auth_exempt():
"""加载豁免清单模块(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
spec = importlib.util.spec_from_file_location(
"yj_auth_exempt", os.path.join(APP_DIR, "auth_exempt.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
EXEMPT = _load_auth_exempt()
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS /
PUBLIC_PATH_PREFIXES 只是**派生镜像**,用于自测交叉断言与诊断输出;
判定一律走 `EXEMPT.is_exempt_path()`。真源不可用/异常 → 空镜像 + fail-closed。
"""
if mod is None:
return {"revision": "unavailable", "dspy_paths": frozenset(),
"prefixes": (), "wildcard_free": False}
try:
mf = mod.manifest()
except Exception: # noqa: BLE001
# manifest 不可用时退回真源的属性(仍是同一份数据,不是本地字面量)
return {
"revision": getattr(mod, "EXEMPT_REVISION", "unavailable"),
"dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()),
"prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()),
"wildcard_free": bool(getattr(mod, "EXEMPT_REVISION", "")),
}
return {
"revision": str(mf.get("revision") or "unavailable"),
"dspy_paths": frozenset(mf.get("dspy_paths") or ()),
"prefixes": tuple(mf.get("prefixes") or ()),
"wildcard_free": bool(mf.get("wildcard_free", False)),
}
EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT)
#: 免鉴权端点(逐条精确路径;登录/注册/探针)—— **由 manifest() 派生**,只读镜像。
PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"]
#: 免鉴权前缀(静态资产 / 健康探针,按路径段边界匹配,无通配)—— **由 manifest() 派生**。
PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"]
#: 豁免口径版本(写进诊断与自测输出;同样来自 manifest())
EXEMPT_REVISION = EXEMPT_MANIFEST["revision"]
#: 中间件函数名(供装配后置核验使用)
MIDDLEWARE_NAME = "auth_route_gate_middleware"
#: 会话凭据来源(与 ahserver/rbac 对齐;具体 cookie 名从 conf 读)
DEFAULT_SESSION_COOKIE = "AIOSESSION"
# --------------------------------------------------------------------------- #
# 路径工具
# --------------------------------------------------------------------------- #
def _app_root():
"""应用根目录(= app/ 的上级)。"""
return os.path.dirname(APP_DIR)
def normalize_request_path(path):
"""归一路径:去 query/fragment、URL 解码、折叠重复斜杠、去尾斜杠。
不做大小写折叠(文件系统可能大小写敏感),小写化只用于白名单匹配。
"""
try:
from urllib.parse import unquote
except Exception: # pragma: no cover
def unquote(x):
return x
p = str(path or "")
for sep in ("?", "#"):
idx = p.find(sep)
if idx >= 0:
p = p[:idx]
try:
p = unquote(p)
except Exception: # noqa: BLE001
pass
p = p.replace("\\", "/")
while "//" in p:
p = p.replace("//", "/")
if len(p) > 1 and p.endswith("/"):
p = p.rstrip("/")
if not p.startswith("/"):
p = "/" + p
return p
def _candidate_paths(url_path):
"""把 URL 映射为候选文件路径(应用 wwwroot + 外部扫描根,任一存在即视为存在)。"""
p = normalize_request_path(url_path)
cands = []
for root, prefix in _scan_roots():
if prefix:
if not (p.lower() == prefix.lower() or p.lower().startswith(prefix.lower() + "/")):
continue
rel = p[len(prefix):].lstrip("/")
else:
rel = p.lstrip("/")
if not rel:
continue
cands.append(os.path.join(root, rel))
cands.append(os.path.join(root, rel, "index.dspy"))
cands.append(os.path.join(root, rel, "index.ui"))
return cands
def resource_exists(url_path):
"""资源在 wwwroot 下是否存在(软链已跟随即存在)。"""
for c in _candidate_paths(url_path):
try:
if os.path.isfile(c):
return True
except OSError:
continue
return False
def is_public_endpoint(url_path):
"""是否免鉴权(委托单一真源 auth_exempt.is_exempt_path,精确比对无通配)。"""
try:
return bool(EXEMPT.is_exempt_path(url_path))
except Exception: # noqa: BLE001
# 豁免判定自身故障 → fail-closed(不放行),但记日志,绝不静默。
log.error("[auth_route_gate] 豁免判定异常,按不豁免处理: %r", url_path)
return False
def is_protected(url_path):
"""该路径是否受本门禁保护(= 后缀在 PROTECTED_SUFFIXES 内且非公开)。"""
p = normalize_request_path(url_path)
if not p.lower().endswith(PROTECTED_SUFFIXES):
return False
return not is_public_endpoint(p)
# --------------------------------------------------------------------------- #
# 会话校验(复用 route_guard_middleware 的会话解密,不重复实现密码学)
# --------------------------------------------------------------------------- #
_ROUTE_GUARD = None
def _route_guard_module():
"""按文件路径加载 route_guard_middleware(不依赖 app 包可导入)。"""
global _ROUTE_GUARD
if _ROUTE_GUARD is not None:
return _ROUTE_GUARD
try:
from app import route_guard_middleware as rg # noqa: F401
_ROUTE_GUARD = rg
return rg
except Exception: # noqa: BLE001
pass
path = os.path.join(APP_DIR, "route_guard_middleware.py")
try:
spec = importlib.util.spec_from_file_location("_yj_route_guard", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_ROUTE_GUARD = mod
return mod
except Exception: # noqa: BLE001
_ROUTE_GUARD = False
return False
async def _verify_session_impl(request):
"""返回 (ok: bool, reason: str)。
优先委托 route_guard_middleware.verify_session(真实 cookie 解密 + rbac 会话)。
取不到实现时 **fail-closed**(返回 False)——宁可 401 也不放行匿名。
"""
rg = _route_guard_module()
if rg and hasattr(rg, "verify_session"):
try:
res = await rg.verify_session(request)
except Exception as exc: # noqa: BLE001
return False, "session_verify_error:%s" % type(exc).__name__
if isinstance(res, tuple):
return bool(res[0]), str(res[1] if len(res) > 1 else "")
return bool(res), ""
return False, "no_session_verifier"
#: 测试替身挂载点(selftest 覆盖此名字)
_verify_session = _verify_session_impl
async def verify_session(request):
return await _verify_session(request)
# --------------------------------------------------------------------------- #
# 纯函数判定(可脱框架单测)
# --------------------------------------------------------------------------- #
def gate_decision(url_path, exists=None, session_ok=None):
"""返回 (http_code, reason)。http_code 为 None = 放行。
exists / session_ok 传入 None 时按真实实现推算(exists 用文件系统,
session_ok 无法在纯函数里 await,必须显式传入)。
"""
p = normalize_request_path(url_path)
if not p.lower().endswith(PROTECTED_SUFFIXES):
return None, "not_dspy"
# 豁免先于存在性(与真实中间件同口径,T4)
if is_public_endpoint(p):
return None, "public_endpoint"
if exists is None:
exists = resource_exists(p)
if not exists:
return 404, "no such resource"
if not session_ok:
return 401, "authentication required"
return None, "authorized"
# --------------------------------------------------------------------------- #
# aiohttp 中间件
# --------------------------------------------------------------------------- #
def _json_response(status, payload, headers=None):
"""构造 JSON 响应;aiohttp 不可用时退化为轻量对象(供离线自测)。"""
body = json.dumps(payload, ensure_ascii=False)
try:
from aiohttp import web
return web.Response(status=status, text=body, content_type="application/json",
headers=headers or {})
except Exception: # noqa: BLE001
class _LiteResponse(object):
def __init__(self, status, text, hdrs):
self.status = status
self.text = text
self.headers = dict(hdrs or {})
self.content_type = "application/json"
def __repr__(self):
return "<LiteResponse %s %s>" % (self.status, self.text)
return _LiteResponse(status, body, headers)
def _gate_enabled():
return (os.environ.get(GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no")
async def auth_route_gate_middleware(request, handler):
"""存在性 → 鉴权(顺序不可颠倒;404 不得被 401 覆盖)。"""
if not _gate_enabled():
return await handler(request)
path = normalize_request_path(getattr(request, "path", "") or "")
if not path.lower().endswith(PROTECTED_SUFFIXES):
return await handler(request)
# ① 豁免(**必须先于存在性与会话校验**,T4 / Bug uifdBIMdmixZcmJEdV33O):
# 登录/登出/注册/会话自检端点在「还没有会话」的时刻被调用,任何形式的
# 404/401 都会造成「先登录才能登录」的自指死循环。豁免判定放在最前,
# 意味着 wwwroot 软链缺失(存在性为假)、会话组件不可用(校验抛错)
# 都不会把登录端点变成本门禁的错误信封;真不存在时由框架自己给 404。
if is_public_endpoint(path):
return await handler(request)
# ② 存在性:不存在 → 404(先于鉴权)
if not resource_exists(path):
return _json_response(404, {
"status": 404,
"success": False,
"ok": False,
"reason": "no such resource",
"path": path,
})
# ③ 鉴权:无有效会话 → 401
try:
ok, reason = await verify_session(request)
except Exception as exc: # noqa: BLE001
ok, reason = False, "session_verify_error:%s" % type(exc).__name__
if not ok:
return _json_response(401, {
"status": 401,
"success": False,
"ok": False,
"reason": "authentication required",
"path": path,
"detail": reason or "",
}, headers={"WWW-Authenticate": 'Session realm="yuanjing"'})
# ④ 有身份 → 放行(权限不足的 403 由框架 RBAC 判定)
return await handler(request)
# --------------------------------------------------------------------------- #
# 装配
# --------------------------------------------------------------------------- #
def _middleware_names(app):
return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or ())]
def install(app):
"""把门禁插到 app.middlewares 首位(最外层)。幂等,永不抛出。"""
out = {"layer": "auth_route_gate", "ok": False, "added": [], "error": None,
"position": None}
try:
names = _middleware_names(app)
if MIDDLEWARE_NAME in names:
out["ok"] = True
out["position"] = names.index(MIDDLEWARE_NAME)
out["added"] = []
else:
# [R52 chain-contract] 必须插 new-style(带 __middleware_version__)
# 中间件:裸 async def 进链会被 aiohttp 按 legacy 工厂 mw(app) 调用,
# request 形参收到的是 AHApp → `request.remote` AttributeError(R48 E1)。
app.middlewares.insert(0, _new_style(auth_route_gate_middleware))
names = _middleware_names(app)
out["added"] = [MIDDLEWARE_NAME]
out["position"] = names.index(MIDDLEWARE_NAME) if MIDDLEWARE_NAME in names else None
out["ok"] = out["position"] == 0
out["middlewares"] = names
if not out["ok"] and not out["error"]:
out["error"] = "not at outermost position: names=%s" % (names,)
except Exception as exc: # noqa: BLE001
out["error"] = "%s: %s" % (type(exc).__name__, exc)
return out
# --------------------------------------------------------------------------- #
# 覆盖矩阵(全模块同类排查的机械口径)
# --------------------------------------------------------------------------- #
def _scan_roots():
"""返回 [(物理根目录, URL 前缀)]:应用 wwwroot + 可选的外部模块仓库根。
外部根由环境变量 YUANJING_AUTH_GATE_SCAN_ROOTS 注入(逗号分隔的目录,
每个目录下按 <模块>/wwwroot 扫描),**脚本内不写任何绝对路径**——
部署机与开发工作区的目录层级不同,写死会误报「漏管」。
"""
roots = [(os.path.join(_app_root(), "wwwroot"), "")]
extra = (os.environ.get("YUANJING_AUTH_GATE_SCAN_ROOTS") or "").strip()
for one in extra.split(","):
one = one.strip()
if not one or not os.path.isdir(one):
continue
try:
names = sorted(os.listdir(one))
except OSError:
continue
for name in names:
ww = os.path.join(one, name, "wwwroot")
if os.path.isdir(ww):
roots.append((ww, "/" + name))
return roots
def iter_wwwroot_dspy():
"""遍历 wwwroot 下全部 .dspy(含经软链挂载的各模块),产出 URL 路径。"""
found = []
for root, prefix in _scan_roots():
for dirpath, _dirnames, filenames in os.walk(root, followlinks=True):
for fn in filenames:
if not fn.endswith(".dspy"):
continue
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root).replace(os.sep, "/")
found.append((prefix + "/" + rel) if prefix else ("/" + rel))
return sorted(set(found))
def coverage_matrix(paths=None):
"""返回 [{path, decision, public}],用于「纳管/漏管」机械核验。"""
rows = []
for p in (paths if paths is not None else iter_wwwroot_dspy()):
public = is_public_endpoint(p)
rows.append({
"path": p,
"public": public,
"decision": "public" if public else "protected",
})
return rows
def assert_no_wildcard(items):
"""白名单禁通配符(QC 硬门禁 #6)。"""
bad = [p for p in items if "*" in str(p) or "%" in str(p)]
if bad:
raise AssertionError("wildcard not allowed in allowlist: %r" % (bad,))
return True
assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES))
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):本文件的 `auth_route_gate_middleware` 是**裸** `async def
# (request, handler)`,没有 `@web.middleware`。aiohttp 用 `__middleware_version__`
# 区分中间件风格,缺该标记的可调用对象会被当作 **legacy 工厂** 调用 `mw(app)` ——
# 应用对象被填进第一个形参(request),于是 `pkgs/ahserver/ahserver/real_ip.py:16`
# 的 `request['client_ip'] = request.remote` 抛
# `AttributeError: 'AHApp' object has no attribute 'remote'`,全站 500。
# 这里在**模块级**把它规范成 new-style,并在 install() 插入前再规范化一次(幂等)。
_MCC = None
def _load_contract_module():
"""加载同目录 middleware_chain_contract.py(不依赖 sys.path / 包上下文)。
R48 E1 教训:`except ImportError: return mw` 的静默降级会让裸函数原样进链,
aiohttp 按 legacy 工厂 `mw(app)` 调用 → `real_ip.py:16 request.remote`
AttributeError,全站 500。这里改为 spec_from_file_location 按 `__file__`
同级路径显式加载(同仓 app/scense.py 的既有写法),仍失败返回 None 并由
调用方显式补标记 + 记 error(绝不静默放行)。
"""
global _MCC
if _MCC is not None:
return _MCC
try:
import middleware_chain_contract as _m
_MCC = _m
return _MCC
except ImportError:
pass
try:
from app import middleware_chain_contract as _m
_MCC = _m
return _MCC
except Exception: # noqa: BLE001
pass
_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"middleware_chain_contract.py")
try:
_spec = importlib.util.spec_from_file_location(
"middleware_chain_contract", _path)
if _spec is None or _spec.loader is None:
raise ImportError("spec_from_file_location 失败: %s" % _path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
sys.modules.setdefault("middleware_chain_contract", _mod)
_MCC = _mod
return _MCC
except Exception as _exc: # noqa: BLE001
log.error("[chain-contract] 无法加载 %s: %s", _path, _exc)
return None
def _new_style(mw):
"""把中间件规范成 new-style(带 __middleware_version__)。
**禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记
error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。
"""
_mcc = _load_contract_module()
fixed = mw
if _mcc is not None:
try:
fixed = _mcc.ensure_middleware(mw)
except Exception as _exc: # noqa: BLE001
log.error("[chain-contract] %s 规范化失败(%s),改用手工补 "
"new-style 标记", getattr(mw, "__name__", mw), _exc)
fixed = mw
if getattr(fixed, "__middleware_version__", None) != 1:
try:
setattr(fixed, "__middleware_version__", 1) # aiohttp 认这个标记
log.error("[chain-contract] %s 未经契约模块规范化,已手工补 "
"__middleware_version__=1(请检查中间件链装配)",
getattr(mw, "__name__", mw))
except Exception as _exc: # noqa: BLE001
raise RuntimeError(
"中间件 %s 既无法规范化也无法补标记: %s"
% (getattr(mw, "__name__", mw), _exc))
if getattr(fixed, "__middleware_version__", None) != 1: # 自校验,失败即抛
raise RuntimeError("中间件 %s 规范化后仍缺 __middleware_version__"
% (getattr(mw, "__name__", mw),))
return fixed
auth_route_gate_middleware = _new_style(auth_route_gate_middleware)