-
-
Notifications
You must be signed in to change notification settings - Fork 611
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Based on #1005
- Loading branch information
Showing
26 changed files
with
716 additions
and
6 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,59 @@ | ||
<?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 | ||
{ | ||
$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->tokenManager->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,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) | ||
} | ||
} | ||
} |
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,31 @@ | ||
<?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\BlockedTokenManager"> | ||
<argument type="service" id="lexik_jwt_authentication.blocklist_token.cache"/> | ||
</service> | ||
|
||
</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 |
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 |
---|---|---|
|
@@ -40,4 +40,4 @@ public function authenticate(Request $request): PassportInterface | |
} | ||
} | ||
} | ||
} | ||
} |
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,74 @@ | ||
<?php | ||
|
||
namespace Lexik\Bundle\JWTAuthenticationBundle\Services; | ||
|
||
use DateInterval; | ||
use DateTimeImmutable; | ||
use DateTimeZone; | ||
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 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; | ||
} | ||
|
||
/** | ||
* @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']); | ||
} | ||
} |
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
Oops, something went wrong.