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:
2026-02-01 10:25:25 +01:00
parent 6889c67a44
commit b9d9f48305
93 changed files with 6850 additions and 155 deletions

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateAccount;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Result of the ActivateAccountCommand execution.

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
use App\Administration\Domain\Exception\TokenReplayDetectedException;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;
/**
* Gère le cycle de vie des refresh tokens.
*
* Responsabilités :
* - Création de tokens pour nouvelles sessions
* - Rotation des tokens avec détection de replay
* - Invalidation de familles de tokens compromises
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class RefreshTokenManager
{
private const int WEB_TTL_SECONDS = 86400; // 1 jour pour web
private const int MOBILE_TTL_SECONDS = 604800; // 7 jours pour mobile
public function __construct(
private RefreshTokenRepository $repository,
private Clock $clock,
) {
}
/**
* Crée un nouveau refresh token pour une session.
*/
public function create(
UserId $userId,
TenantId $tenantId,
DeviceFingerprint $deviceFingerprint,
bool $isMobile = false,
): RefreshToken {
$ttl = $isMobile ? self::MOBILE_TTL_SECONDS : self::WEB_TTL_SECONDS;
// Ajouter un jitter de ±10% pour éviter les expirations simultanées
$jitter = (int) ($ttl * 0.1 * (random_int(-100, 100) / 100));
$ttl += $jitter;
$token = RefreshToken::create(
userId: $userId,
tenantId: $tenantId,
deviceFingerprint: $deviceFingerprint,
issuedAt: $this->clock->now(),
ttlSeconds: $ttl,
);
$this->repository->save($token);
return $token;
}
/**
* Valide et rafraîchit un token.
*
* @throws TokenReplayDetectedException si un replay attack est détecté
* @throws TokenAlreadyRotatedException si le token a déjà été rotaté mais est en grace period
* @throws InvalidArgumentException si le token est invalide ou expiré
*
* @return RefreshToken le nouveau token après rotation
*/
public function refresh(
string $tokenString,
DeviceFingerprint $deviceFingerprint,
): RefreshToken {
$tokenId = RefreshToken::extractIdFromTokenString($tokenString);
$token = $this->repository->find($tokenId);
$now = $this->clock->now();
if ($token === null) {
throw new InvalidArgumentException('Token not found');
}
// Vérifier l'expiration
if ($token->isExpired($now)) {
$this->repository->delete($tokenId);
throw new InvalidArgumentException('Token expired');
}
// Vérifier le device fingerprint
if (!$token->matchesDevice($deviceFingerprint)) {
// Potentielle tentative de vol de token - invalider toute la famille
$this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($token->familyId);
}
// Détecter les replay attacks
if ($token->isRotated) {
// Token déjà utilisé !
if ($token->isInGracePeriod($now)) {
// Dans la grace period - probablement une race condition légitime
// On laisse passer mais on ne génère pas de nouveau token
// Le client devrait utiliser le token le plus récent
// Exception dédiée pour ne PAS supprimer le cookie lors d'une race condition légitime
throw new TokenAlreadyRotatedException();
}
// Replay attack confirmé - invalider toute la famille
$this->repository->invalidateFamily($token->familyId);
throw new TokenReplayDetectedException($token->familyId);
}
// Rotation du token (préserve le TTL original)
[$newToken, $rotatedOldToken] = $token->rotate($now);
// Sauvegarder le nouveau token EN PREMIER
// Important: sauvegarder le nouveau token EN PREMIER pour que l'index famille garde le bon TTL
$this->repository->save($newToken);
// Mettre à jour l'ancien token comme rotaté (pour grace period)
$this->repository->save($rotatedOldToken);
return $newToken;
}
/**
* Révoque un token (déconnexion).
*/
public function revoke(string $tokenString): void
{
try {
$tokenId = RefreshToken::extractIdFromTokenString($tokenString);
$token = $this->repository->find($tokenId);
if ($token !== null) {
// Invalider toute la famille pour une déconnexion complète
$this->repository->invalidateFamily($token->familyId);
}
} catch (InvalidArgumentException) {
// Token invalide, rien à faire
}
}
/**
* Invalide toute une famille de tokens.
*
* Utilisé quand un utilisateur est suspendu/archivé pour révoquer toutes ses sessions.
*/
public function invalidateFamily(TokenFamilyId $familyId): void
{
$this->repository->invalidateFamily($familyId);
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
/**

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use DateTimeImmutable;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Endpoint de déconnexion.
*
* Invalide le refresh token et supprime le cookie.
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class LogoutController
{
public function __construct(
private RefreshTokenRepository $refreshTokenRepository,
) {
}
#[Route('/api/token/logout', name: 'api_logout', methods: ['POST'])]
public function __invoke(Request $request): Response
{
$refreshTokenValue = $request->cookies->get('refresh_token');
// Invalider toute la famille de tokens pour une déconnexion complète
if ($refreshTokenValue !== null) {
try {
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
$refreshToken = $this->refreshTokenRepository->find($tokenId);
if ($refreshToken !== null) {
// Invalider toute la famille (déconnecte tous les devices)
$this->refreshTokenRepository->invalidateFamily($refreshToken->familyId);
}
} catch (InvalidArgumentException) {
// Token malformé, ignorer
}
}
// Créer la réponse avec suppression du cookie
$response = new JsonResponse(['message' => 'Déconnexion réussie'], Response::HTTP_OK);
// Supprimer le cookie refresh_token (même path que celui utilisé lors du login)
$response->headers->setCookie(
Cookie::create('refresh_token')
->withValue('')
->withExpires(new DateTimeImmutable('-1 hour'))
->withPath('/api/token')
->withHttpOnly(true)
->withSecure(true)
->withSameSite('strict'),
);
return $response;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Event\TokenReplayDetecte;
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
use App\Administration\Domain\Exception\TokenReplayDetectedException;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\RefreshTokenInput;
use App\Administration\Infrastructure\Api\Resource\RefreshTokenOutput;
use App\Administration\Infrastructure\Security\SecurityUserFactory;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use DateTimeImmutable;
use InvalidArgumentException;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Processor pour le rafraîchissement de token.
*
* Flow :
* 1. Lire le refresh token depuis le cookie HttpOnly
* 2. Valider le token et le device fingerprint
* 3. Détecter les replay attacks
* 4. Générer un nouveau JWT et faire la rotation du refresh token
* 5. Mettre à jour le cookie
*
* @implements ProcessorInterface<RefreshTokenInput, RefreshTokenOutput>
*
* @see Story 1.4 - T6: Endpoint Refresh Token
*/
final readonly class RefreshTokenProcessor implements ProcessorInterface
{
public function __construct(
private RefreshTokenManager $refreshTokenManager,
private JWTTokenManagerInterface $jwtManager,
private UserRepository $userRepository,
private RequestStack $requestStack,
private SecurityUserFactory $securityUserFactory,
private TenantResolver $tenantResolver,
private MessageBusInterface $eventBus,
private Clock $clock,
) {
}
/**
* @param RefreshTokenInput $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): RefreshTokenOutput
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
throw new UnauthorizedHttpException('Bearer', 'Request not available');
}
// Lire le refresh token depuis le cookie
$refreshTokenString = $request->cookies->get('refresh_token');
if ($refreshTokenString === null) {
throw new UnauthorizedHttpException('Bearer', 'Refresh token not found');
}
// Créer le device fingerprint pour validation
$ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown');
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
try {
// Valider et faire la rotation du refresh token
$newRefreshToken = $this->refreshTokenManager->refresh($refreshTokenString, $fingerprint);
// Sécurité: vérifier que le tenant du refresh token correspond au tenant de la requête
// Empêche l'utilisation d'un token d'un tenant pour accéder à un autre
$currentTenantId = $this->resolveCurrentTenant($request->getHost());
if ($currentTenantId !== null && (string) $newRefreshToken->tenantId !== (string) $currentTenantId) {
$this->clearRefreshTokenCookie();
throw new AccessDeniedHttpException('Invalid token for this tenant');
}
// Charger l'utilisateur pour générer le JWT
$user = $this->userRepository->get($newRefreshToken->userId);
// Vérifier que l'utilisateur peut toujours se connecter (pas suspendu/archivé)
if (!$user->peutSeConnecter()) {
// Invalider toute la famille et supprimer le cookie
$this->refreshTokenManager->invalidateFamily($newRefreshToken->familyId);
$this->clearRefreshTokenCookie();
throw new AccessDeniedHttpException('Account is no longer active');
}
$securityUser = $this->securityUserFactory->fromDomainUser($user);
// Générer le nouveau JWT
$jwt = $this->jwtManager->create($securityUser);
// Stocker le cookie dans les attributs de requête pour le listener
// Le RefreshTokenCookieListener l'ajoutera à la réponse
$cookie = Cookie::create('refresh_token')
->withValue($newRefreshToken->toTokenString())
->withExpires($newRefreshToken->expiresAt)
->withPath('/api/token')
->withSecure(true)
->withHttpOnly(true)
->withSameSite('strict');
$request->attributes->set('_refresh_token_cookie', $cookie);
return new RefreshTokenOutput(token: $jwt);
} catch (TokenReplayDetectedException $e) {
// Replay attack détecté - la famille a été invalidée
// Dispatcher l'événement de sécurité pour alertes/audit
$this->eventBus->dispatch(new TokenReplayDetecte(
familyId: $e->familyId,
ipAddress: $ipAddress,
userAgent: $userAgent,
occurredOn: $this->clock->now(),
));
// Supprimer le cookie côté client
$this->clearRefreshTokenCookie();
throw new AccessDeniedHttpException(
'Session compromise detected. All sessions have been invalidated. Please log in again.',
);
} catch (TokenAlreadyRotatedException) {
// Token déjà rotaté mais en grace period - race condition légitime
// NE PAS supprimer le cookie ! Le client a probablement déjà le nouveau token
// d'une requête concurrente. Retourner 409 Conflict pour que le client réessaie.
throw new ConflictHttpException('Token already rotated, retry with current cookie');
} catch (InvalidArgumentException $e) {
// Token invalide ou expiré
$this->clearRefreshTokenCookie();
throw new UnauthorizedHttpException('Bearer', $e->getMessage());
}
}
private function clearRefreshTokenCookie(): void
{
$request = $this->requestStack->getCurrentRequest();
if ($request !== null) {
$cookie = Cookie::create('refresh_token')
->withValue('')
->withExpires(new DateTimeImmutable('-1 day'))
->withPath('/api/token')
->withSecure(true)
->withHttpOnly(true)
->withSameSite('strict');
$request->attributes->set('_refresh_token_cookie', $cookie);
}
}
/**
* Resolves the current tenant from the request host.
*
* Returns null for localhost (dev environment uses default tenant).
*/
private function resolveCurrentTenant(string $host): ?\App\Shared\Domain\Tenant\TenantId
{
// Skip validation for localhost (dev environment)
if ($host === 'localhost' || $host === '127.0.0.1') {
return null;
}
try {
return $this->tenantResolver->resolve($host)->tenantId;
} catch (TenantNotFoundException) {
return null;
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
/**
* Resource API Platform pour le rafraîchissement de token.
*
* Le refresh token est lu depuis le cookie HttpOnly, pas du body.
*
* @see Story 1.4 - T6: Endpoint Refresh Token
*/
#[ApiResource(
operations: [
new Post(
uriTemplate: '/token/refresh',
processor: RefreshTokenProcessor::class,
output: RefreshTokenOutput::class,
name: 'refresh_token',
description: 'Utilise le refresh token (cookie HttpOnly) pour obtenir un nouveau JWT. Le refresh token est automatiquement rotaté.',
),
],
)]
final class RefreshTokenInput
{
// Pas de propriétés - le refresh token vient du cookie
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* Output pour le rafraîchissement de token.
*/
final readonly class RefreshTokenOutput
{
public function __construct(
public string $token,
) {
}
}

View File

@@ -11,7 +11,8 @@ use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function sprintf;
@@ -31,6 +32,7 @@ final class CreateTestActivationTokenCommand extends Command
public function __construct(
private readonly ActivationTokenRepository $activationTokenRepository,
private readonly UserRepository $userRepository,
private readonly TenantRegistry $tenantRegistry,
private readonly Clock $clock,
) {
parent::__construct();
@@ -43,21 +45,65 @@ final class CreateTestActivationTokenCommand extends Command
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173');
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha')
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string $email */
$email = $input->getOption('email');
/** @var string $roleOption */
$roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption);
/** @var string $schoolName */
$schoolName = $input->getOption('school');
$isMinor = $input->getOption('minor');
// Interactive mode only if:
// 1. Input is interactive (not -n flag, has TTY)
// 2. Using all default values (no explicit options provided)
$usingDefaults = $input->getOption('email') === 'test@example.com'
&& $input->getOption('role') === 'PARENT'
&& $input->getOption('tenant') === 'ecole-alpha';
if ($input->isInteractive() && $usingDefaults) {
$io->title('Création d\'un token d\'activation de test');
/** @var string $tenantSubdomain */
$tenantSubdomain = $io->choice(
'Tenant (établissement)',
['ecole-alpha', 'ecole-beta'],
'ecole-alpha'
);
/** @var string $roleChoice */
$roleChoice = $io->choice(
'Rôle',
['PARENT', 'ELEVE', 'PROF', 'ADMIN'],
'PARENT'
);
$defaultEmail = match ($roleChoice) {
'PARENT' => 'parent@test.com',
'ELEVE' => 'eleve@test.com',
'PROF' => 'prof@test.com',
'ADMIN' => 'admin@test.com',
default => 'test@example.com',
};
/** @var string $email */
$email = $io->ask('Email', $defaultEmail);
$roleInput = strtoupper($roleChoice);
/** @var string $schoolName */
$schoolName = $io->ask('Nom de l\'école', 'École de Test');
$isMinor = $io->confirm('Utilisateur mineur (nécessite consentement parental) ?', false);
} else {
/** @var string $email */
$email = $input->getOption('email');
/** @var string $roleOption */
$roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption);
/** @var string $schoolName */
$schoolName = $input->getOption('school');
$isMinor = $input->getOption('minor');
/** @var string $tenantSubdomain */
$tenantSubdomain = $input->getOption('tenant');
}
/** @var string $baseUrlOption */
$baseUrlOption = $input->getOption('base-url');
$baseUrl = rtrim($baseUrlOption, '/');
@@ -77,8 +123,25 @@ final class CreateTestActivationTokenCommand extends Command
return Command::FAILURE;
}
// Resolve tenant from subdomain
try {
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
$tenantId = $tenantConfig->tenantId;
} catch (TenantNotFoundException) {
$availableTenants = array_map(
static fn ($config) => $config->subdomain,
$this->tenantRegistry->getAllConfigs()
);
$io->error(sprintf(
'Tenant "%s" not found. Available tenants: %s',
$tenantSubdomain,
implode(', ', $availableTenants)
));
return Command::FAILURE;
}
$now = $this->clock->now();
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001');
// Create user
$dateNaissance = $isMinor
@@ -118,6 +181,7 @@ final class CreateTestActivationTokenCommand extends Command
['User ID', (string) $user->id],
['Email', $email],
['Role', $role->value],
['Tenant', $tenantSubdomain],
['School', $schoolName],
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
['Token', $token->tokenValue],

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Creates an already-activated test user for E2E login tests.
*
* Unlike the activation token command, this creates a user that can
* immediately log in with the provided password.
*/
#[AsCommand(
name: 'app:dev:create-test-user',
description: 'Creates an already-activated test user for E2E login tests',
)]
final class CreateTestUserCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly PasswordHasher $passwordHasher,
private readonly TenantRegistry $tenantRegistry,
private readonly Clock $clock,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'e2e-login@example.com')
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'TestPassword123')
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string $email */
$email = $input->getOption('email');
/** @var string $password */
$password = $input->getOption('password');
/** @var string $roleOption */
$roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption);
/** @var string $schoolName */
$schoolName = $input->getOption('school');
/** @var string $tenantSubdomain */
$tenantSubdomain = $input->getOption('tenant');
// Convert short role name to full Symfony role format
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
$role = Role::tryFrom($roleName);
if ($role === null) {
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
$io->error(sprintf(
'Invalid role "%s". Valid roles: %s',
$roleInput,
implode(', ', $validRoles)
));
return Command::FAILURE;
}
// Resolve tenant from subdomain
try {
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
$tenantId = $tenantConfig->tenantId;
} catch (TenantNotFoundException) {
$availableTenants = array_map(
static fn ($config) => $config->subdomain,
$this->tenantRegistry->getAllConfigs()
);
$io->error(sprintf(
'Tenant "%s" not found. Available tenants: %s',
$tenantSubdomain,
implode(', ', $availableTenants)
));
return Command::FAILURE;
}
$now = $this->clock->now();
// Check if user already exists
$existingUser = $this->userRepository->findByEmail(new Email($email), $tenantId);
if ($existingUser !== null) {
$io->warning(sprintf('User with email "%s" already exists. Returning existing user.', $email));
$io->table(
['Property', 'Value'],
[
['User ID', (string) $existingUser->id],
['Email', $email],
['Password', $password],
['Role', $existingUser->role->value],
['Status', $existingUser->statut->value],
]
);
return Command::SUCCESS;
}
// Create activated user using reconstitute to bypass domain validation
$hashedPassword = $this->passwordHasher->hash($password);
$user = User::reconstitute(
id: UserId::generate(),
email: new Email($email),
role: $role,
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: $now,
hashedPassword: $hashedPassword,
activatedAt: $now,
consentementParental: null,
);
$this->userRepository->save($user);
$io->success('Test user created successfully!');
$io->table(
['Property', 'Value'],
[
['User ID', (string) $user->id],
['Email', $email],
['Password', $password],
['Role', $role->value],
['Tenant', $tenantSubdomain],
['School', $schoolName],
['Status', StatutCompte::ACTIF->value],
]
);
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Administration\Domain\Event\ConnexionReussie;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Enregistre les événements de connexion dans l'audit log.
*
* Important: Les IP sont hashées pour respecter NFR-S3 (pas de PII dans les logs).
*
* @see Story 1.4 - T5.5: Tracer dans audit log
* @see AC3: Événement tracé dans audit log
*/
final readonly class AuditLoginEventsHandler
{
public function __construct(
private LoggerInterface $auditLogger,
private string $appSecret,
) {
}
#[AsMessageHandler]
public function handleConnexionReussie(ConnexionReussie $event): void
{
$this->auditLogger->info('login.success', [
'user_id' => $event->userId,
'tenant_id' => (string) $event->tenantId,
'ip_hash' => $this->hashIp($event->ipAddress),
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
'occurred_on' => $event->occurredOn->format('c'),
]);
}
#[AsMessageHandler]
public function handleConnexionEchouee(ConnexionEchouee $event): void
{
$this->auditLogger->warning('login.failure', [
'email_hash' => $this->hashEmail($event->email),
'reason' => $event->reason,
'ip_hash' => $this->hashIp($event->ipAddress),
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
'occurred_on' => $event->occurredOn->format('c'),
]);
}
/**
* Hash l'IP pour éviter de stocker des PII.
* Le hash permet toujours de corréler les événements d'une même IP.
*/
private function hashIp(string $ip): string
{
return hash('sha256', $ip . $this->appSecret);
}
/**
* Hash l'email pour éviter de stocker des PII.
*/
private function hashEmail(string $email): string
{
return hash('sha256', strtolower($email) . $this->appSecret);
}
/**
* Hash le User-Agent (généralement pas PII mais peut être très long).
*/
private function hashUserAgent(string $userAgent): string
{
return hash('sha256', $userAgent);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\CompteBloqueTemporairement;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Envoie un email d'alerte quand un compte est bloqué temporairement.
*
* @see Story 1.4 - T4: Email alerte lockout
*/
#[AsMessageHandler]
final readonly class SendLockoutAlertHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(CompteBloqueTemporairement $event): void
{
$blockedForMinutes = (int) ceil($event->blockedForSeconds / 60);
$htmlContent = $this->twig->render('email/lockout_alert.html.twig', [
'email' => $event->email,
'ipAddress' => $event->ipAddress,
'failedAttempts' => $event->failedAttempts,
'blockedForMinutes' => $blockedForMinutes,
'occurredOn' => $event->occurredOn,
]);
$textContent = $this->twig->render('email/lockout_alert.txt.twig', [
'email' => $event->email,
'ipAddress' => $event->ipAddress,
'failedAttempts' => $event->failedAttempts,
'blockedForMinutes' => $blockedForMinutes,
'occurredOn' => $event->occurredOn,
]);
$email = (new Email())
->from($this->fromEmail)
->to($event->email)
->subject('🔒 Alerte de sécurité - Tentatives de connexion suspectes')
->html($htmlContent)
->text($textContent)
->priority(Email::PRIORITY_HIGH);
$this->mailer->send($email);
}
}

View File

@@ -12,7 +12,7 @@ use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Psr\Cache\CacheItemPoolInterface;
@@ -40,8 +40,9 @@ final readonly class CacheUserRepository implements UserRepository
$item->set($this->serialize($user));
$this->usersCache->save($item);
// Save email index for lookup
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email));
// Save email index for lookup (scoped to tenant)
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
@@ -60,9 +61,10 @@ final readonly class CacheUserRepository implements UserRepository
return $this->deserialize($data);
}
public function findByEmail(Email $email): ?User
public function findByEmail(Email $email, TenantId $tenantId): ?User
{
$emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email));
$emailKey = $this->emailIndexKey($email, $tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
if (!$emailItem->isHit()) {
return null;
@@ -159,4 +161,12 @@ final readonly class CacheUserRepository implements UserRepository
{
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
}
/**
* Creates a cache key for email lookup scoped to a tenant.
*/
private function emailIndexKey(Email $email, TenantId $tenantId): string
{
return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email);
}
}

