Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the definition of lexik_jwt_authentication.payload_enrichment.random_jti_enrichment was placed in the Resources/config/blocklist_token.xml file, I feel like this code wouldn't have been necessary

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be cleaned up for 3.0 TBH. Things are kind of in a weird state with this stuff because the payload enrichment services should always be there but the bundle right now only needs the JTI enricher to be active when the blocklist functionality is enabled. Always turning on the JTI enricher in 2.x could be disruptive to downstream users for whatever reason, so it's probably safer to have this block for the next 2.x release, and in 3.0, the bundle defaults to always providing the JTI and a downstream app can replace/remove this service if they want to take control over that.

->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
Loading