feat(02-02): auth API endpoints + security hardening + Python 3.9 compat
- backend/api/auth.py: register, login (TOTP+backup), refresh, logout, me, change-password; per-account Redis rate limit; HIBP check - backend/main.py: Origin validation middleware, CSP headers middleware, CORS locked to settings.cors_origins, Redis lifespan (app.state.redis), admin bootstrap, auth router included, slowapi SlowAPIMiddleware - backend/services/email.py: already created in Plan 01 (verified exists) - Python 3.9 compat: fixed match statement in ai/__init__.py, str|None union syntax in openai_provider.py, api/documents.py, api/topics.py, api/settings.py, services/classifier.py All 17 tests in test_auth_api.py pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+97
-6
@@ -1,11 +1,19 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
import aioredis
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from minio import Minio
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from sqlalchemy import text
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from api.auth import limiter as auth_limiter
|
||||
from api.documents import router as documents_router
|
||||
from api.settings import router as settings_router
|
||||
from api.topics import router as topics_router
|
||||
@@ -13,12 +21,58 @@ from config import settings
|
||||
from db.session import AsyncSessionLocal, engine
|
||||
|
||||
|
||||
# ── CSP / Security headers middleware ────────────────────────────────────────
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options
|
||||
to every response (SEC-05, T-02-14).
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
|
||||
# ── Origin validation middleware (SEC-01, T-02-11) ────────────────────────────
|
||||
|
||||
class OriginValidationMiddleware(BaseHTTPMiddleware):
|
||||
"""Reject state-changing requests from Origins not in settings.cors_origins.
|
||||
|
||||
For any non-idempotent method (not GET/HEAD/OPTIONS): if the Origin header
|
||||
is present and not in the allowed list, return 403.
|
||||
|
||||
Placed BEFORE CORSMiddleware so it runs first (Starlette applies middleware
|
||||
in reverse insertion order — last added runs first).
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
origin = request.headers.get("Origin")
|
||||
if origin is not None and origin not in settings.cors_origins:
|
||||
return StarletteResponse(content="Forbidden", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""FastAPI lifespan: create MinIO bucket at startup, dispose engine at shutdown.
|
||||
"""FastAPI lifespan: initialize MinIO, Redis, and admin bootstrap at startup.
|
||||
|
||||
D-07: bucket auto-create ensures the docuvault bucket exists on every reboot.
|
||||
MinIO client stored on app.state.minio for use in the /health endpoint.
|
||||
Redis stored on app.state.redis for per-account rate limiting (SEC-02) and
|
||||
TOTP replay prevention (AUTH-08).
|
||||
Admin bootstrap (D-04): idempotent, runs only if no users exist.
|
||||
"""
|
||||
# MinIO bucket initialization (RESEARCH.md Pattern 4)
|
||||
minio_client = Minio(
|
||||
@@ -32,21 +86,52 @@ async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(minio_client.make_bucket, settings.minio_bucket)
|
||||
app.state.minio = minio_client
|
||||
|
||||
# Redis init for per-account rate limiting + TOTP replay prevention
|
||||
app.state.redis = await aioredis.from_url(settings.redis_url)
|
||||
|
||||
# Admin bootstrap (D-04)
|
||||
from services.auth import bootstrap_admin # noqa: PLC0415
|
||||
async with AsyncSessionLocal() as session:
|
||||
await bootstrap_admin(session)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: close all pooled connections
|
||||
# Shutdown: close pooled connections and Redis
|
||||
await app.state.redis.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ── Application factory ───────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="Document Scanner API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
# Rate limiter state (slowapi)
|
||||
app.state.limiter = auth_limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# ── Middleware registration order (Starlette: last added = first to run) ───────
|
||||
# Desired execution order (request path): Origin → CORS → SecurityHeaders → route
|
||||
# Insertion order (last registered = first to run): SecurityHeaders → CORS → Origin
|
||||
# Result: register SecurityHeaders first, then CORS, then Origin last.
|
||||
|
||||
# 1. Security headers (CSP etc.) — runs last in the chain
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# 2. CORS — updated to use settings.cors_origins (D-09); wildcard removed (T-02-15)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Phase 1: locked down in Phase 2 after auth lands
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True, # Required for httpOnly cookie flow
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 3. Origin validation — runs first (added last), before CORS and route handlers
|
||||
app.add_middleware(OriginValidationMiddleware)
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health(request: Request):
|
||||
@@ -78,10 +163,16 @@ async def health(request: Request):
|
||||
except Exception as e:
|
||||
checks["minio"] = f"error: {type(e).__name__}: {e}"
|
||||
|
||||
status = "ok" if all(v == "ok" for v in checks.values()) else "degraded"
|
||||
return {"status": status, "checks": checks}
|
||||
status_val = "ok" if all(v == "ok" for v in checks.values()) else "degraded"
|
||||
return {"status": status_val, "checks": checks}
|
||||
|
||||
|
||||
# ── Include routers ───────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(documents_router)
|
||||
app.include_router(topics_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
# Phase 2: auth and admin routers
|
||||
from api.auth import router as auth_router # noqa: E402
|
||||
app.include_router(auth_router)
|
||||
|
||||
Reference in New Issue
Block a user