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:
@@ -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)%'
|
||||
|
||||
10
backend/config/packages/lock.yaml
Normal file
10
backend/config/packages/lock.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
framework:
|
||||
lock: '%env(LOCK_DSN)%'
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
lock: '%env(REDIS_URL)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
lock: '%env(REDIS_URL)%'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache'
|
||||
# Bind refresh tokens cache pool (7-day TTL)
|
||||
Psr\Cache\CacheItemPoolInterface $refreshTokensCache: '@refresh_tokens.cache'
|
||||
# Bind password reset tokens cache pool (1-hour TTL)
|
||||
Psr\Cache\CacheItemPoolInterface $passwordResetTokensCache: '@password_reset_tokens.cache'
|
||||
# Bind named message buses
|
||||
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
||||
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
||||
@@ -79,6 +81,14 @@ services:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
|
||||
App\Administration\Infrastructure\Messaging\SendPasswordResetEmailHandler:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
|
||||
App\Administration\Infrastructure\Messaging\SendPasswordResetConfirmationHandler:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
|
||||
# Audit log handler (uses dedicated audit channel)
|
||||
App\Administration\Infrastructure\Messaging\AuditLoginEventsHandler:
|
||||
arguments:
|
||||
@@ -98,6 +108,16 @@ services:
|
||||
App\Administration\Domain\Repository\RefreshTokenRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Redis\RedisRefreshTokenRepository
|
||||
|
||||
# Password Reset Token Repository
|
||||
App\Administration\Domain\Repository\PasswordResetTokenRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Redis\RedisPasswordResetTokenRepository
|
||||
|
||||
# Password Reset Processor with rate limiters
|
||||
App\Administration\Infrastructure\Api\Processor\RequestPasswordResetProcessor:
|
||||
arguments:
|
||||
$passwordResetByEmailLimiter: '@limiter.password_reset_by_email'
|
||||
$passwordResetByIpLimiter: '@limiter.password_reset_by_ip'
|
||||
|
||||
# Login handlers
|
||||
App\Administration\Infrastructure\Security\LoginSuccessHandler:
|
||||
tags:
|
||||
|
||||
Reference in New Issue
Block a user