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
143 lines
5.2 KiB
PHP
143 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Administration\Infrastructure\Persistence\Redis;
|
|
|
|
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\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use Override;
|
|
use Psr\Cache\CacheItemPoolInterface;
|
|
|
|
final readonly class RedisActivationTokenRepository implements ActivationTokenRepository
|
|
{
|
|
private const string KEY_PREFIX = 'activation:';
|
|
private const int TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
|
|
public function __construct(
|
|
private CacheItemPoolInterface $activationTokensCache,
|
|
) {
|
|
}
|
|
|
|
#[Override]
|
|
public function save(ActivationToken $token): void
|
|
{
|
|
// Store by token value for lookup during activation
|
|
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue);
|
|
$item->set($this->serialize($token));
|
|
$item->expiresAfter(self::TTL_SECONDS);
|
|
$this->activationTokensCache->save($item);
|
|
|
|
// Also store by ID for direct access
|
|
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id);
|
|
$idItem->set($token->tokenValue);
|
|
$idItem->expiresAfter(self::TTL_SECONDS);
|
|
$this->activationTokensCache->save($idItem);
|
|
}
|
|
|
|
#[Override]
|
|
public function findByTokenValue(string $tokenValue): ?ActivationToken
|
|
{
|
|
$item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $tokenValue);
|
|
|
|
if (!$item->isHit()) {
|
|
return null;
|
|
}
|
|
|
|
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
|
|
$data = $item->get();
|
|
|
|
return $this->deserialize($data);
|
|
}
|
|
|
|
#[Override]
|
|
public function get(ActivationTokenId $id): ActivationToken
|
|
{
|
|
// First get the token value from the ID index
|
|
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
|
|
|
if (!$idItem->isHit()) {
|
|
throw ActivationTokenNotFoundException::withId($id);
|
|
}
|
|
|
|
/** @var string $tokenValue */
|
|
$tokenValue = $idItem->get();
|
|
$token = $this->findByTokenValue($tokenValue);
|
|
|
|
if ($token === null) {
|
|
throw ActivationTokenNotFoundException::withId($id);
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
#[Override]
|
|
public function delete(ActivationTokenId $id): void
|
|
{
|
|
// Get token value first
|
|
$idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id);
|
|
|
|
if ($idItem->isHit()) {
|
|
/** @var string $tokenValue */
|
|
$tokenValue = $idItem->get();
|
|
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
|
}
|
|
|
|
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id);
|
|
}
|
|
|
|
#[Override]
|
|
public function deleteByTokenValue(string $tokenValue): void
|
|
{
|
|
$token = $this->findByTokenValue($tokenValue);
|
|
|
|
if ($token !== null) {
|
|
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id);
|
|
}
|
|
|
|
$this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue);
|
|
}
|
|
|
|
/**
|
|
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
|
|
*/
|
|
private function serialize(ActivationToken $token): array
|
|
{
|
|
return [
|
|
'id' => (string) $token->id,
|
|
'token_value' => $token->tokenValue,
|
|
'user_id' => $token->userId,
|
|
'email' => $token->email,
|
|
'tenant_id' => (string) $token->tenantId,
|
|
'role' => $token->role,
|
|
'school_name' => $token->schoolName,
|
|
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
|
|
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
|
|
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
|
|
*/
|
|
private function deserialize(array $data): ActivationToken
|
|
{
|
|
return ActivationToken::reconstitute(
|
|
id: ActivationTokenId::fromString($data['id']),
|
|
tokenValue: $data['token_value'],
|
|
userId: $data['user_id'],
|
|
email: $data['email'],
|
|
tenantId: TenantId::fromString($data['tenant_id']),
|
|
role: $data['role'],
|
|
schoolName: $data['school_name'],
|
|
createdAt: new DateTimeImmutable($data['created_at']),
|
|
expiresAt: new DateTimeImmutable($data['expires_at']),
|
|
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
|
|
);
|
|
}
|
|
}
|