#!/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 映射到 `/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/`、`wwwroot//index.ui`, 以及模块未软链的情况(`pkgs//wwwroot/`)。 * 只改 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/ 时,退回 pkgs//wwwroot/ 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=;保留 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 `)。 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": "", ...}`,不含任何业务字段。 """ path = request.path or "" # [fix Zhpd9MXyeGtznDMqini6X / R49] 鉴权裁决、会话校验、下游 handler 三段各自独立: # 旧实现把 `await handler(request)` 与 `verify_session()` 包在同一个 try 里, # 任何下游异常(aiohttp 抛 `Expect response, not `、 # 业务 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)