Files
Classeo/backend/src/Administration/Infrastructure/Persistence/Redis/RedisRefreshTokenRepository.php
Mathias STRASSER b9d9f48305 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
2026-02-01 14:43:12 +01:00

170 lines
6.1 KiB
PHP

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