feat: Connexion utilisateur avec sécurité renforcée

Implémente la Story 1.4 du système d'authentification avec plusieurs
couches de protection contre les attaques par force brute.

Sécurité backend :
- Authentification JWT avec access token (15min) + refresh token (7j)
- Rotation automatique des refresh tokens avec détection de replay
- Rate limiting progressif par IP (délai Fibonacci après échecs)
- Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives
- Alerte email à l'utilisateur après blocage temporaire
- Isolation multi-tenant (un utilisateur ne peut se connecter que sur
  son établissement)

Frontend :
- Page de connexion avec feedback visuel des délais et erreurs
- Composant TurnstileCaptcha réutilisable
- Gestion d'état auth avec stockage sécurisé des tokens
- Tests E2E Playwright pour login, tenant isolation, et activation

Infrastructure :
- Configuration Symfony Security avec json_login + jwt
- Cache pools séparés (filesystem en test, Redis en prod)
- NullLoginRateLimiter pour environnement de test (évite blocage CI)
- Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
2026-02-01 10:25:25 +01:00
parent 6889c67a44
commit b9d9f48305
93 changed files with 6850 additions and 155 deletions

View File

@@ -0,0 +1,273 @@
import { getApiBaseUrl } from '$lib/api';
import { goto } from '$app/navigation';
/**
* Service d'authentification côté client.
*
* Sécurité :
* - Access token stocké en mémoire uniquement (pas localStorage - vulnérable XSS)
* - Refresh token en cookie HttpOnly (géré côté serveur)
*
* @see Story 1.4 - Connexion utilisateur
*/
/** Délai entre les tentatives de refresh lors de race conditions multi-onglets (ms) */
const REFRESH_RACE_RETRY_DELAY_MS = 100;
// État réactif de l'authentification
let accessToken = $state<string | null>(null);
let isRefreshing = $state(false);
export interface LoginCredentials {
email: string;
password: string;
captcha_token?: string | undefined;
}
export interface LoginResult {
success: boolean;
error?: {
type: 'invalid_credentials' | 'rate_limited' | 'captcha_required' | 'captcha_invalid' | 'unknown';
message: string;
retryAfter?: number | undefined;
delay?: number | undefined;
attempts?: number | undefined;
captchaRequired?: boolean | undefined;
};
}
export interface ApiError {
type: string;
title: string;
status: number;
detail: string;
retryAfter?: number | undefined;
}
/**
* Effectue une tentative de connexion.
*/
export async function login(credentials: LoginCredentials): Promise<LoginResult> {
const apiUrl = getApiBaseUrl();
try {
const response = await fetch(`${apiUrl}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
credentials: 'include', // Important pour recevoir le cookie refresh_token
});
if (response.ok) {
const data = await response.json();
accessToken = data.token;
return { success: true };
}
// Erreur
const error = await response.json() as ApiError & {
attempts?: number;
captchaRequired?: boolean;
delay?: number;
};
// IP bloquée (429)
if (response.status === 429) {
return {
success: false,
error: {
type: 'rate_limited',
message: error.detail,
retryAfter: error.retryAfter,
},
};
}
// CAPTCHA requis (428)
if (response.status === 428) {
return {
success: false,
error: {
type: 'captcha_required',
message: error.detail,
attempts: error.attempts,
captchaRequired: true,
},
};
}
// CAPTCHA invalide (400)
if (response.status === 400 && error.type === '/errors/captcha-invalid') {
return {
success: false,
error: {
type: 'captcha_invalid',
message: error.detail,
captchaRequired: true,
},
};
}
// Authentification échouée (401)
return {
success: false,
error: {
type: 'invalid_credentials',
message: error.detail || 'Identifiants incorrects',
attempts: error.attempts,
delay: error.delay,
captchaRequired: error.captchaRequired,
},
};
} catch (error) {
console.error('[auth] Login error:', error);
return {
success: false,
error: {
type: 'unknown',
message: 'Erreur de connexion. Veuillez réessayer.',
},
};
}
}
/**
* Rafraîchit le token JWT via le refresh token (cookie).
*
* Gère le cas de race condition multi-onglets (409 Conflict) :
* Si deux onglets tentent de rafraîchir simultanément, le second recevra
* un 409 car le token a déjà été rotaté. Dans ce cas, on attend un court
* instant et on réessaie car le cookie contient maintenant le nouveau token.
*/
export async function refreshToken(retryCount = 0): Promise<boolean> {
if (isRefreshing && retryCount === 0) {
// Déjà en cours de refresh, attendre
return new Promise((resolve) => {
const interval = setInterval(() => {
if (!isRefreshing) {
clearInterval(interval);
resolve(accessToken !== null);
}
}, REFRESH_RACE_RETRY_DELAY_MS);
});
}
if (retryCount === 0) {
isRefreshing = true;
}
const apiUrl = getApiBaseUrl();
try {
const response = await fetch(`${apiUrl}/token/refresh`, {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
accessToken = data.token;
return true;
}
// 409 Conflict = token déjà rotaté (race condition multi-onglets)
// Attendre un court instant et réessayer avec le nouveau cookie
if (response.status === 409 && retryCount < 2) {
await new Promise((resolve) => setTimeout(resolve, REFRESH_RACE_RETRY_DELAY_MS));
return refreshToken(retryCount + 1);
}
// Refresh échoué - token expiré ou replay détecté
accessToken = null;
return false;
} catch (error) {
console.error('[auth] Refresh token error:', error);
accessToken = null;
return false;
} finally {
if (retryCount === 0) {
isRefreshing = false;
}
}
}
/**
* Effectue une requête authentifiée.
* Rafraîchit automatiquement le token si nécessaire.
*/
export async function authenticatedFetch(
url: string,
options: RequestInit = {},
): Promise<Response> {
// Si pas de token, essayer de rafraîchir
if (!accessToken) {
const refreshed = await refreshToken();
if (!refreshed) {
// Rediriger vers login
goto('/login');
throw new Error('Not authenticated');
}
}
// Ajouter le token à la requête
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${accessToken}`);
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
// Si 401, essayer de rafraîchir et rejouer
if (response.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
headers.set('Authorization', `Bearer ${accessToken}`);
return fetch(url, { ...options, headers, credentials: 'include' });
}
// Refresh échoué, rediriger vers login
goto('/login');
throw new Error('Session expired');
}
return response;
}
/**
* Déconnexion.
*/
export async function logout(): Promise<void> {
const apiUrl = getApiBaseUrl();
try {
await fetch(`${apiUrl}/token/logout`, {
method: 'POST',
credentials: 'include',
});
} catch (error) {
// Log mais ne pas bloquer la déconnexion locale
console.warn('[auth] Logout API error (continuing with local logout):', error);
}
accessToken = null;
goto('/login');
}
/**
* Vérifie si l'utilisateur est authentifié.
*/
export function isAuthenticated(): boolean {
return accessToken !== null;
}
/**
* Retourne le token actuel (pour debug uniquement).
*/
export function getAccessToken(): string | null {
return accessToken;
}