fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
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.
This commit is contained in:
@@ -9,6 +9,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, RolePermission
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import json
|
||||
|
||||
@@ -199,6 +200,23 @@ def require_role(*roles: str):
|
||||
return role_checker
|
||||
|
||||
|
||||
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)──────────────
|
||||
# FastAPI 的 HTTPBearer(auto_error=True) 对"缺 Authorization 头"返回 403;
|
||||
# 安全语义上「未认证」应为 401(401=未认证 / 403=无权)。
|
||||
# 本依赖用 auto_error=False 自行判空 → 缺 token 显式 401,有 token 仍完全委托 require_auth。
|
||||
_optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def require_auth_401(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
|
||||
return require_auth(credentials=credentials, db=db)
|
||||
|
||||
|
||||
def has_permission(user: User, module: str, db: Session = None) -> bool:
|
||||
routes, _ = _load_permissions(db)
|
||||
return module in routes.get(user.role, [])
|
||||
|
||||
Reference in New Issue
Block a user