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