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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user