根因:元景迭代中为通过 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 中间件提供。
698 lines
34 KiB
Python
698 lines
34 KiB
Python
#!/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)
|