Skip to content

Commit

Permalink
Fix lexik#1137 Invalidate token
Browse files Browse the repository at this point in the history
Based on lexik#1005
  • Loading branch information
ldaspt committed Nov 9, 2023
1 parent 2db3658 commit 1af0826
Show file tree
Hide file tree
Showing 20 changed files with 507 additions and 4 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);
}
}
}
58 changes: 58 additions & 0 deletions EventListener/BlockJWTListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?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
{
if ($event->getException() 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) {
// Ignore decode failures, this would mean the token is invalid anyway
return;
}

try {
$this->tokenManager->add($payload);
} catch (MissingClaimException) {
// 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);
}
}
32 changes: 32 additions & 0 deletions Resources/config/blocklist_token.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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"/>
<!-- TODO rendre configurable le dispatcher https://symfony.com/blog/new-in-symfony-5-1-simpler-logout-customization -->
<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>
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
}
}
}
}
}
72 changes: 72 additions & 0 deletions Services/BlockedTokenManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services;

use DateTime;
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 DateTime('@' . $payload['exp'], new \DateTimeZone('UTC'));
$now = new DateTime(timezone: new \DateTimeZone('UTC'));

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

$cacheExpiration = (clone $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
84 changes: 84 additions & 0 deletions Tests/Functional/BlocklistTokenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional;

use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager;
use Symfony\Component\HttpFoundation\Response;

class BlocklistTokenTest extends TestCase
{
public function testShouldInvalidateTokenOnLogoutWhenBlockListTokenIsEnabled()
{
static::$client = static::createClient(['test_case' => 'BlockListToken']);

$token = static::getAuthenticatedToken();

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');

static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_FOUND);

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token');

$responseBody = json_decode(static::$client->getResponse()->getContent(), true);
$this->assertEquals('Invalid JWT Token', $responseBody['message']);
}

public function testShouldAddJtiWhenBlockListTokenIsEnabled()
{
static::$client = static::createClient(['test_case' => 'BlockListToken']);

$token = static::getAuthenticatedToken();
/** @var JWTManager $jwtManager */
$jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
$payload = $jwtManager->parse($token);
self::assertNotEmpty($payload['jti']);
}

public function testShouldInvalidateTokenOnLogoutWhenBlockListTokenIsEnabledAndWhenUsingCustomLogout()
{
static::$client = static::createClient(['test_case' => 'BlockListToken']);

$token = static::getAuthenticatedToken();

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');

static::$client->jsonRequest('GET', '/api/logout_custom', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_OK);

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token');

$responseBody = json_decode(static::$client->getResponse()->getContent(), true);
$this->assertEquals('Invalid JWT Token', $responseBody['message']);
}

public function testShouldNotInvalidateTokenOnLogoutWhenBlockListTokenIsDisabled()
{
static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']);
$token = static::getAuthenticatedToken();

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseIsSuccessful('Pre condition - a valid token should be able to contact the api');

static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_FOUND);

static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]);
static::assertResponseStatusCodeSame(Response::HTTP_OK, 'Logout should NOT invalidate token when block list config is not enabled');
}

public function testShouldNotAddJtiWhenBlockListTokenIsDisabled()
{
static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']);

$token = static::getAuthenticatedToken();
/** @var JWTManager $jwtManager */
$jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager');
$payload = $jwtManager->parse($token);
self::assertArrayNotHasKey('jti', $payload);
}
}
17 changes: 17 additions & 0 deletions Tests/Functional/Bundle/Controller/TestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

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

use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;

class TestController
{
Expand All @@ -15,4 +20,16 @@ public function securedAction(UserInterface $user)
'username' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(),
]);
}

public function logoutAction()
{
throw new \Exception('This should never be reached!');
}

public function logoutCustomAction(Request $request, EventDispatcherInterface $eventDispatcher, TokenStorageInterface $tokenStorage)
{
$eventDispatcher->dispatch(new LogoutEvent($request, $tokenStorage->getToken()));

return new Response();
}
}
6 changes: 6 additions & 0 deletions Tests/Functional/app/config/BlockListToken/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
imports:
- { resource: '../base_config.yml' }

lexik_jwt_authentication:
blocklist_token:
enabled: true
Loading

0 comments on commit 1af0826

Please sign in to comment.