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:
2026-02-01 23:15:01 +01:00
parent b7354b8448
commit affad287f9
71 changed files with 4829 additions and 222 deletions

View File

@@ -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 (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,

View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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
{
}

View File

@@ -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, // 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,

View File

@@ -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
{

View File

@@ -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,

View File

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

View File

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