Files
office_translator/_bmad-output/implementation-artifacts/1-4-logout-utilisateur.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

16 KiB
Raw Blame History

Story 1.4: Logout Utilisateur

Status: done

Story

As a logged-in user, I want to logout and invalidate my session, so that my account remains secure.

Acceptance Criteria

  1. AC1: Endpoint LogoutPOST /api/v1/auth/logout existe et requiert un Bearer token valide dans le header Authorization
  2. AC2: Invalidation du refresh token — Après un logout réussi, le refresh token est révoqué et ne peut plus être utilisé pour obtenir un nouvel access token
  3. AC3: Invalidation de l'access token — Après un logout réussi, l'ancien access token retourne 401 si réutilisé
  4. AC4: Réponse succès — Logout réussi retourne 200 avec {"data": {"message": "Déconnexion réussie"}, "meta": {}}
  5. AC5: Token manquant — Appel sans Authorization header retourne 401 avec {"error": "TOKEN_MISSING", "message": "Token d'authentification requis"}
  6. AC6: Token invalide — Appel avec token expiré/invalide retourne 401 avec {"error": "TOKEN_INVALID", "message": "Token invalide ou expiré"}

Tasks / Subtasks

  • Task 1: Ajouter JTI aux tokens JWT (AC: 2, 3)

    • 1.1 Dans services/auth_service.py, ajouter import uuid en haut du fichier
    • 1.2 Dans create_access_token(), ajouter "jti": str(uuid.uuid4()) au payload JWT
    • 1.3 Dans create_refresh_token(), ajouter "jti": str(uuid.uuid4()) au payload JWT
    • 1.4 Note: les tokens JWT fallback (sans PyJWT) ne supportent pas JTI, ne pas modifier le fallback base64
  • Task 2: Implémenter la blocklist en mémoire (AC: 2, 3)

    • 2.1 Dans services/auth_service.py, ajouter _revoked_jtis: dict[str, float] = {} (jti → timestamp d'expiry pour GC)
    • 2.2 Créer la fonction revoke_token_jti(jti: str, expires_at: float) -> None qui ajoute le JTI + expiry dans _revoked_jtis
    • 2.3 Créer la fonction is_token_revoked(jti: str) -> bool qui vérifie si un JTI est révoqué (en nettoyant au passage les JTI expirés)
    • 2.4 Dans verify_token(), après décodage JWT réussi, vérifier is_token_revoked(payload.get("jti", "")) — si révoqué, retourner None
  • Task 3: Créer l'endpoint logout (AC: 1, 4, 5, 6)

    • 3.1 Dans routes/auth_routes.py, ajouter le modèle Pydantic LogoutRequest avec refresh_token: Optional[str] = None
    • 3.2 Ajouter @router_v1.post("/logout") dans le bloc router_v1
    • 3.3 Extraire le Bearer token depuis le header Authorization manuellement (pattern identique à Story 1.3)
    • 3.4 Si header absent ou non-Bearer → retourner 401 TOKEN_MISSING
    • 3.5 Appeler verify_token(access_token) — si invalide/expiré → retourner 401 TOKEN_INVALID
    • 3.6 Révoquer le JTI de l'access token via revoke_token_jti()
    • 3.7 Si le corps contient un refresh_token, tenter de le décoder et révoquer son JTI aussi
    • 3.8 Retourner JSONResponse(200, {"data": {"message": "Déconnexion réussie"}, "meta": {}})
  • Task 4: Ajouter les tests (AC: 16)

    • 4.1 Créer tests/test_auth_logout.py
    • 4.2 Test: logout réussi retourne 200 avec message "Déconnexion réussie"
    • 4.3 Test: après logout, réutiliser l'access token retourne 401
    • 4.4 Test: après logout, utiliser le refresh_token pour se reconnecter retourne 401
    • 4.5 Test: logout sans Authorization header retourne 401 TOKEN_MISSING
    • 4.6 Test: logout avec token invalide/malformé retourne 401 TOKEN_INVALID
    • 4.7 Test: logout avec token expiré retourne 401 TOKEN_INVALID
    • 4.8 Test: les anciens tokens (sans JTI) restent valides si non révoqués (backward compat)

Dev Notes

🚨 CONTEXTE BROWNFIELD — CRITIQUE

Ce projet est un refactoring brownfield. Les fichiers à modifier existent déjà.

Fichier État actuel Action
routes/auth_routes.py A router_v1 avec /register et /login Ajouter /logout dans ce même routeur
services/auth_service.py A create_access_token(), create_refresh_token(), verify_token() Modifier pour ajouter JTI + blocklist
tests/test_auth_login.py 22 tests — 54 tests totaux passent NE PAS MODIFIER, vérifier non-régression

Architecture Compliance

Format de réponse attendu (architecture.md) :

Succès (200) :

{
  "data": {"message": "Déconnexion réussie"},
  "meta": {}
}

Erreur token manquant (401) :

{
  "error": "TOKEN_MISSING",
  "message": "Token d'authentification requis"
}

Erreur token invalide (401) :

{
  "error": "TOKEN_INVALID",
  "message": "Token invalide ou expiré"
}

⚠️ PAS de champ data dans les réponses d'erreur (convention établie en Story 1.2 et 1.3).

Design Décision: Blocklist en Mémoire (In-Process)

Justification : Redis n'est pas encore disponible (prévu en Story 1.6). Une blocklist en mémoire est la solution la plus simple sans dépendance additionnelle.

Limitations documentées :

  • La blocklist est perdue au redémarrage du serveur
  • En cas de déploiement multi-process (Gunicorn avec workers), chaque worker a sa propre blocklist

Acceptable car :

  • Access tokens durent 15 minutes → impact limité après restart
  • MVP solo dev sur un seul processus
  • Story 1.6 (Redis) permettra une migration vers redis.setex(jti, ttl, "revoked") si nécessaire

Structure de la blocklist :

# services/auth_service.py
_revoked_jtis: dict[str, float] = {}
# Clé: jti (str), Valeur: timestamp Unix d'expiry (pour garbage collection)

Garbage collection automatique :

import time

def is_token_revoked(jti: str) -> bool:
    """Vérifie si un JTI est révoqué. Nettoie les entrées expirées au passage."""
    if not jti:
        return False
    now = time.time()
    # Nettoyage lazy: supprimer les JTI expirés
    expired = [k for k, v in _revoked_jtis.items() if v < now]
    for k in expired:
        _revoked_jtis.pop(k, None)
    return jti in _revoked_jtis

Patterns Établis à Suivre (Stories 1.2 et 1.3)

Pattern d'extraction du Bearer token (à implémenter dans l'endpoint):

