Skip to content

Commit 3785c0f

Browse files
committed
feature #1170 feat: Invalidate a JWT token (ldaspt)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- feat: Invalidate a JWT token This PR adds support for invalidating a JWT token #1137. The code comes mainly from the discussion #1005 (reply in thread) Thanks to `@mbabker` I think that the PR meets the needs mentioned in #1137 (comment) > > * This feature must be opt-in > * Tokens should be given a jti claim whose value should be the only thing persisted: if the feature is enabled and a token's jti exists in the blocklist then that token must be rejected. > * Feature detection should not be only based on the presence of the jti, as it mght break existing code that relies on this claim today. > * The blacklist term should be avoided, alternative such as blocklist should be preferred :) > * We will probably need a simple abstraction for the blocklist storage. A very limited set of built-in implementations should be provided, not necessarily as part of the first iteration (i.e. it can wait til another PR). > Commits ------- bb8aa6d feat: Invalidate a JWT token
2 parents 3d7359d + bb8aa6d commit 3785c0f

23 files changed

+740
-2
lines changed

DependencyInjection/Configuration.php

+10
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ public function getConfigTreeBuilder(): TreeBuilder
247247
->end()
248248
->end()
249249
->end()
250+
->arrayNode('blocklist_token')
251+
->addDefaultsIfNotSet()
252+
->canBeEnabled()
253+
->children()
254+
->scalarNode('cache')
255+
->defaultValue('cache.app')
256+
->info('Storage to track blocked tokens')
257+
->end()
258+
->end()
259+
->end()
250260
->end()
251261
->end();
252262

DependencyInjection/LexikJWTAuthenticationExtension.php

+6
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ public function load(array $configs, ContainerBuilder $container): void
169169
}
170170

171171
$this->processWithWebTokenConfig($config, $container, $loader);
172+
173+
if ($this->isConfigEnabled($container, $config['blocklist_token'])) {
174+
$loader->load('blocklist_token.xml');
175+
$blockListTokenConfig = $config['blocklist_token'];
176+
$container->setAlias('lexik_jwt_authentication.blocklist_token.cache', $blockListTokenConfig['cache']);
177+
}
172178
}
173179

174180
private function createTokenExtractors(ContainerBuilder $container, array $tokenExtractorsConfig): array
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;
4+
5+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
6+
7+
class AddClaimsToJWTListener
8+
{
9+
public function __invoke(JWTCreatedEvent $event): void
10+
{
11+
$data = $event->getData();
12+
13+
if (!isset($data['jti'])) {
14+
$data['jti'] = bin2hex(random_bytes(16));
15+
16+
$event->setData($data);
17+
}
18+
}
19+
}

