feat: Réinitialisation de mot de passe avec tokens sécurisés

Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):

Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide

Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé

Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet
This commit is contained in:
2026-02-01 23:15:01 +01:00
parent b7354b8448
commit affad287f9
71 changed files with 4829 additions and 222 deletions

View File

@@ -0,0 +1,596 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getApiBaseUrl } from '$lib/api/config';
const token = $derived($page.params.token);
// Form state
let password = $state('');
let confirmPassword = $state('');
let isSubmitting = $state(false);
let isSuccess = $state(false);
let error = $state<{ type: string; message: string } | null>(null);
// Password visibility
let showPassword = $state(false);
let showConfirmPassword = $state(false);
// Password validation
const passwordRequirements = $derived({
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[^A-Za-z0-9]/.test(password)
});
const isPasswordValid = $derived(
passwordRequirements.length &&
passwordRequirements.uppercase &&
passwordRequirements.lowercase &&
passwordRequirements.number &&
passwordRequirements.special
);
const passwordsMatch = $derived(password === confirmPassword && password.length > 0);
const canSubmit = $derived(isPasswordValid && passwordsMatch && !isSubmitting);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
error = null;
isSubmitting = true;
try {
const response = await fetch(`${getApiBaseUrl()}/password/reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token,
password
})
});
if (response.ok) {
isSuccess = true;
// Redirect to login after 3 seconds
globalThis.setTimeout(() => {
goto('/login?password_reset=true');
}, 3000);
} else if (response.status === 400) {
error = {
type: 'invalid_token',
message: 'Ce lien de réinitialisation est invalide.'
};
} else if (response.status === 410) {
const data = await response.json();
error = {
type: 'expired_token',
message: data.detail || 'Ce lien a expiré ou a déjà été utilisé.'
};
} else if (response.status === 422) {
const data = await response.json();
error = {
type: 'validation_error',
message: data.detail || 'Le mot de passe ne respecte pas les critères de sécurité.'
};
} else {
error = {
type: 'server_error',
message: 'Une erreur est survenue. Veuillez réessayer.'
};
}
} catch {
error = {
type: 'network_error',
message: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
};
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Nouveau mot de passe | 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="page">
<div class="container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
<div class="card">
{#if isSuccess}
<!-- Success State -->
<div class="success-state">
<div class="success-icon">
<span></span>
</div>
<h1>Mot de passe modifié</h1>
<p>
Votre mot de passe a été réinitialisé avec succès.<br />
Vous allez être redirigé vers la page de connexion...
</p>
<div class="actions">
<a href="/login" class="link-button">Se connecter maintenant</a>
</div>
</div>
{:else if error?.type === 'invalid_token' || error?.type === 'expired_token'}
<!-- Token Error State -->
<div class="error-state">
<div class="error-state-icon">
<span></span>
</div>
<h1>Lien invalide</h1>
<p>{error.message}</p>
<p class="hint">
Vous pouvez faire une nouvelle demande de réinitialisation.
</p>
<div class="actions">
<a href="/mot-de-passe-oublie" class="link-button">Nouvelle demande</a>
</div>
</div>
{:else}
<!-- Form State -->
<h1>Nouveau mot de passe</h1>
<p class="description">Choisissez un nouveau mot de passe sécurisé pour votre compte.</p>
{#if error}
<div class="error-banner">
<span class="error-icon-inline"></span>
<span class="error-message">{error.message}</span>
</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="form-group">
<label for="password">Nouveau mot de passe</label>
<div class="input-wrapper">
<input
id="password"
type={showPassword ? 'text' : 'password'}
required
placeholder="Votre nouveau mot de passe"
bind:value={password}
disabled={isSubmitting}
autocomplete="new-password"
/>
<button
type="button"
class="toggle-visibility"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
>
{showPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
<!-- Password requirements -->
<ul class="requirements">
<li class:valid={passwordRequirements.length}>Au moins 8 caractères</li>
<li class:valid={passwordRequirements.uppercase}>Une majuscule</li>
<li class:valid={passwordRequirements.lowercase}>Une minuscule</li>
<li class:valid={passwordRequirements.number}>Un chiffre</li>
<li class:valid={passwordRequirements.special}>Un caractère spécial</li>
</ul>
</div>
<div class="form-group">
<label for="confirmPassword">Confirmer le mot de passe</label>
<div class="input-wrapper">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
required
placeholder="Confirmer votre mot de passe"
bind:value={confirmPassword}
disabled={isSubmitting}
autocomplete="new-password"
/>
<button
type="button"
class="toggle-visibility"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
aria-label={showConfirmPassword ? 'Masquer' : 'Afficher'}
>
{showConfirmPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
{#if confirmPassword && !passwordsMatch}
<p class="field-error">Les mots de passe ne correspondent pas</p>
{/if}
{#if passwordsMatch && confirmPassword}
<p class="field-success">Les mots de passe correspondent</p>
{/if}
</div>
<button type="submit" class="submit-button" disabled={!canSubmit}>
{#if isSubmitting}
<span class="spinner"></span>
Modification en cours...
{:else}
Réinitialiser le mot de passe
{/if}
</button>
</form>
{/if}
</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);
}
.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;
}
.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: 12px;
text-align: center;
}
.description {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 24px;
line-height: 1.5;
}
/* Success State */
.success-state {
text-align: center;
}
.success-state .success-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 90%) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
font-size: 28px;
color: var(--color-calm);
}
.success-state h1 {
margin-bottom: 16px;
}
.success-state p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 24px;
}
/* Error State */
.error-state {
text-align: center;
}
.error-state-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, hsl(38, 92%, 95%) 0%, hsl(38, 92%, 90%) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
font-size: 28px;
}
.error-state h1 {
margin-bottom: 16px;
}
.error-state p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 8px;
}
.error-state .hint {
color: var(--text-muted);
margin-bottom: 24px;
}
.actions {
margin-top: 24px;
}
.link-button {
display: inline-block;
padding: 12px 24px;
background: var(--accent-primary);
color: white;
text-decoration: none;
border-radius: var(--radius-sm);
font-weight: 500;
font-size: 15px;
transition: background 0.2s;
}
.link-button:hover {
background: hsl(199, 89%, 42%);
}
/* Error Banner */
.error-banner {
display: flex;
align-items: center;
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-icon-inline {
flex-shrink: 0;
font-size: 18px;
}
.error-message {
font-weight: 500;
}
/* 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 48px 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;
}
.toggle-visibility {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 16px;
opacity: 0.6;
transition: opacity 0.2s;
}
.toggle-visibility:hover {
opacity: 1;
}
/* Password requirements */
.requirements {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.requirements li {
font-size: 12px;
color: var(--text-muted);
padding-left: 18px;
position: relative;
}
.requirements li::before {
content: '○';
position: absolute;
left: 0;
color: var(--text-muted);
}
.requirements li.valid {
color: var(--color-calm);
}
.requirements li.valid::before {
content: '✓';
color: var(--color-calm);
}
/* Field messages */
.field-error {
font-size: 12px;
color: var(--color-alert);
margin: 4px 0 0 0;
}
.field-success {
font-size: 12px;
color: var(--color-calm);
margin: 4px 0 0 0;
}
/* 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;
margin-top: 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);
}
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>