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
274 lines
6.3 KiB
TypeScript
274 lines
6.3 KiB
TypeScript
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;
|
|
}
|