""" Dépendances FastAPI — authentification par API Key + vérification de scopes. Sécurité : - Hashage SHA-256 avec pepper (SECRET_KEY) pour le stockage - Comparaison timing-safe via secrets.compare_digest() - Injection du plan client dans le ContextVar pour le rate limiting dynamique - Les clés API sont des tokens 256-bit aléatoires (secrets.token_urlsafe(32)) Usage dans les routers : client = Depends(get_current_client) _ = Depends(require_scope("images:read")) """ import hashlib import logging import secrets as sec from typing import Callable from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.models.client import APIClient from app.middleware import _current_client_plan logger = logging.getLogger(__name__) def hash_api_key(api_key: str) -> str: """ Hash une clé API avec SHA-256 + pepper (SECRET_KEY). Le pepper empêche les attaques par rainbow table même si la BDD fuit. Les clés API sont des tokens aléatoires 256-bit → SHA-256 est suffisant. """ peppered = f"{api_key}:{settings.SECRET_KEY}" return hashlib.sha256(peppered.encode("utf-8")).hexdigest() async def verify_api_key( request: Request, authorization: str | None = Header( None, alias="Authorization", description="Clé API au format 'Bearer '", ), x_api_key: str | None = Header( None, alias="X-API-Key", description="Clé API alternative", ), db: AsyncSession = Depends(get_db), ) -> APIClient: """ Vérifie la clé API fournie dans le header Authorization ou X-API-Key. Injecte client_id et client_plan dans request.state pour le rate limiter. Injecte le plan dans le ContextVar pour le rate limiting dynamique. Raises: HTTPException 401: clé absente, invalide ou client inactif. """ raw_key = None # ── 1. Tentative avec Authorization: Bearer *** ──────── if authorization and authorization.startswith("Bearer "): raw_key = authorization[7:].strip() # ── 2. Tentative avec X-API-Key ────────────────────────── if not raw_key and x_api_key: raw_key = x_api_key.strip() if not raw_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise (Header Authorization ou X-API-Key manquant)", headers={"WWW-Authenticate": "Bearer"}, ) # ── 1.5. Vérification Master Key (ADMIN_API_KEY) ─────── # Comparaison timing-safe de la master key if settings.ADMIN_API_KEY: master_hash = hash_api_key(settings.ADMIN_API_KEY) candidate_hash = hash_api_key(raw_key) if sec.compare_digest(candidate_hash, master_hash): client = APIClient( id="admin-master", name="Imago Master Admin", scopes=["admin", "images:read", "images:write", "ai:use"], plan="premium", ) request.state.client_id = client.id request.state.client_plan = "premium" _current_client_plan.set("premium") return client # ── Lookup par hash ─────────────────────────────────────── key_hash = hash_api_key(raw_key) result = await db.execute( select(APIClient).where(APIClient.api_key_hash == key_hash) ) client = result.scalar_one_or_none() if client is None: logger.warning("auth.invalid_key") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Clé API invalide", headers={"WWW-Authenticate": "Bearer"}, ) if not client.is_active: logger.warning("auth.inactive_client", extra={"client_id": client.id}) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Client désactivé", headers={"WWW-Authenticate": "Bearer"}, ) # Injecter dans request.state pour le rate limiter request.state.client_id = client.id request.state.client_plan = client.plan.value if client.plan else "free" # Injecter dans le ContextVar pour le rate limiting dynamique _current_client_plan.set(client.plan.value if client.plan else "free") return client # Alias pratique pour injection dans les routers get_current_client = verify_api_key def require_scope(scope: str) -> Callable: """ Factory qui retourne une dépendance FastAPI vérifiant qu'un scope est accordé. Usage: @router.get("/...", dependencies=[Depends(require_scope("images:read"))]) """ async def _check_scope( client: APIClient = Depends(get_current_client), ) -> APIClient: if not client.has_scope(scope): logger.warning( "auth.scope_denied", extra={"client_id": client.id, "scope": scope}, ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Permission insuffisante : scope '{scope}' requis", ) return client return _check_scope