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:
rayhpeng
2026-04-22 11:31:42 +08:00
parent a0ab3a3dd4
commit 0f82f8a3a2
47 changed files with 5516 additions and 0 deletions
@@ -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"]
+106
View File
@@ -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"]