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