From affad287f961c8470ab148d4551f62d001046cb8 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 1 Feb 2026 23:15:01 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20R=C3=A9initialisation=20de=20mot=20de?= =?UTF-8?q?=20passe=20avec=20tokens=20s=C3=A9curis=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 43 +- backend/.env | 6 + backend/.env.test | 1 + backend/composer.json | 1 + backend/composer.lock | 84 ++- backend/config/packages/cache.yaml | 36 ++ backend/config/packages/lock.yaml | 10 + backend/config/packages/messenger.yaml | 5 +- backend/config/packages/rate_limiter.yaml | 15 + backend/config/packages/security.yaml | 4 +- backend/config/services.yaml | 20 + .../RequestPasswordResetCommand.php | 25 + .../RequestPasswordResetHandler.php | 117 ++++ .../ResetPassword/ResetPasswordCommand.php | 17 + .../ResetPassword/ResetPasswordHandler.php | 86 +++ .../Service/RefreshTokenManager.php | 68 +- .../Domain/Event/ConnexionEchouee.php | 10 +- .../Domain/Event/MotDePasseChange.php | 42 ++ .../Event/PasswordResetTokenGenerated.php | 37 ++ .../Domain/Event/PasswordResetTokenUsed.php | 33 + ...PasswordResetTokenAlreadyUsedException.php | 21 + .../PasswordResetTokenExpiredException.php | 21 + .../PasswordResetTokenNotFoundException.php | 29 + .../TokenConsumptionInProgressException.php | 25 + .../Model/ActivationToken/ActivationToken.php | 2 +- .../PasswordResetToken/PasswordResetToken.php | 140 ++++ .../PasswordResetTokenId.php | 11 + .../Model/RefreshToken/RefreshToken.php | 82 +-- .../Domain/Model/User/StatutCompte.php | 16 +- .../Administration/Domain/Model/User/User.php | 46 +- .../PasswordResetTokenRepository.php | 63 ++ .../Repository/RefreshTokenRepository.php | 6 + .../Api/Controller/LogoutController.php | 18 +- .../Api/Processor/RefreshTokenProcessor.php | 61 +- .../RequestPasswordResetProcessor.php | 141 +++++ .../Api/Processor/ResetPasswordProcessor.php | 65 ++ .../Api/Resource/ActivateAccountInput.php | 8 + .../Api/Resource/RefreshTokenInput.php | 10 +- .../Resource/RequestPasswordResetInput.php | 34 + .../Resource/RequestPasswordResetOutput.php | 18 + .../Api/Resource/ResetPasswordInput.php | 54 ++ .../Api/Resource/ResetPasswordOutput.php | 20 + .../CreateTestPasswordResetTokenCommand.php | 171 +++++ .../SendPasswordResetConfirmationHandler.php | 46 ++ .../SendPasswordResetEmailHandler.php | 47 ++ .../InMemoryPasswordResetTokenRepository.php | 140 ++++ .../InMemoryRefreshTokenRepository.php | 116 ++++ .../RedisPasswordResetTokenRepository.php | 239 +++++++ .../Redis/RedisRefreshTokenRepository.php | 102 ++- .../Security/DatabaseUserProvider.php | 14 +- .../Security/JwtPayloadEnricher.php | 16 +- .../Security/LoginFailureHandler.php | 18 +- .../Security/LoginSuccessHandler.php | 16 +- .../Infrastructure/Security/SecurityUser.php | 12 +- backend/src/Shared/Domain/CorrelationId.php | 2 +- backend/src/Shared/Domain/EntityId.php | 2 +- .../Tenant/TenantMiddleware.php | 2 + backend/symfony.lock | 12 + .../templates/emails/password_reset.html.twig | 113 ++++ .../password_reset_confirmation.html.twig | 130 ++++ .../Api/PasswordResetEndpointsTest.php | 106 ++++ .../RequestPasswordResetHandlerTest.php | 252 ++++++++ .../ResetPasswordHandlerTest.php | 320 ++++++++++ .../ActivationToken/ActivationTokenTest.php | 4 +- .../PasswordResetTokenTest.php | 293 +++++++++ .../Unit/Shared/Domain/AggregateRootTest.php | 2 +- frontend/e2e/activation.spec.ts | 24 +- frontend/e2e/password-reset.spec.ts | 271 ++++++++ .../src/routes/activate/[token]/+page.svelte | 18 +- .../routes/mot-de-passe-oublie/+page.svelte | 416 ++++++++++++ .../reset-password/[token]/+page.svelte | 596 ++++++++++++++++++ 71 files changed, 4829 insertions(+), 222 deletions(-) create mode 100644 backend/config/packages/lock.yaml create mode 100644 backend/src/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetCommand.php create mode 100644 backend/src/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetHandler.php create mode 100644 backend/src/Administration/Application/Command/ResetPassword/ResetPasswordCommand.php create mode 100644 backend/src/Administration/Application/Command/ResetPassword/ResetPasswordHandler.php create mode 100644 backend/src/Administration/Domain/Event/MotDePasseChange.php create mode 100644 backend/src/Administration/Domain/Event/PasswordResetTokenGenerated.php create mode 100644 backend/src/Administration/Domain/Event/PasswordResetTokenUsed.php create mode 100644 backend/src/Administration/Domain/Exception/PasswordResetTokenAlreadyUsedException.php create mode 100644 backend/src/Administration/Domain/Exception/PasswordResetTokenExpiredException.php create mode 100644 backend/src/Administration/Domain/Exception/PasswordResetTokenNotFoundException.php create mode 100644 backend/src/Administration/Domain/Exception/TokenConsumptionInProgressException.php create mode 100644 backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetToken.php create mode 100644 backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenId.php create mode 100644 backend/src/Administration/Domain/Repository/PasswordResetTokenRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ResetPasswordProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/RequestPasswordResetInput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/RequestPasswordResetOutput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ResetPasswordInput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ResetPasswordOutput.php create mode 100644 backend/src/Administration/Infrastructure/Console/CreateTestPasswordResetTokenCommand.php create mode 100644 backend/src/Administration/Infrastructure/Messaging/SendPasswordResetConfirmationHandler.php create mode 100644 backend/src/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandler.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPasswordResetTokenRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Redis/RedisPasswordResetTokenRepository.php create mode 100644 backend/templates/emails/password_reset.html.twig create mode 100644 backend/templates/emails/password_reset_confirmation.html.twig create mode 100644 backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ResetPassword/ResetPasswordHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenTest.php create mode 100644 frontend/e2e/password-reset.spec.ts create mode 100644 frontend/src/routes/mot-de-passe-oublie/+page.svelte create mode 100644 frontend/src/routes/reset-password/[token]/+page.svelte diff --git a/Makefile b/Makefile index e076360..977a3de 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ cs-check: ## Vérifier le code style PHP sans corriger .PHONY: test-php test-php: ## Lancer les tests PHPUnit - docker compose exec php composer test + docker compose exec -e APP_ENV=test php composer test .PHONY: warmup warmup: ## Préchauffer le cache Symfony @@ -116,6 +116,47 @@ test: test-php test-js ## Lancer tous les tests (PHPUnit + Vitest) .PHONY: check check: phpstan cs-check lint check-types ## Lancer tous les linters et checks +.PHONY: ci +ci: ## Lancer TOUS les tests et checks (comme en CI) + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Code Style PHP (PHP-CS-Fixer)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) cs-check + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Analyse statique PHP (PHPStan level 9)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) phpstan + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Tests d'architecture (PHPat)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) arch + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Tests PHP (PHPUnit)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) test-php + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Lint Frontend (ESLint)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) lint + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Types Frontend (svelte-check)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) check-types + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " Tests Frontend (Vitest)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @$(MAKE) test-js + @echo "" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " ✅ Tous les checks sont passés !" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + # ============================================================================= # Scripts # ============================================================================= diff --git a/backend/.env b/backend/.env index 54c1a64..63d1096 100644 --- a/backend/.env +++ b/backend/.env @@ -83,3 +83,9 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA # Fail open on API errors: true=allow through (dev), false=block (prod) TURNSTILE_FAIL_OPEN=true ###< cloudflare/turnstile ### + +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/backend/.env.test b/backend/.env.test index 64bd111..0f3fd0c 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -1,3 +1,4 @@ # define your env variables for the test env here +APP_ENV=test KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' diff --git a/backend/composer.json b/backend/composer.json index bc56d20..0cd7f15 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -26,6 +26,7 @@ "symfony/flex": "^2", "symfony/framework-bundle": "^8.0", "symfony/http-client": "8.0.*", + "symfony/lock": "8.0.*", "symfony/mailer": "8.0.*", "symfony/messenger": "^8.0", "symfony/monolog-bundle": "^4.0", diff --git a/backend/composer.lock b/backend/composer.lock index 587285e..0037045 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "07fe67e8d6e7bdfbca22ab4e7c6a65c2", + "content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0", "packages": [ { "name": "api-platform/core", @@ -4189,6 +4189,88 @@ ], "time": "2026-01-28T10:46:31+00:00" }, + { + "name": "symfony/lock", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "37f0408f4dee212e922dea8f8eabd693f0e10e00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/37f0408f4dee212e922dea8f8eabd693f0e10e00", + "reference": "37f0408f4dee212e922dea8f8eabd693f0e10e00", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, { "name": "symfony/mailer", "version": "v8.0.4", diff --git a/backend/config/packages/cache.yaml b/backend/config/packages/cache.yaml index effb292..44b73ee 100644 --- a/backend/config/packages/cache.yaml +++ b/backend/config/packages/cache.yaml @@ -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)%' diff --git a/backend/config/packages/lock.yaml b/backend/config/packages/lock.yaml new file mode 100644 index 0000000..a49f00f --- /dev/null +++ b/backend/config/packages/lock.yaml @@ -0,0 +1,10 @@ +framework: + lock: '%env(LOCK_DSN)%' + +when@test: + framework: + lock: '%env(REDIS_URL)%' + +when@prod: + framework: + lock: '%env(REDIS_URL)%' diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 64b3192..722366e 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -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 diff --git a/backend/config/packages/rate_limiter.yaml b/backend/config/packages/rate_limiter.yaml index 6145051..cd31682 100644 --- a/backend/config/packages/rate_limiter.yaml +++ b/backend/config/packages/rate_limiter.yaml @@ -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 diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index 3d50910..c95f33d 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -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: diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 48dfc3b..c0101bd 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -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: diff --git a/backend/src/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetCommand.php b/backend/src/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetCommand.php new file mode 100644 index 0000000..ca55c9c --- /dev/null +++ b/backend/src/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetCommand.php @@ -0,0 +1,25 @@ +email); + } catch (EmailInvalideException) { + // Perform dummy work to match timing of valid email path + $this->simulateTokenLookup(); + + return; + } + + // Try to find user by email for this tenant + $user = $this->userRepository->findByEmail($email, $command->tenantId); + + // If user doesn't exist, silently return (no email enumeration) + if ($user === null) { + // Perform dummy work to match timing of valid email path + // This prevents timing attacks that could reveal email existence + $this->simulateTokenLookup(); + + return; + } + + // Check if a valid token already exists for this user + $existingToken = $this->tokenRepository->findValidTokenForUser((string) $user->id); + + if ($existingToken !== null) { + // Reuse existing token - manually dispatch event to resend email + // (reconstituted tokens don't have domain events) + $this->eventBus->dispatch(new PasswordResetTokenGenerated( + tokenId: $existingToken->id, + tokenValue: $existingToken->tokenValue, + userId: $existingToken->userId, + email: $existingToken->email, + tenantId: $existingToken->tenantId, + occurredOn: $this->clock->now(), + )); + + return; + } + + // Generate new reset token + $now = $this->clock->now(); + $token = PasswordResetToken::generate( + userId: (string) $user->id, + email: $command->email, + tenantId: $command->tenantId, + createdAt: $now, + ); + + // Persist token + $this->tokenRepository->save($token); + + // Dispatch domain events (triggers email sending) + foreach ($token->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } + + /** + * Performs dummy work to match timing of valid email path. + * + * This prevents timing attacks that could reveal email existence by ensuring + * similar processing time regardless of whether the email exists or not. + */ + private function simulateTokenLookup(): void + { + // Generate a fake userId to query (will not exist) + // This matches the timing of findValidTokenForUser() call + $fakeUserId = bin2hex(random_bytes(16)); + $this->tokenRepository->findValidTokenForUser($fakeUserId); + } +} diff --git a/backend/src/Administration/Application/Command/ResetPassword/ResetPasswordCommand.php b/backend/src/Administration/Application/Command/ResetPassword/ResetPasswordCommand.php new file mode 100644 index 0000000..2cbf105 --- /dev/null +++ b/backend/src/Administration/Application/Command/ResetPassword/ResetPasswordCommand.php @@ -0,0 +1,17 @@ +clock->now(); + + // Atomically consume token (validates + marks as used in one operation) + // This prevents race conditions where two concurrent requests could both + // pass validation before either marks the token as used + $token = $this->tokenRepository->consumeIfValid($command->token, $now); + + // Find user + $userId = UserId::fromString($token->userId); + $user = $this->userRepository->get($userId); + + // Hash and update password + $hashedPassword = $this->passwordHasher->hash($command->newPassword); + $user->changerMotDePasse($hashedPassword, $now); + + // Save user changes + $this->userRepository->save($user); + + // Invalidate all user sessions (force re-authentication on all devices) + $this->refreshTokenRepository->invalidateAllForUser($userId); + + // Note: We intentionally keep the used token in storage (until TTL expiry) + // This allows distinguishing "already used" (410) from "invalid" (400) + // when the same link is submitted again + + // Dispatch domain events from user (MotDePasseChange) + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Dispatch domain events from token (PasswordResetTokenUsed) + foreach ($token->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } +} diff --git a/backend/src/Administration/Application/Service/RefreshTokenManager.php b/backend/src/Administration/Application/Service/RefreshTokenManager.php index 0f75f7c..c7c5aa5 100644 --- a/backend/src/Administration/Application/Service/RefreshTokenManager.php +++ b/backend/src/Administration/Application/Service/RefreshTokenManager.php @@ -16,19 +16,19 @@ use App\Shared\Domain\Tenant\TenantId; use InvalidArgumentException; /** - * Gère le cycle de vie des refresh tokens. + * Manages the lifecycle of refresh tokens. * - * Responsabilités : - * - Création de tokens pour nouvelles sessions - * - Rotation des tokens avec détection de replay - * - Invalidation de familles de tokens compromises + * Responsibilities: + * - Token creation for new sessions + * - Token rotation with replay detection + * - Invalidation of compromised token families * - * @see Story 1.4 - Connexion utilisateur + * @see Story 1.4 - User login */ final readonly class RefreshTokenManager { - private const int WEB_TTL_SECONDS = 86400; // 1 jour pour web - private const int MOBILE_TTL_SECONDS = 604800; // 7 jours pour mobile + private const int WEB_TTL_SECONDS = 86400; // 1 day for web + private const int MOBILE_TTL_SECONDS = 604800; // 7 days for mobile public function __construct( private RefreshTokenRepository $repository, @@ -37,7 +37,7 @@ final readonly class RefreshTokenManager } /** - * Crée un nouveau refresh token pour une session. + * Creates a new refresh token for a session. */ public function create( UserId $userId, @@ -47,7 +47,7 @@ final readonly class RefreshTokenManager ): RefreshToken { $ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS; - // Ajouter un jitter de ±10% pour éviter les expirations simultanées + // Add ±10% jitter to avoid simultaneous expirations $jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100)); $ttl += $jitter; @@ -65,13 +65,13 @@ final readonly class RefreshTokenManager } /** - * Valide et rafraîchit un token. + * Validates and refreshes a token. * - * @throws TokenReplayDetectedException si un replay attack est détecté - * @throws TokenAlreadyRotatedException si le token a déjà été rotaté mais est en grace period - * @throws InvalidArgumentException si le token est invalide ou expiré + * @throws TokenReplayDetectedException if a replay attack is detected + * @throws TokenAlreadyRotatedException if the token has already been rotated but is in grace period + * @throws InvalidArgumentException if the token is invalid or expired * - * @return RefreshToken le nouveau token après rotation + * @return RefreshToken the new token after rotation */ public function refresh( string $tokenString, @@ -85,53 +85,53 @@ final readonly class RefreshTokenManager throw new InvalidArgumentException('Token not found'); } - // Vérifier l'expiration + // Check expiration if ($token->isExpired($now)) { $this->repository->delete($tokenId); throw new InvalidArgumentException('Token expired'); } - // Vérifier le device fingerprint + // Check device fingerprint if (!$token->matchesDevice($deviceFingerprint)) { - // Potentielle tentative de vol de token - invalider toute la famille + // Potential token theft attempt - invalidate the entire family $this->repository->invalidateFamily($token->familyId); throw new TokenReplayDetectedException($token->familyId); } - // Détecter les replay attacks + // Detect replay attacks if ($token->isRotated) { - // Token déjà utilisé ! + // Token already used! if ($token->isInGracePeriod($now)) { - // Dans la grace period - probablement une race condition légitime - // On laisse passer mais on ne génère pas de nouveau token - // Le client devrait utiliser le token le plus récent - // Exception dédiée pour ne PAS supprimer le cookie lors d'une race condition légitime + // In grace period - probably a legitimate race condition + // We let it pass but don't generate a new token + // The client should use the most recent token + // Dedicated exception to NOT delete the cookie during a legitimate race condition throw new TokenAlreadyRotatedException(); } - // Replay attack confirmé - invalider toute la famille + // Confirmed replay attack - invalidate the entire family $this->repository->invalidateFamily($token->familyId); throw new TokenReplayDetectedException($token->familyId); } - // Rotation du token (préserve le TTL original) + // Rotate the token (preserves original TTL) [$newToken, $rotatedOldToken] = $token->rotate($now); - // Sauvegarder le nouveau token EN PREMIER - // Important: sauvegarder le nouveau token EN PREMIER pour que l'index famille garde le bon TTL + // Save the new token FIRST + // Important: save the new token FIRST so the family index keeps the correct TTL $this->repository->save($newToken); - // Mettre à jour l'ancien token comme rotaté (pour grace period) + // Update the old token as rotated (for grace period) $this->repository->save($rotatedOldToken); return $newToken; } /** - * Révoque un token (déconnexion). + * Revokes a token (logout). */ public function revoke(string $tokenString): void { @@ -140,18 +140,18 @@ final readonly class RefreshTokenManager $token = $this->repository->find($tokenId); if ($token !== null) { - // Invalider toute la famille pour une déconnexion complète + // Invalidate the entire family for a complete logout $this->repository->invalidateFamily($token->familyId); } } catch (InvalidArgumentException) { - // Token invalide, rien à faire + // Invalid token, nothing to do } } /** - * Invalide toute une famille de tokens. + * Invalidates an entire token family. * - * Utilisé quand un utilisateur est suspendu/archivé pour révoquer toutes ses sessions. + * Used when a user is suspended/archived to revoke all their sessions. */ public function invalidateFamily(TokenFamilyId $familyId): void { diff --git a/backend/src/Administration/Domain/Event/ConnexionEchouee.php b/backend/src/Administration/Domain/Event/ConnexionEchouee.php index ed7e34a..0eaf991 100644 --- a/backend/src/Administration/Domain/Event/ConnexionEchouee.php +++ b/backend/src/Administration/Domain/Event/ConnexionEchouee.php @@ -10,12 +10,12 @@ use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; /** - * Événement émis lors d'une tentative de connexion échouée. + * Event emitted when a login attempt fails. * - * Note: L'email est enregistré pour le tracking mais ne révèle pas - * si le compte existe (même message d'erreur dans tous les cas). + * Note: The email is recorded for tracking but does not reveal + * whether the account exists (same error message in all cases). * - * @see Story 1.4 - AC2: Gestion erreurs d'authentification + * @see Story 1.4 - AC2: Authentication error handling */ final readonly class ConnexionEchouee implements DomainEvent { @@ -35,7 +35,7 @@ final readonly class ConnexionEchouee implements DomainEvent public function aggregateId(): UuidInterface { - // Pas d'aggregate associé, utiliser un UUID basé sur l'email + // No associated aggregate, use a UUID based on the email return Uuid::uuid5( Uuid::NAMESPACE_DNS, 'login_attempt:' . $this->email, diff --git a/backend/src/Administration/Domain/Event/MotDePasseChange.php b/backend/src/Administration/Domain/Event/MotDePasseChange.php new file mode 100644 index 0000000..918488a --- /dev/null +++ b/backend/src/Administration/Domain/Event/MotDePasseChange.php @@ -0,0 +1,42 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return Uuid::fromString($this->userId); + } +} diff --git a/backend/src/Administration/Domain/Event/PasswordResetTokenGenerated.php b/backend/src/Administration/Domain/Event/PasswordResetTokenGenerated.php new file mode 100644 index 0000000..ef827d6 --- /dev/null +++ b/backend/src/Administration/Domain/Event/PasswordResetTokenGenerated.php @@ -0,0 +1,37 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->tokenId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/PasswordResetTokenUsed.php b/backend/src/Administration/Domain/Event/PasswordResetTokenUsed.php new file mode 100644 index 0000000..2e461ba --- /dev/null +++ b/backend/src/Administration/Domain/Event/PasswordResetTokenUsed.php @@ -0,0 +1,33 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->tokenId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/PasswordResetTokenAlreadyUsedException.php b/backend/src/Administration/Domain/Exception/PasswordResetTokenAlreadyUsedException.php new file mode 100644 index 0000000..cbb4122 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/PasswordResetTokenAlreadyUsedException.php @@ -0,0 +1,21 @@ +toString(), + tokenValue: Uuid::uuid7()->toString(), userId: $userId, email: $email, tenantId: $tenantId, diff --git a/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetToken.php b/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetToken.php new file mode 100644 index 0000000..ca0315c --- /dev/null +++ b/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetToken.php @@ -0,0 +1,140 @@ +toString(), + userId: $userId, + email: $email, + tenantId: $tenantId, + createdAt: $createdAt, + expiresAt: $createdAt->modify(sprintf('+%d hour', self::EXPIRATION_HOURS)), + ); + + $token->recordEvent(new PasswordResetTokenGenerated( + tokenId: $token->id, + tokenValue: $token->tokenValue, + userId: $userId, + email: $email, + tenantId: $tenantId, + occurredOn: $createdAt, + )); + + return $token; + } + + /** + * Reconstitute a PasswordResetToken from storage. + * Does NOT record domain events (this is not a new creation). + * + * @internal For use by Infrastructure layer only + */ + public static function reconstitute( + PasswordResetTokenId $id, + string $tokenValue, + string $userId, + string $email, + TenantId $tenantId, + DateTimeImmutable $createdAt, + DateTimeImmutable $expiresAt, + ?DateTimeImmutable $usedAt, + ): self { + $token = new self( + id: $id, + tokenValue: $tokenValue, + userId: $userId, + email: $email, + tenantId: $tenantId, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $token->usedAt = $usedAt; + + return $token; + } + + public function isExpired(DateTimeImmutable $at): bool + { + return $at >= $this->expiresAt; + } + + public function isUsed(): bool + { + return $this->usedAt !== null; + } + + /** + * Validate that the token can be used (not expired, not already used). + * Does NOT mark the token as used - use use() for that after successful password reset. + * + * @throws PasswordResetTokenAlreadyUsedException if token was already used + * @throws PasswordResetTokenExpiredException if token is expired + */ + public function validateForUse(DateTimeImmutable $at): void + { + if ($this->isUsed()) { + throw PasswordResetTokenAlreadyUsedException::forToken($this->id); + } + + if ($this->isExpired($at)) { + throw PasswordResetTokenExpiredException::forToken($this->id); + } + } + + /** + * Mark the token as used. Should only be called after successful password reset. + * + * @throws PasswordResetTokenAlreadyUsedException if token was already used + * @throws PasswordResetTokenExpiredException if token is expired + */ + public function use(DateTimeImmutable $at): void + { + $this->validateForUse($at); + + $this->usedAt = $at; + + $this->recordEvent(new PasswordResetTokenUsed( + tokenId: $this->id, + userId: $this->userId, + occurredOn: $at, + )); + } +} diff --git a/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenId.php b/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenId.php new file mode 100644 index 0000000..8bdceb5 --- /dev/null +++ b/backend/src/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenId.php @@ -0,0 +1,11 @@ +expiresAt->getTimestamp() - $this->issuedAt->getTimestamp(); $newToken = new self( id: RefreshTokenId::generate(), - familyId: $this->familyId, // Même famille + familyId: $this->familyId, // Same family userId: $this->userId, tenantId: $this->tenantId, deviceFingerprint: $this->deviceFingerprint, issuedAt: $at, expiresAt: $at->modify("+{$originalTtlSeconds} seconds"), - rotatedFrom: $this->id, // Traçabilité + rotatedFrom: $this->id, // Traceability isRotated: false, rotatedAt: null, ); @@ -120,14 +120,14 @@ final readonly class RefreshToken expiresAt: $this->expiresAt, rotatedFrom: $this->rotatedFrom, isRotated: true, - rotatedAt: $at, // Pour la grace period + rotatedAt: $at, // For the grace period ); return [$newToken, $rotatedOldToken]; } /** - * Vérifie si le token est expiré. + * Checks if the token is expired. */ public function isExpired(DateTimeImmutable $at): bool { @@ -135,11 +135,11 @@ final readonly class RefreshToken } /** - * Vérifie si le token est dans la période de grâce après rotation. + * Checks if the token is in the grace period after rotation. * - * La grace period permet de gérer les race conditions quand plusieurs onglets - * tentent de rafraîchir le token simultanément. Elle est basée sur le moment - * de la rotation, pas sur l'émission initiale du token. + * The grace period handles race conditions when multiple tabs attempt to + * refresh the token simultaneously. It is based on the rotation time, + * not the initial token issuance. */ public function isInGracePeriod(DateTimeImmutable $at): bool { @@ -153,7 +153,7 @@ final readonly class RefreshToken } /** - * Vérifie si l'empreinte du device correspond. + * Checks if the device fingerprint matches. */ public function matchesDevice(DeviceFingerprint $fingerprint): bool { @@ -161,9 +161,9 @@ final readonly class RefreshToken } /** - * Génère le token string à stocker dans le cookie. + * Generates the token string to store in the cookie. * - * Le format est opaque pour le client : base64(id) + * The format is opaque to the client: base64(id) */ public function toTokenString(): string { @@ -171,7 +171,7 @@ final readonly class RefreshToken } /** - * Extrait l'ID depuis un token string. + * Extracts the ID from a token string. */ public static function extractIdFromTokenString(string $tokenString): RefreshTokenId { @@ -185,9 +185,9 @@ final readonly class RefreshToken } /** - * Reconstitue un RefreshToken depuis le stockage. + * Reconstitutes a RefreshToken from storage. * - * @internal Pour usage par l'Infrastructure uniquement + * @internal For Infrastructure use only */ public static function reconstitute( RefreshTokenId $id, diff --git a/backend/src/Administration/Domain/Model/User/StatutCompte.php b/backend/src/Administration/Domain/Model/User/StatutCompte.php index 77d6930..5fb83ef 100644 --- a/backend/src/Administration/Domain/Model/User/StatutCompte.php +++ b/backend/src/Administration/Domain/Model/User/StatutCompte.php @@ -5,18 +5,18 @@ declare(strict_types=1); namespace App\Administration\Domain\Model\User; /** - * Enum représentant le statut d'activation d'un compte utilisateur. + * Enum representing the activation status of a user account. */ enum StatutCompte: string { - case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation - case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental - case ACTIF = 'active'; // Compte activé et utilisable - case SUSPENDU = 'suspended'; // Compte temporairement désactivé - case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité) + case EN_ATTENTE = 'pending'; // Account created, awaiting activation + case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent + case ACTIF = 'active'; // Account activated and usable + case SUSPENDU = 'suspended'; // Account temporarily disabled + case ARCHIVE = 'archived'; // Account archived (end of schooling) /** - * Vérifie si l'utilisateur peut se connecter avec ce statut. + * Checks if the user can log in with this status. */ public function peutSeConnecter(): bool { @@ -24,7 +24,7 @@ enum StatutCompte: string } /** - * Vérifie si l'utilisateur peut activer son compte. + * Checks if the user can activate their account. */ public function peutActiver(): bool { diff --git a/backend/src/Administration/Domain/Model/User/User.php b/backend/src/Administration/Domain/Model/User/User.php index 772879c..b5dd8a8 100644 --- a/backend/src/Administration/Domain/Model/User/User.php +++ b/backend/src/Administration/Domain/Model/User/User.php @@ -6,6 +6,7 @@ namespace App\Administration\Domain\Model\User; use App\Administration\Domain\Event\CompteActive; use App\Administration\Domain\Event\CompteCreated; +use App\Administration\Domain\Event\MotDePasseChange; use App\Administration\Domain\Exception\CompteNonActivableException; use App\Administration\Domain\Model\ConsentementParental\ConsentementParental; use App\Administration\Domain\Policy\ConsentementParentalPolicy; @@ -14,11 +15,11 @@ use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; /** - * Aggregate Root représentant un utilisateur dans Classeo. + * Aggregate Root representing a user in Classeo. * - * Un utilisateur appartient à un établissement (tenant) et possède un rôle. - * Le cycle de vie du compte passe par plusieurs statuts : création → activation. - * Les mineurs (< 15 ans) nécessitent un consentement parental avant activation. + * A user belongs to a school (tenant) and has a role. + * The account lifecycle goes through multiple statuses: creation → activation. + * Minors (< 15 years) require parental consent before activation. */ final class User extends AggregateRoot { @@ -39,7 +40,7 @@ final class User extends AggregateRoot } /** - * Crée un nouveau compte utilisateur en attente d'activation. + * Creates a new user account awaiting activation. */ public static function creer( Email $email, @@ -72,9 +73,9 @@ final class User extends AggregateRoot } /** - * Active le compte avec le mot de passe hashé. + * Activates the account with the hashed password. * - * @throws CompteNonActivableException si le compte ne peut pas être activé + * @throws CompteNonActivableException if the account cannot be activated */ public function activer( string $hashedPassword, @@ -85,7 +86,7 @@ final class User extends AggregateRoot throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut); } - // Vérifier si le consentement parental est requis + // Check if parental consent is required if ($consentementPolicy->estRequis($this->dateNaissance)) { if ($this->consentementParental === null) { throw CompteNonActivableException::carConsentementManquant($this->id); @@ -107,20 +108,20 @@ final class User extends AggregateRoot } /** - * Enregistre le consentement parental donné par le parent. + * Records the parental consent given by the parent. */ public function enregistrerConsentementParental(ConsentementParental $consentement): void { $this->consentementParental = $consentement; - // Si le compte était en attente de consentement, passer en attente d'activation + // If the account was awaiting consent, move to awaiting activation if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) { $this->statut = StatutCompte::EN_ATTENTE; } } /** - * Vérifie si cet utilisateur est mineur et nécessite un consentement parental. + * Checks if this user is a minor and requires parental consent. */ public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool { @@ -128,7 +129,7 @@ final class User extends AggregateRoot } /** - * Vérifie si le compte est actif et peut se connecter. + * Checks if the account is active and can log in. */ public function peutSeConnecter(): bool { @@ -136,9 +137,26 @@ final class User extends AggregateRoot } /** - * Reconstitue un User depuis le stockage. + * Changes the user's password. * - * @internal Pour usage par l'Infrastructure uniquement + * Used during password reset. + */ + public function changerMotDePasse(string $hashedPassword, DateTimeImmutable $at): void + { + $this->hashedPassword = $hashedPassword; + + $this->recordEvent(new MotDePasseChange( + userId: (string) $this->id, + email: (string) $this->email, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Reconstitutes a User from storage. + * + * @internal For Infrastructure use only */ public static function reconstitute( UserId $id, diff --git a/backend/src/Administration/Domain/Repository/PasswordResetTokenRepository.php b/backend/src/Administration/Domain/Repository/PasswordResetTokenRepository.php new file mode 100644 index 0000000..03ad978 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/PasswordResetTokenRepository.php @@ -0,0 +1,63 @@ +cookies->get('refresh_token'); - // Invalider toute la famille de tokens pour une déconnexion complète + // Invalidate the entire token family for a complete logout if ($refreshTokenValue !== null) { try { $tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue); $refreshToken = $this->refreshTokenRepository->find($tokenId); if ($refreshToken !== null) { - // Invalider toute la famille (déconnecte tous les devices) + // Invalidate the entire family (disconnects all devices) $this->refreshTokenRepository->invalidateFamily($refreshToken->familyId); } } catch (InvalidArgumentException) { - // Token malformé, ignorer + // Malformed token, ignore } } - // Créer la réponse avec suppression du cookie - $response = new JsonResponse(['message' => 'Déconnexion réussie'], Response::HTTP_OK); + // Create the response with cookie deletion + $response = new JsonResponse(['message' => 'Logout successful'], Response::HTTP_OK); - // Supprimer le cookie refresh_token (même path que celui utilisé lors du login) + // Delete the refresh_token cookie (same path as used during login) $response->headers->setCookie( Cookie::create('refresh_token') ->withValue('') diff --git a/backend/src/Administration/Infrastructure/Api/Processor/RefreshTokenProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/RefreshTokenProcessor.php index d8ccb0c..7314fa9 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/RefreshTokenProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/RefreshTokenProcessor.php @@ -29,18 +29,18 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Messenger\MessageBusInterface; /** - * Processor pour le rafraîchissement de token. + * Processor for token refresh. * - * Flow : - * 1. Lire le refresh token depuis le cookie HttpOnly - * 2. Valider le token et le device fingerprint - * 3. Détecter les replay attacks - * 4. Générer un nouveau JWT et faire la rotation du refresh token - * 5. Mettre à jour le cookie + * Flow: + * 1. Read the refresh token from the HttpOnly cookie + * 2. Validate the token and device fingerprint + * 3. Detect replay attacks + * 4. Generate a new JWT and rotate the refresh token + * 5. Update the cookie * * @implements ProcessorInterface * - * @see Story 1.4 - T6: Endpoint Refresh Token + * @see Story 1.4 - T6: Refresh Token Endpoint */ final readonly class RefreshTokenProcessor implements ProcessorInterface { @@ -67,24 +67,24 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface throw new UnauthorizedHttpException('Bearer', 'Request not available'); } - // Lire le refresh token depuis le cookie + // Read the refresh token from the cookie $refreshTokenString = $request->cookies->get('refresh_token'); if ($refreshTokenString === null) { throw new UnauthorizedHttpException('Bearer', 'Refresh token not found'); } - // Créer le device fingerprint pour validation + // Create the device fingerprint for validation $ipAddress = $request->getClientIp() ?? 'unknown'; $userAgent = $request->headers->get('User-Agent', 'unknown'); $fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress); try { - // Valider et faire la rotation du refresh token + // Validate and rotate the refresh token $newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint); - // Sécurité: vérifier que le tenant du refresh token correspond au tenant de la requête - // Empêche l'utilisation d'un token d'un tenant pour accéder à un autre + // Security: verify that the refresh token's tenant matches the request tenant + // Prevents using a token from one tenant to access another $currentTenantId = $this->resolveCurrentTenant($request->getHost()); if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) { $this->clearRefreshTokenCookie(); @@ -92,12 +92,12 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface throw new AccessDeniedHttpException('Invalid token for this tenant'); } - // Charger l'utilisateur pour générer le JWT + // Load the user to generate the JWT $user = $this->userRepository->get($newRefreshToken->userId); - // Vérifier que l'utilisateur peut toujours se connecter (pas suspendu/archivé) + // Verify the user can still log in (not suspended/archived) if (!$user->peutSeConnecter()) { - // Invalider toute la famille et supprimer le cookie + // Invalidate the entire family and delete the cookie $this->refreshTokenManager->invalidateFamily($newRefreshToken->familyId); $this->clearRefreshTokenCookie(); @@ -106,11 +106,11 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface $securityUser = $this->securityUserFactory->fromDomainUser($user); - // Générer le nouveau JWT + // Generate the new JWT $jwt = $this->jwtManager->create($securityUser); - // Stocker le cookie dans les attributs de requête pour le listener - // Le RefreshTokenCookieListener l'ajoutera à la réponse + // Store the cookie in request attributes for the listener + // The RefreshTokenCookieListener will add it to the response $cookie = Cookie::create('refresh_token') ->withValue($newRefreshToken->toTokenString()) ->withExpires($newRefreshToken->expiresAt) @@ -123,8 +123,8 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface return new RefreshTokenOutput(token: $jwt); } catch (TokenReplayDetectedException $e) { - // Replay attack détecté - la famille a été invalidée - // Dispatcher l'événement de sécurité pour alertes/audit + // Replay attack detected - the family has been invalidated + // Dispatch the security event for alerts/audit $this->eventBus->dispatch(new TokenReplayDetecte( familyId: $e->familyId, ipAddress: $ipAddress, @@ -132,19 +132,19 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface occurredOn: $this->clock->now(), )); - // Supprimer le cookie côté client + // Delete the cookie on client side $this->clearRefreshTokenCookie(); throw new AccessDeniedHttpException( 'Session compromise detected. All sessions have been invalidated. Please log in again.', ); } catch (TokenAlreadyRotatedException) { - // Token déjà rotaté mais en grace period - race condition légitime - // NE PAS supprimer le cookie ! Le client a probablement déjà le nouveau token - // d'une requête concurrente. Retourner 409 Conflict pour que le client réessaie. + // Token already rotated but in grace period - legitimate race condition + // DO NOT delete the cookie! The client probably already has the new token + // from a concurrent request. Return 409 Conflict so the client retries. throw new ConflictHttpException('Token already rotated, retry with current cookie'); } catch (InvalidArgumentException $e) { - // Token invalide ou expiré + // Invalid or expired token $this->clearRefreshTokenCookie(); throw new UnauthorizedHttpException('Bearer', $e->getMessage()); @@ -171,7 +171,9 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface /** * Resolves the current tenant from the request host. * - * Returns null for localhost (dev environment uses default tenant). + * Returns null only for localhost (dev environment). + * Throws AccessDeniedHttpException for unknown hosts in production to prevent + * cross-tenant token exchange via direct IP or base domain access. */ private function resolveCurrentTenant(string $host): ?\App\Shared\Domain\Tenant\TenantId { @@ -183,7 +185,10 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface try { return $this->tenantResolver->resolve($host)->tenantId; } catch (TenantNotFoundException) { - return null; + // Security: reject requests from unknown hosts to prevent cross-tenant attacks + // An attacker with a valid refresh token from tenant A could try to use it + // via direct IP access or base domain to bypass tenant isolation + throw new AccessDeniedHttpException('Invalid host for token refresh'); } } } diff --git a/backend/src/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessor.php new file mode 100644 index 0000000..deca665 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessor.php @@ -0,0 +1,141 @@ + + */ +final readonly class RequestPasswordResetProcessor implements ProcessorInterface +{ + public function __construct( + private RequestPasswordResetHandler $handler, + private RequestStack $requestStack, + private TenantResolver $tenantResolver, + private RateLimiterFactory $passwordResetByEmailLimiter, + private RateLimiterFactory $passwordResetByIpLimiter, + ) { + } + + /** + * @param RequestPasswordResetInput $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): RequestPasswordResetOutput + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + throw new BadRequestHttpException('Request not available'); + } + + $email = strtolower(trim($data->email)); + $ip = $request->getClientIp() ?? 'unknown'; + $tenantId = $this->resolveCurrentTenant(); + + // Check rate limits - returns false if email limit exceeded (skip processing silently) + // Tenant is included in email key to isolate rate limits per establishment + if (!$this->checkRateLimits($tenantId, $email, $ip)) { + // Email rate limit exceeded - return success without processing + // This prevents email enumeration while stopping token flooding + return new RequestPasswordResetOutput(); + } + + $command = new RequestPasswordResetCommand( + email: $email, + tenantId: $tenantId, + ); + + // Handler always succeeds (no exceptions) - this is by design + ($this->handler)($command); + + // Always return success message (prevents email enumeration) + return new RequestPasswordResetOutput(); + } + + /** + * Check rate limits for email and IP. + * + * @throws TooManyRequestsHttpException if IP rate limit exceeded + * + * @return bool true if processing should continue, false if email limit exceeded + */ + private function checkRateLimits(TenantId $tenantId, string $email, string $ip): bool + { + // Check IP rate limit first (throws exception - visible to user) + $ipLimiter = $this->passwordResetByIpLimiter->create($ip); + $ipLimit = $ipLimiter->consume(); + + if (!$ipLimit->isAccepted()) { + throw new TooManyRequestsHttpException( + $ipLimit->getRetryAfter()->getTimestamp() - time(), + 'Trop de demandes. Veuillez réessayer plus tard.', + ); + } + + // Check email rate limit (silent - prevents enumeration) + // Key includes tenant to isolate rate limits per establishment + $emailLimiter = $this->passwordResetByEmailLimiter->create("$tenantId:$email"); + $emailLimit = $emailLimiter->consume(); + + if (!$emailLimit->isAccepted()) { + // Return false to skip processing silently + // User sees success but no token is generated (prevents flooding) + return false; + } + + return true; + } + + /** + * Resolves the current tenant from the request host. + * + * For localhost (dev), uses a default tenant. + * + * @throws BadRequestHttpException if tenant cannot be resolved + */ + private function resolveCurrentTenant(): TenantId + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + throw new BadRequestHttpException('Request not available'); + } + + $host = $request->getHost(); + + // Skip validation for localhost (dev environment uses ecole-alpha tenant) + if ($host === 'localhost' || $host === '127.0.0.1') { + // In dev mode, use ecole-alpha tenant + return TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + } + + try { + return $this->tenantResolver->resolve($host)->tenantId; + } catch (TenantNotFoundException) { + throw new BadRequestHttpException('Établissement non reconnu.'); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/ResetPasswordProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/ResetPasswordProcessor.php new file mode 100644 index 0000000..9fff6b0 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/ResetPasswordProcessor.php @@ -0,0 +1,65 @@ + + */ +final readonly class ResetPasswordProcessor implements ProcessorInterface +{ + public function __construct( + private ResetPasswordHandler $handler, + ) { + } + + /** + * @param ResetPasswordInput $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ResetPasswordOutput + { + $command = new ResetPasswordCommand( + token: $data->token, + newPassword: $data->password, + ); + + try { + ($this->handler)($command); + } catch (PasswordResetTokenNotFoundException) { + throw new BadRequestHttpException('Le lien de réinitialisation est invalide.'); + } catch (PasswordResetTokenExpiredException) { + throw new GoneHttpException('Le lien de réinitialisation a expiré. Veuillez faire une nouvelle demande.'); + } catch (PasswordResetTokenAlreadyUsedException) { + throw new GoneHttpException('Ce lien a déjà été utilisé. Veuillez faire une nouvelle demande si nécessaire.'); + } catch (TokenConsumptionInProgressException) { + throw new ConflictHttpException('Requête en cours de traitement. Veuillez réessayer.'); + } + + return new ResetPasswordOutput(); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php index e519a15..6ceea51 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php @@ -41,9 +41,17 @@ final class ActivateAccountInput pattern: '/[A-Z]/', message: 'Le mot de passe doit contenir au moins une majuscule.', )] + #[Assert\Regex( + pattern: '/[a-z]/', + message: 'Le mot de passe doit contenir au moins une minuscule.', + )] #[Assert\Regex( pattern: '/[0-9]/', message: 'Le mot de passe doit contenir au moins un chiffre.', )] + #[Assert\Regex( + pattern: '/[^A-Za-z0-9]/', + message: 'Le mot de passe doit contenir au moins un caractère spécial.', + )] public string $password = ''; } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/RefreshTokenInput.php b/backend/src/Administration/Infrastructure/Api/Resource/RefreshTokenInput.php index ba5a67e..d933f1f 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/RefreshTokenInput.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/RefreshTokenInput.php @@ -9,11 +9,11 @@ use ApiPlatform\Metadata\Post; use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor; /** - * Resource API Platform pour le rafraîchissement de token. + * API Platform resource for token refresh. * - * Le refresh token est lu depuis le cookie HttpOnly, pas du body. + * The refresh token is read from the HttpOnly cookie, not from the body. * - * @see Story 1.4 - T6: Endpoint Refresh Token + * @see Story 1.4 - T6: Refresh Token Endpoint */ #[ApiResource( operations: [ @@ -22,11 +22,11 @@ use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor; processor: RefreshTokenProcessor::class, output: RefreshTokenOutput::class, name: 'refresh_token', - description: 'Utilise le refresh token (cookie HttpOnly) pour obtenir un nouveau JWT. Le refresh token est automatiquement rotaté.', + description: 'Uses the refresh token (HttpOnly cookie) to obtain a new JWT. The refresh token is automatically rotated.', ), ], )] final class RefreshTokenInput { - // Pas de propriétés - le refresh token vient du cookie + // No properties - the refresh token comes from the cookie } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/RequestPasswordResetInput.php b/backend/src/Administration/Infrastructure/Api/Resource/RequestPasswordResetInput.php new file mode 100644 index 0000000..030b4d7 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/RequestPasswordResetInput.php @@ -0,0 +1,34 @@ +message = 'Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter.'; + } +} diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestPasswordResetTokenCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestPasswordResetTokenCommand.php new file mode 100644 index 0000000..f15261d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Console/CreateTestPasswordResetTokenCommand.php @@ -0,0 +1,171 @@ +addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'reset-test@example.com') + ->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain', 'ecole-alpha') + ->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174') + ->addOption('expired', null, InputOption::VALUE_NONE, 'Create an expired token (for testing expired flow)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var non-empty-string $email */ + $email = $input->getOption('email'); + /** @var string $tenantSubdomain */ + $tenantSubdomain = $input->getOption('tenant'); + /** @var string $baseUrlOption */ + $baseUrlOption = $input->getOption('base-url'); + $baseUrl = rtrim($baseUrlOption, '/'); + $expired = $input->getOption('expired'); + + // Resolve tenant + try { + $tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain); + $tenantId = $tenantConfig->tenantId; + } catch (TenantNotFoundException) { + $io->error(sprintf('Tenant "%s" not found.', $tenantSubdomain)); + + return Command::FAILURE; + } + + $now = $this->clock->now(); + + // Check if user already exists + $user = $this->userRepository->findByEmail(new Email($email), $tenantId); + + if ($user === null) { + // Create an activated user (password reset requires an existing activated account) + $user = User::creer( + email: new Email($email), + role: Role::PARENT, + tenantId: $tenantId, + schoolName: 'École de Test E2E', + dateNaissance: null, + createdAt: $now, + ); + + // Create SecurityUser adapter for password hashing + $securityUser = new SecurityUser( + userId: $user->id, + email: $email, + hashedPassword: '', + tenantId: $tenantId, + roles: [$user->role->value], + ); + + // Activate the user with a password + $hashedPassword = $this->passwordHasher->hashPassword($securityUser, 'OldPassword123!'); + $user->activer($hashedPassword, $now, $this->consentementPolicy); + + $this->userRepository->save($user); + $io->note('Created new activated user'); + } elseif ($user->statut !== StatutCompte::ACTIF) { + // Create SecurityUser adapter for password hashing + $securityUser = new SecurityUser( + userId: $user->id, + email: $email, + hashedPassword: '', + tenantId: $tenantId, + roles: [$user->role->value], + ); + + // Activate existing user if not active + $hashedPassword = $this->passwordHasher->hashPassword($securityUser, 'OldPassword123!'); + $user->activer($hashedPassword, $now, $this->consentementPolicy); + $this->userRepository->save($user); + $io->note('Activated existing user'); + } + + // Create password reset token + $createdAt = $expired + ? $now->modify('-2 hours') // Expired: created 2 hours ago (tokens expire after 1 hour) + : $now; + + $token = PasswordResetToken::generate( + userId: (string) $user->id, + email: $email, + tenantId: $tenantId, + createdAt: $createdAt, + ); + + $this->passwordResetTokenRepository->save($token); + + $resetUrl = sprintf('%s/reset-password/%s', $baseUrl, $token->tokenValue); + + $io->success('Test password reset token created!'); + + $io->table( + ['Property', 'Value'], + [ + ['User ID', (string) $user->id], + ['Email', $email], + ['Tenant', $tenantSubdomain], + ['Token', $token->tokenValue], + ['Created', $token->createdAt->format('Y-m-d H:i:s')], + ['Expires', $token->expiresAt->format('Y-m-d H:i:s')], + ['Expired', $expired ? 'Yes' : 'No'], + ] + ); + + $io->writeln(''); + $io->writeln(sprintf('Reset URL: %s', $resetUrl, $resetUrl)); + $io->writeln(''); + + return Command::SUCCESS; + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetConfirmationHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetConfirmationHandler.php new file mode 100644 index 0000000..3fa2a8a --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetConfirmationHandler.php @@ -0,0 +1,46 @@ +twig->render('emails/password_reset_confirmation.html.twig', [ + 'email' => $event->email, + 'changedAt' => $event->occurredOn()->format('d/m/Y à H:i'), + 'loginUrl' => rtrim($this->appUrl, '/') . '/login', + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($event->email) + ->subject('Votre mot de passe Classeo a été modifié') + ->html($html); + + $this->mailer->send($email); + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandler.php new file mode 100644 index 0000000..aa64a99 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandler.php @@ -0,0 +1,47 @@ +appUrl, '/') . '/reset-password/' . $event->tokenValue; + + $html = $this->twig->render('emails/password_reset.html.twig', [ + 'email' => $event->email, + 'resetUrl' => $resetUrl, + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($event->email) + ->subject('Réinitialisation de votre mot de passe Classeo') + ->html($html); + + $this->mailer->send($email); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPasswordResetTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPasswordResetTokenRepository.php new file mode 100644 index 0000000..eace5de --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPasswordResetTokenRepository.php @@ -0,0 +1,140 @@ + Indexed by token value */ + private array $byTokenValue = []; + + /** @var array Maps ID to token value */ + private array $idToTokenValue = []; + + /** @var array Maps user ID to token value */ + private array $userIdToTokenValue = []; + + public function __construct( + private ?Clock $clock = null, + ) { + } + + #[Override] + public function save(PasswordResetToken $token): void + { + $this->byTokenValue[$token->tokenValue] = $token; + $this->idToTokenValue[(string) $token->id] = $token->tokenValue; + $this->userIdToTokenValue[$token->userId] = $token->tokenValue; + } + + #[Override] + public function findByTokenValue(string $tokenValue): ?PasswordResetToken + { + return $this->byTokenValue[$tokenValue] ?? null; + } + + #[Override] + public function getByTokenValue(string $tokenValue): PasswordResetToken + { + $token = $this->findByTokenValue($tokenValue); + + if ($token === null) { + throw PasswordResetTokenNotFoundException::withTokenValue($tokenValue); + } + + return $token; + } + + #[Override] + public function get(PasswordResetTokenId $id): PasswordResetToken + { + $tokenValue = $this->idToTokenValue[(string) $id] ?? null; + + if ($tokenValue === null) { + throw PasswordResetTokenNotFoundException::withId($id); + } + + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token === null) { + throw PasswordResetTokenNotFoundException::withId($id); + } + + return $token; + } + + #[Override] + public function delete(PasswordResetTokenId $id): void + { + $tokenValue = $this->idToTokenValue[(string) $id] ?? null; + + if ($tokenValue !== null) { + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token !== null) { + unset($this->userIdToTokenValue[$token->userId]); + } + + unset($this->byTokenValue[$tokenValue]); + } + + unset($this->idToTokenValue[(string) $id]); + } + + #[Override] + public function deleteByTokenValue(string $tokenValue): void + { + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token !== null) { + unset($this->idToTokenValue[(string) $token->id]); + unset($this->userIdToTokenValue[$token->userId]); + } + + unset($this->byTokenValue[$tokenValue]); + } + + #[Override] + public function findValidTokenForUser(string $userId): ?PasswordResetToken + { + $tokenValue = $this->userIdToTokenValue[$userId] ?? null; + + if ($tokenValue === null) { + return null; + } + + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token === null) { + return null; + } + + // Check if token is still valid (not used and not expired) + $now = $this->clock?->now() ?? new DateTimeImmutable(); + if ($token->isUsed() || $token->isExpired($now)) { + return null; + } + + return $token; + } + + #[Override] + public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken + { + $token = $this->getByTokenValue($tokenValue); + $token->validateForUse($at); + $token->use($at); + $this->save($token); + + return $token; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepository.php new file mode 100644 index 0000000..c2e1ec8 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepository.php @@ -0,0 +1,116 @@ + Indexed by token ID */ + private array $tokens = []; + + /** @var array> Maps family ID to token IDs */ + private array $familyIndex = []; + + /** @var array> Maps user ID to family IDs */ + private array $userIndex = []; + + #[Override] + public function save(RefreshToken $token): void + { + $this->tokens[(string) $token->id] = $token; + + // Index by family + $familyId = (string) $token->familyId; + if (!isset($this->familyIndex[$familyId])) { + $this->familyIndex[$familyId] = []; + } + $this->familyIndex[$familyId][] = (string) $token->id; + $this->familyIndex[$familyId] = array_values(array_unique($this->familyIndex[$familyId])); + + // Index by user + $userId = (string) $token->userId; + if (!isset($this->userIndex[$userId])) { + $this->userIndex[$userId] = []; + } + $this->userIndex[$userId][] = $familyId; + $this->userIndex[$userId] = array_values(array_unique($this->userIndex[$userId])); + } + + #[Override] + public function find(RefreshTokenId $id): ?RefreshToken + { + return $this->tokens[(string) $id] ?? null; + } + + #[Override] + public function findByToken(string $tokenValue): ?RefreshToken + { + return $this->find(RefreshTokenId::fromString($tokenValue)); + } + + #[Override] + public function delete(RefreshTokenId $id): void + { + unset($this->tokens[(string) $id]); + } + + #[Override] + public function invalidateFamily(TokenFamilyId $familyId): void + { + $familyIdStr = (string) $familyId; + + if (!isset($this->familyIndex[$familyIdStr])) { + return; + } + + // Delete all tokens in the family + foreach ($this->familyIndex[$familyIdStr] as $tokenId) { + unset($this->tokens[$tokenId]); + } + + // Remove family index + unset($this->familyIndex[$familyIdStr]); + } + + #[Override] + public function invalidateAllForUser(UserId $userId): void + { + $userIdStr = (string) $userId; + + if (!isset($this->userIndex[$userIdStr])) { + return; + } + + // Invalidate all families for this user + foreach ($this->userIndex[$userIdStr] as $familyId) { + $this->invalidateFamily(TokenFamilyId::fromString($familyId)); + } + + // Remove user index + unset($this->userIndex[$userIdStr]); + } + + /** + * Helper method for testing: check if user has any active sessions. + */ + public function hasActiveSessionsForUser(UserId $userId): bool + { + return isset($this->userIndex[(string) $userId]) && count($this->userIndex[(string) $userId]) > 0; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Redis/RedisPasswordResetTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisPasswordResetTokenRepository.php new file mode 100644 index 0000000..28a8f24 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisPasswordResetTokenRepository.php @@ -0,0 +1,239 @@ +passwordResetTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue); + $item->set($this->serialize($token)); + $item->expiresAfter(self::TTL_SECONDS); + $this->passwordResetTokensCache->save($item); + + // Also store by ID for direct access + $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id); + $idItem->set($token->tokenValue); + $idItem->expiresAfter(self::TTL_SECONDS); + $this->passwordResetTokensCache->save($idItem); + + // Store by user_id for lookup of existing tokens + $userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $token->userId); + $userItem->set($token->tokenValue); + $userItem->expiresAfter(self::TTL_SECONDS); + $this->passwordResetTokensCache->save($userItem); + } + + #[Override] + public function findByTokenValue(string $tokenValue): ?PasswordResetToken + { + $item = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . $tokenValue); + + if (!$item->isHit()) { + return null; + } + + /** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data */ + $data = $item->get(); + + return $this->deserialize($data); + } + + #[Override] + public function getByTokenValue(string $tokenValue): PasswordResetToken + { + $token = $this->findByTokenValue($tokenValue); + + if ($token === null) { + throw PasswordResetTokenNotFoundException::withTokenValue($tokenValue); + } + + return $token; + } + + #[Override] + public function get(PasswordResetTokenId $id): PasswordResetToken + { + // First get the token value from the ID index + $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id); + + if (!$idItem->isHit()) { + throw PasswordResetTokenNotFoundException::withId($id); + } + + /** @var string $tokenValue */ + $tokenValue = $idItem->get(); + $token = $this->findByTokenValue($tokenValue); + + if ($token === null) { + throw PasswordResetTokenNotFoundException::withId($id); + } + + return $token; + } + + #[Override] + public function delete(PasswordResetTokenId $id): void + { + // Get token first to clean up all indices + $idItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id); + + if ($idItem->isHit()) { + /** @var string $tokenValue */ + $tokenValue = $idItem->get(); + $token = $this->findByTokenValue($tokenValue); + + if ($token !== null) { + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId); + } + + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue); + } + + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id); + } + + #[Override] + public function deleteByTokenValue(string $tokenValue): void + { + $token = $this->findByTokenValue($tokenValue); + + if ($token !== null) { + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id); + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . 'user:' . $token->userId); + } + + $this->passwordResetTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue); + } + + #[Override] + public function findValidTokenForUser(string $userId): ?PasswordResetToken + { + $userItem = $this->passwordResetTokensCache->getItem(self::KEY_PREFIX . 'user:' . $userId); + + if (!$userItem->isHit()) { + return null; + } + + /** @var string $tokenValue */ + $tokenValue = $userItem->get(); + $token = $this->findByTokenValue($tokenValue); + + if ($token === null) { + return null; + } + + // Check if token is still valid (not used and not expired) + if ($token->isUsed() || $token->isExpired(new DateTimeImmutable())) { + return null; + } + + return $token; + } + + #[Override] + public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken + { + // Use Symfony Lock for atomic lock acquisition (Redis SETNX under the hood) + $lock = $this->lockFactory->createLock( + resource: self::KEY_PREFIX . 'lock:' . $tokenValue, + ttl: 30, // 30 seconds max for password hashing + save + autoRelease: true, + ); + + // Try to acquire lock without blocking + if (!$lock->acquire(blocking: false)) { + // Another request is consuming this token + // Check if the token was already used by the other request + $token = $this->findByTokenValue($tokenValue); + if ($token !== null && $token->isUsed()) { + $token->validateForUse($at); // Will throw AlreadyUsedException + } + + // Lock is held but token not yet consumed - client should retry + throw new TokenConsumptionInProgressException($tokenValue); + } + + try { + // Get and validate token + $token = $this->getByTokenValue($tokenValue); + $token->validateForUse($at); + + // Mark as used + $token->use($at); + + // Save the consumed token + $this->save($token); + + return $token; + } finally { + // Always release the lock + $lock->release(); + } + } + + /** + * @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} + */ + private function serialize(PasswordResetToken $token): array + { + return [ + 'id' => (string) $token->id, + 'token_value' => $token->tokenValue, + 'user_id' => $token->userId, + 'email' => $token->email, + 'tenant_id' => (string) $token->tenantId, + 'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM), + 'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM), + 'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM), + ]; + } + + /** + * @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, created_at: string, expires_at: string, used_at: string|null} $data + */ + private function deserialize(array $data): PasswordResetToken + { + return PasswordResetToken::reconstitute( + id: PasswordResetTokenId::fromString($data['id']), + tokenValue: $data['token_value'], + userId: $data['user_id'], + email: $data['email'], + tenantId: TenantId::fromString($data['tenant_id']), + createdAt: new DateTimeImmutable($data['created_at']), + expiresAt: new DateTimeImmutable($data['expires_at']), + usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Redis/RedisRefreshTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisRefreshTokenRepository.php index b1f2711..c9bab17 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Redis/RedisRefreshTokenRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisRefreshTokenRepository.php @@ -14,33 +14,47 @@ use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use DateTimeInterface; use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** - * Implémentation Redis du repository de refresh tokens. + * Redis implementation of the refresh tokens repository. * - * Structure de stockage : - * - Token individuel : refresh:{token_id} → données JSON du token - * - Index famille : refresh_family:{family_id} → set des token_ids de la famille + * Storage structure: + * - Individual token: refresh:{token_id} → token JSON data + * - Family index: refresh_family:{family_id} → set of token_ids in the family + * - User index: refresh_user:{user_id} → set of family_ids for the user * - * @see Story 1.4 - Connexion utilisateur + * @see Story 1.4 - User login */ final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository { private const string TOKEN_PREFIX = 'refresh:'; private const string FAMILY_PREFIX = 'refresh_family:'; + private const string USER_PREFIX = 'refresh_user:'; + + /** + * Maximum TTL for user index (7 days + 10% jitter margin). + * + * Must be >= the longest possible token TTL to ensure invalidateAllForUser() works correctly. + * RefreshTokenManager applies ±10% jitter, so max token TTL = 604800 * 1.1 = 665280s. + * We use 8 days (691200s) for a safe margin. + */ + private const int MAX_USER_INDEX_TTL = 691200; public function __construct( private CacheItemPoolInterface $refreshTokensCache, + private LoggerInterface $logger = new NullLogger(), ) { } public function save(RefreshToken $token): void { - // Sauvegarder le token + // Save the token $tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id); $tokenItem->set($this->serialize($token)); - // Calculer le TTL restant + // Calculate remaining TTL $now = new DateTimeImmutable(); $ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp(); if ($ttl > 0) { @@ -49,9 +63,9 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito $this->refreshTokensCache->save($tokenItem); - // Ajouter à l'index famille - // Ne jamais réduire le TTL de l'index famille - // L'index doit survivre aussi longtemps que le token le plus récent de la famille + // Add to family index + // Never reduce the family index TTL + // The index must survive as long as the most recent token in the family $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId); /** @var list $familyTokenIds */ @@ -59,17 +73,32 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito $familyTokenIds[] = (string) $token->id; $familyItem->set(array_unique($familyTokenIds)); - // Seulement étendre le TTL, jamais le réduire - // Pour les tokens rotated (ancien), on ne change pas le TTL de l'index + // Only extend TTL, never reduce + // For rotated tokens (old), we don't change the index TTL if (!$token->isRotated && $ttl > 0) { $familyItem->expiresAfter($ttl); } elseif (!$familyItem->isHit()) { - // Nouveau index - définir le TTL initial + // New index - set initial TTL $familyItem->expiresAfter($ttl > 0 ? $ttl : 604800); } - // Si c'est un token rotaté et l'index existe déjà, on garde le TTL existant + // If it's a rotated token and the index already exists, keep the existing TTL $this->refreshTokensCache->save($familyItem); + + // Add to user index (for invalidating all sessions) + $userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $token->userId); + + /** @var list $userFamilyIds */ + $userFamilyIds = $userItem->isHit() ? $userItem->get() : []; + $userFamilyIds[] = (string) $token->familyId; + $userItem->set(array_unique($userFamilyIds)); + + // Always use max TTL for user index to ensure it survives as long as any token + // This prevents invalidateAllForUser() from missing long-lived sessions (e.g., mobile) + // when a shorter-lived session (e.g., web) is created afterwards + $userItem->expiresAfter(self::MAX_USER_INDEX_TTL); + + $this->refreshTokensCache->save($userItem); } public function find(RefreshTokenId $id): ?RefreshToken @@ -107,15 +136,56 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito /** @var list $tokenIds */ $tokenIds = $familyItem->get(); - // Supprimer tous les tokens de la famille + // Delete all tokens in the family foreach ($tokenIds as $tokenId) { $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId); } - // Supprimer l'index famille + // Delete the family index $this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId); } + /** + * Invalidates all refresh token sessions for a user. + * + * IMPORTANT: This method relies on the user index (refresh_user:{userId}) which is only + * created when tokens are saved with this repository version. Tokens created before this + * code was deployed will NOT have a user index and will not be invalidated. + * + * For deployments with existing tokens, either: + * - Wait for old tokens to naturally expire (max 7 days) + * - Run a migration script to rebuild user indexes + * - Force all users to re-login after deployment + */ + public function invalidateAllForUser(UserId $userId): void + { + $userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $userId); + + if (!$userItem->isHit()) { + // User index doesn't exist - this could mean: + // 1. User has no active sessions (normal case) + // 2. User has legacy sessions created before user index was implemented (migration needed) + // Log at info level to help operators identify migration needs + $this->logger->info('No user index found when invalidating sessions. Legacy tokens may exist.', [ + 'user_id' => (string) $userId, + 'action' => 'invalidateAllForUser', + ]); + + return; + } + + /** @var list $familyIds */ + $familyIds = $userItem->get(); + + // Invalidate all token families for the user + foreach ($familyIds as $familyId) { + $this->invalidateFamily(TokenFamilyId::fromString($familyId)); + } + + // Delete the user index + $this->refreshTokensCache->deleteItem(self::USER_PREFIX . $userId); + } + /** * @return array */ diff --git a/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php b/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php index 65270b5..5f49bbe 100644 --- a/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php +++ b/backend/src/Administration/Infrastructure/Security/DatabaseUserProvider.php @@ -17,15 +17,15 @@ use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; /** - * Charge les utilisateurs depuis le domaine pour l'authentification Symfony. + * Loads users from the domain for Symfony authentication. * - * Ce provider fait le pont entre Symfony Security et notre Domain Layer. - * Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité. - * Les utilisateurs sont isolés par tenant (établissement). + * This provider bridges Symfony Security with our Domain Layer. + * It never reveals whether a user exists or not for security reasons. + * Users are isolated by tenant (school). * * @implements UserProviderInterface * - * @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte) + * @see Story 1.4 - User login (AC2: no account existence disclosure) */ final readonly class DatabaseUserProvider implements UserProviderInterface { @@ -50,12 +50,12 @@ final readonly class DatabaseUserProvider implements UserProviderInterface $user = $this->userRepository->findByEmail($email, $tenantId); - // Message générique pour ne pas révéler l'existence du compte + // Generic message to not reveal account existence if ($user === null) { throw new SymfonyUserNotFoundException(); } - // Ne pas permettre la connexion si le compte n'est pas actif + // Do not allow login if the account is not active if (!$user->peutSeConnecter()) { throw new SymfonyUserNotFoundException(); } diff --git a/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php b/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php index 0dedb3b..e2c2030 100644 --- a/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php +++ b/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php @@ -7,15 +7,15 @@ namespace App\Administration\Infrastructure\Security; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; /** - * Enrichit le payload JWT avec les claims métier. + * Enriches the JWT payload with business claims. * - * Claims ajoutés: - * - sub: Email de l'utilisateur (identifiant Symfony Security) - * - user_id: UUID de l'utilisateur (pour les consommateurs d'API) - * - tenant_id: UUID du tenant pour l'isolation multi-tenant - * - roles: Liste des rôles Symfony pour l'autorisation + * Added claims: + * - sub: User email (Symfony Security identifier) + * - user_id: User UUID (for API consumers) + * - tenant_id: Tenant UUID for multi-tenant isolation + * - roles: List of Symfony roles for authorization * - * @see Story 1.4 - Connexion utilisateur + * @see Story 1.4 - User login */ final readonly class JwtPayloadEnricher { @@ -29,7 +29,7 @@ final readonly class JwtPayloadEnricher $payload = $event->getData(); - // Claims métier pour l'isolation multi-tenant et l'autorisation + // Business claims for multi-tenant isolation and authorization $payload['user_id'] = $user->userId(); $payload['tenant_id'] = $user->tenantId(); $payload['roles'] = $user->getRoles(); diff --git a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php index 0a02245..00e8012 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php @@ -22,11 +22,11 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; /** - * Gère les échecs de login : rate limiting Fibonacci, audit, messages user-friendly. + * Handles login failures: Fibonacci rate limiting, audit, user-friendly messages. * - * Important: Ne jamais révéler si l'email existe ou non (AC2). + * Important: Never reveal whether the email exists or not (AC2). * - * @see Story 1.4 - T5: Endpoint Login Backend + * @see Story 1.4 - T5: Backend Login Endpoint */ final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface { @@ -46,10 +46,10 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler $ipAddress = $request->getClientIp() ?? 'unknown'; $userAgent = $request->headers->get('User-Agent', 'unknown'); - // Enregistrer l'échec et obtenir le nouvel état + // Record the failure and get the new state $result = $this->rateLimiter->recordFailure($request, $email); - // Émettre l'événement d'échec + // Dispatch the failure event $this->eventBus->dispatch(new ConnexionEchouee( email: $email, ipAddress: $ipAddress, @@ -58,7 +58,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler occurredOn: $this->clock->now(), )); - // Si l'IP vient d'être bloquée + // If the IP was just blocked if ($result->ipBlocked) { $this->eventBus->dispatch(new CompteBloqueTemporairement( email: $email, @@ -72,7 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler return $this->createBlockedResponse($result); } - // Réponse standard d'échec avec infos sur le délai et CAPTCHA + // Standard failure response with delay and CAPTCHA info return $this->createFailureResponse($result); } @@ -106,13 +106,13 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler 'attempts' => $result->attempts, ]; - // Ajouter le délai si applicable + // Add delay if applicable if ($result->delaySeconds > 0) { $data['delay'] = $result->delaySeconds; $data['delayFormatted'] = $result->getFormattedDelay(); } - // Indiquer si CAPTCHA requis pour la prochaine tentative + // Indicate if CAPTCHA is required for the next attempt if ($result->requiresCaptcha) { $data['captchaRequired'] = true; } diff --git a/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php b/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php index 304e1b0..4483d4c 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginSuccessHandler.php @@ -17,9 +17,9 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Messenger\MessageBusInterface; /** - * Gère les actions post-login réussi : refresh token, reset rate limit, audit. + * Handles post-login success actions: refresh token, reset rate limit, audit. * - * @see Story 1.4 - T5: Endpoint Login Backend + * @see Story 1.4 - T5: Backend Login Endpoint */ final readonly class LoginSuccessHandler { @@ -48,13 +48,13 @@ final readonly class LoginSuccessHandler $ipAddress = $request->getClientIp() ?? 'unknown'; $userAgent = $request->headers->get('User-Agent', 'unknown'); - // Créer le device fingerprint + // Create the device fingerprint $fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress); - // Détecter si c'est un mobile (pour le TTL du refresh token) + // Detect if this is a mobile device (for refresh token TTL) $isMobile = str_contains(strtolower($userAgent), 'mobile'); - // Créer le refresh token + // Create the refresh token $refreshToken = $this->refreshTokenManager->create( $userId, $tenantId, @@ -62,7 +62,7 @@ final readonly class LoginSuccessHandler $isMobile, ); - // Ajouter le refresh token en cookie HttpOnly + // Add the refresh token as HttpOnly cookie $cookie = Cookie::create('refresh_token') ->withValue($refreshToken->toTokenString()) ->withExpires($refreshToken->expiresAt) @@ -73,10 +73,10 @@ final readonly class LoginSuccessHandler $response->headers->setCookie($cookie); - // Reset le rate limiter pour cet email + // Reset the rate limiter for this email $this->rateLimiter->reset($email); - // Émettre l'événement de connexion réussie + // Dispatch the successful login event $this->eventBus->dispatch(new ConnexionReussie( userId: $user->userId(), email: $email, diff --git a/backend/src/Administration/Infrastructure/Security/SecurityUser.php b/backend/src/Administration/Infrastructure/Security/SecurityUser.php index 490786d..dd5424d 100644 --- a/backend/src/Administration/Infrastructure/Security/SecurityUser.php +++ b/backend/src/Administration/Infrastructure/Security/SecurityUser.php @@ -10,12 +10,12 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; /** - * Adapter entre le Domain User et Symfony Security. + * Adapter between the Domain User and Symfony Security. * - * Ce DTO est utilisé par le système d'authentification Symfony. - * Il ne contient pas de logique métier - c'est un simple transporteur de données. + * This DTO is used by the Symfony authentication system. + * It contains no business logic - it's a simple data carrier. * - * @see Story 1.4 - Connexion utilisateur + * @see Story 1.4 - User login */ final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface { @@ -24,7 +24,7 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate /** * @param non-empty-string $email - * @param list $roles Les rôles Symfony (ROLE_*) + * @param list $roles Symfony roles (ROLE_*) */ public function __construct( private UserId $userId, @@ -74,6 +74,6 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate public function eraseCredentials(): void { - // Rien à effacer, les données sont immutables + // Nothing to erase, data is immutable } } diff --git a/backend/src/Shared/Domain/CorrelationId.php b/backend/src/Shared/Domain/CorrelationId.php index d56d868..08d0fc0 100644 --- a/backend/src/Shared/Domain/CorrelationId.php +++ b/backend/src/Shared/Domain/CorrelationId.php @@ -15,7 +15,7 @@ final readonly class CorrelationId public static function generate(): self { - return new self(Uuid::uuid4()->toString()); + return new self(Uuid::uuid7()->toString()); } public static function fromString(string $value): self diff --git a/backend/src/Shared/Domain/EntityId.php b/backend/src/Shared/Domain/EntityId.php index 99aa906..a43e59a 100644 --- a/backend/src/Shared/Domain/EntityId.php +++ b/backend/src/Shared/Domain/EntityId.php @@ -19,7 +19,7 @@ abstract readonly class EntityId public static function generate(): static { - return new static(Uuid::uuid4()); + return new static(Uuid::uuid7()); } public static function fromString(string $value): static diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php index dc1fd59..ddfa7ac 100644 --- a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php +++ b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php @@ -34,6 +34,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface '/api/activation-tokens', '/api/activate', '/api/login', + '/api/password', + '/api/token', '/_profiler', '/_wdt', '/_error', diff --git a/backend/symfony.lock b/backend/symfony.lock index ca11a38..14ac008 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -178,6 +178,18 @@ ".editorconfig" ] }, + "symfony/lock": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/mailer": { "version": "8.0", "recipe": { diff --git a/backend/templates/emails/password_reset.html.twig b/backend/templates/emails/password_reset.html.twig new file mode 100644 index 0000000..cb8fd8b --- /dev/null +++ b/backend/templates/emails/password_reset.html.twig @@ -0,0 +1,113 @@ + + + + + + Réinitialisation de mot de passe - Classeo + + + +
+

