Skip to content

Commit

Permalink
Fix #1137 Invalidate token
Browse files Browse the repository at this point in the history
Based on #1005
  • Loading branch information
ldaspt authored and Ludovic Daoudal committed Nov 27, 2023
1 parent 3b02f52 commit 59a9c58
Show file tree
Hide file tree
Showing 26 changed files with 716 additions and 6 deletions.
10 changes: 10 additions & 0 deletions DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()
->arrayNode('blocklist_token')
->addDefaultsIfNotSet()
->canBeEnabled()
->children()
->scalarNode('cache')
->defaultValue('cache.app')
->info('Storage to track blocked tokens')
->end()
->end()
->end()
->end();

return $treeBuilder;
Expand Down
6 changes: 6 additions & 0 deletions DependencyInjection/LexikJWTAuthenticationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ public function load(array $configs, ContainerBuilder $container): void
->replaceArgument(2, $config['api_platform']['username_path'] ?? null)
->replaceArgument(3, $config['api_platform']['password_path'] ?? null);
}

if ($this->isConfigEnabled($container, $config['blocklist_token'])) {
$loader->load('blocklist_token.xml');
$blockListTokenConfig = $config['blocklist_token'];
$container->setAlias('lexik_jwt_authentication.blocklist_token.cache', $blockListTokenConfig['cache']);
}
}

private function createTokenExtractors(ContainerBuilder $container, array $tokenExtractorsConfig): array
Expand Down
19 changes: 19 additions & 0 deletions EventListener/AddClaimsToJWTListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;

class AddClaimsToJWTListener
{
public function __invoke(JWTCreatedEvent $event): void
{
$data = $event->getData();

if (!isset($data['jti'])) {
$data['jti'] = bin2hex(random_bytes(16));

$event->setData($data);
}
}
}
59 changes: 59 additions & 0 deletions EventListener/BlockJWTListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManager;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent;

class BlockJWTListener
{
public function __construct(
private BlockedTokenManager $tokenManager,
private TokenExtractorInterface $tokenExtractor,
private JWTTokenManagerInterface $jwtManager,
) {
}

public function onLoginFailure(LoginFailureEvent $event): void
{
$exception = $event->getException();
if (($exception instanceof DisabledException) || ($exception->getPrevious() instanceof DisabledException)) {
$this->blockTokenFromRequest($event->getRequest());
}
}

public function onLogout(LogoutEvent $event): void
{
$this->blockTokenFromRequest($event->getRequest());
}

private function blockTokenFromRequest(Request $request): void
{
$token = $this->tokenExtractor->extract($request);

if ($token === false) {
// There's nothing to block if the token isn't in the request
return;
}

try {
$payload = $this->jwtManager->parse($token);
} catch (JWTDecodeFailureException $e) {
// Ignore decode failures, this would mean the token is invalid anyway
return;
}

try {
$this->tokenManager->add($payload);
} catch (MissingClaimException $e) {
// We can't block a token missing the claims our system requires, so silently ignore this one
}
}
}
29 changes: 29 additions & 0 deletions EventListener/RejectBlockedTokenListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManager;

class RejectBlockedTokenListener
{
public function __construct(private BlockedTokenManager $tokenManager)
{
}

/**
* @throws InvalidTokenException if the JWT is blocked
*/
public function __invoke(JWTAuthenticatedEvent $event): void
{
try {
if ($this->tokenManager->has($event->getPayload())) {
throw new InvalidTokenException('JWT blocked');
}
} catch (MissingClaimException) {
// Do nothing if the required claims do not exist on the payload (older JWTs won't have the "jti" claim the manager requires)
}
}
}
15 changes: 15 additions & 0 deletions Exception/MissingClaimException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Exception;

use Throwable;

