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