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

313 lines
13 KiB
Python
Raw 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 -*-
"""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",
]