class MissingClaimException extends JWTFailureException
{
public function __construct(
string $claim,
Throwable $previous = null
) {
parent::__construct('missing_claim', sprintf('Missing required "%s" claim on JWT payload.', $claim), $previous);
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The bulk of the documentation is stored in the [`Resources/doc`](Resources/doc/i
* [Creating JWT tokens programmatically](Resources/doc/7-manual-token-creation.rst)
* [A database-less user provider](Resources/doc/8-jwt-user-provider.rst)
* [Accessing the authenticated JWT token](Resources/doc/9-access-authenticated-jwt-token.rst)
* [Invalidate token on logout](Resources/doc/10-invalidate-token-on-logout.rst)

Community Support
-----------------
Expand Down
31 changes: 31 additions & 0 deletions Resources/config/blocklist_token.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="lexik_jwt_authentication.event_listener.add_claims_to_jwt_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\AddClaimsToJWTListener">
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_jwt_created" />
</service>

<service id="lexik_jwt_authentication.event_listener.block_jwt_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\BlockJWTListener">
<argument type="service" id="lexik_jwt_authentication.blocked_token_manager"/>
<argument type="service" id="lexik_jwt_authentication.extractor.chain_extractor"/>
<argument type="service" id="lexik_jwt_authentication.jwt_manager"/>
<tag name="kernel.event_listener" event="Symfony\Component\Security\Http\Event\LoginFailureEvent" method="onLoginFailure" dispatcher="event_dispatcher"/>
<tag name="kernel.event_listener" event="Symfony\Component\Security\Http\Event\LogoutEvent" method="onLogout" dispatcher="event_dispatcher"/>
</service>

<service id="lexik_jwt_authentication.event_listener.reject_blocked_token_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\RejectBlockedTokenListener">
<argument type="service" id="lexik_jwt_authentication.blocked_token_manager"/>
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_jwt_authenticated"/>
</service>

<service id="lexik_jwt_authentication.blocked_token_manager" class="Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManager">
<argument type="service" id="lexik_jwt_authentication.blocklist_token.cache"/>
</service>

</services>

</container>
5 changes: 5 additions & 0 deletions Resources/doc/1-configuration-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ Full default configuration
# remove the token from the response body when using cookies
remove_token_from_body_when_cookies_used: true
# invalidate the token on logout by storing it in the cache
blocklist_token:
enabled: true
cache: cache.app
Encoder configuration
~~~~~~~~~~~~~~~~~~~~~

Expand Down
89 changes: 89 additions & 0 deletions Resources/doc/10-invalidate-token.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
Invalidate token
================

The token blocklist relies on the ``jti`` claim, a standard claim designed for tracking and revoking JWTs. `"jti" (JWT ID) Claim <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7>`_

The blocklist storage utilizes a cache implementing ``Psr\Cache\CacheItemPoolInterface``. The cache stores the ``jti`` of the blocked token to the cache, and the cache item expires after the "exp" (expiration time) claim of the token

Configuration
~~~~~~~~~~~~~

To configure token blocklist, update your `lexik_jwt_authentication.yaml` file:

.. code-block:: yaml
# config/packages/lexik_jwt_authentication.yaml
# ...
lexik_jwt_authentication:
# ...
# invalidate the token on logout by storing it in the cache
blocklist_token:
enabled: true
cache: cache.app
Enabling ``blocklist_token`` causes the activation of listeners:

* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\EventListenerAddClaimsToJWTListener`` which adds a ``jti`` claim if not present when the token is created

* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\BlockJWTListener`` which blocks JWTs on logout (``Symfony\Component\Security\Http\Event\LogoutEvent``)
or on login failure due to the user not being enabled (``Symfony\Component\Security\Core\Exception\DisabledException``)

* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\RejectBlockedTokenListener`` which rejects blocked tokens during authentication

To block JWTs on logout, you must either activate logout in the firewall configuration or do it programmatically

* by firewall configuration

.. code-block:: yaml
# config/packages/security.yaml
security:
enable_authenticator_manager: true
firewalls:
api:
...
jwt: ~
logout:
path: app_logout
* programmatically in a controller action

.. code-block:: php
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
//...
class SecurityController
{
//...
public function logout(Request $request, EventDispatcherInterface $eventDispatcher, TokenStorageInterface $tokenStorage)
{
$eventDispatcher->dispatch(new LogoutEvent($request, $tokenStorage->getToken()));
return new JsonResponse();
}
]
Refer to `Symfony logging out <https://symfony.com/doc/current/security.html#logging-out>`_ for more details.

Changing blocklist storage
~~~~~~~~~~~~~~~~~~~~~~~~~~

To change the blocklist storage, refer to `Configuring Cache with FrameworkBundle <https://symfony.com/doc/current/cache.html#configuring-cache-with-frameworkbundle>`_

.. code-block:: yaml
# config/packages/framework.yaml
framework:
# ...
cache:
default_redis_provider: 'redis://localhost'
pools:
block_list_token_cache_pool:
adapter: cache.adapter.redis
# ...
blocklist_token:
enabled: true
cache: block_list_token_cache_pool
2 changes: 1 addition & 1 deletion Security/Authenticator/ForwardCompatAuthenticatorTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ public function authenticate(Request $request): PassportInterface
}
}
}
}
}
74 changes: 74 additions & 0 deletions Services/BlockedTokenManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services;

use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
use Psr\Cache\CacheItemPoolInterface;

class BlockedTokenManager
{
private $cacheJwt;

public function __construct(CacheItemPoolInterface $cacheJwt)
{
$this->cacheJwt = $cacheJwt;
}

/**
* @throws MissingClaimException if required claims do not exist in the payload
*/
public function add(array $payload): bool
{
if (!isset($payload['exp'])) {
throw new MissingClaimException('exp');
}

$expiration = new DateTimeImmutable('@' . $payload['exp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));

// If the token is already expired, there's no point in adding it to storage
if ($expiration <= $now) {
return false;
}

$cacheExpiration = $expiration->add(new DateInterval('PT5M'));

if (!isset($payload['jti'])) {
throw new MissingClaimException('jti');
}

$cacheItem = $this->cacheJwt->getItem($payload['jti']);
$cacheItem->set([]);
$cacheItem->expiresAt($cacheExpiration);
$this->cacheJwt->save($cacheItem);

return true;
}

/**
* @throws MissingClaimException if required claims do not exist in the payload
*/
public function has(array $payload): bool
{
if (!isset($payload['jti'])) {
throw new MissingClaimException('jti');
}

return $this->cacheJwt->hasItem($payload['jti']);
}

/**
* @throws MissingClaimException if required claims do not exist in the payload
*/
public function remove(array $payload): void
{
if (!isset($payload['jti'])) {
throw new MissingClaimException('jti');
}

$this->cacheJwt->deleteItem($payload['jti']);
}
}
2 changes: 1 addition & 1 deletion Services/JWTManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public function parse(string $jwtToken): array
*/
protected function addUserIdentityToPayload(UserInterface $user, array &$payload)
{
$accessor = PropertyAccess::createPropertyAccessor();
$accessor = PropertyAccess::createPropertyAccessor();
$identityField = $this->userIdClaim ?: $this->userIdentityField;

if ($user instanceof InMemoryUser && 'username' === $identityField) {
Expand Down
Loading

0 comments on commit 59a9c58

Please sign in to comment.