根因:元景迭代中为通过 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 中间件提供。
313 lines
13 KiB
Python
313 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""aiohttp 中间件链「签名归一 + 响应契约」模块(Bug rvtGi1mlUsTwQrXnybZj6 / R60 正解)。
|
||
|
||
问题(部署态实测,证据 docs/04-deploy/evidence_r58/)
|
||
----------------------------------------------------
|
||
`POST /rbac/user/up_login.dspy` 恒返回 `401 {"error": "auth_unavailable"}`、无 `Set-Cookie`;
|
||
`logs/scense.log:1735-1737` 三条原文:
|
||
|
||
[auth_strict] 闸门异常但为公开路径,放行: /rbac/user/up_login.dspy
|
||
[auth_strict] 拒绝匿名/无效会话请求: POST /rbac/user/up_login.dspy reason=auth_unavailable
|
||
[auth_strict] 闸门异常: Expect response, not <class 'function'>
|
||
|
||
`Expect response, not <class 'function'>` = **链上某层把「未 await 的 handler 函数本体」
|
||
当成 Response 交回上游**。成因是 13 层中间件里新式 `async def (request, handler)` 与旧式工厂
|
||
`def (app, handler) -> handler` 混排,链构建器按新式语义调用旧式工厂时拿到的就是 handler。
|
||
真实故障被 `auth_gate_strict` 的 fail-closed `except` 降级成 401,于是「登录端点自己 401」,
|
||
形成 "先登录才能登录" 的自锁。
|
||
|
||
为什么不能靠 `__middleware_version__` 标记(R55 已实测失败,勿重试)
|
||
--------------------------------------------------------------------
|
||
aiohttp>=3.9 用 `getattr(m, "__middleware_version__", None) == 1` 区分新式/旧式。
|
||
但标记只能由 `web.middleware` 装饰器写在**函数对象**上;链上还可能存在
|
||
`functools.partial` / 可调用实例 / 已冻结 FrozenList 中的对象,setattr 静默失败,
|
||
R55 目标机实测 `new_style_marked=[]`(标记一个没生效)。**标记不可依赖**。
|
||
|
||
本模块的正解
|
||
------------
|
||
不再猜协议、不再改原对象,而是把**每一个**中间件包成同一种适配器:
|
||
|
||
* 适配器自身是 `async def (request, handler)`,且带 `__middleware_version__ = 1`
|
||
(属性写在适配器函数对象上,一定成功——不是去改别人的对象);
|
||
* 适配器**按返回值自适应**:awaitable → 新式,直接 await;callable → 旧式工厂,
|
||
继续以其返回值收口(旧式 `(app, handler)` 用 `request.app` 作为 app 传入);
|
||
* 出口做**响应契约校验**:最终必须是非 callable 的 Response 类对象,否则抛
|
||
`ResponseContractError`(含层名 + 原始类型)。这样「把 handler 当 Response 返回」
|
||
会立刻变成一条带层名的明确 500,而不是被下游闸门静默改写成 401。
|
||
|
||
用法(`app/scense.py::_load_semantic_middlewares()` 的调用点)
|
||
--------------------------------------------------------------
|
||
from mw_signature import normalize_chain
|
||
mws = normalize_chain(_load_semantic_middlewares()) # 幂等
|
||
|
||
# app.middlewares 已就位时(钩子期兜底,可选):
|
||
from mw_signature import normalize_app_middlewares
|
||
normalize_app_middlewares(app)
|
||
|
||
不依赖 aiohttp 的导入(本地无 aiohttp 的可测性):只在需要时探 `aiohttp.web`
|
||
判断 Response 类型,缺失时退化为鸭子类型(`hasattr(resp, "status")` 且非 callable),
|
||
因此单元自测可在无 aiohttp 环境跑(见 scripts/selftest_mw_signature.py)。
|
||
"""
|
||
|
||
import inspect
|
||
import logging
|
||
|
||
log = logging.getLogger("yuanjing.mw_signature")
|
||
|
||
#: aiohttp 判定新式中间件用的属性名(只写在我们自己的适配器上)。
|
||
NEW_STYLE_ATTR = "__middleware_version__"
|
||
|
||
#: 适配器幂等标记(同一对象重复 normalize 不再套娃)。
|
||
ADAPTER_MARK = "__mw_signature_adapter__"
|
||
|
||
#: 旧式工厂的第一形参惯用名。命中即按「工厂 (app, handler) -> handler」调用。
|
||
LEGACY_FIRST_PARAMS = frozenset(("app", "application", "_app", "ahapp"))
|
||
|
||
try: # pragma: no cover - 目标机必有 aiohttp,本机可能没有
|
||
from aiohttp import web as _aiohttp_web
|
||
|
||
_RESPONSE_BASES = (_aiohttp_web.StreamResponse,)
|
||
except Exception: # noqa: BLE001
|
||
_aiohttp_web = None
|
||
_RESPONSE_BASES = ()
|
||
|
||
|
||
class ResponseContractError(RuntimeError):
|
||
"""中间件把非 Response(典型:handler 函数本体)返回给了上游。
|
||
|
||
这不是 401 语义,是**装配/契约缺陷**:必须 500 + traceback,禁止降级成鉴权失败。
|
||
"""
|
||
|
||
|
||
def describe(mw):
|
||
"""给中间件一个可读名字(日志/诊断用,永不抛)。"""
|
||
for attr in ("__qualname__", "__name__"):
|
||
val = getattr(mw, attr, None)
|
||
if isinstance(val, str) and val:
|
||
mod = getattr(mw, "__module__", "") or ""
|
||
return ("%s.%s" % (mod, val)) if mod else val
|
||
return type(mw).__name__
|
||
|
||
|
||
def is_new_style(mw):
|
||
"""是否已被标记为新式 `(request, handler)` 中间件。"""
|
||
return getattr(mw, NEW_STYLE_ATTR, None) == 1
|
||
|
||
|
||
def is_adapter(mw):
|
||
return bool(getattr(mw, ADAPTER_MARK, False))
|
||
|
||
|
||
def _first_param_name(fn):
|
||
try:
|
||
params = list(inspect.signature(fn).parameters.values())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
for p in params:
|
||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
|
||
return p.name.lower()
|
||
return None
|
||
|
||
|
||
def _looks_like_response(obj):
|
||
"""Response 判定:优先 aiohttp 真实类型,无 aiohttp 时鸭子类型。"""
|
||
if obj is None or callable(obj):
|
||
return False
|
||
if _RESPONSE_BASES and isinstance(obj, _RESPONSE_BASES):
|
||
return True
|
||
# 鸭子类型:StreamResponse 家族都有 status + headers
|
||
return hasattr(obj, "status") and hasattr(obj, "headers") and not inspect.iscoroutine(obj)
|
||
|
||
|
||
def _contract_check(result, mw, request):
|
||
"""出口响应契约校验:非 Response → ResponseContractError(带层名,可定位)。"""
|
||
if _looks_like_response(result):
|
||
return result
|
||
path = getattr(request, "path", "-")
|
||
kind = type(result).__name__
|
||
if callable(result):
|
||
detail = ("中间件返回了可调用对象(%s),而不是 Response —— "
|
||
"典型的旧式工厂被按新式调用 / handler 未 await 直接返回" % kind)
|
||
elif result is None:
|
||
detail = "中间件返回 None(漏写 return 或 return 写在 async with 块内)"
|
||
else:
|
||
detail = "中间件返回了非 Response 对象(%s)" % kind
|
||
raise ResponseContractError(
|
||
"Expect response, not %r: layer=%s path=%s: %s"
|
||
% (kind, describe(mw), path, detail))
|
||
|
||
|
||
def _call_legacy_factory(factory, request, handler):
|
||
"""旧式工厂:(app, handler) -> 新 handler。app 取 request.app(取不到给 None)。"""
|
||
app = getattr(request, "app", None)
|
||
return factory(app, handler)
|
||
|
||
|
||
async def _invoke(mw, request, handler):
|
||
"""按返回值自适应地执行一层中间件,并做响应契约校验。"""
|
||
first = _first_param_name(mw)
|
||
if first in LEGACY_FIRST_PARAMS:
|
||
result = _call_legacy_factory(mw, request, handler)
|
||
else:
|
||
result = mw(request, handler)
|
||
if inspect.isawaitable(result):
|
||
return _contract_check(await result, mw, request)
|
||
if callable(result):
|
||
# 旧式工厂的返回值:新 handler,继续收口一次
|
||
inner = result
|
||
resp = inner(request)
|
||
if inspect.isawaitable(resp):
|
||
resp = await resp
|
||
return _contract_check(resp, mw, request)
|
||
return _contract_check(result, mw, request)
|
||
|
||
|
||
def adapt(mw):
|
||
"""把单个中间件包成统一的新式适配器(幂等;已是适配器则原样返回)。"""
|
||
if is_adapter(mw):
|
||
return mw
|
||
layer = describe(mw)
|
||
|
||
async def _adapter(request, handler):
|
||
try:
|
||
return await _invoke(mw, request, handler)
|
||
except ResponseContractError:
|
||
# 契约错误原样上抛(带层名),由上层/闸门转 500 —— 绝不改写成 401
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
log.exception("[mw-signature] 层内部异常 layer=%s path=%s: %s: %s",
|
||
layer, getattr(request, "path", "-"),
|
||
type(exc).__name__, exc)
|
||
raise
|
||
|
||
try:
|
||
_adapter.__name__ = "%s__mw_sig" % getattr(mw, "__name__", type(mw).__name__)
|
||
_adapter.__qualname__ = _adapter.__name__
|
||
_adapter.__doc__ = "mw_signature adapter of %s" % layer
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
setattr(_adapter, NEW_STYLE_ATTR, 1)
|
||
setattr(_adapter, ADAPTER_MARK, True)
|
||
setattr(_adapter, "__mw_signature_of__", layer)
|
||
return _adapter
|
||
|
||
|
||
def normalize_chain(middlewares):
|
||
"""归一整个中间件列表(顺序不变:列表首位 = 最外层)。
|
||
|
||
:param middlewares: 可迭代中间件
|
||
:return: `ChainResult`(list 子类,可直接 `app = web.Application(middlewares=normalize_chain(mws))`)。
|
||
报告从属性取:`res = normalize_chain(mws); res.report`。
|
||
|
||
[fix rvtGi1mlUsTwQrXnybZj6 / R61] 旧的 docstring 声称支持
|
||
`mws, report = normalize_chain(x)` 两值解包 —— **实际不支持**:ChainResult 是
|
||
list 子类,两值解包会按元素迭代,抛 `ValueError: too many values to unpack`
|
||
(R61 本地 e2e 实测命中)。这里改正契约说明,避免调用方按错误文档取报告。
|
||
"""
|
||
out = []
|
||
table = []
|
||
for pos, mw in enumerate(list(middlewares or ())):
|
||
layer = describe(mw)
|
||
old_style = _first_param_name(mw) in LEGACY_FIRST_PARAMS
|
||
marked = is_new_style(mw)
|
||
wrapped = adapt(mw)
|
||
out.append(wrapped)
|
||
table.append({
|
||
"pos": pos,
|
||
"layer": layer,
|
||
"new_style_marked": marked,
|
||
"legacy_factory": old_style,
|
||
"adapter": describe(wrapped),
|
||
})
|
||
report = {
|
||
"count": len(out),
|
||
"legacy_factory_layers": [e["layer"] for e in table if e["legacy_factory"]],
|
||
"unmarked_layers": [e["layer"] for e in table if not e["new_style_marked"]],
|
||
"all_adapter_verified": all(is_new_style(m) for m in out),
|
||
"table": table,
|
||
}
|
||
if report["legacy_factory_layers"] or report["unmarked_layers"]:
|
||
log.warning("[mw-signature] 链签名归一:%d 层;旧式工厂=%s;原未标记=%s;全部适配器新式=%s",
|
||
report["count"], report["legacy_factory_layers"],
|
||
report["unmarked_layers"], report["all_adapter_verified"])
|
||
else:
|
||
log.info("[mw-signature] 链签名归一:%d 层,全部已是新式并完成适配器包裹", report["count"])
|
||
return ChainResult(out, report)
|
||
|
||
|
||
class ChainResult(list):
|
||
"""兼容 `mws = normalize_chain(x)` 直接赋值用法:本体就是中间件列表。"""
|
||
|
||
def __init__(self, items, report):
|
||
super().__init__(items)
|
||
self.report = report
|
||
|
||
|
||
def normalize_app_middlewares(app):
|
||
"""把 `app.middlewares` 就地换成一整串适配器(含子应用),幂等。
|
||
|
||
返回 report dict。FrozenList 已冻结时依次尝试 `_list[i]` / `_middlewares` 回写;
|
||
全部失败则记 ERROR(该层仍按原样进链,但下一次 normalize 会再试)。
|
||
"""
|
||
fixed = []
|
||
|
||
def _fix(application):
|
||
mws = getattr(application, "middlewares", None)
|
||
if not mws:
|
||
return
|
||
for i, m in enumerate(list(mws)):
|
||
nm = adapt(m)
|
||
if nm is m:
|
||
continue
|
||
written = False
|
||
for target in (mws, getattr(mws, "_list", None)):
|
||
if target is None:
|
||
continue
|
||
try:
|
||
target[i] = nm
|
||
written = True
|
||
break
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
if written:
|
||
fixed.append(describe(m))
|
||
else:
|
||
log.error("[mw-signature] 回写 app.middlewares 失败 pos=%s layer=%s", i, describe(m))
|
||
|
||
_fix(app)
|
||
for sub in list(getattr(app, "_subapps", None) or ()):
|
||
_fix(sub)
|
||
report = {"normalized": fixed, "count": len(fixed)}
|
||
if fixed:
|
||
log.info("[mw-signature] app.middlewares 归一 %d 层: %s", len(fixed), fixed)
|
||
return report
|
||
|
||
|
||
def signature_table(middlewares):
|
||
"""纯诊断:13 层中间件的签名形态表(写进 /healthz 诊断或启动日志)。"""
|
||
table = []
|
||
for pos, mw in enumerate(list(middlewares or ())):
|
||
table.append({
|
||
"pos": pos,
|
||
"layer": describe(mw),
|
||
"new_style_marked": is_new_style(mw),
|
||
"adapted": is_adapter(mw),
|
||
"legacy_factory": _first_param_name(mw) in LEGACY_FIRST_PARAMS,
|
||
})
|
||
return table
|
||
|
||
|
||
__all__ = [
|
||
"NEW_STYLE_ATTR",
|
||
"ResponseContractError",
|
||
"ChainResult",
|
||
"adapt",
|
||
"normalize_chain",
|
||
"normalize_app_middlewares",
|
||
"signature_table",
|
||
"is_new_style",
|
||
"is_adapter",
|
||
"describe",
|
||
]
|