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>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -0,0 +1,410 @@
# 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.