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>
267 lines
11 KiB
Markdown
267 lines
11 KiB
Markdown
# Story 1.3: Login Utilisateur (JWT)
|
||
|
||
Status: done
|
||
|
||
## Story
|
||
|
||
As a **registered user**,
|
||
I want **to login with email and password**,
|
||
so that **I can access my dashboard and translation features**.
|
||
|
||
## Acceptance Criteria
|
||
|
||
1. **AC1: Login Success** - When correct email + password submitted, receive `access_token` (15min expiry) and `refresh_token` (7 days expiry)
|
||
2. **AC2: JWT Signing** - Tokens are JWT signed with `SECRET_KEY` from environment variable
|
||
3. **AC3: Invalid Password** - Returns 401 with `{error: "INVALID_CREDENTIALS", message: "..."}`
|
||
4. **AC4: User Not Found** - Returns 401 with `{error: "INVALID_CREDENTIALS", message: "..."}` (security: same error as wrong password to prevent email enumeration)
|
||
5. **AC5: API Versioning** - Endpoint at `/api/v1/auth/login` (not legacy `/api/auth/login`)
|
||
|
||
## Tasks / Subtasks
|
||
|
||
- [x] **Task 1: Create Login Endpoint** (AC: 1, 2, 5)
|
||
- [x] 1.1 Create `POST /api/v1/auth/login` endpoint in `routes/auth_routes.py`
|
||
- [x] 1.2 Accept `UserLogin` schema with `email` and `password` fields
|
||
- [x] 1.3 Return `TokenResponse` with `access_token`, `refresh_token`, and `token_type: "bearer"`
|
||
- [x] 1.4 Generate access token with 15min expiry using PyJWT
|
||
- [x] 1.5 Generate refresh token with 7 days expiry using PyJWT
|
||
|
||
- [x] **Task 2: Implement JWT Token Generation** (AC: 2)
|
||
- [x] 2.1 Create or update `create_access_token()` function in `services/auth_service.py`
|
||
- [x] 2.2 Create or update `create_refresh_token()` function in `services/auth_service.py`
|
||
- [x] 2.3 Use `SECRET_KEY` from `core/config.py` (loaded from env)
|
||
- [x] 2.4 Include `sub` (user_id) and `exp` claims in tokens
|
||
- [x] 2.5 Optionally include `tier` claim for quick access
|
||
|
||
- [x] **Task 3: Implement Credential Validation** (AC: 1, 3, 4)
|
||
- [x] 3.1 Fetch user by email using existing `get_user_by_email()` from `services/auth_service.py`
|
||
- [x] 3.2 Verify password using existing `verify_password()` function (passlib[bcrypt])
|
||
- [x] 3.3 Return standardized error for user not found: 401 `INVALID_CREDENTIALS` (security: same as wrong password to prevent email enumeration)
|
||
- [x] 3.4 Return standardized error for wrong password: 401 `INVALID_CREDENTIALS`
|
||
- [x] 3.5 Use same error message pattern as Story 1.2 (French messages)
|
||
|
||
- [x] **Task 4: Standardize Error Responses** (AC: 3, 4)
|
||
- [x] 4.1 User not found → 401 with `{error: "INVALID_CREDENTIALS", message: "Email ou mot de passe incorrect"}` (security: prevents email enumeration)
|
||
- [x] 4.2 Invalid password → 401 with `{error: "INVALID_CREDENTIALS", message: "Mot de passe incorrect"}`
|
||
- [x] 4.3 Invalid email format → 400 with `{error: "INVALID_EMAIL", message: "Format d'email invalide"}` (Pydantic validation)
|
||
- [x] 4.4 Use `JSONResponse` pattern from Story 1.2 for error responses
|
||
|
||
- [x] **Task 5: Add Tests** (AC: 1-5)
|
||
- [x] 5.1 Create `tests/test_auth_login.py`
|
||
- [x] 5.2 Test: successful login returns 200 + tokens with correct expiry
|
||
- [x] 5.3 Test: invalid password returns 401 INVALID_CREDENTIALS
|
||
- [x] 5.4 Test: non-existent user returns 401 INVALID_CREDENTIALS (security: same error as wrong password)
|
||
- [x] 5.5 Test: invalid email format returns 400 INVALID_EMAIL
|
||
- [x] 5.6 Test: tokens contain correct user_id in `sub` claim
|
||
- [x] 5.7 Test: tokens are signed and verifiable with SECRET_KEY
|
||
|
||
## Dev Notes
|
||
|
||
### 🚨 BROWNFIELD CONTEXT - CRITICAL
|
||
|
||
This is **REFACTORING**, not greenfield. Existing code to leverage:
|
||
|
||
| File | Current State | Action |
|
||
|------|---------------|--------|
|
||
| `routes/auth_routes.py` | Has `/api/auth/login` (legacy) + `/api/v1/auth/register` | Add `/api/v1/auth/login` |
|
||
| `services/auth_service.py` | Has `verify_password()`, `get_user_by_email()`, `hash_password()` | Reuse functions |
|
||
| `core/config.py` | Should have `SECRET_KEY` | Verify/ensure exists |
|
||
| `database/models.py` | User model with `id`, `email`, `hashed_password`, `tier` | Already complete (Story 1.1) |
|
||
|
||
### Architecture Compliance
|
||
|
||
**API Response Format (from architecture.md):**
|
||
|
||
Success (200):
|
||
```json
|
||
{
|
||
"data": {
|
||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"token_type": "bearer"
|
||
},
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
Error (401):
|
||
```json
|
||
{
|
||
"error": "INVALID_CREDENTIALS",
|
||
"message": "Mot de passe incorrect"
|
||
}
|
||
```
|
||
|
||
**⚠️ NO `data` field in error responses!**
|
||
|
||
**Token Structure (from architecture.md:183-184):**
|
||
```
|
||
Access Token: 15min expiry
|
||
Refresh Token: 7 days expiry
|
||
Algorithm: HS256 (PyJWT default)
|
||
Claims: sub (user_id), exp (expiry), optionally tier
|
||
```
|
||
|
||
**Naming (from architecture.md:276-289):**
|
||
- DB/API: snake_case (`user_id`, `access_token`)
|
||
- Python: snake_case variables/functions, PascalCase classes
|
||
- Endpoint: `/api/v1/auth/login`
|
||
|
||
### Existing Code Patterns to Follow (from Story 1.2)
|
||
|
||
**Router pattern** (established in Story 1.2):
|
||
```python
|
||
# routes/auth_routes.py
|
||
router_v1 = APIRouter(prefix="/api/v1/auth")
|
||
|
||
@router_v1.post("/login")
|
||
async def login_v1(user_login: UserLogin):
|
||
# Implementation
|
||
```
|
||
|
||
**Error response pattern** (from Story 1.2):
|
||
```python
|
||
from fastapi.responses import JSONResponse
|
||
|
||
# For errors, use JSONResponse directly (not HTTPException)
|
||
return JSONResponse(
|
||
status_code=401,
|
||
content={
|
||
"error": "INVALID_CREDENTIALS",
|
||
"message": "Mot de passe incorrect"
|
||
}
|
||
)
|
||
```
|
||
|
||
**Password verification** (`services/auth_service.py`):
|
||
```python
|
||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||
if PASSLIB_AVAILABLE:
|
||
return pwd_context.verify(plain_password, hashed_password)
|
||
else:
|
||
# Fallback SHA256 (dev only)
|
||
```
|
||
|
||
**User lookup** (`services/auth_service.py:180-200`):
|
||
```python
|
||
def get_user_by_email(email: str) -> Optional[User]:
|
||
# Already exists, reuse
|
||
```
|
||
|
||
### Dependencies (Already Installed)
|
||
|
||
| Package | Version | Purpose |
|
||
|---------|---------|---------|
|
||
| PyJWT | 2.8.0 | JWT token generation |
|
||
| passlib[bcrypt] | 1.7.4 | Password verification |
|
||
| pydantic | 2.x | Request/response schemas |
|
||
| pytest | 7.x | Testing |
|
||
|
||
### Files to Modify
|
||
|
||
| File | Action | Notes |
|
||
|------|--------|-------|
|
||
| `routes/auth_routes.py` | MODIFY | Add `/api/v1/auth/login` endpoint |
|
||
| `services/auth_service.py` | MODIFY | Add token generation functions if not present |
|
||
| `schemas/auth_schemas.py` or inline | CREATE/MODIFY | `UserLogin` schema |
|
||
| `core/config.py` | VERIFY | Ensure `SECRET_KEY` setting exists |
|
||
| `tests/test_auth_login.py` | CREATE | New test file |
|
||
|
||
### Files NOT to Touch
|
||
|
||
- `database/models.py` - User model already complete (Story 1.1)
|
||
- `database/connection.py` - Already setup
|
||
- `alembic/` - No schema changes needed
|
||
- Frontend files - Not in scope
|
||
|
||
### Token Generation Code Reference
|
||
|
||
```python
|
||
import jwt
|
||
from datetime import datetime, timedelta, timezone
|
||
from core.config import settings
|
||
|
||
def create_access_token(user_id: str, tier: str = "free") -> str:
|
||
payload = {
|
||
"sub": user_id,
|
||
"tier": tier,
|
||
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
|
||
"type": "access"
|
||
}
|
||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||
|
||
def create_refresh_token(user_id: str) -> str:
|
||
payload = {
|
||
"sub": user_id,
|
||
"exp": datetime.now(timezone.utc) + timedelta(days=7),
|
||
"type": "refresh"
|
||
}
|
||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||
```
|
||
|
||
### Testing Commands
|
||
|
||
```bash
|
||
# Run tests
|
||
pytest tests/test_auth_login.py -v
|
||
|
||
# Test endpoint manually
|
||
curl -X POST http://localhost:8000/api/v1/auth/login \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"email": "test@example.com", "password": "Password123!"}'
|
||
```
|
||
|
||
### Security Considerations (from NFR6)
|
||
|
||
- Access token expires in 15 minutes
|
||
- Refresh token expires in 7 days
|
||
- SECRET_KEY must come from environment (never hardcoded)
|
||
- Password verification uses bcrypt (not plaintext comparison)
|
||
- Error messages should not reveal whether email exists vs password is wrong (but AC4 requires separate error - use same timing to prevent enumeration)
|
||
|
||
### Project Structure Notes
|
||
|
||
- Backend uses snake_case for files, variables, functions
|
||
- PascalCase for classes
|
||
- Follow existing patterns in `routes/` and `services/`
|
||
- Tests in `tests/` directory following pytest conventions
|
||
|
||
### References
|
||
|
||
- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.3]
|
||
- [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#FR18]
|
||
- [Source: _bmad-output/planning-artifacts/prd.md#NFR6]
|
||
- [Source: _bmad-output/implementation-artifacts/1-2-inscription-utilisateur.md - Previous story patterns]
|
||
|
||
## Dev Agent Record
|
||
|
||
### Agent Model Used
|
||
|
||
claude-4.6-sonnet-medium-thinking (Cursor)
|
||
|
||
### Debug Log References
|
||
|
||
Aucun blocage rencontré. Implémentation directe suivant les patterns établis en Story 1.2.
|
||
|
||
### Completion Notes List
|
||
|
||
- **Endpoint créé** : `POST /api/v1/auth/login` dans `router_v1` de `routes/auth_routes.py`, suivant exactement le pattern de `register_v1` (parse JSON manuel + PydanticValidationError catch).
|
||
- **Token generation** : `create_access_token()` appelé avec `expires_delta=timedelta(minutes=15)` ; `create_refresh_token()` mis à jour pour accepter un paramètre `expires_delta` optionnel, appelé avec `timedelta(days=7)`. Les endpoints legacy continuent d'utiliser leurs valeurs par défaut (24h / 30j) sans impact.
|
||
- **Gestion d'erreurs** : `INVALID_CREDENTIALS` (401) pour email inconnu ET mot de passe incorrect (sécurité : évite l'énumération d'emails), `INVALID_EMAIL` (400) si format email invalide (Pydantic), `INVALID_REQUEST` (400) pour JSON malformé ou champs manquants. Tous les messages sont en français conformément à la Story 1.2.
|
||
- **Format de réponse** : `{"data": {"access_token": ..., "refresh_token": ..., "token_type": "bearer"}, "meta": {}}` conforme à l'architecture.
|
||
- **Token tier claim** : `create_access_token()` inclut le claim `tier` pour accès rapide au plan utilisateur.
|
||
- **Tests** : 24 tests dans `tests/test_auth_login.py` couvrant AC1–AC5 + tier claim + algorithme HS256.
|
||
- **Cycle RED-GREEN-REFACTOR** : tests écrits et confirmés échouants (404) avant l'implémentation.
|
||
|
||
### File List
|
||
|
||
- `routes/auth_routes.py` — modifié : ajout import `timedelta` et `verify_password`, ajout endpoint `login_v1`, passage du `tier` à `create_access_token()`, fix sécurité anti-énumération email
|
||
- `services/auth_service.py` — modifié : paramètre `expires_delta` ajouté à `create_refresh_token()`, paramètre `tier` ajouté à `create_access_token()`
|
||
- `tests/test_auth_login.py` — créé : 24 tests couvrant AC1–AC5, tier claim, algorithme HS256
|
||
|
||
### Change Log
|
||
|
||
- 2026-02-20 : Code review — fix tier claim manquant, fix anti-énumération email (USER_NOT_FOUND → INVALID_CREDENTIALS), ajout tests tier claim et HS256
|
||
- 2026-02-20 : Implémentation Story 1.3 — endpoint `POST /api/v1/auth/login` avec JWT (15min/7j), validation des credentials, gestion d'erreurs standardisée, 24 tests ajoutés.
|