Files
Classeo/backend/src/Administration/Domain/Model/User/User.php
Mathias STRASSER affad287f9 feat: Réinitialisation de mot de passe avec tokens sécurisés
Implémentation complète du flux de réinitialisation de mot de passe (Story 1.5):

Backend:
- Aggregate PasswordResetToken avec TTL 1h, UUID v7, usage unique
- Endpoint POST /api/password/forgot avec rate limiting (3/h par email, 10/h par IP)
- Endpoint POST /api/password/reset avec validation token
- Templates email (demande + confirmation)
- Repository Redis avec TTL 2h pour distinguer expiré/invalide

Frontend:
- Page /mot-de-passe-oublie avec message générique (anti-énumération)
- Page /reset-password/[token] avec validation temps réel des critères
- Gestion erreurs: token invalide, expiré, déjà utilisé

Tests:
- 14 tests unitaires PasswordResetToken
- 7 tests unitaires RequestPasswordResetHandler
- 7 tests unitaires ResetPasswordHandler
- Tests E2E Playwright pour le flux complet
2026-02-02 09:45:15 +01:00

192 lines
5.7 KiB
PHP

<?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\Event\MotDePasseChange;
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\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root representing a user in Classeo.
*
* A user belongs to a school (tenant) and has a role.
* The account lifecycle goes through multiple statuses: creation → activation.
* Minors (< 15 years) require parental consent before 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,
) {
}
/**
* Creates a new user account awaiting 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;
}
/**
* Activates the account with the hashed password.
*
* @throws CompteNonActivableException if the account cannot be activated
*/
public function activer(
string $hashedPassword,
DateTimeImmutable $at,
ConsentementParentalPolicy $consentementPolicy,
): void {
if (!$this->statut->peutActiver()) {
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
}
// Check if parental consent is required
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,
));
}
/**
* Records the parental consent given by the parent.
*/
public function enregistrerConsentementParental(ConsentementParental $consentement): void
{
$this->consentementParental = $consentement;
// If the account was awaiting consent, move to awaiting activation
if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) {
$this->statut = StatutCompte::EN_ATTENTE;
}
}
/**
* Checks if this user is a minor and requires parental consent.
*/
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
{
return $policy->estRequis($this->dateNaissance);
}
/**
* Checks if the account is active and can log in.
*/
public function peutSeConnecter(): bool
{
return $this->statut->peutSeConnecter();
}
/**
* Changes the user's password.
*
* Used during password reset.
*/
public function changerMotDePasse(string $hashedPassword, DateTimeImmutable $at): void
{
$this->hashedPassword = $hashedPassword;
$this->recordEvent(new MotDePasseChange(
userId: (string) $this->id,
email: (string) $this->email,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Reconstitutes a User from storage.
*
* @internal For Infrastructure use only
*/
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;
}
}