Skip to content

Commit 71ff07b

Browse files
ldasptLudovic Daoudal
authored and
Ludovic Daoudal
committed
Fix lexik#1137 Invalidate token
Based on lexik#1005
1 parent 2db3658 commit 71ff07b

26 files changed

+716
-6
lines changed

DependencyInjection/Configuration.php

+10
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ public function getConfigTreeBuilder(): TreeBuilder
134134
->end()
135135
->end()
136136
->end()
137+
->arrayNode('blocklist_token')
138+
->addDefaultsIfNotSet()
139+
->canBeEnabled()
140+
->children()
141+
->scalarNode('cache')
142+
->defaultValue('cache.app')
143+
->info('Storage to track blocked tokens')
144+
->end()
145+
->end()
146+
->end()
137147
->end();
138148

139149
return $treeBuilder;

DependencyInjection/LexikJWTAuthenticationExtension.php

+6
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ public function load(array $configs, ContainerBuilder $container): void
157157
->replaceArgument(2, $config['api_platform']['username_path'] ?? null)
158158
->replaceArgument(3, $config['api_platform']['password_path'] ?? null);
159159
}
160+
161+
if ($this->isConfigEnabled($container, $config['blocklist_token'])) {
162+
$loader->load('blocklist_token.xml');
163+
$blockListTokenConfig = $config['blocklist_token'];
164+
$container->setAlias('lexik_jwt_authentication.blocklist_token.cache', $blockListTokenConfig['cache']);
165+
}
160166
}
161167

162168
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

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

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
@@ -30,6 +30,7 @@ The bulk of the documentation is stored in the [`Resources/doc`](Resources/doc/i
3030
* [Creating JWT tokens programmatically](Resources/doc/7-manual-token-creation.rst)
3131
* [A database-less user provider](Resources/doc/8-jwt-user-provider.rst)
3232
* [Accessing the authenticated JWT token](Resources/doc/9-access-authenticated-jwt-token.rst)
33+
* [Invalidate token on logout](Resources/doc/10-invalidate-token-on-logout.rst)
3334

3435
Community Support
3536
-----------------

Resources/config/blocklist_token.xml

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\BlockedTokenManager">
26+
<argument type="service" id="lexik_jwt_authentication.blocklist_token.cache"/>
27+
</service>
28+
29+
</services>
30+
31+
</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

Security/Authenticator/ForwardCompatAuthenticatorTrait.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ public function authenticate(Request $request): PassportInterface
4040
}
4141
}
4242
}
43-
}
43+
}

Services/BlockedTokenManager.php

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\Services;
4+
5+
use DateInterval;
6+
use DateTimeImmutable;
7+
use DateTimeZone;
8+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
9+
use Psr\Cache\CacheItemPoolInterface;
10+
11+
class BlockedTokenManager
12+
{
13+
private $cacheJwt;
14+
15+
public function __construct(CacheItemPoolInterface $cacheJwt)
16+
{
17+
$this->cacheJwt = $cacheJwt;
18+
}
19+
20+
/**
21+
* @throws MissingClaimException if required claims do not exist in the payload
22+
*/
23+
public function add(array $payload): bool
24+
{
25+
if (!isset($payload['exp'])) {
26+
throw new MissingClaimException('exp');
27+
}
28+
29+
$expiration = new DateTimeImmutable('@' . $payload['exp'], new DateTimeZone('UTC'));
30+
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
31+
32+
// If the token is already expired, there's no point in adding it to storage
33+
if ($expiration <= $now) {
34+
return false;
35+
}
36+
37+
$cacheExpiration = $expiration->add(new DateInterval('PT5M'));
38+
39+
if (!isset($payload['jti'])) {
40+
throw new MissingClaimException('jti');
41+
}
42+
43+
$cacheItem = $this->cacheJwt->getItem($payload['jti']);
44+
$cacheItem->set([]);
45+
$cacheItem->expiresAt($cacheExpiration);
46+
$this->cacheJwt->save($cacheItem);
47+
48+
return true;
49+
}
50+
51+
/**
52+
* @throws MissingClaimException if required claims do not exist in the payload
53+
*/
54+
public function has(array $payload): bool
55+
{
56+
if (!isset($payload['jti'])) {
57+
throw new MissingClaimException('jti');
58+
}
59+
60+
return $this->cacheJwt->hasItem($payload['jti']);
61+
}
62+
63+
/**
64+
* @throws MissingClaimException if required claims do not exist in the payload
65+
*/
66+
public function remove(array $payload): void
67+
{
68+
if (!isset($payload['jti'])) {
69+
throw new MissingClaimException('jti');
70+
}
71+
72+
$this->cacheJwt->deleteItem($payload['jti']);
73+
}
74+
}

Services/JWTManager.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ public function parse(string $jwtToken): array
144144
*/
145145
protected function addUserIdentityToPayload(UserInterface $user, array &$payload)
146146
{
147-
$accessor = PropertyAccess::createPropertyAccessor();
147+
$accessor = PropertyAccess::createPropertyAccessor();
148148
$identityField = $this->userIdClaim ?: $this->userIdentityField;
149149

150150
if ($user instanceof InMemoryUser && 'username' === $identityField) {

0 commit comments

Comments
 (0)