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

502 lines
20 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 -*-
"""登录端点入参的多源取值与「是否真的缺参」判定(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx)。
为什么需要这个模块(实测根因)
------------------------------
`app/http_semantics_middleware.py` 的 ⑤ 号闸门(R41 引入)本意是:**缺** username/password
时早返回 400,避免 None 被灌进 `appPublic/rc4._crypt` 崩成 500。它只读
`collect_request_params()` 的结果。
R49-b 线上回显(docs/03-test/R49-b-deployed-verify-decision.md §2.2,2026-10-26):
POST -d 'username=admin&password=admin123' /rbac/user/up_login.dspy -> HTTP=400
POST -H 'Content-Type: application/json' -d '{"username":"admin",...}' -> HTTP=400
body: {"status":400, "reason":"missing required parameter", "missing":["username","password"]}
即**参数明明提交了,却判成缺失**。原因不在 `parse_body_params`(form/JSON 解析在离线自测里是
通过的),而在取值时序:本中间件之前还有语义中间件链(`app/semantic_wiring.py` 装配的
auth_gate / notfound / business_status 等)。只要链上任何一环先消费过请求流(走
`request.multipart()` / StreamReader 而不是 `request.read()`),aiohttp 的 `_read_bytes`
缓存就是空的,`request.read()` 此时返回 `b""` —— 于是 body 解析得到 `{}`,
`missing_login_params()` 判「两个必填都缺」,登录被 400 挡死。
后果是**阻断面**:没有会话 → 全部受保护端点在部署态不可观测,test/deploy 的矩阵只能匿名跑。
修法(两层)
------------
1. **多源取值**:query / 路径变量 / ahserver 已合并的请求参数缓存(若存在)/ 原始 body
字节(JSON、form-urlencoded、multipart 普通字段)/ `request.post()` 兜底,逐源合并,
任一源取到即用。
2. **证据分级 + fail-open**:只有「确实读到了请求体(或确实没有请求体)且必填名为空」
才判 `missing` → 400;如果所有源都取不到值**且请求体读取失败/不可判**,判 `unknown`,
**不再伪造 400**,放行业务处理器(让它按真实情况成功或返回自己的错误)。
理由:把「已提交凭据」误判成「缺参」是**功能性阻断**(P0,全站不可用),
比漏判一次 500 严重得多;而真正的缺参请求(`-d '{}'` / 无 body)证据链完整,
仍然稳定 400(保留 R41 的原口径,不回退)。
硬约束
------
* 不打印口令(日志里只出现参数名与证据键,值一律 `***`);
* 不修改基座只读模块(rbac / ahserver / apppublic),一切收敛在应用层;
* 本模块只做取值与判定,不写响应体(响应仍由 http_semantics_middleware 统一出)。
"""
from __future__ import annotations
import json
import logging
import os
import re
log = logging.getLogger("yuanjing.login_param_source")
#: 判定结论
VERDICT_MISSING = "missing" # 证据充分:必填参数确实没提交 → 可以 400
VERDICT_PRESENT = "present" # 取到了必填参数 → 放行
VERDICT_UNKNOWN = "unknown" # 证据不足(请求体不可读且各源皆空)→ 放行(不伪造 400)
#: ahserver/aiohttp 上可能已存放「合并后请求参数」的属性名(best-effort 探测,命中即用)
_CACHED_PARAM_ATTRS = (
"params_kw", # ahserver 约定:query+body 合并后的参数字典
"_params_kw",
"ah_params",
"request_params",
"_ah_params",
)
_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE")
#: 标量白名单:口令之类允许空串判定,但容器值一律丢弃(避免把 dict 当参数值)
_SCALAR_TYPES = (str, int, float, bool, bytes)
# 自测钩子:置 1 时 `_read_body_bytes` 模拟「流已被上游消费」的场景
_FORCE_BODY_UNREADABLE = bool(os.environ.get("YJ_FAKE_BODY_UNREADABLE"))
def _is_meaningful(value):
"""非空标量才算「提交了该参数」(空串/纯空白/容器/None 都算没提交)。"""
if value is None:
return False
if isinstance(value, (dict, list, tuple, set)):
return False
if isinstance(value, bytes):
try:
value = value.decode("utf-8", "replace")
except Exception: # noqa: BLE001
return False
if isinstance(value, str):
return value.strip() != ""
return True
#: 合法参数名:可见字符,不含空白/控制符/换行,不以 -- 开头(防 multipart 脏 key 入库)
_INVALID_NAME_RE = re.compile(r"[\r\n\t\x00-\x1f]|^--|;|content-disposition", re.IGNORECASE)
def _valid_name(name):
return bool(name) and not _INVALID_NAME_RE.search(str(name))
def _flatten_mapping(raw, out):
"""把 dict / MultiDict 类容器摊平成 {小写名: 值}(只收有意义的标量)。"""
if raw is None:
return out
items = None
if isinstance(raw, dict):
items = raw.items()
elif hasattr(raw, "items") and callable(getattr(raw, "items")):
try:
items = raw.items()
except Exception: # noqa: BLE001
items = None
elif hasattr(raw, "getall") and callable(getattr(raw, "getall")):
items = None
if items is not None:
try:
for k, v in items:
out.setdefault(str(k).lower(), v)
except Exception: # noqa: BLE001
pass
else:
try:
for k in raw:
out.setdefault(str(k).lower(), raw[k])
except Exception: # noqa: BLE001
pass
return out
def _from_framework_cache(request, out):
"""源①:ahserver 已解析并挂到 request / _run_ns 上的参数缓存(存在即最可信)。"""
hit = None
carriers = [request]
run_ns = getattr(request, "_run_ns", None)
if run_ns is not None:
carriers.append(run_ns)
for carrier in carriers:
for attr in _CACHED_PARAM_ATTRS:
candidate = getattr(carrier, attr, None)
if isinstance(candidate, (dict, list, tuple)) or hasattr(candidate, "items"):
hit = candidate
break
if hit is not None:
break
if hit is None:
return out, False
_flatten_mapping(hit, out)
return out, True
def _from_query_and_match(request, out):
"""源②:查询串 + 路径变量(两者都是 aiohttp 的只读缓存,读取无副作用)。"""
try:
_flatten_mapping(request.query, out)
except Exception: # noqa: BLE001
pass
try:
_flatten_mapping(request.match_info, out)
except Exception: # noqa: BLE001
pass
# 兼容历史写法:GET 表单走 request.GET
try:
_flatten_mapping(getattr(request, "GET", None), out)
except Exception: # noqa: BLE001
pass
return out
def _json_dict(text):
try:
parsed = json.loads(text)
except Exception: # noqa: BLE001
return None
return parsed if isinstance(parsed, dict) else None
def _qsl_pairs(text):
try:
from urllib.parse import parse_qsl
return parse_qsl(text, keep_blank_values=True)
except Exception: # noqa: BLE001
return []
_NAME_RE = re.compile(r"name=\"([^\"]*)\"", re.IGNORECASE)
_NAME_RE2 = re.compile(r"name=([^;\r\n\"]+)", re.IGNORECASE)
_BOUNDARY_RE = re.compile(r'boundary="?([^";,]+)"?', re.IGNORECASE)
def _boundary_of(content_type):
"""取 Content-Type 里的 boundary(**保留原始大小写**)。"""
m = _BOUNDARY_RE.search(content_type or "")
return m.group(1).strip() if m else ""
def _field_name(header_text):
"""从 Content-Disposition 头里取 name= (带引号优先,其次裸值)。"""
m = _NAME_RE.search(header_text)
if m:
return m.group(1).strip()
m = _NAME_RE2.search(header_text)
if m:
return m.group(1).strip().strip('"').strip("'")
return None
def multipart_plain_fields(body, boundary, keep_case=False):
"""按 RFC 2046 精确切分 multipart 分段,取**普通文本字段**(跳过 filename= 文件字段)。
keep_case=True 时返回的字段名保持原始大小写(`request.params` 口径需要),
默认小写(本模块内部判定口径)。
为什么不能 `body.split("--"+boundary)`(原实现的缺陷,QC 意见 #4):
* delimiter 的**前导 CRLF** 属于分隔符的一部分;直接用 `--boundary` split 会把
上一段正文结尾的 `\\r\\n` 留在下一段的头部,导致 `username` 的值被吞成
`'admin\\r\\n--XY\\r\\nContent-Disposition...'`;
* 每段结尾的 `\\r\\n`(属于下一个 delimiter)会被当成正文的一部分留下;
* `--boundary--`(final boundary)之后的 epilogue 也会被当成一段解析,产出
`--xy\\r\\ncontent-disposition: form-data; name` 这类脏 key。
本实现逐段定位 delimiter(含前导 CRLF),识别 final boundary 即终止,
并在截取的段内剥离尾部 CRLF,只取 Content-Disposition 的 name= 与紧随其后的正文。
"""
out = {}
if not body or not boundary:
return out
delim = ("--" + str(boundary)).encode("utf-8", "replace")
first = body.find(delim)
if first < 0:
return out
idx = first
while True:
start = idx + len(delim)
# final boundary: "--boundary--" → 后面只有 epilogue,解析结束
if body[start:start + 2] == b"--":
break
# delimiter 之后必须紧跟 CRLF(或流末尾),否则不是合法分段起点
if body[start:start + 2] != b"\r\n":
nxt = body.find(delim, start)
if nxt < 0:
break
idx = nxt
continue
start += 2
nxt = body.find(delim, start)
if nxt < 0:
part = body[start:]
else:
part = body[start:nxt]
# 段尾的 CRLF 属于下一个 delimiter,剥掉
if part.endswith(b"\r\n"):
part = part[:-2]
if part and part[-2:] == b"\r\n":
part = part[:-2]
head_raw, sep, payload = part.partition(b"\r\n\r\n")
if sep:
head_text = head_raw.decode("utf-8", "replace")
low = head_text.lower()
if "content-disposition" in low and "filename=" not in low:
name = _field_name(head_text)
if name:
key = name if keep_case else name.lower()
out.setdefault(key, payload.decode("utf-8", "replace"))
if nxt < 0:
break
idx = nxt
return out
#: 向后兼容旧私有名
def _multipart_plain_fields(body, boundary):
return multipart_plain_fields(body, boundary)
def _parse_body_bytes(body, content_type, out):
"""源④:原始请求体字节(multipart → JSON → form-urlencoded)。"""
if not body:
return out
ctype_raw = content_type or ""
ctype = ctype_raw.lower()
if ctype.startswith("multipart/"):
# boundary 是**大小写敏感**的(RFC 2046),必须从原始 Content-Type 取值,
# 不能从 lower() 后的串取——否则 `boundary=XY` 会被当成 `xy` 而一段都切不到,
# 再落进 form-urlencoded 兜底,产出 `--xy\r\ncontent-disposition...` 脏 key。
merged = multipart_plain_fields(body, _boundary_of(ctype_raw))
for k, v in merged.items():
out.setdefault(k, v)
if out:
return out
# 没解析出任何字段也**不得**回落到 qsl 解析(会把整段 multipart 当表单串切坏)
return out
text = body.decode("utf-8", "replace").strip()
if not text:
return out
if text.startswith("{") or "json" in ctype:
parsed = _json_dict(text)
if isinstance(parsed, dict):
for k, v in parsed.items():
out.setdefault(str(k).lower(), v)
return out
for k, v in _qsl_pairs(text):
out.setdefault(str(k).lower(), v)
return out
async def _read_body_bytes(request):
"""读请求体字节,返回 (body, readable)。
readable=False 表示「拿不到证据」:上游已消费流且补读失败——此时**不得**判缺参。
"""
if _FORCE_BODY_UNREADABLE:
return b"", False
cached = getattr(request, "_read_bytes", None)
if isinstance(cached, (bytes, bytearray)) and bytes(cached):
return bytes(cached), True
# 上游已消费过:_read_bytes 为空 → read() 只会拿到 b"",此时尝试 can_read_body 判定
if not getattr(request, "can_read_body", True):
try:
body = await request.read()
except Exception: # noqa: BLE001
body = b""
if isinstance(body, (bytes, bytearray)) and bytes(body):
return bytes(body), True
# 请求声明了 body 却读不到内容:证据不足(真实提交过凭据也可能落在这里)
return b"", False
try:
body = await request.read()
except Exception: # noqa: BLE001
return b"", False
if isinstance(body, (bytes, bytearray)):
return bytes(body), True
return b"", False
def _declares_body(request):
"""请求是否在协议层**声明**带了 body(Content-Length>0 或 Transfer-Encoding: chunked)。
这是判定「证据不足」的关键:只要协议声明有 body,而本层一个字节都没读到
(上游中间件已消费流 / aiohttp 缓存为空),就**不能**断言「参数没提交」。
"""
try:
if getattr(request, "content_length", None):
return True
except Exception: # noqa: BLE001
pass
try:
headers = getattr(request, "headers", None)
except Exception: # noqa: BLE001
headers = None
if headers is not None:
try:
cl = headers.get("Content-Length")
except Exception: # noqa: BLE001
cl = None
try:
if cl is not None and int(cl) > 0:
return True
except Exception: # noqa: BLE001
pass
try:
te = str(headers.get("Transfer-Encoding") or "").lower()
except Exception: # noqa: BLE001
te = ""
if "chunked" in te:
return True
# 有 Content-Type 但没有 Content-Length 的替身对象(离线自测):视为声明带 body
try:
ctype = getattr(request, "content_type", "") or ""
except Exception: # noqa: BLE001
ctype = ""
if ctype and getattr(request, "content_length", None) is None:
return True
return False
async def _from_request_post(request, out):
"""源⑤:`request.post()`(form/multipart 的官方解析路径)兜底。"""
try:
post = await request.post()
except Exception: # noqa: BLE001
return out
_flatten_mapping(post, out)
return out
def _required_names(path, spec_table):
"""取该端点的必填 canonical 名与别名表(缺配置返回 None)。"""
specs = (spec_table or {}).get(path or "")
if not specs:
return None
return [(canonical, tuple(a.lower() for a in aliases)) for canonical, aliases in specs]
async def collect_login_params(request, spec_table):
"""多源收集登录端点参数,返回 (params, evidence)。
params : {小写参数名: 原始值}(只保留有意义的标量)
evidence : {"sources":[..], "body_readable":bool, "body_len":int,
"framework_cache_hit":bool}
"""
params = {}
sources = []
merged, cache_hit = _from_framework_cache(request, params)
if cache_hit and any(_is_meaningful(v) for v in merged.values()):
sources.append("framework_cache")
before = len([k for k, v in params.items() if _is_meaningful(v)])
_from_query_and_match(request, params)
if len([k for k, v in params.items() if _is_meaningful(v)]) > before:
sources.append("query/match_info")
body_readable = True
body_len = 0
declared_body = False
if request.method in _BODY_METHODS:
declared_body = _declares_body(request)
body, body_readable = await _read_body_bytes(request)
body_len = len(body or b"")
if body:
_parse_body_bytes(body, request.content_type, params)
sources.append("raw_body")
if not any(_is_meaningful(v) for v in params.values()):
await _from_request_post(request, params)
if any(_is_meaningful(v) for v in params.values()):
sources.append("request.post")
clean = {k: v for k, v in params.items() if _is_meaningful(v)}
evidence = {
"sources": sources,
"body_readable": body_readable,
"body_len": body_len,
"declared_body": declared_body,
"framework_cache_hit": cache_hit,
"names": sorted(clean.keys()),
}
return clean, evidence
def judge_required(path, params, evidence, spec_table):
"""返回 (verdict, missing_names)。
* 必填都取到 → present
* 确实缺(且有证据) → missing
* 取不到但证据不足 → unknown(调用方**不得**据此 400)
"""
specs = _required_names(path, spec_table)
if not specs:
return VERDICT_PRESENT, []
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
missing = []
for canonical, aliases in specs:
if not any(canonicalize_alias(a) in lowered and _is_meaningful(lowered.get(canonicalize_alias(a)))
for a in aliases):
missing.append(canonical)
if not missing:
return VERDICT_PRESENT, []
# 有必填没取到:区分「真没提交」与「没读到」
ev = evidence or {}
body_readable = ev.get("body_readable", True)
body_len = ev.get("body_len", 0) or 0
declared_body = bool(ev.get("declared_body"))
# 口径①(QC 意见 #5 的核心):协议声明带 body(Content-Length>0 / chunked)而本层
# 读不到内容 → 证据不足,必须判 unknown 放行,绝不伪造 400 挡死合法登录。
if not body_readable and (declared_body or body_len > 0):
return VERDICT_UNKNOWN, missing
# 口径②:读到过内容(body_len>0 或已取到别的参数)但仍缺必填,且流不可重读 →
# 同样属于"取值链不完整",证据不足放行。
if not body_readable and (lowered or body_len > 0):
return VERDICT_UNKNOWN, missing
# 口径③:Content-Length=0 / 确实无 body / 读到了 body 但必填为空 → 真缺参,可以 400
return VERDICT_MISSING, missing
def canonicalize_alias(alias):
return str(alias).lower()
def masked_evidence(evidence):
"""日志安全的证据摘要(不含任何值,只有参数名/长度/来源)。"""
ev = evidence or {}
return "sources=%s names=%s body_len=%s body_readable=%s fw_cache=%s" % (
",".join(ev.get("sources") or []) or "-",
",".join(ev.get("names") or []) or "-",
ev.get("body_len", 0),
ev.get("body_readable", True),
ev.get("framework_cache_hit", False),
)
# --- 兼容旧调用(离线自测里按同口径构造过判定)-----------------------------
def judge_missing(path, params, spec_table):
"""旧接口:只做名字判定,返回缺失 canonical 列表。"""
specs = _required_names(path, spec_table) or []
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
out = []
for canonical, aliases in specs:
if not any(a in lowered and _is_meaningful(lowered.get(a)) for a in aliases):
out.append(canonical)
return out