feat: Activation de compte utilisateur avec validation token

L'inscription Classeo se fait via invitation : un admin crée un compte,
l'utilisateur reçoit un lien d'activation par email pour définir son
mot de passe. Ce flow sécurisé évite les inscriptions non autorisées
et garantit que seuls les utilisateurs légitimes accèdent au système.

Points clés de l'implémentation :
- Tokens d'activation à usage unique stockés en cache (Redis/filesystem)
- Validation du consentement parental pour les mineurs < 15 ans (RGPD)
- L'échec d'activation ne consume pas le token (retry possible)
- Users dans un cache séparé sans TTL (pas d'expiration)
- Hot reload en dev (FrankenPHP sans mode worker)

Story: 1.3 - Inscription et activation de compte
This commit is contained in:
2026-01-31 18:00:43 +01:00
parent 1fd256346a
commit c5e6c1d810
69 changed files with 5173 additions and 13 deletions

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountOutput;
use App\Shared\Domain\Clock;
use Override;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* API Platform processor for account activation.
*
* @implements ProcessorInterface<ActivateAccountInput, ActivateAccountOutput>
*/
final readonly class ActivateAccountProcessor implements ProcessorInterface
{
public function __construct(
private ActivateAccountHandler $handler,
private UserRepository $userRepository,
private ActivationTokenRepository $tokenRepository,
private ConsentementParentalPolicy $consentementPolicy,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
/**
* @param ActivateAccountInput $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ActivateAccountOutput
{
$command = new ActivateAccountCommand(
tokenValue: $data->tokenValue,
password: $data->password,
);
try {
$result = ($this->handler)($command);
} catch (ActivationTokenNotFoundException) {
throw new NotFoundHttpException('Token d\'activation invalide ou introuvable.');
} catch (ActivationTokenExpiredException) {
throw new BadRequestHttpException('Le token d\'activation a expiré. Veuillez contacter votre établissement pour obtenir un nouveau lien.');
} catch (ActivationTokenAlreadyUsedException) {
throw new BadRequestHttpException('Ce token d\'activation a déjà été utilisé.');
}
// Activate the User account
try {
$user = $this->userRepository->get(UserId::fromString($result->userId));
$user->activer(
hashedPassword: $result->hashedPassword,
at: $this->clock->now(),
consentementPolicy: $this->consentementPolicy,
);
$this->userRepository->save($user);
// Publish domain events recorded on the User aggregate
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Delete token only after successful user activation
// This ensures failed activations (e.g., missing parental consent) don't burn the token
$this->tokenRepository->deleteByTokenValue($data->tokenValue);
} catch (UserNotFoundException) {
throw new NotFoundHttpException('Utilisateur introuvable.');
} catch (CompteNonActivableException $e) {
throw new BadRequestHttpException($e->getMessage());
}
return new ActivateAccountOutput(
userId: $result->userId,
email: $result->email,
role: $result->role,
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Infrastructure\Api\Resource\ActivationTokenInfo;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use Override;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* API Platform provider for activation token information.
*
* @implements ProviderInterface<ActivationTokenInfo>
*/
final readonly class ActivationTokenInfoProvider implements ProviderInterface
{
public function __construct(
private ActivationTokenRepository $tokenRepository,
private Clock $clock,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ActivationTokenInfo
{
/** @var string $tokenValue */
$tokenValue = $uriVariables['tokenValue'] ?? '';
$token = $this->tokenRepository->findByTokenValue($tokenValue);
if ($token === null) {
throw new NotFoundHttpException('Token d\'activation introuvable.');
}
if ($token->isUsed()) {
throw new NotFoundHttpException('Ce token d\'activation a déjà été utilisé.');
}
return new ActivationTokenInfo(
tokenValue: $token->tokenValue,
email: $token->email,
role: $this->translateRole($token->role),
schoolName: $token->schoolName,
isExpired: $token->isExpired($this->clock->now()),
expiresAt: $token->expiresAt->format(DateTimeImmutable::ATOM),
);
}
private function translateRole(string $role): string
{
$roleEnum = Role::tryFrom($role);
return $roleEnum?->label() ?? $role;
}
}

View File

@@ -0,0 +1,49 @@
<?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\ActivateAccountProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for account activation.
*
* This endpoint accepts a token value and new password to activate a user account.
*/
#[ApiResource(
shortName: 'AccountActivation',
operations: [
new Post(
uriTemplate: '/activate',
processor: ActivateAccountProcessor::class,
output: ActivateAccountOutput::class,
validationContext: ['groups' => ['Default', 'activate']],
name: 'activate_account',
),
],
)]
final class ActivateAccountInput
{
#[Assert\NotBlank(message: 'Le token d\'activation est requis.')]
#[Assert\Uuid(message: 'Le token d\'activation doit être un UUID valide.')]
public string $tokenValue = '';
#[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: '/[0-9]/',
message: 'Le mot de passe doit contenir au moins un chiffre.',
)]
public string $password = '';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* API Output for successful account activation.
*/
final readonly class ActivateAccountOutput
{
public function __construct(
public string $userId,
public string $email,
public string $role,
public string $message = 'Compte activé avec succès.',
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Administration\Infrastructure\Api\Provider\ActivationTokenInfoProvider;
/**
* API Resource for retrieving activation token information.
*
* Used by the frontend to display the establishment name and role
* before the user submits their password.
*/
#[ApiResource(
shortName: 'ActivationTokenInfo',
operations: [
new Get(
uriTemplate: '/activation-tokens/{tokenValue}',
provider: ActivationTokenInfoProvider::class,
name: 'get_activation_token_info',
),
],
)]
final readonly class ActivationTokenInfo
{
public function __construct(
public string $tokenValue,
public string $email,
public string $role,
public string $schoolName,
public bool $isExpired,
public string $expiresAt,
) {
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
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\Repository\ActivationTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
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;
#[AsCommand(
name: 'app:dev:create-test-activation-token',
description: 'Creates a test user and activation token for development',
)]
final class CreateTestActivationTokenCommand extends Command
{
public function __construct(
private readonly ActivationTokenRepository $activationTokenRepository,
private readonly UserRepository $userRepository,
private readonly Clock $clock,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'test@example.com')
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string $email */
$email = $input->getOption('email');
/** @var string $roleOption */
$roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption);
/** @var string $schoolName */
$schoolName = $input->getOption('school');
$isMinor = $input->getOption('minor');
/** @var string $baseUrlOption */
$baseUrlOption = $input->getOption('base-url');
$baseUrl = rtrim($baseUrlOption, '/');
// Convert short role name to full Symfony role format
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
$role = Role::tryFrom($roleName);
if ($role === null) {
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
$io->error(sprintf(
'Invalid role "%s". Valid roles: %s',
$roleInput,
implode(', ', $validRoles)
));
return Command::FAILURE;
}
$now = $this->clock->now();
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
// Create user
$dateNaissance = $isMinor
? $now->modify('-13 years') // 13 ans = mineur
: null;
$user = User::creer(
email: new Email($email),
role: $role,
tenantId: $tenantId,
schoolName: $schoolName,
dateNaissance: $dateNaissance,
createdAt: $now,
);
$this->userRepository->save($user);
// Create activation token
$token = ActivationToken::generate(
userId: (string) $user->id,
email: $email,
tenantId: $tenantId,
role: $role->value,
schoolName: $schoolName,
createdAt: $now,
);
$this->activationTokenRepository->save($token);
$activationUrl = sprintf('%s/activate/%s', $baseUrl, $token->tokenValue);
$io->success('Test activation token created successfully!');
$io->table(
['Property', 'Value'],
[
['User ID', (string) $user->id],
['Email', $email],
['Role', $role->value],
['School', $schoolName],
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
['Token', $token->tokenValue],
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
]
);
$io->writeln('');
$io->writeln(sprintf('<info>Activation URL:</info> <href=%s>%s</>', $activationUrl, $activationUrl));
$io->writeln('');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Model\User\Role;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Sends a confirmation email when an account is activated.
*
* This handler listens for CompteActive events and sends an email
* to the user confirming their account activation.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendActivationConfirmationHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private string $appUrl,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(CompteActive $event): void
{
$roleEnum = Role::tryFrom($event->role);
$roleLabel = $roleEnum?->label() ?? $event->role;
$html = $this->twig->render('emails/activation_confirmation.html.twig', [
'email' => $event->email,
'role' => $roleLabel,
'loginUrl' => rtrim($this->appUrl, '/') . '/login',
]);
$email = (new Email())
->from($this->fromEmail)
->to($event->email)
->subject('Votre compte Classeo est activé')
->html($html);
$this->mailer->send($email);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Cache;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
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\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Psr\Cache\CacheItemPoolInterface;
/**
* Cache-based UserRepository for development and testing.
* Uses PSR-6 cache (filesystem in dev, Redis in prod).
*
* Note: Uses a dedicated users.cache pool with no TTL to ensure
* user records don't expire (unlike activation tokens which expire after 7 days).
*/
final readonly class CacheUserRepository implements UserRepository
{
private const string KEY_PREFIX = 'user:';
private const string EMAIL_INDEX_PREFIX = 'user_email:';
public function __construct(
private CacheItemPoolInterface $usersCache,
) {
}
public function save(User $user): void
{
// Save user data
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
$item->set($this->serialize($user));
$this->usersCache->save($item);
// Save email index for lookup
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
public function findById(UserId $id): ?User
{
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
public function findByEmail(Email $email): ?User
{
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
if (!$emailItem->isHit()) {
return null;
}
/** @var string $userId */
$userId = $emailItem->get();
return $this->findById(UserId::fromString($userId));
}
public function get(UserId $id): User
{
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
/**
* @return array<string, mixed>
*/
private function serialize(User $user): array
{
$consentement = $user->consentementParental;
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'role' => $user->role->value,
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
'statut' => $user->statut->value,
'hashed_password' => $user->hashedPassword,
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
'created_at' => $user->createdAt->format('c'),
'activated_at' => $user->activatedAt?->format('c'),
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
'date_consentement' => $consentement->dateConsentement->format('c'),
'ip_address' => $consentement->ipAddress,
] : null,
];
}
/**
* @param array{
* id: string,
* email: string,
* role: string,
* tenant_id: string,
* school_name: string,
* statut: string,
* hashed_password: string|null,
* date_naissance: string|null,
* created_at: string,
* activated_at: string|null,
* consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null
* } $data
*/
private function deserialize(array $data): User
{
$consentement = null;
if ($data['consentement_parental'] !== null) {
$consentementData = $data['consentement_parental'];
$consentement = ConsentementParental::accorder(
parentId: $consentementData['parent_id'],
eleveId: $consentementData['eleve_id'],
at: new DateTimeImmutable($consentementData['date_consentement']),
ipAddress: $consentementData['ip_address'],
);
}
return User::reconstitute(
id: UserId::fromString($data['id']),
email: new Email($data['email']),
role: Role::from($data['role']),
tenantId: TenantId::fromString($data['tenant_id']),
schoolName: $data['school_name'],
statut: StatutCompte::from($data['statut']),
dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null,
createdAt: new DateTimeImmutable($data['created_at']),
hashedPassword: $data['hashed_password'],
activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null,
consentementParental: $consentement,
);
}
private function normalizeEmail(Email $email): string
{
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use Override;
final class InMemoryActivationTokenRepository implements ActivationTokenRepository
{
/** @var array<string, ActivationToken> Indexed by token value */
private array $byTokenValue = [];
/** @var array<string, string> Maps ID to token value */
private array $idToTokenValue = [];
#[Override]
public function save(ActivationToken $token): void
{
$this->byTokenValue[$token->tokenValue] = $token;
$this->idToTokenValue[(string) $token->id] = $token->tokenValue;
}
#[Override]
public function findByTokenValue(string $tokenValue): ?ActivationToken
{
return $this->byTokenValue[$tokenValue] ?? null;
}
#[Override]
public function get(ActivationTokenId $id): ActivationToken
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue === null) {
throw ActivationTokenNotFoundException::withId($id);
}
$token = $this->byTokenValue[$tokenValue] ?? null;
if ($token === null) {
throw ActivationTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(ActivationTokenId $id): void
{
$tokenValue = $this->idToTokenValue[(string) $id] ?? null;
if ($tokenValue !== null) {
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->byTokenValue[$tokenValue]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use Override;
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> Indexed by ID */
private array $byId = [];
/** @var array<string, User> Indexed by email (lowercase) */
private array $byEmail = [];
#[Override]
public function save(User $user): void
{
$this->byId[(string) $user->id] = $user;
$this->byEmail[strtolower((string) $user->email)] = $user;
}
#[Override]
public function get(UserId $id): User
{
$user = $this->byId[(string) $id] ?? null;
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
#[Override]
public function findByEmail(Email $email): ?User
{
return $this->byEmail[strtolower((string) $email)] ?? null;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
final readonly class RedisActivationTokenRepository implements ActivationTokenRepository
{
private const string KEY_PREFIX = 'activation:';
private const int TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
public function __construct(
private CacheItemPoolInterface $activationTokensCache,
) {
}
#[Override]
public function save(ActivationToken $token): void
{
// Store by token value for lookup during activation
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
$item->set($this->serialize($token));
$item->expiresAfter(self::TTL_SECONDS);
$this->activationTokensCache->save($item);
// Also store by ID for direct access
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
$idItem->set($token->tokenValue);
$idItem->expiresAfter(self::TTL_SECONDS);
$this->activationTokensCache->save($idItem);
}
#[Override]
public function findByTokenValue(string $tokenValue): ?ActivationToken
{
$item = $this->activationTokensCache->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, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
#[Override]
public function get(ActivationTokenId $id): ActivationToken
{
// First get the token value from the ID index
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if (!$idItem->isHit()) {
throw ActivationTokenNotFoundException::withId($id);
}
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$token = $this->findByTokenValue($tokenValue);
if ($token === null) {
throw ActivationTokenNotFoundException::withId($id);
}
return $token;
}
#[Override]
public function delete(ActivationTokenId $id): void
{
// Get token value first
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
if ($idItem->isHit()) {
/** @var string $tokenValue */
$tokenValue = $idItem->get();
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
}
#[Override]
public function deleteByTokenValue(string $tokenValue): void
{
$token = $this->findByTokenValue($tokenValue);
if ($token !== null) {
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
}
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
}
/**
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
*/
private function serialize(ActivationToken $token): array
{
return [
'id' => (string) $token->id,
'token_value' => $token->tokenValue,
'user_id' => $token->userId,
'email' => $token->email,
'tenant_id' => (string) $token->tenantId,
'role' => $token->role,
'school_name' => $token->schoolName,
'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, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
*/
private function deserialize(array $data): ActivationToken
{
return ActivationToken::reconstitute(
id: ActivationTokenId::fromString($data['id']),
tokenValue: $data['token_value'],
userId: $data['user_id'],
email: $data['email'],
tenantId: TenantId::fromString($data['tenant_id']),
role: $data['role'],
schoolName: $data['school_name'],
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

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Application\Port\PasswordHasher;
use Override;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
/**
* Symfony implementation of the PasswordHasher port.
*
* Uses Symfony's PasswordHasher component with Argon2id algorithm.
*/
final readonly class SymfonyPasswordHasher implements PasswordHasher
{
private const string HASHER_ID = 'common';
public function __construct(
private PasswordHasherFactoryInterface $hasherFactory,
) {
}
#[Override]
public function hash(string $plainPassword): string
{
return $this->hasherFactory
->getPasswordHasher(self::HASHER_ID)
->hash($plainPassword);
}
#[Override]
public function verify(string $hashedPassword, string $plainPassword): bool
{
return $this->hasherFactory
->getPasswordHasher(self::HASHER_ID)
->verify($hashedPassword, $plainPassword);
}
}