<?php
namespace App\EventSubscriber;
use App\Entity\User;
use App\Service\LogService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent;
class SecuritySubscriber implements EventSubscriberInterface
{
public function __construct(
private TokenStorageInterface $tokenStorage,
private EntityManagerInterface $em,
private RouterInterface $router,
private LogService $logService,
) {}
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => 'onLoginSuccess',
LogoutEvent::class => 'onLogout',
KernelEvents::REQUEST => ['onKernelRequest', 6],
];
}
/**
* Génère un nouveau token à chaque connexion réussie.
* L'ancien token (autre navigateur/onglet) devient immédiatement invalide.
*/
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
if (!$user instanceof User) {
return;
}
$token = bin2hex(random_bytes(32));
$user->setSessionToken($token);
$this->em->flush();
$event->getRequest()->getSession()->set('_app_session_token', $token);
$this->logService->log(
$user->getLogin(),
'CONNEXION',
"Connexion réussie de {$user->getPrenom()} {$user->getNom()}",
null,
'/'
);
if ($user->isPremiereConnexion() === false) {
$event->setResponse(new RedirectResponse($this->router->generate('changer_password')));
}
}
/**
* Log la déconnexion.
*/
public function onLogout(LogoutEvent $event): void
{
$token = $event->getToken();
if ($token === null) {
return;
}
$user = $token->getUser();
if (!$user instanceof User) {
return;
}
$this->logService->log(
$user->getLogin(),
'DECONNEXION',
"Déconnexion de {$user->getPrenom()} {$user->getNom()}",
null,
'/deconnexion'
);
}
/**
* Sur chaque requête, vérifie que le token en session correspond
* à celui en base. Si non → session expirée (connexion sur un autre poste).
*/
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$securityToken = $this->tokenStorage->getToken();
if ($securityToken === null) {
return;
}
$user = $securityToken->getUser();
if (!$user instanceof User) {
return;
}
$request = $event->getRequest();
$sessionToken = $request->getSession()->get('_app_session_token');
// Recharge l'entité depuis la base pour avoir le token à jour
$this->em->refresh($user);
$dbToken = $user->getSessionToken();
if ($user->isPremiereConnexion() === false) {
$exemptPaths = [
$this->router->generate('changer_password'),
$this->router->generate('deconnexion'),
$this->router->generate('avantdeconnexion'),
];
if (!in_array($request->getPathInfo(), $exemptPaths, true)) {
$event->setResponse(new RedirectResponse($exemptPaths[0]));
return;
}
}
if ($dbToken === null) {
// Aucun token en base : la politique de session unique ne s'applique
// pas encore à cet utilisateur (jamais authentifié via ce mécanisme).
return;
}
if ($sessionToken === null) {
// Cas normal juste après une connexion réussie : le token vient d'être
// généré et flushé par onLoginSuccess, mais la session (nouvellement
// migrée par Symfony pour la protection anti-fixation) ne l'a pas encore.
// On la synchronise ici plutôt que d'expulser l'utilisateur qui vient
// tout juste de se connecter.
$request->getSession()->set('_app_session_token', $dbToken);
return;
}
if (!hash_equals($dbToken, $sessionToken)) {
$login = $user->getLogin();
$this->logService->log(
$login,
'SESSION_EXPIREE',
"Session expirée pour {$user->getPrenom()} {$user->getNom()} (connexion depuis un autre appareil)",
null,
$request->getPathInfo()
);
$this->tokenStorage->setToken(null);
$request->getSession()->invalidate();
$url = $this->router->generate('connexion') . '?expired=1';
$event->setResponse(new RedirectResponse($url));
}
}
}