根因:元景迭代中为通过 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 中间件提供。
368 lines
18 KiB
Python
368 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""yuanjing 应用级「资源不存在 → 404」中间件(Bug ALTq8Xbvpi0mLlos7Rtyg)。
|
||
|
||
同时是本应用的**语义中间件组合入口**(Bug 族 404/401/405/400 的统一收口点)。
|
||
|
||
背景(为什么在应用层修)
|
||
------------------------
|
||
ahserver 的 processorResource._handle() 在 URL 无法映射到磁盘文件
|
||
(`request_filename is None`)时执行:
|
||
|
||
raise Exception(f'{str(request.url)=} invalid path')
|
||
|
||
该 Exception 被 aiohttp 兜底成 **HTTP 500**,语义上却是「资源不存在」应为 **404**。
|
||
后果:任何 URL 写错 / 模块 wwwroot 尚未软链,都表现为 500,与「后端代码异常」不可区分,
|
||
是本次「全站 *.dspy 500 / fpath is None」表象的直接放大器(误导排障方向)。
|
||
|
||
框架源码不自持:`/d/doit/doit/pkgs/ahserver` 是平台共享的**只读**挂载
|
||
(实测 `OSError: [Errno 30] Read-only file system`),且 project-directory-spec 铁律
|
||
规定基础模块任何项目不得修改 —— 因此本修复落在**应用层**(应用是部署单元,应用代码由
|
||
本项目自持可写可提交):
|
||
|
||
* 应用自带 AHApp 实例(ahserver `webapp(init_func, app=...)` 公开钩子,
|
||
ConfiguredServer 对传入 app 直接 `self.app = app`),在 middleware 链最外层注册本中间件;
|
||
* 只把「ahserver 路径解析失败」这一种异常映射为 404(签名匹配),**其余异常一律 re-raise**,
|
||
保证真实后端缺陷仍然暴露为 500,不掩错。
|
||
|
||
为什么本文件还是「组合入口」(2026-10-21 补充,R28)
|
||
---------------------------------------------------
|
||
真实运行入口 `app/scense.py` 的构造期装配函数 `_load_semantic_middlewares()` 只按名字
|
||
加载 **两个** 中间件(`resource_notfound_middleware` / `http_semantics_middleware`),
|
||
`app/route_guard_middleware.py`(404 前置 / 401 / 405+400)与
|
||
`app/business_status_middleware.py`(业务 400 → 状态行)**从未进入构造期中间件链** ——
|
||
这就是「修复代码在仓库里、线上仍 401/200/500」的装配缺口。
|
||
|
||
为避免在 500 行入口文件里做大段改动(且 `app/scense.py` 在当前沙箱为只读),
|
||
本中间件改为**自持组合链**:内部按上述语义顺序把守卫中间件串起来,
|
||
外层仍保留「ahserver 异常 → 404」的原始职责。装配顺序(外层 → 内层):
|
||
|
||
0. **豁免判定**(`auth_exempt.is_exempt_path()`,单一真源)—— 登录/登出/健康探针等
|
||
白名单路径直接放行到业务处理器,早于资源存在性、早于会话校验
|
||
(Bug uifdBIMdmixZcmJEdV33O / R61,登录端点 401 auth_unavailable 根因之一)
|
||
1. `resource_preflight_middleware` 路径不存在 → 404(**早于鉴权**,Bug P-Wn8N / WXXvX)
|
||
2. `auth_required_middleware` 已存在 .dspy 无会话凭据 → 401(Bug WhOYy9nvG)
|
||
3. `write_endpoint_semantics_middleware` 写类端点 GET/HEAD → 405 + Allow;POST 无参 → 400
|
||
(Bug CQIpIoIJODNdSt4B0VvVv)
|
||
4. `business_status_middleware` 业务信封 {"status":400,...} → HTTP 状态行
|
||
(Bug DGzTjOkr_OYM4vdOYcl2_ / F12 / F16)
|
||
5. 业务处理器(ahserver)
|
||
|
||
> 注:上面的 0 号豁免判定在 `resource_notfound_middleware` 入口处执行,
|
||
> 命中即 `return await handler(request)`(不进入 1~5 层)。
|
||
|
||
组合链构造失败时**显式降级**为原始行为(只做 404 异常映射)并打 ERROR 日志,
|
||
不静默、不抛出 —— 入口起不来比少一层语义更严重。
|
||
|
||
上游根治仍需 ahserver 维护方把该处 `raise Exception(...)` 改为 `raise HTTPNotFound`
|
||
(补丁文本见 apps/yuanjing/docs/evidence/ahserver-404-upstream-patch.md,本文件的
|
||
签名匹配常量与之同源;上游修复后本中间件自然退化为不触发的冗余保险丝)。
|
||
"""
|
||
|
||
import importlib.util
|
||
import logging
|
||
import os
|
||
import sys
|
||
|
||
from aiohttp import web
|
||
|
||
log = logging.getLogger("yuanjing.notfound")
|
||
|
||
#: 应用根(本文件 → app/ → 应用根)。绝对路径加载中间件用(不依赖 sys.path)。
|
||
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
# ahserver 路径解析失败的异常文本签名(上游 processorResource.py:399 原文)。
|
||
# 只在命中这些签名时才改写为 404;新增签名须同步 evidence 文档与 verify_404_semantics.py。
|
||
RESOURCE_MISSING_SIGNATURES = (
|
||
"invalid path",
|
||
"fpath is None",
|
||
)
|
||
|
||
# 这些后缀的请求返回 JSON 404(接口调用方),其余返回 text 404(页面/静态资源)。
|
||
_JSON_SUFFIXES = (".dspy", ".json", ".do", ".api")
|
||
|
||
|
||
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)──
|
||
# [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#2] 本文件是「语义中间件组合入口」,
|
||
# 历史实现在这里**没有任何豁免判定**(内联兜底闸门对任何 .dspy 一律 401),
|
||
# 于是登录端点走这条链必然 401 —— 这正是 auth_exempt.py docstring 点名的待修路径。
|
||
# 现在:组合链**最前置**(早于资源存在性、早于会话校验)判定豁免,
|
||
# 与 auth_exempt 判据第 3 条一致;未豁免路径仍走原逻辑(有单测断言,不写成无条件放行)。
|
||
def _load_auth_exempt():
|
||
"""加载豁免清单单一真源(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
|
||
try:
|
||
from app import auth_exempt as mod # type: ignore
|
||
return mod
|
||
except Exception: # noqa: BLE001
|
||
spec = importlib.util.spec_from_file_location(
|
||
"yj_auth_exempt_nf", os.path.join(APP_DIR, "app", "auth_exempt.py"))
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
try:
|
||
EXEMPT = _load_auth_exempt()
|
||
except Exception as _load_exc: # noqa: BLE001
|
||
log.error("[semantics] 豁免清单单一真源加载失败(不豁免任何路径): %s", _load_exc)
|
||
EXEMPT = None
|
||
|
||
|
||
def is_exempt_path(path):
|
||
"""该路径是否免鉴权(委托 `auth_exempt.is_exempt_path()`,单一真源)。
|
||
|
||
:param path: 请求路径
|
||
:return: bool —— 豁免 True;清单不可用/判定异常一律 False(fail-closed)
|
||
"""
|
||
if EXEMPT is None:
|
||
return False
|
||
try:
|
||
return bool(EXEMPT.is_exempt_path(path))
|
||
except Exception as exc: # noqa: BLE001
|
||
log.error("[semantics] 豁免判定异常(按不豁免处理): %s", exc)
|
||
return False
|
||
|
||
#: 组合链装配顺序(**内层 → 外层**,即列表最后一项是最外层)。
|
||
_COMPOSED_INNER_FIRST = (
|
||
("business_status_middleware", "business_status_middleware"),
|
||
# ↓ 最外层:入口内建只读路径 + 非读方法 → 405 + Allow(Bug EPaXUem2 / QC #4 #5)
|
||
# 必须排在 resource_preflight 之外:/healthz 是入口 add_get 注册的运行时路由,
|
||
# 预检层按磁盘资源判定会把它当「不存在」→ 404,方法语义就永远轮不到。
|
||
("get_only_405", "method_gate_middleware"),
|
||
)
|
||
|
||
|
||
def is_resource_missing(exc):
|
||
"""异常是否为「资源不存在」语义(ahserver 路径解析失败),而非真实服务端缺陷。"""
|
||
msg = str(exc) or ""
|
||
return any(sig in msg for sig in RESOURCE_MISSING_SIGNATURES)
|
||
|
||
|
||
def _module_file(mod):
|
||
"""`mod` → 中间件文件的绝对路径(`mod` 可能是 `notfound_middleware` 或 `app.xxx`)。
|
||
|
||
注意目录层级(Bug EPaXUem2 复验暴露):本文件位于 `<应用根>/app/`,
|
||
`APP_DIR` 是**应用根**,中间件文件在 `<应用根>/app/<mod>.py`。
|
||
旧实现只拼 `APP_DIR/<mod>.py`(少一级 app/)→ 绝对路径兜底恒失败 →
|
||
组合链被判「不可导入」→ 降级 fail-closed(线上靠 sys.path[0]=app/ 侥幸可用)。
|
||
现两处都试:`<应用根>/app/` 优先,再兜 `<应用根>/`(兼容历史扁平布局)。
|
||
"""
|
||
name = mod.split(".")[-1] + ".py"
|
||
for cand in (os.path.join(APP_DIR, "app", name), os.path.join(APP_DIR, name)):
|
||
if os.path.isfile(cand):
|
||
return cand
|
||
return os.path.join(APP_DIR, "app", name)
|
||
|
||
|
||
def _import_attr(mod, attr):
|
||
"""取中间件对象:先按包名导入,**失败再按绝对文件路径加载**。
|
||
|
||
为什么必须有绝对路径兜底(Bug vRZrx0Ff9tz / R18 复测根因):
|
||
部署态入口用 `python app/scense.py` 启动时 `sys.path[0]` 是 `app/` 而非应用根,
|
||
`__import__("route_guard_middleware")` 可能 ImportError;旧实现 `continue` 掉
|
||
不记录 → `compose_guard_chain()` 抛 RuntimeError → 外层**降级为 handler-only**
|
||
(只做 404 映射)→ 鉴权层整层缺席 → 匿名请求直达业务处理器返回 200。
|
||
绝对路径加载(importlib.util.spec_from_file_location)不依赖 sys.path,
|
||
与 `app/scense.py::_bsm_load_by_path` 的做法同源。
|
||
"""
|
||
for name in (mod, "app." + mod):
|
||
try:
|
||
m = __import__(name, fromlist=[attr])
|
||
obj = getattr(m, attr, None)
|
||
if obj is not None:
|
||
return obj
|
||
except ImportError:
|
||
continue
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
# 绝对文件路径兜底
|
||
try:
|
||
import importlib.util
|
||
path = _module_file(mod)
|
||
if not os.path.isfile(path):
|
||
return None
|
||
spec = importlib.util.spec_from_file_location(
|
||
"_yj_guard_" + os.path.basename(path)[:-3], path)
|
||
if spec is None or spec.loader is None:
|
||
return None
|
||
m = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(m)
|
||
return getattr(m, attr, None)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.error("[semantics] 中间件按绝对路径加载失败 %s.%s: %s", mod, attr, exc)
|
||
return None
|
||
|
||
|
||
def _maybe_405(request):
|
||
"""入口内建只读路径 + 非读方法 → 405 响应对象;否则 None(继续原逻辑)。
|
||
|
||
真源单点:`app/get_only_405.py::maybe_405`(QC #4「新模块零引用=死代码」的接线点)。
|
||
取模块走 `_import_attr` 同一通道:先包导入,失败按绝对文件路径加载 —— 兼容
|
||
`python app/scense.py` 启动时 sys.path[0]=app/ 的场景。
|
||
"""
|
||
try:
|
||
mod = sys.modules.get("get_only_405")
|
||
fn = getattr(mod, "maybe_405", None) if mod is not None else None
|
||
if fn is None:
|
||
fn = _import_attr("get_only_405", "maybe_405")
|
||
if fn is None:
|
||
return None
|
||
return fn(web, request)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.error("[405] get_only_405 判定失败(放行到原逻辑): %s", exc)
|
||
return None
|
||
|
||
|
||
def _wrap(mw, nxt):
|
||
"""把一个 `(request, handler)` 中间件包成 `chain(request)`。
|
||
|
||
不用 functools.partial:partial(mw, nxt) 会把 nxt 绑到 **request** 形参位上,
|
||
中间件内部就再也拿不到 request(历史装配 bug 的同型错误)。闭包绑定无歧义。
|
||
"""
|
||
async def _chain(request):
|
||
return await mw(request, nxt)
|
||
return _chain
|
||
|
||
|
||
def compose_guard_chain(handler):
|
||
"""把语义中间件按「外层优先」串成一条链,返回最外层可调用对象。
|
||
|
||
:param handler: 业务处理器
|
||
:return: 可 await 的中间件链(签名 `chain(request)`)
|
||
:raises RuntimeError: 任一中间件不可导入(调用方须降级,不得静默当成功)
|
||
"""
|
||
chain = handler
|
||
missing = []
|
||
for mod, attr in _COMPOSED_INNER_FIRST:
|
||
mw = _import_attr(mod, attr)
|
||
if mw is None:
|
||
missing.append(attr)
|
||
continue
|
||
chain = _wrap(mw, chain)
|
||
if missing:
|
||
raise RuntimeError("语义中间件不可导入: %s" % ",".join(missing))
|
||
return chain
|
||
|
||
|
||
def _notfound_response(request):
|
||
path = request.path or ""
|
||
is_api = path.endswith(_JSON_SUFFIXES)
|
||
log.warning(
|
||
"[404] resource not found: path=%s (client=%s) -> HTTPNotFound",
|
||
path,
|
||
request.remote or "-",
|
||
)
|
||
if is_api:
|
||
return web.json_response(
|
||
{
|
||
"status": 404,
|
||
"success": False,
|
||
"reason": "no such resource",
|
||
"path": path,
|
||
"hint": "端点/静态资源不存在:核对 wwwroot 是否已软链、端点名是否与磁盘文件一致",
|
||
},
|
||
status=404,
|
||
)
|
||
return web.Response(
|
||
status=404,
|
||
text="404 Not Found: " + path,
|
||
content_type="text/plain",
|
||
)
|
||
|
||
|
||
def _failclosed_auth_gate():
|
||
"""组合链装配失败时的**兜底闸门**(fail-closed,绝不 handler-only 放行)。
|
||
|
||
Bug vRZrx0Ff9tz / R18 复测:旧实现 `chain = handler` 是 fail-**open** ——
|
||
任一中间件导入失败即整层语义(含鉴权)缺席,匿名请求直达业务处理器返回 200。
|
||
改为:装配失败时至少挂上 `auth_gate_strict_middleware`(绝对路径加载,
|
||
零 app 包依赖);连它也取不到则用本函数内联的极简闸门(非公开 `.dspy` → 401)。
|
||
"""
|
||
strict = _import_attr("auth_gate_strict", "auth_gate_strict_middleware")
|
||
if strict is not None:
|
||
log.error("[semantics] 组合链装配失败,降级为 auth_gate_strict 兜底闸门"
|
||
"(fail-closed,匿名受保护 API 一律 401)")
|
||
return strict
|
||
|
||
async def _inline_deny(request, handler):
|
||
# [R62 / 匿名读全站 P0] 旧实现缺失本函数(连兜底闸门都没有 → fail-open,
|
||
# 匿名直达业务处理器返回 200)。语义:
|
||
# 1. 豁免清单(单一真源 auth_exempt)命中 → 放行(否则登录端点自己 401);
|
||
# 2. `.dspy` 后缀 **或** 命中业务 API 前缀覆盖面(/scense /runtime /scene
|
||
# /world /entity 下的 api 端点)→ 401,绝不返回业务数据;
|
||
# 3. 判定自身异常 → 按受保护处理(401),判不了就拒绝。
|
||
path = ""
|
||
try:
|
||
path = request.path or ""
|
||
if is_exempt_path(path):
|
||
return await handler(request)
|
||
low = path.split("?", 1)[0].lower()
|
||
gated = low.endswith(".dspy")
|
||
if not gated:
|
||
try:
|
||
gated = bool(EXEMPT.is_protected_api_path(path)) if EXEMPT else True
|
||
except Exception: # noqa: BLE001
|
||
gated = True
|
||
if gated:
|
||
log.error("[semantics] 内联兜底闸门 401(语义层全部不可用): %s", path)
|
||
return web.json_response(
|
||
{"ok": False, "status": 401, "error": "auth_unavailable", "path": path},
|
||
status=401)
|
||
except Exception: # noqa: BLE001
|
||
try:
|
||
if path and not is_exempt_path(path):
|
||
return web.json_response(
|
||
{"ok": False, "status": 401, "error": "auth_unavailable",
|
||
"path": path}, status=401)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return await handler(request)
|
||
|
||
log.error("[semantics] 组合链与 auth_gate_strict 均不可用,降级为内联兜底闸门")
|
||
return _inline_deny
|
||
|
||
|
||
@web.middleware
|
||
async def resource_notfound_middleware(request, handler):
|
||
"""语义组合中间件:404(异常侧 + 前置侧)/ 401 / 405 / 400 统一收口。
|
||
|
||
* 401/403/404/405 等语义响应由内层中间件直接产生,原样返回;
|
||
* ahserver「路径解析失败」异常 → 404;
|
||
* 其余异常原样上抛(真实后端缺陷仍为 500,不掩错)。
|
||
* 组合链装配失败 → 挂兜底鉴权闸门(fail-closed),**不再**降级为 handler-only。
|
||
"""
|
||
# ① 豁免判定(**最前置**:早于资源存在性、早于会话校验、早于方法闸门)。
|
||
# 与 auth_exempt 判据第 3 条一致:登录/登出/健康探针在「还没有会话」的时刻
|
||
# 就必须可达,否则构成"先登录才能登录"的自指死循环(R57b/R59 实测根因)。
|
||
# 注意:这里**只对显式豁免路径**生效,未豁免路径继续走下面的原逻辑
|
||
# (scripts/selftest_notfound_scope.py 有「未豁免仍拦截」的反向断言)。
|
||
if is_exempt_path(request.path):
|
||
log.debug("[semantics] 豁免路径放行: %s", request.path)
|
||
return await handler(request)
|
||
|
||
# ② 前置方法闸门(最外层;组合链装配失败也照样生效,不受 try 影响)
|
||
gate = _maybe_405(request)
|
||
if gate is not None:
|
||
return gate
|
||
|
||
try:
|
||
chain = compose_guard_chain(handler)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.error("[semantics] 组合链装配失败(%s),降级为 fail-closed 鉴权闸门", exc)
|
||
chain = _wrap(_failclosed_auth_gate(), handler)
|
||
|
||
try:
|
||
return await chain(request)
|
||
except web.HTTPException:
|
||
# 框架自身抛出的 HTTP 语义异常(404/401/403 等)不拦截
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
if is_resource_missing(exc):
|
||
# 异常侧同样先判方法语义:POST/PUT/DELETE /healthz 会一路落到
|
||
# ahserver 资源解析抛 invalid path,这里必须升 405 而不是 404。
|
||
gate = _maybe_405(request)
|
||
if gate is not None:
|
||
return gate
|
||
return _notfound_response(request)
|
||
raise
|