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

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

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

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

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

View File

@@ -80,7 +80,7 @@ cs-check: ## Vérifier le code style PHP sans corriger
.PHONY: test-php .PHONY: test-php
test-php: ## Lancer les tests PHPUnit test-php: ## Lancer les tests PHPUnit
docker compose exec php composer test docker compose exec -e APP_ENV=test php composer test
.PHONY: warmup .PHONY: warmup
warmup: ## Préchauffer le cache Symfony warmup: ## Préchauffer le cache Symfony
@@ -116,6 +116,47 @@ test: test-php test-js ## Lancer tous les tests (PHPUnit + Vitest)
.PHONY: check .PHONY: check
check: phpstan cs-check lint check-types ## Lancer tous les linters et checks 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 # Scripts
# ============================================================================= # =============================================================================

View File

@@ -83,3 +83,9 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# Fail open on API errors: true=allow through (dev), false=block (prod) # Fail open on API errors: true=allow through (dev), false=block (prod)
TURNSTILE_FAIL_OPEN=true TURNSTILE_FAIL_OPEN=true
###< cloudflare/turnstile ### ###< cloudflare/turnstile ###
###> symfony/lock ###
# Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###

View File

@@ -1,3 +1,4 @@
# define your env variables for the test env here # define your env variables for the test env here
APP_ENV=test
KERNEL_CLASS='App\Kernel' KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'

View File

@@ -26,6 +26,7 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "^8.0", "symfony/framework-bundle": "^8.0",
"symfony/http-client": "8.0.*", "symfony/http-client": "8.0.*",
"symfony/lock": "8.0.*",
"symfony/mailer": "8.0.*", "symfony/mailer": "8.0.*",
"symfony/messenger": "^8.0", "symfony/messenger": "^8.0",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",

84
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "07fe67e8d6e7bdfbca22ab4e7c6a65c2", "content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",
@@ -4189,6 +4189,88 @@
], ],
"time": "2026-01-28T10:46:31+00:00" "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", "name": "symfony/mailer",
"version": "v8.0.4", "version": "v8.0.4",

View File

@@ -19,11 +19,43 @@ framework:
adapter: cache.adapter.filesystem adapter: cache.adapter.filesystem
default_lifetime: 604800 # 7 jours 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) # Pool dédié au rate limiting (15 min TTL)
cache.rate_limiter: cache.rate_limiter:
adapter: cache.adapter.filesystem adapter: cache.adapter.filesystem
default_lifetime: 900 # 15 minutes 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: when@prod:
framework: framework:
cache: cache:
@@ -44,6 +76,10 @@ when@prod:
adapter: cache.adapter.redis adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%' provider: '%env(REDIS_URL)%'
default_lifetime: 604800 # 7 jours 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: cache.rate_limiter:
adapter: cache.adapter.redis adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%' provider: '%env(REDIS_URL)%'

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ security:
failure_handler: App\Administration\Infrastructure\Security\LoginFailureHandler failure_handler: App\Administration\Infrastructure\Security\LoginFailureHandler
provider: app_user_provider provider: app_user_provider
api_public: 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 stateless: true
security: false security: false
api: api:
@@ -48,6 +48,8 @@ security:
- { path: ^/api/activate, roles: PUBLIC_ACCESS } - { path: ^/api/activate, roles: PUBLIC_ACCESS }
- { path: ^/api/token/refresh, roles: PUBLIC_ACCESS } - { path: ^/api/token/refresh, roles: PUBLIC_ACCESS }
- { path: ^/api/token/logout, 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 } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test: when@test:

View File

