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>
16 KiB
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
- AC1: Endpoint Logout —
POST /api/v1/auth/logoutexiste et requiert un Bearer token valide dans le headerAuthorization - 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
- AC3: Invalidation de l'access token — Après un logout réussi, l'ancien access token retourne 401 si réutilisé
- AC4: Réponse succès — Logout réussi retourne 200 avec
{"data": {"message": "Déconnexion réussie"}, "meta": {}} - AC5: Token manquant — Appel sans
Authorizationheader retourne 401 avec{"error": "TOKEN_MISSING", "message": "Token d'authentification requis"} - 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, ajouterimport uuiden 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
- 1.1 Dans
-
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) -> Nonequi ajoute le JTI + expiry dans_revoked_jtis - 2.3 Créer la fonction
is_token_revoked(jti: str) -> boolqui 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érifieris_token_revoked(payload.get("jti", ""))— si révoqué, retournerNone
- 2.1 Dans
-
Task 3: Créer l'endpoint logout (AC: 1, 4, 5, 6)
- 3.1 Dans
routes/auth_routes.py, ajouter le modèle PydanticLogoutRequestavecrefresh_token: Optional[str] = None - 3.2 Ajouter
@router_v1.post("/logout")dans le blocrouter_v1 - 3.3 Extraire le Bearer token depuis le header
Authorizationmanuellement (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 401TOKEN_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": {}})
- 3.1 Dans
-
Task 4: Ajouter les tests (AC: 1–6)
- 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)
- 4.1 Créer
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")retourneNoneis_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 requisalembic/— pas de migration (approche in-memory)tests/test_auth_login.py— ne pas modifier, vérifier non-régressiontests/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
Authorizationet 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_KEYreste 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/etservices/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/refreshavec refresh_token révoqué, assertion 401 - [High] Task 3.1 : modèle Pydantic
LogoutRequestajouté dansroutes/auth_routes.py - [Medium]
verify_token():except Exceptionremplacé parexcept 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.JWTErrorn'existe pas en PyJWT v2 → corrigé enexcept 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-safetyroutes/auth_routes.py— MODIFIÉ :LogoutRequest, importrevoke_token_jti, endpointPOST /api/v1/auth/logouttests/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.