""" Dépendances FastAPI — authentification par API Key + vérification de scopes. Usage dans les routers : client = Depends(get_current_client) _ = Depends(require_scope("images:read")) """ import hashlib import logging from typing import Callable from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.client import APIClient logger = logging.getLogger(__name__) def hash_api_key(api_key: str) -> str: """Hash SHA-256 d'une clé API — fonction utilitaire réutilisable.""" return hashlib.sha256(api_key.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. 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"}, ) # ── 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("Tentative d'authentification avec une clé invalide") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise", headers={"WWW-Authenticate": "Bearer"}, ) if not client.is_active: logger.warning("Tentative d'authentification avec un client inactif: %s", client.id) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise", 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" 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( "Client %s (%s) a tenté d'accéder au scope '%s' sans autorisation", client.id, client.name, scope, ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission insuffisante", ) return client return _check_scope