# 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.