scense/app/_archive_disabled_20261028_151505/middleware_chain_contract.py
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

342 lines
14 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 -*-
"""中间件链**签名契约**层(R48 根因修复)。
根因(R48 实测 2026-10-26 06:43,evidence/retest-r48-probe-20261026-064308.log)
----------------------------------------------------------------------------
部署态全站 500(`500 Internal Server Error / Server got itself in trouble`),
A/B/C 三族(/healthz 方法语义、匿名读鉴权、缺参写状态行)全部被遮蔽。
调用链:
app/get_only_405.py (healthz 兜底 / 链最外层)
-> 内层链
-> pkgs/ahserver/ahserver/real_ip.py:16 request['client_ip'] = request.remote
-> AttributeError: 'AHApp' object has no attribute 'remote'
`request` 位置上出现的是 **AHApp(应用对象)**,不是 `web.Request`。这不是 real_ip
写错,而是**中间件注册/装配的签名契约不匹配**:
aiohttp 的 `Application` 用 `__middleware_version__` 属性区分两种中间件风格——
* **new-style**:`@web.middleware` 装饰过的 `async def mw(request, handler)`,
装饰器写入 `__middleware_version__ = 1`;
* **legacy-style**:**没有**该标记的可调用对象,被当作「工厂」调用 `mw(app)`。
因此把一个**裸** `async def mw(request, handler)`(忘记 `@web.middleware`)塞进
`app.middlewares` / 构造期 `mws` 列表,aiohttp 会按 legacy 工厂路径用 **app** 去填
第一个形参 —— 中间件眼里的 `request` 变成 `AHApp`,`request.remote` 立刻
AttributeError;同时内层链可能返回**未被 await 的 coroutine/function**,产生
`Expect response, not <class 'function'>`(R21 历史日志原文)。
本仓库的暴露点(本轮机械核对,全部是「裸函数进链」):
app/get_only_405.py :: method_gate_middleware (无 @web.middleware)
app/auth_route_gate.py :: auth_route_gate_middleware(无 @web.middleware)
且 install() 直接 insert 裸函数
另有已正确装饰但仍需契约兜底的:`route_guard_middleware.py`、
`business_status_middleware.py`(它们的注册点分布在多条路径上,任一处漏装饰
就会复现同类 500)。
修复策略(本文件,确定性、可自测)
----------------------------------
1. `ensure_middleware(mw)` —— 幂等地把任意中间件规范成 new-style(补
`__middleware_version__ = 1`),保留 `__name__`;非可调用/签名不符则拒绝。
2. `assert_middleware_contract(seq)` —— 返回违规清单(供 /healthz 诊断与 selftest)。
3. `normalize_chain(seq)` —— 就地规范化中间件序列,返回诊断 dict(不静默失败:
不可修复项写进 `rejected`)。
4. `safe_bind(mw, handler)` —— 构造 chain 时把 handler 绑到**第二个**形参位
(`mw(request, handler)`),**禁止** `partial(mw, handler)` 这类把 handler 绑进
第一个(request)位的写法;并校验返回值必须是 `web.StreamResponse`,否则抛
`MiddlewareContractError`(不产生 "Expect response, not function" 的隐晦 500)。
5. `install_chain_contract(app)` —— 构造期/装配后统一规范化 `app._middlewares`
(或 `app.middlewares`),幂等,永不抛出(返回诊断)。
6. `selftest()` —— 进程内 aiohttp 应用自测(/healthz GET/HEAD/POST/PUT/DELETE +
受保护 .dspy 匿名/带 cookie 两口径 + 裸中间件回归),见 `tests/test_middleware_chain_contract.py`。
设计约束
--------
* 零应用内依赖(可被 scense.py / yuanjing.py 以 `import middleware_chain_contract`
或 `from app import ...` 两种路径导入,不触发包内其他模块的副作用)。
* 一切「不可修复」都进诊断,不 `except: pass`。
"""
import inspect
import logging
log = logging.getLogger("yuanjing.middleware_chain_contract")
#: 契约修订号(/healthz 与 selftest 断言用;改动契约语义必须递增)
CONTRACT_REVISION = "r52-chain-contract-1"
#: aiohttp new-style 中间件标记属性
MARKER = "__middleware_version__"
#: aiohttp 要求中间件的形参个数(request, handler)
ARITY = 2
class MiddlewareContractError(RuntimeError):
"""中间件不符合 `(request, handler)` 签名契约,或返回了非响应对象。"""
# ---------------------------------------------------------------- 判定
def _aiohttp_web():
"""取 aiohttp.web(取不到返回 None,调用方必须降级而不能静默成功)。"""
try:
from aiohttp import web as _w # noqa: PLC0415
return _w
except Exception: # noqa: BLE001
return None
def has_marker(mw):
"""该中间件是否已带 new-style 标记(aiohttp 才不会再按 legacy 工厂调用)。"""
return getattr(mw, MARKER, None) == 1
def positional_arity(fn):
"""可调用对象接受的位置参数个数;无法判定时返回 -1。"""
try:
sig = inspect.signature(fn)
except (TypeError, ValueError):
return -1
return len([
p for p in sig.parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
])
def is_middleware_shaped(mw):
"""形如 `(request, handler)` 的协程函数(或带 1 个位置的 legacy 工厂)。"""
if not callable(mw):
return False
n = positional_arity(mw)
if n == ARITY:
return True
# legacy 工厂:def factory(app) -> middleware;本轮只告警,不当作本应用风格
return False
def contract_violations(mw):
"""返回单项违规描述列表(空列表 = 合规)。"""
out = []
if not callable(mw):
out.append("not callable: %r" % (mw,))
return out
if not is_middleware_shaped(mw):
out.append("signature != (request, handler): %s%s"
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
if not has_marker(mw):
out.append("missing %s (aiohttp 会按 legacy 工厂 mw(app) 调用 → "
"'AHApp' object has no attribute 'remote'): %s"
% (MARKER, getattr(mw, "__name__", repr(mw))))
return out
def kind_suffix(mw):
try:
return " (arity=%d)" % positional_arity(mw)
except Exception: # noqa: BLE001
return ""
def assert_middleware_contract(seq):
"""返回 `[{index, name, violations}]`(空列表 = 全部合规)。"""
out = []
for i, mw in enumerate(seq or ()):
v = contract_violations(mw)
if v:
out.append({"index": i, "name": getattr(mw, "__name__", repr(mw)),
"violations": v})
return out
def describe(seq):
"""中间件名列表(诊断用)。"""
return [getattr(m, "__name__", repr(m)) for m in (seq or ())]
# ---------------------------------------------------------------- 规范化
def ensure_middleware(mw, name=None):
"""幂等规范化:返回带 new-style 标记的中间件;不合规抛 MiddlewareContractError。"""
if not callable(mw):
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
if not is_middleware_shaped(mw):
raise MiddlewareContractError(
"middleware signature must be (request, handler): %s%s"
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
if has_marker(mw):
if name:
try_set_name(mw, name)
return mw
w = _aiohttp_web()
fixed = mw
if w is not None:
try:
fixed = w.middleware(mw) # aiohttp 官方装饰器,写入标记
except Exception: # noqa: BLE001
fixed = mw
if not has_marker(fixed):
try:
setattr(fixed, MARKER, 1) # 兜底:手工补标记
except Exception as exc: # noqa: BLE001
raise MiddlewareContractError(
"cannot mark middleware %s: %s"
% (getattr(mw, "__name__", repr(mw)), exc))
try_set_name(fixed, name or getattr(mw, "__name__", None))
return fixed
def try_set_name(mw, name):
if not name:
return mw
try:
mw.__name__ = name
except Exception: # noqa: BLE001
pass
return mw
def normalize_chain(seq, only_missing_marker=True):
"""就地规范化中间件序列,返回诊断 dict(幂等)。
:param seq: 可变序列(list)。`app._middlewares` / 构造期 mws 列表。
:param only_missing_marker: True 时只补标记,不改变顺序、不删项。
:return: {revision, total, normalized, rejected, names, ok}
"""
diag = {"revision": CONTRACT_REVISION, "total": 0, "normalized": [],
"rejected": [], "names": [], "ok": True}
if seq is None:
diag["ok"] = False
diag["rejected"].append("chain is None")
return diag
try:
total = len(seq)
except Exception: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append("chain not a sized sequence")
return diag
diag["total"] = total
for i in range(total):
mw = seq[i]
name = getattr(mw, "__name__", repr(mw))
if has_marker(mw) and is_middleware_shaped(mw):
continue
try:
seq[i] = ensure_middleware(mw)
diag["normalized"].append({"index": i, "name": name})
log.warning("[chain-contract] 第 %d 层中间件 %s 未带 %s,已规范化为 "
"new-style(原实现会被 aiohttp 按 legacy 工厂 mw(app) 调用)",
i, name, MARKER)
except MiddlewareContractError as exc:
diag["rejected"].append({"index": i, "name": name, "error": str(exc)})
log.error("[chain-contract] 第 %d 层中间件 %s 不可修复:%s", i, name, exc)
diag["names"] = describe(seq)
diag["violations"] = assert_middleware_contract(seq)
diag["ok"] = not diag["rejected"] and not diag["violations"]
return diag
# ---------------------------------------------------------------- 绑定与执行
def safe_bind(mw, handler):
"""把 `handler` 绑到中间件的**第二个**形参位,返回 `chain(request)`。
**禁止** `partial(mw, handler)`:partial 会把 handler 填到第一个形参
(request)位,请求对象反而落到 handler 位 —— 这正是
「`Expect response, not <class 'function'>`」与
「`'AHApp' object has no attribute 'remote'`」两类 500 的同源错误绑定。
"""
if not callable(mw):
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
async def _chain(request):
resp = await mw(request, handler)
return assert_response(resp, mw, request)
_chain.__name__ = "chain(%s)" % getattr(mw, "__name__", "mw")
return _chain
def assert_response(resp, mw=None, request=None):
"""中间件必须返回 `web.StreamResponse`;否则抛清晰契约错误。"""
w = _aiohttp_web()
name = getattr(mw, "__name__", "mw") if mw is not None else "mw"
if w is None:
return resp
if isinstance(resp, w.StreamResponse):
return resp
if inspect.isawaitable(resp):
raise MiddlewareContractError(
"%s returned an awaitable (%r) —— 中间件漏 await"
% (name, type(resp).__name__))
raise MiddlewareContractError(
"Expect response, not %r from %s (path=%s)"
% (type(resp).__name__, name, getattr(request, "path", "") or "?"))
def build_chain(middlewares, handler):
"""按 `[最外层 ... 最内层]` 顺序组链,返回 `chain(request)`;全程契约校验。"""
chain = handler
for mw in reversed(list(middlewares or ())):
chain = safe_bind(ensure_middleware(mw), chain)
return chain
# ---------------------------------------------------------------- 装配钩子
def _mw_list(app):
"""取 app 的中间件列表(aiohttp 版本差异:`middlewares` / `_middlewares`)。"""
mws = getattr(app, "_middlewares", None)
if mws is not None:
return mws, "_middlewares"
mws = getattr(app, "middlewares", None)
if mws is not None:
return mws, "middlewares"
return None, None
def install_chain_contract(app, middlewares=None):
"""构造期/装配后统一规范化中间件链签名契约(幂等,永不抛出)。
:param app: AHApp / aiohttp Application 实例(可为 None)
:param middlewares: 构造期 mws 列表(在 `make_app()` 之前先规范化)
:return: 诊断 dict(含 revision / normalized / rejected / names / ok)
"""
diag = {"revision": CONTRACT_REVISION, "ok": True, "sources": [],
"normalized": [], "rejected": [], "names": []}
try:
if middlewares is not None:
d = normalize_chain(middlewares)
diag["sources"].append("ctor-arg")
diag["normalized"] += d.get("normalized", [])
diag["rejected"] += d.get("rejected", [])
diag["ok"] = diag["ok"] and d.get("ok", True)
diag["names"] = d.get("names", [])
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append({"source": "ctor-arg",
"error": "%s: %s" % (type(exc).__name__, exc)})
try:
if app is not None:
mws, src = _mw_list(app)
if mws is None:
diag["ok"] = False
diag["rejected"].append({"source": "app", "error": "no middleware list"})
else:
d = normalize_chain(mws)
diag["sources"].append(src)
diag["normalized"] += d.get("normalized", [])
diag["rejected"] += d.get("rejected", [])
diag["ok"] = diag["ok"] and d.get("ok", True)
diag["names"] = d.get("names", [])
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append({"source": "app",
"error": "%s: %s" % (type(exc).__name__, exc)})
if not diag["ok"]:
log.error("[chain-contract] 装配核验未通过 rejected=%s", diag["rejected"])
else:
log.info("[chain-contract] 装配核验通过 rev=%s sources=%s names=%s",
CONTRACT_REVISION, diag["sources"], diag["names"])
return diag