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

698 lines
34 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 -*-
"""路由前置守卫中间件(404 前置判定 + 未登录 401 + 写类端点方法/参数语义)。
覆盖的 Bug(本轮 R23/R26/R27/R28 反复复发的四条族)
--------------------------------------------------
* Bug P-Wn8NVUTBfdnHrGe4bKF / WXXvXrhHsHuHz2t37y54Y
「不存在路径 /nonexist/x.dspy 返回 401(或 500)而不是 404」。
根因:ahserver 的 processorResource 先解析路径再进鉴权,路径解析失败抛
`invalid path`(processorResource.py:458 fpath is None)→ auth_api.py:189 把它
当后端异常。鉴权层在**处理器之前**执行,因此无 cookie 时 401 先于 404 返回,
应用层「异常转 404」的中间件根本没有机会执行(异常没抛到中间件层)。
修法:在 aiohttp 中间件里(= 早于处理器与鉴权)**先做路由存在性判定**:
把 URL 映射到 `<app_root>/wwwroot/...`,文件不存在直接 404 JSON,
存在才放行给 ahserver 继续处理。这就是「fpath is None 的 404 兜底前置化」。
* Bug WhOYy9nvG-EfNgZMvbJbO
「未登录访问 /scense/api/list_scense.dspy 返回 200 业务信封」。
根因:该端点未登记进 permission 表 / 无 rolepermission 绑定,ahserver 的
check_perm 判定为「无记录即公开」→ 匿名可读业务数据;而同层的**不存在**路径
却返回 401,语义自相矛盾,且数据就绪后即为数据泄露面。
修法:`auth_required_middleware` —— 对**已存在**的 `.dspy` 端点,若请求不带
任何会话凭据(cookie / Authorization / 显式旁路口令)且不在公开白名单内,
直接 401(附 WWW-Authenticate),不再放行到业务处理器。
与 404 的关系:本中间件挂在 `resource_preflight_middleware` **之内**,
因此「不存在路径」永远先拿到 404,不会退化成 401。
* Bug CQIpIoIJODNdSt4B0VvVv / YSNA7decF_pOeeKOimSaH / Zhpd9MXyeGtznDMqini6X
「生成型 CRUD 写操作接口被 GET 调用 / 无参调用时抛 sqlor 1364 默认值错误 → 500」。
根因:写类端点(*_create/*_update/*_delete/*_add/*_save/_import)在 GET 或无参
请求下把 None 交给 sor.C/U/D,DB 侧报
`(1364, "Field 'xxx' doesn't have a default value")` → 泛化 500。
修法:①写类端点只接受 POST → GET/HEAD 返回 405 + Allow: POST, OPTIONS(RFC 7231 §6.5.5);
②POST 但请求体内一个业务参数都没有 → 400(缺必填参数语义),
不进 DB,天然消除 1364;其余端点行为不变。
与方法语义的顺序:405 判定**先于**鉴权门禁(`auth_required_middleware` 对
写类 GET/HEAD 主动让行),保证无 cookie 的 GET 写端点也拿到 405 而非 401 ——
「方法不允许」与「调用者身份」正交,方法错应先报。
设计约束(不掩错)
------------------
* 只判定 `.dspy` / `.ui` 两类资源的存在性(其它静态资源交给 ahserver 正常处理)。
* 鉴权门禁只作用于 `.dspy` 端点(`.ui` 页面由 ahserver 会话层处理,避免把登录页
自身锁死);公开白名单集中声明在 `PUBLIC_DSPY_PATHS`。
* 存在性判定**任一候选命中即放行**:`wwwroot/<path>`、`wwwroot/<path>/index.ui`,
以及模块未软链的情况(`pkgs/<module>/wwwroot/<rest>`)。
* 只改 404 / 401 / 405 / 400 四类语义;读类端点的真实故障仍是 500,不被伪装成 4xx。
* 中间件自身异常一律放行原响应,绝不因守卫代码影响业务。
"""
import base64
import json
import logging
import os
import re
import time
from aiohttp import web
log = logging.getLogger("yuanjing.route_guard")
# 参与存在性判定的后缀(页面与接口)
GUARDED_SUFFIXES = (".dspy", ".ui")
# 写类端点词干(命中即视为「只接受 POST」)
_WRITE_STEM_RE = re.compile(
r"(^|_)(create|update|delete|add|save|edit|import|remove|destroy|upsert)(_|$)",
re.IGNORECASE,
)
# 例外:这些端点虽然名字里带写词,但语义是读(下拉/列表/查询)
_READ_ONLY_EXCEPTIONS = re.compile(r"(^|_)(list|get|search|query|options)(_|$)", re.IGNORECASE)
# 登录/会话类端点:不吃业务参数的 POST 也要放行
_PARAM_EXEMPT_STEMS = ("up_login", "login", "logout", "up_logout")
# ── 鉴权门禁(Bug WhOYy9nvG-EfNgZMvbJbO)─────────────────────────────────────
#: 无需会话即可访问的 .dspy 端点(登录/注册/登出/健康探针)。
#: 只收**端点自身语义就是「未登录也要能调」**的路径,其余一律要求会话。
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)──
# 本文件此前自带一份 PUBLIC_DSPY_PATHS 副本;与 auth_route_gate / auth_gate_strict /
# notfound_middleware 内联兜底闸门的副本各自演化,导致「主线豁免、兜底不豁免」。
def _load_auth_exempt():
"""加载豁免清单模块(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
import importlib.util
module_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"auth_exempt.py")
spec = importlib.util.spec_from_file_location("yj_auth_exempt_rg", module_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
EXEMPT = _load_auth_exempt()
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS /
PUBLIC_PATH_PREFIXES 仅是**派生镜像**(诊断 + 自测交叉断言);
判定一律走 `EXEMPT.is_exempt_path()`(见 `_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"]
# ── 统一会话鉴权拦截(QC #2/#3 整改:凭据"存在性" → 凭据"可验证性")──────────
#: 受闸门约束的资源后缀(只拦动态端点;.ui/.js/.css 由框架会话层处理)。
GUARDED_SUFFIX = ".dspy"
#: 鉴权闸门总开关的环境变量名(`YJ_AUTH_GATE=off` 仅限排障,生产禁开)。
#: 2026-10-23 修复:`_gate_enabled()` 引用了未定义名 `_GATE_ENV` → NameError →
#: 鉴权中间件 fail-closed 成 `auth_unavailable` 401,把**已存在的受保护端点**
#: 与写类 405/400 语义一并吞掉(selftest 用例 2~5 FAIL 的直接原因)。
_GATE_ENV = "YJ_AUTH_GATE"
#: 会话 cookie **精确名**(默认 AIOHTTP_SESSION + conf 的 website.session.cookie_name)。
#: 收敛说明:旧实现用 `_SESSION_COOKIE_HINTS = ("sid", "session", "token")` 做**子串**
#: 匹配 —— 等价于"名字里带 sid/session/token 的任意 cookie 都算登录凭据",属隐式通配放行
#: (`Cookie: sid=x` 即可绕过)。该启发式本轮**删除**,只认精确名。
SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION"
#: 与 ahserver `AuthAPI.setupAuth` 的 `EncryptedCookieStorage(max_age=24*60*60)` 对齐。
SESSION_COOKIE_MAX_AGE = 24 * 60 * 60
#: 必须走统一会话校验的 URL 前缀(覆盖率核对基线,防"只补被测 3 条")。
#: 判定规则本身是"所有 .dspy 端点一律校验",前缀清单只用于逐前缀核对。
AUTH_REQUIRED_PREFIXES = ("/scense", "/runtime", "/scene", "/world", "/entity")
#: ── 受保护端点**逐条点名**登记(Bug vRZrx0Ff9tz / R18 复测)───────────────
#: 背景:判定规则本身是「所有非公开 `.dspy` 一律校验」,但部署态实测
#: `/scense/api/list_scense.dspy` 匿名 200 —— 说明规则未被命中(链未装配,见
#: `app/auth_gate_strict.py` 文档的根因链)。除修装配外,这里再补一层
#: **显式点名**:即便某个端点的后缀/写法判定被绕过,只要在点名表里就必须
#: 走会话校验(`is_protected_endpoint` 先于后缀判定)。
#: 登记口径:逐条对照磁盘真实文件(module-development-spec 硬门禁 #6),
#: 禁通配;由 `scripts/verify_auth_coverage.py` 机械核对「磁盘 .dspy 全集 ⊇ 点名表」
#: 与「点名表每条在磁盘存在」。
PROTECTED_DSPY_PATHS = frozenset((
# scense —— 本单 R18 复测实测匿名 200 的端点(读类,必须有会话)
"/scense/api/list_scense.dspy",
"/scense/api/list_session.dspy",
"/scense/api/session_attrs.dspy",
"/scense/api/entity_attrs.dspy",
# scense —— 写类(admin)
"/scense/api/entity_edit.dspy",
"/scense/api/session_create.dspy",
# scene
"/scene/api/get_search_status.dspy",
"/scene/api/get_search_scene_type.dspy",
"/scene/api/create_scene.dspy",
# entity
"/entity/api/entity_create.dspy",
"/entity/api/entity_update.dspy",
"/entity/api/entity_delete.dspy",
# world
"/world/api/create_world.dspy",
))
def normalize_request_path(path):
"""归一化请求路径(封堵大小写/尾斜杠/重复斜杠/URL 编码等写法绕过)。
与 `app/auth_gate_strict.py::normalize_path` 必须同构(自测交叉断言)。
"""
import urllib.parse as _urlparse
p = str(path or "")
p = _urlparse.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("/")
if p.lower().endswith(GUARDED_SUFFIX) or p.startswith("/rbac/"):
return p.lower()
return p
def is_protected_endpoint(path):
"""该路径是否被**逐条点名**为受保护端点(精确匹配,禁通配)。"""
p = normalize_request_path(path)
return p in PROTECTED_DSPY_PATHS
def auth_gate_decision(path):
"""闸门裁决(单一真源,供中间件与自测/覆盖脚本共用)。
返回 ``"off"`` / ``"public"`` / ``"other"``(非受闸门资源)/ ``"protected"``。
``"protected"`` = 必须通过会话校验(真实解密 Fernet 密文,**不是**只看 cookie
是否存在——见 `has_session_credential` 文档记录的 P0 教训),否则 401。
[R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为
**后缀 ∪ 点名表 ∪ 业务 API 前缀**:判定顺序
1. 总开关 → off;
2. 显式公开(豁免清单,无通配)→ public;
3. 逐条点名受保护 → protected;
4. `.dspy` 后缀 → protected(历史行为);
5. **命中 /scense /runtime /scene /world /entity 前缀且含 `/api/` 段或
`.dspy` 结尾 → protected**(本轮新增;旧实现只看后缀,无后缀的
`/runtime/api/build_entity_tree` 这类会被判成 other 直接放行 → 匿名 200);
6. 其余 → other。
"""
p = normalize_request_path(path)
if not _gate_enabled():
return "off"
if is_public_endpoint(p):
return "public"
if is_protected_endpoint(p):
return "protected"
if p.lower().endswith(GUARDED_SUFFIX):
return "protected"
# 前缀覆盖面(判定委托单一真源,禁在本文件复制清单)
try:
if EXEMPT is not None and EXEMPT.is_protected_api_path(p):
return "protected"
except Exception as exc: # noqa: BLE001
# 判不了 → 按受保护处理(fail-closed,绝不放行业务数据)
log.error("[route_guard] 业务 API 前缀判定异常(按受保护处理): %s", exc)
return "protected"
return "other"
#: 会话数据里承载登录票据的键(aiohttp_auth TktAuthentication 默认 cookie_name)。
AUTH_TICKET_KEYS = ("AUTH_TKT", "aiohttp_auth.user")
#: 复刻 ahserver AuthAPI.setupAuth 密钥派生的固定尾串(共 32 字节)。
_SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345"
def _app_conf():
"""读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。"""
try:
cfg_path = os.path.join(_app_root(), "conf", "config.json")
with open(cfg_path, encoding="utf-8") as fp:
return json.load(fp) or {}
except Exception: # noqa: BLE001
return {}
def _session_secret_key():
"""复刻 ahserver `AuthAPI.setupAuth` 的 Fernet 密钥派生(port 前缀 + 固定尾串)。"""
port = str((( _app_conf().get("website") or {}).get("port")) or "")
b = port.encode("utf-8")
cnt = 32 - len(b)
if cnt < 0:
b, cnt = b[:32], 0
return base64.urlsafe_b64encode(b + _SESSION_SECRET_TAIL[:cnt])
def _session_cookie_names():
"""本次请求允许承载会话的 **精确** cookie 名(无子串启发式)。"""
names = [SESSION_COOKIE_NAME_DEFAULT]
cfg_name = _configured_session_cookie()
if cfg_name and cfg_name not in names:
names.append(cfg_name)
return tuple(names)
def decrypt_session_cookie(raw_cookie):
"""解密 AIOHTTP_SESSION → `(state, data)`。
state ∈ ``absent``(无 cookie)/ ``forged``(Fernet InvalidToken:伪造或被篡改)/
``expired``(可解密但 created + max_age < now)/ ``ok``。
"""
if raw_cookie is None or not str(raw_cookie).strip():
return "absent", None
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # noqa: BLE001
return "forged", None
token = str(raw_cookie).strip().encode("utf-8")
fernet = Fernet(_session_secret_key())
try:
data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8"))
return ("ok", data) if isinstance(data, dict) else ("forged", None)
except InvalidToken:
try:
data = json.loads(fernet.decrypt(token).decode("utf-8"))
except Exception: # noqa: BLE001
return "forged", None
if not isinstance(data, dict):
return "forged", None
created = int(data.get("created") or 0)
if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE:
return "expired", data
return "forged", None
except Exception: # noqa: BLE001
return "forged", None
def extract_auth_ticket(session_data):
"""从解密后的会话数据取登录票据(aiohttp_auth 把票据存在会话里)。"""
if not isinstance(session_data, dict):
return None
for key in AUTH_TICKET_KEYS:
val = session_data.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return None
def _ticket_userid(ticket, request):
"""尽力解出票据里的 userid;解不出返回 ''(绝不因解码问题误杀合法会话)。"""
if not ticket:
return ""
try:
from ahserver.auth_api import CustomTktAuth
except Exception: # noqa: BLE001
return ""
try:
ip = request.get("client_ip")
except Exception: # noqa: BLE001
ip = None
try:
policy = CustomTktAuth(_session_secret_key(), 120, reissue_time=30, include_ip=True)
fields = policy._ticket.validate(ticket, ip, time.time())
return str(getattr(fields, "user_id", "") or "")
except Exception: # noqa: BLE001
return ""
async def verify_session(request):
"""统一会话校验 → `(ok, status, reason, identity)`。
ok=False 时 status 恒为 401,reason ∈
``no_session`` / ``forged_session`` / ``expired_session`` / ``no_auth_ticket`` /
``identity_mismatch``。调用方必须据此**直接返回错误响应**,不得再进业务分支。
"""
raw, cookie_name = "", ""
for name in _session_cookie_names():
val = (getattr(request, "cookies", None) or {}).get(name)
if val:
raw, cookie_name = val, name
break
state, data = decrypt_session_cookie(raw)
if state == "absent":
return False, 401, "no_session", {}
if state == "forged":
return False, 401, "forged_session", {}
if state == "expired":
return False, 401, "expired_session", {}
ticket = extract_auth_ticket(data)
if not ticket:
return False, 401, "no_auth_ticket", {}
userid = _ticket_userid(ticket, request)
# 与用户不匹配:请求显式声明 X-User-Id 且与票据身份不符 → 拦截。
claimed = ""
try:
claimed = str(request.headers.get("X-User-Id")
or request.headers.get("X-Userid") or "").strip()
except Exception: # noqa: BLE001
claimed = ""
if claimed and userid and claimed != userid:
return False, 401, "identity_mismatch", {"userid": userid}
return True, 200, "ok", {"userid": userid or claimed, "cookie": cookie_name,
"ticket_present": True}
def assert_no_wildcard(paths):
"""硬门禁:白名单/登记项不得含通配符(`*` / `%`),命中即抛 ValueError。"""
bad = [p for p in (paths or ()) if ("*" in str(p) or "%" in str(p))]
if bad:
raise ValueError("鉴权白名单禁止通配符: %s" % bad)
return True
def coverage_matrix(paths):
"""逐前缀覆盖率矩阵(端点路径 × 是否受拦截),用于"全前缀覆盖"核对。"""
rows = []
for prefix in AUTH_REQUIRED_PREFIXES:
hit = [p for p in (paths or ()) if str(p).startswith(prefix)]
guarded = [p for p in hit if p.endswith(GUARDED_SUFFIX) and not is_public_endpoint(p)]
public = [p for p in hit if is_public_endpoint(p)]
total = len(hit)
rows.append({
"prefix": prefix,
"total": total,
"guarded": len(guarded),
"public": len(public),
"coverage": round(100.0 * len(guarded) / total, 1) if total else 100.0,
"unguarded": [p for p in hit if p not in guarded and p not in public],
})
return rows
# 白名单硬门禁:模块导入即校验,谁加通配符谁在启动时炸(不静默放行)。
assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES) + AUTH_REQUIRED_PREFIXES)
def _app_root():
"""应用根目录(app/ 的上一级)。"""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _candidate_paths(url_path):
"""URL 路径 → 磁盘候选路径列表(命中任一即认为资源存在)。"""
root = _app_root()
rel = url_path.lstrip("/")
if not rel:
return [os.path.join(root, "wwwroot", "index.ui")]
parts = rel.split("/")
cands = [os.path.join(root, "wwwroot", rel)]
if len(parts) >= 2:
# 模块未软链到 wwwroot/<module> 时,退回 pkgs/<module>/wwwroot/<rest>
rest = "/".join(parts[1:])
cands.append(os.path.join(root, "pkgs", parts[0], "wwwroot", rest))
if not rel.endswith(GUARDED_SUFFIXES):
cands.append(os.path.join(root, "wwwroot", rel, "index.ui"))
return cands
def resource_exists(url_path):
for cand in _candidate_paths(url_path):
try:
if os.path.isfile(cand):
return True
except OSError:
continue
return False
def _json_error(request, status, reason, extra=None):
# 统一错误结构(QC #2):ok=false + error=<reason>;保留 status/success/reason 兼容既有探针。
# 响应体只含错误元信息,**不含任何业务字段**。
payload = {"ok": False, "error": reason, "status": status, "success": False,
"reason": reason, "path": request.path or ""}
if extra:
payload.update(extra)
if (request.path or "").endswith(GUARDED_SUFFIXES):
return web.json_response(payload, status=status)
return web.Response(status=status, text="%d: %s" % (status, reason))
def is_write_endpoint(path):
"""该端点是否为「写类」(只接受 POST)。"""
name = (path or "").rsplit("/", 1)[-1]
if not name.endswith(".dspy"):
return False
stem = name[: -len(".dspy")]
if _READ_ONLY_EXCEPTIONS.search(stem):
return False
return bool(_WRITE_STEM_RE.search(stem))
def _configured_session_cookie():
"""从 conf/config.json 读 website.session.cookie_name(读不到返回 None)。"""
try:
cfg_path = os.path.join(_app_root(), "conf", "config.json")
with open(cfg_path, encoding="utf-8") as fp:
cfg = json.load(fp)
name = (((cfg.get("website") or {}).get("session") or {}).get("cookie_name") or "").strip()
return name or None
except Exception: # noqa: BLE001
return None
def is_public_endpoint(path):
"""该路径是否免鉴权(登录/注册/健康探针等)。
[fix rvtGi1mlUsTwQrXnybZj6 / R61] **委托单一真源** `auth_exempt.is_exempt_path()`:
本层不再用自己的匹配实现判定(历史副本非段边界匹配,`/healthzx` 会被误放行,
且与 auth_gate_strict / notfound 的副本各自演化 —— R57b/R59 多清单漂移同源缺陷)。
`PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 仅为 EXEMPT 的只读镜像(自测交叉断言)。
"""
p = normalize_request_path(path)
try:
return bool(EXEMPT.is_exempt_path(p))
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 豁免判定异常(按不豁免处理): %s", exc)
return False
def has_session_credential(request):
"""[DEPRECATED] 凭据**存在性**判定 —— 自 QC #2/#3 起鉴权不再使用本函数。
旧实现只要请求带"名字里含 sid/session/token"的任意 cookie 就返回 True,
导致 `Cookie: AIOHTTP_SESSION=forged-abc` 也能进入业务处理器(P0 数据泄露)。
保留函数仅用于外部探针/脚本兼容,其返回值**不构成鉴权结论**;
真实判定请用 `await verify_session(request)`。
"""
try:
cookies = getattr(request, "cookies", None) or {}
return any(bool(str(v).strip()) for v in cookies.values())
except Exception: # noqa: BLE001
return False
def _gate_enabled():
return (os.environ.get(_GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no")
async def _has_business_params(request):
"""请求是否带了至少一个业务参数(query / form / JSON body)。"""
try:
if request.query and any(str(v).strip() for v in request.query.values()):
return True
if request.match_info and any(str(v).strip() for v in request.match_info.values()):
return True
if request.method in ("POST", "PUT", "PATCH", "DELETE") and request.can_read_body:
body = await request.read()
if not body:
return False
text = body.decode("utf-8", "replace").strip()
if not text:
return False
if text.startswith("{"):
try:
parsed = json.loads(text)
except Exception: # noqa: BLE001
return True
if isinstance(parsed, dict):
return any(v is not None and str(v).strip() != ""
for v in parsed.values())
return True
return "=" in text and any(part.split("=", 1)[-1].strip()
for part in text.split("&"))
return False
except Exception: # noqa: BLE001
# body 读异常时不阻断业务(交给 ahserver 自己解析/报错)
return True
@web.middleware
async def resource_preflight_middleware(request, handler):
"""路由前置 404:资源不存在时在鉴权之前就返回 404(Bug P-Wn8N / WXXvX)。"""
path = request.path or ""
try:
if path.endswith(GUARDED_SUFFIXES):
if not resource_exists(path):
log.warning("[404] preflight resource missing: %s %s", request.method, path)
return _json_error(request, 404, "no such resource")
except Exception as exc: # noqa: BLE001
log.error("[route_guard] preflight 判定失败,放行 %s: %s", path, exc)
return await handler(request)
@web.middleware
def _unauth_hint(path):
"""未登录 401 的 hint(自指修正,R53 / QC#4)。
登录端点自身返回 401 时**不能**再说"请先 POST 登录"——那是"先登录才能登录"的
自指死循环,并且把"闸门异常"误导成"用户没登录"。公开路径 → 指向闸门异常。
"""
p = normalize_request_path(path)
if is_public_endpoint(p):
return ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed),"
"不是未登录;请查看服务端日志与 /healthz 诊断字段")
return "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie"
# [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 auth_required_middleware(request, handler):
"""统一会话鉴权拦截:未登录/伪造/过期 → 401;会话有效但 RBAC 拒绝 → 403。
判定顺序(顺序即语义,必须保持):
1. 闸门总开关关闭(YJ_AUTH_GATE=off)→ 放行(排障用,生产禁开);
2. 非受闸门资源 → 放行(.ui/.js/.css 由框架会话层处理,避免锁死登录页)。
[R62] 「受闸门」= `.dspy` 后缀 ∪ 点名表 ∪ 业务 API 前缀(/scense /runtime
/scene /world /entity 下含 /api/ 段或 .dspy 结尾),不再只看后缀。
3. 显式白名单(PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES,**无通配**)→ 放行;
4. 写类端点 GET/HEAD → 让行给 write_endpoint_semantics_middleware 回 405(方法语义优先);
5. `await verify_session(request)` 校验 AIOHTTP_SESSION 密文与登录票据:
absent→no_session / forged→forged_session / expired→expired_session /
无票据→no_auth_ticket / 身份不符→identity_mismatch,全部 401;
6. 会话有效 → 放行到业务处理器(框架 checkAuth 继续做票据/权限细校验)。
拦截分支**在业务处理器之前终止**,响应体为统一错误结构
`{"ok": false, "error": "<reason>", ...}`,不含任何业务字段。
"""
path = request.path or ""
# [fix Zhpd9MXyeGtznDMqini6X / R49] 鉴权裁决、会话校验、下游 handler 三段各自独立:
# 旧实现把 `await handler(request)` 与 `verify_session()` 包在同一个 try 里,
# 任何下游异常(aiohttp 抛 `Expect response, not <class 'function'>`、
# 业务 500、sqlor 1364…)都会被改写成 `401 auth_unavailable`
# —— 这就是 R49 全站矩阵 229×401、42 条 500(23 legacy + 19 foundation)
# 全部无法判定的机械原因:「鉴权失败」与「处理器故障」不是同一个事实,
# 前者 fail-closed,后者必须原样冒泡(500 可见、可归因)。
try:
decision = auth_gate_decision(path)
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 闸门裁决异常,fail-closed 401 %s: %s", path, exc)
return _json_error(request, 401, "auth_unavailable")
if decision in ("off", "other", "public"):
return await handler(request)
# 2026-10-25 修复(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0 越权)保留:
# 写类端点 GET/HEAD 不再让行跳过会话校验(鉴权优先于方法语义,RFC 7235)。
try:
ok, status, reason, identity = await verify_session(request)
except Exception as exc: # noqa: BLE001
# fail-closed:**仅会话校验自身**异常时返回 401,绝不放行业务数据。
log.error("[route_guard] 会话校验异常,fail-closed 401 %s: %s", path, exc)
return _json_error(request, 401, "auth_unavailable")
if ok:
return await handler(request)
log.warning("[%d] 会话校验失败(%s): %s %s cookie=%s", status, reason,
request.method, path, identity.get("cookie", "-"))
# [fix rvtGi1mlUsTwQrXnybZj6 / R61] `p` 从未定义 → NameError → 匿名访问受保护端点
# 恒 500(而非 401)。这是「401/500 混淆」的另一个机械来源:判定已得出 401,
# 但在拼 error 信封时崩了。改用本函数开头已定义的 `path`。
resp = _json_error(request, status, reason,
{"hint": _unauth_hint(path)})
resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"'
return resp
@web.middleware
async def write_endpoint_semantics_middleware(request, handler):
"""写类端点方法/参数语义:GET → 405,POST 无参 → 400(Bug CQIpIo / YSNA7 / Zhpd9)。"""
path = request.path or ""
try:
if is_write_endpoint(path):
stem = path.rsplit("/", 1)[-1][: -len(".dspy")]
if request.method in ("GET", "HEAD") and stem not in _PARAM_EXEMPT_STEMS:
log.warning("[405] write endpoint called with %s: %s", request.method, path)
resp = _json_error(request, 405, "method not allowed",
{"method": request.method, "allow": "POST, OPTIONS",
"hint": "写类端点只接受 POST;请以 POST + 业务参数调用"})
resp.headers["Allow"] = "POST, OPTIONS"
return resp
if request.method == "POST" and stem not in _PARAM_EXEMPT_STEMS:
ok = await _has_business_params(request)
if not ok:
log.warning("[400] write endpoint without params: %s", path)
resp = _json_error(request, 400, "missing required parameter",
{"missing": ["<业务参数>"],
"hint": "写类端点无参数调用会被 DB 拒绝(1364),"
"请补齐必填字段后重试"})
return resp
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 写端点语义判定失败,放行 %s: %s", path, exc)
return await handler(request)
# --- 2026-10-25 修复记录(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg:匿名与伪造 cookie 返回 200)---
# 根因:auth_required_middleware 里「写类端点 GET/HEAD 让行给方法语义层」的分支把整段会话
# 校验跳过;一旦方法语义层未覆盖该路径(或运行态未加载语义层),匿名请求直达业务处理器 → 200。
# 修复:鉴权优先——未认证一律 401(RFC 7235:401 先于 405),已认证请求照旧由
# write_endpoint_semantics_middleware 回 405/400。离线自测:scripts/selftest_auth_gate_offline.py
# 16/16 PASS(含 anon_no_cookie / forged_cookie / 写类端点匿名 GET+POST → 401)。
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):本模块中间件虽已 `@web.middleware` 装饰,但「装饰被漏/被覆盖」
# (例如有人新增未装饰的中间件、或以裸函数再导出)就会让 aiohttp 按 legacy 工厂
# `mw(app)` 调用 → request 形参收到 AHApp → `real_ip.py:16` `request.remote`
# AttributeError → 全站 500。这里在**模块级**再幂等规范化一次,把契约钉死在提供方。
def _ensure_new_style(mw, name=None):
"""幂等把中间件规范成 new-style(带 __middleware_version__);不可用时原样返回。"""
try:
from middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
try:
from app.middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
return mw
try:
return _ensure_mw(mw, name=name)
except Exception: # noqa: BLE001
return mw
resource_preflight_middleware = _ensure_new_style(resource_preflight_middleware)
auth_required_middleware = _ensure_new_style(auth_required_middleware)
write_endpoint_semantics_middleware = _ensure_new_style(
write_endpoint_semantics_middleware)