Upgrade configuration and Docker stack to v2.0.0

This commit is contained in:
2026-06-22 14:49:56 -04:00
parent 2f2a5e9b4a
commit 0d2ecbd218
19 changed files with 779 additions and 495 deletions
+45 -20
View File
@@ -1,27 +1,42 @@
"""
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 SHA-256 d'une clé API — fonction utilitaire réutilisable."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
"""
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(
@@ -41,16 +56,17 @@ async def verify_api_key(
"""
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 <key> ────────
# ── 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()
@@ -63,15 +79,21 @@ async def verify_api_key(
)
# ── 1.5. Vérification Master Key (ADMIN_API_KEY) ───────
from app.config import settings
if settings.ADMIN_API_KEY and raw_key == settings.ADMIN_API_KEY:
# Retourne un client virtuel avec tous les droits
return APIClient(
id="admin-master",
name="Imago Master Admin",
scopes=["admin", "images:read", "images:write", "ai:use"],
plan="premium"
)
# 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)
@@ -81,18 +103,18 @@ async def verify_api_key(
client = result.scalar_one_or_none()
if client is None:
logger.warning("Tentative d'authentification avec une clé invalide")
logger.warning("auth.invalid_key")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
detail="Clé API invalide",
headers={"WWW-Authenticate": "Bearer"},
)
if not client.is_active:
logger.warning("Tentative d'authentification avec un client inactif: %s", client.id)
logger.warning("auth.inactive_client", extra={"client_id": client.id})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
detail="Client désactivé",
headers={"WWW-Authenticate": "Bearer"},
)
@@ -100,6 +122,9 @@ async def verify_api_key(
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
@@ -120,12 +145,12 @@ def require_scope(scope: str) -> Callable:
) -> 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,
"auth.scope_denied",
extra={"client_id": client.id, "scope": scope},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission insuffisante",
detail=f"Permission insuffisante : scope '{scope}' requis",
)
return client