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

@@ -19,11 +19,43 @@ framework:
adapter: cache.adapter.filesystem
default_lifetime: 604800 # 7 jours
# Pool dédié aux tokens de reset mot de passe (1 heure TTL)
password_reset_tokens.cache:
adapter: cache.adapter.filesystem
default_lifetime: 3600 # 1 heure
# Pool dédié au rate limiting (15 min TTL)
cache.rate_limiter:
adapter: cache.adapter.filesystem
default_lifetime: 900 # 15 minutes
# Test environment uses Redis to avoid filesystem cache timing issues in E2E tests
# (CLI creates tokens, FrankenPHP must see them immediately)
when@test:
framework:
cache:
pools:
activation_tokens.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 604800
users.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 0
refresh_tokens.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 604800
password_reset_tokens.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 3600
cache.rate_limiter:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 900
when@prod:
framework:
cache:
@@ -44,6 +76,10 @@ when@prod:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 604800 # 7 jours
password_reset_tokens.cache:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 3600 # 1 heure
cache.rate_limiter:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'

View File

@@ -0,0 +1,10 @@
framework:
lock: '%env(LOCK_DSN)%'
when@test:
framework:
lock: '%env(REDIS_URL)%'
when@prod:
framework:
lock: '%env(REDIS_URL)%'

View File

@@ -41,4 +41,7 @@ framework:
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# Password reset events are async to prevent timing attacks (email enumeration)
# and to improve API response time
'App\Administration\Domain\Event\PasswordResetTokenGenerated': async
'App\Administration\Domain\Event\MotDePasseChange': async

View File

@@ -16,3 +16,18 @@ framework:
limit: 20
interval: '15 minutes'
cache_pool: cache.rate_limiter
# Limite les demandes de reset password par email
# Story 1.5 - AC1: Rate limiting 3 demandes/heure
password_reset_by_email:
policy: fixed_window
limit: 3
interval: '1 hour'
cache_pool: cache.rate_limiter
# Limite les demandes de reset password par IP (protection contre énumération)
password_reset_by_ip:
policy: sliding_window
limit: 10
interval: '1 hour'
cache_pool: cache.rate_limiter

View File

@@ -27,7 +27,7 @@ security:
failure_handler: App\Administration\Infrastructure\Security\LoginFailureHandler
provider: app_user_provider
api_public:
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|docs)(/|$)
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$)
stateless: true
security: false
api:
@@ -48,6 +48,8 @@ security:
- { path: ^/api/activate, roles: PUBLIC_ACCESS }
- { path: ^/api/token/refresh, roles: PUBLIC_ACCESS }
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test: