根因:元景迭代中为通过 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 中间件提供。
177 lines
8.0 KiB
Python
177 lines
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""响应契约守卫(最内层中间件)—— Bug Zhpd9MXyeGtznDMqini6X / R50
|
||
|
||
## 为什么需要它(R50 机械取证定案)
|
||
|
||
R49 全站矩阵把 500 记为 0,R50 在目标机日志里取到真实栈(`logs/*.log`):
|
||
|
||
------ /index.ui RuntimeError -----
|
||
Traceback (most recent call last):
|
||
File ".../app/entry_semantics.py", line 485, in entry_notfound_guard
|
||
return await handler(request)
|
||
File ".../app/route_guard_middleware.py", line 499, in resource_preflight_middleware
|
||
return await handler(request)
|
||
File ".../app/route_guard_middleware.py", line 533, in auth_required_middleware
|
||
return await handler(request)
|
||
File ".../app/route_guard_middleware.py", line 579, in write_endpoint_semantics_middleware
|
||
return await handler(request)
|
||
File ".../app/business_status_middleware.py", line 153, in business_status_middleware
|
||
response = await handler(request)
|
||
File ".../venv/lib/python3.10/site-packages/aiohttp_session/__init__.py", line 196, in factory
|
||
raise RuntimeError(f"Expect response, not {type(response)!r}")
|
||
RuntimeError: Expect response, not <class 'function'>
|
||
|
||
即:链尾**路由处理器返回了一个函数对象**,不是 `web.Response`。
|
||
`aiohttp_session.factory` 在拿到非 Response 时抛 RuntimeError,异常沿中间件链上抛,
|
||
最终被鉴权/状态层改写成一个「鉴权失败」样子的响应 —— 这就是
|
||
「38 条 500 一条也判不出来 / 500 计数=0」的机械原因:
|
||
**处理器契约破坏(返回函数)与鉴权失败是两件事,却被压成同一种 401 表象。**
|
||
|
||
## 本守卫做两件事(顺序即语义)
|
||
|
||
1. **救援**:链尾返回可调用对象时,把它当作真实处理器调用一次
|
||
(`resp(request)`,awaitable 则 await)。这是 aiohttp 装配期的「handler 未落地」
|
||
短路,直接补上这一次调用即可拿到真正的 Response —— 站点能力立刻恢复,
|
||
而不是让整站挂在鉴权表象上。
|
||
2. **归因**:救援失败 / 返回 None / 返回非法对象时,返回 **500 + 处理器身份**
|
||
(模块名、限定名、源文件、行号),让 500 可被矩阵判为「真实 500 并指名道姓」,
|
||
绝不再伪装成 401 auth_unavailable。
|
||
|
||
作为 `app.middlewares` 的**最后一位(最内层)**装配:只有贴住路由处理器,
|
||
才能观察到「处理器返回值」本身;装在外层只会看到已经被 factory 判定过的结果。
|
||
|
||
## 不变量(不得放宽)
|
||
|
||
- 正常 Response(含所有 4xx/5xx 语义响应)**原样透传**,一个字节都不改。
|
||
- 守卫自身异常一律吞掉并放行原响应,避免守卫成为新的单点故障。
|
||
"""
|
||
|
||
import inspect
|
||
import logging
|
||
|
||
log = logging.getLogger("yuanjing.response_contract_guard")
|
||
|
||
#: 装配标记,供 /healthz 与自检读取
|
||
REVISION = "r50-response-contract-v1"
|
||
|
||
|
||
def _handler_identity(fn):
|
||
"""取可调用对象身份:模块 / 限定名 / 源文件 / 行号。取不到就给 repr。"""
|
||
ident = {"repr": repr(fn)[:300]}
|
||
try:
|
||
ident["module"] = getattr(fn, "__module__", "") or ""
|
||
ident["qualname"] = getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "") or ""
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
src = inspect.getsourcefile(fn)
|
||
if src:
|
||
ident["file"] = src
|
||
_, lineno = inspect.getsourcelines(fn)
|
||
ident["line"] = lineno
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return ident
|
||
|
||
|
||
def _is_response(obj):
|
||
"""宽松判定:aiohttp Response 及其子类(含各 HTTPException 派生)。"""
|
||
try:
|
||
from aiohttp import web
|
||
return isinstance(obj, web.StreamResponse)
|
||
except Exception: # noqa: BLE001
|
||
return hasattr(obj, "status") and hasattr(obj, "headers")
|
||
|
||
|
||
def _json_response(payload, status):
|
||
"""构造 JSON 响应。**不得传 dumps=None**——aiohttp 的 `json_response(dumps=None)`
|
||
会退化成 text/plain 直发 repr,导致 500 归因体不是合法 JSON、矩阵无法解析。"""
|
||
from aiohttp import web
|
||
try:
|
||
return web.json_response(payload, status=status)
|
||
except Exception: # noqa: BLE001
|
||
import json as _json
|
||
return web.Response(status=status,
|
||
text=_json.dumps(payload, ensure_ascii=False, default=str),
|
||
content_type="application/json")
|
||
|
||
|
||
def _fail(request, reason, ident, rescued_error=""):
|
||
payload = {
|
||
"ok": False,
|
||
"success": False,
|
||
"status": 500,
|
||
"error": "response_contract_violation",
|
||
"reason": reason,
|
||
"path": getattr(request, "path", "") or "",
|
||
"method": (getattr(request, "method", "GET") or "GET").upper(),
|
||
"handler": ident,
|
||
"rescued_error": str(rescued_error)[:500],
|
||
"guard_revision": REVISION,
|
||
"hint": "处理器返回值不是 Response:见 handler.file/line 定位真实缺陷点",
|
||
}
|
||
log.error("[response_contract] %s %s %s -> %s %s",
|
||
payload["method"], payload["path"], reason, ident.get("qualname", "-"),
|
||
ident.get("file", "-"))
|
||
return _json_response(payload, 500)
|
||
|
||
|
||
# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用
|
||
# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用
|
||
# → 返回 handler 函数而不是 Response(`Expect response, not <class 'function'>`)。
|
||
from aiohttp import web as _r60_web # noqa: E402
|
||
@_r60_web.middleware
|
||
async def response_contract_guard(request, handler):
|
||
"""最内层响应契约守卫:救援「返回函数」的处理器,并把契约破坏归因为 500。"""
|
||
try:
|
||
resp = await handler(request)
|
||
except Exception: # noqa: BLE001
|
||
# 处理器自身异常:原样上抛,交给既有的 500/业务状态层(不在此改写语义)
|
||
raise
|
||
|
||
if _is_response(resp):
|
||
return resp
|
||
|
||
if resp is None:
|
||
return _fail(request, "handler_returned_none", {"repr": "None"})
|
||
|
||
if callable(resp):
|
||
ident = _handler_identity(resp)
|
||
# ① 救援:把返回的函数当作真实处理器调用一次
|
||
try:
|
||
rescued = resp(request)
|
||
if inspect.isawaitable(rescued):
|
||
rescued = await rescued
|
||
except Exception as exc: # noqa: BLE001
|
||
return _fail(request, "handler_returned_callable_rescue_failed", ident, exc)
|
||
if _is_response(rescued):
|
||
log.warning("[response_contract] rescued callable handler %s -> %s",
|
||
ident.get("qualname", "-"), getattr(rescued, "status", "?"))
|
||
return rescued
|
||
return _fail(request, "handler_returned_callable_rescue_nonresponse", ident,
|
||
"type=%r" % (type(rescued),))
|
||
|
||
return _fail(request, "handler_returned_nonresponse",
|
||
{"repr": repr(resp)[:300], "type": type(resp).__name__})
|
||
|
||
|
||
def install(app):
|
||
"""把守卫装到 app.middlewares 的**最后一位(最内层)**。幂等,永不抛出。"""
|
||
out = {"ok": False, "revision": REVISION, "names": [], "error": ""}
|
||
try:
|
||
mws = getattr(app, "middlewares", None)
|
||
if mws is None:
|
||
out["error"] = "app.middlewares unavailable"
|
||
return out
|
||
names = [getattr(m, "__name__", repr(m)) for m in mws]
|
||
if response_contract_guard.__name__ in names:
|
||
out.update(ok=True, names=names)
|
||
return out
|
||
app.middlewares.append(response_contract_guard)
|
||
out.update(ok=True, names=[getattr(m, "__name__", repr(m)) for m in app.middlewares])
|
||
return out
|
||
except Exception as exc: # noqa: BLE001
|
||
out["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||
log.error("[response_contract] install failed: %s", out["error"])
|
||
return out
|