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