@router_v1.post("/logout")
async def logout_v1(request: Request):
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return JSONResponse(
            status_code=401,
            content={
                "error": "TOKEN_MISSING",
                "message": "Token d'authentification requis",
            },
        )
    access_token = auth_header[7:]  # enlever "Bearer "

    payload = verify_token(access_token)
    if not payload:
        return JSONResponse(
            status_code=401,
            content={
                "error": "TOKEN_INVALID",
                "message": "Token invalide ou expiré",
            },
        )

    # Révoquer l'access token
    jti = payload.get("jti")
    if jti:
        exp = payload.get("exp", 0)
        revoke_token_jti(jti, float(exp))

    # Optionnellement révoquer le refresh token
    try:
        body = await request.json()
        refresh_token = body.get("refresh_token")
        if refresh_token:
            refresh_payload = verify_token(refresh_token)
            if refresh_payload and refresh_payload.get("jti"):
                revoke_token_jti(
                    refresh_payload["jti"],
                    float(refresh_payload.get("exp", 0))
                )
    except Exception:
        pass  # Corps optionnel, pas d'erreur si absent ou invalide

    return JSONResponse(
        status_code=200,
        content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
    )

Pattern d'ajout de JTI dans create_access_token :

import uuid

def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
    # ...
    to_encode = {
        "sub": user_id,
        "exp": expire,
        "type": "access",
        "jti": str(uuid.uuid4()),   # ← NOUVEAU
    }
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

