Root cause: main.py include_router had no global auth dependency; user routers (products/cost/predict/...) relied on assert_entity_scope, which treats a missing session token as the Bot service channel and lets the request through. Result: 46 user endpoints returned data without any token on a public site. Changes: - auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to require_auth otherwise); 401/403 semantics are explicit. - router-level Depends(require_auth_401) on products, cost, predict, notifications, permissions, tenant, alignment; endpoint-level on /auth/roles and /admin/cache/clear. - deps.py: document that get_entity_id/assert_entity_scope are NOT auth. - frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2, Dashboard x1). - tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401, token 200, cross-entity 403, bot channel unchanged); adapt test_predict / test_kpi_forecast to send session tokens like production. - scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""CMA API 鉴权面审计器(cma-api-auth-surface-audit-20260914)
|
||
|
||
用途
|
||
----
|
||
枚举 FastAPI app 的**全部** APIRoute,逐个判定其鉴权姿态并分类:
|
||
|
||
A 类 — 合法 Bot 通道:以 X-BOT-KEY / bridge token 鉴权(verify_bot_key / verify_bridge_token)
|
||
B 类 — 用户端点缺鉴权(缺口):既无 X-BOT-KEY 也无 require_auth 依赖
|
||
C 类 — 显式公开:在 PUBLIC_PATHS 白名单内(登录/注册/验证码/官网留资/健康探针等)
|
||
|
||
判定依据是**依赖图**(route.dependant 递归),不是源码文本 grep——router 级
|
||
dependencies=[Depends(require_auth_401)] 与端点级 Depends(require_auth) 都能识别。
|
||
|
||
用法(在 backend 目录下)
|
||
-------------------------
|
||
./venv/bin/python3 scripts/audit_api_auth_surface.py # 人类可读表格
|
||
./venv/bin/python3 scripts/audit_api_auth_surface.py --json # 机器可读
|
||
|
||
退出码:0 = 无 B 类缺口;1 = 存在 B 类缺口(可直接当 CI 闸门用)。
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
|
||
# 允许直接以脚本方式运行(把 backend 根加入 sys.path)
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
_BACKEND = os.path.dirname(_HERE)
|
||
if _BACKEND not in sys.path:
|
||
sys.path.insert(0, _BACKEND)
|
||
|
||
# 测试/离线环境变量兜底(不连接真实 MySQL:仅枚举路由,不触发 get_db)
|
||
os.environ.setdefault("CMA_DB_USER", "audit")
|
||
os.environ.setdefault("CMA_DB_PASS", "audit")
|
||
os.environ.setdefault("CMA_DB_HOST", "localhost")
|
||
os.environ.setdefault("CMA_DB_PORT", "3306")
|
||
os.environ.setdefault("CMA_DB_NAME", "audit")
|
||
|
||
AUTH_STRICT = {"require_auth", "require_auth_401", "role_checker"}
|
||
BOT_AUTH = {"verify_bot_key", "verify_bridge_token"}
|
||
|
||
# C 类:显式登记的公开端点((METHOD, path))
|
||
PUBLIC_PATHS = {
|
||
("GET", "/health"),
|
||
("POST", "/api/cma/auth/login"),
|
||
("POST", "/api/cma/auth/register"),
|
||
("GET", "/api/cma/auth/login-entities"),
|
||
("GET", "/api/cma/bot/ping"),
|
||
("GET", "/api/cma/security/captcha/request"),
|
||
("GET", "/api/cma/security/captcha/request2"),
|
||
("POST", "/api/cma/security/captcha/verify"),
|
||
("POST", "/api/cma/lead"),
|
||
}
|
||
|
||
|
||
def dep_names(route) -> set:
|
||
"""递归收集 route 依赖图中所有可调用对象的名字"""
|
||
acc = set()
|
||
|
||
def walk(dep, depth=0):
|
||
if depth > 12:
|
||
return
|
||
fn = getattr(dep.call, "__name__", None)
|
||
acc.add(fn if fn else dep.call.__class__.__name__)
|
||
for sub in dep.dependencies:
|
||
walk(sub, depth + 1)
|
||
|
||
for dep in route.dependant.dependencies:
|
||
walk(dep)
|
||
return acc
|
||
|
||
|
||
def collect_routes(app) -> list:
|
||
"""返回全部 APIRoute 的鉴权姿态行"""
|
||
from fastapi.routing import APIRoute
|
||
|
||
rows = []
|
||
for r in app.routes:
|
||
if not isinstance(r, APIRoute):
|
||
continue
|
||
methods = sorted(m for m in r.methods if m not in ("HEAD", "OPTIONS"))
|
||
deps = dep_names(r)
|
||
auth = sorted(deps & AUTH_STRICT)
|
||
bot = sorted(deps & BOT_AUTH)
|
||
public = bool(methods) and all((m, r.path) in PUBLIC_PATHS for m in methods)
|
||
if auth:
|
||
cls = "AUTHED"
|
||
elif bot:
|
||
cls = "A" # Bot 通道
|
||
elif public:
|
||
cls = "C" # 显式公开
|
||
else:
|
||
cls = "B" # 缺口
|
||
rows.append({
|
||
"path": r.path,
|
||
"methods": ",".join(methods),
|
||
"handler": r.endpoint.__name__,
|
||
"module": r.endpoint.__module__,
|
||
"auth": auth,
|
||
"bot_auth": bot,
|
||
"class": cls,
|
||
})
|
||
rows.sort(key=lambda x: (x["class"], x["path"], x["methods"]))
|
||
return rows
|
||
|
||
|
||
def build_app():
|
||
"""构建 app 实例(不连接真实 DB:仅导入模块)"""
|
||
# 先让 database 模块指向内存 SQLite,避免任何意外真实连接
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
from app import database as db_module
|
||
|
||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool)
|
||
db_module.engine = engine
|
||
db_module.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||
|
||
from app.main import app
|
||
return app
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
||
args = ap.parse_args()
|
||
|
||
rows = collect_routes(build_app())
|
||
b = [r for r in rows if r["class"] == "B"]
|
||
a = [r for r in rows if r["class"] == "A"]
|
||
c = [r for r in rows if r["class"] == "C"]
|
||
authed = [r for r in rows if r["class"] == "AUTHED"]
|
||
|
||
if args.json:
|
||
print(json.dumps({"total": len(rows), "authed": len(authed), "A": len(a),
|
||
"B": len(b), "C": len(c), "rows": rows}, ensure_ascii=False, indent=1))
|
||
else:
|
||
print(f"CMA API 鉴权面审计 — 端点总数 {len(rows)}")
|
||
print(f" AUTHED(require_auth/role) = {len(authed)}")
|
||
print(f" A 类(合法 Bot 通道 X-BOT-KEY) = {len(a)}")
|
||
print(f" C 类(显式公开) = {len(c)}")
|
||
print(f" B 类(缺鉴权缺口) = {len(b)}")
|
||
if b:
|
||
print("\n=== B 类缺口(需补鉴权)===")
|
||
for r in b:
|
||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||
if c:
|
||
print("\n=== C 类(显式公开)===")
|
||
for r in c:
|
||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||
return 1 if b else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|