# 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 Logout** — `POST /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 - [x] **Task 1: Ajouter JTI aux tokens JWT** (AC: 2, 3) - [x] 1.1 Dans `services/auth_service.py`, ajouter `import uuid` en haut du fichier - [x] 1.2 Dans `create_access_token()`, ajouter `"jti": str(uuid.uuid4())` au payload JWT - [x] 1.3 Dans `create_refresh_token()`, ajouter `"jti": str(uuid.uuid4())` au payload JWT - [x] 1.4 Note: les tokens JWT fallback (sans PyJWT) ne supportent pas JTI, ne pas modifier le fallback base64 - [x] **Task 2: Implémenter la blocklist en mémoire** (AC: 2, 3) - [x] 2.1 Dans `services/auth_service.py`, ajouter `_revoked_jtis: dict[str, float] = {}` (jti → timestamp d'expiry pour GC) - [x] 2.2 Créer la fonction `revoke_token_jti(jti: str, expires_at: float) -> None` qui ajoute le JTI + expiry dans `_revoked_jtis` - [x] 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) - [x] 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` - [x] **Task 3: Créer l'endpoint logout** (AC: 1, 4, 5, 6) - [x] 3.1 Dans `routes/auth_routes.py`, ajouter le modèle Pydantic `LogoutRequest` avec `refresh_token: Optional[str] = None` - [x] 3.2 Ajouter `@router_v1.post("/logout")` dans le bloc `router_v1` - [x] 3.3 Extraire le Bearer token depuis le header `Authorization` manuellement (pattern identique à Story 1.3) - [x] 3.4 Si header absent ou non-Bearer → retourner 401 `TOKEN_MISSING` - [x] 3.5 Appeler `verify_token(access_token)` — si invalide/expiré → retourner 401 `TOKEN_INVALID` - [x] 3.6 Révoquer le JTI de l'access token via `revoke_token_jti()` - [x] 3.7 Si le corps contient un `refresh_token`, tenter de le décoder et révoquer son JTI aussi - [x] 3.8 Retourner `JSONResponse(200, {"data": {"message": "Déconnexion réussie"}, "meta": {}})` - [x] **Task 4: Ajouter les tests** (AC: 1–6) - [x] 4.1 Créer `tests/test_auth_logout.py` - [x] 4.2 Test: logout réussi retourne 200 avec message "Déconnexion réussie" - [x] 4.3 Test: après logout, réutiliser l'access token retourne 401 - [x] 4.4 Test: après logout, utiliser le refresh_token pour se reconnecter retourne 401 - [x] 4.5 Test: logout sans Authorization header retourne 401 TOKEN_MISSING - [x] 4.6 Test: logout avec token invalide/malformé retourne 401 TOKEN_INVALID - [x] 4.7 Test: logout avec token expiré retourne 401 TOKEN_INVALID - [x] 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) : ```json { "data": {"message": "Déconnexion réussie"}, "meta": {} } ``` Erreur token manquant (401) : ```json { "error": "TOKEN_MISSING", "message": "Token d'authentification requis" } ``` Erreur token invalide (401) : ```json { "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 :** ```python # services/auth_service.py _revoked_jtis: dict[str, float] = {} # Clé: jti (str), Valeur: timestamp Unix d'expiry (pour garbage collection) ``` **Garbage collection automatique :** ```python 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):** ```python @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` :** ```python 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` :** ```python 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` ```python 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()`) :** ```python 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 AC1–AC6 | ### 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) ```python # 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 ```bash # 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 " \ -H "Content-Type: application/json" \ -d '{"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 " ``` ### 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) :** - [x] [High] AC2 : test complété — appel réel à `POST /api/auth/refresh` avec refresh_token révoqué, assertion 401 - [x] [High] Task 3.1 : modèle Pydantic `LogoutRequest` ajouté dans `routes/auth_routes.py` - [x] [Medium] `verify_token()` : `except Exception` remplacé par `except jwt.PyJWTError` - [x] [Low] Code mort supprimé dans tests (branche `if False`, monkeypatch inutile) - [x] [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 1–4 : 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.