Files
Classeo/backend/src/Administration/Infrastructure/Persistence/Redis/RedisActivationTokenRepository.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

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