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

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