Files
Classeo/frontend/src/routes/login/+page.svelte
Mathias STRASSER b9d9f48305 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
2026-02-01 14:43:12 +01:00

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>