2d982c3f0b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
8.4 KiB
Python
197 lines
8.4 KiB
Python
"""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 logging
|
|
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.api_key_backend import build_api_key_backend
|
|
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
|
from app.gateway.auth.models import ActiveWorkspace
|
|
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 deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Paths that never require authentication.
|
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
|
"/health",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
)
|
|
|
|
# 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/register",
|
|
"/api/v1/auth/logout",
|
|
"/api/v1/auth/setup-status",
|
|
"/api/v1/auth/initialize",
|
|
}
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
# Data-plane / SDK route prefixes a service principal (API key) may reach.
|
|
# Everything else (global control plane: models/mcp/memory/skills/channels/
|
|
# agents, plus management/auth endpoints) is denied by default for API keys.
|
|
# NOTE: nginx rewrites /api/langgraph/(.*) -> /api/$1 before the gateway, so
|
|
# AuthMiddleware never sees /api/langgraph; the SDK surface arrives as
|
|
# /api/threads, /api/runs, /api/assistants. assistants.search()/get() is
|
|
# required for langgraph-sdk client init, so /api/assistants is allowed.
|
|
_DATAPLANE_PREFIXES: tuple[str, ...] = (
|
|
"/api/threads",
|
|
"/api/v1/threads",
|
|
"/api/runs",
|
|
"/api/v1/runs",
|
|
"/api/assistants",
|
|
)
|
|
|
|
|
|
def _is_dataplane_path(path: str) -> bool:
|
|
"""True if an API key request may reach this path. Reusable by a future
|
|
Pattern B service-token branch."""
|
|
return any(path.startswith(prefix) for prefix in _DATAPLANE_PREFIXES)
|
|
|
|
|
|
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:
|
|
if _is_public(request.url.path):
|
|
return await call_next(request)
|
|
|
|
# API key path: "Authorization: Bearer dfk_..." authenticates a
|
|
# service account. Resolved principal is mapped to the same
|
|
# (user_id, workspace_id) contextvars a human would set (spec D1),
|
|
# so all downstream isolation works unchanged.
|
|
auth_header = request.headers.get("authorization", "")
|
|
if auth_header.startswith("Bearer dfk_"):
|
|
token = auth_header[len("Bearer ") :]
|
|
backend = build_api_key_backend()
|
|
try:
|
|
result = await backend.authenticate(token) if backend is not None else None
|
|
except Exception:
|
|
logger.exception("API key authentication failed unexpectedly")
|
|
return JSONResponse(status_code=503, content={"detail": "Authentication service unavailable"})
|
|
if result is None:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()},
|
|
)
|
|
request.state.user = result.principal
|
|
request.state.auth = AuthContext(user=result.principal, permissions=result.permissions)
|
|
user_token = set_current_user(result.principal)
|
|
ws_token = set_current_workspace(ActiveWorkspace(id=result.workspace_id, role=result.role))
|
|
try:
|
|
return await call_next(request)
|
|
finally:
|
|
reset_current_workspace(ws_token)
|
|
reset_current_user(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
|
|
if internal_user is None and not request.cookies.get("access_token"):
|
|
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)
|
|
user_token = set_current_user(user)
|
|
|
|
# Inject workspace contextvar from the JWT's wid/role claims.
|
|
# decode_token has already rejected legacy no-wid tokens upstream,
|
|
# so by the time we get here payload.wid is guaranteed non-None
|
|
# for cookie-authenticated requests. Internal-auth requests skip
|
|
# the workspace contextvar (they don't have a workspace scope —
|
|
# the internal user is a system actor).
|
|
ws_token = None
|
|
payload = getattr(request.state, "auth_payload", None)
|
|
if payload is not None and payload.wid is not None:
|
|
ws_token = set_current_workspace(ActiveWorkspace(id=payload.wid, role=payload.role or "owner"))
|
|
|
|
try:
|
|
return await call_next(request)
|
|
finally:
|
|
if ws_token is not None:
|
|
reset_current_workspace(ws_token)
|
|
reset_current_user(user_token)
|