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,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ActivationToken extends AggregateRoot
|
||||
{
|
||||
private const int EXPIRATION_DAYS = 7;
|
||||
|
||||
public private(set) ?DateTimeImmutable $usedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ActivationTokenId $id,
|
||||
public private(set) string $tokenValue,
|
||||
public private(set) string $userId,
|
||||
public private(set) string $email,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $role,
|
||||
public private(set) string $schoolName,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) DateTimeImmutable $expiresAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function generate(
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: ActivationTokenId::generate(),
|
||||
tokenValue: Uuid::uuid4()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
|
||||
);
|
||||
|
||||
$token->recordEvent(new ActivationTokenGenerated(
|
||||
tokenId: $token->id,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute an ActivationToken from storage.
|
||||
* Does NOT record domain events (this is not a new creation).
|
||||
*
|
||||
* @internal For use by Infrastructure layer only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ActivationTokenId $id,
|
||||
string $tokenValue,
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
string $role,
|
||||
string $schoolName,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $expiresAt,
|
||||
?DateTimeImmutable $usedAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: $id,
|
||||
tokenValue: $tokenValue,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
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 activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function validateForUse(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->isUsed()) {
|
||||
throw ActivationTokenAlreadyUsedException::forToken($this->id);
|
||||
}
|
||||
|
||||
if ($this->isExpired($at)) {
|
||||
throw ActivationTokenExpiredException::forToken($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used. Should only be called after successful user activation.
|
||||
*
|
||||
* @throws ActivationTokenAlreadyUsedException if token was already used
|
||||
* @throws ActivationTokenExpiredException if token is expired
|
||||
*/
|
||||
public function use(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->validateForUse($at);
|
||||
|
||||
$this->usedAt = $at;
|
||||
|
||||
$this->recordEvent(new ActivationTokenUsed(
|
||||
tokenId: $this->id,
|
||||
userId: $this->userId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user