feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs couches de protection contre les attaques par force brute. Sécurité backend : - Authentification JWT avec access token (15min) + refresh token (7j) - Rotation automatique des refresh tokens avec détection de replay - Rate limiting progressif par IP (délai Fibonacci après échecs) - Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives - Alerte email à l'utilisateur après blocage temporaire - Isolation multi-tenant (un utilisateur ne peut se connecter que sur son établissement) Frontend : - Page de connexion avec feedback visuel des délais et erreurs - Composant TurnstileCaptcha réutilisable - Gestion d'état auth avec stockage sécurisé des tokens - Tests E2E Playwright pour login, tenant isolation, et activation Infrastructure : - Configuration Symfony Security avec json_login + jwt - Cache pools séparés (filesystem en test, Redis en prod) - NullLoginRateLimiter pour environnement de test (évite blocage CI) - Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
@@ -6,7 +6,7 @@ namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis quand un compte est bloqué temporairement suite à trop de tentatives.
|
||||
*
|
||||
* Cet événement déclenche l'envoi d'un email d'alerte à l'utilisateur.
|
||||
*
|
||||
* @see Story 1.4 - AC3: Lockout après 5 échecs, email d'alerte
|
||||
*/
|
||||
final readonly class CompteBloqueTemporairement implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public int $blockedForSeconds,
|
||||
public int $failedAttempts,
|
||||
public DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return Uuid::uuid5(
|
||||
Uuid::NAMESPACE_DNS,
|
||||
'account_lockout:' . $this->email,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
44
backend/src/Administration/Domain/Event/ConnexionEchouee.php
Normal file
44
backend/src/Administration/Domain/Event/ConnexionEchouee.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors d'une tentative de connexion échouée.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* @see Story 1.4 - AC2: Gestion erreurs d'authentification
|
||||
*/
|
||||
final readonly class ConnexionEchouee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public string $reason, // 'invalid_credentials', 'account_locked', 'rate_limited'
|
||||
public DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
// Pas d'aggregate associé, utiliser un UUID basé sur l'email
|
||||
return Uuid::uuid5(
|
||||
Uuid::NAMESPACE_DNS,
|
||||
'login_attempt:' . $this->email,
|
||||
);
|
||||
}
|
||||
}
|
||||
39
backend/src/Administration/Domain/Event/ConnexionReussie.php
Normal file
39
backend/src/Administration/Domain/Event/ConnexionReussie.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors d'une connexion réussie.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class ConnexionReussie implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $userId,
|
||||
public string $email,
|
||||
public TenantId $tenantId,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return Uuid::fromString($this->userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la détection d'une tentative de replay attack.
|
||||
*
|
||||
* Indique qu'un token déjà utilisé a été présenté, ce qui suggère
|
||||
* un vol de token. Toute la famille de tokens a été invalidée.
|
||||
*
|
||||
* Cet événement doit déclencher une alerte sécurité.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur (sécurité refresh tokens)
|
||||
*/
|
||||
final readonly class TokenReplayDetecte implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public TokenFamilyId $familyId,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->familyId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exception thrown when a refresh token has already been rotated but is still in grace period.
|
||||
*
|
||||
* This is a benign condition during multi-tab race conditions - the client should use
|
||||
* the newer token. The cookie should NOT be cleared in this case.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final class TokenAlreadyRotatedException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Token already rotated, use new token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Exception levée quand un replay attack sur un refresh token est détecté.
|
||||
*
|
||||
* Cette exception indique qu'un token déjà utilisé a été présenté à nouveau,
|
||||
* suggérant que le token a été volé et utilisé par un attaquant.
|
||||
*
|
||||
* Quand cette exception est levée :
|
||||
* - Toute la famille de tokens est invalidée
|
||||
* - L'utilisateur doit se reconnecter
|
||||
* - Un audit log doit être créé
|
||||
* - Une alerte de sécurité peut être envoyée
|
||||
*/
|
||||
final class TokenReplayDetectedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly TokenFamilyId $familyId,
|
||||
) {
|
||||
parent::__construct(
|
||||
sprintf('Token replay attack detected for family %s. All tokens in family have been invalidated.', $familyId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ 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 App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\RefreshToken;
|
||||
|
||||
/**
|
||||
* Empreinte du device pour validation de l'origine du refresh token.
|
||||
*
|
||||
* Calculée comme SHA-256(User-Agent) pour identifier le device/navigateur.
|
||||
*
|
||||
* Note: L'IP n'est intentionnellement PAS incluse car les utilisateurs
|
||||
* mobiles ou VPN changent fréquemment d'IP, ce qui invaliderait leur
|
||||
* session de façon inattendue.
|
||||
*/
|
||||
final readonly class DeviceFingerprint
|
||||
{
|
||||
private function __construct(
|
||||
public string $value,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une empreinte à partir des informations de la requête.
|
||||
*
|
||||
* @param string $userAgent User-Agent du navigateur
|
||||
* @param string $_ipAddress Non utilisé (conservé pour compatibilité API)
|
||||
*/
|
||||
public static function fromRequest(string $userAgent, string $_ipAddress = ''): self
|
||||
{
|
||||
$fingerprint = hash('sha256', $userAgent);
|
||||
|
||||
return new self($fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue une empreinte depuis le stockage.
|
||||
*/
|
||||
public static function fromString(string $fingerprint): self
|
||||
{
|
||||
return new self($fingerprint);
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return hash_equals($this->value, $other->value);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\RefreshToken;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Représente un refresh token pour le renouvellement silencieux des sessions.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @see Story 1.4 - Connexion utilisateur
|
||||
*/
|
||||
final readonly class RefreshToken
|
||||
{
|
||||
/**
|
||||
* TTL par défaut : 7 jours (604800 secondes).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
private const int GRACE_PERIOD_SECONDS = 30;
|
||||
|
||||
private function __construct(
|
||||
public RefreshTokenId $id,
|
||||
public TokenFamilyId $familyId,
|
||||
public UserId $userId,
|
||||
public TenantId $tenantId,
|
||||
public DeviceFingerprint $deviceFingerprint,
|
||||
public DateTimeImmutable $issuedAt,
|
||||
public DateTimeImmutable $expiresAt,
|
||||
public ?RefreshTokenId $rotatedFrom,
|
||||
public bool $isRotated,
|
||||
public ?DateTimeImmutable $rotatedAt = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau refresh token pour une nouvelle session.
|
||||
*/
|
||||
public static function create(
|
||||
UserId $userId,
|
||||
TenantId $tenantId,
|
||||
DeviceFingerprint $deviceFingerprint,
|
||||
DateTimeImmutable $issuedAt,
|
||||
int $ttlSeconds = self::DEFAULT_TTL_SECONDS,
|
||||
): self {
|
||||
return new self(
|
||||
id: RefreshTokenId::generate(),
|
||||
familyId: TokenFamilyId::generate(),
|
||||
userId: $userId,
|
||||
tenantId: $tenantId,
|
||||
deviceFingerprint: $deviceFingerprint,
|
||||
issuedAt: $issuedAt,
|
||||
expiresAt: $issuedAt->modify("+{$ttlSeconds} seconds"),
|
||||
rotatedFrom: null,
|
||||
isRotated: false,
|
||||
rotatedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une rotation du token (génère un nouveau token, marque l'ancien comme rotaté).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @return array{0: self, 1: self} Le nouveau token et l'ancien token marqué comme rotaté
|
||||
*/
|
||||
public function rotate(DateTimeImmutable $at): array
|
||||
{
|
||||
// Préserver le TTL original pour respecter la politique de session (web = 1 jour, mobile = 7 jours)
|
||||
$originalTtlSeconds = $this->expiresAt->getTimestamp() - $this->issuedAt->getTimestamp();
|
||||
|
||||
$newToken = new self(
|
||||
id: RefreshTokenId::generate(),
|
||||
familyId: $this->familyId, // Même famille
|
||||
userId: $this->userId,
|
||||
tenantId: $this->tenantId,
|
||||
deviceFingerprint: $this->deviceFingerprint,
|
||||
issuedAt: $at,
|
||||
expiresAt: $at->modify("+{$originalTtlSeconds} seconds"),
|
||||
rotatedFrom: $this->id, // Traçabilité
|
||||
isRotated: false,
|
||||
rotatedAt: null,
|
||||
);
|
||||
|
||||
$rotatedOldToken = new self(
|
||||
id: $this->id,
|
||||
familyId: $this->familyId,
|
||||
userId: $this->userId,
|
||||
tenantId: $this->tenantId,
|
||||
deviceFingerprint: $this->deviceFingerprint,
|
||||
issuedAt: $this->issuedAt,
|
||||
expiresAt: $this->expiresAt,
|
||||
rotatedFrom: $this->rotatedFrom,
|
||||
isRotated: true,
|
||||
rotatedAt: $at, // Pour la grace period
|
||||
);
|
||||
|
||||
return [$newToken, $rotatedOldToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est expiré.
|
||||
*/
|
||||
public function isExpired(DateTimeImmutable $at): bool
|
||||
{
|
||||
return $at > $this->expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le token est dans la période de grâce après 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.
|
||||
*/
|
||||
public function isInGracePeriod(DateTimeImmutable $at): bool
|
||||
{
|
||||
if (!$this->isRotated || $this->rotatedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$gracePeriodEnd = $this->rotatedAt->modify('+' . self::GRACE_PERIOD_SECONDS . ' seconds');
|
||||
|
||||
return $at <= $gracePeriodEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'empreinte du device correspond.
|
||||
*/
|
||||
public function matchesDevice(DeviceFingerprint $fingerprint): bool
|
||||
{
|
||||
return $this->deviceFingerprint->equals($fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le token string à stocker dans le cookie.
|
||||
*
|
||||
* Le format est opaque pour le client : base64(id)
|
||||
*/
|
||||
public function toTokenString(): string
|
||||
{
|
||||
return base64_encode((string) $this->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'ID depuis un token string.
|
||||
*/
|
||||
public static function extractIdFromTokenString(string $tokenString): RefreshTokenId
|
||||
{
|
||||
$decoded = base64_decode($tokenString, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Invalid token format');
|
||||
}
|
||||
|
||||
return RefreshTokenId::fromString($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un RefreshToken depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage par l'Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
RefreshTokenId $id,
|
||||
TokenFamilyId $familyId,
|
||||
UserId $userId,
|
||||
TenantId $tenantId,
|
||||
DeviceFingerprint $deviceFingerprint,
|
||||
DateTimeImmutable $issuedAt,
|
||||
DateTimeImmutable $expiresAt,
|
||||
?RefreshTokenId $rotatedFrom,
|
||||
bool $isRotated,
|
||||
?DateTimeImmutable $rotatedAt = null,
|
||||
): self {
|
||||
return new self(
|
||||
id: $id,
|
||||
familyId: $familyId,
|
||||
userId: $userId,
|
||||
tenantId: $tenantId,
|
||||
deviceFingerprint: $deviceFingerprint,
|
||||
issuedAt: $issuedAt,
|
||||
expiresAt: $expiresAt,
|
||||
rotatedFrom: $rotatedFrom,
|
||||
isRotated: $isRotated,
|
||||
rotatedAt: $rotatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\RefreshToken;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
/**
|
||||
* Identifiant unique d'un refresh token (JTI claim).
|
||||
*/
|
||||
final readonly class RefreshTokenId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\RefreshToken;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
/**
|
||||
* Identifiant de la famille de tokens.
|
||||
*
|
||||
* Tous les tokens issus d'une même connexion partagent le même family_id.
|
||||
* Utilisé pour détecter et invalider les replay attacks.
|
||||
*/
|
||||
final readonly class TokenFamilyId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -16,11 +16,12 @@ use const FILTER_VALIDATE_EMAIL;
|
||||
*/
|
||||
final readonly class Email
|
||||
{
|
||||
/** @var non-empty-string */
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false || $value === '') {
|
||||
throw EmailInvalideException::pourAdresse($value);
|
||||
}
|
||||
|
||||
@@ -32,6 +33,9 @@ final readonly class Email
|
||||
return strtolower($this->value) === strtolower($other->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-empty-string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Repository pour la gestion des refresh tokens.
|
||||
*
|
||||
* Implémentation attendue : Redis avec TTL automatique.
|
||||
*/
|
||||
interface RefreshTokenRepository
|
||||
{
|
||||
/**
|
||||
* Sauvegarde un refresh token.
|
||||
*/
|
||||
public function save(RefreshToken $token): void;
|
||||
|
||||
/**
|
||||
* Récupère un refresh token par son ID.
|
||||
*/
|
||||
public function find(RefreshTokenId $id): ?RefreshToken;
|
||||
|
||||
/**
|
||||
* Récupère un refresh token par sa valeur (le token string).
|
||||
*/
|
||||
public function findByToken(string $tokenValue): ?RefreshToken;
|
||||
|
||||
/**
|
||||
* Supprime un refresh token.
|
||||
*/
|
||||
public function delete(RefreshTokenId $id): void;
|
||||
|
||||
/**
|
||||
* Invalide tous les tokens d'une famille (en cas de replay attack détectée).
|
||||
*/
|
||||
public function invalidateFamily(TokenFamilyId $familyId): void;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
@@ -17,5 +18,9 @@ interface UserRepository
|
||||
*/
|
||||
public function get(UserId $id): User;
|
||||
|
||||
public function findByEmail(Email $email): ?User;
|
||||
/**
|
||||
* Finds a user by email within a specific tenant.
|
||||
* Returns null if user doesn't exist in that tenant.
|
||||
*/
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user