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:
2026-02-01 10:25:25 +01:00
parent 6889c67a44
commit b9d9f48305
93 changed files with 6850 additions and 155 deletions

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* Charge les utilisateurs depuis le domaine pour l'authentification Symfony.
*
* Ce provider fait le pont entre Symfony Security et notre Domain Layer.
* Il ne révèle jamais si un utilisateur existe ou non pour des raisons de sécurité.
* Les utilisateurs sont isolés par tenant (établissement).
*
* @implements UserProviderInterface<SecurityUser>
*
* @see Story 1.4 - Connexion utilisateur (AC2: pas de révélation d'existence du compte)
*/
final readonly class DatabaseUserProvider implements UserProviderInterface
{
public function __construct(
private UserRepository $userRepository,
private TenantResolver $tenantResolver,
private RequestStack $requestStack,
private SecurityUserFactory $securityUserFactory,
) {
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$tenantId = $this->getCurrentTenantId();
try {
$email = new Email($identifier);
} catch (EmailInvalideException) {
// Malformed email = treat as user not found (security: generic error)
throw new SymfonyUserNotFoundException();
}
$user = $this->userRepository->findByEmail($email, $tenantId);
// Message générique pour ne pas révéler l'existence du compte
if ($user === null) {
throw new SymfonyUserNotFoundException();
}
// Ne pas permettre la connexion si le compte n'est pas actif
if (!$user->peutSeConnecter()) {
throw new SymfonyUserNotFoundException();
}
return $this->securityUserFactory->fromDomainUser($user);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof SecurityUser) {
throw new InvalidArgumentException('Expected instance of ' . SecurityUser::class);
}
return $this->loadUserByIdentifier($user->email());
}
public function supportsClass(string $class): bool
{
return $class === SecurityUser::class;
}
/**
* Resolves the current tenant from the request host.
*
* @throws SymfonyUserNotFoundException if tenant cannot be resolved (security: generic error)
*/
private function getCurrentTenantId(): TenantId
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
throw new SymfonyUserNotFoundException();
}
$host = $request->getHost();
// Dev/test fallback: localhost uses ecole-alpha tenant
if ($host === 'localhost' || $host === '127.0.0.1') {
try {
return $this->tenantResolver->resolve('ecole-alpha.classeo.local')->tenantId;
} catch (TenantNotFoundException) {
throw new SymfonyUserNotFoundException();
}
}
try {
$tenantConfig = $this->tenantResolver->resolve($host);
return $tenantConfig->tenantId;
} catch (TenantNotFoundException) {
// Don't reveal tenant doesn't exist - use same error as invalid credentials
throw new SymfonyUserNotFoundException();
}
}
}