From d8ed8de197c6a71bc3440b978d50b6d18f75c4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berislav=20Balogovi=C4=87?= Date: Sun, 19 May 2019 18:56:40 +0200 Subject: [PATCH] Add a console command for removing expired tokens --- Command/ClearExpiredTokensCommand.php | 91 +++++++++++++++ Manager/AccessTokenManagerInterface.php | 2 + Manager/Doctrine/AccessTokenManager.php | 11 ++ Manager/Doctrine/RefreshTokenManager.php | 11 ++ Manager/InMemory/AccessTokenManager.php | 13 +++ Manager/InMemory/RefreshTokenManager.php | 13 +++ Manager/RefreshTokenManagerInterface.php | 2 + Resources/config/services.xml | 5 + .../ClearExpiredTokensCommandTest.php | 110 ++++++++++++++++++ .../DoctrineAccessTokenManagerTest.php | 78 +++++++++++++ .../DoctrineRefreshTokenManagerTest.php | 84 +++++++++++++ Tests/Unit/InMemoryAccessTokenManagerTest.php | 69 +++++++++++ .../Unit/InMemoryRefreshTokenManagerTest.php | 74 ++++++++++++ docs/basic-setup.md | 18 +++ 14 files changed, 581 insertions(+) create mode 100644 Command/ClearExpiredTokensCommand.php create mode 100644 Tests/Acceptance/ClearExpiredTokensCommandTest.php create mode 100644 Tests/Acceptance/DoctrineAccessTokenManagerTest.php create mode 100644 Tests/Acceptance/DoctrineRefreshTokenManagerTest.php create mode 100644 Tests/Unit/InMemoryAccessTokenManagerTest.php create mode 100644 Tests/Unit/InMemoryRefreshTokenManagerTest.php diff --git a/Command/ClearExpiredTokensCommand.php b/Command/ClearExpiredTokensCommand.php new file mode 100644 index 00000000..d8eaa0ea --- /dev/null +++ b/Command/ClearExpiredTokensCommand.php @@ -0,0 +1,91 @@ +accessTokenManager = $accessTokenManager; + $this->refreshTokenManager = $refreshTokenManager; + } + + protected function configure(): void + { + $this + ->setDescription('Clears all expired access and/or refresh tokens') + ->addOption( + 'access-tokens-only', + 'a', + InputOption::VALUE_NONE, + 'Clear only access tokens.' + ) + ->addOption( + 'refresh-tokens-only', + 'r', + InputOption::VALUE_NONE, + 'Clear only refresh tokens.' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $clearExpiredAccessTokens = !$input->getOption('refresh-tokens-only'); + $clearExpiredRefreshTokens = !$input->getOption('access-tokens-only'); + + if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens) { + $io->error('Please choose only one of the following options: "access-tokens-only", "refresh-tokens-only".'); + + return 1; + } + + if (true === $clearExpiredAccessTokens) { + $numOfClearedAccessTokens = $this->accessTokenManager->clearExpired(); + $io->success(sprintf( + 'Cleared %d expired access token%s.', + $numOfClearedAccessTokens, + 1 === $numOfClearedAccessTokens ? '' : 's' + )); + } + + if (true === $clearExpiredRefreshTokens) { + $numOfClearedRefreshTokens = $this->refreshTokenManager->clearExpired(); + $io->success(sprintf( + 'Cleared %d expired refresh token%s.', + $numOfClearedRefreshTokens, + 1 === $numOfClearedRefreshTokens ? '' : 's' + )); + } + + return 0; + } +} diff --git a/Manager/AccessTokenManagerInterface.php b/Manager/AccessTokenManagerInterface.php index ab94b9e1..fc0b94db 100644 --- a/Manager/AccessTokenManagerInterface.php +++ b/Manager/AccessTokenManagerInterface.php @@ -11,4 +11,6 @@ interface AccessTokenManagerInterface public function find(string $identifier): ?AccessToken; public function save(AccessToken $accessToken): void; + + public function clearExpired(): int; } diff --git a/Manager/Doctrine/AccessTokenManager.php b/Manager/Doctrine/AccessTokenManager.php index 3b1929fb..1192266e 100644 --- a/Manager/Doctrine/AccessTokenManager.php +++ b/Manager/Doctrine/AccessTokenManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine; +use DateTime; use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; @@ -36,4 +37,14 @@ public function save(AccessToken $accessToken): void $this->entityManager->persist($accessToken); $this->entityManager->flush(); } + + public function clearExpired(): int + { + return $this->entityManager->createQueryBuilder() + ->delete(AccessToken::class, 'at') + ->where('at.expiry < :expiry') + ->setParameter('expiry', new DateTime()) + ->getQuery() + ->execute(); + } } diff --git a/Manager/Doctrine/RefreshTokenManager.php b/Manager/Doctrine/RefreshTokenManager.php index 411ad672..96b92777 100644 --- a/Manager/Doctrine/RefreshTokenManager.php +++ b/Manager/Doctrine/RefreshTokenManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\Doctrine; +use DateTime; use Doctrine\ORM\EntityManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -36,4 +37,14 @@ public function save(RefreshToken $refreshToken): void $this->entityManager->persist($refreshToken); $this->entityManager->flush(); } + + public function clearExpired(): int + { + return $this->entityManager->createQueryBuilder() + ->delete(RefreshToken::class, 'rt') + ->where('rt.expiry < :expiry') + ->setParameter('expiry', new DateTime()) + ->getQuery() + ->execute(); + } } diff --git a/Manager/InMemory/AccessTokenManager.php b/Manager/InMemory/AccessTokenManager.php index efd04943..13a99eab 100644 --- a/Manager/InMemory/AccessTokenManager.php +++ b/Manager/InMemory/AccessTokenManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; +use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; @@ -29,4 +30,16 @@ public function save(AccessToken $accessToken): void { $this->accessTokens[$accessToken->getIdentifier()] = $accessToken; } + + public function clearExpired(): int + { + $count = \count($this->accessTokens); + + $now = new DateTime(); + $this->accessTokens = array_filter($this->accessTokens, function (AccessToken $accessToken) use ($now): bool { + return $accessToken->getExpiry() >= $now; + }); + + return $count - \count($this->accessTokens); + } } diff --git a/Manager/InMemory/RefreshTokenManager.php b/Manager/InMemory/RefreshTokenManager.php index 446e59e7..02970601 100644 --- a/Manager/InMemory/RefreshTokenManager.php +++ b/Manager/InMemory/RefreshTokenManager.php @@ -4,6 +4,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; +use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -29,4 +30,16 @@ public function save(RefreshToken $refreshToken): void { $this->refreshTokens[$refreshToken->getIdentifier()] = $refreshToken; } + + public function clearExpired(): int + { + $count = \count($this->refreshTokens); + + $now = new DateTime(); + $this->refreshTokens = array_filter($this->refreshTokens, function (RefreshToken $refreshToken) use ($now): bool { + return $refreshToken->getExpiry() >= $now; + }); + + return $count - \count($this->refreshTokens); + } } diff --git a/Manager/RefreshTokenManagerInterface.php b/Manager/RefreshTokenManagerInterface.php index 70c79ec6..1a4729d8 100644 --- a/Manager/RefreshTokenManagerInterface.php +++ b/Manager/RefreshTokenManagerInterface.php @@ -11,4 +11,6 @@ interface RefreshTokenManagerInterface public function find(string $identifier): ?RefreshToken; public function save(RefreshToken $refreshToken): void; + + public function clearExpired(): int; } diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 7e50064c..1a41500e 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -90,6 +90,11 @@ + + + + + diff --git a/Tests/Acceptance/ClearExpiredTokensCommandTest.php b/Tests/Acceptance/ClearExpiredTokensCommandTest.php new file mode 100644 index 00000000..13f51fc9 --- /dev/null +++ b/Tests/Acceptance/ClearExpiredTokensCommandTest.php @@ -0,0 +1,110 @@ +client->getContainer()->get(ScopeManagerInterface::class), + $this->client->getContainer()->get(ClientManagerInterface::class), + $this->client->getContainer()->get(AccessTokenManagerInterface::class), + $this->client->getContainer()->get(RefreshTokenManagerInterface::class) + ); + } + + protected function tearDown(): void + { + timecop_return(); + + parent::tearDown(); + } + + public function testClearExpiredAccessAndRefreshTokens(): void + { + $command = $this->command(); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([ + 'command' => $command->getName(), + ]); + + $this->assertSame(0, $exitCode); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Cleared 1 expired access token.', $output); + $this->assertStringContainsString('Cleared 1 expired refresh token.', $output); + } + + public function testClearExpiredAccessTokens(): void + { + $command = $this->command(); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([ + 'command' => $command->getName(), + '--access-tokens-only' => true, + ]); + + $this->assertSame(0, $exitCode); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Cleared 1 expired access token.', $output); + $this->assertStringNotContainsString('Cleared 1 expired refresh token.', $output); + } + + public function testClearExpiredRefreshTokens(): void + { + $command = $this->command(); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([ + 'command' => $command->getName(), + '--refresh-tokens-only' => true, + ]); + + $this->assertSame(0, $exitCode); + + $output = $commandTester->getDisplay(); + $this->assertStringNotContainsString('Cleared 1 expired access token.', $output); + $this->assertStringContainsString('Cleared 1 expired refresh token.', $output); + } + + public function testErrorWhenBothOptionsAreUsed(): void + { + $command = $this->command(); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([ + 'command' => $command->getName(), + '--access-tokens-only' => true, + '--refresh-tokens-only' => true, + ]); + + $this->assertSame(1, $exitCode); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Please choose only one of the following options:', $output); + } + + private function command(): Command + { + return $this->application->find('trikoder:oauth2:clear-expired-tokens'); + } +} diff --git a/Tests/Acceptance/DoctrineAccessTokenManagerTest.php b/Tests/Acceptance/DoctrineAccessTokenManagerTest.php new file mode 100644 index 00000000..d146455c --- /dev/null +++ b/Tests/Acceptance/DoctrineAccessTokenManagerTest.php @@ -0,0 +1,78 @@ +client->getContainer()->get('doctrine.orm.entity_manager'); + + $doctrineAccessTokenManager = new DoctrineAccessTokenManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + $em->flush(); + + timecop_freeze(new DateTime()); + + $testData = $this->buildClearExpiredTestData($client); + + /** @var AccessToken $token */ + foreach ($testData['input'] as $token) { + $doctrineAccessTokenManager->save($token); + } + + $this->assertSame(3, $doctrineAccessTokenManager->clearExpired()); + + timecop_return(); + + $this->assertSame( + $testData['output'], + $em->getRepository(AccessToken::class)->findBy([], ['identifier' => 'ASC']) + ); + } + + private function buildClearExpiredTestData(Client $client): array + { + $validAccessTokens = [ + $this->buildAccessToken('1111', '+1 day', $client), + $this->buildAccessToken('2222', '+1 hour', $client), + $this->buildAccessToken('3333', '+1 second', $client), + $this->buildAccessToken('4444', 'now', $client), + ]; + + $expiredAccessTokens = [ + $this->buildAccessToken('5555', '-1 day', $client), + $this->buildAccessToken('6666', '-1 hour', $client), + $this->buildAccessToken('7777', '-1 second', $client), + ]; + + return [ + 'input' => array_merge($validAccessTokens, $expiredAccessTokens), + 'output' => $validAccessTokens, + ]; + } + + private function buildAccessToken(string $identifier, string $modify, Client $client): AccessToken + { + return new AccessToken( + $identifier, + (new DateTime())->modify($modify), + $client, + null, + [] + ); + } +} diff --git a/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php b/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php new file mode 100644 index 00000000..e9062353 --- /dev/null +++ b/Tests/Acceptance/DoctrineRefreshTokenManagerTest.php @@ -0,0 +1,84 @@ +client->getContainer()->get('doctrine.orm.entity_manager'); + + $doctrineRefreshTokenManager = new DoctrineRefreshTokenManager($em); + + $client = new Client('client', 'secret'); + $em->persist($client); + $em->flush(); + + timecop_freeze(new DateTime()); + + $testData = $this->buildClearExpiredTestData($client); + + /** @var RefreshToken $token */ + foreach ($testData['input'] as $token) { + $em->persist($token->getAccessToken()); + $doctrineRefreshTokenManager->save($token); + } + + $this->assertSame(3, $doctrineRefreshTokenManager->clearExpired()); + + timecop_return(); + + $this->assertSame( + $testData['output'], + $em->getRepository(RefreshToken::class)->findBy([], ['identifier' => 'ASC']) + ); + } + + private function buildClearExpiredTestData(Client $client): array + { + $validRefreshTokens = [ + $this->buildRefreshToken('1111', '+1 day', $client), + $this->buildRefreshToken('2222', '+1 hour', $client), + $this->buildRefreshToken('3333', '+1 second', $client), + $this->buildRefreshToken('4444', 'now', $client), + ]; + + $expiredRefreshTokens = [ + $this->buildRefreshToken('5555', '-1 day', $client), + $this->buildRefreshToken('6666', '-1 hour', $client), + $this->buildRefreshToken('7777', '-1 second', $client), + ]; + + return [ + 'input' => array_merge($validRefreshTokens, $expiredRefreshTokens), + 'output' => $validRefreshTokens, + ]; + } + + private function buildRefreshToken(string $identifier, string $modify, Client $client): RefreshToken + { + return new RefreshToken( + $identifier, + (new DateTime())->modify($modify), + new AccessToken( + $identifier, + (new DateTime('+1 day')), + $client, + null, + [] + ) + ); + } +} diff --git a/Tests/Unit/InMemoryAccessTokenManagerTest.php b/Tests/Unit/InMemoryAccessTokenManagerTest.php new file mode 100644 index 00000000..1b568f79 --- /dev/null +++ b/Tests/Unit/InMemoryAccessTokenManagerTest.php @@ -0,0 +1,69 @@ +buildClearExpiredTestData(); + + foreach ($testData['input'] as $token) { + $inMemoryAccessTokenManager->save($token); + } + + $this->assertSame(3, $inMemoryAccessTokenManager->clearExpired()); + + timecop_return(); + + $reflectionProperty = new ReflectionProperty(InMemoryAccessTokenManager::class, 'accessTokens'); + $reflectionProperty->setAccessible(true); + + $this->assertSame($testData['output'], $reflectionProperty->getValue($inMemoryAccessTokenManager)); + } + + private function buildClearExpiredTestData(): array + { + $validAccessTokens = [ + '1111' => $this->buildAccessToken('1111', '+1 day'), + '2222' => $this->buildAccessToken('2222', '+1 hour'), + '3333' => $this->buildAccessToken('3333', '+1 second'), + '4444' => $this->buildAccessToken('4444', 'now'), + ]; + + $expiredAccessTokens = [ + '5555' => $this->buildAccessToken('5555', '-1 day'), + '6666' => $this->buildAccessToken('6666', '-1 hour'), + '7777' => $this->buildAccessToken('7777', '-1 second'), + ]; + + return [ + 'input' => $validAccessTokens + $expiredAccessTokens, + 'output' => $validAccessTokens, + ]; + } + + private function buildAccessToken(string $identifier, string $modify): AccessToken + { + return new AccessToken( + $identifier, + (new DateTime())->modify($modify), + new Client('client', 'secret'), + null, + [] + ); + } +} diff --git a/Tests/Unit/InMemoryRefreshTokenManagerTest.php b/Tests/Unit/InMemoryRefreshTokenManagerTest.php new file mode 100644 index 00000000..2b52bc55 --- /dev/null +++ b/Tests/Unit/InMemoryRefreshTokenManagerTest.php @@ -0,0 +1,74 @@ +buildClearExpiredTestData(); + + foreach ($testData['input'] as $token) { + $inMemoryRefreshTokenManager->save($token); + } + + $this->assertSame(3, $inMemoryRefreshTokenManager->clearExpired()); + + timecop_return(); + + $reflectionProperty = new ReflectionProperty(InMemoryRefreshTokenManager::class, 'refreshTokens'); + $reflectionProperty->setAccessible(true); + + $this->assertSame($testData['output'], $reflectionProperty->getValue($inMemoryRefreshTokenManager)); + } + + private function buildClearExpiredTestData(): array + { + $validRefreshTokens = [ + '1111' => $this->buildRefreshToken('1111', '+1 day'), + '2222' => $this->buildRefreshToken('2222', '+1 hour'), + '3333' => $this->buildRefreshToken('3333', '+1 second'), + '4444' => $this->buildRefreshToken('4444', 'now'), + ]; + + $expiredRefreshTokens = [ + '5555' => $this->buildRefreshToken('5555', '-1 day'), + '6666' => $this->buildRefreshToken('6666', '-1 hour'), + '7777' => $this->buildRefreshToken('7777', '-1 second'), + ]; + + return [ + 'input' => $validRefreshTokens + $expiredRefreshTokens, + 'output' => $validRefreshTokens, + ]; + } + + private function buildRefreshToken(string $identifier, string $modify): RefreshToken + { + return new RefreshToken( + $identifier, + (new DateTime())->modify($modify), + new AccessToken( + $identifier, + (new DateTime('+1 day')), + new Client('client', 'secret'), + null, + [] + ) + ); + } +} diff --git a/docs/basic-setup.md b/docs/basic-setup.md index 6b4fa8a4..bd3507b2 100644 --- a/docs/basic-setup.md +++ b/docs/basic-setup.md @@ -153,6 +153,24 @@ There are two possible reasons for the authentication server to reject a request - Provided token is expired or invalid (HTTP response 401 `Unauthorized`) - Provided token is valid but scopes are insufficient (HTTP response 403 `Forbidden`) +## Clearing expired access & refresh tokens + +To clear expired access & refresh tokens you can use the `trikoder:oauth2:clear-expired-tokens` command. + +The command removes all tokens whose expiry time is lesser than the current. + +```sh +Description: + Clears all expired access and/or refresh tokens + +Usage: + trikoder:oauth2:clear-expired-tokens [options] + +Options: + -a, --access-tokens-only Clear only access tokens. + -r, --refresh-tokens-only Clear only refresh tokens. +``` + ## CORS requests For CORS handling, use [NelmioCorsBundle](https://github.com/nelmio/NelmioCorsBundle)