Classeo

+
+ +
+

Réinitialisation de votre mot de passe

+ +

Bonjour,

+ +

Nous avons reçu une demande de réinitialisation du mot de passe de votre compte Classeo associé à l'adresse {{ email }}.

+ +

Si vous êtes à l'origine de cette demande, cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe :

+ +

+ Réinitialiser mon mot de passe +

+ +
+

Ce lien est valide pendant 1 heure et ne peut être utilisé qu'une seule fois.

+
+ +
+

Vous n'avez pas demandé cette réinitialisation ?
+ Ignorez simplement cet email. Votre mot de passe ne sera pas modifié et le lien expirera automatiquement.

+
+ +

Conseils de sécurité :

+
    +
  • Ne partagez jamais ce lien avec d'autres personnes
  • +
  • Choisissez un mot de passe fort et unique
  • +
  • Si vous suspectez une activité suspecte, contactez votre établissement
  • +
+
+ + + + diff --git a/backend/templates/emails/password_reset_confirmation.html.twig b/backend/templates/emails/password_reset_confirmation.html.twig new file mode 100644 index 0000000..8a059be --- /dev/null +++ b/backend/templates/emails/password_reset_confirmation.html.twig @@ -0,0 +1,130 @@ + + + + + + Mot de passe modifié - Classeo + + + +
+

