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,151 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
/**
* Cloudflare Turnstile CAPTCHA Component
*
* @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
* @see Story 1.4 - T8: CAPTCHA anti-bot
*/
// Site Key from environment (PUBLIC_TURNSTILE_SITE_KEY)
// Fallback to Cloudflare's official "always passes" test key for development
// @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
const TURNSTILE_TEST_KEY = '1x00000000000000000000AA';
const SITE_KEY = env['PUBLIC_TURNSTILE_SITE_KEY'] || TURNSTILE_TEST_KEY;
// Warn in console if using test key (helps catch missing config)
if (SITE_KEY === TURNSTILE_TEST_KEY) {
console.warn(
'[TurnstileCaptcha] Using Cloudflare test key. Set PUBLIC_TURNSTILE_SITE_KEY for production.'
);
}
interface Props {
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
onExpired?: () => void;
}
let { onSuccess, onError, onExpired }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let widgetId: string | null = $state(null);
let isLoaded = $state(false);
// Load the Turnstile script
function loadScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (window.turnstile) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
document.head.appendChild(script);
});
}
// Render the widget
async function renderWidget() {
try {
await loadScript();
if (!container || widgetId !== null) return;
widgetId = window.turnstile.render(container, {
sitekey: SITE_KEY,
callback: (token: string) => {
onSuccess?.(token);
},
'error-callback': (error: string) => {
onError?.(error || 'Vérification échouée');
},
'expired-callback': () => {
onExpired?.();
},
theme: 'light',
language: 'fr'
});
isLoaded = true;
} catch (error) {
console.error('Turnstile render error:', error);
onError?.('Impossible de charger la vérification de sécurité');
}
}
// Reset the widget (for retry)
export function reset() {
if (widgetId !== null && window.turnstile) {
window.turnstile.reset(widgetId);
}
}
// Mount effect
$effect(() => {
if (container) {
renderWidget();
}
return () => {
if (widgetId !== null && window.turnstile) {
window.turnstile.remove(widgetId);
widgetId = null;
}
};
});
</script>
<div class="turnstile-container">
<div bind:this={container} class="turnstile-widget"></div>
{#if !isLoaded}
<div class="turnstile-loading">
<span class="loading-spinner"></span>
<span>Chargement de la vérification...</span>
</div>
{/if}
</div>
<style>
.turnstile-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 1rem 0;
}
.turnstile-widget {
min-height: 65px;
}
.turnstile-loading {
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}
.loading-spinner {
width: 1rem;
height: 1rem;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>