根因:元景迭代中为通过 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 中间件提供。
214 lines
9.7 KiB
Python
214 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""鉴权豁免清单**单一真源**(Bug uifdBIMdmixZcmJEdV33O / T4)。
|
||
|
||
问题
|
||
----
|
||
修复前,「哪些端点免鉴权」这件事在 5 个文件里各存了一份:
|
||
|
||
app/auth_route_gate.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
|
||
app/route_guard_middleware.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
|
||
app/auth_gate_strict.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
|
||
app/entry_guard_r19.py 自己没有清单,委托 auth_gate_strict
|
||
app/notfound_middleware.py **完全没有清单**(内联兜底闸门任何 .dspy 都 401)
|
||
|
||
后果(R57b / R59 实测):登录端点 `/rbac/user/up_login.dspy` 在主线闸门上被豁免,
|
||
但在「语义组合链装配失败 → 内联兜底闸门」这条路径上**没有任何豁免**,一律返回
|
||
`401 {"error": "auth_unavailable"}`;而更早的版本还会把闸门自身异常统一降级成
|
||
`auth_unavailable`(R60 已改为 500 auth_gate_internal_error,但豁免缺失仍在)。
|
||
于是「登录端点自己 401」被固化,cookie jar 恒空,全部带登录态复测无法执行。
|
||
|
||
本模块的做法
|
||
------------
|
||
* **清单只有这一份**:`AUTH_EXEMPT_DSPY_PATHS`(精确路径,禁通配)+
|
||
`AUTH_EXEMPT_PREFIXES`(按**路径段边界**匹配的前缀,禁通配)。
|
||
* 所有闸门只调用 `is_exempt_path()`,不再各自维护副本。
|
||
* 豁免判定必须是闸门里**最靠前**的判定:在「资源存在性」「会话校验」之前。
|
||
这样无论 wwwroot 软链是否挂上、会话组件是否可用、闸门内部是否抛异常,
|
||
登录端点都不会被判成 404 / 401(真不存在时由框架自己给 404,语义更准)。
|
||
|
||
豁免判据(逐条写明,评审可核对)
|
||
--------------------------------
|
||
1. 路径归一化(去 query/fragment、URL 解码、`\`→`/`、折叠重复斜杠、去尾斜杠、补前导斜杠)后
|
||
**整串精确等于** `AUTH_EXEMPT_DSPY_PATHS` 中的某一项 → 豁免。
|
||
**不做前缀/通配/子串匹配**:`/rbac/*` 这种写法会连带放行 `/rbac/user/*` 全部端点
|
||
(含用户管理写接口),属于越权放行,明令禁止(QC 硬门禁 #6)。
|
||
2. 命中 `AUTH_EXEMPT_PREFIXES` 之一,且**按路径段边界**命中(`/static/` 命中 `/static/a`、
|
||
`/static` 本身,但**不**命中 `/staticx`)→ 豁免。这些是静态资产与健康探针,无业务数据。
|
||
3. 其余一律不豁免(默认拒绝,default-deny)。
|
||
|
||
零硬编码:本文件不含 host / port / 域名 / 凭据;清单是代码常量,逐条精确。
|
||
"""
|
||
|
||
import os
|
||
import logging
|
||
|
||
log = logging.getLogger("yuanjing.auth_exempt")
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 豁免清单(唯一真源,逐条精确,禁止通配)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
#: 免鉴权的动态端点(**精确路径**,小写比较)。
|
||
#: 依据:登录/登出/注册/会话自检这几件事发生在「还没有会话」的时刻,
|
||
#: 若要求会话则构成"先登录才能登录"的自指死循环。
|
||
AUTH_EXEMPT_DSPY_PATHS = frozenset((
|
||
"/rbac/user/up_login.dspy", # 主登录端点(前端表单 / API 均打这里)
|
||
"/rbac/user/login.dspy", # 兼容旧前端占位
|
||
"/rbac/user/up_logout.dspy", # 登出(销毁会话,本身不需要有效会话)
|
||
"/rbac/user/logout.dspy", # 兼容别名
|
||
"/rbac/user/register.dspy", # 注册
|
||
"/rbac/user/check_login.dspy", # 会话自检(返回是否已登录)
|
||
"/rbac/user/get_login_user.dspy", # 当前登录用户(未登录返回空,不 401)
|
||
))
|
||
|
||
#: 免鉴权前缀(静态资产 / 健康探针;按**路径段边界**匹配)。
|
||
AUTH_EXEMPT_PREFIXES = (
|
||
"/healthz",
|
||
"/health",
|
||
"/robots.txt",
|
||
"/favicon.ico",
|
||
"/i18n/",
|
||
"/static/",
|
||
"/files/",
|
||
)
|
||
|
||
#: 【R62 / Bug 匿名读全站 P0】受保护 API 前缀(**显式列举,禁通配**)。
|
||
#: 需求口径:修复必须覆盖 /scense、/runtime、/scene、/world、/entity 五个前缀下的
|
||
#: 全部 api 端点,而不是只补被测的 3 条。判据见 `is_protected_api_path()`:
|
||
#: 命中这五个前缀且「路径含 /api/ 段」或「以 .dspy 结尾」→ 必须带可验证会话。
|
||
#: 注意:这是**受保护**清单(扩大闸门覆盖面),不是豁免清单;豁免仍然只有上面
|
||
#: 那 7 条精确登录类端点 + 7 条静态/健康探针前缀,未因本次修复新增任何免鉴权项。
|
||
PROTECTED_API_PREFIXES = (
|
||
"/scense",
|
||
"/runtime",
|
||
"/scene",
|
||
"/world",
|
||
"/entity",
|
||
)
|
||
|
||
#: 判定口径标识(写进 /healthz 诊断与自测输出,便于部署态核对生效版本)。
|
||
EXEMPT_REVISION = "r62-api-prefix-coverage-v1"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 路径归一化与匹配
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def normalize_path(path):
|
||
r"""归一化请求路径,封堵写法绕过。
|
||
|
||
[fix rvtGi1mlUsTwQrXnybZj6 / R61] 本文档串含反斜杠样例 `\up_login.dspy`,
|
||
原为非 raw 三引号 → `\u` 被当作 unicode 转义前缀(`\up` 非法)→ 整个模块
|
||
**SyntaxError 无法 import**,进而 `route_guard_middleware` 的 auth_required 层
|
||
装载失败、鉴权层整层缺席(实测 SyntaxError: truncated \uXXXX escape, line 90)。
|
||
改为 raw docstring 后模块可正常导入;语义不变。
|
||
|
||
`/rbac/user/up_login.dspy/`、`//rbac/user/up_login.dspy`、
|
||
`/rbac/user/%75p_login.dspy`、`/rbac/user\up_login.dspy`、
|
||
`/rbac/user/up_login.dspy?x=1` 必须与规范路径得到**同一判定**。
|
||
"""
|
||
p = str(path or "")
|
||
try:
|
||
from urllib.parse import unquote
|
||
p = unquote(p)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
p = p.split("?", 1)[0].split("#", 1)[0]
|
||
p = p.replace("\\", "/")
|
||
p = "/" + "/".join([seg for seg in p.split("/") if seg])
|
||
if p != "/":
|
||
p = p.rstrip("/")
|
||
return p
|
||
|
||
|
||
def _prefix_hit(low_path, prefix):
|
||
"""按**路径段边界**判断前缀命中(禁止 `/static` 命中 `/staticx`)。"""
|
||
pre = str(prefix or "").lower()
|
||
if not pre:
|
||
return False
|
||
if pre.endswith("/"):
|
||
base = pre.rstrip("/")
|
||
return low_path == base or low_path.startswith(pre)
|
||
return low_path == pre or low_path.startswith(pre + "/")
|
||
|
||
|
||
def is_exempt_path(path):
|
||
"""该请求路径是否免鉴权(唯一判定入口,所有闸门只调这一个函数)。"""
|
||
try:
|
||
p = normalize_path(path)
|
||
except Exception: # noqa: BLE001
|
||
# 归一化都失败 → 不放行(fail-closed),但记日志便于定位。
|
||
log.error("[auth_exempt] 路径归一化失败,按不豁免处理: %r", path)
|
||
return False
|
||
low = p.lower()
|
||
if low in AUTH_EXEMPT_DSPY_PATHS:
|
||
return True
|
||
for prefix in AUTH_EXEMPT_PREFIXES:
|
||
if _prefix_hit(low, prefix):
|
||
return True
|
||
return False
|
||
|
||
|
||
def exempt_reason(path):
|
||
"""返回豁免依据字符串(未豁免返回空串),供自测/诊断输出。"""
|
||
p = normalize_path(path)
|
||
low = p.lower()
|
||
if low in AUTH_EXEMPT_DSPY_PATHS:
|
||
return "dspy_exact:" + low
|
||
for prefix in AUTH_EXEMPT_PREFIXES:
|
||
if _prefix_hit(low, prefix):
|
||
return "prefix:" + prefix
|
||
return ""
|
||
|
||
|
||
def assert_no_wildcard(paths=None):
|
||
"""硬门禁:豁免清单不得含通配符(`*` / `%`)。命中即抛 ValueError。"""
|
||
items = AUTH_EXEMPT_DSPY_PATHS if paths is None else paths
|
||
bad = [p for p in (items or ()) if ("*" in str(p) or "%" in str(p))]
|
||
if bad:
|
||
raise ValueError("鉴权豁免清单禁止通配符: %s" % (bad,))
|
||
return True
|
||
|
||
|
||
def is_protected_api_path(path):
|
||
"""该路径是否属于「必须鉴权的业务 API 覆盖面」(R62 新增判据)。
|
||
|
||
判据(默认拒绝,与豁免清单解耦):
|
||
1. 归一化 + 小写;
|
||
2. 必须**按路径段边界**命中 `PROTECTED_API_PREFIXES` 之一(`/scene` 不命中 `/scense`,
|
||
也不命中 `/scenex`);
|
||
3. 且该路径含 `/api/` 段,或以 `.dspy` 结尾(动态端点)。
|
||
命中后再由调用方判断是否豁免(`is_exempt_path()`)——豁免优先级更高,
|
||
但豁免清单里没有、也不允许有这五个前缀下的任何业务端点。
|
||
"""
|
||
try:
|
||
low = normalize_path(path).lower()
|
||
except Exception: # noqa: BLE001
|
||
return True # 判不了 → 按受保护处理
|
||
if not any(_prefix_hit(low, "/" + str(pfx).strip("/")) for pfx in PROTECTED_API_PREFIXES):
|
||
return False
|
||
return ("/api/" in low or low.endswith(".dspy")
|
||
or ("/api" in low and low.endswith(".api")))
|
||
|
||
|
||
def manifest():
|
||
"""豁免清单快照(写进 /healthz 诊断字段与自测输出)。"""
|
||
return {
|
||
"revision": EXEMPT_REVISION,
|
||
"dspy_paths": sorted(AUTH_EXEMPT_DSPY_PATHS),
|
||
"prefixes": list(AUTH_EXEMPT_PREFIXES),
|
||
"protected_api_prefixes": list(PROTECTED_API_PREFIXES),
|
||
"wildcard_free": assert_no_wildcard(),
|
||
}
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
import json as _json
|
||
log.setLevel(logging.INFO)
|
||
print(_json.dumps(manifest(), ensure_ascii=False, indent=2))
|
||
for _p in ("/rbac/user/up_login.dspy", "/rbac/user/up_login.dspy/",
|
||
"/rbac/user/%75p_login.dspy", "/rbac/user/up_login.dspy?a=1",
|
||
"/rbac/user/list_users.dspy", "/healthz", "/staticx/a.js"):
|
||
print("%-42s exempt=%-5s reason=%s" % (_p, is_exempt_path(_p), exempt_reason(_p) or "-"))
|