158 lines
5.3 KiB
Python
158 lines
5.3 KiB
Python
"""
|
|
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 <key>'",
|
|
),
|
|
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
|