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
152 lines
3.4 KiB
Svelte
152 lines
3.4 KiB
Svelte
<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>
|