fix(auth): unify site-wide 401/403 semantics (cma-auth-401-403-unify-20260915)

require_auth now uses HTTPBearer(auto_error=False) and raises 401 explicitly
when the Authorization header is missing or non-Bearer, matching
require_auth_401. Unauthenticated requests are 401 for every user endpoint;
authenticated-but-forbidden (cross-entity / role) stays 403.

- app/auth_middleware.py: single optional bearer, 401 on missing token
  (require_auth_401 kept as a compatibility alias)
- tests: update 4 pre-existing cases that asserted 403 without a token
  (test_auth, test_reports, test_kpi_causality, test_expenses,
  test_api_auth_surface_20260914) - intentional behavior change
- add tests/test_auth_401_unification_20260915.py: sample + full sweep
  (all AUTHED GET routes -> 401), invalid token -> 401, cross-entity and
  role-denied still 403, bot channel (X-BOT-KEY) and public paths unchanged
This commit is contained in:
Hermes CI Fix
2026-09-15 17:00:52 +08:00
parent f1d61f337a
commit b796897f8c
7 changed files with 245 additions and 21 deletions
+27 -11
View File
@@ -176,10 +176,26 @@ def extract_bearer_token(request) -> str | None:
return None
# ── 统一全站鉴权语义(cma-auth-401-403-unify-20260915)────────────────────
# 口径:**未认证(缺 token / 无效 token)→ 401**;**已认证但无权(跨实体 / 角色不足)→ 403**。
# FastAPI 的 HTTPBearer(auto_error=True) 对「缺 Authorization 头」返回 403,与
# require_auth_401 的 401 语义在同站并存(同一种「没带 token」两种状态码)。
# 现统一为 auto_error=False 自行判空:缺 header / 非 Bearer 格式 → 显式 401;
# 有 token → 仍按原逻辑校验身份(无效/过期 token → 401)。
# 403 语义保留:require_role(角色不足)、deps.assert_entity_scope(跨实体无权)。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db: Session = Depends(get_db),
) -> User:
"""认证依赖(全站统一):缺 token / 非 Bearer 格式 → 401;无效 token → 401。
已认证但无权(角色/实体)由 require_role、deps.assert_entity_scope 另行返回 403。
"""
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
token = credentials.credentials
user_id = _resolve_user_id(token)
if user_id is None:
@@ -200,20 +216,20 @@ 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)
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)
# 2026-09-15(cma-auth-401-403-unify-20260915):语义与 _optional_bearer 已统一,
# 本入口保留作兼容别名(既有 router 与审计器 AUTH_STRICT 均引用此名)。
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")
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)
2026-09-15(cma-auth-401-403-unify-20260915)起与 require_auth 语义完全一致
(require_auth 也改为 auto_error=False 自判空 → 缺 token 401)。
保留本名称:既有 router 的 dependencies=[Depends(require_auth_401)] 与
backend/scripts/audit_api_auth_surface.py 的 AUTH_STRICT 分类仍引用它。
"""
return require_auth(credentials=credentials, db=db)