-
-
Notifications
You must be signed in to change notification settings - Fork 610
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
23 changed files
with
740 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
<?php | ||
|
||
namespace Lexik\Bundle\JWTAuthenticationBundle\EventListener; | ||
|
||
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; | ||
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException; | ||
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface; | ||
use Lexik\Bundle\JWTAuthenticationBundle\Services\CacheItemPoolBlockedTokenManager; | ||
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 | ||
{ | ||
private $blockedTokenManager; | ||
private $tokenExtractor; | ||
private $jwtManager; | ||
|
||
public function __construct( | ||
BlockedTokenManagerInterface $blockedTokenManager, | ||
TokenExtractorInterface $tokenExtractor, | ||
JWTTokenManagerInterface $jwtManager | ||
) { | ||
$this->blockedTokenManager = $blockedTokenManager; | ||
$this->tokenExtractor = $tokenExtractor; | ||
$this->jwtManager = $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->blockedTokenManager->add($payload); | ||
} catch (MissingClaimException $e) { | ||
// We can't block a token missing the claims our system requires, so silently ignore this one | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<?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\BlockedTokenManagerInterface; | ||
|
||
class RejectBlockedTokenListener | ||
{ | ||
private $blockedTokenManager; | ||
|
||
public function __construct(BlockedTokenManagerInterface $blockedTokenManager) | ||
{ | ||
$this->blockedTokenManager = $blockedTokenManager; | ||
} | ||
|
||
/** | ||
* @throws InvalidTokenException if the JWT is blocked | ||
*/ | ||
public function __invoke(JWTAuthenticatedEvent $event): void | ||
{ | ||
try { | ||
if ($this->blockedTokenManager->has($event->getPayload())) { | ||
throw new InvalidTokenException('JWT blocked'); | ||
} | ||
} catch (MissingClaimException $e) { | ||
// Do nothing if the required claims do not exist on the payload (older JWTs won't have the "jti" claim the manager requires) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
<?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\BlockedToken\CacheItemPoolBlockedTokenManager"> | ||
<argument type="service" id="lexik_jwt_authentication.blocklist_token.cache"/> | ||
</service> | ||
|
||
<service id="Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface" alias="lexik_jwt_authentication.blocked_token_manager" /> | ||
|
||
</services> | ||
|
||
</container> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
66 changes: 66 additions & 0 deletions
66
Services/BlockedToken/CacheItemPoolBlockedTokenManager.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
<?php | ||
|
||
namespace Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedToken; | ||
|
||
use DateInterval; | ||
use DateTimeImmutable; | ||
use DateTimeZone; | ||
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingClaimException; | ||
use Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface; | ||
use Psr\Cache\CacheItemPoolInterface; | ||
|
||
class CacheItemPoolBlockedTokenManager implements BlockedTokenManagerInterface | ||
{ | ||
private $cacheJwt; | ||
|
||
public function __construct(CacheItemPoolInterface $cacheJwt) | ||
{ | ||
$this->cacheJwt = $cacheJwt; | ||
} | ||
|
||
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; | ||
} | ||
|
||
public function has(array $payload): bool | ||
{ | ||
if (!isset($payload['jti'])) { | ||
throw new MissingClaimException('jti'); | ||
} | ||
|
||
return $this->cacheJwt->hasItem($payload['jti']); | ||
} | ||
|
||
public function remove(array $payload): void | ||
{ | ||
if (!isset($payload['jti'])) { | ||
throw new MissingClaimException('jti'); | ||
} | ||
|
||
$this->cacheJwt->deleteItem($payload['jti']); | ||
} | ||
} |
Oops, something went wrong.