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
567 lines
12 KiB
Svelte
567 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import { login, type LoginResult } from '$lib/auth';
|
|
import TurnstileCaptcha from '$lib/components/TurnstileCaptcha.svelte';
|
|
|
|
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
|
|
|
|
// Form state
|
|
let email = $state('');
|
|
let password = $state('');
|
|
let captchaToken = $state<string | null>(null);
|
|
let isSubmitting = $state(false);
|
|
let error = $state<{ type: string; message: string } | null>(null);
|
|
|
|
// Rate limit / delay state
|
|
let isRateLimited = $state(false);
|
|
let isDelayed = $state(false);
|
|
let retryAfterSeconds = $state(0);
|
|
let delaySeconds = $state(0);
|
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// CAPTCHA state
|
|
let showCaptcha = $state(false);
|
|
let captchaComponent: TurnstileCaptcha | undefined = $state();
|
|
|
|
function startCountdown(seconds: number, isBlock: boolean = true) {
|
|
if (isBlock) {
|
|
retryAfterSeconds = seconds;
|
|
isRateLimited = true;
|
|
} else {
|
|
delaySeconds = seconds;
|
|
isDelayed = true;
|
|
}
|
|
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
}
|
|
|
|
countdownInterval = setInterval(() => {
|
|
if (isBlock) {
|
|
retryAfterSeconds--;
|
|
if (retryAfterSeconds <= 0) {
|
|
isRateLimited = false;
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
}
|
|
} else {
|
|
delaySeconds--;
|
|
if (delaySeconds <= 0) {
|
|
isDelayed = false;
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
}
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function formatCountdown(seconds: number): string {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function handleCaptchaSuccess(token: string) {
|
|
captchaToken = token;
|
|
}
|
|
|
|
function handleCaptchaError(errorMsg: string) {
|
|
error = {
|
|
type: 'captcha_error',
|
|
message: errorMsg
|
|
};
|
|
}
|
|
|
|
function handleCaptchaExpired() {
|
|
captchaToken = null;
|
|
error = {
|
|
type: 'captcha_expired',
|
|
message: 'La vérification a expiré. Veuillez réessayer.'
|
|
};
|
|
}
|
|
|
|
async function handleSubmit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
|
|
if (isRateLimited || isDelayed || isSubmitting) return;
|
|
|
|
// Si CAPTCHA requis mais pas encore résolu
|
|
if (showCaptcha && !captchaToken) {
|
|
error = {
|
|
type: 'captcha_required',
|
|
message: 'Veuillez compléter la vérification de sécurité.'
|
|
};
|
|
return;
|
|
}
|
|
|
|
error = null;
|
|
isSubmitting = true;
|
|
|
|
try {
|
|
const result: LoginResult = await login({
|
|
email,
|
|
password,
|
|
captcha_token: captchaToken ?? undefined
|
|
});
|
|
|
|
if (result.success) {
|
|
// Rediriger vers le dashboard
|
|
goto('/');
|
|
} else if (result.error) {
|
|
// Gérer les différents types d'erreur
|
|
switch (result.error.type) {
|
|
case 'rate_limited':
|
|
if (result.error.retryAfter) {
|
|
startCountdown(result.error.retryAfter);
|
|
}
|
|
break;
|
|
|
|
case 'captcha_required':
|
|
showCaptcha = true;
|
|
break;
|
|
|
|
case 'captcha_invalid':
|
|
// Réinitialiser le CAPTCHA pour réessayer
|
|
captchaToken = null;
|
|
captchaComponent?.reset();
|
|
break;
|
|
|
|
case 'invalid_credentials':
|
|
// Afficher CAPTCHA si requis pour la prochaine tentative
|
|
if (result.error.captchaRequired) {
|
|
showCaptcha = true;
|
|
}
|
|
// Réinitialiser le token si CAPTCHA déjà affiché
|
|
if (showCaptcha) {
|
|
captchaToken = null;
|
|
captchaComponent?.reset();
|
|
}
|
|
// Appliquer le délai Fibonacci si présent
|
|
if (result.error.delay && result.error.delay > 0) {
|
|
startCountdown(result.error.delay, false);
|
|
}
|
|
break;
|
|
}
|
|
|
|
error = {
|
|
type: result.error.type,
|
|
message: result.error.message
|
|
};
|
|
}
|
|
} finally {
|
|
isSubmitting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Connexion | Classeo</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
rel="stylesheet"
|
|
/>
|
|
</svelte:head>
|
|
|
|
<div class="login-page">
|
|
<div class="login-container">
|
|
<!-- Logo -->
|
|
<div class="logo">
|
|
<span class="logo-icon">📚</span>
|
|
<span class="logo-text">Classeo</span>
|
|
</div>
|
|
|
|
<div class="card">
|
|
{#if justActivated}
|
|
<div class="success-banner">
|
|
<span class="success-icon">✓</span>
|
|
<span>Votre compte a été activé avec succès !</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<h1>Connexion</h1>
|
|
|
|
{#if error}
|
|
<div class="error-banner" class:rate-limited={isRateLimited}>
|
|
{#if isRateLimited}
|
|
<span class="error-icon">🔒</span>
|
|
<div class="error-content">
|
|
<span class="error-message">{error.message}</span>
|
|
<span class="countdown">
|
|
Réessayez dans <strong>{formatCountdown(retryAfterSeconds)}</strong>
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<span class="error-icon">⚠</span>
|
|
<span class="error-message">{error.message}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<form onsubmit={handleSubmit}>
|
|
<div class="form-group">
|
|
<label for="email">Adresse email</label>
|
|
<div class="input-wrapper">
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
required
|
|
placeholder="votre@email.com"
|
|
bind:value={email}
|
|
disabled={isSubmitting || isRateLimited || isDelayed}
|
|
autocomplete="email"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="password">Mot de passe</label>
|
|
<div class="input-wrapper">
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
required
|
|
placeholder="Votre mot de passe"
|
|
bind:value={password}
|
|
disabled={isSubmitting || isRateLimited || isDelayed}
|
|
autocomplete="current-password"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showCaptcha}
|
|
<div class="captcha-section">
|
|
<p class="captcha-label">Vérification de sécurité</p>
|
|
<TurnstileCaptcha
|
|
bind:this={captchaComponent}
|
|
onSuccess={handleCaptchaSuccess}
|
|
onError={handleCaptchaError}
|
|
onExpired={handleCaptchaExpired}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
class="submit-button"
|
|
disabled={isSubmitting || isRateLimited || isDelayed || !email || !password || (showCaptcha && !captchaToken)}
|
|
>
|
|
{#if isSubmitting}
|
|
<span class="spinner"></span>
|
|
Connexion en cours...
|
|
{:else if isRateLimited}
|
|
Compte bloqué ({formatCountdown(retryAfterSeconds)})
|
|
{:else if isDelayed}
|
|
Patientez {delaySeconds}s...
|
|
{:else}
|
|
Se connecter
|
|
{/if}
|
|
</button>
|
|
</form>
|
|
|
|
<div class="links">
|
|
<a href="/mot-de-passe-oublie" class="forgot-password">Mot de passe oublié ?</a>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="footer">Un problème ? Contactez votre établissement.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Design Tokens - Calm Productivity */
|
|
:root {
|
|
--color-calm: hsl(142, 76%, 36%);
|
|
--color-attention: hsl(38, 92%, 50%);
|
|
--color-alert: hsl(0, 72%, 51%);
|
|
--surface-primary: hsl(210, 20%, 98%);
|
|
--surface-elevated: hsl(0, 0%, 100%);
|
|
--text-primary: hsl(222, 47%, 11%);
|
|
--text-secondary: hsl(215, 16%, 47%);
|
|
--text-muted: hsl(215, 13%, 65%);
|
|
--accent-primary: hsl(199, 89%, 48%);
|
|
--border-subtle: hsl(214, 32%, 91%);
|
|
--radius-sm: 8px;
|
|
--radius-md: 12px;
|
|
--radius-lg: 16px;
|
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.login-page {
|
|
min-height: 100vh;
|
|
background: var(--surface-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 24px;
|
|
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
|
}
|
|
|
|
.login-container {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
}
|
|
|
|
/* Logo */
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.logo-icon {
|
|
font-size: 32px;
|
|
}
|
|
|
|
.logo-text {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Card */
|
|
.card {
|
|
background: var(--surface-elevated);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow-elevated);
|
|
padding: 32px;
|
|
}
|
|
|
|
.card h1 {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Success Banner */
|
|
.success-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 97%) 100%);
|
|
border: 1px solid hsl(142, 76%, 85%);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: 24px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-calm);
|
|
}
|
|
|
|
.success-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
background: var(--color-calm);
|
|
color: white;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Error Banner */
|
|
.error-banner {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
background: linear-gradient(135deg, hsl(0, 76%, 95%) 0%, hsl(0, 76%, 97%) 100%);
|
|
border: 1px solid hsl(0, 76%, 85%);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: 24px;
|
|
font-size: 14px;
|
|
color: var(--color-alert);
|
|
}
|
|
|
|
.error-banner.rate-limited {
|
|
background: linear-gradient(135deg, hsl(38, 92%, 95%) 0%, hsl(38, 92%, 97%) 100%);
|
|
border-color: hsl(38, 92%, 75%);
|
|
color: hsl(38, 92%, 30%);
|
|
}
|
|
|
|
.error-icon {
|
|
flex-shrink: 0;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.error-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.error-message {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.countdown {
|
|
font-size: 13px;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.countdown strong {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Form */
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.form-group label {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.input-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.input-wrapper input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
font-size: 15px;
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-elevated);
|
|
color: var(--text-primary);
|
|
transition:
|
|
border-color 0.2s,
|
|
box-shadow 0.2s;
|
|
}
|
|
|
|
.input-wrapper input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-primary);
|
|
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
|
|
}
|
|
|
|
.input-wrapper input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.input-wrapper input:disabled {
|
|
background: var(--surface-primary);
|
|
color: var(--text-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* CAPTCHA Section */
|
|
.captcha-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 16px;
|
|
background: var(--surface-primary);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.captcha-label {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* Submit Button */
|
|
.submit-button {
|
|
width: 100%;
|
|
padding: 14px 24px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: white;
|
|
background: var(--accent-primary);
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
transition:
|
|
background 0.2s,
|
|
transform 0.1s,
|
|
box-shadow 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.submit-button:hover:not(:disabled) {
|
|
background: hsl(199, 89%, 42%);
|
|
box-shadow: var(--shadow-card);
|
|
}
|
|
|
|
.submit-button:active:not(:disabled) {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.submit-button:disabled {
|
|
background: var(--text-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Spinner */
|
|
.spinner {
|
|
width: 18px;
|
|
height: 18px;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: white;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Links */
|
|
.links {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
.forgot-password {
|
|
font-size: 14px;
|
|
color: var(--accent-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.forgot-password:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* Footer */
|
|
.footer {
|
|
text-align: center;
|
|
font-size: 14px;
|
|
color: var(--text-muted);
|
|
margin-top: 24px;
|
|
}
|
|
</style>
|