Files
Imago/app/dependencies/auth.py
T

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