Classeo

+
+ +
+
+ +
+ +

Mot de passe modifié

+ +

Bonjour,

+ +

Nous vous confirmons que le mot de passe de votre compte Classeo associé à l'adresse {{ email }} a été modifié avec succès.

+ +
+

Date du changement : {{ changedAt }}

+
+ +
+

Vous n'êtes pas à l'origine de ce changement ?
+ Si vous n'avez pas demandé cette modification, votre compte pourrait être compromis. + Contactez immédiatement votre établissement ou faites une nouvelle demande de réinitialisation.

+
+ +

Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.

+ +

+ Se connecter +

+ +

Conseils de sécurité :

+
    +
  • Ne partagez jamais votre mot de passe
  • +
  • Utilisez un mot de passe unique pour chaque service
  • +
  • Déconnectez-vous des appareils partagés
  • +
+
+ + + + diff --git a/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php b/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php new file mode 100644 index 0000000..1360746 --- /dev/null +++ b/backend/tests/Functional/Administration/Api/PasswordResetEndpointsTest.php @@ -0,0 +1,106 @@ +request('POST', '/api/password/forgot', [ + 'json' => ['email' => 'test@example.com'], + 'headers' => [ + 'Host' => 'localhost', + ], + ]); + + // Should NOT return 401 Unauthorized + // It should return 200 (success) or 429 (rate limited), but never 401 + self::assertNotEquals(401, $response->getStatusCode(), 'Password forgot endpoint should be accessible without JWT'); + + // The endpoint always returns success to prevent email enumeration + // Even for non-existent emails + self::assertResponseIsSuccessful(); + } + + #[Test] + public function passwordResetEndpointIsAccessibleWithoutAuthentication(): void + { + $client = static::createClient(); + + $response = $client->request('POST', '/api/password/reset', [ + 'json' => [ + 'token' => 'invalid-token-for-test', + 'password' => 'NewSecurePassword123!', + ], + 'headers' => [ + 'Host' => 'localhost', + ], + ]); + + // Should NOT return 401 Unauthorized + // It should return 400 (invalid token) or 410 (expired), but never 401 + self::assertNotEquals(401, $response->getStatusCode(), 'Password reset endpoint should be accessible without JWT'); + + // With an invalid token, we expect a 400 Bad Request + self::assertResponseStatusCodeSame(400); + } + + #[Test] + public function passwordForgotValidatesEmailFormat(): void + { + $client = static::createClient(); + + $response = $client->request('POST', '/api/password/forgot', [ + 'json' => ['email' => 'not-an-email'], + 'headers' => [ + 'Host' => 'localhost', + ], + ]); + + // Invalid email format returns validation error (422) + // This is acceptable because it doesn't reveal user existence + // (format validation happens before checking if user exists) + self::assertResponseStatusCodeSame(422); + } + + #[Test] + public function passwordResetValidatesPasswordRequirements(): void + { + $client = static::createClient(); + + // Password too short + $response = $client->request('POST', '/api/password/reset', [ + 'json' => [ + 'token' => 'some-token', + 'password' => 'short', + ], + 'headers' => [ + 'Host' => 'localhost', + ], + ]); + + // Should return 422 Unprocessable Entity for validation errors + self::assertResponseStatusCodeSame(422); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetHandlerTest.php new file mode 100644 index 0000000..f80fd0a --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/RequestPasswordReset/RequestPasswordResetHandlerTest.php @@ -0,0 +1,252 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public DateTimeImmutable $now; + + public function __construct() + { + $this->now = new DateTimeImmutable('2026-01-28 10:00:00'); + } + + #[Override] + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock); + + $this->dispatchedEvents = []; + $eventBus = new class($this->dispatchedEvents) implements MessageBusInterface { + /** @param DomainEvent[] $events */ + public function __construct(private array &$events) + { + } + + #[Override] + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->events[] = $message; + + return new Envelope($message); + } + }; + + $this->tenantId = TenantId::fromString(self::TENANT_ID); + + $this->handler = new RequestPasswordResetHandler( + $this->userRepository, + $this->tokenRepository, + $this->clock, + $eventBus, + ); + } + + #[Test] + public function itGeneratesTokenWhenUserExists(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + + $command = new RequestPasswordResetCommand( + email: self::EMAIL, + tenantId: $this->tenantId, + ); + + ($this->handler)($command); + + // Verify token was created + $token = $this->tokenRepository->findValidTokenForUser((string) $user->id); + self::assertNotNull($token); + self::assertSame(self::EMAIL, $token->email); + self::assertTrue($token->tenantId->equals($this->tenantId)); + } + + #[Test] + public function itDispatchesTokenGeneratedEvent(): void + { + $this->createAndSaveUser(self::EMAIL); + + $command = new RequestPasswordResetCommand( + email: self::EMAIL, + tenantId: $this->tenantId, + ); + + ($this->handler)($command); + + self::assertCount(1, $this->dispatchedEvents); + self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]); + } + + #[Test] + public function itSilentlySucceedsWhenUserDoesNotExist(): void + { + $command = new RequestPasswordResetCommand( + email: 'nonexistent@example.com', + tenantId: $this->tenantId, + ); + + // Should NOT throw - silently succeeds + ($this->handler)($command); + + // No events dispatched + self::assertCount(0, $this->dispatchedEvents); + } + + #[Test] + public function itSilentlySucceedsWhenEmailIsInvalid(): void + { + $command = new RequestPasswordResetCommand( + email: 'not-an-email', + tenantId: $this->tenantId, + ); + + // Should NOT throw - silently succeeds + ($this->handler)($command); + + // No events dispatched + self::assertCount(0, $this->dispatchedEvents); + } + + #[Test] + public function itReusesExistingValidToken(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + + // First request - creates token + $command = new RequestPasswordResetCommand( + email: self::EMAIL, + tenantId: $this->tenantId, + ); + + ($this->handler)($command); + + $firstToken = $this->tokenRepository->findValidTokenForUser((string) $user->id); + self::assertNotNull($firstToken); + + // Clear dispatched events + $this->dispatchedEvents = []; + + // Second request - should NOT create new token + ($this->handler)($command); + + // Same token should exist + $secondToken = $this->tokenRepository->findValidTokenForUser((string) $user->id); + self::assertNotNull($secondToken); + self::assertSame($firstToken->tokenValue, $secondToken->tokenValue); + } + + #[Test] + public function itCreatesNewTokenWhenExistingTokenIsExpired(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + + // Create an expired token manually + $expiredToken = PasswordResetToken::generate( + userId: (string) $user->id, + email: self::EMAIL, + tenantId: $this->tenantId, + createdAt: new DateTimeImmutable('2026-01-28 08:00:00'), // 2 hours ago + ); + $this->tokenRepository->save($expiredToken); + + // findValidTokenForUser should return null for expired tokens + $validToken = $this->tokenRepository->findValidTokenForUser((string) $user->id); + self::assertNull($validToken); + + // Now request a new token + $command = new RequestPasswordResetCommand( + email: self::EMAIL, + tenantId: $this->tenantId, + ); + + ($this->handler)($command); + + // A new token should be created + self::assertCount(1, $this->dispatchedEvents); + self::assertInstanceOf(PasswordResetTokenGenerated::class, $this->dispatchedEvents[0]); + } + + #[Test] + public function itDoesNotGenerateTokenForUserInDifferentTenant(): void + { + // Create user in tenant 1 + $this->createAndSaveUser(self::EMAIL); + + // Request reset for different tenant + $differentTenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'); + $command = new RequestPasswordResetCommand( + email: self::EMAIL, + tenantId: $differentTenantId, + ); + + ($this->handler)($command); + + // No events dispatched (user not found in different tenant) + self::assertCount(0, $this->dispatchedEvents); + } + + private function createAndSaveUser(string $email): User + { + $user = User::creer( + email: new Email($email), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + dateNaissance: new DateTimeImmutable('1990-01-01'), + createdAt: $this->clock->now(), + ); + + // Activate user so they can request password reset + $consentementPolicy = new ConsentementParentalPolicy($this->clock); + $user->activer( + hashedPassword: '$argon2id$hashed', + at: $this->clock->now(), + consentementPolicy: $consentementPolicy, + ); + + $this->userRepository->save($user); + + return $user; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ResetPassword/ResetPasswordHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ResetPassword/ResetPasswordHandlerTest.php new file mode 100644 index 0000000..2242a41 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ResetPassword/ResetPasswordHandlerTest.php @@ -0,0 +1,320 @@ +userRepository = new InMemoryUserRepository(); + $this->clock = new class implements Clock { + public DateTimeImmutable $now; + + public function __construct() + { + $this->now = new DateTimeImmutable('2026-01-28 10:00:00'); + } + + #[Override] + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock); + $this->refreshTokenRepository = new InMemoryRefreshTokenRepository(); + + $this->passwordHasher = new class implements PasswordHasher { + #[Override] + public function hash(string $plainPassword): string + { + return ResetPasswordHandlerTest::HASHED_PASSWORD; + } + + #[Override] + public function verify(string $hashedPassword, string $plainPassword): bool + { + return true; + } + }; + + $this->dispatchedEvents = []; + $eventBus = new class($this->dispatchedEvents) implements MessageBusInterface { + /** @param DomainEvent[] $events */ + public function __construct(private array &$events) + { + } + + #[Override] + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->events[] = $message; + + return new Envelope($message); + } + }; + + $this->tenantId = TenantId::fromString(self::TENANT_ID); + + $this->handler = new ResetPasswordHandler( + $this->tokenRepository, + $this->userRepository, + $this->refreshTokenRepository, + $this->passwordHasher, + $this->clock, + $eventBus, + ); + } + + #[Test] + public function itResetsPasswordWithValidToken(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + ($this->handler)($command); + + // Verify password was updated + $updatedUser = $this->userRepository->get($user->id); + self::assertSame(self::HASHED_PASSWORD, $updatedUser->hashedPassword); + } + + #[Test] + public function itDispatchesPasswordChangedEvent(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + ($this->handler)($command); + + // Should have MotDePasseChange and PasswordResetTokenUsed events + $passwordChangedEvents = array_filter( + $this->dispatchedEvents, + static fn ($e) => $e instanceof MotDePasseChange + ); + self::assertCount(1, $passwordChangedEvents); + } + + #[Test] + public function itDispatchesTokenUsedEvent(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + ($this->handler)($command); + + $tokenUsedEvents = array_filter( + $this->dispatchedEvents, + static fn ($e) => $e instanceof PasswordResetTokenUsed + ); + self::assertCount(1, $tokenUsedEvents); + } + + #[Test] + public function itThrowsWhenTokenNotFound(): void + { + $command = new ResetPasswordCommand( + token: 'nonexistent-token', + newPassword: self::NEW_PASSWORD, + ); + + $this->expectException(PasswordResetTokenNotFoundException::class); + + ($this->handler)($command); + } + + #[Test] + public function itThrowsWhenTokenExpired(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + + // Create an expired token (2 hours ago) + $token = PasswordResetToken::generate( + userId: (string) $user->id, + email: self::EMAIL, + tenantId: $this->tenantId, + createdAt: new DateTimeImmutable('2026-01-28 07:00:00'), // 3 hours ago + ); + $this->tokenRepository->save($token); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + $this->expectException(PasswordResetTokenExpiredException::class); + + ($this->handler)($command); + } + + #[Test] + public function itThrowsWhenTokenAlreadyUsed(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + // First use succeeds + ($this->handler)($command); + + // Second attempt with same token should fail with "already used" + // (token remains in storage until TTL expiry to preserve this error semantic) + $this->expectException(PasswordResetTokenAlreadyUsedException::class); + + ($this->handler)($command); + } + + #[Test] + public function itKeepsUsedTokenInStorageToPreserveErrorSemantics(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + $tokenValue = $token->tokenValue; + + $command = new ResetPasswordCommand( + token: $tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + ($this->handler)($command); + + // Token should remain in storage (marked as used) until TTL expiry + // This allows distinguishing "already used" (410) from "invalid" (400) + $foundToken = $this->tokenRepository->findByTokenValue($tokenValue); + self::assertNotNull($foundToken); + self::assertTrue($foundToken->isUsed()); + } + + #[Test] + public function itInvalidatesAllUserSessionsAfterPasswordReset(): void + { + $user = $this->createAndSaveUser(self::EMAIL); + $token = $this->createAndSaveToken($user); + + // Create active refresh tokens for the user (simulating active sessions) + $refreshToken = RefreshToken::create( + userId: $user->id, + tenantId: $this->tenantId, + deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'), + issuedAt: $this->clock->now(), + ); + $this->refreshTokenRepository->save($refreshToken); + + // Verify user has active sessions before reset + self::assertTrue($this->refreshTokenRepository->hasActiveSessionsForUser($user->id)); + + $command = new ResetPasswordCommand( + token: $token->tokenValue, + newPassword: self::NEW_PASSWORD, + ); + + ($this->handler)($command); + + // All sessions should be invalidated after password reset (AC3) + self::assertFalse($this->refreshTokenRepository->hasActiveSessionsForUser($user->id)); + } + + private function createAndSaveUser(string $email): User + { + $user = User::creer( + email: new Email($email), + role: Role::PROF, + tenantId: $this->tenantId, + schoolName: 'École Test', + dateNaissance: new DateTimeImmutable('1990-01-01'), + createdAt: $this->clock->now(), + ); + + // Activate user + $consentementPolicy = new ConsentementParentalPolicy($this->clock); + $user->activer( + hashedPassword: '$argon2id$oldhashed', + at: $this->clock->now(), + consentementPolicy: $consentementPolicy, + ); + + $this->userRepository->save($user); + + return $user; + } + + private function createAndSaveToken(User $user): PasswordResetToken + { + $token = PasswordResetToken::generate( + userId: (string) $user->id, + email: self::EMAIL, + tenantId: $this->tenantId, + createdAt: $this->clock->now(), + ); + // Drain events to avoid counting generation event + $token->pullDomainEvents(); + $this->tokenRepository->save($token); + + return $token; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php index dc00039..5ef6add 100644 --- a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php +++ b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php @@ -64,12 +64,12 @@ final class ActivationTokenTest extends TestCase } #[Test] - public function tokenValueIsUuidV4Format(): void + public function tokenValueIsUuidV7Format(): void { $token = $this->createToken(); self::assertMatchesRegularExpression( - '/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', + '/^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $token->tokenValue, ); } diff --git a/backend/tests/Unit/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenTest.php b/backend/tests/Unit/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenTest.php new file mode 100644 index 0000000..72bb98e --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/PasswordResetToken/PasswordResetTokenTest.php @@ -0,0 +1,293 @@ +tenantId = TenantId::generate(); + $this->now = new DateTimeImmutable('2026-01-28 10:00:00'); + } + + #[Test] + public function itGeneratesANewToken(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + self::assertInstanceOf(PasswordResetTokenId::class, $token->id); + self::assertNotEmpty($token->tokenValue); + self::assertSame('user-123', $token->userId); + self::assertSame('user@example.com', $token->email); + self::assertTrue($token->tenantId->equals($this->tenantId)); + self::assertSame($this->now, $token->createdAt); + self::assertNull($token->usedAt); + } + + #[Test] + public function itExpiresAfterOneHour(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $expectedExpiresAt = $this->now->modify('+1 hour'); + self::assertEquals($expectedExpiresAt, $token->expiresAt); + } + + #[Test] + public function itIsNotExpiredBeforeOneHour(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $fiftyNineMinutesLater = $this->now->modify('+59 minutes'); + self::assertFalse($token->isExpired($fiftyNineMinutesLater)); + } + + #[Test] + public function itIsExpiredAfterOneHour(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $oneHourLater = $this->now->modify('+1 hour'); + self::assertTrue($token->isExpired($oneHourLater)); + } + + #[Test] + public function itRecordsGenerationEvent(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $events = $token->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(PasswordResetTokenGenerated::class, $events[0]); + + /** @var PasswordResetTokenGenerated $event */ + $event = $events[0]; + self::assertTrue($event->tokenId->equals($token->id)); + self::assertSame('user-123', $event->userId); + self::assertSame('user@example.com', $event->email); + self::assertTrue($event->tenantId->equals($this->tenantId)); + } + + #[Test] + public function itCanBeUsedWhenValid(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $token->pullDomainEvents(); // Clear generation event + + $useAt = $this->now->modify('+30 minutes'); + $token->use($useAt); + + self::assertSame($useAt, $token->usedAt); + self::assertTrue($token->isUsed()); + } + + #[Test] + public function itRecordsUsedEventWhenConsumed(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $token->pullDomainEvents(); // Clear generation event + + $useAt = $this->now->modify('+30 minutes'); + $token->use($useAt); + + $events = $token->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(PasswordResetTokenUsed::class, $events[0]); + + /** @var PasswordResetTokenUsed $event */ + $event = $events[0]; + self::assertTrue($event->tokenId->equals($token->id)); + self::assertSame('user-123', $event->userId); + } + + #[Test] + public function itCannotBeUsedTwice(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $useAt = $this->now->modify('+30 minutes'); + $token->use($useAt); + + $this->expectException(PasswordResetTokenAlreadyUsedException::class); + + $token->use($useAt->modify('+1 minute')); + } + + #[Test] + public function itCannotBeUsedWhenExpired(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $this->expectException(PasswordResetTokenExpiredException::class); + + $twoHoursLater = $this->now->modify('+2 hours'); + $token->use($twoHoursLater); + } + + #[Test] + public function itValidatesForUseWithoutConsuming(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $thirtyMinutesLater = $this->now->modify('+30 minutes'); + + // Should not throw + $token->validateForUse($thirtyMinutesLater); + + // Token should NOT be marked as used + self::assertFalse($token->isUsed()); + self::assertNull($token->usedAt); + } + + #[Test] + public function validateForUseThrowsWhenExpired(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $this->expectException(PasswordResetTokenExpiredException::class); + + $twoHoursLater = $this->now->modify('+2 hours'); + $token->validateForUse($twoHoursLater); + } + + #[Test] + public function validateForUseThrowsWhenAlreadyUsed(): void + { + $token = PasswordResetToken::generate( + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + ); + + $token->use($this->now->modify('+30 minutes')); + + $this->expectException(PasswordResetTokenAlreadyUsedException::class); + + $token->validateForUse($this->now->modify('+31 minutes')); + } + + #[Test] + public function itCanBeReconstitutedFromStorage(): void + { + $id = PasswordResetTokenId::generate(); + $tokenValue = 'abc-123-def-456'; + $expiresAt = $this->now->modify('+1 hour'); + $usedAt = $this->now->modify('+30 minutes'); + + $token = PasswordResetToken::reconstitute( + id: $id, + tokenValue: $tokenValue, + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + expiresAt: $expiresAt, + usedAt: $usedAt, + ); + + self::assertTrue($token->id->equals($id)); + self::assertSame($tokenValue, $token->tokenValue); + self::assertSame('user-123', $token->userId); + self::assertSame('user@example.com', $token->email); + self::assertTrue($token->tenantId->equals($this->tenantId)); + self::assertSame($this->now, $token->createdAt); + self::assertEquals($expiresAt, $token->expiresAt); + self::assertSame($usedAt, $token->usedAt); + self::assertTrue($token->isUsed()); + } + + #[Test] + public function reconstituteDoesNotRecordEvents(): void + { + $token = PasswordResetToken::reconstitute( + id: PasswordResetTokenId::generate(), + tokenValue: 'abc-123', + userId: 'user-123', + email: 'user@example.com', + tenantId: $this->tenantId, + createdAt: $this->now, + expiresAt: $this->now->modify('+1 hour'), + usedAt: null, + ); + + $events = $token->pullDomainEvents(); + + self::assertCount(0, $events); + } +} diff --git a/backend/tests/Unit/Shared/Domain/AggregateRootTest.php b/backend/tests/Unit/Shared/Domain/AggregateRootTest.php index 3f83882..628b292 100644 --- a/backend/tests/Unit/Shared/Domain/AggregateRootTest.php +++ b/backend/tests/Unit/Shared/Domain/AggregateRootTest.php @@ -82,6 +82,6 @@ final readonly class TestDomainEvent implements DomainEvent public function aggregateId(): UuidInterface { - return $this->testAggregateId ?? Uuid::uuid4(); + return $this->testAggregateId ?? Uuid::uuid7(); } } diff --git a/frontend/e2e/activation.spec.ts b/frontend/e2e/activation.spec.ts index 0ce510b..dc91a09 100644 --- a/frontend/e2e/activation.spec.ts +++ b/frontend/e2e/activation.spec.ts @@ -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 }); diff --git a/frontend/e2e/password-reset.spec.ts b/frontend/e2e/password-reset.spec.ts new file mode 100644 index 0000000..30618ae --- /dev/null +++ b/frontend/e2e/password-reset.spec.ts @@ -0,0 +1,271 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Helper to create a password reset token via CLI command + */ +function createResetToken(options: { email: string; expired?: boolean }): string | null { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + try { + const expiredFlag = options.expired ? ' --expired' : ''; + // Use APP_ENV=test to ensure Redis cache is used (same as the web server in CI) + const result = execSync( + `docker compose -f "${composeFile}" exec -T -e APP_ENV=test php php bin/console app:dev:create-test-password-reset-token --email=${options.email}${expiredFlag} 2>&1`, + { encoding: 'utf-8' } + ); + + const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i); + if (tokenMatch) { + return tokenMatch[1]; + } + console.error('Could not extract token from output:', result); + return null; + } catch (error) { + console.error('Failed to create reset token:', error); + return null; + } +} + +test.describe('Password Reset Flow', () => { + test.describe('Forgot Password Page (/mot-de-passe-oublie)', () => { + test('displays the forgot password form', async ({ page }) => { + await page.goto('/mot-de-passe-oublie'); + + // Page should have the heading and form elements + await expect(page.getByRole('heading', { name: /mot de passe oublié/i })).toBeVisible(); + await expect(page.locator('input#email')).toBeVisible(); + await expect( + page.getByRole('button', { name: /envoyer le lien de réinitialisation/i }) + ).toBeVisible(); + }); + + test('shows success message after submitting email', async ({ page }) => { + await page.goto('/mot-de-passe-oublie'); + + const emailInput = page.locator('input#email'); + await emailInput.fill('test-forgot@example.com'); + + await page.getByRole('button', { name: /envoyer le lien de réinitialisation/i }).click(); + + // Should show success message (always, to prevent enumeration) + await expect(page.getByText(/vérifiez votre boîte mail/i)).toBeVisible({ timeout: 10000 }); + }); + + test('has link to login page', async ({ page }) => { + await page.goto('/mot-de-passe-oublie'); + + const loginLink = page.getByRole('link', { name: /retour à la connexion/i }); + await expect(loginLink).toBeVisible(); + }); + }); + + test.describe('Reset Password Page (/reset-password/[token])', () => { + test.describe('Password Form Display', () => { + test('displays password form', async ({ page }, testInfo) => { + // Create a fresh token for this test + const email = `e2e-form-display-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + await page.goto(`/reset-password/${token}`); + + // Form should be visible + await expect(page.locator('form')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('#password')).toBeVisible(); + await expect(page.locator('#confirmPassword')).toBeVisible(); + }); + + test('validates password requirements in real-time', async ({ page }, testInfo) => { + const email = `e2e-validation-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const passwordInput = page.locator('#password'); + + // Test short password - should not pass length requirement + await passwordInput.fill('Abc1!'); + const minLengthItem = page + .locator('.requirements li') + .filter({ hasText: /8 caractères/ }); + await expect(minLengthItem).not.toHaveClass(/valid/); + + // Test missing uppercase + await passwordInput.fill('abcdefgh1!'); + const uppercaseItem = page + .locator('.requirements li') + .filter({ hasText: /majuscule/ }); + await expect(uppercaseItem).not.toHaveClass(/valid/); + + // Valid password should show all checkmarks (5 requirements) + await passwordInput.fill('Abcdefgh1!'); + const validItems = page.locator('.requirements li.valid'); + await expect(validItems).toHaveCount(5); + }); + + test('validates password confirmation matches', async ({ page }, testInfo) => { + const email = `e2e-confirm-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const passwordInput = page.locator('#password'); + const confirmInput = page.locator('#confirmPassword'); + + await passwordInput.fill('SecurePass123!'); + await confirmInput.fill('DifferentPass123!'); + + // Should show mismatch error + await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible(); + + // Fix confirmation + await confirmInput.fill('SecurePass123!'); + await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible(); + }); + + test('submit button is disabled until form is valid', async ({ page }, testInfo) => { + const email = `e2e-button-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const submitButton = page.getByRole('button', { name: /réinitialiser/i }); + + // Initially disabled + await expect(submitButton).toBeDisabled(); + + // Fill valid password + await page.locator('#password').fill('NewSecurePass123!'); + await page.locator('#confirmPassword').fill('NewSecurePass123!'); + + // Should now be enabled + await expect(submitButton).toBeEnabled(); + }); + }); + + test.describe('Token Validation', () => { + test('shows error for invalid token after form submission', async ({ page }) => { + // Use an invalid token format + await page.goto('/reset-password/00000000-0000-0000-0000-000000000000'); + + // Form should still be visible (validation happens on submit) + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password and submit + await page.locator('#password').fill('ValidPassword123!'); + await page.locator('#confirmPassword').fill('ValidPassword123!'); + + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show error after submission + await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({ + timeout: 10000 + }); + }); + + test('shows expiration message for expired token after form submission', async ({ + page + }, testInfo) => { + const email = `e2e-expired-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email, expired: true }); + test.skip(!token, 'Could not create expired test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password and submit + await page.locator('#password').fill('ValidPassword123!'); + await page.locator('#confirmPassword').fill('ValidPassword123!'); + + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show expired/used error (shows "Lien invalide" heading with expiry message) + await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({ + timeout: 10000 + }); + }); + }); + + test.describe('Complete Reset Flow', () => { + test('successfully resets password and redirects to login', async ({ page }, testInfo) => { + const email = `e2e-success-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill form + await page.locator('#password').fill('NewSecurePass123!'); + await page.locator('#confirmPassword').fill('NewSecurePass123!'); + + const submitButton = page.getByRole('button', { name: /réinitialiser/i }); + await expect(submitButton).toBeEnabled(); + + // Submit + await submitButton.click(); + + // Should show success message (heading "Mot de passe modifié") + await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({ + timeout: 10000 + }); + }); + + test('token cannot be reused after successful reset', async ({ page }, testInfo) => { + const email = `e2e-reuse-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + // First reset + await page.goto(`/reset-password/${token}`); + await expect(page.locator('form')).toBeVisible({ timeout: 5000 }); + + await page.locator('#password').fill('FirstPassword123!'); + await page.locator('#confirmPassword').fill('FirstPassword123!'); + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Wait for success (heading "Mot de passe modifié") + await expect(page.getByRole('heading', { name: 'Mot de passe modifié' })).toBeVisible({ + timeout: 10000 + }); + + // Try to use same token again + await page.goto(`/reset-password/${token}`); + await expect(page.locator('form')).toBeVisible({ timeout: 5000 }); + + await page.locator('#password').fill('SecondPassword123!'); + await page.locator('#confirmPassword').fill('SecondPassword123!'); + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show error (already used) - page shows "Lien invalide" heading + await expect(page.getByRole('heading', { name: 'Lien invalide' })).toBeVisible({ + timeout: 10000 + }); + }); + }); + }); +}); diff --git a/frontend/src/routes/activate/[token]/+page.svelte b/frontend/src/routes/activate/[token]/+page.svelte index 97b9896..07dc4c4 100644 --- a/frontend/src/routes/activate/[token]/+page.svelte +++ b/frontend/src/routes/activate/[token]/+page.svelte @@ -22,9 +22,13 @@ // Critères de validation du mot de passe const hasMinLength = $derived(password.length >= 8); const hasUppercase = $derived(/[A-Z]/.test(password)); + const hasLowercase = $derived(/[a-z]/.test(password)); const hasDigit = $derived(/[0-9]/.test(password)); + const hasSpecial = $derived(/[^A-Za-z0-9]/.test(password)); const passwordsMatch = $derived(password === passwordConfirmation && password.length > 0); - const isPasswordValid = $derived(hasMinLength && hasUppercase && hasDigit && passwordsMatch); + const isPasswordValid = $derived( + hasMinLength && hasUppercase && hasLowercase && hasDigit && hasSpecial && passwordsMatch + ); // Query pour récupérer les infos du token // svelte-ignore state_referenced_locally @@ -209,11 +213,19 @@
  • {hasUppercase ? '✓' : '○'} - Au moins 1 majuscule + Une majuscule +
  • +
  • + {hasLowercase ? '✓' : '○'} + Une minuscule
  • {hasDigit ? '✓' : '○'} - Au moins 1 chiffre + Un chiffre +
  • +
  • + {hasSpecial ? '✓' : '○'} + Un caractère spécial
  • diff --git a/frontend/src/routes/mot-de-passe-oublie/+page.svelte b/frontend/src/routes/mot-de-passe-oublie/+page.svelte new file mode 100644 index 0000000..c13fb8d --- /dev/null +++ b/frontend/src/routes/mot-de-passe-oublie/+page.svelte @@ -0,0 +1,416 @@ + + + + Mot de passe oublié | Classeo + + + + + +
    +
    + + + +
    + {#if isSubmitted} + +
    +
    + +
    +

    Vérifiez votre boîte mail

    +

    + Si un compte existe avec l'adresse {email}, vous recevrez + un email avec les instructions pour réinitialiser votre mot de passe. +

    +

    + Le lien sera valide pendant 1 heure. +

    + +
    + {:else} + +

    Mot de passe oublié

    +

    + Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser + votre mot de passe. +

    + + {#if error} +
    + + {error} +
    + {/if} + +
    +
    + +
    + +
    +
    + + +
    + + + {/if} +
    + + +
    +
    + + diff --git a/frontend/src/routes/reset-password/[token]/+page.svelte b/frontend/src/routes/reset-password/[token]/+page.svelte new file mode 100644 index 0000000..d5581e6 --- /dev/null +++ b/frontend/src/routes/reset-password/[token]/+page.svelte @@ -0,0 +1,596 @@ + + + + Nouveau mot de passe | Classeo + + + + + +
    +
    + + + +
    + {#if isSuccess} + +
    +
    + +
    +

    Mot de passe modifié

    +

    + Votre mot de passe a été réinitialisé avec succès.
    + Vous allez être redirigé vers la page de connexion... +

    + +
    + {:else if error?.type === 'invalid_token' || error?.type === 'expired_token'} + +
    +
    + +
    +

    Lien invalide

    +

    {error.message}

    +

    + Vous pouvez faire une nouvelle demande de réinitialisation. +

    + +
    + {:else} + +

    Nouveau mot de passe

    +

    Choisissez un nouveau mot de passe sécurisé pour votre compte.

    + + {#if error} +
    + + {error.message} +
    + {/if} + +
    +
    + +
    + + +
    + + +
      +
    • Au moins 8 caractères
    • +
    • Une majuscule
    • +
    • Une minuscule
    • +
    • Un chiffre
    • +
    • Un caractère spécial
    • +
    +
    + +
    + +
    + + +
    + {#if confirmPassword && !passwordsMatch} +

    Les mots de passe ne correspondent pas

    + {/if} + {#if passwordsMatch && confirmPassword} +

    Les mots de passe correspondent

    + {/if} +
    + + +
    + {/if} +
    + + +
    +
    + +