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

@@ -90,10 +90,12 @@ test.describe('Account Activation Flow', () => {
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
await expect(digitItem).not.toHaveClass(/valid/);
// Valid password should show all checkmarks
// Valid password (without special char) should show 4/5 checkmarks
// Requirements: minLength, uppercase, lowercase, digit, specialChar
// "Abcdefgh1" satisfies: minLength(9>=8), uppercase(A), lowercase(bcdefgh), digit(1)
await passwordInput.fill('Abcdefgh1');
const validItems = page.locator('.password-requirements li.valid');
await expect(validItems).toHaveCount(3);
await expect(validItems).toHaveCount(4);
});
test('requires password confirmation to match', async ({ page }) => {
@@ -106,13 +108,13 @@ test.describe('Account Activation Flow', () => {
const passwordInput = page.locator('#password');
const confirmInput = page.locator('#passwordConfirmation');
await passwordInput.fill('SecurePass123');
await confirmInput.fill('DifferentPass123');
await passwordInput.fill('SecurePass123!');
await confirmInput.fill('DifferentPass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
// Fix confirmation
await confirmInput.fill('SecurePass123');
await confirmInput.fill('SecurePass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
});
@@ -128,9 +130,9 @@ test.describe('Account Activation Flow', () => {
// Initially disabled
await expect(submitButton).toBeDisabled();
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Should now be enabled
await expect(submitButton).toBeEnabled();
@@ -194,9 +196,9 @@ test.describe('Account Activation Flow', () => {
// Button should be disabled initially (no password yet)
await expect(submitButton).toBeDisabled();
// Fill valid password
await page.locator('#password').fill('SecurePass123');
await page.locator('#passwordConfirmation').fill('SecurePass123');
// Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Wait for validation to complete - button should now be enabled
await expect(submitButton).toBeEnabled({ timeout: 2000 });