diff --git a/Command/ClearExpiredTokensCommand.php b/Command/ClearExpiredTokensCommand.php new file mode 100644 index 00000000..13e4725e --- /dev/null +++ b/Command/ClearExpiredTokensCommand.php @@ -0,0 +1,89 @@ +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 chose 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 a8ae8257..3bdd09ff 100644 --- a/Manager/AccessTokenManagerInterface.php +++ b/Manager/AccessTokenManagerInterface.php @@ -9,4 +9,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 e91acc24..1d5c0aaf 100644 --- a/Manager/Doctrine/AccessTokenManager.php +++ b/Manager/Doctrine/AccessTokenManager.php @@ -34,4 +34,13 @@ 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 < CURRENT_TIMESTAMP()') + ->getQuery() + ->execute(); + } } diff --git a/Manager/Doctrine/RefreshTokenManager.php b/Manager/Doctrine/RefreshTokenManager.php index 41225d02..2fedb861 100644 --- a/Manager/Doctrine/RefreshTokenManager.php +++ b/Manager/Doctrine/RefreshTokenManager.php @@ -34,4 +34,13 @@ 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 < CURRENT_TIMESTAMP()') + ->getQuery() + ->execute(); + } } diff --git a/Manager/InMemory/AccessTokenManager.php b/Manager/InMemory/AccessTokenManager.php index 2a887aa5..81127b3c 100644 --- a/Manager/InMemory/AccessTokenManager.php +++ b/Manager/InMemory/AccessTokenManager.php @@ -2,6 +2,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; +use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\AccessTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\AccessToken; @@ -27,4 +28,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 8797889d..03f8cdf7 100644 --- a/Manager/InMemory/RefreshTokenManager.php +++ b/Manager/InMemory/RefreshTokenManager.php @@ -2,6 +2,7 @@ namespace Trikoder\Bundle\OAuth2Bundle\Manager\InMemory; +use DateTime; use Trikoder\Bundle\OAuth2Bundle\Manager\RefreshTokenManagerInterface; use Trikoder\Bundle\OAuth2Bundle\Model\RefreshToken; @@ -27,4 +28,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 6e17d5a7..b0042c22 100644 --- a/Manager/RefreshTokenManagerInterface.php +++ b/Manager/RefreshTokenManagerInterface.php @@ -9,4 +9,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..6c33b4f0 --- /dev/null +++ b/Tests/Acceptance/ClearExpiredTokensCommandTest.php @@ -0,0 +1,108 @@ +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 chose only one of the following options:', $output); + } + + private function command(): Command + { + return $this->application->find('trikoder:oauth2:clear-expired-tokens'); + } +} diff --git a/Tests/Unit/InMemoryAccessTokenManagerTest.php b/Tests/Unit/InMemoryAccessTokenManagerTest.php new file mode 100644 index 00000000..eebc7ca0 --- /dev/null +++ b/Tests/Unit/InMemoryAccessTokenManagerTest.php @@ -0,0 +1,66 @@ +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..015abc05 --- /dev/null +++ b/Tests/Unit/InMemoryRefreshTokenManagerTest.php @@ -0,0 +1,72 @@ +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..fdf0fe88 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 tokes 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) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d689c7eb..ef848162 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,9 @@ + + ./Tests/Unit + ./Tests/Acceptance