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