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:
curo1305
2026-05-22 19:35:38 +02:00
parent 1d425d4392
commit 1882edfff6
8 changed files with 565 additions and 38 deletions
+97 -6
View File
@@ -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)