Pattern de vérification dans verify_token :

def verify_token(token: str) -> Optional[Dict[str, Any]]:
    # ... (PyJWT decode)
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        # Vérifier blocklist si JTI présent
        jti = payload.get("jti")
        if jti and is_token_revoked(jti):
            return None
        return payload
    except jwt.ExpiredSignatureError:
        return None
    except jwt.JWTError:
        return None

Imports à ajouter dans services/auth_service.py

import uuid
import time

Import à ajouter dans routes/auth_routes.py

Aucun import supplémentaire requis — Request, JSONResponse, Optional sont déjà importés.

Modèle Pydantic optionnel (non obligatoire, le body est parsé via request.json()) :

class LogoutRequest(BaseModel):
    refresh_token: Optional[str] = None

Backward Compatibility

Les tokens créés avant cette implémentation (sans champ jti) :

  • payload.get("jti") retourne None
  • is_token_revoked("")False (tokens sans JTI ne sont pas révocables, mais continuent de fonctionner)
  • Aucune régression — les 54 tests existants continuent de passer

Fichiers Modifiés/Créés

Fichier Action Raison
services/auth_service.py MODIFIER Ajouter uuid, time, _revoked_jtis, revoke_token_jti(), is_token_revoked(), JTI dans tokens, check dans verify_token
routes/auth_routes.py MODIFIER Ajouter endpoint POST /api/v1/auth/logout dans router_v1
tests/test_auth_logout.py CRÉER 8+ tests couvrant AC1AC6

Fichiers à NE PAS Toucher

  • database/models.py — aucun changement de schéma requis
  • alembic/ — pas de migration (approche in-memory)
  • tests/test_auth_login.py — ne pas modifier, vérifier non-régression
  • tests/test_auth_register.py — ne pas modifier
  • Tout code frontend — hors scope

Pattern de Test (conftest.py)

# tests/test_auth_logout.py
import pytest
from fastapi.testclient import TestClient
from pathlib import Path

LOGOUT_URL = "/api/v1/auth/logout"
LOGIN_URL = "/api/v1/auth/login"
REGISTER_URL = "/api/v1/auth/register"

@pytest.fixture()
def client(tmp_path: Path, monkeypatch):
    import services.auth_service as auth_svc
    monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
    monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
    monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)

    # CRITIQUE: Réinitialiser la blocklist entre les tests
    monkeypatch.setattr(auth_svc, "_revoked_jtis", {})

    from middleware.rate_limiting import RateLimitManager
    async def _allow(self, request): return True, "ok", "test"
    async def _allow_translation(self, request, file_size_mb=0): return True, "ok"
    monkeypatch.setattr(RateLimitManager, "check_request", _allow)
    monkeypatch.setattr(RateLimitManager, "check_translation", _allow_translation)

    from main import app
    return TestClient(app, raise_server_exceptions=True)

⚠️ CRITIQUE : Toujours monkeypatch.setattr(auth_svc, "_revoked_jtis", {}) dans chaque test fixture pour éviter les fuites d'état entre tests.

Dépendances (Déjà Installées)

Package Version Usage
PyJWT 2.8.0 Encode/decode JWT avec JTI
pydantic 2.x Validation schema (optionnel pour body)
pytest 7.x Framework de test
uuid stdlib Génération JTI
time stdlib Garbage collection blocklist

Commandes de Test

# Lancer les tests de la story
pytest tests/test_auth_logout.py -v

# Vérifier non-régression (tous les tests existants)
pytest tests/ -v

# Test manuel de l'endpoint
# 1. S'inscrire
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "Password123!", "name": "Test User"}'

# 2. Se connecter
curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "Password123!"}'

# 3. Se déconnecter (avec les tokens obtenus)
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "<refresh_token>"}'

# 4. Vérifier que l'access token est révoqué (doit retourner 401)
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -H "Authorization: Bearer <access_token>"

