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

214 lines
9.7 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 -*-
"""鉴权豁免清单**单一真源**(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 "-"))