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>
411 lines
16 KiB
Markdown
411 lines
16 KiB
Markdown
# 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: 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 <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 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.
|