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,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class ActivationTokenGenerated implements DomainEvent
{
public function __construct(
public ActivationTokenId $tokenId,
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\ActivationToken\ActivationTokenId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class ActivationTokenUsed implements DomainEvent
{
public function __construct(
public ActivationTokenId $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,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Shared\Domain\DomainEvent;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Event emitted when a user account is activated.
*
* This event triggers the sending of a confirmation email
* and any other side effects related to account activation.
*/
final readonly class CompteActive implements DomainEvent
{
public function __construct(
public string $userId,
public string $email,
public TenantId $tenantId,
public string $role,
private DateTimeImmutable $occurredOn,
private UuidInterface $aggregateId,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->aggregateId;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Event émis lors de la création d'un nouveau compte utilisateur.
*/
final readonly class CompteCreated implements DomainEvent
{
public function __construct(
public UserId $userId,
public string $email,
public string $role,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->userId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use RuntimeException;
use function sprintf;
final class ActivationTokenAlreadyUsedException extends RuntimeException
{
public static function forToken(ActivationTokenId $tokenId): self
{
return new self(sprintf(
'Activation 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\ActivationToken\ActivationTokenId;
use RuntimeException;
use function sprintf;
final class ActivationTokenExpiredException extends RuntimeException
{
public static function forToken(ActivationTokenId $tokenId): self
{
return new self(sprintf(
'Activation 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\ActivationToken\ActivationTokenId;
use RuntimeException;
use function sprintf;
final class ActivationTokenNotFoundException extends RuntimeException
{
public static function withId(ActivationTokenId $tokenId): self
{
return new self(sprintf(
'Activation token with ID "%s" not found.',
$tokenId,
));
}
public static function withTokenValue(string $tokenValue): self
{
return new self(sprintf(
'Activation token with value "%s" not found.',
$tokenValue,
));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use function sprintf;
final class CompteNonActivableException extends RuntimeException
{
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
{
return new self(sprintf(
'Le compte "%s" ne peut pas être activé car son statut est "%s".',
$userId,
$statut->value,
));
}
public static function carConsentementManquant(UserId $userId): self
{
return new self(sprintf(
'Le compte "%s" ne peut pas être activé : consentement parental manquant.',
$userId,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class EmailInvalideException extends RuntimeException
{
public static function pourAdresse(string $email): self
{
return new self(sprintf(
'L\'adresse email "%s" n\'est pas valide.',
$email,
));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use function sprintf;
final class UserNotFoundException extends RuntimeException
{
public static function withId(UserId $userId): self
{
return new self(sprintf(
'User with ID "%s" not found.',
$userId,
));
}
public static function withEmail(Email $email): self
{
return new self(sprintf(
'User with email "%s" not found.',
$email,
));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\ConsentementParental;
use DateTimeImmutable;
/**
* Value Object représentant le consentement parental.
*
* Requis pour les utilisateurs mineurs (< 15 ans) conformément au RGPD (NFR-C1).
* Le consentement doit être donné par un parent avant que l'élève puisse activer son compte.
*/
final readonly class ConsentementParental
{
public function __construct(
public string $parentId,
public string $eleveId,
public DateTimeImmutable $dateConsentement,
public string $ipAddress,
) {
}
/**
* Crée un nouveau consentement parental horodaté.
*/
public static function accorder(
string $parentId,
string $eleveId,
DateTimeImmutable $at,
string $ipAddress,
): self {
return new self(
parentId: $parentId,
eleveId: $eleveId,
dateConsentement: $at,
ipAddress: $ipAddress,
);
}
public function estPourEleve(string $eleveId): bool
{
return $this->eleveId === $eleveId;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\User;
use App\Administration\Domain\Exception\EmailInvalideException;
use const FILTER_VALIDATE_EMAIL;
/**
* Value Object représentant une adresse email valide.
*
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
*/
final readonly class Email
{
public string $value;
public function __construct(string $value)
{
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw EmailInvalideException::pourAdresse($value);
}
$this->value = $value;
}
public function equals(self $other): bool
{
return strtolower($this->value) === strtolower($other->value);
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\User;
use function in_array;
/**
* Enum représentant les rôles utilisateur dans Classeo.
*
* Hiérarchie RBAC :
* - ROLE_SUPER_ADMIN → Accès à tous les établissements
* - ROLE_ADMIN → Direction d'un établissement
* - ROLE_PROF → Enseignant
* - ROLE_VIE_SCOLAIRE → Personnel vie scolaire
* - ROLE_SECRETARIAT → Personnel administratif
* - ROLE_PARENT → Parent d'élève
* - ROLE_ELEVE → Élève
*/
enum Role: string
{
case SUPER_ADMIN = 'ROLE_SUPER_ADMIN';
case ADMIN = 'ROLE_ADMIN';
case PROF = 'ROLE_PROF';
case VIE_SCOLAIRE = 'ROLE_VIE_SCOLAIRE';
case SECRETARIAT = 'ROLE_SECRETARIAT';
case PARENT = 'ROLE_PARENT';
case ELEVE = 'ROLE_ELEVE';
/**
* Vérifie si ce rôle inclut implicitement un autre rôle (hiérarchie).
*/
public function inclut(Role $autre): bool
{
$hierarchie = [
self::SUPER_ADMIN->value => [
self::ADMIN, self::PROF, self::VIE_SCOLAIRE,
self::SECRETARIAT, self::PARENT, self::ELEVE,
],
self::ADMIN->value => [
self::PROF, self::VIE_SCOLAIRE, self::SECRETARIAT,
],
];
$rolesInclus = $hierarchie[$this->value] ?? [];
return in_array($autre, $rolesInclus, true);
}
/**
* Retourne le libellé français du rôle.
*/
public function label(): string
{
return match ($this) {
self::SUPER_ADMIN => 'Super Administrateur',
self::ADMIN => 'Directeur',
self::PROF => 'Enseignant',
self::VIE_SCOLAIRE => 'Vie Scolaire',
self::SECRETARIAT => 'Secrétariat',
self::PARENT => 'Parent',
self::ELEVE => 'Élève',
};
}
/**
* Vérifie si ce rôle nécessite un consentement parental potentiel.
*/
public function peutEtreMineur(): bool
{
return $this === self::ELEVE;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\User;
/**
* Enum représentant le statut d'activation d'un compte utilisateur.
*/
enum StatutCompte: string
{
case EN_ATTENTE = 'pending'; // Compte créé, en attente d'activation
case CONSENTEMENT_REQUIS = 'consent'; // Mineur < 15 ans, en attente consentement parental
case ACTIF = 'active'; // Compte activé et utilisable
case SUSPENDU = 'suspended'; // Compte temporairement désactivé
case ARCHIVE = 'archived'; // Compte archivé (fin de scolarité)
/**
* Vérifie si l'utilisateur peut se connecter avec ce statut.
*/
public function peutSeConnecter(): bool
{
return $this === self::ACTIF;
}
/**
* Vérifie si l'utilisateur peut activer son compte.
*/
public function peutActiver(): bool
{
return $this === self::EN_ATTENTE;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\User;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant un utilisateur dans Classeo.
*
* Un utilisateur appartient à un établissement (tenant) et possède un rôle.
* Le cycle de vie du compte passe par plusieurs statuts : création → activation.
* Les mineurs (< 15 ans) nécessitent un consentement parental avant activation.
*/
final class User extends AggregateRoot
{
public private(set) ?string $hashedPassword = null;
public private(set) ?DateTimeImmutable $activatedAt = null;
public private(set) ?ConsentementParental $consentementParental = null;
private function __construct(
public private(set) UserId $id,
public private(set) Email $email,
public private(set) Role $role,
public private(set) TenantId $tenantId,
public private(set) string $schoolName,
public private(set) StatutCompte $statut,
public private(set) ?DateTimeImmutable $dateNaissance,
public private(set) DateTimeImmutable $createdAt,
) {
}
/**
* Crée un nouveau compte utilisateur en attente d'activation.
*/
public static function creer(
Email $email,
Role $role,
TenantId $tenantId,
string $schoolName,
?DateTimeImmutable $dateNaissance,
DateTimeImmutable $createdAt,
): self {
$user = new self(
id: UserId::generate(),
email: $email,
role: $role,
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::EN_ATTENTE,
dateNaissance: $dateNaissance,
createdAt: $createdAt,
);
$user->recordEvent(new CompteCreated(
userId: $user->id,
email: (string) $user->email,
role: $user->role->value,
tenantId: $user->tenantId,
occurredOn: $createdAt,
));
return $user;
}
/**
* Active le compte avec le mot de passe hashé.
*
* @throws CompteNonActivableException si le compte ne peut pas être activé
*/
public function activer(
string $hashedPassword,
DateTimeImmutable $at,
ConsentementParentalPolicy $consentementPolicy,
): void {
if (!$this->statut->peutActiver()) {
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
}
// Vérifier si le consentement parental est requis
if ($consentementPolicy->estRequis($this->dateNaissance)) {
if ($this->consentementParental === null) {
throw CompteNonActivableException::carConsentementManquant($this->id);
}
}
$this->hashedPassword = $hashedPassword;
$this->statut = StatutCompte::ACTIF;
$this->activatedAt = $at;
$this->recordEvent(new CompteActive(
userId: (string) $this->id,
email: (string) $this->email,
tenantId: $this->tenantId,
role: $this->role->value,
occurredOn: $at,
aggregateId: $this->id->value,
));
}
/**
* Enregistre le consentement parental donné par le parent.
*/
public function enregistrerConsentementParental(ConsentementParental $consentement): void
{
$this->consentementParental = $consentement;
// Si le compte était en attente de consentement, passer en attente d'activation
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
$this->statut = StatutCompte::EN_ATTENTE;
}
}
/**
* Vérifie si cet utilisateur est mineur et nécessite un consentement parental.
*/
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
{
return $policy->estRequis($this->dateNaissance);
}
/**
* Vérifie si le compte est actif et peut se connecter.
*/
public function peutSeConnecter(): bool
{
return $this->statut->peutSeConnecter();
}
/**
* Reconstitue un User depuis le stockage.
*
* @internal Pour usage par l'Infrastructure uniquement
*/
public static function reconstitute(
UserId $id,
Email $email,
Role $role,
TenantId $tenantId,
string $schoolName,
StatutCompte $statut,
?DateTimeImmutable $dateNaissance,
DateTimeImmutable $createdAt,
?string $hashedPassword,
?DateTimeImmutable $activatedAt,
?ConsentementParental $consentementParental,
): self {
$user = new self(
id: $id,
email: $email,
role: $role,
tenantId: $tenantId,
schoolName: $schoolName,
statut: $statut,
dateNaissance: $dateNaissance,
createdAt: $createdAt,
);
$user->hashedPassword = $hashedPassword;
$user->activatedAt = $activatedAt;
$user->consentementParental = $consentementParental;
return $user;
}
}

View File

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

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Policy;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
/**
* Policy déterminant si le consentement parental est requis.
*
* Conformément au RGPD (NFR-C1), le consentement parental est obligatoire
* pour les utilisateurs de moins de 15 ans.
*/
final readonly class ConsentementParentalPolicy
{
private const int AGE_MAJORITE_NUMERIQUE = 15;
public function __construct(
private Clock $clock,
) {
}
/**
* Vérifie si le consentement parental est requis pour un utilisateur
* né à la date spécifiée.
*/
public function estRequis(?DateTimeImmutable $dateNaissance): bool
{
if ($dateNaissance === null) {
return false;
}
return $this->calculerAge($dateNaissance) < self::AGE_MAJORITE_NUMERIQUE;
}
/**
* Calcule l'âge en années à partir de la date de naissance.
*/
private function calculerAge(DateTimeImmutable $dateNaissance): int
{
$now = $this->clock->now();
$interval = $now->diff($dateNaissance);
return $interval->y;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
interface ActivationTokenRepository
{
public function save(ActivationToken $token): void;
/**
* Find a token by its unique token value.
*/
public function findByTokenValue(string $tokenValue): ?ActivationToken;
/**
* Get a token by its ID.
*
* @throws \App\Administration\Domain\Exception\ActivationTokenNotFoundException if token does not exist
*/
public function get(ActivationTokenId $id): ActivationToken;
/**
* Delete a token (after use or for cleanup).
*/
public function delete(ActivationTokenId $id): void;
/**
* Delete a token by its token value.
*/
public function deleteByTokenValue(string $tokenValue): void;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
interface UserRepository
{
public function save(User $user): void;
/**
* @throws \App\Administration\Domain\Exception\UserNotFoundException
*/
public function get(UserId $id): User;
public function findByEmail(Email $email): ?User;
}