feat(app): add plugin system with auth plugin and static assets
Add new application structure: - app/main.py - application entry point - app/plugins/ - plugin system with auth plugin: - api/ - REST API endpoints and schemas - authorization/ - auth policies, providers, hooks - domain/ - business logic (service, models, jwt, password) - injection/ - route injection and guards - ops/ - operational utilities - runtime/ - runtime configuration - security/ - middleware, CSRF, dependencies - storage/ - user repositories and models - app/static/ - static assets (scalar.js for API docs) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""Security layer for the auth plugin."""
|
||||
|
||||
from app.plugins.auth.security.actor_context import (
|
||||
bind_request_actor_context,
|
||||
bind_user_actor_context,
|
||||
resolve_request_user_id,
|
||||
)
|
||||
from app.plugins.auth.security.csrf import (
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
CSRFMiddleware,
|
||||
get_csrf_token,
|
||||
is_secure_request,
|
||||
)
|
||||
from app.plugins.auth.security.dependencies import (
|
||||
CurrentAuthService,
|
||||
CurrentUserRepository,
|
||||
get_auth_service,
|
||||
get_current_user_from_request,
|
||||
get_current_user_id,
|
||||
get_optional_user_from_request,
|
||||
get_user_repository,
|
||||
)
|
||||
from app.plugins.auth.security.langgraph import add_owner_filter, auth, authenticate
|
||||
from app.plugins.auth.security.middleware import AuthMiddleware
|
||||
|
||||
__all__ = [
|
||||
"CSRF_COOKIE_NAME",
|
||||
"CSRF_HEADER_NAME",
|
||||
"CSRFMiddleware",
|
||||
"AuthMiddleware",
|
||||
"CurrentAuthService",
|
||||
"CurrentUserRepository",
|
||||
"add_owner_filter",
|
||||
"auth",
|
||||
"authenticate",
|
||||
"bind_request_actor_context",
|
||||
"bind_user_actor_context",
|
||||
"get_auth_service",
|
||||
"get_csrf_token",
|
||||
"get_current_user_from_request",
|
||||
"get_current_user_id",
|
||||
"get_optional_user_from_request",
|
||||
"get_user_repository",
|
||||
"is_secure_request",
|
||||
"resolve_request_user_id",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Auth-plugin bridge from request user to runtime actor context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from deerflow.runtime.actor_context import ActorContext, bind_actor_context, reset_actor_context
|
||||
|
||||
|
||||
def resolve_request_user_id(request: Request) -> str | None:
|
||||
scope = getattr(request, "scope", None)
|
||||
user = scope.get("user") if isinstance(scope, dict) else None
|
||||
if user is None:
|
||||
state = getattr(request, "state", None)
|
||||
state_vars = vars(state) if state is not None and hasattr(state, "__dict__") else {}
|
||||
user = state_vars.get("user")
|
||||
user_id = getattr(user, "id", None)
|
||||
if user_id is None:
|
||||
return None
|
||||
return str(user_id)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_request_actor_context(request: Request):
|
||||
token = bind_actor_context(ActorContext(user_id=resolve_request_user_id(request)))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_actor_context(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_user_actor_context(user_id: str | None):
|
||||
token = bind_actor_context(ActorContext(user_id=str(user_id) if user_id is not None else None))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_actor_context(token)
|
||||
|
||||
|
||||
__all__ = ["bind_request_actor_context", "bind_user_actor_context", "resolve_request_user_id"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CSRF protection middleware and helpers for cookie-based auth flows."""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
"""Detect whether the original client request was made over HTTPS."""
|
||||
return request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
"""Generate a secure random CSRF token."""
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
"""Determine if a request needs CSRF validation."""
|
||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
return False
|
||||
|
||||
path = request.url.path.rstrip("/")
|
||||
if path == "/api/v1/auth/me":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/v1/auth/login/local",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/register",
|
||||
"/api/v1/auth/initialize",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_auth_endpoint(request: Request) -> bool:
|
||||
"""Check if the request is to an auth endpoint."""
|
||||
return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Implement CSRF protection using the double-submit cookie pattern."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
_is_auth = is_auth_endpoint(request)
|
||||
|
||||
if should_check_csrf(request) and not _is_auth:
|
||||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||||
header_token = request.headers.get(CSRF_HEADER_NAME)
|
||||
|
||||
if not cookie_token or not header_token:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "CSRF token missing. Include X-CSRF-Token header."},
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(cookie_token, header_token):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "CSRF token mismatch."},
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
if _is_auth and request.method == "POST":
|
||||
csrf_token = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE_NAME,
|
||||
value=csrf_token,
|
||||
httponly=False,
|
||||
secure=is_secure_request(request),
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_csrf_token(request: Request) -> str | None:
|
||||
"""Get the CSRF token from the current request's cookies."""
|
||||
return request.cookies.get(CSRF_COOKIE_NAME)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CSRF_COOKIE_NAME",
|
||||
"CSRF_HEADER_NAME",
|
||||
"CSRFMiddleware",
|
||||
"generate_csrf_token",
|
||||
"get_csrf_token",
|
||||
"is_auth_endpoint",
|
||||
"is_secure_request",
|
||||
"should_check_csrf",
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Security dependency helpers for the auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.plugins.auth.domain.errors import (
|
||||
AuthErrorCode,
|
||||
AuthErrorResponse,
|
||||
TokenError,
|
||||
token_error_to_code,
|
||||
)
|
||||
from app.plugins.auth.domain.jwt import decode_token
|
||||
from app.plugins.auth.domain.service import AuthService
|
||||
from app.plugins.auth.storage import DbUserRepository, UserRepositoryProtocol
|
||||
|
||||
|
||||
def _get_session_factory(request: Request) -> async_sessionmaker[AsyncSession] | None:
|
||||
persistence = getattr(request.app.state, "persistence", None)
|
||||
if persistence is None:
|
||||
return None
|
||||
return getattr(persistence, "session_factory", None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _auth_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||
injected = getattr(request.state, "_auth_session", None)
|
||||
if injected is not None:
|
||||
yield injected
|
||||
return
|
||||
|
||||
session_factory = _get_session_factory(request)
|
||||
if session_factory is None:
|
||||
raise HTTPException(status_code=503, detail="Auth session not available")
|
||||
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_user_repository(request: Request) -> UserRepositoryProtocol:
|
||||
async with _auth_session(request) as session:
|
||||
return DbUserRepository(session)
|
||||
|
||||
|
||||
def get_auth_service(request: Request) -> AuthService:
|
||||
session_factory = _get_session_factory(request)
|
||||
if session_factory is None:
|
||||
raise HTTPException(status_code=503, detail="Auth session factory not available")
|
||||
return AuthService(session_factory)
|
||||
|
||||
|
||||
async def get_current_user_from_request(request: Request):
|
||||
access_token = request.cookies.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(),
|
||||
)
|
||||
|
||||
payload = decode_token(access_token)
|
||||
if isinstance(payload, TokenError):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=AuthErrorResponse(
|
||||
code=token_error_to_code(payload),
|
||||
message=f"Token error: {payload.value}",
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
async with _auth_session(request) as session:
|
||||
user_repo = DbUserRepository(session)
|
||||
user = await user_repo.get_user_by_id(payload.sub)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(),
|
||||
)
|
||||
|
||||
if user.token_version != payload.ver:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=AuthErrorResponse(
|
||||
code=AuthErrorCode.TOKEN_INVALID,
|
||||
message="Token revoked (password changed)",
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user_from_request(request: Request):
|
||||
try:
|
||||
return await get_current_user_from_request(request)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user_id(request: Request) -> str | None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
return user.id if user else None
|
||||
|
||||
|
||||
CurrentUserRepository = Annotated[UserRepositoryProtocol, Depends(get_user_repository)]
|
||||
CurrentAuthService = Annotated[AuthService, Depends(get_auth_service)]
|
||||
|
||||
__all__ = [
|
||||
"CurrentAuthService",
|
||||
"CurrentUserRepository",
|
||||
"get_auth_service",
|
||||
"get_current_user_from_request",
|
||||
"get_current_user_id",
|
||||
"get_optional_user_from_request",
|
||||
"get_user_repository",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""LangGraph auth adapter for the auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
from app.plugins.auth.security.dependencies import get_current_user_from_request
|
||||
|
||||
auth = Auth()
|
||||
|
||||
_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
||||
|
||||
|
||||
def _check_csrf(request) -> None:
|
||||
method = getattr(request, "method", "") or ""
|
||||
if method.upper() not in _CSRF_METHODS:
|
||||
return
|
||||
|
||||
cookie_token = request.cookies.get("csrf_token")
|
||||
header_token = request.headers.get("x-csrf-token")
|
||||
|
||||
if not cookie_token or not header_token:
|
||||
raise Auth.exceptions.HTTPException(
|
||||
status_code=403,
|
||||
detail="CSRF token missing. Include X-CSRF-Token header.",
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(cookie_token, header_token):
|
||||
raise Auth.exceptions.HTTPException(status_code=403, detail="CSRF token mismatch.")
|
||||
|
||||
|
||||
@auth.authenticate
|
||||
async def authenticate(request):
|
||||
_check_csrf(request)
|
||||
resolver_request = SimpleNamespace(
|
||||
cookies=getattr(request, "cookies", {}),
|
||||
state=SimpleNamespace(_auth_session=getattr(request, "_auth_session", None)),
|
||||
app=SimpleNamespace(state=SimpleNamespace(persistence=getattr(request, "_persistence", None))),
|
||||
)
|
||||
|
||||
try:
|
||||
user = await get_current_user_from_request(resolver_request)
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if status_code is None:
|
||||
raise
|
||||
detail = getattr(exc, "detail", "Not authenticated")
|
||||
message = detail.get("message") if isinstance(detail, dict) else str(detail)
|
||||
raise Auth.exceptions.HTTPException(status_code=status_code, detail=message) from exc
|
||||
|
||||
return user.id
|
||||
|
||||
|
||||
@auth.on
|
||||
async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["user_id"] = ctx.user.identity
|
||||
return {"user_id": ctx.user.identity}
|
||||
|
||||
|
||||
__all__ = ["add_owner_filter", "auth", "authenticate"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Global authentication middleware for the auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.plugins.auth.authorization import _ALL_PERMISSIONS, AuthContext
|
||||
from app.plugins.auth.domain.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.plugins.auth.injection.registry_loader import RoutePolicyRegistry
|
||||
from app.plugins.auth.security.dependencies import get_current_user_from_request
|
||||
from deerflow.runtime.actor_context import ActorContext, bind_actor_context, reset_actor_context
|
||||
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = ("/health", "/docs", "/redoc", "/openapi.json")
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
registry = getattr(request.app.state, "auth_route_policy_registry", None)
|
||||
is_public = False
|
||||
if isinstance(registry, RoutePolicyRegistry):
|
||||
is_public = registry.is_public_request(request.method, request.url.path)
|
||||
if is_public or _is_public(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
if not request.cookies.get("access_token"):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"detail": AuthErrorResponse(
|
||||
code=AuthErrorCode.NOT_AUTHENTICATED,
|
||||
message="Authentication required",
|
||||
).model_dump()
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
user = await get_current_user_from_request(request)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
auth_context = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
request.scope["user"] = user
|
||||
request.scope["auth"] = auth_context
|
||||
request.state.user = user
|
||||
request.state.auth = auth_context
|
||||
token = bind_actor_context(ActorContext(user_id=str(user.id)))
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
reset_actor_context(token)
|
||||
|
||||
|
||||
__all__ = ["AuthMiddleware", "_is_public"]
|
||||
Reference in New Issue
Block a user