@@ -19,6 +19,8 @@ services:
Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache' Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache'
# Bind refresh tokens cache pool (7-day TTL) # Bind refresh tokens cache pool (7-day TTL)
Psr\Cache\CacheItemPoolInterface $refreshTokensCache: '@refresh_tokens.cache' 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 # Bind named message buses
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus' Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus' Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
@@ -79,6 +81,14 @@ services:
arguments: arguments:
$appUrl: '%app.url%' $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) # Audit log handler (uses dedicated audit channel)
App\Administration\Infrastructure\Messaging\AuditLoginEventsHandler: App\Administration\Infrastructure\Messaging\AuditLoginEventsHandler:
arguments: arguments:
@@ -98,6 +108,16 @@ services:
App\Administration\Domain\Repository\RefreshTokenRepository: App\Administration\Domain\Repository\RefreshTokenRepository:
alias: App\Administration\Infrastructure\Persistence\Redis\RedisRefreshTokenRepository 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 # Login handlers
App\Administration\Infrastructure\Security\LoginSuccessHandler: App\Administration\Infrastructure\Security\LoginSuccessHandler:
tags: tags:

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RequestPasswordReset;
use App\Shared\Domain\Tenant\TenantId;
/**
* Command to request a password reset.
*
* This command is dispatched when a user submits the "forgot password" form.
* The handler will generate a reset token and trigger the email sending.
*
* Security: The handler always returns success, even if the email doesn't exist,
* to prevent email enumeration attacks.
*/
final readonly class RequestPasswordResetCommand
{
public function __construct(
public string $email,
public TenantId $tenantId,
) {
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RequestPasswordReset;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handles password reset requests.
*
* Security principles:
* - Never reveals if email exists (prevents enumeration)
* - Reuses existing valid token if one exists (prevents token flooding)
* - Rate limited at API level (3 requests/hour per IP)
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RequestPasswordResetHandler
{
public function __construct(
private UserRepository $userRepository,
private PasswordResetTokenRepository $tokenRepository,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* Process password reset request.
*
* Always succeeds from the caller's perspective.
* Only generates token and dispatches events if user exists.
*
* Security: performs similar work for non-existent emails to prevent timing attacks.
*/
public function __invoke(RequestPasswordResetCommand $command): void
{
// Try to parse email - silently return on invalid email (no enumeration)
try {
$email = new Email($command->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);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResetPassword;
/**
* Command to reset a user's password using a valid reset token.
*/
final readonly class ResetPasswordCommand
{
public function __construct(
public string $token,
public string $newPassword,
) {
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResetPassword;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handles password reset using a valid token.
*
* Security:
* - Validates token is not expired
* - Validates token is not already used
* - Marks token as used after successful reset
* - Invalidates all user sessions (force re-authentication)
* - Dispatches events for audit and confirmation email
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ResetPasswordHandler
{
public function __construct(
private PasswordResetTokenRepository $tokenRepository,
private UserRepository $userRepository,
private RefreshTokenRepository $refreshTokenRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* Process password reset.
*
* @throws PasswordResetTokenNotFoundException if token doesn't exist
* @throws PasswordResetTokenExpiredException if token has expired
* @throws PasswordResetTokenAlreadyUsedException if token was already used
*/
public function __invoke(ResetPasswordCommand $command): void
{
$now = $this->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);
}
}
}

View File

@@ -16,19 +16,19 @@ use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException; use InvalidArgumentException;
/** /**
* Gère le cycle de vie des refresh tokens. * Manages the lifecycle of refresh tokens.
* *
* Responsabilités : * Responsibilities:
* - Création de tokens pour nouvelles sessions * - Token creation for new sessions
* - Rotation des tokens avec détection de replay * - Token rotation with replay detection
* - Invalidation de familles de tokens compromises * - Invalidation of compromised token families
* *
* @see Story 1.4 - Connexion utilisateur * @see Story 1.4 - User login
*/ */
final readonly class RefreshTokenManager final readonly class RefreshTokenManager
{ {
private const int WEB_TTL_SECONDS = 86400; // 1 jour pour web private const int WEB_TTL_SECONDS = 86400; // 1 day for web
private const int MOBILE_TTL_SECONDS = 604800; // 7 jours pour mobile private const int MOBILE_TTL_SECONDS = 604800; // 7 days for mobile
public function __construct( public function __construct(
private RefreshTokenRepository $repository, 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( public function create(
UserId $userId, UserId $userId,
@@ -47,7 +47,7 @@ final readonly class RefreshTokenManager
): RefreshToken { ): RefreshToken {
$ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS; $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)); $jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100));
$ttl += $jitter; $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 TokenReplayDetectedException if a replay attack is detected
* @throws TokenAlreadyRotatedException si le token a déjà été rotaté mais est en grace period * @throws TokenAlreadyRotatedException if the token has already been rotated but is in grace period
* @throws InvalidArgumentException si le token est invalide ou expiré * @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( public function refresh(
string $tokenString, string $tokenString,
@@ -85,53 +85,53 @@ final readonly class RefreshTokenManager
throw new InvalidArgumentException('Token not found'); throw new InvalidArgumentException('Token not found');
} }
// Vérifier l'expiration // Check expiration
if ($token->isExpired($now)) { if ($token->isExpired($now)) {
$this->repository->delete($tokenId); $this->repository->delete($tokenId);
throw new InvalidArgumentException('Token expired'); throw new InvalidArgumentException('Token expired');
} }
// Vérifier le device fingerprint // Check device fingerprint
if (!$token->matchesDevice($deviceFingerprint)) { 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); $this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($token->familyId); throw new TokenReplayDetectedException($token->familyId);
} }
// Détecter les replay attacks // Detect replay attacks
if ($token->isRotated) { if ($token->isRotated) {
// Token déjà utilisé ! // Token already used!
if ($token->isInGracePeriod($now)) { if ($token->isInGracePeriod($now)) {
// Dans la grace period - probablement une race condition légitime // In grace period - probably a legitimate race condition
// On laisse passer mais on ne génère pas de nouveau token // We let it pass but don't generate a new token
// Le client devrait utiliser le token le plus récent // The client should use the most recent token
// Exception dédiée pour ne PAS supprimer le cookie lors d'une race condition légitime // Dedicated exception to NOT delete the cookie during a legitimate race condition
throw new TokenAlreadyRotatedException(); throw new TokenAlreadyRotatedException();
} }
// Replay attack confirmé - invalider toute la famille // Confirmed replay attack - invalidate the entire family
$this->repository->invalidateFamily($token->familyId); $this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($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); [$newToken, $rotatedOldToken] = $token->rotate($now);
// Sauvegarder le nouveau token EN PREMIER // Save the new token FIRST
// Important: sauvegarder le nouveau token EN PREMIER pour que l'index famille garde le bon TTL // Important: save the new token FIRST so the family index keeps the correct TTL
$this->repository->save($newToken); $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); $this->repository->save($rotatedOldToken);
return $newToken; return $newToken;
} }
/** /**
* Révoque un token (déconnexion). * Revokes a token (logout).
*/ */
public function revoke(string $tokenString): void public function revoke(string $tokenString): void
{ {
@@ -140,18 +140,18 @@ final readonly class RefreshTokenManager
$token = $this->repository->find($tokenId); $token = $this->repository->find($tokenId);
if ($token !== null) { 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); $this->repository->invalidateFamily($token->familyId);
} }
} catch (InvalidArgumentException) { } 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 public function invalidateFamily(TokenFamilyId $familyId): void
{ {

View File

@@ -10,12 +10,12 @@ use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface; 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 * Note: The email is recorded for tracking but does not reveal
* si le compte existe (me message d'erreur dans tous les cas). * 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 final readonly class ConnexionEchouee implements DomainEvent
{ {
@@ -35,7 +35,7 @@ final readonly class ConnexionEchouee implements DomainEvent
public function aggregateId(): UuidInterface 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( return Uuid::uuid5(
Uuid::NAMESPACE_DNS, Uuid::NAMESPACE_DNS,
'login_attempt:' . $this->email, 'login_attempt:' . $this->email,

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
/**
* Event emitted when a user's password is changed.
*
* This event triggers:
* - Sending a confirmation email
* - Audit logging
*/
final readonly class MotDePasseChange implements DomainEvent
{
public function __construct(
public string $userId,
public string $email,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return Uuid::fromString($this->userId);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class PasswordResetTokenGenerated implements DomainEvent
{
public function __construct(
public PasswordResetTokenId $tokenId,
public string $tokenValue,
public string $userId,
public string $email,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->tokenId->value;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class PasswordResetTokenUsed implements DomainEvent
{
public function __construct(
public PasswordResetTokenId $tokenId,
public string $userId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->tokenId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use function sprintf;
final class PasswordResetTokenAlreadyUsedException extends RuntimeException
{
public static function forToken(PasswordResetTokenId $tokenId): self
{
return new self(sprintf(
'Password reset token "%s" has already been used.',
$tokenId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use function sprintf;
final class PasswordResetTokenExpiredException extends RuntimeException
{
public static function forToken(PasswordResetTokenId $tokenId): self
{
return new self(sprintf(
'Password reset token "%s" has expired.',
$tokenId,
));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use function sprintf;
final class PasswordResetTokenNotFoundException extends RuntimeException
{
public static function withId(PasswordResetTokenId $tokenId): self
{
return new self(sprintf(
'Password reset token with ID "%s" not found.',
$tokenId,
));
}
public static function withTokenValue(string $tokenValue): self
{
return new self(sprintf(
'Password reset token with value "%s" not found.',
$tokenValue,
));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
/**
* Thrown when a token consumption is already in progress.
*
* This indicates a concurrent request is processing the same token,
* and the client should retry after a short delay.
*/
final class TokenConsumptionInProgressException extends RuntimeException
{
public function __construct(string $tokenValue)
{
parent::__construct(
sprintf('Token consumption in progress for token "%s". Please retry.', $tokenValue)
);
}
}

View File

@@ -44,7 +44,7 @@ final class ActivationToken extends AggregateRoot
): self { ): self {
$token = new self( $token = new self(
id: ActivationTokenId::generate(), id: ActivationTokenId::generate(),
tokenValue: Uuid::uuid4()->toString(), tokenValue: Uuid::uuid7()->toString(),
userId: $userId, userId: $userId,
email: $email, email: $email,
tenantId: $tenantId, tenantId: $tenantId,

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\PasswordResetToken;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Event\PasswordResetTokenUsed;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use function sprintf;
final class PasswordResetToken extends AggregateRoot
{
private const int EXPIRATION_HOURS = 1;
public private(set) ?DateTimeImmutable $usedAt = null;
private function __construct(
public private(set) PasswordResetTokenId $id,
public private(set) string $tokenValue,
public private(set) string $userId,
public private(set) string $email,
public private(set) TenantId $tenantId,
public private(set) DateTimeImmutable $createdAt,
public private(set) DateTimeImmutable $expiresAt,
) {
}
public static function generate(
string $userId,
string $email,
TenantId $tenantId,
DateTimeImmutable $createdAt,
): self {
$token = new self(
id: PasswordResetTokenId::generate(),
tokenValue: Uuid::uuid7()->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,
));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\PasswordResetToken;
use App\Shared\Domain\EntityId;
final readonly class PasswordResetTokenId extends EntityId
{
}

View File

@@ -10,39 +10,39 @@ use DateTimeImmutable;
use InvalidArgumentException; use InvalidArgumentException;
/** /**
* Représente un refresh token pour le renouvellement silencieux des sessions. * Represents a refresh token for silent session renewal.
* *
* Stratégie de sécurité : * Security strategy:
* - Rotation : chaque utilisation génère un nouveau token et invalide l'ancien * - Rotation: each use generates a new token and invalidates the old one
* - Family tracking : tous les tokens d'une session partagent un family_id * - Family tracking: all tokens from a session share a family_id
* - Replay detection : si un token déjà utilisé est présenté, toute la famille est invalidée * - Replay detection: if an already-used token is presented, the entire family is invalidated
* - Device binding : le token est lié à un device fingerprint * - Device binding: the token is bound to a device fingerprint
* - Grace period : 30s de tolérance pour les race conditions multi-onglets * - Grace period: 30s tolerance for multi-tab race conditions
* *
* Note sur les méthodes statiques : * Note on static methods:
* Cette classe utilise des factory methods statiques (create(), reconstitute()) conformément * This class uses static factory methods (create(), reconstitute()) following standard
* aux patterns DDD standards pour la création d'Aggregates. Bien que le projet suive les * DDD patterns for Aggregate creation. Although the project follows Elegant Objects
* principes Elegant Objects "No Static", les factory methods pour les Aggregates sont une * "No Static" principles, factory methods for Aggregates are a documented exception
* exception documentée car elles encapsulent la logique d'instanciation et rendent le * as they encapsulate instantiation logic and keep the constructor private, thus
* constructeur privé, préservant ainsi l'invariant du domain. * preserving domain invariants.
* *
* @see Story 1.4 - Connexion utilisateur * @see Story 1.4 - User login
*/ */
final readonly class RefreshToken final readonly class RefreshToken
{ {
/** /**
* TTL par défaut : 7 jours (604800 secondes). * Default TTL: 7 days (604800 seconds).
* *
* Ce TTL est utilisé si aucun TTL n'est spécifié à la création du token. * This TTL is used if none is specified at token creation.
* RefreshTokenManager utilise 1 jour (86400s) pour les sessions web afin * RefreshTokenManager uses 1 day (86400s) for web sessions to
* de limiter l'exposition en cas de vol de cookie sur navigateur. * limit exposure in case of cookie theft on browsers.
*/ */
private const int DEFAULT_TTL_SECONDS = 604800; private const int DEFAULT_TTL_SECONDS = 604800;
/** /**
* Période de grâce après rotation pour gérer les race conditions multi-onglets. * Grace period after rotation to handle multi-tab race conditions.
* Si deux onglets rafraîchissent simultanément, le second recevra une erreur * If two tabs refresh simultaneously, the second will receive a benign
* bénigne au lieu d'invalider toute la famille de tokens. * error instead of invalidating the entire token family.
*/ */
private const int GRACE_PERIOD_SECONDS = 30; private const int GRACE_PERIOD_SECONDS = 30;
@@ -61,7 +61,7 @@ final readonly class RefreshToken
} }
/** /**
* Crée un nouveau refresh token pour une nouvelle session. * Creates a new refresh token for a new session.
*/ */
public static function create( public static function create(
UserId $userId, UserId $userId,
@@ -85,27 +85,27 @@ final readonly class RefreshToken
} }
/** /**
* Effectue une rotation du token (génère un nouveau token, marque l'ancien comme rotaté). * Performs token rotation (generates a new token, marks the old one as rotated).
* *
* Le nouveau token conserve le même TTL que l'original pour respecter la politique de session * The new token preserves the same TTL as the original to respect the session policy
* (web = 1 jour, mobile = 7 jours). L'ancien token est marqué avec rotatedAt pour la grace period. * (web = 1 day, mobile = 7 days). The old token is marked with rotatedAt for the grace period.
* *
* @return array{0: self, 1: self} Le nouveau token et l'ancien token marqué comme rotaté * @return array{0: self, 1: self} The new token and the old token marked as rotated
*/ */
public function rotate(DateTimeImmutable $at): array public function rotate(DateTimeImmutable $at): array
{ {
// Préserver le TTL original pour respecter la politique de session (web = 1 jour, mobile = 7 jours) // Preserve original TTL to respect session policy (web = 1 day, mobile = 7 days)
$originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp(); $originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp();
$newToken = new self( $newToken = new self(
id: RefreshTokenId::generate(), id: RefreshTokenId::generate(),
familyId: $this->familyId, // me famille familyId: $this->familyId, // Same family
userId: $this->userId, userId: $this->userId,
tenantId: $this->tenantId, tenantId: $this->tenantId,
deviceFingerprint: $this->deviceFingerprint, deviceFingerprint: $this->deviceFingerprint,
issuedAt: $at, issuedAt: $at,
expiresAt: $at->modify("+{$originalTtlSeconds} seconds"), expiresAt: $at->modify("+{$originalTtlSeconds} seconds"),
rotatedFrom: $this->id, // Traçabilité rotatedFrom: $this->id, // Traceability
isRotated: false, isRotated: false,
rotatedAt: null, rotatedAt: null,
); );
@@ -120,14 +120,14 @@ final readonly class RefreshToken
expiresAt: $this->expiresAt, expiresAt: $this->expiresAt,
rotatedFrom: $this->rotatedFrom, rotatedFrom: $this->rotatedFrom,
isRotated: true, isRotated: true,
rotatedAt: $at, // Pour la grace period rotatedAt: $at, // For the grace period
); );
return [$newToken, $rotatedOldToken]; return [$newToken, $rotatedOldToken];
} }
/** /**
* Vérifie si le token est expiré. * Checks if the token is expired.
*/ */
public function isExpired(DateTimeImmutable $at): bool 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 * The grace period handles race conditions when multiple tabs attempt to
* tentent de rafraîchir le token simultanément. Elle est basée sur le moment * refresh the token simultaneously. It is based on the rotation time,
* de la rotation, pas sur l'émission initiale du token. * not the initial token issuance.
*/ */
public function isInGracePeriod(DateTimeImmutable $at): bool 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 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 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 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( public static function reconstitute(
RefreshTokenId $id, RefreshTokenId $id,

View File

@@ -5,18 +5,18 @@ declare(strict_types=1);
namespace App\Administration\Domain\Model\User; 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 enum StatutCompte: string
{ {
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation case EN_ATTENTE = 'pending'; // Account created, awaiting activation
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent
case ACTIF = 'active'; // Compte activé et utilisable case ACTIF = 'active'; // Account activated and usable
case SUSPENDU = 'suspended'; // Compte temporairement désactivé case SUSPENDU = 'suspended'; // Account temporarily disabled
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité) 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 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 public function peutActiver(): bool
{ {

View File

@@ -6,6 +6,7 @@ namespace App\Administration\Domain\Model\User;
use App\Administration\Domain\Event\CompteActive; use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated; use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Exception\CompteNonActivableException; use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental; use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Policy\ConsentementParentalPolicy; use App\Administration\Domain\Policy\ConsentementParentalPolicy;
@@ -14,11 +15,11 @@ use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; 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. * A user belongs to a school (tenant) and has a role.
* Le cycle de vie du compte passe par plusieurs statuts : création → activation. * The account lifecycle goes through multiple statuses: creation → activation.
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation. * Minors (< 15 years) require parental consent before activation.
*/ */
final class User extends AggregateRoot 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( public static function creer(
Email $email, 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( public function activer(
string $hashedPassword, string $hashedPassword,
@@ -85,7 +86,7 @@ final class User extends AggregateRoot
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut); 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 ($consentementPolicy->estRequis($this->dateNaissance)) {
if ($this->consentementParental === null) { if ($this->consentementParental === null) {
throw CompteNonActivableException::carConsentementManquant($this->id); 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 public function enregistrerConsentementParental(ConsentementParental $consentement): void
{ {
$this->consentementParental = $consentement; $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) { if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
$this->statut = StatutCompte::EN_ATTENTE; $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 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 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( public static function reconstitute(
UserId $id, UserId $id,

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use DateTimeImmutable;
interface PasswordResetTokenRepository
{
public function save(PasswordResetToken $token): void;
/**
* Find a token by its unique token value.
* Use getByTokenValue() when you expect the token to exist.
*/
public function findByTokenValue(string $tokenValue): ?PasswordResetToken;
/**
* Get a token by its unique token value.
*
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
*/
public function getByTokenValue(string $tokenValue): PasswordResetToken;
/**
* Get a token by its ID.
*
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
*/
public function get(PasswordResetTokenId $id): PasswordResetToken;
/**
* Delete a token (after use or for cleanup).
*/
public function delete(PasswordResetTokenId $id): void;
/**
* Delete a token by its token value.
*/
public function deleteByTokenValue(string $tokenValue): void;
/**
* Find an existing valid (not used, not expired) token for a user.
* Returns null if no valid token exists.
*/
public function findValidTokenForUser(string $userId): ?PasswordResetToken;
/**
* Atomically consume a token: validate it and mark it as used.
*
* This operation is protected against concurrent double-use by using a lock.
* If two requests try to consume the same token simultaneously, only one
* will succeed; the other will see the token as already used.
*
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
* @throws \App\Administration\Domain\Exception\PasswordResetTokenExpiredException if token has expired
* @throws \App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException if token was already used
*/
public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken;
}

View File

@@ -7,6 +7,7 @@ namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\RefreshToken\RefreshToken; use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId; use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId; use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
/** /**
* Repository pour la gestion des refresh tokens. * Repository pour la gestion des refresh tokens.
@@ -39,4 +40,9 @@ interface RefreshTokenRepository
* Invalide tous les tokens d'une famille (en cas de replay attack détectée). * Invalide tous les tokens d'une famille (en cas de replay attack détectée).
*/ */
public function invalidateFamily(TokenFamilyId $familyId): void; public function invalidateFamily(TokenFamilyId $familyId): void;
/**
* Invalide tous les tokens d'un utilisateur (après changement de mot de passe).
*/
public function invalidateAllForUser(UserId $userId): void;
} }

View File

@@ -15,11 +15,11 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
* Endpoint de déconnexion. * Logout endpoint.
* *
* Invalide le refresh token et supprime le cookie. * Invalidates the refresh token and deletes the cookie.
* *
* @see Story 1.4 - Connexion utilisateur * @see Story 1.4 - User login
*/ */
final readonly class LogoutController final readonly class LogoutController
{ {
@@ -33,25 +33,25 @@ final readonly class LogoutController
{ {
$refreshTokenValue = $request->cookies->get('refresh_token'); $refreshTokenValue = $request->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) { if ($refreshTokenValue !== null) {
try { try {
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue); $tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
$refreshToken = $this->refreshTokenRepository->find($tokenId); $refreshToken = $this->refreshTokenRepository->find($tokenId);
if ($refreshToken !== null) { if ($refreshToken !== null) {
// Invalider toute la famille (déconnecte tous les devices) // Invalidate the entire family (disconnects all devices)
$this->refreshTokenRepository->invalidateFamily($refreshToken->familyId); $this->refreshTokenRepository->invalidateFamily($refreshToken->familyId);
} }
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
// Token malformé, ignorer // Malformed token, ignore
} }
} }
// Créer la réponse avec suppression du cookie // Create the response with cookie deletion
$response = new JsonResponse(['message' => 'Déconnexion réussie'], Response::HTTP_OK); $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( $response->headers->setCookie(
Cookie::create('refresh_token') Cookie::create('refresh_token')
->withValue('') ->withValue('')

View File

@@ -29,18 +29,18 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
/** /**
* Processor pour le rafraîchissement de token. * Processor for token refresh.
* *
* Flow: * Flow:
* 1. Lire le refresh token depuis le cookie HttpOnly * 1. Read the refresh token from the HttpOnly cookie
* 2. Valider le token et le device fingerprint * 2. Validate the token and device fingerprint
* 3. Détecter les replay attacks * 3. Detect replay attacks
* 4. Générer un nouveau JWT et faire la rotation du refresh token * 4. Generate a new JWT and rotate the refresh token
* 5. Mettre à jour le cookie * 5. Update the cookie
* *
* @implements ProcessorInterface<RefreshTokenInput, RefreshTokenOutput> * @implements ProcessorInterface<RefreshTokenInput, RefreshTokenOutput>
* *
* @see Story 1.4 - T6: Endpoint Refresh Token * @see Story 1.4 - T6: Refresh Token Endpoint
*/ */
final readonly class RefreshTokenProcessor implements ProcessorInterface final readonly class RefreshTokenProcessor implements ProcessorInterface
{ {
@@ -67,24 +67,24 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
throw new UnauthorizedHttpException('Bearer', 'Request not available'); 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'); $refreshTokenString = $request->cookies->get('refresh_token');
if ($refreshTokenString === null) { if ($refreshTokenString === null) {
throw new UnauthorizedHttpException('Bearer', 'Refresh token not found'); 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'; $ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown'); $userAgent = $request->headers->get('User-Agent', 'unknown');
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress); $fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
try { try {
// Valider et faire la rotation du refresh token // Validate and rotate the refresh token
$newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint); $newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint);
// Sécurité: vérifier que le tenant du refresh token correspond au tenant de la requête // Security: verify that the refresh token's tenant matches the request tenant
// Empêche l'utilisation d'un token d'un tenant pour accéder à un autre // Prevents using a token from one tenant to access another
$currentTenantId = $this->resolveCurrentTenant($request->getHost()); $currentTenantId = $this->resolveCurrentTenant($request->getHost());
if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) { if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) {
$this->clearRefreshTokenCookie(); $this->clearRefreshTokenCookie();
@@ -92,12 +92,12 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
throw new AccessDeniedHttpException('Invalid token for this tenant'); 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); $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()) { 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->refreshTokenManager->invalidateFamily($newRefreshToken->familyId);
$this->clearRefreshTokenCookie(); $this->clearRefreshTokenCookie();
@@ -106,11 +106,11 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
$securityUser = $this->securityUserFactory->fromDomainUser($user); $securityUser = $this->securityUserFactory->fromDomainUser($user);
// Générer le nouveau JWT // Generate the new JWT
$jwt = $this->jwtManager->create($securityUser); $jwt = $this->jwtManager->create($securityUser);
// Stocker le cookie dans les attributs de requête pour le listener // Store the cookie in request attributes for the listener
// Le RefreshTokenCookieListener l'ajoutera à la réponse // The RefreshTokenCookieListener will add it to the response
$cookie = Cookie::create('refresh_token') $cookie = Cookie::create('refresh_token')
->withValue($newRefreshToken->toTokenString()) ->withValue($newRefreshToken->toTokenString())
->withExpires($newRefreshToken->expiresAt) ->withExpires($newRefreshToken->expiresAt)
@@ -123,8 +123,8 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
return new RefreshTokenOutput(token: $jwt); return new RefreshTokenOutput(token: $jwt);
} catch (TokenReplayDetectedException $e) { } catch (TokenReplayDetectedException $e) {
// Replay attack détecté - la famille a été invalidée // Replay attack detected - the family has been invalidated
// Dispatcher l'événement de sécurité pour alertes/audit // Dispatch the security event for alerts/audit
$this->eventBus->dispatch(new TokenReplayDetecte( $this->eventBus->dispatch(new TokenReplayDetecte(
familyId: $e->familyId, familyId: $e->familyId,
ipAddress: $ipAddress, ipAddress: $ipAddress,
@@ -132,19 +132,19 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
occurredOn: $this->clock->now(), occurredOn: $this->clock->now(),
)); ));
// Supprimer le cookie côté client // Delete the cookie on client side
$this->clearRefreshTokenCookie(); $this->clearRefreshTokenCookie();
throw new AccessDeniedHttpException( throw new AccessDeniedHttpException(
'Session compromise detected. All sessions have been invalidated. Please log in again.', 'Session compromise detected. All sessions have been invalidated. Please log in again.',
); );
} catch (TokenAlreadyRotatedException) { } catch (TokenAlreadyRotatedException) {
// Token déjà rotaté mais en grace period - race condition légitime // Token already rotated but in grace period - legitimate race condition
// NE PAS supprimer le cookie ! Le client a probablement déjà le nouveau token // DO NOT delete the cookie! The client probably already has the new token
// d'une requête concurrente. Retourner 409 Conflict pour que le client réessaie. // from a concurrent request. Return 409 Conflict so the client retries.
throw new ConflictHttpException('Token already rotated, retry with current cookie'); throw new ConflictHttpException('Token already rotated, retry with current cookie');
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
// Token invalide ou expiré // Invalid or expired token
$this->clearRefreshTokenCookie(); $this->clearRefreshTokenCookie();
throw new UnauthorizedHttpException('Bearer', $e->getMessage()); throw new UnauthorizedHttpException('Bearer', $e->getMessage());
@@ -171,7 +171,9 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
/** /**
* Resolves the current tenant from the request host. * 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 private function resolveCurrentTenant(string $host): ?\App\Shared\Domain\Tenant\TenantId
{ {
@@ -183,7 +185,10 @@ final readonly class RefreshTokenProcessor implements ProcessorInterface
try { try {
return $this->tenantResolver->resolve($host)->tenantId; return $this->tenantResolver->resolve($host)->tenantId;
} catch (TenantNotFoundException) { } 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');
} }
} }
} }

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetCommand;
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetHandler;
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetInput;
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetOutput;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use Override;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
/**
* API Platform processor for password reset request.
*
* Security:
* - Always returns success to prevent email enumeration
* - Rate limited: 3 requests/hour per email, 10 requests/hour per IP
*
* @implements ProcessorInterface<RequestPasswordResetInput, RequestPasswordResetOutput>
*/
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.');
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ResetPassword\ResetPasswordCommand;
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Exception\TokenConsumptionInProgressException;
use App\Administration\Infrastructure\Api\Resource\ResetPasswordInput;
use App\Administration\Infrastructure\Api\Resource\ResetPasswordOutput;
use Override;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\GoneHttpException;
/**
* API Platform processor for password reset.
*
* Handles errors:
* - Token not found → 400 Bad Request
* - Token expired → 410 Gone
* - Token already used → 410 Gone
* - Concurrent consumption → 409 Conflict (client should retry)
*
* @implements ProcessorInterface<ResetPasswordInput, ResetPasswordOutput>
*/
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();
}
}

View File

@@ -41,9 +41,17 @@ final class ActivateAccountInput
pattern: '/[A-Z]/', pattern: '/[A-Z]/',
message: 'Le mot de passe doit contenir au moins une majuscule.', 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( #[Assert\Regex(
pattern: '/[0-9]/', pattern: '/[0-9]/',
message: 'Le mot de passe doit contenir au moins un chiffre.', 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 = ''; public string $password = '';
} }

View File

@@ -9,11 +9,11 @@ use ApiPlatform\Metadata\Post;
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor; 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( #[ApiResource(
operations: [ operations: [
@@ -22,11 +22,11 @@ use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
processor: RefreshTokenProcessor::class, processor: RefreshTokenProcessor::class,
output: RefreshTokenOutput::class, output: RefreshTokenOutput::class,
name: 'refresh_token', 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 final class RefreshTokenInput
{ {
// Pas de propriétés - le refresh token vient du cookie // No properties - the refresh token comes from the cookie
} }

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Administration\Infrastructure\Api\Processor\RequestPasswordResetProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for password reset request.
*
* This endpoint accepts an email and generates a reset token.
* Always returns success to prevent email enumeration.
*/
#[ApiResource(
shortName: 'PasswordResetRequest',
operations: [
new Post(
uriTemplate: '/password/forgot',
processor: RequestPasswordResetProcessor::class,
output: RequestPasswordResetOutput::class,
name: 'request_password_reset',
),
],
)]
final class RequestPasswordResetInput
{
#[Assert\NotBlank(message: 'L\'adresse email est requise.')]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
public string $email = '';
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* Output for password reset request.
*
* Always returns a generic success message to prevent email enumeration.
*/
final readonly class RequestPasswordResetOutput
{
public function __construct(
public string $message = 'Si cette adresse email est associée à un compte, un email de réinitialisation a été envoyé.',
) {
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Administration\Infrastructure\Api\Processor\ResetPasswordProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Input DTO for password reset.
*
* Endpoint: POST /api/password/reset
*/
#[ApiResource(
shortName: 'PasswordReset',
operations: [
new Post(
uriTemplate: '/password/reset',
processor: ResetPasswordProcessor::class,
output: ResetPasswordOutput::class,
),
],
)]
final class ResetPasswordInput
{
#[Assert\NotBlank(message: 'Le token est requis.')]
public string $token = '';
#[Assert\NotBlank(message: 'Le mot de passe est requis.')]
#[Assert\Length(
min: 8,
minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.',
)]
#[Assert\Regex(
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 = '';
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* Output DTO for password reset.
*
* Returns a success message after password is successfully reset.
*/
final readonly class ResetPasswordOutput
{
public string $message;
public function __construct()
{
$this->message = 'Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter.';
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Creates a test user with an activated account and a password reset token.
*
* This command is for E2E testing only. It creates:
* - An activated user (so they can use the reset password flow)
* - A password reset token for that user
*/
#[AsCommand(
name: 'app:dev:create-test-password-reset-token',
description: 'Creates a test user and password reset token for E2E testing',
)]
final class CreateTestPasswordResetTokenCommand extends Command
{
public function __construct(
private readonly PasswordResetTokenRepository $passwordResetTokenRepository,
private readonly UserRepository $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly TenantRegistry $tenantRegistry,
private readonly ConsentementParentalPolicy $consentementPolicy,
private readonly Clock $clock,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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('<info>Reset URL:</info> <href=%s>%s</>', $resetUrl, $resetUrl));
$io->writeln('');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\MotDePasseChange;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Sends a confirmation email when a password is changed.
*
* This handler listens for MotDePasseChange events and sends an email
* to the user confirming their password has been reset.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendPasswordResetConfirmationHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private string $appUrl,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(MotDePasseChange $event): void
{
$html = $this->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);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Sends a password reset email when a reset token is generated.
*
* This handler listens for PasswordResetTokenGenerated events and sends
* an email to the user with a link to reset their password.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendPasswordResetEmailHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private string $appUrl,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(PasswordResetTokenGenerated $event): void
{
$resetUrl = rtrim($this->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);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use Override;
final class InMemoryPasswordResetTokenRepository implements PasswordResetTokenRepository
{
/** @var array<string, PasswordResetToken> Indexed by token value */
private array $byTokenValue = [];
/** @var array<string, string> Maps ID to token value */
private array $idToTokenValue = [];
/** @var array<string, string> 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;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use function array_unique;
use function array_values;
use function count;
use Override;
/**
* In-memory implementation of RefreshTokenRepository for testing.
*/
final class InMemoryRefreshTokenRepository implements RefreshTokenRepository
{
/** @var array<string, RefreshToken> Indexed by token ID */
private array $tokens = [];
/** @var array<string, list<string>> Maps family ID to token IDs */
private array $familyIndex = [];
/** @var array<string, list<string>> 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;
}
}

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Exception\TokenConsumptionInProgressException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Administration\Domain\Repository\PasswordResetTokenRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;
final readonly class RedisPasswordResetTokenRepository implements PasswordResetTokenRepository
{
private const string KEY_PREFIX = 'password_reset:';
/**
* Cache TTL is 2 hours: 1 hour validity + 1 hour grace period.
*
* Keeping tokens longer than their domain expiry allows distinguishing
* "expired" (410) from "invalid/not found" (400) in API responses.
*/
private const int TTL_SECONDS = 60 * 60 * 2; // 2 hours
public function __construct(
private CacheItemPoolInterface $passwordResetTokensCache,
private LockFactory $lockFactory,
) {
}
#[Override]
public function save(PasswordResetToken $token): void
{
// Store by token value for lookup during password reset
$item = $this->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,
);
}
}

View File

@@ -14,33 +14,47 @@ use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface; 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 : * Storage structure:
* - Token individuel : refresh:{token_id} → données JSON du token * - Individual token: refresh:{token_id} → token JSON data
* - Index famille : refresh_family:{family_id} → set des token_ids de la famille * - 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 final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository
{ {
private const string TOKEN_PREFIX = 'refresh:'; private const string TOKEN_PREFIX = 'refresh:';
private const string FAMILY_PREFIX = 'refresh_family:'; 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( public function __construct(
private CacheItemPoolInterface $refreshTokensCache, private CacheItemPoolInterface $refreshTokensCache,
private LoggerInterface $logger = new NullLogger(),
) { ) {
} }
public function save(RefreshToken $token): void public function save(RefreshToken $token): void
{ {
// Sauvegarder le token // Save the token
$tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id); $tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id);
$tokenItem->set($this->serialize($token)); $tokenItem->set($this->serialize($token));
// Calculer le TTL restant // Calculate remaining TTL
$now = new DateTimeImmutable(); $now = new DateTimeImmutable();
$ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp(); $ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp();
if ($ttl > 0) { if ($ttl > 0) {
@@ -49,9 +63,9 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
$this->refreshTokensCache->save($tokenItem); $this->refreshTokensCache->save($tokenItem);
// Ajouter à l'index famille // Add to family index
// Ne jamais réduire le TTL de l'index famille // Never reduce the family index TTL
// L'index doit survivre aussi longtemps que le token le plus récent de la famille // The index must survive as long as the most recent token in the family
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId); $familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId);
/** @var list<string> $familyTokenIds */ /** @var list<string> $familyTokenIds */
@@ -59,17 +73,32 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
$familyTokenIds[] = (string) $token->id; $familyTokenIds[] = (string) $token->id;
$familyItem->set(array_unique($familyTokenIds)); $familyItem->set(array_unique($familyTokenIds));
// Seulement étendre le TTL, jamais le réduire // Only extend TTL, never reduce
// Pour les tokens rotated (ancien), on ne change pas le TTL de l'index // For rotated tokens (old), we don't change the index TTL
if (!$token->isRotated && $ttl > 0) { if (!$token->isRotated && $ttl > 0) {
$familyItem->expiresAfter($ttl); $familyItem->expiresAfter($ttl);
} elseif (!$familyItem->isHit()) { } elseif (!$familyItem->isHit()) {
// Nouveau index - définir le TTL initial // New index - set initial TTL
$familyItem->expiresAfter($ttl > 0 ? $ttl : 604800); $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); $this->refreshTokensCache->save($familyItem);
// Add to user index (for invalidating all sessions)
$userItem = $this->refreshTokensCache->getItem(self::USER_PREFIX . $token->userId);
/** @var list<string> $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 public function find(RefreshTokenId $id): ?RefreshToken
@@ -107,15 +136,56 @@ final readonly class RedisRefreshTokenRepository implements RefreshTokenReposito
/** @var list<string> $tokenIds */ /** @var list<string> $tokenIds */
$tokenIds = $familyItem->get(); $tokenIds = $familyItem->get();
// Supprimer tous les tokens de la famille // Delete all tokens in the family
foreach ($tokenIds as $tokenId) { foreach ($tokenIds as $tokenId) {
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId); $this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId);
} }
// Supprimer l'index famille // Delete the family index
$this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId); $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<string> $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<string, mixed> * @return array<string, mixed>
*/ */

View File

@@ -17,15 +17,15 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; 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. * This provider bridges Symfony Security with our Domain Layer.
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité. * It never reveals whether a user exists or not for security reasons.
* Les utilisateurs sont isolés par tenant (établissement). * Users are isolated by tenant (school).
* *
* @implements UserProviderInterface<SecurityUser> * @implements UserProviderInterface<SecurityUser>
* *
* @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 final readonly class DatabaseUserProvider implements UserProviderInterface
{ {
@@ -50,12 +50,12 @@ final readonly class DatabaseUserProvider implements UserProviderInterface
$user = $this->userRepository->findByEmail($email, $tenantId); $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) { if ($user === null) {
throw new SymfonyUserNotFoundException(); 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()) { if (!$user->peutSeConnecter()) {
throw new SymfonyUserNotFoundException(); throw new SymfonyUserNotFoundException();
} }

View File

@@ -7,15 +7,15 @@ namespace App\Administration\Infrastructure\Security;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; 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: * Added claims:
* - sub: Email de l'utilisateur (identifiant Symfony Security) * - sub: User email (Symfony Security identifier)
* - user_id: UUID de l'utilisateur (pour les consommateurs d'API) * - user_id: User UUID (for API consumers)
* - tenant_id: UUID du tenant pour l'isolation multi-tenant * - tenant_id: Tenant UUID for multi-tenant isolation
* - roles: Liste des rôles Symfony pour l'autorisation * - roles: List of Symfony roles for authorization
* *
* @see Story 1.4 - Connexion utilisateur * @see Story 1.4 - User login
*/ */
final readonly class JwtPayloadEnricher final readonly class JwtPayloadEnricher
{ {
@@ -29,7 +29,7 @@ final readonly class JwtPayloadEnricher
$payload = $event->getData(); $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['user_id'] = $user->userId();
$payload['tenant_id'] = $user->tenantId(); $payload['tenant_id'] = $user->tenantId();
$payload['roles'] = $user->getRoles(); $payload['roles'] = $user->getRoles();

View File

@@ -22,11 +22,11 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; 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 final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
{ {
@@ -46,10 +46,10 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
$ipAddress = $request->getClientIp() ?? 'unknown'; $ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', '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); $result = $this->rateLimiter->recordFailure($request, $email);
// Émettre l'événement d'échec // Dispatch the failure event
$this->eventBus->dispatch(new ConnexionEchouee( $this->eventBus->dispatch(new ConnexionEchouee(
email: $email, email: $email,
ipAddress: $ipAddress, ipAddress: $ipAddress,
@@ -58,7 +58,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
occurredOn: $this->clock->now(), occurredOn: $this->clock->now(),
)); ));
// Si l'IP vient d'être bloquée // If the IP was just blocked
if ($result->ipBlocked) { if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement( $this->eventBus->dispatch(new CompteBloqueTemporairement(
email: $email, email: $email,
@@ -72,7 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
return $this->createBlockedResponse($result); 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); return $this->createFailureResponse($result);
} }
@@ -106,13 +106,13 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
'attempts' => $result->attempts, 'attempts' => $result->attempts,
]; ];
// Ajouter le délai si applicable // Add delay if applicable
if ($result->delaySeconds > 0) { if ($result->delaySeconds > 0) {
$data['delay'] = $result->delaySeconds; $data['delay'] = $result->delaySeconds;
$data['delayFormatted'] = $result->getFormattedDelay(); $data['delayFormatted'] = $result->getFormattedDelay();
} }
// Indiquer si CAPTCHA requis pour la prochaine tentative // Indicate if CAPTCHA is required for the next attempt
if ($result->requiresCaptcha) { if ($result->requiresCaptcha) {
$data['captchaRequired'] = true; $data['captchaRequired'] = true;
} }

View File

@@ -17,9 +17,9 @@ use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface; 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 final readonly class LoginSuccessHandler
{ {
@@ -48,13 +48,13 @@ final readonly class LoginSuccessHandler
$ipAddress = $request->getClientIp() ?? 'unknown'; $ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown'); $userAgent = $request->headers->get('User-Agent', 'unknown');
// Créer le device fingerprint // Create the device fingerprint
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress); $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'); $isMobile = str_contains(strtolower($userAgent), 'mobile');
// Créer le refresh token // Create the refresh token
$refreshToken = $this->refreshTokenManager->create( $refreshToken = $this->refreshTokenManager->create(
$userId, $userId,
$tenantId, $tenantId,
@@ -62,7 +62,7 @@ final readonly class LoginSuccessHandler
$isMobile, $isMobile,
); );
// Ajouter le refresh token en cookie HttpOnly // Add the refresh token as HttpOnly cookie
$cookie = Cookie::create('refresh_token') $cookie = Cookie::create('refresh_token')
->withValue($refreshToken->toTokenString()) ->withValue($refreshToken->toTokenString())
->withExpires($refreshToken->expiresAt) ->withExpires($refreshToken->expiresAt)
@@ -73,10 +73,10 @@ final readonly class LoginSuccessHandler
$response->headers->setCookie($cookie); $response->headers->setCookie($cookie);
// Reset le rate limiter pour cet email // Reset the rate limiter for this email
$this->rateLimiter->reset($email); $this->rateLimiter->reset($email);
// Émettre l'événement de connexion réussie // Dispatch the successful login event
$this->eventBus->dispatch(new ConnexionReussie( $this->eventBus->dispatch(new ConnexionReussie(
userId: $user->userId(), userId: $user->userId(),
email: $email, email: $email,

View File

@@ -10,12 +10,12 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; 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. * This DTO is used by the Symfony authentication system.
* Il ne contient pas de logique métier - c'est un simple transporteur de données. * 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 final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
{ {
@@ -24,7 +24,7 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
/** /**
* @param non-empty-string $email * @param non-empty-string $email
* @param list<string> $roles Les rôles Symfony (ROLE_*) * @param list<string> $roles Symfony roles (ROLE_*)
*/ */
public function __construct( public function __construct(
private UserId $userId, private UserId $userId,
@@ -74,6 +74,6 @@ final readonly class SecurityUser implements UserInterface, PasswordAuthenticate
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
// Rien à effacer, les données sont immutables // Nothing to erase, data is immutable
} }
} }

View File

@@ -15,7 +15,7 @@ final readonly class CorrelationId
public static function generate(): self public static function generate(): self
{ {
return new self(Uuid::uuid4()->toString()); return new self(Uuid::uuid7()->toString());
} }
public static function fromString(string $value): self public static function fromString(string $value): self

View File

@@ -19,7 +19,7 @@ abstract readonly class EntityId
public static function generate(): static public static function generate(): static
{ {
return new static(Uuid::uuid4()); return new static(Uuid::uuid7());
} }
public static function fromString(string $value): static public static function fromString(string $value): static

View File

@@ -34,6 +34,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
'/api/activation-tokens', '/api/activation-tokens',
'/api/activate', '/api/activate',
'/api/login', '/api/login',
'/api/password',
'/api/token',
'/_profiler', '/_profiler',
'/_wdt', '/_wdt',
'/_error', '/_error',

View File

@@ -178,6 +178,18 @@
".editorconfig" ".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": { "symfony/mailer": {
"version": "8.0", "version": "8.0",
"recipe": { "recipe": {

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réinitialisation de mot de passe - Classeo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #4f46e5;
}
.header h1 {
color: #4f46e5;
margin: 0;
font-size: 28px;
}
.content {
padding: 30px 0;
}
.info-box {
background-color: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.info-box p {
margin: 0;
color: #92400e;
}
.button {
display: inline-block;
background-color: #4f46e5;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
}
.button:hover {
background-color: #4338ca;
}
.expiry-notice {
background-color: #f3f4f6;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
text-align: center;
}
.expiry-notice p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Classeo</h1>
</div>
<div class="content">
<h2>Réinitialisation de votre mot de passe</h2>
<p>Bonjour,</p>
<p>Nous avons reçu une demande de réinitialisation du mot de passe de votre compte Classeo associé à l'adresse <strong>{{ email }}</strong>.</p>
<p>Si vous êtes à l'origine de cette demande, cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe :</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ resetUrl }}" class="button">Réinitialiser mon mot de passe</a>
</p>
<div class="expiry-notice">
<p>Ce lien est valide pendant <strong>1 heure</strong> et ne peut être utilisé qu'une seule fois.</p>
</div>
<div class="info-box">
<p><strong>Vous n'avez pas demandé cette réinitialisation ?</strong><br>
Ignorez simplement cet email. Votre mot de passe ne sera pas modifié et le lien expirera automatiquement.</p>
</div>
<p><strong>Conseils de sécurité :</strong></p>
<ul>
<li>Ne partagez jamais ce lien avec d'autres personnes</li>
<li>Choisissez un mot de passe fort et unique</li>
<li>Si vous suspectez une activité suspecte, contactez votre établissement</li>
</ul>
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par Classeo.</p>
<p>Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mot de passe modifié - Classeo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #4f46e5;
}
.header h1 {
color: #4f46e5;
margin: 0;
font-size: 28px;
}
.content {
padding: 30px 0;
}
.success-icon {
text-align: center;
padding: 20px;
}
.success-icon span {
display: inline-block;
width: 60px;
height: 60px;
background-color: #10b981;
border-radius: 50%;
line-height: 60px;
color: white;
font-size: 30px;
}
.info-box {
background-color: #f3f4f6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-box p {
margin: 5px 0;
}
.warning-box {
background-color: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.warning-box p {
margin: 0;
color: #92400e;
font-size: 14px;
}
.button {
display: inline-block;
background-color: #4f46e5;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
}
.button:hover {
background-color: #4338ca;
}
.footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Classeo</h1>
</div>
<div class="content">
<div class="success-icon">
<span>✓</span>
</div>
<h2 style="text-align: center;">Mot de passe modifié</h2>
<p>Bonjour,</p>
<p>Nous vous confirmons que le mot de passe de votre compte Classeo associé à l'adresse <strong>{{ email }}</strong> a été modifié avec succès.</p>
<div class="info-box">
<p><strong>Date du changement :</strong> {{ changedAt }}</p>
</div>
<div class="warning-box">
<p><strong>Vous n'êtes pas à l'origine de ce changement ?</strong><br>
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.</p>
</div>
<p>Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ loginUrl }}" class="button">Se connecter</a>
</p>
<p><strong>Conseils de sécurité :</strong></p>
<ul>
<li>Ne partagez jamais votre mot de passe</li>
<li>Utilisez un mot de passe unique pour chaque service</li>
<li>Déconnectez-vous des appareils partagés</li>
</ul>
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par Classeo.</p>
<p>Si vous avez des questions, contactez votre établissement.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests that password reset endpoints are accessible without authentication.
*
* These endpoints MUST be public because users who forgot their password
* cannot authenticate to request a reset.
*/
final class PasswordResetEndpointsTest extends ApiTestCase
{
/**
* Opt-in for API Platform 5.0 behavior where kernel boot is explicit.
*
* @see https://github.com/api-platform/core/issues/6971
*/
protected static ?bool $alwaysBootKernel = true;
#[Test]
public function passwordForgotEndpointIsAccessibleWithoutAuthentication(): void
{
$client = static::createClient();
$response = $client->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);
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\RequestPasswordReset;
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetCommand;
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetHandler;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class RequestPasswordResetHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private InMemoryUserRepository $userRepository;
private InMemoryPasswordResetTokenRepository $tokenRepository;
private Clock $clock;
/** @var DomainEvent[] */
private array $dispatchedEvents = [];
private RequestPasswordResetHandler $handler;
private TenantId $tenantId;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ResetPassword;
use App\Administration\Application\Command\ResetPassword\ResetPasswordCommand;
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Event\PasswordResetTokenUsed;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Exception\PasswordResetTokenNotFoundException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ResetPasswordHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string NEW_PASSWORD = 'NewSecurePassword123!';
public const string HASHED_PASSWORD = '$argon2id$newhashedpassword';
private InMemoryUserRepository $userRepository;
private InMemoryPasswordResetTokenRepository $tokenRepository;
private InMemoryRefreshTokenRepository $refreshTokenRepository;
private Clock $clock;
/** @var DomainEvent[] */
private array $dispatchedEvents = [];
private ResetPasswordHandler $handler;
private TenantId $tenantId;
private PasswordHasher $passwordHasher;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -64,12 +64,12 @@ final class ActivationTokenTest extends TestCase
} }
#[Test] #[Test]
public function tokenValueIsUuidV4Format(): void public function tokenValueIsUuidV7Format(): void
{ {
$token = $this->createToken(); $token = $this->createToken();
self::assertMatchesRegularExpression( 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, $token->tokenValue,
); );
} }

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\PasswordResetToken;
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
use App\Administration\Domain\Event\PasswordResetTokenUsed;
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PasswordResetTokenTest extends TestCase
{
private TenantId $tenantId;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -82,6 +82,6 @@ final readonly class TestDomainEvent implements DomainEvent
public function aggregateId(): UuidInterface public function aggregateId(): UuidInterface
{ {
return $this->testAggregateId ?? Uuid::uuid4(); return $this->testAggregateId ?? Uuid::uuid7();
} }
} }

View File

@@ -90,10 +90,12 @@ test.describe('Account Activation Flow', () => {
const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ }); const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ });
await expect(digitItem).not.toHaveClass(/valid/); 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'); await passwordInput.fill('Abcdefgh1');
const validItems = page.locator('.password-requirements li.valid'); 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 }) => { test('requires password confirmation to match', async ({ page }) => {
@@ -106,13 +108,13 @@ test.describe('Account Activation Flow', () => {
const passwordInput = page.locator('#password'); const passwordInput = page.locator('#password');
const confirmInput = page.locator('#passwordConfirmation'); const confirmInput = page.locator('#passwordConfirmation');
await passwordInput.fill('SecurePass123'); await passwordInput.fill('SecurePass123!');
await confirmInput.fill('DifferentPass123'); await confirmInput.fill('DifferentPass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible(); await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible();
// Fix confirmation // Fix confirmation
await confirmInput.fill('SecurePass123'); await confirmInput.fill('SecurePass123!');
await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible(); await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible();
}); });
@@ -128,9 +130,9 @@ test.describe('Account Activation Flow', () => {
// Initially disabled // Initially disabled
await expect(submitButton).toBeDisabled(); await expect(submitButton).toBeDisabled();
// Fill valid password // Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123'); await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123'); await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Should now be enabled // Should now be enabled
await expect(submitButton).toBeEnabled(); await expect(submitButton).toBeEnabled();
@@ -194,9 +196,9 @@ test.describe('Account Activation Flow', () => {
// Button should be disabled initially (no password yet) // Button should be disabled initially (no password yet)
await expect(submitButton).toBeDisabled(); await expect(submitButton).toBeDisabled();
// Fill valid password // Fill valid password (must include special char)
await page.locator('#password').fill('SecurePass123'); await page.locator('#password').fill('SecurePass123!');
await page.locator('#passwordConfirmation').fill('SecurePass123'); await page.locator('#passwordConfirmation').fill('SecurePass123!');
// Wait for validation to complete - button should now be enabled // Wait for validation to complete - button should now be enabled
await expect(submitButton).toBeEnabled({ timeout: 2000 }); await expect(submitButton).toBeEnabled({ timeout: 2000 });

View File

@@ -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
});
});
});
});
});

View File

@@ -22,9 +22,13 @@
// Critères de validation du mot de passe // Critères de validation du mot de passe
const hasMinLength = $derived(password.length >= 8); const hasMinLength = $derived(password.length >= 8);
const hasUppercase = $derived(/[A-Z]/.test(password)); const hasUppercase = $derived(/[A-Z]/.test(password));
const hasLowercase = $derived(/[a-z]/.test(password));
const hasDigit = $derived(/[0-9]/.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 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 // Query pour récupérer les infos du token
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
@@ -209,11 +213,19 @@
</li> </li>
<li class:valid={hasUppercase}> <li class:valid={hasUppercase}>
<span class="check">{hasUppercase ? '✓' : '○'}</span> <span class="check">{hasUppercase ? '✓' : '○'}</span>
Au moins 1 majuscule Une majuscule
</li>
<li class:valid={hasLowercase}>
<span class="check">{hasLowercase ? '✓' : '○'}</span>
Une minuscule
</li> </li>
<li class:valid={hasDigit}> <li class:valid={hasDigit}>
<span class="check">{hasDigit ? '✓' : '○'}</span> <span class="check">{hasDigit ? '✓' : '○'}</span>
Au moins 1 chiffre Un chiffre
</li>
<li class:valid={hasSpecial}>
<span class="check">{hasSpecial ? '✓' : '○'}</span>
Un caractère spécial
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,416 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
// Form state
let email = $state('');
let isSubmitting = $state(false);
let isSubmitted = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (isSubmitting) return;
error = null;
isSubmitting = true;
try {
const response = await fetch(`${getApiBaseUrl()}/password/forgot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: email.trim().toLowerCase() })
});
if (response.status === 429) {
error = 'Trop de demandes. Veuillez patienter avant de réessayer.';
return;
}
// Always show success message (no email enumeration)
isSubmitted = true;
} catch {
error = 'Une erreur est survenue. Veuillez réessayer.';
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Mot de passe oublié | Classeo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="page">
<div class="container">
<!-- Logo -->
<div class="logo">
<span class="logo-icon">📚</span>
<span class="logo-text">Classeo</span>
</div>
<div class="card">
{#if isSubmitted}
<!-- Success State -->
<div class="success-state">
<div class="success-icon">
<span></span>
</div>
<h1>Vérifiez votre boîte mail</h1>
<p>
Si un compte existe avec l'adresse <strong>{email}</strong>, vous recevrez
un email avec les instructions pour réinitialiser votre mot de passe.
</p>
<p class="hint">
Le lien sera valide pendant <strong>1 heure</strong>.
</p>
<div class="actions">
<a href="/login" class="link-button">Retour à la connexion</a>
</div>
</div>
{:else}
<!-- Form State -->
<h1>Mot de passe oublié</h1>
<p class="description">
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser
votre mot de passe.
</p>
{#if error}
<div class="error-banner">
<span class="error-icon"></span>
<span class="error-message">{error}</span>
</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="form-group">
<label for="email">Adresse email</label>
<div class="input-wrapper">
<input
id="email"
type="email"
required
placeholder="votre@email.com"
bind:value={email}
disabled={isSubmitting}
autocomplete="email"
/>
</div>
</div>
<button type="submit" class="submit-button" disabled={isSubmitting || !email}>
{#if isSubmitting}
<span class="spinner"></span>
Envoi en cours...
{:else}
Envoyer le lien de réinitialisation
{/if}
</button>
</form>
<div class="links">
<a href="/login" class="back-link">← Retour à la connexion</a>
</div>
{/if}
</div>
<p class="footer">Un problème ? Contactez votre établissement.</p>
</div>
</div>
<style>
/* Design Tokens - Calm Productivity */
:root {
--color-calm: hsl(142, 76%, 36%);
--color-attention: hsl(38, 92%, 50%);
--color-alert: hsl(0, 72%, 51%);
--surface-primary: hsl(210, 20%, 98%);
--surface-elevated: hsl(0, 0%, 100%);
--text-primary: hsl(222, 47%, 11%);
--text-secondary: hsl(215, 16%, 47%);
--text-muted: hsl(215, 13%, 65%);
--accent-primary: hsl(199, 89%, 48%);
--border-subtle: hsl(214, 32%, 91%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
}
.page {
min-height: 100vh;
background: var(--surface-primary);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
.container {
width: 100%;
max-width: 420px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 32px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* Card */
.card {
background: var(--surface-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
padding: 32px;
}
.card h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
text-align: center;
}
.description {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 24px;
line-height: 1.5;
}
/* Success State */
.success-state {
text-align: center;
}
.success-state .success-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, hsl(142, 76%, 95%) 0%, hsl(142, 76%, 90%) 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
font-size: 28px;
}
.success-state h1 {
margin-bottom: 16px;
}
.success-state p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 16px;
}
.success-state .hint {
font-size: 13px;
color: var(--text-muted);
padding: 12px;
background: var(--surface-primary);
border-radius: var(--radius-sm);
margin-bottom: 24px;
}
.actions {
margin-top: 24px;
}
.link-button {
display: inline-block;
padding: 12px 24px;
background: var(--accent-primary);
color: white;
text-decoration: none;
border-radius: var(--radius-sm);
font-weight: 500;
font-size: 15px;
transition: background 0.2s;
}
.link-button:hover {
background: hsl(199, 89%, 42%);
}
/* Error Banner */
.error-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: linear-gradient(135deg, hsl(0, 76%, 95%) 0%, hsl(0, 76%, 97%) 100%);
border: 1px solid hsl(0, 76%, 85%);
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 14px;
color: var(--color-alert);
}
.error-icon {
flex-shrink: 0;
font-size: 18px;
}
.error-message {
font-weight: 500;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.input-wrapper {
position: relative;
}
.input-wrapper input {
width: 100%;
padding: 12px 16px;
font-size: 15px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px hsla(199, 89%, 48%, 0.15);
}
.input-wrapper input::placeholder {
color: var(--text-muted);
}
.input-wrapper input:disabled {
background: var(--surface-primary);
color: var(--text-muted);
cursor: not-allowed;
}
/* Submit Button */
.submit-button {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: var(--accent-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
background 0.2s,
transform 0.1s,
box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-button:hover:not(:disabled) {
background: hsl(199, 89%, 42%);
box-shadow: var(--shadow-card);
}
.submit-button:active:not(:disabled) {
transform: scale(0.98);
}
.submit-button:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
/* Spinner */
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Links */
.links {
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
.back-link {
font-size: 14px;
color: var(--accent-primary);
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
/* Footer */
.footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
margin-top: 24px;
}
</style>

View File

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