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

411 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 1.4: Logout Utilisateur
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## 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: 16)
- [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 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)
```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 <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) :**
- [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 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.