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

177 lines
8.0 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.

# -*- 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