Files
office_translator/_bmad-output/implementation-artifacts/3-4-authentification-api-via-x-api-key.md
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 15:01:47 +02:00

20 KiB

Story 3.4: Authentification API via X-API-Key

Status: done

Story

En tant que système, Je veux authentifier les requêtes API via le header X-API-Key, de sorte que les clients d'automatisation puissent accéder à l'API sans JWT.

Acceptance Criteria

  1. Header X-API-Key: Les requêtes peuvent inclure X-API-Key: sk_live_... pour l'authentification. (FR32)
  2. Authentification Valide: Si la clé est valide et active, la requête procède avec le contexte utilisateur.
  3. Clé Invalide: Si la clé n'existe pas dans la base, retourne 401 avec erreur INVALID_API_KEY.
  4. Clé Révoquée: Si la clé existe mais is_active=False, retourne 401 avec erreur API_KEY_REVOKED.
  5. Clé Expirée: Si la clé a une date expires_at dans le passé, retourne 401 avec erreur API_KEY_EXPIRED.
  6. Clé Manquante: Si aucune clé n'est fournie et pas de JWT, retourne 401 avec erreur MISSING_API_KEY (selon contexte).
  7. Mise à Jour Usage: À chaque utilisation réussie, last_used_at et usage_count sont mis à jour.
  8. Coexistence JWT: L'authentification par JWT (web users) et X-API-Key (automation) coexistent sur les mêmes endpoints.

