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:
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
/**
|
||||
* Command to activate a user account using an activation token.
|
||||
*
|
||||
* This command is dispatched when a user clicks their activation link
|
||||
* and submits a valid password.
|
||||
*/
|
||||
final readonly class ActivateAccountCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tokenValue,
|
||||
public string $password,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Repository\ActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ActivateAccountHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ActivationTokenRepository $tokenRepository,
|
||||
private PasswordHasher $passwordHasher,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActivationTokenNotFoundException if token does not exist
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenExpiredException if token is expired
|
||||
* @throws \App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException if token was already used
|
||||
*/
|
||||
public function __invoke(ActivateAccountCommand $command): ActivateAccountResult
|
||||
{
|
||||
$token = $this->tokenRepository->findByTokenValue($command->tokenValue);
|
||||
|
||||
if ($token === null) {
|
||||
throw ActivationTokenNotFoundException::withTokenValue($command->tokenValue);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Validate token can be used (throws if expired or already used)
|
||||
// Note: Token is NOT marked as used here - that's deferred to the processor
|
||||
// after successful user activation, so failed activations don't burn the token
|
||||
$token->validateForUse($now);
|
||||
|
||||
// Hash the password for User model
|
||||
$hashedPassword = $this->passwordHasher->hash($command->password);
|
||||
|
||||
return new ActivateAccountResult(
|
||||
userId: $token->userId,
|
||||
email: $token->email,
|
||||
tenantId: $token->tenantId,
|
||||
role: $token->role,
|
||||
hashedPassword: $hashedPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Result of the ActivateAccountCommand execution.
|
||||
*
|
||||
* Contains the information needed to complete the activation process,
|
||||
* including the hashed password to be stored on the User aggregate.
|
||||
*/
|
||||
final readonly class ActivateAccountResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
public string $role,
|
||||
public string $hashedPassword,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port interface for password hashing operations.
|
||||
*
|
||||
* This abstracts the password hashing mechanism, allowing the Application
|
||||
* layer to remain independent of the specific hashing implementation
|
||||
* (e.g., Symfony PasswordHasher with Argon2id).
|
||||
*/
|
||||
interface PasswordHasher
|
||||
{
|
||||
/**
|
||||
* Hash a plain text password.
|
||||
*/
|
||||
public function hash(string $plainPassword): string;
|
||||
|
||||
/**
|
||||
* Verify a plain password against a hash.
|
||||
*/
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool;
|
||||
}
|
||||
Reference in New Issue
Block a user