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

241 lines
11 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 -*-
"""R19 运行入口硬守卫链(Bug nrOjOBSxzsHGa1HgElzmn / DGzTjOkr_OYM4vdOYcl2_ /
WhOYy9nvG-EfNgZMvbJbO 的**运行入口根因修复**)。
## 为什么还要这个文件(根因结论,不是又写一遍语义实现)
三条语义的实现早就存在于仓库里:
/healthz 非 GET → 405 + Allow app/entry_semantics.py (BUILTIN_GET_ONLY_PATHS)
业务信封 status>=400 → HTTP 状态行 app/business_status_middleware.py (middleware + 信号)
非公开 .dspy 无会话 → 401 app/auth_gate_strict.py / route_guard_middleware.py
但目标机真实运行入口是 `app/scense.py`(`ps -ef` 唯一进程 `venv/bin/python app/scense.py`),
R10 复测 5/5 全部在部署态失效(HTTP=404 / 200 / 503 / 200)。三处装配缺口:
1. **404 先于 405**:`entry_semantics` 的判定顺序是「资源是否存在」→ 再判方法。
`/healthz` 是 `app.router.add_get()` 注册的**动态路由**,不在 wwwroot 文件树里,
于是「资源不存在」分支先命中 → 404 no such resource,`BUILTIN_GET_ONLY_PATHS` 的
405 分支根本走不到。`/healthz` 自检里 `http_semantics.installed=true, added=2`
指的是**装配两层**(`entry_semantics` 零依赖层 + `semantic_wiring` 完整层)成功,
不是「405 分支命中」,因此自检为真而行为为假。
2. **业务 400 层缺席**:`business_status_middleware` 从未进入
`app/scense.py::_load_semantic_middlewares()` 的构造期清单(清单里只有
notfound / http_semantics / auth_gate_strict 三项)。信号层
`_install_business_status_signal` 挂在 `_on_ahapp_built` 钩子里,而构造期路径
`entry_semantics_ctor.make_app(mws)` 成功时钩子只做幂等核验,不保证 `on_response_prepare`
被挂到最终 aiohttp Application 上;钩子与中间件两条路径**都不在链上** → 4/4 端点
缺参仍 HTTP 200(body 里 status=400)。
3. **鉴权闸门不在链上**:同上,`auth_gate_strict_middleware` 虽然在清单里,但装配走
`entry_semantics_ctor` 构造期 + `notfound_middleware.compose_guard_chain()` 包导入
`__import__("app." + 模块名)`,部署态 `python app/scense.py` 的 sys.path 不含应用根
→ 包导入静默失败 → 组合链降级「仅 404」→ 匿名 `GET /scense/api/list_scense.dspy`
返回 200(同轮 `session_create.dspy` 返 401,说明闸门代码本身没坏,是没上链)。
## 本文件的设计要点(为什么这样修才在运行入口生效)
* **零应用包依赖**:只 import 标准库 + aiohttp + 同目录文件按**绝对路径**加载
(`auth_gate_strict.py` 复用已验证的会话校验),彻底绕开 sys.path 陷阱;
* **顺序即语义**:作为 `app.middlewares` 的**第 1 位(最外层)**装配,
「方法不允许」与「未登录」在业务处理器/404 判定之前返回,天然修复 404 抢答;
* **不掩错**:只处理 405 / 401 / 业务信封 4xx 回写三类;其它异常一律 re-raise,
真实后端缺陷仍暴露为 500。
"""
import importlib.util
import json
import logging
import os
import urllib.parse
from aiohttp import web
log = logging.getLogger("yuanjing.entry_guard_r19")
APP_DIR = os.path.dirname(os.path.abspath(__file__))
#: 内建「只允许读」的路径(精确匹配,禁通配)。新增内建 GET 端点必须逐条登记。
GET_ONLY_PATHS = frozenset((
"/healthz",
"/health",
))
#: 405 时下发的 Allow 头(清单顺序固定,便于断言)。
ALLOW_HEADER = "GET, HEAD, OPTIONS"
#: 视为「读」的安全方法——非此集合即方法不允许。
SAFE_METHODS = frozenset(("GET", "HEAD", "OPTIONS"))
#: 业务信封回写只作用于 .dspy(页面/静态资产语义不动)。
ENVELOPE_SUFFIX = ".dspy"
# ---------------------------------------------------------------- 纯逻辑(可离线桩测)
def normalize_path(path):
"""归一化路径:去掉重复斜杠 / 尾斜杠 / 查询串,并解 URL 编码。
与 `auth_gate_strict.normalize_path` 同口径,保证 `/HEALTHZ/`、
`//healthz`、`/healthz?x=1` 都能命中 GET_ONLY_PATHS,不能靠写法绕过 405。
"""
p = urllib.parse.unquote(str(path or ""))
p = p.split("?", 1)[0].split("#", 1)[0]
segs = [s for s in p.split("/") if s]
p = "/" + "/".join(segs)
return p.lower() if p != "/" else p
def method_not_allowed(path, method):
"""(path, method) 是否需要 405。返回 Allow 头字符串;不需要则 None。
判定在**任何资源存在性检查之前**完成——这是本文件相对 entry_semantics 的
关键差异(后者先判存在性,动态路由 /healthz 被判成「不存在」→ 404)。
"""
p = normalize_path(path)
if p not in GET_ONLY_PATHS:
return None
if str(method or "").upper() in SAFE_METHODS:
return None
return ALLOW_HEADER
def envelope_status(raw_body):
"""从响应体解析业务信封 status(只认 JSON 对象的 4xx/5xx 整数 status)。"""
if not raw_body:
return None
try:
if isinstance(raw_body, bytes):
raw_body = raw_body.decode("utf-8", "replace")
obj = json.loads(raw_body)
except (TypeError, ValueError):
return None
if not isinstance(obj, dict):
return None
code = obj.get("status")
if isinstance(code, bool):
return None
if isinstance(code, int) and 400 <= code <= 599:
return code
return None
def should_rewrite_status(path, http_status, raw_body):
"""是否需要把 HTTP 状态行回写成信封 status。返回目标码或 None。"""
p = normalize_path(path)
if not p.endswith(ENVELOPE_SUFFIX):
return None
if int(http_status or 0) != 200:
return None
return envelope_status(raw_body)
# ---------------------------------------------------------------- 会话闸门(复用已验证实现)
_GATE = {"mod": None}
def _auth_gate():
"""按绝对路径加载 auth_gate_strict.py(部署态 sys.path 不含应用根,禁包导入)。"""
if _GATE["mod"] is not None:
return _GATE["mod"]
path = os.path.join(APP_DIR, "auth_gate_strict.py")
spec = importlib.util.spec_from_file_location("yj_auth_gate_strict_r19", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_GATE["mod"] = mod
return mod
def auth_verdict(path, request):
"""鉴权判定:返回 "public" / "allow" / "deny"。
与 auth_gate_strict 同一套公开清单与会话校验(精确路径,禁通配);
任何异常都 fail-closed 成 deny(绝不 fail-open)。
"""
try:
gate = _auth_gate()
if not gate.gate_enabled():
return "public"
if gate.is_public_path(path):
return "public"
if not gate.is_gated_path(path):
return "public"
# [R21 401 根因补修] auth_gate_strict._has_session() 返回的是
# **(ok, reason) 元组**,不是布尔。原写法 `"allow" if gate._has_session(request)`
# 让任何非空元组(含 (False, "no_session"))都为真 → 匿名请求被判 allow,
# 401 在入口层彻底失效(即便会话校验实现正确)。此处显式取第 0 位布尔,
# 对列表/字典/布尔三种返回形态都做归一,绝不依赖对象真值。
verdict = gate._has_session(request)
if isinstance(verdict, (tuple, list)):
ok = bool(verdict[0]) if verdict else False
elif isinstance(verdict, dict):
ok = bool(verdict.get("ok"))
else:
ok = bool(verdict)
return "allow" if ok else "deny"
except Exception as exc: # noqa: BLE001
log.error("[guard19] 鉴权判定异常,fail-closed 401: %s", exc)
return "deny"
# ---------------------------------------------------------------- 中间件本体
def _json_response(status, payload, extra_headers=None):
headers = {"Cache-Control": "no-store"}
if extra_headers:
headers.update(extra_headers)
return web.json_response(payload, status=status, headers=headers)
@web.middleware # ← aiohttp>=3.9 新式中间件标记(见下方 R19d 说明)
async def entry_guard_middleware(request, handler):
"""入口硬守卫:405(方法语义)→ 401(会话闸门)→ 业务信封 4xx 回写。
[fix-iter17 r19d] 必须带 `@web.middleware`(= 设置 `__middleware_version__ = 1`)。
aiohttp >= 3.9(requirements 锁 `aiohttp>=3.9,<4.0`)在
`Application._prepare_middleware()` 里按该标记区分新旧式:**未标记的普通协程函数
会被当成「旧式工厂」** m(app, handler) 调用,于是本函数收到 request=app、
handler=真实 handler,`await handler(request)` 抛
`TypeError: 'Response' object is not callable` → 每个请求 500;
/healthz 的 405 退化由 aiohttp 路由自身给出(Allow 只有 GET,HEAD,body 为
`405: Method Not Allowed` 纯文本),闸门与信封回写整层失效。
实测证据(本机真实执行回显,非引用):
projects/proj_qEVGJSL3/docs/02-develop/evidence/r19_entry_assembly_verify_20261026.txt
(由 apps/yuanjing/scripts/verify_entry_assembly_r19.py 生成,含 TOTAL=/PASS=/FAIL= 行)
"""
path = getattr(request, "path", "") or ""
# ① 方法语义(先于 404/鉴权;/healthz 等内建只读路径非读方法 → 405 + Allow)
#
# [QC#7] 原写法 `if allow == "GET, HEAD, OPTIONS"` 把返回值退化成布尔信号,
# 与 ALLOW_HEADER 形成两处字面量(改 ALLOW_HEADER 后判定静默漂移),且后面
# 再判一次 OPTIONS(method_not_allowed 已用 SAFE_METHODS 排除了 OPTIONS/GET/HEAD)。
# 现在:返回值即单一真源——None = 不需要 405,非 None = 需要 405 且它就是 Allow 头。
allow = method_not_allowed(path, getattr(request, "method", ""))
if allow:
return _json_response(
405,
{"status": 405, "success": False, "reason": "method not allowed",
"path": normalize_path(path), "allow": allow},
{"Allow": allow},
)
# ② 会话闸门(非公开 .dspy 无会话 → 401,fail-closed)
verdict = auth_verdict(path, request)
if verdict == "deny":
return _json_response(
401,
{"status": 401, "success": False, "reason": "unauthorized",
"path": normalize_path(path)},
)
# ③ 业务信封 status>=400 → HTTP 状态行(只改状态行,不改 body)
resp = await handler(request)
try:
target = should_rewrite_status(path, getattr(resp, "status", 200),
getattr(resp, "body", None))
if target is not None and hasattr(resp, "set_status"):
resp.set_status(target)
log.info("[guard19] 业务信封 status=%s → HTTP %s path=%s",
target, target, normalize_path(path))
except Exception as exc: # noqa: BLE001
log.warning("[guard19] 状态行回写失败(不影响 body): %s", exc)
return resp