"""Global authentication middleware — fail-closed safety net. Rejects unauthenticated requests to non-public paths with 401. When a request passes the cookie check, resolves the JWT payload to a real ``User`` object and stamps it into both ``request.state.user`` and the ``deerflow.runtime.user_context`` contextvar so that repository-layer owner filtering works automatically via the sentinel pattern. Fine-grained permission checks remain in authz.py decorators. """ import re from collections.abc import Callable from fastapi import HTTPException, Request, Response from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.types import ASGIApp from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.authz import _ALL_PERMISSIONS, AuthContext from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from app.gateway.proxy_path import app_relative_path from deerflow.runtime.user_context import reset_current_user, set_current_user # Paths that never require authentication. # # ``/api/public/`` is a namespace reserved for intentionally world-readable, # read-only endpoints (e.g. public conversation share snapshots). Only mount # safe read-only routes under it — everything there bypasses auth entirely. _PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( "/health", "/api/health", "/docs", "/redoc", "/openapi.json", "/api/public/", # 开放问答接口:无需登录即可指定模型 + 智能体做问答(见 routers/open_chat.py)。 # 以 "default" 用户桶运行,只暴露受控的问答能力。 "/api/open/", ) # Exact auth paths that are public (login/register/status check). # /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public. _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( { "/api/v1/auth/login/local", "/api/v1/auth/login/username", "/api/v1/auth/login/token", "/api/v1/auth/register", "/api/v1/auth/logout", "/api/v1/auth/setup-status", "/api/v1/auth/initialize", # 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。 "/api/parallel-agents/auth/token", } ) def _is_public(path: str) -> bool: stripped = path.rstrip("/") if stripped in _PUBLIC_EXACT_PATHS: return True return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) # 「发起问答」的 run 创建接口(会真正调用大模型): # POST /api/threads/{id}/runs[/stream|/wait] # POST /api/runs/{stream|wait} # (含 nginx 未改写到的 /api/langgraph/... 前缀,双保险) # 只匹配这些「提交问答」的 POST,不碰 GET 列表 / join / cancel / feedback 等, # 这样登录白名单开启后,未放行用户(凭旧 token)无法再用接口问答,但普通浏览不受影响。 _QA_RUN_RE = re.compile(r"^/api/(?:langgraph/)?(?:threads/[^/]+/)?runs(?:/stream|/wait)?$") def _is_qa_run_request(method: str, path: str) -> bool: """True for an authenticated「发起问答」run-creation request (see ``_QA_RUN_RE``).""" return method.upper() == "POST" and bool(_QA_RUN_RE.match(path.rstrip("/"))) class AuthMiddleware(BaseHTTPMiddleware): """Strict auth gate: reject requests without a valid session. Two-stage check for non-public paths: 1. Cookie presence — return 401 NOT_AUTHENTICATED if missing 2. JWT validation via ``get_optional_user_from_request`` — return 401 TOKEN_INVALID if the token is absent, malformed, expired, or the signed user does not exist / is stale On success, stamps ``request.state.user`` and the ``deerflow.runtime.user_context`` contextvar so that repository-layer owner filters work downstream without every route needing a ``@require_auth`` decorator. Routes that need per-resource authorization (e.g. "user A cannot read user B's thread by guessing the URL") should additionally use ``@require_permission(..., owner_check=True)`` for explicit enforcement — but authentication itself is fully handled here. """ def __init__(self, app: ASGIApp) -> None: super().__init__(app) async def dispatch(self, request: Request, call_next: Callable) -> Response: # Match the application-relative path so a reverse-proxy prefix # (e.g. /xxx/api/public/...) does not defeat the public whitelist. if _is_public(app_relative_path(request)): return await call_next(request) if is_auth_disabled(): user = await get_assumed_user_for_disabled_auth() request.state.user = user request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) token = set_current_user(user) try: return await call_next(request) finally: reset_current_user(token) internal_user = None if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): internal_user = get_internal_user() # Non-public path: require session cookie from app.gateway.deps import get_request_access_token if internal_user is None and not get_request_access_token(request): return JSONResponse( status_code=401, content={ "detail": AuthErrorResponse( code=AuthErrorCode.NOT_AUTHENTICATED, message="Authentication required", ).model_dump() }, ) # Strict JWT validation: reject junk/expired tokens with 401 # right here instead of silently passing through. This closes # the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8): # without this, non-isolation routes like /api/models would # accept any cookie-shaped string as authentication. # # We call the *strict* resolver so that fine-grained error # codes (token_expired, token_invalid, user_not_found, …) # propagate from AuthErrorCode, not get flattened into one # generic code. BaseHTTPMiddleware doesn't let HTTPException # bubble up, so we catch and render it as JSONResponse here. from app.gateway.deps import get_current_user_from_request if internal_user is not None: user = internal_user else: try: user = await get_current_user_from_request(request) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) # Stamp both request.state.user (for the contextvar pattern) # and request.state.auth (so @require_permission's "auth is # None" branch short-circuits instead of running the entire # JWT-decode + DB-lookup pipeline a second time per request). request.state.user = user request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) # 登录白名单:开关开启时,未放行用户(非 admin)不仅被拦在登录外,连「接口 # 问答」也封掉——已拿到 token 的未放行用户用旧会话直接调问答接口同样被拒。 # 仅作用于「发起问答」的 run 接口;内部/渠道调用(internal_user)不受限。 if internal_user is None and _is_qa_run_request(request.method, app_relative_path(request)): from app.gateway.routers.auth import enforce_user_approved try: enforce_user_approved(user) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) token = set_current_user(user) try: return await call_next(request) finally: reset_current_user(token)