Upgrade configuration and Docker stack to v2.0.0
This commit is contained in:
+45
-20
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user