From 069b7bb6405e664dfe35aeecbd590eb651862f01 Mon Sep 17 00:00:00 2001 From: ldaspt Date: Mon, 22 Apr 2024 10:23:59 +0200 Subject: [PATCH] Invalidate a JWT token - Adding the jti claim by the JWTManager class instead of doing it via a listener --- .../CollectPayloadEnrichmentsPass.php | 22 ++++++++++ .../LexikJWTAuthenticationExtension.php | 3 ++ EventListener/AddClaimsToJWTListener.php | 19 --------- LexikJWTAuthenticationBundle.php | 2 + Resources/config/blocklist_token.xml | 5 --- Resources/config/jwt_manager.xml | 8 ++++ Resources/doc/10-invalidate-token.rst | 8 ++-- Services/JWTManager.php | 13 +++++- .../PayloadEnrichment/ChainEnrichment.php | 26 ++++++++++++ Services/PayloadEnrichment/NullEnrichment.php | 13 ++++++ .../PayloadEnrichment/RandomJtiEnrichment.php | 14 +++++++ Services/PayloadEnrichmentInterface.php | 10 +++++ .../PayloadEnrichment/ChainEnrichmentTest.php | 34 ++++++++++++++++ .../PayloadEnrichment/NullEnrichmentTest.php | 18 +++++++++ .../RandomJtiEnrichmentTest.php | 20 ++++++++++ Tests/Services/JWTManagerTest.php | 40 +++++++++++++++++++ 16 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 DependencyInjection/Compiler/CollectPayloadEnrichmentsPass.php delete mode 100644 EventListener/AddClaimsToJWTListener.php create mode 100644 Services/PayloadEnrichment/ChainEnrichment.php create mode 100644 Services/PayloadEnrichment/NullEnrichment.php create mode 100644 Services/PayloadEnrichment/RandomJtiEnrichment.php create mode 100644 Services/PayloadEnrichmentInterface.php create mode 100644 Tests/PayloadEnrichment/ChainEnrichmentTest.php create mode 100644 Tests/PayloadEnrichment/NullEnrichmentTest.php create mode 100644 Tests/PayloadEnrichment/RandomJtiEnrichmentTest.php diff --git a/DependencyInjection/Compiler/CollectPayloadEnrichmentsPass.php b/DependencyInjection/Compiler/CollectPayloadEnrichmentsPass.php new file mode 100644 index 00000000..ffbbb50f --- /dev/null +++ b/DependencyInjection/Compiler/CollectPayloadEnrichmentsPass.php @@ -0,0 +1,22 @@ +hasDefinition('lexik_jwt_authentication.payload_enrichment')) { + return; + } + + $container->getDefinition('lexik_jwt_authentication.payload_enrichment') + ->replaceArgument(0, $this->findAndSortTaggedServices('lexik_jwt_authentication.payload_enrichment', $container)); + } +} diff --git a/DependencyInjection/LexikJWTAuthenticationExtension.php b/DependencyInjection/LexikJWTAuthenticationExtension.php index 65aed886..877f80ad 100644 --- a/DependencyInjection/LexikJWTAuthenticationExtension.php +++ b/DependencyInjection/LexikJWTAuthenticationExtension.php @@ -174,6 +174,9 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('blocklist_token.xml'); $blockListTokenConfig = $config['blocklist_token']; $container->setAlias('lexik_jwt_authentication.blocklist_token.cache', $blockListTokenConfig['cache']); + } else { + $container->getDefinition('lexik_jwt_authentication.payload_enrichment.random_jti_enrichment') + ->clearTag('lexik_jwt_authentication.payload_enrichment'); } } diff --git a/EventListener/AddClaimsToJWTListener.php b/EventListener/AddClaimsToJWTListener.php deleted file mode 100644 index bd180b1c..00000000 --- a/EventListener/AddClaimsToJWTListener.php +++ /dev/null @@ -1,19 +0,0 @@ -getData(); - - if (!isset($data['jti'])) { - $data['jti'] = bin2hex(random_bytes(16)); - - $event->setData($data); - } - } -} diff --git a/LexikJWTAuthenticationBundle.php b/LexikJWTAuthenticationBundle.php index e1e133f7..cc61d3f2 100644 --- a/LexikJWTAuthenticationBundle.php +++ b/LexikJWTAuthenticationBundle.php @@ -3,6 +3,7 @@ namespace Lexik\Bundle\JWTAuthenticationBundle; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\ApiPlatformOpenApiPass; +use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\CollectPayloadEnrichmentsPass; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\DeprecateLegacyGuardAuthenticatorPass; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\RegisterLegacyGuardAuthenticatorPass; use Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler\WireGenerateTokenCommandPass; @@ -34,6 +35,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new WireGenerateTokenCommandPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new DeprecateLegacyGuardAuthenticatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ApiPlatformOpenApiPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + $container->addCompilerPass(new CollectPayloadEnrichmentsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); /** @var SecurityExtension $extension */ $extension = $container->getExtension('security'); diff --git a/Resources/config/blocklist_token.xml b/Resources/config/blocklist_token.xml index 45652fe5..e313a30f 100644 --- a/Resources/config/blocklist_token.xml +++ b/Resources/config/blocklist_token.xml @@ -5,10 +5,6 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - - @@ -27,7 +23,6 @@ - diff --git a/Resources/config/jwt_manager.xml b/Resources/config/jwt_manager.xml index faee1bb0..0dbdb963 100644 --- a/Resources/config/jwt_manager.xml +++ b/Resources/config/jwt_manager.xml @@ -9,6 +9,7 @@ %lexik_jwt_authentication.user_id_claim% + %lexik_jwt_authentication.user_identity_field% false @@ -16,5 +17,12 @@ + + + + + + + diff --git a/Resources/doc/10-invalidate-token.rst b/Resources/doc/10-invalidate-token.rst index 80588dc6..24f3c1be 100644 --- a/Resources/doc/10-invalidate-token.rst +++ b/Resources/doc/10-invalidate-token.rst @@ -22,14 +22,14 @@ To configure token blocklist, update your `lexik_jwt_authentication.yaml` file: cache: cache.app -Enabling ``blocklist_token`` causes the activation of listeners: +Enabling ``blocklist_token``: -* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\EventListenerAddClaimsToJWTListener`` which adds a ``jti`` claim if not present when the token is created +* Adds a ``jti`` claim to the payload via `Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment\RandomJtiEnrichment` passed as an argument to the `Lexik\Bundle\JWTAuthenticationBundle\Services\JwtManager` -* an event listener ``Lexik\Bundle\JWTAuthenticationBundle\BlockJWTListener`` which blocks JWTs on logout (``Symfony\Component\Security\Http\Event\LogoutEvent``) +* activates the 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 +* activates 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 diff --git a/Services/JWTManager.php b/Services/JWTManager.php index b2008bbd..3fcf7c09 100644 --- a/Services/JWTManager.php +++ b/Services/JWTManager.php @@ -9,6 +9,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTEncodedEvent; use Lexik\Bundle\JWTAuthenticationBundle\Events; use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException; +use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment\NullEnrichment; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -45,15 +46,21 @@ class JWTManager implements JWTManagerInterface, JWTTokenManagerInterface */ protected $userIdClaim; + /** + * @var PayloadEnrichmentInterface + */ + private $payloadEnrichment; + /** * @param string|null $userIdClaim */ - public function __construct(JWTEncoderInterface $encoder, EventDispatcherInterface $dispatcher, $userIdClaim = null) + public function __construct(JWTEncoderInterface $encoder, EventDispatcherInterface $dispatcher, $userIdClaim = null, PayloadEnrichmentInterface $payloadEnrichment = null) { $this->jwtEncoder = $encoder; $this->dispatcher = $dispatcher; $this->userIdentityField = 'username'; $this->userIdClaim = $userIdClaim; + $this->payloadEnrichment = $payloadEnrichment ?? new NullEnrichment(); } /** @@ -64,6 +71,8 @@ public function create(UserInterface $user): string $payload = ['roles' => $user->getRoles()]; $this->addUserIdentityToPayload($user, $payload); + $this->payloadEnrichment->enrich($user, $payload); + return $this->generateJwtStringAndDispatchEvents($user, $payload); } @@ -75,6 +84,8 @@ public function createFromPayload(UserInterface $user, array $payload): string $payload = array_merge(['roles' => $user->getRoles()], $payload); $this->addUserIdentityToPayload($user, $payload); + $this->payloadEnrichment->enrich($user, $payload); + return $this->generateJwtStringAndDispatchEvents($user, $payload); } diff --git a/Services/PayloadEnrichment/ChainEnrichment.php b/Services/PayloadEnrichment/ChainEnrichment.php new file mode 100644 index 00000000..5dcc4b5b --- /dev/null +++ b/Services/PayloadEnrichment/ChainEnrichment.php @@ -0,0 +1,26 @@ +enrichments = $enrichments; + } + + public function enrich(UserInterface $user, array &$payload): void + { + foreach ($this->enrichments as $enrichment) { + $enrichment->enrich($user, $payload); + } + } +} diff --git a/Services/PayloadEnrichment/NullEnrichment.php b/Services/PayloadEnrichment/NullEnrichment.php new file mode 100644 index 00000000..f18669ff --- /dev/null +++ b/Services/PayloadEnrichment/NullEnrichment.php @@ -0,0 +1,13 @@ + 'bar']; + + $enrichmentFoo = new class() implements PayloadEnrichmentInterface { + public function enrich(UserInterface $user, array &$payload): void + { + $payload['foo'] = 'baz'; + } + }; + + $enrichmentBar = new class() implements PayloadEnrichmentInterface { + public function enrich(UserInterface $user, array &$payload): void + { + $payload['bar'] = 'qux'; + } + }; + + $chainEnrichment = new ChainEnrichment([$enrichmentFoo, $enrichmentBar]); + $chainEnrichment->enrich($this->createMock(UserInterface::class), $payload); + + $this->assertEquals(['foo' => 'baz', 'bar' => 'qux'], $payload); + } +} diff --git a/Tests/PayloadEnrichment/NullEnrichmentTest.php b/Tests/PayloadEnrichment/NullEnrichmentTest.php new file mode 100644 index 00000000..cf491da4 --- /dev/null +++ b/Tests/PayloadEnrichment/NullEnrichmentTest.php @@ -0,0 +1,18 @@ + 'bar']; + $enrichment = new NullEnrichment(); + $enrichment->enrich($this->createMock(UserInterface::class), $payload); + + $this->assertEquals(['foo' => 'bar'], $payload); + } +} diff --git a/Tests/PayloadEnrichment/RandomJtiEnrichmentTest.php b/Tests/PayloadEnrichment/RandomJtiEnrichmentTest.php new file mode 100644 index 00000000..40911bae --- /dev/null +++ b/Tests/PayloadEnrichment/RandomJtiEnrichmentTest.php @@ -0,0 +1,20 @@ + 'bar']; + $enrichment = new RandomJtiEnrichment(); + $enrichment->enrich($this->createMock(UserInterface::class), $payload); + + $this->assertArrayHasKey('jti', $payload); + $this->assertIsString($payload['jti']); + $this->assertArrayHasKey('foo', $payload); + } +} diff --git a/Tests/Services/JWTManagerTest.php b/Tests/Services/JWTManagerTest.php index 1954a43a..d9bab4ac 100644 --- a/Tests/Services/JWTManagerTest.php +++ b/Tests/Services/JWTManagerTest.php @@ -9,6 +9,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Events; use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager; +use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface; use Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs\User as CustomUser; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -48,6 +49,25 @@ public function testCreate() $this->assertEquals('secrettoken', $manager->create($this->createUser('user', 'password'))); } + public function testCreateWithPayloadEnrichment() + { + $dispatcher = $this->getEventDispatcherMock(); + $encoder = $this->getJWTEncoderMock(); + $encoder + ->method('encode') + ->with($this->arrayHasKey('baz')) + ->willReturn('secrettoken'); + + $manager = new JWTManager($encoder, $dispatcher, 'username', new class() implements PayloadEnrichmentInterface { + public function enrich(UserInterface $user, array &$payload): void + { + $payload['baz'] = 'qux'; + } + }); + + $this->assertEquals('secrettoken', $manager->create($this->createUser('user', 'password'))); + } + /** * test create. */ @@ -74,6 +94,26 @@ public function testCreateFromPayload() $this->assertEquals('secrettoken', $manager->createFromPayload($this->createUser('user', 'password'), $payload)); } + public function testCreateFromPayloadWithPayloadEnrichment() + { + $dispatcher = $this->getEventDispatcherMock(); + + $encoder = $this->getJWTEncoderMock(); + $encoder + ->method('encode') + ->with($this->arrayHasKey('baz')) + ->willReturn('secrettoken'); + + $manager = new JWTManager($encoder, $dispatcher, 'username', new class() implements PayloadEnrichmentInterface { + public function enrich(UserInterface $user, array &$payload): void + { + $payload['baz'] = 'qux'; + } + }); + $payload = ['foo' => 'bar']; + $this->assertEquals('secrettoken', $manager->createFromPayload($this->createUser('user', 'password'), $payload)); + } + /** * test decode. */