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

368 lines
18 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 -*-
"""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