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

411 lines
19 KiB
Python
Raw Permalink 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 应用级「最后一层」会话鉴权闸门(Bug vRZrx0Ff9tz / R18 复测,P1 匿名读越权)。
为什么还需要这一层(根因链,实测于部署态 2026-10-25)
------------------------------------------------------
`app/route_guard_middleware.py::auth_required_middleware` 的判定逻辑本身是对的
(非公开 `.dspy` + 无会话 → 401),但部署态实测:
NOCOOKIE GET /scense/api/list_scense.dspy -> 200 {"ok": true, "total": 0, "rows": []}
NOCOOKIE GET /scense/api/session_create.dspy -> 401 # 同层对照
NOCOOKIE GET /no/such/path.dspy -> 401 # 框架层回 401
即「门禁按端点覆盖不全」而非整链缺失。语义中间件链的装配点是
`app/notfound_middleware.py::compose_guard_chain()`,它**按模块名**导入
`route_guard_middleware` 等中间件;部署态 `python app/scense.py` 的 sys.path 不含应用根时
导入失败 → `compose_guard_chain` 抛 RuntimeError → 外层降级为「只做 404 异常映射」的
handler-only 链 → **鉴权层整层缺席**,业务处理器直接执行 → 200。
(同一降级路径也是 2026-10-25 上一轮 business_status 层缺席的同源故障。)
本模块是**自持、零 app 包依赖**的兜底闸门:
* 只依赖 stdlib + aiohttp + cryptography(Fernet),不 import `app.*`;
* 默认 deny:除显式公开路径(登录/登出/健康探针)与静态资源外,**任何 `.dspy` 都必须
带可验证的会话密文**,否则 401 并以统一错误信封返回(无业务字段);
* 自检/解密异常一律 fail-closed(401),绝不 fail-open;
* 由 `app/scense.py::_load_semantic_middlewares()` **按绝对文件路径**加载并注册,
与 `compose_guard_chain` 的装配成败解耦 —— 组合链修好后本层成为幂等冗余保险丝。
与 `route_guard_middleware` 的关系
----------------------------------
职责重叠是有意的(纵深防御)。责任重叠是有意的(纵深防御)。**公开路径集合不再由本文件持有**:
鉴权豁免清单的单一真源是 `app/auth_exempt.py`,本文件 `is_public_path()` 委托其
`is_exempt_path()`;本地 `PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 已降级为只读镜像,
仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等(防多清单漂移)。
只读镜像(仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等)。
其余事实常量(密钥派生尾串、cookie 精确名、会话有效期)仍由该自测交叉断言,
改一处不改另一处会直接让自测 FAIL —— 以此防止实现漂移。
"""
import base64
import importlib.util
import json
import logging
import os
import time
import urllib.parse
from aiohttp import web
log = logging.getLogger("yuanjing.auth_gate_strict")
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)──
# 修复前本文件自持一份 PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES 副本,与
# auth_route_gate / route_guard_middleware / notfound_middleware 的另外几份副本各自演化
# (R57b / R59:主线豁免、兜底路径不豁免 → 登录端点仍 401 auth_unavailable)。
# [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 现在本文件**不再持有第二份清单**:
# 判定一律委托 `auth_exempt.is_exempt_path()`,下面两个名字只是它的**只读镜像**,
# 仅用于自测交叉断言与诊断输出(自测 `scripts/selftest_auth_gate_list_scense.py`
# 断言三者相等,防止再次各自演化)。
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_strict",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "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
# 清单都加载不了 → 不做任何豁免(fail-closed),但必须显式告警,不静默。
log.error("[auth_strict] 豁免清单单一真源加载失败(所有 .dspy 均按需会话处理): %s",
_load_exc)
EXEMPT = None
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 下面两个 PUBLIC_* 名字仅是**派生镜像**
(自测交叉断言 + 诊断输出),判定一律走 `EXEMPT.is_exempt_path()`。
"""
if mod is None:
return {"revision": "unavailable", "dspy_paths": frozenset(),
"prefixes": (), "wildcard_free": False}
try:
mf = mod.manifest()
except Exception: # noqa: BLE001
return {
"revision": getattr(mod, "EXEMPT_REVISION", "unavailable"),
"dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()),
"prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()),
"wildcard_free": False,
}
return {
"revision": str(mf.get("revision") or "unavailable"),
"dspy_paths": frozenset(mf.get("dspy_paths") or ()),
"prefixes": tuple(mf.get("prefixes") or ()),
"wildcard_free": bool(mf.get("wildcard_free", False)),
}
EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT)
#: **交叉断言镜像**(非清单本体,由 manifest() 派生):仅为自测比对与诊断输出保留。
PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"]
#: **交叉断言镜像**(非清单本体,由 manifest() 派生):同上。
PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"]
#: 受闸门约束的后缀。`.dspy` 是唯一需要会话的动态端点类型;
#: `.ui/.js/.css/.html/.json` 属页面与静态资产,由框架会话层处理(避免锁死登录页)。
GATED_SUFFIX = ".dspy"
#: 会话 cookie 精确名(与 ahserver AuthAPI.setupAuth / route_guard 同源)。
SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION"
SESSION_COOKIE_MAX_AGE = 24 * 60 * 60
#: 复刻 ahserver AuthAPI.setupAuth 的 Fernet 密钥派生(port 前缀 + 固定尾串,共 32 字节)。
SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345"
#: 闸门总开关(仅排障用;生产禁开)。与 route_guard 同名环境变量。
GATE_ENV = "YJ_AUTH_GATE"
#: 应用根(本文件 → app/ → 应用根)。
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def gate_enabled():
"""总开关:默认开;`YJ_AUTH_GATE=off|0|false|no` 才关(排障专用)。"""
return (os.environ.get(GATE_ENV) or "on").strip().lower() not in (
"off", "0", "false", "no")
def normalize_path(path):
"""归一化请求路径,封堵写法绕过。
`/scense/api/list_scense.dspy/`、`/SCENSE/API/LIST_SCENSE.DSPY`、
`//scense/api/list_scense.dspy`、`/scense/api/%6cist_scense.dspy` 都必须
与 `/scense/api/list_scense.dspy` 得到同一判定,不能因为写法不同而绕过闸门。
"""
p = str(path or "")
p = urllib.parse.unquote(p)
p = p.split("?", 1)[0].split("#", 1)[0]
p = "/" + "/".join([seg for seg in p.split("/") if seg])
if p != "/":
p = p.rstrip("/")
return p.lower() if p.lower().endswith(GATED_SUFFIX) or p.startswith("/rbac/") else p
def is_public_path(path):
"""显式公开/豁免路径判定 —— **委托单一真源** `auth_exempt.is_exempt_path()`。
[fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 本函数**不再**用本地常量判定:
历史实现在这里持有第二份清单,与 route_guard / auth_route_gate /
notfound 兜底闸门的副本各自演化(R57b / R59 多清单漂移同源缺陷)。
现在只有一份清单(auth_exempt),本层只是调用方。
:param path: 请求路径(可含 query / 尾斜杠 / 编码,内部归一化)
:return: bool —— 豁免 True;清单不可用或判定异常一律 False(fail-closed)
"""
if EXEMPT is None:
return False
try:
return bool(EXEMPT.is_exempt_path(normalize_path(path)))
except Exception as exc: # noqa: BLE001
log.error("[auth_strict] 豁免判定异常(按不豁免处理): %s", exc)
return False
def public_reason(path):
"""豁免依据(未豁免返回空串)—— 委托单一真源,供自测/诊断输出。"""
if EXEMPT is None:
return ""
try:
return EXEMPT.exempt_reason(normalize_path(path)) or ""
except Exception: # noqa: BLE001
return ""
def is_gated_path(path):
"""该路径是否需要会话凭据。
[R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为
**后缀 ∪ 业务 API 前缀**,判定顺序(顺序即语义):
1. 显式公开路径(登录/登出/健康探针,单一真源 `auth_exempt`)→ 不需会话;
2. `.dspy` 动态端点 → 需要会话(历史行为,保持);
3. 命中 `auth_exempt.PROTECTED_API_PREFIXES`(/scense /runtime /scene /world
/entity)且路径含 `/api/` 段或以 `.dspy` 结尾 → **同样需要会话**。
第 3 条是本次修复的关键:旧实现**只看后缀**(`if not endswith('.dspy'): return
False`),任何「无 `.dspy` 后缀的业务 API」(反向代理/重写规则下的
`/runtime/api/build_entity_tree`、`/scense/api/...` 变体写法)都会被判成
「非 `.dspy` → 放行」,于是匿名 200 返回业务数据。按需求 2,覆盖面必须按
**前缀**枚举而非按后缀,且不得只补被测的 3 条。
"""
p = normalize_path(path)
if is_public_path(p):
return False
if p.lower().endswith(GATED_SUFFIX):
return True
# 前缀覆盖面:判定委托单一真源,禁在本文件复制清单
try:
if EXEMPT is not None and EXEMPT.is_protected_api_path(p):
return True
except Exception as exc: # noqa: BLE001
# 判不了 → 按受保护处理(fail-closed,绝不放行业务数据)
log.error("[auth_strict] 业务 API 前缀判定异常(按受保护处理): %s", exc)
return True
return False
def _app_conf():
"""读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。"""
try:
with open(os.path.join(APP_ROOT, "conf", "config.json"), encoding="utf-8") as fp:
return json.load(fp) or {}
except Exception: # noqa: BLE001
return {}
def session_cookie_names():
"""允许承载会话的 cookie **精确名**(默认名 + conf 的 cookie_name)。"""
names = [SESSION_COOKIE_NAME_DEFAULT]
try:
cfg = _app_conf()
name = ((((cfg.get("website") or {}).get("session") or {})
.get("cookie_name")) or "").strip()
if name and name not in names:
names.append(name)
except Exception: # noqa: BLE001
pass
return tuple(names)
def session_key():
"""Fernet 密钥:port 前 32 字节 + 固定尾串补齐(复刻 ahserver 派生)。"""
port = str(((_app_conf().get("website") or {}).get("port")) or "")
b = port.encode("utf-8")[:32]
cnt = max(0, 32 - len(b))
return base64.urlsafe_b64encode(b + SESSION_SECRET_TAIL[:cnt])
def verify_cookie(raw_cookie):
"""校验会话密文 → `(ok, reason)`。
reason ∈ ``no_session`` / ``forged_session`` / ``expired_session`` /
``session_decode_unavailable``(cryptography 不可用 → fail-closed)。
"""
if not raw_cookie or not str(raw_cookie).strip():
return False, "no_session"
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # noqa: BLE001
return False, "session_decode_unavailable"
try:
fernet = Fernet(session_key())
token = str(raw_cookie).strip().encode("utf-8")
try:
data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8"))
return (True, "ok") if isinstance(data, dict) else (False, "forged_session")
except InvalidToken:
data = json.loads(fernet.decrypt(token).decode("utf-8"))
if not isinstance(data, dict):
return False, "forged_session"
created = int(data.get("created") or 0)
if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE:
return False, "expired_session"
return False, "forged_session"
except Exception: # noqa: BLE001
return False, "forged_session"
def _has_session(request):
"""请求是否携带可验证的会话 → `(ok, reason)`。"""
cookies = getattr(request, "cookies", None) or {}
for name in session_cookie_names():
val = cookies.get(name)
if val:
return verify_cookie(val)
return False, "no_session"
def _deny(request, reason):
"""统一 401 错误信封(**不含任何业务字段**)。
hint 自指修正(R53 / QC#4):
* 登录端点自身(`/rbac/user/up_login.dspy` 等公开路径)被拒 → **不是未登录**,
而是服务端鉴权闸门/会话组件异常(fail-closed 掩盖了真实故障)。此时 hint
必须指向「闸门异常」而非「请先 POST 登录」,否则形成"先登录才能登录"的自指死循环。
* 其余受保护端点 → 保持"请先登录"的指引。
"""
path = normalize_path(getattr(request, "path", "") or "")
log.warning("[auth_strict] 拒绝匿名/无效会话请求: %s %s reason=%s",
getattr(request, "method", "-"), path, reason)
if is_public_path(path):
hint = ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed),"
"不是未登录。请查看服务端日志 [auth_strict] 与 /healthz 的 gate/chain 诊断字段")
else:
hint = "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie"
resp = web.json_response(
{
"ok": False,
"status": 401,
"error": reason,
"path": path,
"hint": hint,
},
status=401,
)
resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"'
return resp
def _is_response_contract_error(exc):
"""异常是否属于「中间件把非 Response(handler 函数本体)交回上游」这一类契约错误。"""
txt = "%s: %s" % (type(exc).__name__, exc)
for key in ("Expect response", "should return a response", "not <class 'function'>",
"not a Response", "ResponseContractError", "__mw_signature_adapter__"):
if key in txt:
return True
return False
def _internal_error(request, exc):
"""闸门内部异常 → **500**(不是 401)。带 traceback 与层名,供运维直接定位。
:param request: aiohttp Request
:param exc: 捕获到的异常
:return: aiohttp web.Response(status=500, error=auth_gate_internal_error)
"""
path = "-"
try:
path = normalize_path(getattr(request, "path", "") or "") or "-"
except Exception: # noqa: BLE001
pass
contract = _is_response_contract_error(exc)
log.error("[auth_strict] 闸门内部异常(非会话缺失)→ 500 %s %s contract_error=%s: %s: %s",
getattr(request, "method", "-"), path, contract,
type(exc).__name__, exc, exc_info=True)
detail = "%s: %s" % (type(exc).__name__, exc)
if contract:
hint = ("中间件链响应契约错误:某层把未 await 的 handler 当作 Response 返回。"
"查日志 [mw-signature]/[auth_strict] 里的 layer= 字段定位该层")
else:
hint = ("服务端鉴权闸门内部异常(既不是未登录,也不是会话缺失)。"
"查服务端日志 [auth_strict] 的 traceback")
resp = web.json_response(
{
"ok": False,
"status": 500,
"error": "auth_gate_internal_error",
"path": path,
"detail": detail,
"contract_error": contract,
"hint": hint,
},
status=500,
)
try:
resp.headers["X-Auth-Gate-Error"] = "internal"
except Exception: # noqa: BLE001
pass
return resp
@web.middleware
async def auth_gate_strict_middleware(request, handler):
"""最后一层会话闸门:非公开 `.dspy` 无有效会话 → 401(fail-closed)。
判定顺序(顺序即语义):
1. 总开关关闭(排障)→ 放行;
2. 非 `.dspy`(页面/静态)→ 放行;
3. 显式公开路径(登录/登出/健康探针)→ 放行;
4. 其余 `.dspy` → 校验会话密文,失败 401,成功放行给内层链/业务处理器。
"""
# [fix Zhpd9MXyeGtznDMqini6X / R49] 与 route_guard 同源修法:
# 只把「闸门自身判定/会话校验」的异常 fail-closed 成 401,
# `await handler(request)`(下游业务处理器)必须在 try 之外,
# 否则下游 500 / aiohttp `Expect response, not <class 'function'>`
# 会被改写成 401 auth_unavailable,把真实故障整层遮蔽(R49 矩阵
# 229×401、42 条 500 无法判定的机械原因)。
try:
if not gate_enabled():
return await handler(request)
path = normalize_path(getattr(request, "path", "") or "")
if not is_gated_path(path):
return await handler(request)
ok, reason = _has_session(request)
except Exception as exc: # noqa: BLE001
# fail-closed 仍成立(绝不放行业务数据),但**错误码不再冒充鉴权失败**。
# 历史(已废除):R50 曾把公开路径的闸门异常 `return await handler(request)`
# 放行,其余一律 401 auth_unavailable —— 两者都在掩盖真实故障,R60 起废除。
# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 语义分离:**闸门内部异常 ≠ 会话缺失**。
# 旧实现把任何异常(典型:链装配错误 `Expect response, not <class 'function'>`)
# 一律降级成 401 auth_unavailable,于是「登录端点自己 401」被固化,
# 真实故障(某层把未 await 的 handler 当 Response 返回)被 401 完全遮蔽 ——
# R58 实测 logs/scense.log:1735-1737 就是这条遮蔽路径。
# 现在:会话缺失/无效 → 401(见下方 ok 分支);闸门自身或内层链异常 →
# 500 + error=auth_gate_internal_error + 完整 traceback,绝不改写成鉴权失败。
return _internal_error(request, exc)
if ok:
return await handler(request)
return _deny(request, reason)