diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index ad1ca8d7..56e1e460 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -247,6 +247,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 44fa2af9..65aed886 100644 --- a/DependencyInjection/LexikJWTAuthenticationExtension.php +++ b/DependencyInjection/LexikJWTAuthenticationExtension.php @@ -169,6 +169,12 @@ 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']); + } } private function createTokenExtractors(ContainerBuilder $container, array $tokenExtractorsConfig): array diff --git a/EventListener/AddClaimsToJWTListener.php b/EventListener/AddClaimsToJWTListener.php new file mode 100644 index 00000000..bd180b1c --- /dev/null +++ b/EventListener/AddClaimsToJWTListener.php @@ -0,0 +1,19 @@ +getData(); + + if (!isset($data['jti'])) { + $data['jti'] = bin2hex(random_bytes(16)); + + $event->setData($data); + } + } +} 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/doc/1-configuration-reference.rst b/Resources/doc/1-configuration-reference.rst index 1ae2be45..72a59c24 100644 --- a/Resources/doc/1-configuration-reference.rst +++ b/Resources/doc/1-configuration-reference.rst @@ -86,6 +86,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..80588dc6 --- /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`` 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 `_ 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 @@ + '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 c50585ee..802f2e15 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) 'username' => method_exists($user, 'getUserIdentifier') ? $user->getUserIdentifier() : $user->getUsername(), ]); } + + 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 e66d7d1c..f9696eb4 100644 --- a/Tests/Functional/TestCase.php +++ b/Tests/Functional/TestCase.php @@ -39,11 +39,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/app/config/BlockListToken/config.yml b/Tests/Functional/app/config/BlockListToken/config.yml new file mode 100644 index 00000000..5b663c6c --- /dev/null +++ b/Tests/Functional/app/config/BlockListToken/config.yml @@ -0,0 +1,6 @@ +imports: + - { resource: '../base_config.yml' } + +lexik_jwt_authentication: + blocklist_token: + enabled: true diff --git a/Tests/Functional/app/config/base_config.yml b/Tests/Functional/app/config/base_config.yml index 84f8bcec..3ca281e7 100644 --- a/Tests/Functional/app/config/base_config.yml +++ b/Tests/Functional/app/config/base_config.yml @@ -19,3 +19,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/app/config/base_security.yml b/Tests/Functional/app/config/base_security.yml index c0fa1f3c..b411275b 100644 --- a/Tests/Functional/app/config/base_security.yml +++ b/Tests/Functional/app/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/app/config/routing.yml b/Tests/Functional/app/config/routing.yml index 4258670c..9634dee9 100644 --- a/Tests/Functional/app/config/routing.yml +++ b/Tests/Functional/app/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/app/config/security_in_memory.yml b/Tests/Functional/app/config/security_in_memory.yml index a248da8a..7f360bb4 100644 --- a/Tests/Functional/app/config/security_in_memory.yml +++ b/Tests/Functional/app/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/app/config/security_lexik_jwt.yml b/Tests/Functional/app/config/security_lexik_jwt.yml index 34cbd74a..3e078dfd 100644 --- a/Tests/Functional/app/config/security_lexik_jwt.yml +++ b/Tests/Functional/app/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/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/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; + } +}