EventListener/BlockJWTListener.php

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;
4+
5+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;
6+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
7+
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface;
8+
use Lexik\Bundle\JWTAuthenticationBundle\Services\CacheItemPoolBlockedTokenManager;
9+
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
10+
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
11+
use Symfony\Component\HttpFoundation\Request;
12+
use Symfony\Component\Security\Core\Exception\DisabledException;
13+
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
14+
use Symfony\Component\Security\Http\Event\LogoutEvent;
15+
16+
class BlockJWTListener
17+
{
18+
private $blockedTokenManager;
19+
private $tokenExtractor;
20+
private $jwtManager;
21+
22+
public function __construct(
23+
BlockedTokenManagerInterface $blockedTokenManager,
24+
TokenExtractorInterface $tokenExtractor,
25+
JWTTokenManagerInterface $jwtManager
26+
) {
27+
$this->blockedTokenManager = $blockedTokenManager;
28+
$this->tokenExtractor = $tokenExtractor;
29+
$this->jwtManager = $jwtManager;
30+
}
31+
32+
public function onLoginFailure(LoginFailureEvent $event): void
33+
{
34+
$exception = $event->getException();
35+
if (($exception instanceof DisabledException) || ($exception->getPrevious() instanceof DisabledException)) {
36+
$this->blockTokenFromRequest($event->getRequest());
37+
}
38+
}
39+
40+
public function onLogout(LogoutEvent $event): void
41+
{
42+
$this->blockTokenFromRequest($event->getRequest());
43+
}
44+
45+
private function blockTokenFromRequest(Request $request): void
46+
{
47+
$token = $this->tokenExtractor->extract($request);
48+
49+
if ($token === false) {
50+
// There's nothing to block if the token isn't in the request
51+
return;
52+
}
53+
54+
try {
55+
$payload = $this->jwtManager->parse($token);
56+
} catch (JWTDecodeFailureException $e) {
57+
// Ignore decode failures, this would mean the token is invalid anyway
58+
return;
59+
}
60+
61+
try {
62+
$this->blockedTokenManager->add($payload);
63+
} catch (MissingClaimException $e) {
64+
// We can't block a token missing the claims our system requires, so silently ignore this one
65+
}
66+
}
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener;
4+
5+
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
6+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidTokenException;
7+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
8+
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface;
9+
10+
class RejectBlockedTokenListener
11+
{
12+
private $blockedTokenManager;
13+
14+
public function __construct(BlockedTokenManagerInterface $blockedTokenManager)
15+
{
16+
$this->blockedTokenManager = $blockedTokenManager;
17+
}
18+
19+
/**
20+
* @throws InvalidTokenException if the JWT is blocked
21+
*/
22+
public function __invoke(JWTAuthenticatedEvent $event): void
23+
{
24+
try {
25+
if ($this->blockedTokenManager->has($event->getPayload())) {
26+
throw new InvalidTokenException('JWT blocked');
27+
}
28+
} catch (MissingClaimException $e) {
29+
// Do nothing if the required claims do not exist on the payload (older JWTs won't have the "jti" claim the manager requires)
30+
}
31+
}
32+
}

Exception/MissingClaimException.php

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\Exception;
4+
5+
use Throwable;
6+
7+
class MissingClaimException extends JWTFailureException
8+
{
9+
public function __construct(
10+
string $claim,
11+
Throwable $previous = null
12+
) {
13+
parent::__construct('missing_claim', sprintf('Missing required "%s" claim on JWT payload.', $claim), $previous);
14+
}
15+
}

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ The bulk of the documentation is stored in the [`Resources/doc`](Resources/doc/i
2828
* [Creating JWT tokens programmatically](Resources/doc/7-manual-token-creation.rst)
2929
* [A database-less user provider](Resources/doc/8-jwt-user-provider.rst)
3030
* [Accessing the authenticated JWT token](Resources/doc/9-access-authenticated-jwt-token.rst)
31+
* [Invalidate token on logout](Resources/doc/10-invalidate-token-on-logout.rst)
3132

3233
Community Support
3334
-----------------

Resources/config/blocklist_token.xml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<service id="lexik_jwt_authentication.event_listener.add_claims_to_jwt_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\AddClaimsToJWTListener">
9+
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_jwt_created" />
10+
</service>
11+
12+
<service id="lexik_jwt_authentication.event_listener.block_jwt_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\BlockJWTListener">
13+
<argument type="service" id="lexik_jwt_authentication.blocked_token_manager"/>
14+
<argument type="service" id="lexik_jwt_authentication.extractor.chain_extractor"/>
15+
<argument type="service" id="lexik_jwt_authentication.jwt_manager"/>
16+
<tag name="kernel.event_listener" event="Symfony\Component\Security\Http\Event\LoginFailureEvent" method="onLoginFailure" dispatcher="event_dispatcher"/>
17+
<tag name="kernel.event_listener" event="Symfony\Component\Security\Http\Event\LogoutEvent" method="onLogout" dispatcher="event_dispatcher"/>
18+
</service>
19+
20+
<service id="lexik_jwt_authentication.event_listener.reject_blocked_token_listener" class="Lexik\Bundle\JWTAuthenticationBundle\EventListener\RejectBlockedTokenListener">
21+
<argument type="service" id="lexik_jwt_authentication.blocked_token_manager"/>
22+
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_jwt_authenticated"/>
23+
</service>
24+
25+
<service id="lexik_jwt_authentication.blocked_token_manager" class="Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedToken\CacheItemPoolBlockedTokenManager">
26+
<argument type="service" id="lexik_jwt_authentication.blocklist_token.cache"/>
27+
</service>
28+
29+
<service id="Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface" alias="lexik_jwt_authentication.blocked_token_manager" />
30+
31+
</services>
32+
33+
</container>

Resources/doc/1-configuration-reference.rst

+5
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ Full default configuration
8686
# remove the token from the response body when using cookies
8787
remove_token_from_body_when_cookies_used: true
8888
89+
# invalidate the token on logout by storing it in the cache
90+
blocklist_token:
91+
enabled: true
92+
cache: cache.app
93+
8994
Encoder configuration
9095
~~~~~~~~~~~~~~~~~~~~~
9196

Resources/doc/10-invalidate-token.rst

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
Invalidate token
2+
================
3+
4+
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>`_
5+
6+
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
7+
8+
Configuration
9+
~~~~~~~~~~~~~
10+
11+
To configure token blocklist, update your `lexik_jwt_authentication.yaml` file:
12+
13+
.. code-block:: yaml
14+
15+
# config/packages/lexik_jwt_authentication.yaml
16+
# ...
17+
lexik_jwt_authentication:
18+
# ...
19+
# invalidate the token on logout by storing it in the cache
20+
blocklist_token:
21+
enabled: true
22+
cache: cache.app
23+
24+
25+
Enabling ``blocklist_token`` causes the activation of listeners:
26+
27+
* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\EventListenerAddClaimsToJWTListener`` which adds a ``jti`` claim if not present when the token is created
28+
29+
* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\BlockJWTListener`` which blocks JWTs on logout (``Symfony\Component\Security\Http\Event\LogoutEvent``)
30+
or on login failure due to the user not being enabled (``Symfony\Component\Security\Core\Exception\DisabledException``)
31+
32+
* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\RejectBlockedTokenListener`` which rejects blocked tokens during authentication
33+
34+
To block JWTs on logout, you must either activate logout in the firewall configuration or do it programmatically
35+
36+
* by firewall configuration
37+
38+
.. code-block:: yaml
39+
# config/packages/security.yaml
40+
security:
41+
enable_authenticator_manager: true
42+
firewalls:
43+
api:
44+
...
45+
jwt: ~
46+
logout:
47+
path: app_logout
48+
49+
* programmatically in a controller action
50+
51+
.. code-block:: php
52+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
53+
use Symfony\Component\HttpFoundation\JsonResponse;
54+
use Symfony\Component\HttpFoundation\Request;
55+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
56+
use Symfony\Component\Security\Http\Event\LogoutEvent;
57+
//...
58+
class SecurityController
59+
{
60+
//...
61+
public function logout(Request $request, EventDispatcherInterface $eventDispatcher, TokenStorageInterface $tokenStorage)
62+
{
63+
$eventDispatcher->dispatch(new LogoutEvent($request, $tokenStorage->getToken()));
64+
65+
return new JsonResponse();
66+
}
67+
]
68+
69+
Refer to `Symfony logging out <https://symfony.com/doc/current/security.html#logging-out>`_ for more details.
70+
71+
Changing blocklist storage
72+
~~~~~~~~~~~~~~~~~~~~~~~~~~
73+
74+
To change the blocklist storage, refer to `Configuring Cache with FrameworkBundle <https://symfony.com/doc/current/cache.html#configuring-cache-with-frameworkbundle>`_
75+
76+
.. code-block:: yaml
77+
78+
# config/packages/framework.yaml
79+
framework:
80+
# ...
81+
cache:
82+
default_redis_provider: 'redis://localhost'
83+
pools:
84+
block_list_token_cache_pool:
85+
adapter: cache.adapter.redis
86+
# ...
87+
blocklist_token:
88+
enabled: true
89+
cache: block_list_token_cache_pool
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedToken;
4+
5+
use DateInterval;
6+
use DateTimeImmutable;
7+
use DateTimeZone;
8+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
9+
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface;
10+
use Psr\Cache\CacheItemPoolInterface;
11+
12+
class CacheItemPoolBlockedTokenManager implements BlockedTokenManagerInterface
13+
{
14+
private $cacheJwt;
15+
16+
public function __construct(CacheItemPoolInterface $cacheJwt)
17+
{
18+
$this->cacheJwt = $cacheJwt;
19+
}
20+
21+
public function add(array $payload): bool
22+
{
23+
if (!isset($payload['exp'])) {
24+
throw new MissingClaimException('exp');
25+
}
26+
27+
$expiration = new DateTimeImmutable('@' . $payload['exp'], new DateTimeZone('UTC'));
28+
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
29+
30+
// If the token is already expired, there's no point in adding it to storage
31+
if ($expiration <= $now) {
32+
return false;
33+
}
34+
35+
$cacheExpiration = $expiration->add(new DateInterval('PT5M'));
36+
37+
if (!isset($payload['jti'])) {
38+
throw new MissingClaimException('jti');
39+
}
40+
41+
$cacheItem = $this->cacheJwt->getItem($payload['jti']);
42+
$cacheItem->set([]);
43+
$cacheItem->expiresAt($cacheExpiration);
44+
$this->cacheJwt->save($cacheItem);
45+
46+
return true;
47+
}
48+
49+
public function has(array $payload): bool
50+
{
51+
if (!isset($payload['jti'])) {
52+
throw new MissingClaimException('jti');
53+
}
54+
55+
return $this->cacheJwt->hasItem($payload['jti']);
56+
}
57+
58+
public function remove(array $payload): void
59+
{
60+
if (!isset($payload['jti'])) {
61+
throw new MissingClaimException('jti');
62+
}
63+
64+
$this->cacheJwt->deleteItem($payload['jti']);
65+
}
66+
}

0 commit comments

Comments
 (0)