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:
@@ -1,7 +1,162 @@
|
||||
<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>
|
||||
@@ -32,7 +187,24 @@
|
||||
|
||||
<h1>Connexion</h1>
|
||||
|
||||
<form>
|
||||
{#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">
|
||||
@@ -41,6 +213,9 @@
|
||||
type="email"
|
||||
required
|
||||
placeholder="votre@email.com"
|
||||
bind:value={email}
|
||||
disabled={isSubmitting || isRateLimited || isDelayed}
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,18 +228,46 @@
|
||||
type="password"
|
||||
required
|
||||
placeholder="Votre mot de passe"
|
||||
bind:value={password}
|
||||
disabled={isSubmitting || isRateLimited || isDelayed}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button">
|
||||
Se connecter
|
||||
{#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>
|
||||
|
||||
<p class="help-text">
|
||||
La connexion sera disponible prochainement.
|
||||
</p>
|
||||
<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>
|
||||
@@ -170,6 +373,50 @@
|
||||
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;
|
||||
@@ -201,7 +448,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
@@ -214,6 +463,30 @@
|
||||
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%;
|
||||
@@ -225,28 +498,64 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.1s,
|
||||
box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: hsl(199, 89%, 42%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Help Text */
|
||||
.help-text {
|
||||
.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;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user