Skip to content

Commit 984e5bb

Browse files
committed
Fix lexik#1137 Invalidate token
Based on lexik#1005
1 parent 2db3658 commit 984e5bb

20 files changed

+506
-4
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

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
if ($event->getException() instanceof DisabledException) {
27+
$this->blockTokenFromRequest($event->getRequest());
28+
}
29+
}
30+
31+
public function onLogout(LogoutEvent $event): void
32+
{
33+
$this->blockTokenFromRequest($event->getRequest());
34+
}
35+
36+
private function blockTokenFromRequest(Request $request): void
37+
{
38+
$token = $this->tokenExtractor->extract($request);
39+
40+
if ($token === false) {
41+
// There's nothing to block if the token isn't in the request
42+
return;
43+
}
44+
45+
try {
46+
$payload = $this->jwtManager->parse($token);
47+
} catch (JWTDecodeFailureException) {
48+
// Ignore decode failures, this would mean the token is invalid anyway
49+
return;
50+
}
51+
52+
try {
53+
$this->tokenManager->add($payload);
54+
} catch (MissingClaimException) {
55+
// We can't block a token missing the claims our system requires, so silently ignore this one
56+
}
57+
}
58+
}
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+
}

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>

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

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\Services;
4+
5+
use DateTime;
6+
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException;
7+
use Psr\Cache\CacheItemPoolInterface;
8+
9+
class BlockedTokenManager
10+
{
11+
private $cacheJwt;
12+
13+
public function __construct(CacheItemPoolInterface $cacheJwt)
14+
{
15+
$this->cacheJwt = $cacheJwt;
16+
}
17+
18+
/**
19+
* @throws MissingClaimException if required claims do not exist in the payload
20+
*/
21+
public function add(array $payload): bool
22+
{
23+
if (!isset($payload['exp'])) {
24+
throw new MissingClaimException('exp');
25+
}
26+
27+
$expiration = new DateTime('@' . $payload['exp'], new \DateTimeZone('UTC'));
28+
$now = new DateTime(timezone: 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 = (clone $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+
/**
50+
* @throws MissingClaimException if required claims do not exist in the payload
51+
*/
52+
public function has(array $payload): bool
53+
{
54+
if (!isset($payload['jti'])) {
55+
throw new MissingClaimException('jti');
56+
}
57+
58+
return $this->cacheJwt->hasItem($payload['jti']);
59+
}
60+
61+
/**
62+
* @throws MissingClaimException if required claims do not exist in the payload
63+
*/
64+
public function remove(array $payload): void
65+
{
66+
if (!isset($payload['jti'])) {
67+
throw new MissingClaimException('jti');
68+
}
69+
70+
$this->cacheJwt->deleteItem($payload['jti']);
71+
}
72+
}

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) {
+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional;
4+
5+
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager;
6+
use Symfony\Component\HttpFoundation\Response;
7+
8+
class BlocklistTokenTest extends TestCase
9+
{
10+
public function testShouldInvalidateTokenOnLogoutWhenBlockListTokenIsEnabled()
11+
{
12+
static::$client = static::createClient(['test_case' => 'BlockListToken']);
13+
14+
$token = static::getAuthenticatedToken();
15+
16+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
17+
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');
18+
19+
static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
20+
static::assertResponseStatusCodeSame(Response::HTTP_FOUND);
21+
22+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
23+
static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token');
24+
25+
$responseBody = json_decode(static::$client->getResponse()->getContent(), true);
26+
$this->assertEquals('Invalid JWT Token', $responseBody['message']);
27+
}
28+
29+
public function testShouldAddJtiWhenBlockListTokenIsEnabled()
30+
{
31+
static::$client = static::createClient(['test_case' => 'BlockListToken']);
32+
33+
$token = static::getAuthenticatedToken();
34+
/** @var JWTManager $jwtManager */
35+
$jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
36+
$payload = $jwtManager->parse($token);
37+
self::assertNotEmpty($payload['jti']);
38+
}
39+
40+
public function testShouldInvalidateTokenOnLogoutWhenBlockListTokenIsEnabledAndWhenUsingCustomLogout()
41+
{
42+
static::$client = static::createClient(['test_case' => 'BlockListToken']);
43+
44+
$token = static::getAuthenticatedToken();
45+
46+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
47+
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');
48+
49+
static::$client->jsonRequest('GET', '/api/logout_custom', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
50+
static::assertResponseStatusCodeSame(Response::HTTP_OK);
51+
52+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
53+
static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token');
54+
55+
$responseBody = json_decode(static::$client->getResponse()->getContent(), true);
56+
$this->assertEquals('Invalid JWT Token', $responseBody['message']);
57+
}
58+
59+
public function testShouldNotInvalidateTokenOnLogoutWhenBlockListTokenIsDisabled()
60+
{
61+
static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']);
62+
$token = static::getAuthenticatedToken();
63+
64+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
65+
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');
66+
67+
static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
68+
static::assertResponseStatusCodeSame(Response::HTTP_FOUND);
69+
70+
static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
71+
static::assertResponseStatusCodeSame(Response::HTTP_OK, 'Logout should NOT invalidate token when block list config is not enabled');
72+
}
73+
74+
public function testShouldNotAddJtiWhenBlockListTokenIsDisabled()
75+
{
76+
static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']);
77+
78+
$token = static::getAuthenticatedToken();
79+
/** @var JWTManager $jwtManager */
80+
$jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
81+
$payload = $jwtManager->parse($token);
82+
self::assertArrayNotHasKey('jti', $payload);
83+
}
84+
}

Tests/Functional/Bundle/Controller/TestController.php

+17
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller;
44

5+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
56
use Symfony\Component\HttpFoundation\JsonResponse;
7+
use Symfony\Component\HttpFoundation\Request;
8+
use Symfony\Component\HttpFoundation\Response;
9+
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
610
use Symfony\Component\Security\Core\User\UserInterface;
11+
use Symfony\Component\Security\Http\Event\LogoutEvent;
712

813
class TestController
914
{
@@ -15,4 +20,16 @@ public function securedAction(UserInterface $user)
1520
'username' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(),
1621
]);
1722
}
23+
24+
public function logoutAction()
25+
{
26+
throw new \Exception('This should never be reached!');
27+
}
28+
29+
public function logoutCustomAction(Request $request, EventDispatcherInterface $eventDispatcher, TokenStorageInterface $tokenStorage)
30+
{
31+
$eventDispatcher->dispatch(new LogoutEvent($request, $tokenStorage->getToken()));
32+
33+
return new Response();
34+
}
1835
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
imports:
2+
- { resource: '../base_config.yml' }
3+
4+
lexik_jwt_authentication:
5+
blocklist_token:
6+
enabled: true

0 commit comments

Comments
 (0)