diff --git a/CHANGELOG.md b/CHANGELOG.md index c77718a6..6ef48c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,14 @@ CHANGELOG ========= -For a diff between two versions https://github.com/lexik/LexikJWTAuthenticationBundle/compare/v1.0.0...v2.20.3 +For a diff between two versions https://github.com/lexik/LexikJWTAuthenticationBundle/compare/v1.0.0...v2.21.0 + +## [2.21.0](https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v2.21.0) (2024-04-27) + +* feature [\#1218](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/1170) Invalidate a JWT token ([@ldaspt](https://github.com/ldaspt)) +* feature [\#1170](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/1170) Invalidate a JWT token ([@ldaspt](https://github.com/ldaspt)) +* feature [\#1207](https://github.com/lexik/LexikJWTAuthenticationBundle/pull/1207) Web-Token Framework simplified ([@Spomky](https://github.com/Spomky)) +* bug [\60770f1](https://github.com/lexik/LexikJWTAuthenticationBundle/commit/60770f1fb9ec0b8b0f27a0ee9e2bf441ca4a1db9) Fix CI & web-token encryption support ([@Spomky](https://github.com/Spomky)) ## [2.20.3](https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v2.20.3) (2023-12-14) 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/Configuration.php b/DependencyInjection/Configuration.php index 32c711a6..d011c844 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -225,6 +225,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('blocklist_token') + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->children() + ->scalarNode('cache') + ->defaultValue('cache.app') + ->info('Storage to track blocked tokens') + ->end() + ->end() + ->end() ->end() ->end(); diff --git a/DependencyInjection/LexikJWTAuthenticationExtension.php b/DependencyInjection/LexikJWTAuthenticationExtension.php index 26203a80..2a014322 100644 --- a/DependencyInjection/LexikJWTAuthenticationExtension.php +++ b/DependencyInjection/LexikJWTAuthenticationExtension.php @@ -145,6 +145,15 @@ public function load(array $configs, ContainerBuilder $container): void } $this->processWithWebTokenConfig($config, $container, $loader); + + if ($this->isConfigEnabled($container, $config['blocklist_token'])) { + $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'); + } } private function createTokenExtractors(ContainerBuilder $container, array $tokenExtractorsConfig): array diff --git a/EventListener/BlockJWTListener.php b/EventListener/BlockJWTListener.php new file mode 100644 index 00000000..ba19790c --- /dev/null +++ b/EventListener/BlockJWTListener.php @@ -0,0 +1,67 @@ +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 + } + } +} diff --git a/EventListener/RejectBlockedTokenListener.php b/EventListener/RejectBlockedTokenListener.php new file mode 100644 index 00000000..31a48f69 --- /dev/null +++ b/EventListener/RejectBlockedTokenListener.php @@ -0,0 +1,32 @@ +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) + } + } +} diff --git a/Exception/MissingClaimException.php b/Exception/MissingClaimException.php new file mode 100644 index 00000000..72fba070 --- /dev/null +++ b/Exception/MissingClaimException.php @@ -0,0 +1,15 @@ +addCompilerPass(new WireGenerateTokenCommandPass(), 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/README.md b/README.md index 8be3e32b..598b0394 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The bulk of the documentation is stored in the [`Resources/doc`](Resources/doc/i * [Creating JWT tokens programmatically](Resources/doc/7-manual-token-creation.rst) * [A database-less user provider](Resources/doc/8-jwt-user-provider.rst) * [Accessing the authenticated JWT token](Resources/doc/9-access-authenticated-jwt-token.rst) + * [Invalidate token on logout](Resources/doc/10-invalidate-token-on-logout.rst) Community Support ----------------- diff --git a/Resources/config/blocklist_token.xml b/Resources/config/blocklist_token.xml new file mode 100644 index 00000000..e313a30f --- /dev/null +++ b/Resources/config/blocklist_token.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/config/jwt_manager.xml b/Resources/config/jwt_manager.xml index 073b0d9c..a54621e7 100644 --- a/Resources/config/jwt_manager.xml +++ b/Resources/config/jwt_manager.xml @@ -9,8 +9,16 @@ %lexik_jwt_authentication.user_id_claim% + + + + + + + + diff --git a/Resources/doc/1-configuration-reference.rst b/Resources/doc/1-configuration-reference.rst index 842729d8..fd6d7959 100644 --- a/Resources/doc/1-configuration-reference.rst +++ b/Resources/doc/1-configuration-reference.rst @@ -85,6 +85,11 @@ Full default configuration # remove the token from the response body when using cookies remove_token_from_body_when_cookies_used: true + # invalidate the token on logout by storing it in the cache + blocklist_token: + enabled: true + cache: cache.app + Encoder configuration ~~~~~~~~~~~~~~~~~~~~~ diff --git a/Resources/doc/10-invalidate-token.rst b/Resources/doc/10-invalidate-token.rst new file mode 100644 index 00000000..24f3c1be --- /dev/null +++ b/Resources/doc/10-invalidate-token.rst @@ -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 `_ + +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``: + +* 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` + +* 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``) + +* 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 + +* 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 `_ for more details. + +Changing blocklist storage +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To change the blocklist storage, refer to `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 diff --git a/Services/BlockedToken/CacheItemPoolBlockedTokenManager.php b/Services/BlockedToken/CacheItemPoolBlockedTokenManager.php new file mode 100644 index 00000000..96ceeace --- /dev/null +++ b/Services/BlockedToken/CacheItemPoolBlockedTokenManager.php @@ -0,0 +1,66 @@ +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']); + } +} diff --git a/Services/BlockedTokenManagerInterface.php b/Services/BlockedTokenManagerInterface.php new file mode 100644 index 00000000..d1b59fbb --- /dev/null +++ b/Services/BlockedTokenManagerInterface.php @@ -0,0 +1,23 @@ +jwtEncoder = $encoder; $this->dispatcher = $dispatcher; $this->userIdClaim = $userIdClaim; + $this->payloadEnrichment = $payloadEnrichment ?? new NullEnrichment(); } /** @@ -45,6 +48,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); } @@ -58,6 +63,8 @@ public function createFromPayload(UserInterface $user, array $payload = []): str $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 @@ + 'BlockListToken']); + + $token = static::getAuthenticatedToken(); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseIsSuccessful('Precondition - a valid token should be able to contact the api'); + + static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_FOUND); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token'); + + $responseBody = json_decode(static::$client->getResponse()->getContent(), true); + $this->assertEquals('Invalid JWT Token', $responseBody['message']); + $this->assertThatTokenIsInTheBlockList($token); + } + + public function testShouldAddJtiWhenBlockListTokenIsEnabled() + { + static::$client = static::createClient(['test_case' => 'BlockListToken']); + + $token = static::getAuthenticatedToken(); + /** @var JWTManager $jwtManager */ + $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager'); + $payload = $jwtManager->parse($token); + self::assertNotEmpty($payload['jti']); + } + + public function testShouldInvalidateTokenOnLogoutWhenBlockListTokenIsEnabledAndWhenUsingCustomLogout() + { + static::$client = static::createClient(['test_case' => 'BlockListToken']); + + $token = static::getAuthenticatedToken(); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseIsSuccessful('Precondition - a valid token should be able to contact the api'); + + static::$client->jsonRequest('GET', '/api/logout_custom', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_OK); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'Logout should invalidate token'); + + $responseBody = json_decode(static::$client->getResponse()->getContent(), true); + $this->assertEquals('Invalid JWT Token', $responseBody['message']); + $this->assertThatTokenIsInTheBlockList($token); + } + + public function testShouldNotInvalidateTokenOnLogoutWhenBlockListTokenIsDisabled() + { + static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']); + $token = static::getAuthenticatedToken(); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseIsSuccessful('Precondition - a valid token should be able to contact the api'); + + static::$client->jsonRequest('GET', '/api/logout', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_FOUND); + + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_OK, 'Logout should NOT invalidate token when block list config is not enabled'); + } + + public function testShouldNotAddJtiWhenBlockListTokenIsDisabled() + { + static::$client = static::createClient(['test_case' => 'BlockListTokenDisabled']); + + $token = static::getAuthenticatedToken(); + /** @var JWTManager $jwtManager */ + $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager'); + $payload = $jwtManager->parse($token); + self::assertArrayNotHasKey('jti', $payload); + } + + public function testShouldInvalidateTokenIfDisabledUserWhenBlockListTokenIsEnabled() + { + static::$client = static::createClient(['test_case' => 'BlockListToken']); + if ('lexik_jwt' === static::$kernel->getUserProvider()) { + $this->markTestSkipped('Test not implemented with lexik_jwt provider'); + } + + UserProvider::$users['lexik_disabled']['enabled'] = true; + $token = static::getAuthenticatedToken('lexik_disabled'); + + UserProvider::$users['lexik_disabled']['enabled'] = true; + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseIsSuccessful('Should be able to contact the api'); + + /** @var UserProvider $userProvider */ + UserProvider::$users['lexik_disabled']['enabled'] = false; + static::$client->jsonRequest('GET', '/api/secured', [], ['HTTP_AUTHORIZATION' => "Bearer $token"]); + static::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED, 'An user disabled should not be able to contact the api'); + $this->assertThatTokenIsInTheBlockList($token); + } + + private function assertThatTokenIsInTheBlockList(string $token): void + { + /** @var JWTManager $jwtManager */ + $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager'); + $payload = $jwtManager->parse($token); + + /** @var CacheItemPoolInterface $cache */ + $cache = static::getContainer()->get('lexik_jwt_authentication.blocklist_token.cache'); + self::assertTrue($cache->hasItem($payload['jti']), 'The token should be in the block list'); + } +} diff --git a/Tests/Functional/Bundle/Controller/TestController.php b/Tests/Functional/Bundle/Controller/TestController.php index a343845b..b26db512 100644 --- a/Tests/Functional/Bundle/Controller/TestController.php +++ b/Tests/Functional/Bundle/Controller/TestController.php @@ -2,8 +2,13 @@ namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Http\Event\LogoutEvent; class TestController { @@ -15,4 +20,16 @@ public function securedAction(UserInterface $user): JsonResponse 'username' => $user->getUserIdentifier(), ]); } + + public function logoutAction() + { + throw new \Exception('This should never be reached!'); + } + + public function logoutCustomAction(Request $request, EventDispatcherInterface $eventDispatcher, TokenStorageInterface $tokenStorage) + { + $eventDispatcher->dispatch(new LogoutEvent($request, $tokenStorage->getToken())); + + return new Response(); + } } diff --git a/Tests/Functional/TestCase.php b/Tests/Functional/TestCase.php index da8e858b..db2c8cf8 100644 --- a/Tests/Functional/TestCase.php +++ b/Tests/Functional/TestCase.php @@ -37,11 +37,11 @@ protected static function createAuthenticatedClient($token = null) return $client; } - protected static function getAuthenticatedToken() + protected static function getAuthenticatedToken(string $username = 'lexik') { $client = static::$client ?: static::createClient(); - $client->jsonRequest('POST', '/login_check', ['username' => 'lexik', 'password' => 'dummy']); + $client->jsonRequest('POST', '/login_check', ['username' => $username, 'password' => 'dummy']); $response = $client->getResponse(); $responseBody = json_decode($response->getContent(), true); diff --git a/Tests/Functional/config/BlockListToken/config.yml b/Tests/Functional/config/BlockListToken/config.yml new file mode 100644 index 00000000..2854fade --- /dev/null +++ b/Tests/Functional/config/BlockListToken/config.yml @@ -0,0 +1,7 @@ +imports: + - { resource: '../base_config.yml' } + +lexik_jwt_authentication: + blocklist_token: + enabled: true + cache: cache.app diff --git a/Tests/Functional/config/base_config.yml b/Tests/Functional/config/base_config.yml index 299fd4f2..70e12e30 100644 --- a/Tests/Functional/config/base_config.yml +++ b/Tests/Functional/config/base_config.yml @@ -14,3 +14,7 @@ services: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller\TestController: arguments: ['@security.token_storage'] public: true + tags: ['controller.service_arguments'] + + Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs\UserProvider: + public: true diff --git a/Tests/Functional/config/base_security.yml b/Tests/Functional/config/base_security.yml index c0fa1f3c..b411275b 100644 --- a/Tests/Functional/config/base_security.yml +++ b/Tests/Functional/config/base_security.yml @@ -1,11 +1,16 @@ security: providers: in_memory: + chain: + providers: [ 'lexik_in_memory', 'disabled_user_provider' ] + lexik_in_memory: memory: users: lexik: password: dummy roles: ROLE_USER + disabled_user_provider: + id: Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs\UserProvider jwt: lexik_jwt: class: Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs\JWTUser diff --git a/Tests/Functional/config/routing.yml b/Tests/Functional/config/routing.yml index 4258670c..9634dee9 100644 --- a/Tests/Functional/config/routing.yml +++ b/Tests/Functional/config/routing.yml @@ -6,3 +6,13 @@ secured: path: /api/secured defaults: { _controller: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller\TestController::securedAction } methods: [GET] + +app_logout: + path: /api/logout + defaults: { _controller: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller\TestController::logoutAction } + methods: GET + +app_logout_custom: + path: /api/logout_custom + defaults: { _controller: Lexik\Bundle\JWTAuthenticationBundle\Tests\Functional\Bundle\Controller\TestController::logoutCustomAction } + methods: GET diff --git a/Tests/Functional/config/security_in_memory.yml b/Tests/Functional/config/security_in_memory.yml index a248da8a..7f360bb4 100644 --- a/Tests/Functional/config/security_in_memory.yml +++ b/Tests/Functional/config/security_in_memory.yml @@ -21,6 +21,8 @@ security: lazy: true provider: in_memory jwt: ~ + logout: + path: app_logout access_control: - { path: ^/login, roles: PUBLIC_ACCESS } diff --git a/Tests/Functional/config/security_lexik_jwt.yml b/Tests/Functional/config/security_lexik_jwt.yml index 3209651c..2c27d71b 100644 --- a/Tests/Functional/config/security_lexik_jwt.yml +++ b/Tests/Functional/config/security_lexik_jwt.yml @@ -20,6 +20,8 @@ security: stateless: true provider: jwt jwt: ~ + logout: + path: app_logout access_control: - { path: ^/login, roles: PUBLIC_ACCESS } diff --git a/Tests/PayloadEnrichment/ChainEnrichmentTest.php b/Tests/PayloadEnrichment/ChainEnrichmentTest.php new file mode 100644 index 00000000..555dda14 --- /dev/null +++ b/Tests/PayloadEnrichment/ChainEnrichmentTest.php @@ -0,0 +1,34 @@ + '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/BlockedToken/CacheItemPoolBlockedTokenManagerTest.php b/Tests/Services/BlockedToken/CacheItemPoolBlockedTokenManagerTest.php new file mode 100644 index 00000000..234fa42b --- /dev/null +++ b/Tests/Services/BlockedToken/CacheItemPoolBlockedTokenManagerTest.php @@ -0,0 +1,137 @@ +expectException(MissingClaimException::class); + $cacheAdapter = new ArrayAdapter(); + $blockedTokenManager = new CacheItemPoolBlockedTokenManager($cacheAdapter); + $blockedTokenManager->add( + [ + 'iat' => self::IAT, + 'jti' => self::JTI, + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ] + ); + } + + public function testAddPayloadWithoutJitShouldThrowsAnException() + { + $this->expectException(MissingClaimException::class); + $cacheAdapter = new ArrayAdapter(); + $blockedTokenManager = new CacheItemPoolBlockedTokenManager($cacheAdapter); + $blockedTokenManager->add( + [ + 'iat' => self::IAT, + "exp" => (int) (new DateTime('2050-01-01'))->format('U'), + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ] + ); + } + + public function testShouldNotAddPayloadIfItHasExpired() + { + $cacheAdapter = new ArrayAdapter(); + $blockedTokenManager = new CacheItemPoolBlockedTokenManager($cacheAdapter); + self::assertFalse( + $blockedTokenManager->add( + [ + 'iat' => self::IAT, + 'jti' => self::JTI, + "exp" => (int) (new DateTime('2020-01-01'))->format('U'), + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ] + ) + ); + self::assertCount(0, $cacheAdapter->getItems()); + } + + public function testShouldBlockTokenIfPaylaodHasNotExpired() + { + ClockMock::register(ArrayAdapter::class); + + $cacheAdapter = new ArrayAdapter(); + $blockedTokenManager = new CacheItemPoolBlockedTokenManager($cacheAdapter); + + $expirationDateTime = new DateTimeImmutable('2050-01-01 00:00:00'); + self::assertTrue( + $blockedTokenManager->add( + [ + 'iat' => self::IAT, + 'jti' => self::JTI, + "exp" => (int) $expirationDateTime->format('U'), + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ] + ) + ); + self::assertCount(1, $cacheAdapter->getValues()); + + self::assertTrue($cacheAdapter->hasItem(self::JTI)); + self::assertNotNull($cacheAdapter->getItem(self::JTI)); + + ClockMock::withClockMock(($expirationDateTime->modify('+5 minutes 1 second')->format('U'))); + self::assertFalse($cacheAdapter->hasItem(self::JTI), 'The cache item should have expired'); + ClockMock::withClockMock(false); + } + + public function testHasToken() + { + $cacheAdapter = new ArrayAdapter(); + $blockedTokenManager = new CacheItemPoolBlockedTokenManager($cacheAdapter); + + $expirationDateTime = new DateTimeImmutable('2050-01-01 00:00:00'); + $payload = [ + 'iat' => self::IAT, + 'jti' => self::JTI, + "exp" => (int) $expirationDateTime->format('U'), + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ]; + + self::assertFalse($blockedTokenManager->has($payload)); + + $blockedTokenManager->add( + [ + 'iat' => self::IAT, + 'jti' => self::JTI, + "exp" => (int) $expirationDateTime->format('U'), + 'roles' => [ + 'ROLE_USER' + ], + 'username' => 'lexik' + ] + ); + self::assertTrue($blockedTokenManager->has($payload)); + + $blockedTokenManager->remove($payload); + self::assertFalse($blockedTokenManager->has($payload)); + } +} diff --git a/Tests/Services/JWTManagerTest.php b/Tests/Services/JWTManagerTest.php index 2106933a..eea01ebc 100644 --- a/Tests/Services/JWTManagerTest.php +++ b/Tests/Services/JWTManagerTest.php @@ -10,6 +10,7 @@ use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\Token\JWTPostAuthenticationToken; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTManager; use PHPUnit\Framework\MockObject\MockObject; +use Lexik\Bundle\JWTAuthenticationBundle\Services\PayloadEnrichmentInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\UserInterface; @@ -47,6 +48,25 @@ public function testCreate() $this->assertSame('secrettoken', $manager->create($this->createUser())); } + 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. */ @@ -73,6 +93,26 @@ public function testCreateFromPayload() $this->assertSame('secrettoken', $manager->createFromPayload($this->createUser(), $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. */ diff --git a/Tests/Stubs/User.php b/Tests/Stubs/User.php deleted file mode 100644 index c85ab401..00000000 --- a/Tests/Stubs/User.php +++ /dev/null @@ -1,95 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Lexik\Bundle\JWTAuthenticationBundle\Tests\Stubs; - -use Symfony\Component\Security\Core\User\UserInterface; - -/** - * User is the user implementation used by the in-memory user provider. - * - * This should not be used for anything else. - * - * @author Fabien Potencier - */ -final class User implements UserInterface -{ - private $userIdentifier; - private $password; - private $roles; - private $email; - - public function __construct($userIdentifier, $password, $email = '', array $roles = []) - { - if (empty($userIdentifier)) { - throw new \InvalidArgumentException('The username cannot be empty.'); - } - - $this->userIdentifier = $userIdentifier; - $this->password = $password; - $this->roles = $roles; - $this->email = $email; - } - - /** - * {@inheritdoc} - */ - public function getRoles(): array - { - return $this->roles; - } - - /** - * {@inheritdoc} - */ - public function getPassword(): ?string - { - return $this->password; - } - - /** - * {@inheritdoc} - */ - public function getSalt(): ?string - { - return null; - } - - /** - * {@inheritdoc} - */ - public function getUsername(): string - { - return $this->getUserIdentifier(); - } - - public function getUserIdentifier(): string - { - return $this->userIdentifier; - } - - /** - * {@inheritdoc} - */ - public function eraseCredentials(): void - { - } - - public function getEmail() - { - return $this->email; - } - - public function setEmail($email) - { - $this->email = $email; - } -} diff --git a/Tests/Stubs/UserProvider.php b/Tests/Stubs/UserProvider.php new file mode 100644 index 00000000..7eb1983c --- /dev/null +++ b/Tests/Stubs/UserProvider.php @@ -0,0 +1,68 @@ + [ + 'username' => 'lexik_disabled', + 'password' => 'dummy', + 'roles' => ['ROLE_USER'], + 'enabled' => true, + ] + ]; + + /** + * Are users enbled ? + */ + public static $enabled = false; + public static $users = self::DEFAULT_USERS; + + public function refreshUser(UserInterface $user): UserInterface + { + return $this->getUser($user->getUserIdentifier()); + } + + public function supportsClass(string $class): bool + { + return InMemoryUser::class === $class; + } + + public function loadUserByUsername(string $username): UserInterface + { + return $this->getUser($username); + } + + public function loadUserByIdentifier(string $identifier): UserInterface + { + return $this->getUser($identifier); + } + + private function getUser(string $username): InMemoryUser + { + $user = self::$users[strtolower($username)] ?? null; + if (null === $user) { + $ex = new UserNotFoundException(sprintf('Username "%s" does not exist.', $username)); + $ex->setUserIdentifier($username); + + throw $ex; + } + + return new InMemoryUser($user['username'], $user['password'], $user['roles'], $user['enabled']); + } + + public function reset(): void + { + self::$users = self::DEFAULT_USERS; + } +}