View File

@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryUserRepository implements UserRepository
@@ -16,14 +17,14 @@ final class InMemoryUserRepository implements UserRepository
/** @var array<string, User> Indexed by ID */
private array $byId = [];
/** @var array<string, User> Indexed by email (lowercase) */
private array $byEmail = [];
/** @var array<string, User> Indexed by tenant:email (lowercase) */
private array $byTenantEmail = [];
#[Override]
public function save(User $user): void
{
$this->byId[(string) $user->id] = $user;
$this->byEmail[strtolower((string) $user->email)] = $user;
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
}
#[Override]
@@ -39,8 +40,13 @@ final class InMemoryUserRepository implements UserRepository
}
#[Override]
public function findByEmail(Email $email): ?User
public function findByEmail(Email $email, TenantId $tenantId): ?User
{
return $this->byEmail[strtolower((string) $email)] ?? null;
return $this->byTenantEmail[$this->emailKey($email, $tenantId)] ?? null;
}
private function emailKey(Email $email, TenantId $tenantId): string
{
return $tenantId . ':' . strtolower((string) $email);
}
}

View File

@@ -8,7 +8,7 @@ use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Administration\Domain\Repository\ActivationTokenRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
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;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use DateTimeInterface;
use Psr\Cache\CacheItemPoolInterface;
/**
* Implémentation Redis du repository de refresh tokens.
*
* Structure de stockage :
* - Token individuel : refresh:{token_id} → données JSON du token
* - Index famille : refresh_family:{family_id} → set des token_ids de la famille
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class RedisRefreshTokenRepository implements RefreshTokenRepository
{
private const string TOKEN_PREFIX = 'refresh:';
private const string FAMILY_PREFIX = 'refresh_family:';
public function __construct(
private CacheItemPoolInterface $refreshTokensCache,
) {
}
public function save(RefreshToken $token): void
{
// Sauvegarder le token
$tokenItem = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $token->id);
$tokenItem->set($this->serialize($token));
// Calculer le TTL restant
$now = new DateTimeImmutable();
$ttl = $token->expiresAt->getTimestamp() - $now->getTimestamp();
if ($ttl > 0) {
$tokenItem->expiresAfter($ttl);
}
$this->refreshTokensCache->save($tokenItem);
// Ajouter à l'index famille
// Ne jamais réduire le TTL de l'index famille
// L'index doit survivre aussi longtemps que le token le plus récent de la famille
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $token->familyId);
/** @var list<string> $familyTokenIds */
$familyTokenIds = $familyItem->isHit() ? $familyItem->get() : [];
$familyTokenIds[] = (string) $token->id;
$familyItem->set(array_unique($familyTokenIds));
// Seulement étendre le TTL, jamais le réduire
// Pour les tokens rotated (ancien), on ne change pas le TTL de l'index
if (!$token->isRotated && $ttl > 0) {
$familyItem->expiresAfter($ttl);
} elseif (!$familyItem->isHit()) {
// Nouveau index - définir le TTL initial
$familyItem->expiresAfter($ttl > 0 ? $ttl : 604800);
}
// Si c'est un token rotaté et l'index existe déjà, on garde le TTL existant
$this->refreshTokensCache->save($familyItem);
}
public function find(RefreshTokenId $id): ?RefreshToken
{
$item = $this->refreshTokensCache->getItem(self::TOKEN_PREFIX . $id);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, family_id: string, user_id: string, tenant_id: string, device_fingerprint: string, issued_at: string, expires_at: string, rotated_from: string|null, is_rotated: bool, rotated_at?: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
public function findByToken(string $tokenValue): ?RefreshToken
{
return $this->find(RefreshTokenId::fromString($tokenValue));
}
public function delete(RefreshTokenId $id): void
{
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $id);
}
public function invalidateFamily(TokenFamilyId $familyId): void
{
$familyItem = $this->refreshTokensCache->getItem(self::FAMILY_PREFIX . $familyId);
if (!$familyItem->isHit()) {
return;
}
/** @var list<string> $tokenIds */
$tokenIds = $familyItem->get();
// Supprimer tous les tokens de la famille
foreach ($tokenIds as $tokenId) {
$this->refreshTokensCache->deleteItem(self::TOKEN_PREFIX . $tokenId);
}
// Supprimer l'index famille
$this->refreshTokensCache->deleteItem(self::FAMILY_PREFIX . $familyId);
}
/**
* @return array<string, mixed>
*/
private function serialize(RefreshToken $token): array
{
return [
'id' => (string) $token->id,
'family_id' => (string) $token->familyId,
'user_id' => (string) $token->userId,
'tenant_id' => (string) $token->tenantId,
'device_fingerprint' => (string) $token->deviceFingerprint,
'issued_at' => $token->issuedAt->format(DateTimeInterface::ATOM),
'expires_at' => $token->expiresAt->format(DateTimeInterface::ATOM),
'rotated_from' => $token->rotatedFrom !== null ? (string) $token->rotatedFrom : null,
'is_rotated' => $token->isRotated,
'rotated_at' => $token->rotatedAt?->format(DateTimeInterface::ATOM),
];
}
/**
* @param array{
* id: string,
* family_id: string,
* user_id: string,
* tenant_id: string,
* device_fingerprint: string,
* issued_at: string,
* expires_at: string,
* rotated_from: string|null,
* is_rotated: bool,
* rotated_at?: string|null
* } $data
*/
private function deserialize(array $data): RefreshToken
{
$rotatedAt = $data['rotated_at'] ?? null;
return RefreshToken::reconstitute(
id: RefreshTokenId::fromString($data['id']),
familyId: TokenFamilyId::fromString($data['family_id']),
userId: UserId::fromString($data['user_id']),
tenantId: TenantId::fromString($data['tenant_id']),
deviceFingerprint: DeviceFingerprint::fromString($data['device_fingerprint']),
issuedAt: new DateTimeImmutable($data['issued_at']),
expiresAt: new DateTimeImmutable($data['expires_at']),
rotatedFrom: $data['rotated_from'] !== null ? RefreshTokenId::fromString($data['rotated_from']) : null,
isRotated: $data['is_rotated'],
rotatedAt: $rotatedAt !== null ? new DateTimeImmutable($rotatedAt) : null,
);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Charge les utilisateurs depuis le domaine pour l'authentification Symfony.
*
* Ce provider fait le pont entre Symfony Security et notre Domain Layer.
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité.
* Les utilisateurs sont isolés par tenant (établissement).
*
* @implements UserProviderInterface<SecurityUser>
*
* @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte)
*/
final readonly class DatabaseUserProvider implements UserProviderInterface
{
public function __construct(
private UserRepository $userRepository,
private TenantResolver $tenantResolver,
private RequestStack $requestStack,
private SecurityUserFactory $securityUserFactory,
) {
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$tenantId = $this->getCurrentTenantId();
try {
$email = new Email($identifier);
} catch (EmailInvalideException) {
// Malformed email = treat as user not found (security: generic error)
throw new SymfonyUserNotFoundException();
}
$user = $this->userRepository->findByEmail($email, $tenantId);
// Message générique pour ne pas révéler l'existence du compte
if ($user === null) {
throw new SymfonyUserNotFoundException();
}
// Ne pas permettre la connexion si le compte n'est pas actif
if (!$user->peutSeConnecter()) {
throw new SymfonyUserNotFoundException();
}
return $this->securityUserFactory->fromDomainUser($user);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof SecurityUser) {
throw new InvalidArgumentException('Expected instance of ' . SecurityUser::class);
}
return $this->loadUserByIdentifier($user->email());
}
public function supportsClass(string $class): bool
{
return $class === SecurityUser::class;
}
/**
* Resolves the current tenant from the request host.
*
* @throws SymfonyUserNotFoundException if tenant cannot be resolved (security: generic error)
*/
private function getCurrentTenantId(): TenantId
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
throw new SymfonyUserNotFoundException();
}
$host = $request->getHost();
// Dev/test fallback: localhost uses ecole-alpha tenant
if ($host === 'localhost' || $host === '127.0.0.1') {
try {
return $this->tenantResolver->resolve('ecole-alpha.classeo.local')->tenantId;
} catch (TenantNotFoundException) {
throw new SymfonyUserNotFoundException();
}
}
try {
$tenantConfig = $this->tenantResolver->resolve($host);
return $tenantConfig->tenantId;
} catch (TenantNotFoundException) {
// Don't reveal tenant doesn't exist - use same error as invalid credentials
throw new SymfonyUserNotFoundException();
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
/**
* Enrichit le payload JWT avec les claims métier.
*
* Claims ajoutés:
* - sub: Email de l'utilisateur (identifiant Symfony Security)
* - user_id: UUID de l'utilisateur (pour les consommateurs d'API)
* - tenant_id: UUID du tenant pour l'isolation multi-tenant
* - roles: Liste des rôles Symfony pour l'autorisation
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class JwtPayloadEnricher
{
public function onJWTCreated(JWTCreatedEvent $event): void
{
$user = $event->getUser();
if (!$user instanceof SecurityUser) {
return;
}
$payload = $event->getData();
// Claims métier pour l'isolation multi-tenant et l'autorisation
$payload['user_id'] = $user->userId();
$payload['tenant_id'] = $user->tenantId();
$payload['roles'] = $user->getRoles();
$event->setData($payload);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
use function is_array;
use function is_string;
use function sprintf;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
/**
* Gère les échecs de login : rate limiting Fibonacci, audit, messages user-friendly.
*
* Important: Ne jamais révéler si l'email existe ou non (AC2).
*
* @see Story 1.4 - T5: Endpoint Login Backend
*/
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
{
public function __construct(
private LoginRateLimiterInterface $rateLimiter,
private MessageBusInterface $eventBus,
private Clock $clock,
) {
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
$content = json_decode($request->getContent(), true);
$email = is_array($content) && isset($content['email']) && is_string($content['email'])
? $content['email']
: 'unknown';
$ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown');
// Enregistrer l'échec et obtenir le nouvel état
$result = $this->rateLimiter->recordFailure($request, $email);
// Émettre l'événement d'échec
$this->eventBus->dispatch(new ConnexionEchouee(
email: $email,
ipAddress: $ipAddress,
userAgent: $userAgent,
reason: 'invalid_credentials',
occurredOn: $this->clock->now(),
));
// Si l'IP vient d'être bloquée
if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement(
email: $email,
ipAddress: $ipAddress,
userAgent: $userAgent,
blockedForSeconds: $result->retryAfter ?? LoginRateLimiterInterface::IP_BLOCK_DURATION,
failedAttempts: $result->attempts,
occurredOn: $this->clock->now(),
));
return $this->createBlockedResponse($result);
}
// Réponse standard d'échec avec infos sur le délai et CAPTCHA
return $this->createFailureResponse($result);
}
private function createBlockedResponse(LoginRateLimitResult $result): JsonResponse
{
$response = new JsonResponse([
'type' => '/errors/ip-blocked',
'title' => 'Accès temporairement bloqué',
'status' => Response::HTTP_TOO_MANY_REQUESTS,
'detail' => sprintf(
'Trop de tentatives de connexion. Réessayez dans %s.',
$result->getFormattedDelay(),
),
'retryAfter' => $result->retryAfter,
], Response::HTTP_TOO_MANY_REQUESTS);
foreach ($result->toHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
return $response;
}
private function createFailureResponse(LoginRateLimitResult $result): JsonResponse
{
$data = [
'type' => '/errors/authentication-failed',
'title' => 'Identifiants incorrects',
'status' => Response::HTTP_UNAUTHORIZED,
'detail' => 'L\'adresse email ou le mot de passe est incorrect.',
'attempts' => $result->attempts,
];
// Ajouter le délai si applicable
if ($result->delaySeconds > 0) {
$data['delay'] = $result->delaySeconds;
$data['delayFormatted'] = $result->getFormattedDelay();
}
// Indiquer si CAPTCHA requis pour la prochaine tentative
if ($result->requiresCaptcha) {
$data['captchaRequired'] = true;
}
$response = new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
foreach ($result->toHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
return $response;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Application\Service\RefreshTokenManager;
use App\Administration\Domain\Event\ConnexionReussie;
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Gère les actions post-login réussi : refresh token, reset rate limit, audit.
*
* @see Story 1.4 - T5: Endpoint Login Backend
*/
final readonly class LoginSuccessHandler
{
public function __construct(
private RefreshTokenManager $refreshTokenManager,
private LoginRateLimiterInterface $rateLimiter,
private MessageBusInterface $eventBus,
private Clock $clock,
private RequestStack $requestStack,
) {
}
public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
{
$user = $event->getUser();
$response = $event->getResponse();
$request = $this->requestStack->getCurrentRequest();
if (!$user instanceof SecurityUser || $request === null) {
return;
}
$email = $user->email();
$userId = UserId::fromString($user->userId());
$tenantId = TenantId::fromString($user->tenantId());
$ipAddress = $request->getClientIp() ?? 'unknown';
$userAgent = $request->headers->get('User-Agent', 'unknown');
// Créer le device fingerprint
$fingerprint = DeviceFingerprint::fromRequest($userAgent, $ipAddress);
// Détecter si c'est un mobile (pour le TTL du refresh token)
$isMobile = str_contains(strtolower($userAgent), 'mobile');
// Créer le refresh token
$refreshToken = $this->refreshTokenManager->create(
$userId,
$tenantId,
$fingerprint,
$isMobile,
);
// Ajouter le refresh token en cookie HttpOnly
$cookie = Cookie::create('refresh_token')
->withValue($refreshToken->toTokenString())
->withExpires($refreshToken->expiresAt)
->withPath('/api/token')
->withSecure(true)
->withHttpOnly(true)
->withSameSite('strict');
$response->headers->setCookie($cookie);
// Reset le rate limiter pour cet email
$this->rateLimiter->reset($email);
// Émettre l'événement de connexion réussie
$this->eventBus->dispatch(new ConnexionReussie(
userId: $user->userId(),
email: $email,
tenantId: $tenantId,
ipAddress: $ipAddress,
userAgent: $userAgent,
occurredOn: $this->clock->now(),
));
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Ajoute le cookie refresh_token à la réponse HTTP.
*
* Ce listener est nécessaire car dans API Platform 4.x, la réponse n'est pas
* disponible dans le context du processor. Le processor stocke le cookie dans
* les attributs de la requête, et ce listener l'ajoute à la réponse.
*
* @see Story 1.4 - T6: Endpoint Refresh Token
*/
#[AsEventListener(event: KernelEvents::RESPONSE, priority: 0)]
final readonly class RefreshTokenCookieListener
{
public function __invoke(ResponseEvent $event): void
{
$request = $event->getRequest();
$cookie = $request->attributes->get('_refresh_token_cookie');
if ($cookie instanceof Cookie) {
$event->getResponse()->headers->setCookie($cookie);
$request->attributes->remove('_refresh_token_cookie');
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Adapter entre le Domain User et Symfony Security.
*
* Ce DTO est utilisé par le système d'authentification Symfony.
* Il ne contient pas de logique métier - c'est un simple transporteur de données.
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class SecurityUser implements UserInterface, PasswordAuthenticatedUserInterface
{
/** @var non-empty-string */
private string $email;
/**
* @param non-empty-string $email
* @param list<string> $roles Les rôles Symfony (ROLE_*)
*/
public function __construct(
private UserId $userId,
string $email,
private string $hashedPassword,
private TenantId $tenantId,
private array $roles,
) {
$this->email = $email;
}
public function getUserIdentifier(): string
{
return $this->email;
}
public function userId(): string
{
return (string) $this->userId;
}
public function getPassword(): string
{
return $this->hashedPassword;
}
/**
* @return list<string>
*/
public function getRoles(): array
{
return $this->roles;
}
public function tenantId(): string
{
return (string) $this->tenantId;
}
/**
* @return non-empty-string
*/
public function email(): string
{
return $this->email;
}
public function eraseCredentials(): void
{
// Rien à effacer, les données sont immutables
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User as DomainUser;
/**
* Factory pour créer des SecurityUser depuis des Domain Users.
*
* Respecte le principe "No Static" d'Elegant Objects.
*
* @see Story 1.4 - Connexion utilisateur
*/
final readonly class SecurityUserFactory
{
public function fromDomainUser(DomainUser $domainUser): SecurityUser
{
return new SecurityUser(
userId: $domainUser->id,
email: (string) $domainUser->email,
hashedPassword: $domainUser->hashedPassword ?? '',
tenantId: $domainUser->tenantId,
roles: [$this->mapRoleToSymfony($domainUser->role)],
);
}
private function mapRoleToSymfony(Role $role): string
{
return $role->value;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Tenant;
use App\Shared\Domain\EntityId;
/**
* Identifiant unique d'un tenant (établissement scolaire).
*
* Value Object du Domain - représente l'identité d'un tenant dans le système multi-tenant.
* Chaque tenant isole ses données (utilisateurs, notes, etc.) des autres.
*
* Note: Cette classe n'est pas `final` pour permettre l'alias Infrastructure
* durant la période de migration. L'alias sera supprimé dans une version future.
*/
readonly class TenantId extends EntityId
{
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Captcha;
/**
* Résultat de la validation Turnstile.
*/
final readonly class TurnstileResult
{
private function __construct(
public bool $isValid,
public ?string $errorMessage,
) {
}
public static function valid(): self
{
return new self(isValid: true, errorMessage: null);
}
public static function invalid(string $errorMessage): self
{
return new self(isValid: false, errorMessage: $errorMessage);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Captcha;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
/**
* Valide les tokens Cloudflare Turnstile.
*
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
* @see Story 1.4 - T8: CAPTCHA anti-bot
*/
final readonly class TurnstileValidator implements TurnstileValidatorInterface
{
private const string VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
private const float TIMEOUT_SECONDS = 5.0;
/**
* @param bool $failOpen Si true, les erreurs API laissent passer (dev). Si false, elles bloquent (prod).
*/
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $secretKey,
private bool $failOpen = false,
) {
}
/**
* Valide un token Turnstile.
*
* @param string $token Le token fourni par le widget Turnstile
* @param string|null $remoteIp L'IP du client (optionnel, mais recommandé)
*/
public function validate(string $token, ?string $remoteIp = null): TurnstileResult
{
if ($token === '') {
return TurnstileResult::invalid('Token vide');
}
try {
$formData = [
'secret' => $this->secretKey,
'response' => $token,
];
if ($remoteIp !== null) {
$formData['remoteip'] = $remoteIp;
}
$response = $this->httpClient->request('POST', self::VERIFY_URL, [
'body' => $formData,
'timeout' => self::TIMEOUT_SECONDS,
]);
$data = $response->toArray();
if ($data['success'] === true) {
return TurnstileResult::valid();
}
// Erreurs possibles : https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
$errorCodes = $data['error-codes'] ?? [];
$errorMessage = $this->translateErrorCodes($errorCodes);
$this->logger->warning('Turnstile validation failed', [
'error_codes' => $errorCodes,
]);
return TurnstileResult::invalid($errorMessage);
} catch (Throwable $e) {
$this->logger->error('Turnstile API error', [
'exception' => $e->getMessage(),
'fail_open' => $this->failOpen,
]);
// Comportement configurable en cas d'erreur API
// - failOpen=true (dev): laisse passer pour ne pas bloquer le développement
// - failOpen=false (prod): bloque pour maintenir la sécurité
if ($this->failOpen) {
return TurnstileResult::valid();
}
return TurnstileResult::invalid('Service de vérification temporairement indisponible');
}
}
/**
* @param array<string> $errorCodes
*/
private function translateErrorCodes(array $errorCodes): string
{
$translations = [
'missing-input-secret' => 'Configuration serveur invalide',
'invalid-input-secret' => 'Configuration serveur invalide',
'missing-input-response' => 'Token manquant',
'invalid-input-response' => 'Token invalide ou expiré',
'bad-request' => 'Requête invalide',
'timeout-or-duplicate' => 'Token expiré ou déjà utilisé',
'internal-error' => 'Erreur serveur Cloudflare',
];
foreach ($errorCodes as $code) {
if (isset($translations[$code])) {
return $translations[$code];
}
}
return 'Vérification échouée';
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Captcha;
/**
* Interface pour la validation des tokens CAPTCHA.
*/
interface TurnstileValidatorInterface
{
/**
* Valide un token CAPTCHA.
*
* @param string $token Le token fourni par le widget CAPTCHA
* @param string|null $remoteIp L'IP du client (optionnel)
*/
public function validate(string $token, ?string $remoteIp = null): TurnstileResult;
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Console;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Réinitialise le cache du rate limiter pour les tests.
*
* Cette commande est uniquement destinée aux environnements de développement et de test.
* Elle vide tous les compteurs de tentatives de login et les blocages IP.
*/
#[AsCommand(
name: 'app:dev:reset-rate-limit',
description: 'Reset the login rate limiter cache (dev/test only)',
)]
final class ResetRateLimitCommand extends Command
{
public function __construct(
private readonly CacheItemPoolInterface $rateLimiterCache,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Clear the entire rate limiter cache pool
$this->rateLimiterCache->clear();
$io->success('Rate limiter cache has been cleared.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\RateLimit;
use App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface;
use function is_array;
use function is_int;
use function is_string;
use Psr\Cache\CacheItemPoolInterface;
use function sprintf;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Vérifie le rate limit AVANT l'authentification.
*
* Ce listener intercepte les requêtes de login et :
* - Bloque immédiatement si l'IP est bloquée
* - Exige un CAPTCHA après 5 échecs et le valide via Cloudflare Turnstile
* - Bloque l'IP si le CAPTCHA échoue 3 fois
*
* @see Story 1.4 - AC3: Protection contre brute force
*/
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
final readonly class LoginRateLimitListener
{
private const int MAX_CAPTCHA_FAILURES = 3;
private const int CAPTCHA_FAILURES_TTL = 900; // 15 minutes
public function __construct(
private LoginRateLimiterInterface $rateLimiter,
private TurnstileValidatorInterface $turnstileValidator,
private CacheItemPoolInterface $rateLimiterCache,
) {
}
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
// Seulement pour la route de login
if ($request->getPathInfo() !== '/api/login' || $request->getMethod() !== 'POST') {
return;
}
// Extraire l'email du body JSON (avec guards contre JSON invalide)
$content = json_decode($request->getContent(), true);
if (!is_array($content)) {
return; // JSON invalide, laisser le validator gérer
}
$email = isset($content['email']) && is_string($content['email']) ? $content['email'] : null;
if ($email === null) {
return; // Laisser le validator gérer
}
// Vérifier l'état du rate limit
$result = $this->rateLimiter->check($request, $email);
// IP bloquée → 429 immédiat
if ($result->ipBlocked) {
$event->setResponse($this->createBlockedResponse($result));
return;
}
// Délai Fibonacci en cours (enforcement serveur) → 429
if (!$result->isAllowed && $result->retryAfter !== null && $result->retryAfter > 0) {
$event->setResponse($this->createDelayedResponse($result));
return;
}
// CAPTCHA requis (après 5 échecs)
if ($result->requiresCaptcha) {
$captchaToken = isset($content['captcha_token']) && is_string($content['captcha_token'])
? $content['captcha_token']
: null;
// Pas de token fourni → demander le CAPTCHA
if ($captchaToken === null || $captchaToken === '') {
$event->setResponse($this->createCaptchaRequiredResponse($result));
return;
}
// Valider le token via Cloudflare Turnstile
$ip = $request->getClientIp();
$turnstileResult = $this->turnstileValidator->validate($captchaToken, $ip);
if (!$turnstileResult->isValid) {
// CAPTCHA invalide → incrémenter les échecs CAPTCHA par IP
// Après 3 échecs CAPTCHA, bloquer l'IP
$captchaFailures = $this->recordCaptchaFailure($ip ?? 'unknown');
if ($captchaFailures >= self::MAX_CAPTCHA_FAILURES) {
$this->rateLimiter->blockIp($ip ?? 'unknown');
$event->setResponse($this->createBlockedResponse(
LoginRateLimitResult::blocked(LoginRateLimiterInterface::IP_BLOCK_DURATION)
));
return;
}
$event->setResponse($this->createCaptchaInvalidResponse(
$turnstileResult->errorMessage ?? 'Vérification échouée',
$captchaFailures,
));
return;
}
// CAPTCHA valide → réinitialiser les échecs CAPTCHA pour cette IP
$this->resetCaptchaFailures($ip ?? 'unknown');
}
// Tout est OK, continuer vers l'authentification
}
private function recordCaptchaFailure(string $ip): int
{
$key = 'captcha_failures_' . md5($ip);
$item = $this->rateLimiterCache->getItem($key);
$cached = $item->get();
$failures = $item->isHit() && is_int($cached) ? $cached + 1 : 1;
$item->set($failures);
$item->expiresAfter(self::CAPTCHA_FAILURES_TTL);
$this->rateLimiterCache->save($item);
return $failures;
}
private function resetCaptchaFailures(string $ip): void
{
$key = 'captcha_failures_' . md5($ip);
$this->rateLimiterCache->deleteItem($key);
}
private function createBlockedResponse(LoginRateLimitResult $result): JsonResponse
{
$response = new JsonResponse([
'type' => '/errors/ip-blocked',
'title' => 'Accès temporairement bloqué',
'status' => Response::HTTP_TOO_MANY_REQUESTS,
'detail' => sprintf(
'Trop de tentatives de connexion. Réessayez dans %s.',
$result->getFormattedDelay(),
),
'retryAfter' => $result->retryAfter,
], Response::HTTP_TOO_MANY_REQUESTS);
foreach ($result->toHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
return $response;
}
private function createDelayedResponse(LoginRateLimitResult $result): JsonResponse
{
$response = new JsonResponse([
'type' => '/errors/rate-limited',
'title' => 'Veuillez patienter',
'status' => Response::HTTP_TOO_MANY_REQUESTS,
'detail' => sprintf(
'Veuillez patienter %s avant de réessayer.',
$result->getFormattedDelay(),
),
'retryAfter' => $result->retryAfter,
'attempts' => $result->attempts,
], Response::HTTP_TOO_MANY_REQUESTS);
foreach ($result->toHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
return $response;
}
private function createCaptchaRequiredResponse(LoginRateLimitResult $result): JsonResponse
{
$response = new JsonResponse([
'type' => '/errors/captcha-required',
'title' => 'Vérification requise',
'status' => Response::HTTP_PRECONDITION_REQUIRED,
'detail' => 'Veuillez compléter la vérification de sécurité pour continuer.',
'attempts' => $result->attempts,
], Response::HTTP_PRECONDITION_REQUIRED);
foreach ($result->toHeaders() as $name => $value) {
$response->headers->set($name, $value);
}
return $response;
}
private function createCaptchaInvalidResponse(string $errorMessage, int $failures): JsonResponse
{
return new JsonResponse([
'type' => '/errors/captcha-invalid',
'title' => 'Vérification échouée',
'status' => Response::HTTP_BAD_REQUEST,
'detail' => $errorMessage,
'captchaFailures' => $failures,
'maxFailures' => self::MAX_CAPTCHA_FAILURES,
], Response::HTTP_BAD_REQUEST);
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\RateLimit;
use function sprintf;
/**
* Résultat de la vérification du rate limit pour le login.
*
* Stratégie de protection :
* - Délai progressif Fibonacci après chaque échec (1s, 1s, 2s, 3s, 5s, 8s, 13s...)
* - CAPTCHA requis après 5 échecs
* - Blocage IP après échec CAPTCHA répété
*/
final readonly class LoginRateLimitResult
{
private function __construct(
public bool $isAllowed,
public int $attempts,
public int $delaySeconds,
public bool $requiresCaptcha,
public bool $ipBlocked,
public ?int $retryAfter,
) {
}
/**
* Tentative autorisée (éventuellement avec délai).
*/
public static function allowed(int $attempts, int $delaySeconds, bool $requiresCaptcha): self
{
return new self(
isAllowed: true,
attempts: $attempts,
delaySeconds: $delaySeconds,
requiresCaptcha: $requiresCaptcha,
ipBlocked: false,
retryAfter: $delaySeconds > 0 ? $delaySeconds : null,
);
}
/**
* IP bloquée (trop de tentatives ou échec CAPTCHA).
*/
public static function blocked(int $retryAfter): self
{
return new self(
isAllowed: false,
attempts: 0,
delaySeconds: $retryAfter,
requiresCaptcha: false,
ipBlocked: true,
retryAfter: $retryAfter,
);
}
/**
* Tentative refusée temporairement (délai Fibonacci en cours).
*/
public static function delayed(int $attempts, int $retryAfter): self
{
return new self(
isAllowed: false,
attempts: $attempts,
delaySeconds: $retryAfter,
requiresCaptcha: $attempts >= 5, // CAPTCHA_THRESHOLD
ipBlocked: false,
retryAfter: $retryAfter,
);
}
/**
* Calcule le délai Fibonacci pour un nombre de tentatives donné.
*
* Suite: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89... (max 89s)
*
* Mapping:
* - 1 tentative = pas de délai
* - 2 tentatives = 1s (F0)
* - 3 tentatives = 1s (F1)
* - 4 tentatives = 2s (F2)
* - 5 tentatives = 3s (F3)
* - etc.
*/
public static function fibonacciDelay(int $attempts): int
{
if ($attempts <= 1) {
return 0; // Première tentative sans délai
}
// Index dans la suite Fibonacci: attempts - 2
// Cap à F(10) = 89 secondes (index 10 dans la suite 1,1,2,3,5,8,13,21,34,55,89)
$n = min($attempts - 2, 10);
return self::fibonacci($n);
}
/**
* Calcule le n-ième nombre de Fibonacci.
*
* F(0)=1, F(1)=1, F(2)=2, F(3)=3, F(4)=5, F(5)=8, F(6)=13, F(7)=21, F(8)=34, F(9)=55, F(10)=89
*/
private static function fibonacci(int $n): int
{
if ($n <= 1) {
return 1;
}
$prev = 1;
$curr = 1;
for ($i = 2; $i <= $n; ++$i) {
$next = $prev + $curr;
$prev = $curr;
$curr = $next;
}
return $curr;
}
/**
* Génère les headers pour la réponse HTTP.
*
* @return array<string, string>
*/
public function toHeaders(): array
{
$headers = [
'X-Login-Attempts' => (string) $this->attempts,
];
if ($this->delaySeconds > 0) {
$headers['X-Login-Delay'] = (string) $this->delaySeconds;
$headers['Retry-After'] = (string) $this->delaySeconds;
}
if ($this->requiresCaptcha) {
$headers['X-Captcha-Required'] = 'true';
}
if ($this->ipBlocked) {
$headers['X-IP-Blocked'] = 'true';
}
return $headers;
}
/**
* Retourne le temps d'attente formaté pour l'utilisateur.
*/
public function getFormattedDelay(): string
{
if ($this->delaySeconds <= 0) {
return '';
}
if ($this->delaySeconds < 60) {
return sprintf('%d seconde%s', $this->delaySeconds, $this->delaySeconds > 1 ? 's' : '');
}
$minutes = (int) ceil($this->delaySeconds / 60);
return sprintf('%d minute%s', $minutes, $minutes > 1 ? 's' : '');
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\RateLimit;
use function is_int;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Service de rate limiting pour les tentatives de login.
*
* Stratégie de protection multi-niveaux :
* - Délai progressif Fibonacci par email (1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s, 55s, 89s max)
* - CAPTCHA requis après 5 échecs sur le même email
* - Blocage IP 15 min si trop de tentatives globales (20) ou échec CAPTCHA répété
*
* @see Story 1.4 - AC3: Protection contre brute force
*/
final readonly class LoginRateLimiter implements LoginRateLimiterInterface
{
private const string EMAIL_ATTEMPTS_PREFIX = 'login_attempts:';
private const string EMAIL_DELAY_PREFIX = 'login_delay:';
private const string IP_ATTEMPTS_PREFIX = 'login_ip:';
private const string IP_BLOCKED_PREFIX = 'login_ip_blocked:';
private const int EMAIL_ATTEMPTS_TTL = 900; // 15 minutes
private const int IP_ATTEMPTS_LIMIT = 20;
public function __construct(
private CacheItemPoolInterface $cache,
) {
}
public function check(Request $request, string $email): LoginRateLimitResult
{
$ip = $request->getClientIp() ?? 'unknown';
// Vérifier si l'IP est bloquée
if ($this->isIpBlocked($ip)) {
$blockedItem = $this->cache->getItem($this->ipBlockedKey($ip));
$blockedUntil = $blockedItem->get();
$retryAfter = is_int($blockedUntil) ? max(0, $blockedUntil - time()) : 0;
return LoginRateLimitResult::blocked($retryAfter);
}
// Vérifier si l'email est en période de délai (enforcement Fibonacci)
$delayedUntil = $this->getDelayedUntil($email);
if ($delayedUntil > time()) {
$retryAfter = $delayedUntil - time();
$attempts = $this->getAttempts($email);
return LoginRateLimitResult::delayed($attempts, $retryAfter);
}
// Récupérer le nombre de tentatives pour cet email
$attempts = $this->getAttempts($email);
$delaySeconds = LoginRateLimitResult::fibonacciDelay($attempts);
$requiresCaptcha = $attempts >= self::CAPTCHA_THRESHOLD;
return LoginRateLimitResult::allowed($attempts, $delaySeconds, $requiresCaptcha);
}
public function recordFailure(Request $request, string $email): LoginRateLimitResult
{
$ip = $request->getClientIp() ?? 'unknown';
// Incrémenter les tentatives pour l'email
$emailAttempts = $this->incrementAttempts($email);
// Incrémenter les tentatives pour l'IP
$ipAttempts = $this->incrementIpAttempts($ip);
// Bloquer l'IP si trop de tentatives globales
if ($ipAttempts >= self::IP_ATTEMPTS_LIMIT) {
$this->blockIp($ip);
return LoginRateLimitResult::blocked(self::IP_BLOCK_DURATION);
}
$delaySeconds = LoginRateLimitResult::fibonacciDelay($emailAttempts);
$requiresCaptcha = $emailAttempts >= self::CAPTCHA_THRESHOLD;
// Enregistrer le timestamp de prochaine tentative autorisée
// Cela permet d'enforcer le délai côté serveur
if ($delaySeconds > 0) {
$this->setDelayedUntil($email, time() + $delaySeconds);
}
return LoginRateLimitResult::allowed($emailAttempts, $delaySeconds, $requiresCaptcha);
}
public function reset(string $email): void
{
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
$this->cache->deleteItem($key);
}
public function blockIp(string $ip): void
{
$item = $this->cache->getItem($this->ipBlockedKey($ip));
$item->set(time() + self::IP_BLOCK_DURATION);
$item->expiresAfter(self::IP_BLOCK_DURATION);
$this->cache->save($item);
}
public function isIpBlocked(string $ip): bool
{
$item = $this->cache->getItem($this->ipBlockedKey($ip));
if (!$item->isHit()) {
return false;
}
$blockedUntil = $item->get();
return $blockedUntil > time();
}
private function getAttempts(string $email): int
{
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
$item = $this->cache->getItem($key);
if (!$item->isHit()) {
return 0;
}
$cached = $item->get();
return is_int($cached) ? $cached : 0;
}
private function incrementAttempts(string $email): int
{
$key = self::EMAIL_ATTEMPTS_PREFIX . $this->normalizeEmail($email);
$item = $this->cache->getItem($key);
$cached = $item->get();
$attempts = $item->isHit() && is_int($cached) ? $cached : 0;
++$attempts;
$item->set($attempts);
$item->expiresAfter(self::EMAIL_ATTEMPTS_TTL);
$this->cache->save($item);
return $attempts;
}
private function incrementIpAttempts(string $ip): int
{
$key = self::IP_ATTEMPTS_PREFIX . $this->hashIp($ip);
$item = $this->cache->getItem($key);
$cached = $item->get();
$attempts = $item->isHit() && is_int($cached) ? $cached : 0;
++$attempts;
$item->set($attempts);
$item->expiresAfter(self::EMAIL_ATTEMPTS_TTL);
$this->cache->save($item);
return $attempts;
}
private function ipBlockedKey(string $ip): string
{
return self::IP_BLOCKED_PREFIX . $this->hashIp($ip);
}
private function getDelayedUntil(string $email): int
{
$key = self::EMAIL_DELAY_PREFIX . $this->normalizeEmail($email);
$item = $this->cache->getItem($key);
if (!$item->isHit()) {
return 0;
}
$cached = $item->get();
return is_int($cached) ? $cached : 0;
}
private function setDelayedUntil(string $email, int $timestamp): void
{
$key = self::EMAIL_DELAY_PREFIX . $this->normalizeEmail($email);
$item = $this->cache->getItem($key);
$item->set($timestamp);
// TTL = délai + marge de sécurité
$item->expiresAfter(max(0, $timestamp - time()) + 10);
$this->cache->save($item);
}
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
private function hashIp(string $ip): string
{
return hash('sha256', $ip);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\RateLimit;
use Symfony\Component\HttpFoundation\Request;
/**
* Interface pour le rate limiting des tentatives de login.
*
* Stratégie de protection multi-niveaux :
* - Délai progressif Fibonacci par email (1s, 1s, 2s, 3s, 5s, 8s...)
* - CAPTCHA requis après 5 échecs
* - Blocage IP après trop de tentatives globales ou échec CAPTCHA
*/
interface LoginRateLimiterInterface
{
public const int CAPTCHA_THRESHOLD = 5;
public const int IP_BLOCK_DURATION = 900; // 15 minutes
/**
* Vérifie l'état du rate limit pour une tentative de login.
*
* Retourne le nombre de tentatives, le délai à appliquer, et si CAPTCHA est requis.
*/
public function check(Request $request, string $email): LoginRateLimitResult;
/**
* Enregistre un échec de login (incrémente le compteur, calcule le nouveau délai).
*/
public function recordFailure(Request $request, string $email): LoginRateLimitResult;
/**
* Réinitialise le compteur pour un email (après login réussi).
*/
public function reset(string $email): void;
/**
* Bloque une IP (après échec CAPTCHA répété).
*/
public function blockIp(string $ip): void;
/**
* Vérifie si une IP est bloquée.
*/
public function isIpBlocked(string $ip): bool;
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\RateLimit;
use Symfony\Component\HttpFoundation\Request;
/**
* Implémentation "null" du rate limiter pour les environnements de test.
*
* Cette implémentation ne fait rien - elle permet de bypasser le rate limiting
* pour les tests E2E où l'IP est partagée entre tous les tests.
*/
final readonly class NullLoginRateLimiter implements LoginRateLimiterInterface
{
public function check(Request $request, string $email): LoginRateLimitResult
{
return LoginRateLimitResult::allowed(attempts: 0, delaySeconds: 0, requiresCaptcha: false);
}
public function recordFailure(Request $request, string $email): LoginRateLimitResult
{
return LoginRateLimitResult::allowed(attempts: 1, delaySeconds: 0, requiresCaptcha: false);
}
public function reset(string $email): void
{
// No-op
}
public function blockIp(string $ip): void
{
// No-op
}
public function isIpBlocked(string $ip): bool
{
return false;
}
}

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use App\Shared\Domain\EntityId;
use App\Shared\Domain\Tenant\TenantId as DomainTenantId;
final readonly class TenantId extends EntityId
/**
* Infrastructure alias for Domain TenantId.
*
* @deprecated Use App\Shared\Domain\Tenant\TenantId instead in Domain layer code.
* This alias exists for backwards compatibility in Infrastructure layer.
*/
final readonly class TenantId extends DomainTenantId
{
}