Considérations Sécurité (NFR6, NFR10)

  • L'access token reste dans le header Authorization et n'est jamais loggué
  • Les JTI sont des UUID aléatoires — pas de fuite d'info sur l'utilisateur
  • Le message d'erreur 401 ne distingue pas "token manquant" vs "token révoqué" (prévention d'énumération)
  • SECRET_KEY reste inchangé et chargé depuis l'environnement

Notes sur la Story 1.5 (Refresh Token)

La Story 1.5 implémentera POST /api/v1/auth/refresh. Grâce au JTI ajouté dans cette story :

  • Le refresh token pourra être révoqué avant son expiry de 7 jours
  • La fonction verify_token() vérifiera déjà la blocklist pour les refresh tokens

Référence Architecture — Endpoints Auth

Endpoint Méthode Story Statut
/api/v1/auth/register POST 1.2 done
/api/v1/auth/login POST 1.3 review
/api/v1/auth/logout POST 1.4 cette story
/api/v1/auth/refresh POST 1.5 backlog

Project Structure Notes

  • Backend utilise snake_case pour fichiers, variables, fonctions
  • PascalCase pour les classes Pydantic
  • Suivre les patterns de routes/ et services/ existants
  • Tests dans tests/ selon les conventions pytest

References

  • [Source: _bmad-output/planning-artifacts/epics.md#Story 1.4]
  • [Source: _bmad-output/planning-artifacts/architecture.md#Authentication & Security]
  • [Source: _bmad-output/planning-artifacts/architecture.md#API Response Formats]
  • [Source: _bmad-output/planning-artifacts/prd.md#FR19]
  • [Source: _bmad-output/planning-artifacts/prd.md#NFR6]
  • [Source: _bmad-output/implementation-artifacts/1-3-login-utilisateur-jwt.md — Patterns établis en Story 1.3]

Senior Developer Review (AI)

Date: 2026-02-20
Outcome: Approve (après corrections automatiques)

Résumé : 8 findings (2 High, 2 Medium, 4 Low). Tous les High et Medium corrigés automatiquement sur demande utilisateur [option 1].

Action Items (tous résolus) :

  • [High] AC2 : test complété — appel réel à POST /api/auth/refresh avec refresh_token révoqué, assertion 401
  • [High] Task 3.1 : modèle Pydantic LogoutRequest ajouté dans routes/auth_routes.py
  • [Medium] verify_token() : except Exception remplacé par except jwt.PyJWTError
  • [Low] Code mort supprimé dans tests (branche if False, monkeypatch inutile)
  • [Low] Docstrings blocklist + thread-safety : commentaire module et docstrings en anglais

Dev Agent Record

Agent Model Used

claude-4.6-sonnet-medium-thinking (Cursor Agent)

Debug Log References

  • Bug pré-existant : jwt.JWTError n'existe pas en PyJWT v2 → corrigé en except jwt.PyJWTError (revue de code).

Completion Notes List

  • Task 14 : implémentation initiale (JTI, blocklist, endpoint logout, 12 tests).
  • Code review 2026-02-20 : corrections appliquées (test AC2 via endpoint refresh, LogoutRequest, PyJWTError, docstrings, nettoyage tests). Tous les tests passent.

File List

  • services/auth_service.py — MODIFIÉ : JTI, blocklist, revoke_token_jti(), is_token_revoked(), verify_token() (blocklist + PyJWTError), docstrings EN, commentaire thread-safety
  • routes/auth_routes.py — MODIFIÉ : LogoutRequest, import revoke_token_jti, endpoint POST /api/v1/auth/logout
  • tests/test_auth_logout.py — CRÉÉ : 12 tests (AC2 vérifié via POST /api/auth/refresh → 401), backward compat sans monkeypatch inutile

Change Log

  • 2026-02-20 : Implémentation Story 1.4 — logout, JTI, blocklist, 12 tests.
  • 2026-02-20 : Code review — corrections (AC2 test, LogoutRequest, PyJWTError, docstrings, tests). Status → done.