src/V4/EventSubscriber/PreventMultipleLoginsEventSubscriber.php line 86

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\V4\EventSubscriber;
  4. use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
  5. use App\Security\Exception\TokenNotWhitelistedException;
  6. use App\Security\User;
  7. use App\Service\ApiWebService;
  8. use App\V4\EventSubscriber\Sentry\RegisterTransactionIdEventSubscriber;
  9. use App\V4\Logger\SentryLogger;
  10. use App\V4\Model\Security\UserInfo;
  11. use DateInterval;
  12. use DateTimeInterface;
  13. use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
  14. use Lexik\Bundle\JWTAuthenticationBundle\Events;
  15. use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException;
  16. use LogicException;
  17. use Psr\Log\LogLevel;
  18. use Symfony\Component\Cache\Adapter\AdapterInterface;
  19. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  20. use Symfony\Component\HttpFoundation\Request;
  21. use Symfony\Component\HttpFoundation\RequestStack;
  22. class PreventMultipleLoginsEventSubscriber implements EventSubscriberInterface
  23. {
  24.     private const AUTHENTICATION_USER_KEY 'authentication.%customer_id%.%user_id%';
  25.     /**
  26.      * @var AdapterInterface
  27.      */
  28.     private $cache;
  29.     /**
  30.      * @var ApiWebService
  31.      */
  32.     private $apiWebService;
  33.     /**
  34.      * @var ItemDataProviderInterface
  35.      */
  36.     private $itemDataProvider;
  37.     /**
  38.      * @var SentryLogger
  39.      */
  40.     private $sentryLogger;
  41.     /**
  42.      * @var RequestStack
  43.      */
  44.     private $requestStack;
  45.     /**
  46.      * @var bool
  47.      */
  48.     private $enableWhitelist;
  49.     /**
  50.      * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
  51.      */
  52.     public function __construct(
  53.         AdapterInterface $cache,
  54.         ApiWebService $apiWebService,
  55.         ItemDataProviderInterface $itemDataProvider,
  56.         SentryLogger $sentryLogger,
  57.         RequestStack $requestStack,
  58.         bool $enableWhitelist false
  59.     ) {
  60.         $this->cache $cache;
  61.         $this->apiWebService $apiWebService;
  62.         $this->itemDataProvider $itemDataProvider;
  63.         $this->sentryLogger $sentryLogger;
  64.         $this->requestStack $requestStack;
  65.         $this->enableWhitelist $enableWhitelist;
  66.     }
  67.     public static function getSubscribedEvents(): array
  68.     {
  69.         return [
  70.             Events::JWT_AUTHENTICATED => 'onJWTAuthenticated',
  71.         ];
  72.     }
  73.     public function onJWTAuthenticated(JWTAuthenticatedEvent $event): void
  74.     {
  75.         if ($this->supports($event) === false) {
  76.             return;
  77.         }
  78.         $tokenIssuedAt $event->getPayload()['iat'];
  79.         $tokenExpiresAt $event->getPayload()['exp'];
  80.         // Should be unnecessary as Lexik should have already checked that
  81.         if ($tokenExpiresAt time()) {
  82.             $this->sentryLogger->captureMessage(
  83.                 SentryLogger::CHANNEL_SECURITY,
  84.                 "Expired token used for user {$event->getToken()->getUser()->getUsername()}",
  85.                 ['event' => $event]
  86.             );
  87.             throw new ExpiredTokenException('Expired JWT Token');
  88.         }
  89.         $user $event->getToken()->getUser();
  90.         if (!$user instanceof User) {
  91.             $this->sentryLogger->captureMessage(
  92.                 SentryLogger::CHANNEL_SECURITY,
  93.                 "No user was logged-in, yet JWTAuthenticatedEvent was dispatched for user {$event->getToken()->getUser()->getUsername()}",
  94.                 ['event' => $event'user' => $user]
  95.             );
  96.             throw new LogicException('No user was logged-in, yet JWTAuthenticatedEvent was dispatched.');
  97.         }
  98.         $databaseUser $this->getDatabaseUser($user$event->getToken()->getCredentials());
  99.         if (!$databaseUser->getTokenValidAfter() instanceof DateTimeInterface) {
  100.             $this->sentryLogger->captureMessage(
  101.                 SentryLogger::CHANNEL_SECURITY,
  102.                 "User {$user->getUsername()} was never authenticated.",
  103.                 ['event' => $event'user' => $user'databaseUser' => $databaseUser]
  104.             );
  105.             throw new LogicException('User was never authenticated.');
  106.         }
  107.         if ($tokenIssuedAt $databaseUser->getTokenValidAfter()->getTimestamp()) {
  108.             $this->sentryLogger->captureMessage(
  109.                 SentryLogger::CHANNEL_SECURITY,
  110.                 "User {$user->getUsername()} tried to use a non-whitelisted token.",
  111.                 ['event' => $event'user' => $user'databaseUser' => $databaseUserSentryLogger::OPTION_NOT_NEED_SENTRY => true]
  112.             );
  113.             throw new TokenNotWhitelistedException('logout_mercure');
  114.         }
  115.         if ($tokenIssuedAt $databaseUser->getTokenValidAfter()->getTimestamp()) {
  116.             $this->clearCache($user);
  117.         }
  118.     }
  119.     private function supports(JWTAuthenticatedEvent $event): bool
  120.     {
  121.         // 1: Whitelist must be enabled
  122.         if (!$this->enableWhitelist) {
  123.             return false;
  124.         }
  125.         // 2: Only Requests should be supported
  126.         $request $this->requestStack->getCurrentRequest();
  127.         if (!$request instanceof Request) {
  128.             return false;
  129.         }
  130.         // 3: System Users should only be restricted if they are using the front API
  131.         $user $event->getToken()->getUser();
  132.         if ($user instanceof User && $user->isSystemUser()) {
  133.             $transactionId $request->headers->get(RegisterTransactionIdEventSubscriber::KEY_TRANSACTION_ID'');
  134.             if (empty($transactionId) || !str_starts_with($transactionId'front:')) {
  135.                 return false;
  136.             }
  137.         }
  138.         return true;
  139.     }
  140.     private function getDatabaseUser(User $userstring $bearerToken): UserInfo
  141.     {
  142.         $cachedUserItem $this
  143.             ->cache
  144.             ->getItem(str_replace(['%customer_id%''%user_id%'], [$user->getCustomerId(), $user->getUserId()], self::AUTHENTICATION_USER_KEY));
  145.         if (!$cachedUserItem->isHit()) {
  146.             $this->apiWebService->disableAuth();
  147.             $this->apiWebService->addHeader('Authorization''Bearer ' $bearerToken);
  148.             $databaseUser $this->itemDataProvider->getItem(UserInfo::class, $user->getUserId());
  149.             if (!$databaseUser instanceof UserInfo) {
  150.                 $this->sentryLogger->captureMessage(
  151.                     SentryLogger::CHANNEL_SECURITY,
  152.                     "User {$user->getUsername()} was not found in the database.",
  153.                     ['user' => $user]
  154.                 );
  155.                 throw new LogicException(sprintf('User [%s] was not found in the database.'$user->getUserId()));
  156.             }
  157.             $cachedUserItem
  158.                 // Force check every 5 minutes, should be necessary as any new token should delete the cache
  159.                 ->expiresAfter(new DateInterval('PT5M'))
  160.                 ->set($databaseUser);
  161.             $this->cache->save($cachedUserItem);
  162.         }
  163.         return $cachedUserItem->get();
  164.     }
  165.     private function clearCache(User $user): void
  166.     {
  167.         $this
  168.             ->cache
  169.             ->deleteItem(
  170.                 str_replace(
  171.                     ['%customer_id%''%user_id%'],
  172.                     [$user->getCustomerId(), $user->getUserId()],
  173.                     self::AUTHENTICATION_USER_KEY
  174.                 )
  175.             );
  176.     }
  177. }