<?php
declare(strict_types=1);
namespace App\V4\EventSubscriber;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use App\Security\Exception\TokenNotWhitelistedException;
use App\Security\User;
use App\Service\ApiWebService;
use App\V4\EventSubscriber\Sentry\RegisterTransactionIdEventSubscriber;
use App\V4\Logger\SentryLogger;
use App\V4\Model\Security\UserInfo;
use DateInterval;
use DateTimeInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException;
use LogicException;
use Psr\Log\LogLevel;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class PreventMultipleLoginsEventSubscriber implements EventSubscriberInterface
{
private const AUTHENTICATION_USER_KEY = 'authentication.%customer_id%.%user_id%';
/**
* @var AdapterInterface
*/
private $cache;
/**
* @var ApiWebService
*/
private $apiWebService;
/**
* @var ItemDataProviderInterface
*/
private $itemDataProvider;
/**
* @var SentryLogger
*/
private $sentryLogger;
/**
* @var RequestStack
*/
private $requestStack;
/**
* @var bool
*/
private $enableWhitelist;
/**
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*/
public function __construct(
AdapterInterface $cache,
ApiWebService $apiWebService,
ItemDataProviderInterface $itemDataProvider,
SentryLogger $sentryLogger,
RequestStack $requestStack,
bool $enableWhitelist = false
) {
$this->cache = $cache;
$this->apiWebService = $apiWebService;
$this->itemDataProvider = $itemDataProvider;
$this->sentryLogger = $sentryLogger;
$this->requestStack = $requestStack;
$this->enableWhitelist = $enableWhitelist;
}
public static function getSubscribedEvents(): array
{
return [
Events::JWT_AUTHENTICATED => 'onJWTAuthenticated',
];
}
public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
{
if ($this->supports($event) === false) {
return;
}
$tokenIssuedAt = $event->getPayload()['iat'];
$tokenExpiresAt = $event->getPayload()['exp'];
// Should be unnecessary as Lexik should have already checked that
if ($tokenExpiresAt < time()) {
$this->sentryLogger->captureMessage(
SentryLogger::CHANNEL_SECURITY,
"Expired token used for user {$event->getToken()->getUser()->getUsername()}",
['event' => $event]
);
throw new ExpiredTokenException('Expired JWT Token');
}
$user = $event->getToken()->getUser();
if (!$user instanceof User) {
$this->sentryLogger->captureMessage(
SentryLogger::CHANNEL_SECURITY,
"No user was logged-in, yet JWTAuthenticatedEvent was dispatched for user {$event->getToken()->getUser()->getUsername()}",
['event' => $event, 'user' => $user]
);
throw new LogicException('No user was logged-in, yet JWTAuthenticatedEvent was dispatched.');
}
$databaseUser = $this->getDatabaseUser($user, $event->getToken()->getCredentials());
if (!$databaseUser->getTokenValidAfter() instanceof DateTimeInterface) {
$this->sentryLogger->captureMessage(
SentryLogger::CHANNEL_SECURITY,
"User {$user->getUsername()} was never authenticated.",
['event' => $event, 'user' => $user, 'databaseUser' => $databaseUser]
);
throw new LogicException('User was never authenticated.');
}
if ($tokenIssuedAt < $databaseUser->getTokenValidAfter()->getTimestamp()) {
$this->sentryLogger->captureMessage(
SentryLogger::CHANNEL_SECURITY,
"User {$user->getUsername()} tried to use a non-whitelisted token.",
['event' => $event, 'user' => $user, 'databaseUser' => $databaseUser, SentryLogger::OPTION_NOT_NEED_SENTRY => true]
);
throw new TokenNotWhitelistedException('logout_mercure');
}
if ($tokenIssuedAt > $databaseUser->getTokenValidAfter()->getTimestamp()) {
$this->clearCache($user);
}
}
private function supports(JWTAuthenticatedEvent $event): bool
{
// 1: Whitelist must be enabled
if (!$this->enableWhitelist) {
return false;
}
// 2: Only Requests should be supported
$request = $this->requestStack->getCurrentRequest();
if (!$request instanceof Request) {
return false;
}
// 3: System Users should only be restricted if they are using the front API
$user = $event->getToken()->getUser();
if ($user instanceof User && $user->isSystemUser()) {
$transactionId = $request->headers->get(RegisterTransactionIdEventSubscriber::KEY_TRANSACTION_ID, '');
if (empty($transactionId) || !str_starts_with($transactionId, 'front:')) {
return false;
}
}
return true;
}
private function getDatabaseUser(User $user, string $bearerToken): UserInfo
{
$cachedUserItem = $this
->cache
->getItem(str_replace(['%customer_id%', '%user_id%'], [$user->getCustomerId(), $user->getUserId()], self::AUTHENTICATION_USER_KEY));
if (!$cachedUserItem->isHit()) {
$this->apiWebService->disableAuth();
$this->apiWebService->addHeader('Authorization', 'Bearer ' . $bearerToken);
$databaseUser = $this->itemDataProvider->getItem(UserInfo::class, $user->getUserId());
if (!$databaseUser instanceof UserInfo) {
$this->sentryLogger->captureMessage(
SentryLogger::CHANNEL_SECURITY,
"User {$user->getUsername()} was not found in the database.",
['user' => $user]
);
throw new LogicException(sprintf('User [%s] was not found in the database.', $user->getUserId()));
}
$cachedUserItem
// Force check every 5 minutes, should be necessary as any new token should delete the cache
->expiresAfter(new DateInterval('PT5M'))
->set($databaseUser);
$this->cache->save($cachedUserItem);
}
return $cachedUserItem->get();
}
private function clearCache(User $user): void
{
$this
->cache
->deleteItem(
str_replace(
['%customer_id%', '%user_id%'],
[$user->getCustomerId(), $user->getUserId()],
self::AUTHENTICATION_USER_KEY
)
);
}
}