<?php
namespace App\EventSubscriber;
use App\Security\MainAuthenticationEntryPoint;
use App\Security\AppAuthenticator;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\FirewallMapInterface;
class SessionIdleTimeoutSubscriber implements EventSubscriberInterface
{
private const LAST_ACTIVITY_KEY = 'last_activity';
private const REMEMBER_ME_COOKIE_NAME = 'REMEMBERME';
private const DEFAULT_SKIPPED_ROUTES = [
'app_logout',
'app_logout_itmconnect',
'_profiler',
'_wdt',
];
public function __construct(
private TokenStorageInterface $tokenStorage,
private FirewallMapInterface $firewallMap,
private MainAuthenticationEntryPoint $authenticationEntryPoint,
private LoggerInterface $logger,
private int $idleTimeout,
private string $loginRoute = AppAuthenticator::LOGIN_ROUTE,
private array $statefulFirewalls = ['main'],
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', -10],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest() || $event->hasResponse() || $this->idleTimeout <= 0) {
return;
}
$request = $event->getRequest();
$route = (string) ($request->attributes->get('_route') ?? '');
if ($this->shouldSkipRoute($route)) {
return;
}
$firewallConfig = $this->firewallMap->getFirewallConfig($request);
if (null === $firewallConfig || $firewallConfig->isStateless()) {
return;
}
if (!in_array($firewallConfig->getName(), $this->statefulFirewalls, true)) {
return;
}
$token = $this->tokenStorage->getToken();
if (null === $token) {
return;
}
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return;
}
if (!$request->hasSession()) {
return;
}
$session = $request->getSession();
if (!$session->isStarted()) {
$session->start();
}
$now = time();
$lastActivity = $session->get(self::LAST_ACTIVITY_KEY);
if (is_int($lastActivity) && ($now - $lastActivity) > $this->idleTimeout) {
$this->tokenStorage->setToken(null);
$session->invalidate();
$this->logger->info('[SECURITY] Session expired due to inactivity.', [
'userId' => method_exists($user, 'getId') ? $user->getId() : null,
'email' => method_exists($user, 'getEmail') ? $user->getEmail() : null,
'identifier' => $user->getUserIdentifier(),
'firewall' => $firewallConfig->getName(),
'route' => $route,
'ip' => $request->getClientIp(),
'idleTimeoutSeconds' => $this->idleTimeout,
]);
$entryPointResponse = $this->authenticationEntryPoint->start($request);
$loginUrl = $entryPointResponse instanceof RedirectResponse
? (string) $entryPointResponse->headers->get('Location')
: $this->authenticationEntryPoint->getLoginUrl($request);
$response = $this->buildExpiredResponse(
$request->isXmlHttpRequest(),
$request->getPreferredFormat(),
$loginUrl,
$entryPointResponse
);
$response->headers->clearCookie(self::REMEMBER_ME_COOKIE_NAME);
$response->headers->clearCookie(strtolower(self::REMEMBER_ME_COOKIE_NAME));
$event->setResponse($response);
return;
}
$session->set(self::LAST_ACTIVITY_KEY, $now);
}
private function shouldSkipRoute(string $route): bool
{
if ($route === '') {
return false;
}
if ($route === $this->loginRoute || in_array($route, self::DEFAULT_SKIPPED_ROUTES, true)) {
return true;
}
return str_starts_with($route, '_profiler') || str_starts_with($route, '_wdt');
}
private function buildExpiredResponse(
bool $isXmlHttpRequest,
string $preferredFormat,
string $loginUrl,
RedirectResponse $entryPointResponse
): JsonResponse|RedirectResponse {
if ($isXmlHttpRequest || $preferredFormat === 'json') {
return new JsonResponse([
'error' => 'Session expired',
'message' => 'Votre session a expire.',
'redirect' => $loginUrl,
], 401);
}
return $entryPointResponse;
}
}