Skip to content

Commit

Permalink
feature #1218 Invalidate a JWT token - Adding the jti claim by the JW…
Browse files Browse the repository at this point in the history
…TManager class instead of doing it via a listener (ldaspt)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

Invalidate a JWT token - Adding the jti claim by the JWTManager class instead of doing it via a listener

Hello `@chalasr` and `@mbabker`,

This PR aims to address the enhancements suggested by `@mbabker` in the discussion of the PR #1170.

`@see` #1170 (comment)

Changes included:
* Remove AddClaimsToJWTListener
* Addition of the concept of Payload Enrichment which aims to enrich the payload just before generating the token in the JwtManager class
* Added a random jti when the JWT token invalidation functionality is enabled
* Added a null payload enrichment and a chain enrichment (which may be superfluous)

The payload enrichment cannot be overridden via the bundle configuration, if the developer wants to overload it, he will have to decorate the service `lexik_jwt_authentication.payload_enrichment`. If necessary, I can do as for the `lexik_jwt_authentication.encoder` service so that this is configurable via the bundle configuration

I hope the PR answers the request correctly

Please review the changes at your convenience, and I welcome any feedback or further suggestions

Commits
-------

069b7bb Invalidate a JWT token - Adding the jti claim by the JWTManager class instead of doing it via a listener
  • Loading branch information
chalasr committed Apr 27, 2024
2 parents 3785c0f + 069b7bb commit ed77442
Show file tree
Hide file tree
Showing 16 changed files with 226 additions and 29 deletions.
22 changes: 22 additions & 0 deletions DependencyInjection/Compiler/CollectPayloadEnrichmentsPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class CollectPayloadEnrichmentsPass implements CompilerPassInterface
{
use PriorityTaggedServiceTrait;

public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('lexik_jwt_authentication.payload_enrichment')) {
return;
}

$container->getDefinition('lexik_jwt_authentication.payload_enrichment')
->replaceArgument(0, $this->findAndSortTaggedServices('lexik_jwt_authentication.payload_enrichment', $container));
}
}
3 changes: 3 additions & 0 deletions DependencyInjection/LexikJWTAuthenticationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
19 changes: 0 additions & 19 deletions EventListener/AddClaimsToJWTListener.php

This file was deleted.

2 changes: 2 additions & 0 deletions LexikJWTAuthenticationBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 0 additions & 5 deletions Resources/config/blocklist_token.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@
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"/>
Expand All @@ -27,7 +23,6 @@
</service>

<service id="Lexik\Bundle\JWTAuthenticationBundle\Services\BlockedTokenManagerInterface" alias="lexik_jwt_authentication.blocked_token_manager" />

</services>

</container>
8 changes: 8 additions & 0 deletions Resources/config/jwt_manager.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@
<argument type="service" id="lexik_jwt_authentication.encoder"/>
<argument type="service" id="event_dispatcher"/>
<argument>%lexik_jwt_authentication.user_id_claim%</argument>
<argument type="service" id="lexik_jwt_authentication.payload_enrichment"/>
<call method="setUserIdentityField">
<argument>%lexik_jwt_authentication.user_identity_field%</argument>
<argument>false</argument>
</call>
</service>

<service id="Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface" alias="lexik_jwt_authentication.jwt_manager" />

<service id="lexik_jwt_authentication.payload_enrichment.random_jti_enrichment" class="Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment\RandomJtiEnrichment">
<tag name="lexik_jwt_authentication.payload_enrichment" priority="0" />
</service>
<service id="lexik_jwt_authentication.payload_enrichment" class="Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment\ChainEnrichment">
<argument type="collection"/>
</service>
</services>
</container>
8 changes: 4 additions & 4 deletions Resources/doc/10-invalidate-token.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion Services/JWTManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand Down
26 changes: 26 additions & 0 deletions Services/PayloadEnrichment/ChainEnrichment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class ChainEnrichment implements PayloadEnrichmentInterface
{
private $enrichments;

/**
* @param PayloadEnrichmentInterface[] $enrichments
*/
public function __construct(array $enrichments)
{
$this->enrichments = $enrichments;
}

public function enrich(UserInterface $user, array &$payload): void
{
foreach ($this->enrichments as $enrichment) {
$enrichment->enrich($user, $payload);
}
}
}
13 changes: 13 additions & 0 deletions Services/PayloadEnrichment/NullEnrichment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class NullEnrichment implements PayloadEnrichmentInterface
{
public function enrich(UserInterface $user, array &$payload): void
{
}
}
14 changes: 14 additions & 0 deletions Services/PayloadEnrichment/RandomJtiEnrichment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class RandomJtiEnrichment implements PayloadEnrichmentInterface
{
public function enrich(UserInterface $user, array &$payload): void
{
$payload['jti'] = bin2hex(random_bytes(16));
}
}
10 changes: 10 additions & 0 deletions Services/PayloadEnrichmentInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services;

use Symfony\Component\Security\Core\User\UserInterface;

interface PayloadEnrichmentInterface
{
public function enrich(UserInterface $user, array &$payload): void;
}
34 changes: 34 additions & 0 deletions Tests/PayloadEnrichment/ChainEnrichmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\User\UserInterface;

class ChainEnrichmentTest extends TestCase
{
public function testEnrich(): void
{
$payload = ['foo' => '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);
}
}
18 changes: 18 additions & 0 deletions Tests/PayloadEnrichment/NullEnrichmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\User\UserInterface;

class NullEnrichmentTest extends TestCase
{
public function testEnrich(): void
{
$payload = ['foo' => 'bar'];
$enrichment = new NullEnrichment();
$enrichment->enrich($this->createMock(UserInterface::class), $payload);

$this->assertEquals(['foo' => 'bar'], $payload);
}
}
20 changes: 20 additions & 0 deletions Tests/PayloadEnrichment/RandomJtiEnrichmentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichment;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\User\UserInterface;

class RandomJtiEnrichmentTest extends TestCase
{
public function testEnrich(): void
{
$payload = ['foo' => 'bar'];
$enrichment = new RandomJtiEnrichment();
$enrichment->enrich($this->createMock(UserInterface::class), $payload);

$this->assertArrayHasKey('jti', $payload);
$this->assertIsString($payload['jti']);
$this->assertArrayHasKey('foo', $payload);
}
}
40 changes: 40 additions & 0 deletions Tests/Services/JWTManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand Down

0 comments on commit ed77442

Please sign in to comment.