Tasks / Subtasks

  • Task 1: Créer un Middleware/Dépendance Réutilisable (AC: #1, #2, #7, #8)

    • 1.1 Créer middleware/api_key_auth.py avec dépendance require_api_key
    • 1.2 Extraire la logique de get_user_from_api_key de translate_routes.py vers le middleware
    • 1.3 Ajouter la mise à jour de last_used_at et usage_count (déjà dans auth_service.py)
    • 1.4 Créer get_authenticated_user_unified qui essaie API key puis JWT
  • Task 2: Standardiser les Réponses d'Erreur (AC: #3, #4, #5, #6)

    • 2.1 Créer des exceptions structurées pour chaque cas d'erreur
    • 2.2 Format uniforme: {error: "CODE", message: "..."}
    • 2.3 S'assurer que tous les endpoints utilisent le même format
  • Task 3: Appliquer aux Endpoints Existants (AC: #8)

    • 3.1 Remplacer get_authenticated_user dans translate_routes.py par la nouvelle dépendance
    • 3.2 Vérifier que tous les endpoints /api/v1/* supportent l'auth API key
    • 3.3 Documenter les endpoints qui nécessitent auth vs optionnels
  • Task 4: Ajouter les Tests (AC: Tous)

    • 4.1 Test authentification réussie avec clé valide
    • 4.2 Test erreur avec clé invalide (401, INVALID_API_KEY)
    • 4.3 Test erreur avec clé révoquée (401, API_KEY_REVOKED)
    • 4.4 Test erreur avec clé expirée (401, API_KEY_EXPIRED)
    • 4.5 Test coexistence JWT et API key (priorité API key si les deux présents)
    • 4.6 Test mise à jour de last_used_at et usage_count
    • 4.7 Test endpoint sans auth (si applicable)

Dev Notes

Infrastructure Existante (Ne pas réimplémenter)

Fonction get_user_by_api_key (services/auth_service.py lignes 290-330):

def get_user_by_api_key(api_key: str) -> Optional[User]:
    """
    Get a user by API key.
    
    Verifies that:
    - The key exists in the database
    - The key is active (is_active=True)
    - The key hasn't expired (expires_at is None or in the future)
    
    Returns the user associated with the API key, or None if invalid/revoked.
    
    Raises:
        ValueError: With code "API_KEY_REVOKED" if key exists but is inactive
    """

Fonction get_user_from_api_key (routes/translate_routes.py lignes 145-175):

async def get_user_from_api_key(
    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
) -> Optional[Any]:
    """
    Get user from X-API-Key header if provided.
    
    Raises:
        HTTPException: 401 with API_KEY_REVOKED if key was revoked
    """

Fonction get_authenticated_user (routes/translate_routes.py lignes 178-185):

async def get_authenticated_user(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
) -> Optional[Any]:
    """Get authenticated user from JWT or API key."""
    user = await get_user_from_api_key(x_api_key)
    if user:
        return user
    return await get_current_user_optional(credentials)

Modèle ApiKey (database/models.py)

class ApiKey(Base):
    __tablename__ = "api_keys"
    
    id = Column(String(36), primary_key=True)
    user_id = Column(String(36), ForeignKey("users.id"))
    name = Column(String(100))
    key_hash = Column(String(255))        # SHA256 du clé
    key_prefix = Column(String(10))       # First 8 chars for identification
    is_active = Column(Boolean, default=True)  # ⭐ Pour révocation
    scopes = Column(JSON, default=list)   # ["translate", "read", "write"]
    last_used_at = Column(DateTime)       # ⭐ Mis à jour à chaque utilisation
    usage_count = Column(Integer, default=0)  # ⭐ Compteur d'utilisation
    created_at = Column(DateTime)
    expires_at = Column(DateTime)         # ⭐ Pour expiration
    revoked_at = Column(DateTime)         # Set when is_active=False

Architecture Actuelle

translate_routes.py
├── get_user_from_api_key()     # Extrait X-API-Key header, appelle auth_service
├── get_authenticated_user()    # Essaie API key, puis JWT
└── translate_document_v1()     # Utilise get_authenticated_user comme dépendance

Architecture Proposée

middleware/
└── api_key_auth.py
    ├── require_api_key()              # Dépendance qui REQUIERT une clé API
    ├── get_user_from_api_key()        # Extrait et valide (optionnel)
    └── get_authenticated_user()       # Unifie API key + JWT (optionnel)

routes/
├── translate_routes.py    # Utilise get_authenticated_user
├── api_key_routes.py      # Utilise JWT (gestion des clés)
└── auth_routes.py         # Utilise JWT (login/register)

Patterns à Suivre

Format de Réponse Erreur (déjà établi):

{
  "error": "INVALID_API_KEY",
  "message": "Clé API invalide ou non reconnue."
}

Codes Erreur API Key:

Code HTTP Condition
INVALID_API_KEY 401 Clé non trouvée dans DB
API_KEY_REVOKED 401 is_active=False
API_KEY_EXPIRED 401 expires_at < now
MISSING_API_KEY 401 Aucune clé fournie (si requis)

Ordre de Priorité Auth:

  1. Si X-API-Key header présent → Utiliser API key auth
  2. Sinon si Authorization: Bearer → Utiliser JWT auth
  3. Sinon → Utilisateur non authentifié (None)

Structure de Fichiers

middleware/
├── __init__.py
├── api_key_auth.py        # CRÉER - Dépendances auth réutilisables
├── error_handler.py       # Existant
├── rate_limit.py          # Existant
├── security.py            # Existant
├── tier_quota.py          # Existant
└── validation.py          # Existant

routes/
├── translate_routes.py    # MODIFIER - Utiliser nouvelle dépendance
├── api_key_routes.py      # Existant (pas de changement)
└── auth_routes.py         # Existant (pas de changement)

tests/
└── test_story_3_4_api_key_authentication.py  # CRÉER

Project Structure Notes

  • Le middleware d'authentification API key doit être réutilisable dans tous les endpoints
  • Les endpoints de gestion de clés (api_key_routes.py) utilisent JWT, pas API key
  • L'authentification API key est pour les endpoints "métier" (translate, etc.)

Références

  • [Source: _bmad-output/planning-artifacts/epics.md#Story 3.4]
  • [Source: _bmad-output/planning-artifacts/architecture.md#Authentication & Security]
  • [Source: services/auth_service.py#get_user_by_api_key (lignes 290-330)]
  • [Source: routes/translate_routes.py#get_user_from_api_key (lignes 145-175)]
  • [Source: database/models.py#ApiKey]

Intelligence de la Story Précédente (3.3)

Ce qui a été implémenté

  1. Endpoint Admin DELETE pour révocation à DELETE /api/v1/admin/api-keys/{key_id}
  2. Soft delete avec is_active=False et revoked_at timestamp
  3. Audit logging avec admin_id, key_id, owner_user_id, reason
  4. Tests complets dans tests/test_story_3_3_admin_api_key_revocation.py

Patterns Établis à Réutiliser

from fastapi.responses import JSONResponse

# Format erreur structuré
return JSONResponse(
    status_code=401,
    content={
        "error": "API_KEY_REVOKED",
        "message": "Cette clé API a été révoquée.",
    },
)

Points d'Attention Identifiés

  1. NE PAS utiliser HTTPException avec detail string - Utiliser JSONResponse structuré
  2. TOUJOURS snake_case dans les réponses JSON
  3. VÉRIFIER que get_user_by_api_key lève ValueError avec le bon code

Intelligence Git (Commits Récents)

Derniers commits pertinents:

  • Story 3.1: Génération de clés API avec secrets.token_urlsafe(32)
  • Story 3.2: Révocation utilisateur avec soft delete
  • Story 3.3: Révocation admin avec audit logging

Patterns identifiés:

  • Tests avec pytest dans tests/
  • Fixtures dans tests/conftest.py
  • Mocking de la DB pour tests unitaires

Contexte Métier

Epic 3: API & Automation (Pro)

Cette story est la quatrième de l'Epic 3 qui permet aux utilisateurs Pro d'automatiser les traductions:

  1. API Keys - Génération (Story 3.1 )
  2. API Keys - Révocation User (Story 3.2 )
  3. API Keys - Révocation Admin (Story 3.3 )
  4. Authentification X-API-Key (cette story)
  5. API Versioning (Story 3.5 - backlog)
  6. Documentation OpenAPI (Story 3.6 - backlog)
  7. Webhooks (Stories 3.7-3.8 - backlog)
  8. Glossaires (Stories 3.9-3.10 - backlog)
  9. Custom Prompts (Stories 3.11-3.12 - backlog)

Valeur Business

L'authentification API est critique pour:

  • Permettre l'automatisation via n8n, Zapier, scripts
  • Intégration dans les pipelines CI/CD
  • Accès programmatique pour les clients Pro
  • Séparation claire entre web users (JWT) et automation (API key)

Dépendances

  • Story 3.1 (prérequis): Génération de clés API
  • Story 3.2 (prérequis): Révocation utilisateur
  • Story 3.3 (prérequis): Révocation admin
  • Stories 3.7-3.12 (impact): Webhooks, glossaires, prompts utiliseront cette auth

Guardrails Développeur

À NE PAS FAIRE

  1. NE PAS réimplémenter get_user_by_api_key - Utiliser celle de services/auth_service.py
  2. NE PAS dupliquer le code de validation - Créer un middleware réutilisable
  3. NE PAS utiliser HTTPException avec detail string (utiliser JSONResponse structuré)
  4. NE PAS oublier de tester la coexistence JWT + API key
  5. NE PAS utiliser camelCase dans les réponses JSON (toujours snake_case)
  6. NE PAS permettre l'auth API key sur les endpoints de gestion de clés (sécurité)

À FAIRE

  1. TOUJOURS utiliser le format {error: "CODE", message: "..."} pour les erreurs
  2. TOUJOURS mettre à jour last_used_at et usage_count après auth réussie
  3. TOUJOURS vérifier is_active ET expires_at
  4. TOUJOURS prioriser API key sur JWT si les deux sont présents
  5. TOUJOURS écrire des tests pour tous les cas d'erreur
  6. CRÉER un middleware réutilisable dans middleware/api_key_auth.py

Code Suggéré

Fichier middleware/api_key_auth.py à créer

"""
API Key Authentication Middleware

Provides reusable dependencies for API key authentication across all endpoints.
"""

from typing import Optional
from fastapi import Header, HTTPException, Depends
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

security = HTTPBearer(auto_error=False)


class APIKeyError(Exception):
    """Exception for API key authentication errors with structured error codes."""
    
    INVALID_API_KEY = "INVALID_API_KEY"
    API_KEY_REVOKED = "API_KEY_REVOKED"
    API_KEY_EXPIRED = "API_KEY_EXPIRED"
    MISSING_API_KEY = "MISSING_API_KEY"
    
    ERROR_MESSAGES = {
        INVALID_API_KEY: "Clé API invalide ou non reconnue.",
        API_KEY_REVOKED: "Cette clé API a été révoquée.",
        API_KEY_EXPIRED: "Cette clé API a expiré.",
        MISSING_API_KEY: "Clé API requise pour cet endpoint.",
    }
    
    def __init__(self, code: str, message: Optional[str] = None):
        self.code = code
        self.message = message or self.ERROR_MESSAGES.get(code, "Erreur d'authentification")
        super().__init__(self.message)
    
    def to_response(self, status_code: int = 401) -> JSONResponse:
        return JSONResponse(
            status_code=status_code,
            content={
                "error": self.code,
                "message": self.message,
            },
        )


async def get_user_from_api_key(
    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
) -> Optional[Any]:
    """
    Get user from X-API-Key header if provided.
    
    Returns:
        User object if valid API key provided
        None if no API key provided (caller should try other auth methods)
    
    Raises:
        HTTPException: 401 with structured error if API key is invalid/revoked/expired
    """
    if not x_api_key:
        return None
    
    try:
        from services.auth_service import get_user_by_api_key
        
        user = get_user_by_api_key(x_api_key)
        return user
        
    except ValueError as e:
        # Handle revoked/expired API keys with specific error codes
        error_code = str(e)
        
        if error_code == "API_KEY_REVOKED":
            raise HTTPException(
                status_code=401,
                detail={
                    "error": "API_KEY_REVOKED",
                    "message": "Cette clé API a été révoquée.",
                },
            )
        elif error_code == "API_KEY_EXPIRED":
            raise HTTPException(
                status_code=401,
                detail={
                    "error": "API_KEY_EXPIRED",
                    "message": "Cette clé API a expiré.",
                },
            )
        else:
            # Unknown error - treat as invalid
            raise HTTPException(
                status_code=401,
                detail={
                    "error": "INVALID_API_KEY",
                    "message": "Clé API invalide ou non reconnue.",
                },
            )
    
    except Exception:
        # Unexpected error - treat as invalid
        raise HTTPException(
            status_code=401,
            detail={
                "error": "INVALID_API_KEY",
                "message": "Clé API invalide ou non reconnue.",
            },
        )


async def get_authenticated_user(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
) -> Optional[Any]:
    """
    Get authenticated user from API key or JWT.
    
    Priority:
    1. X-API-Key header (automation users)
    2. JWT Bearer token (web users)
    3. None (unauthenticated)
    
    Returns:
        User object if authenticated
        None if not authenticated
    """
    # Try API key first (priority for automation)
    user = await get_user_from_api_key(x_api_key)
    if user:
        return user
    
    # Fall back to JWT
    if credentials:
        try:
            from routes.auth_routes import get_current_user
            user = await get_current_user(credentials)
            return user
        except Exception:
            return None
    
    return None


async def require_authenticated_user(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
) -> Any:
    """
    Require authentication (API key or JWT).
    
    Raises:
        HTTPException: 401 if not authenticated
    
    Returns:
        User object (guaranteed to be authenticated)
    """
    user = await get_authenticated_user(credentials, x_api_key)
    
    if not user:
        raise HTTPException(
            status_code=401,
            detail={
                "error": "UNAUTHORIZED",
                "message": "Authentification requise. Utilisez X-API-Key ou Authorization: Bearer.",
            },
        )
    
    return user


async def require_api_key(
    x_api_key: str = Header(..., alias="X-API-Key"),
) -> Any:
    """
    Require API key authentication (no JWT fallback).
    
    Use this for endpoints that MUST use API key (e.g., certain automation endpoints).
    
    Raises:
        HTTPException: 401 if API key is missing, invalid, revoked, or expired
    
    Returns:
        User object (guaranteed to be authenticated via API key)
    """
    return await get_user_from_api_key(x_api_key)

Modification de routes/translate_routes.py

# Remplacer les fonctions locales par import du middleware
from middleware.api_key_auth import get_authenticated_user, require_authenticated_user

# Utiliser dans les endpoints
@router_v1.post("/translate")
async def translate_document_v1(
    ...
    current_user: Optional[Any] = Depends(get_authenticated_user),
):
    ...

Dev Agent Record

Agent Model Used

{{agent_model_name_version}}

Debug Log References

À compléter lors de l'implémentation

Completion Notes List

  • Analyse exhaustive du contexte terminée - guide complet créé pour le développeur
  • Code existant analysé (auth_service.py, translate_routes.py)
  • Patterns de la Story 3.3 réutilisables identifiés
  • Architecture proposée pour middleware réutilisable

File List

  • middleware/api_key_auth.py - À CRÉER - Dépendances auth réutilisables
  • routes/translate_routes.py - À MODIFIER - Utiliser nouvelle dépendance
  • tests/test_story_3_4_api_key_authentication.py - À CRÉER

Change Log

  • 2026-02-22: Story créée avec contexte complet (code existant, architecture proposée, tests suggérés)

Checklist de Validation

Avant de marquer cette story comme terminée, vérifier:

  • middleware/api_key_auth.py créé avec dépendances réutilisables
  • get_user_from_api_key gère tous les cas d'erreur (INVALID, REVOKED, EXPIRED)
  • get_authenticated_user essaie API key puis JWT
  • last_used_at et usage_count sont mis à jour après auth réussie
  • Les endpoints translate utilisent la nouvelle dépendance
  • Les erreurs utilisent le format {error, message}
  • Tous les tests passent (648 tests)
  • La coexistence JWT + API key fonctionne correctement

Fichiers Modifiés/Créés

  • middleware/api_key_auth.py - CRÉÉ - Dépendances auth réutilisables
  • middleware/__init__.py - MODIFIÉ - Export des nouvelles dépendances
  • routes/translate_routes.py - MODIFIÉ - Utilise nouvelle dépendance du middleware
  • tests/test_story_3_4_api_key_authentication.py - CRÉÉ - 13 tests

Résumé d'Implémentation

Ce qui a été implémenté

  1. Middleware api_key_auth.py avec:

    • APIKeyError - Exception structurée avec codes d'erreur
    • get_user_from_api_key() - Valide clé API et retourne utilisateur
    • get_authenticated_user() - Unifie API key + JWT (priorité API key)
    • require_authenticated_user() - Dépendance qui requiert auth
    • require_api_key() - Dépendance qui requiert API key uniquement
  2. Codes d'erreur structurés:

    • INVALID_API_KEY - Clé non trouvée
    • API_KEY_REVOKED - Clé révoquée (is_active=False)
    • API_KEY_EXPIRED - Clé expirée (expires_at < now)
    • MISSING_API_KEY - Clé manquante (si requis)
  3. Tests complets (13 tests):

    • Authentification réussie avec clé valide
    • Erreur avec clé invalide
    • Erreur avec clé révoquée
    • Erreur avec clé expirée
    • Coexistence JWT + API key
    • Mise à jour de last_used_at et usage_count