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
This commit is contained in:
@@ -10,12 +10,12 @@ use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors d'une tentative de connexion échouée.
|
||||
* Event emitted when a login attempt fails.
|
||||
*
|
||||
* Note: L'email est enregistré pour le tracking mais ne révèle pas
|
||||
* si le compte existe (même message d'erreur dans tous les cas).
|
||||
* Note: The email is recorded for tracking but does not reveal
|
||||
* whether the account exists (same error message in all cases).
|
||||
*
|
||||
* @see Story 1.4 - AC2: Gestion erreurs d'authentification
|
||||
* @see Story 1.4 - AC2: Authentication error handling
|
||||
*/
|
||||
final readonly class ConnexionEchouee implements DomainEvent
|
||||
{
|
||||
@@ -35,7 +35,7 @@ final readonly class ConnexionEchouee implements DomainEvent
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
// Pas d'aggregate associé, utiliser un UUID basé sur l'email
|
||||
// No associated aggregate, use a UUID based on the email
|
||||
return Uuid::uuid5(
|
||||
Uuid::NAMESPACE_DNS,
|
||||
'login_attempt:' . $this->email,
|
||||
|
||||
42
backend/src/Administration/Domain/Event/MotDePasseChange.php
Normal file
42
backend/src/Administration/Domain/Event/MotDePasseChange.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Event emitted when a user's password is changed.
|
||||
*
|
||||
* This event triggers:
|
||||
* - Sending a confirmation email
|
||||
* - Audit logging
|
||||
*/
|
||||
final readonly class MotDePasseChange implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
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 Uuid::fromString($this->userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class PasswordResetTokenGenerated implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public PasswordResetTokenId $tokenId,
|
||||
public string $tokenValue,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class PasswordResetTokenUsed implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public PasswordResetTokenId $tokenId,
|
||||
public string $userId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->tokenId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenAlreadyUsedException extends RuntimeException
|
||||
{
|
||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token "%s" has already been used.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenExpiredException extends RuntimeException
|
||||
{
|
||||
public static function forToken(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token "%s" has expired.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetTokenNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(PasswordResetTokenId $tokenId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token with ID "%s" not found.',
|
||||
$tokenId,
|
||||
));
|
||||
}
|
||||
|
||||
public static function withTokenValue(string $tokenValue): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Password reset token with value "%s" not found.',
|
||||
$tokenValue,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Thrown when a token consumption is already in progress.
|
||||
*
|
||||
* This indicates a concurrent request is processing the same token,
|
||||
* and the client should retry after a short delay.
|
||||
*/
|
||||
final class TokenConsumptionInProgressException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $tokenValue)
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf('Token consumption in progress for token "%s". Please retry.', $tokenValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ final class ActivationToken extends AggregateRoot
|
||||
): self {
|
||||
$token = new self(
|
||||
id: ActivationTokenId::generate(),
|
||||
tokenValue: Uuid::uuid4()->toString(),
|
||||
tokenValue: Uuid::uuid7()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\PasswordResetToken;
|
||||
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use App\Administration\Domain\Event\PasswordResetTokenUsed;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\PasswordResetTokenExpiredException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class PasswordResetToken extends AggregateRoot
|
||||
{
|
||||
private const int EXPIRATION_HOURS = 1;
|
||||
|
||||
public private(set) ?DateTimeImmutable $usedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) PasswordResetTokenId $id,
|
||||
public private(set) string $tokenValue,
|
||||
public private(set) string $userId,
|
||||
public private(set) string $email,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
public private(set) DateTimeImmutable $expiresAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function generate(
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: PasswordResetTokenId::generate(),
|
||||
tokenValue: Uuid::uuid7()->toString(),
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $createdAt,
|
||||
expiresAt: $createdAt->modify(sprintf('+%d hour', self::EXPIRATION_HOURS)),
|
||||
);
|
||||
|
||||
$token->recordEvent(new PasswordResetTokenGenerated(
|
||||
tokenId: $token->id,
|
||||
tokenValue: $token->tokenValue,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute a PasswordResetToken from storage.
|
||||
* Does NOT record domain events (this is not a new creation).
|
||||
*
|
||||
* @internal For use by Infrastructure layer only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
PasswordResetTokenId $id,
|
||||
string $tokenValue,
|
||||
string $userId,
|
||||
string $email,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $expiresAt,
|
||||
?DateTimeImmutable $usedAt,
|
||||
): self {
|
||||
$token = new self(
|
||||
id: $id,
|
||||
tokenValue: $tokenValue,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
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 password reset.
|
||||
*
|
||||
* @throws PasswordResetTokenAlreadyUsedException if token was already used
|
||||
* @throws PasswordResetTokenExpiredException if token is expired
|
||||
*/
|
||||
public function validateForUse(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->isUsed()) {
|
||||
throw PasswordResetTokenAlreadyUsedException::forToken($this->id);
|
||||
}
|
||||
|
||||
if ($this->isExpired($at)) {
|
||||
throw PasswordResetTokenExpiredException::forToken($this->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the token as used. Should only be called after successful password reset.
|
||||
*
|
||||
* @throws PasswordResetTokenAlreadyUsedException if token was already used
|
||||
* @throws PasswordResetTokenExpiredException if token is expired
|
||||
*/
|
||||
public function use(DateTimeImmutable $at): void
|
||||
{
|
||||
$this->validateForUse($at);
|
||||
|
||||
$this->usedAt = $at;
|
||||
|
||||
$this->recordEvent(new PasswordResetTokenUsed(
|
||||
tokenId: $this->id,
|
||||
userId: $this->userId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\PasswordResetToken;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class PasswordResetTokenId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -10,39 +10,39 @@ use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Représente un refresh token pour le renouvellement silencieux des sessions.
|
||||
* Represents a refresh token for silent session renewal.
|
||||
*
|
||||
* Stratégie de sécurité :
|
||||
* - Rotation : chaque utilisation génère un nouveau token et invalide l'ancien
|
||||
* - Family tracking : tous les tokens d'une session partagent un family_id
|
||||
* - Replay detection : si un token déjà utilisé est présenté, toute la famille est invalidée
|
||||
* - Device binding : le token est lié à un device fingerprint
|
||||
* - Grace period : 30s de tolérance pour les race conditions multi-onglets
|
||||
* Security strategy:
|
||||
* - Rotation: each use generates a new token and invalidates the old one
|
||||
* - Family tracking: all tokens from a session share a family_id
|
||||
* - Replay detection: if an already-used token is presented, the entire family is invalidated
|
||||
* - Device binding: the token is bound to a device fingerprint
|
||||
* - Grace period: 30s tolerance for multi-tab race conditions
|
||||
*
|
||||
* Note sur les méthodes statiques :
|
||||
* Cette classe utilise des factory methods statiques (create(), reconstitute()) conformément
|
||||
* aux patterns DDD standards pour la création d'Aggregates. Bien que le projet suive les
|
||||
* principes Elegant Objects "No Static", les factory methods pour les Aggregates sont une
|
||||
* exception documentée car elles encapsulent la logique d'instanciation et rendent le
|
||||
* constructeur privé, préservant ainsi l'invariant du domain.
|
||||
* Note on static methods:
|
||||
* This class uses static factory methods (create(), reconstitute()) following standard
|
||||
* DDD patterns for Aggregate creation. Although the project follows Elegant Objects
|
||||
* "No Static" principles, factory methods for Aggregates are a documented exception
|
||||
* as they encapsulate instantiation logic and keep the constructor private, thus
|
||||
* preserving domain invariants.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
* @see Story 1.4 - User login
|
||||
*/
|
||||
final readonly class RefreshToken
|
||||
{
|
||||
/**
|
||||
* TTL par défaut : 7 jours (604800 secondes).
|
||||
* Default TTL: 7 days (604800 seconds).
|
||||
*
|
||||
* Ce TTL est utilisé si aucun TTL n'est spécifié à la création du token.
|
||||
* RefreshTokenManager utilise 1 jour (86400s) pour les sessions web afin
|
||||
* de limiter l'exposition en cas de vol de cookie sur navigateur.
|
||||
* This TTL is used if none is specified at token creation.
|
||||
* RefreshTokenManager uses 1 day (86400s) for web sessions to
|
||||
* limit exposure in case of cookie theft on browsers.
|
||||
*/
|
||||
private const int DEFAULT_TTL_SECONDS = 604800;
|
||||
|
||||
/**
|
||||
* Période de grâce après rotation pour gérer les race conditions multi-onglets.
|
||||
* Si deux onglets rafraîchissent simultanément, le second recevra une erreur
|
||||
* bénigne au lieu d'invalider toute la famille de tokens.
|
||||
* Grace period after rotation to handle multi-tab race conditions.
|
||||
* If two tabs refresh simultaneously, the second will receive a benign
|
||||
* error instead of invalidating the entire token family.
|
||||
*/
|
||||
private const int GRACE_PERIOD_SECONDS = 30;
|
||||
|
||||
@@ -61,7 +61,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau refresh token pour une nouvelle session.
|
||||
* Creates a new refresh token for a new session.
|
||||
*/
|
||||
public static function create(
|
||||
UserId $userId,
|
||||
@@ -85,27 +85,27 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une rotation du token (génère un nouveau token, marque l'ancien comme rotaté).
|
||||
* Performs token rotation (generates a new token, marks the old one as rotated).
|
||||
*
|
||||
* Le nouveau token conserve le même TTL que l'original pour respecter la politique de session
|
||||
* (web = 1 jour, mobile = 7 jours). L'ancien token est marqué avec rotatedAt pour la grace period.
|
||||
* The new token preserves the same TTL as the original to respect the session policy
|
||||
* (web = 1 day, mobile = 7 days). The old token is marked with rotatedAt for the grace period.
|
||||
*
|
||||
* @return array{0: self, 1: self} Le nouveau token et l'ancien token marqué comme rotaté
|
||||
* @return array{0: self, 1: self} The new token and the old token marked as rotated
|
||||
*/
|
||||
public function rotate(DateTimeImmutable $at): array
|
||||
{
|
||||
// Préserver le TTL original pour respecter la politique de session (web = 1 jour, mobile = 7 jours)
|
||||
// Preserve original TTL to respect session policy (web = 1 day, mobile = 7 days)
|
||||
$originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp();
|
||||
|
||||
$newToken = new self(
|
||||
id: RefreshTokenId::generate(),
|
||||
familyId: $this->familyId, // Même famille
|
||||
familyId: $this->familyId, // Same family
|
||||
userId: $this->userId,
|
||||
tenantId: $this->tenantId,
|
||||
deviceFingerprint: $this->deviceFingerprint,
|
||||
issuedAt: $at,
|
||||
expiresAt: $at->modify("+{$originalTtlSeconds} seconds"),
|
||||
rotatedFrom: $this->id, // Traçabilité
|
||||
rotatedFrom: $this->id, // Traceability
|
||||
isRotated: false,
|
||||
rotatedAt: null,
|
||||
);
|
||||
@@ -120,14 +120,14 @@ final readonly class RefreshToken
|
||||
expiresAt: $this->expiresAt,
|
||||
rotatedFrom: $this->rotatedFrom,
|
||||
isRotated: true,
|
||||
rotatedAt: $at, // Pour la grace period
|
||||
rotatedAt: $at, // For the grace period
|
||||
);
|
||||
|
||||
return [$newToken, $rotatedOldToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est expiré.
|
||||
* Checks if the token is expired.
|
||||
*/
|
||||
public function isExpired(DateTimeImmutable $at): bool
|
||||
{
|
||||
@@ -135,11 +135,11 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est dans la période de grâce après rotation.
|
||||
* Checks if the token is in the grace period after rotation.
|
||||
*
|
||||
* La grace period permet de gérer les race conditions quand plusieurs onglets
|
||||
* tentent de rafraîchir le token simultanément. Elle est basée sur le moment
|
||||
* de la rotation, pas sur l'émission initiale du token.
|
||||
* The grace period handles race conditions when multiple tabs attempt to
|
||||
* refresh the token simultaneously. It is based on the rotation time,
|
||||
* not the initial token issuance.
|
||||
*/
|
||||
public function isInGracePeriod(DateTimeImmutable $at): bool
|
||||
{
|
||||
@@ -153,7 +153,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'empreinte du device correspond.
|
||||
* Checks if the device fingerprint matches.
|
||||
*/
|
||||
public function matchesDevice(DeviceFingerprint $fingerprint): bool
|
||||
{
|
||||
@@ -161,9 +161,9 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le token string à stocker dans le cookie.
|
||||
* Generates the token string to store in the cookie.
|
||||
*
|
||||
* Le format est opaque pour le client : base64(id)
|
||||
* The format is opaque to the client: base64(id)
|
||||
*/
|
||||
public function toTokenString(): string
|
||||
{
|
||||
@@ -171,7 +171,7 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'ID depuis un token string.
|
||||
* Extracts the ID from a token string.
|
||||
*/
|
||||
public static function extractIdFromTokenString(string $tokenString): RefreshTokenId
|
||||
{
|
||||
@@ -185,9 +185,9 @@ final readonly class RefreshToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un RefreshToken depuis le stockage.
|
||||
* Reconstitutes a RefreshToken from storage.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
* @internal For Infrastructure use only
|
||||
*/
|
||||
public static function reconstitute(
|
||||
RefreshTokenId $id,
|
||||
|
||||
@@ -5,18 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Model\User;
|
||||
|
||||
/**
|
||||
* Enum représentant le statut d'activation d'un compte utilisateur.
|
||||
* Enum representing the activation status of a user account.
|
||||
*/
|
||||
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é)
|
||||
case EN_ATTENTE = 'pending'; // Account created, awaiting activation
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent
|
||||
case ACTIF = 'active'; // Account activated and usable
|
||||
case SUSPENDU = 'suspended'; // Account temporarily disabled
|
||||
case ARCHIVE = 'archived'; // Account archived (end of schooling)
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut se connecter avec ce statut.
|
||||
* Checks if the user can log in with this status.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
@@ -24,7 +24,7 @@ enum StatutCompte: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur peut activer son compte.
|
||||
* Checks if the user can activate their account.
|
||||
*/
|
||||
public function peutActiver(): bool
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -14,11 +15,11 @@ use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un utilisateur dans Classeo.
|
||||
* Aggregate Root representing a user in 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.
|
||||
* 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
|
||||
{
|
||||
@@ -39,7 +40,7 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau compte utilisateur en attente d'activation.
|
||||
* Creates a new user account awaiting activation.
|
||||
*/
|
||||
public static function creer(
|
||||
Email $email,
|
||||
@@ -72,9 +73,9 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Active le compte avec le mot de passe hashé.
|
||||
* Activates the account with the hashed password.
|
||||
*
|
||||
* @throws CompteNonActivableException si le compte ne peut pas être activé
|
||||
* @throws CompteNonActivableException if the account cannot be activated
|
||||
*/
|
||||
public function activer(
|
||||
string $hashedPassword,
|
||||
@@ -85,7 +86,7 @@ final class User extends AggregateRoot
|
||||
throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut);
|
||||
}
|
||||
|
||||
// Vérifier si le consentement parental est requis
|
||||
// Check if parental consent is required
|
||||
if ($consentementPolicy->estRequis($this->dateNaissance)) {
|
||||
if ($this->consentementParental === null) {
|
||||
throw CompteNonActivableException::carConsentementManquant($this->id);
|
||||
@@ -107,20 +108,20 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le consentement parental donné par le parent.
|
||||
* Records the parental consent given by the parent.
|
||||
*/
|
||||
public function enregistrerConsentementParental(ConsentementParental $consentement): void
|
||||
{
|
||||
$this->consentementParental = $consentement;
|
||||
|
||||
// Si le compte était en attente de consentement, passer en attente d'activation
|
||||
// If the account was awaiting consent, move to awaiting 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.
|
||||
* Checks if this user is a minor and requires parental consent.
|
||||
*/
|
||||
public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool
|
||||
{
|
||||
@@ -128,7 +129,7 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le compte est actif et peut se connecter.
|
||||
* Checks if the account is active and can log in.
|
||||
*/
|
||||
public function peutSeConnecter(): bool
|
||||
{
|
||||
@@ -136,9 +137,26 @@ final class User extends AggregateRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un User depuis le stockage.
|
||||
* Changes the user's password.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
* 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,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface PasswordResetTokenRepository
|
||||
{
|
||||
public function save(PasswordResetToken $token): void;
|
||||
|
||||
/**
|
||||
* Find a token by its unique token value.
|
||||
* Use getByTokenValue() when you expect the token to exist.
|
||||
*/
|
||||
public function findByTokenValue(string $tokenValue): ?PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Get a token by its unique token value.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function getByTokenValue(string $tokenValue): PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Get a token by its ID.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
*/
|
||||
public function get(PasswordResetTokenId $id): PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Delete a token (after use or for cleanup).
|
||||
*/
|
||||
public function delete(PasswordResetTokenId $id): void;
|
||||
|
||||
/**
|
||||
* Delete a token by its token value.
|
||||
*/
|
||||
public function deleteByTokenValue(string $tokenValue): void;
|
||||
|
||||
/**
|
||||
* Find an existing valid (not used, not expired) token for a user.
|
||||
* Returns null if no valid token exists.
|
||||
*/
|
||||
public function findValidTokenForUser(string $userId): ?PasswordResetToken;
|
||||
|
||||
/**
|
||||
* Atomically consume a token: validate it and mark it as used.
|
||||
*
|
||||
* This operation is protected against concurrent double-use by using a lock.
|
||||
* If two requests try to consume the same token simultaneously, only one
|
||||
* will succeed; the other will see the token as already used.
|
||||
*
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenNotFoundException if token does not exist
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenExpiredException if token has expired
|
||||
* @throws \App\Administration\Domain\Exception\PasswordResetTokenAlreadyUsedException if token was already used
|
||||
*/
|
||||
public function consumeIfValid(string $tokenValue, DateTimeImmutable $at): PasswordResetToken;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Administration\Domain\Repository;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
|
||||
/**
|
||||
* Repository pour la gestion des refresh tokens.
|
||||
@@ -39,4 +40,9 @@ interface RefreshTokenRepository
|
||||
* Invalide tous les tokens d'une famille (en cas de replay attack détectée).
|
||||
*/
|
||||
public function invalidateFamily(TokenFamilyId $familyId): void;
|
||||
|
||||
/**
|
||||
* Invalide tous les tokens d'un utilisateur (après changement de mot de passe).
|
||||
*/
|
||||
public function invalidateAllForUser(UserId $userId): void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user