diff --git a/src/Exception/UniqueTokenIdentifierConstraintViolationException.php b/src/Exception/UniqueTokenIdentifierConstraintViolationException.php new file mode 100644 index 00000000..816c249f --- /dev/null +++ b/src/Exception/UniqueTokenIdentifierConstraintViolationException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server\Exception; + + +class UniqueTokenIdentifierConstraintViolationException extends OAuthServerException +{ + public static function create() + { + $errorMessage = 'Could not create unique access token identifier'; + + return new static($errorMessage, 100, 'access_token_duplicate', 500); + } +} diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index 547ab213..38336b76 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -16,6 +16,7 @@ use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\ClientRepositoryInterface; @@ -35,6 +36,8 @@ abstract class AbstractGrant implements GrantTypeInterface const SCOPE_DELIMITER_STRING = ' '; + const MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS = 10; + /** * @var ClientRepositoryInterface */ @@ -321,19 +324,28 @@ abstract class AbstractGrant implements GrantTypeInterface $userIdentifier, array $scopes = [] ) { + $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; + $accessToken = $this->accessTokenRepository->getNewToken($client, $scopes, $userIdentifier); $accessToken->setClient($client); $accessToken->setUserIdentifier($userIdentifier); - $accessToken->setIdentifier($this->generateUniqueIdentifier()); $accessToken->setExpiryDateTime((new \DateTime())->add($accessTokenTTL)); foreach ($scopes as $scope) { $accessToken->addScope($scope); } - $this->accessTokenRepository->persistNewAccessToken($accessToken); - - return $accessToken; + while ($maxGenerationAttempts-- > 0) { + $accessToken->setIdentifier($this->generateUniqueIdentifier()); + try { + $this->accessTokenRepository->persistNewAccessToken($accessToken); + return $accessToken; + } catch (UniqueTokenIdentifierConstraintViolationException $e) { + if ($maxGenerationAttempts === 0) { + throw $e; + } + } + } } /** @@ -354,8 +366,9 @@ abstract class AbstractGrant implements GrantTypeInterface $redirectUri, array $scopes = [] ) { + $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; + $authCode = $this->authCodeRepository->getNewAuthCode(); - $authCode->setIdentifier($this->generateUniqueIdentifier()); $authCode->setExpiryDateTime((new \DateTime())->add($authCodeTTL)); $authCode->setClient($client); $authCode->setUserIdentifier($userIdentifier); @@ -365,9 +378,17 @@ abstract class AbstractGrant implements GrantTypeInterface $authCode->addScope($scope); } - $this->authCodeRepository->persistNewAuthCode($authCode); - - return $authCode; + while ($maxGenerationAttempts-- > 0) { + $authCode->setIdentifier($this->generateUniqueIdentifier()); + try { + $this->authCodeRepository->persistNewAuthCode($authCode); + return $authCode; + } catch (UniqueTokenIdentifierConstraintViolationException $e) { + if ($maxGenerationAttempts === 0) { + throw $e; + } + } + } } /** @@ -377,14 +398,23 @@ abstract class AbstractGrant implements GrantTypeInterface */ protected function issueRefreshToken(AccessTokenEntityInterface $accessToken) { + $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; + $refreshToken = $this->refreshTokenRepository->getNewRefreshToken(); - $refreshToken->setIdentifier($this->generateUniqueIdentifier()); $refreshToken->setExpiryDateTime((new \DateTime())->add($this->refreshTokenTTL)); $refreshToken->setAccessToken($accessToken); - $this->refreshTokenRepository->persistNewRefreshToken($refreshToken); - - return $refreshToken; + while ($maxGenerationAttempts-- > 0) { + $refreshToken->setIdentifier($this->generateUniqueIdentifier()); + try { + $this->refreshTokenRepository->persistNewRefreshToken($refreshToken); + return $refreshToken; + } catch (UniqueTokenIdentifierConstraintViolationException $e) { + if ($maxGenerationAttempts === 0) { + throw $e; + } + } + } } /** diff --git a/tests/Grant/AuthCodeGrantTest.php b/tests/Grant/AuthCodeGrantTest.php index 09ea03e6..8537a1af 100644 --- a/tests/Grant/AuthCodeGrantTest.php +++ b/tests/Grant/AuthCodeGrantTest.php @@ -6,6 +6,7 @@ use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Grant\AuthCodeGrant; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; @@ -1183,4 +1184,299 @@ class AuthCodeGrantTest extends \PHPUnit_Framework_TestCase $this->assertEquals($e->getHint(), 'Check the `code_verifier` parameter'); } } -} \ No newline at end of file + + public function testAuthCodeRepositoryUniqueConstraintCheck() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + $authCodeRepository = $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(); + $authCodeRepository->method('getNewAuthCode')->willReturn(new AuthCodeEntity()); + + $authCodeRepository->expects($this->at(0))->method('persistNewAuthCode')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + $authCodeRepository->expects($this->at(1))->method('persistNewAuthCode'); + + $grant = new AuthCodeGrant( + $authCodeRepository, + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + + $this->assertTrue($grant->completeAuthorizationRequest($authRequest) instanceof RedirectResponse); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\OAuthServerException + * @expectedExceptionCode 7 + */ + public function testAuthCodeRepositoryFailToPersist() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + $authCodeRepository = $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(); + $authCodeRepository->method('getNewAuthCode')->willReturn(new AuthCodeEntity()); + $authCodeRepository->method('persistNewAuthCode')->willThrowException(OAuthServerException::serverError('something bad happened')); + + $grant = new AuthCodeGrant( + $authCodeRepository, + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + + $this->assertTrue($grant->completeAuthorizationRequest($authRequest) instanceof RedirectResponse); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + * @expectedExceptionCode 100 + */ + public function testAuthCodeRepositoryFailToPersistUniqueNoInfiniteLoop() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + $authCodeRepository = $this->getMockBuilder(AuthCodeRepositoryInterface::class)->getMock(); + $authCodeRepository->method('getNewAuthCode')->willReturn(new AuthCodeEntity()); + $authCodeRepository->method('persistNewAuthCode')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + + $grant = new AuthCodeGrant( + $authCodeRepository, + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + + $this->assertTrue($grant->completeAuthorizationRequest($authRequest) instanceof RedirectResponse); + } + + public function testRefreshTokenRepositoryUniqueConstraintCheck() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeEntity = new ScopeEntity(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + $scopeRepositoryMock->method('finalizeScopes')->willReturnArgument(0); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('getNewRefreshToken')->willReturn(new RefreshTokenEntity()); + $refreshTokenRepositoryMock->expects($this->at(0))->method('persistNewRefreshToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + $refreshTokenRepositoryMock->expects($this->at(1))->method('persistNewRefreshToken'); + + $grant = new AuthCodeGrant( + $this->getMock(AuthCodeRepositoryInterface::class), + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setRefreshTokenRepository($refreshTokenRepositoryMock); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + + $request = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'redirect_uri' => 'http://foo/bar', + 'code' => $this->cryptStub->doEncrypt( + json_encode( + [ + 'auth_code_id' => uniqid(), + 'expire_time' => time() + 3600, + 'client_id' => 'foo', + 'user_id' => 123, + 'scopes' => ['foo'], + 'redirect_uri' => 'http://foo/bar', + ] + ) + ), + ] + ); + + /** @var StubResponseType $response */ + $response = $grant->respondToAccessTokenRequest($request, new StubResponseType(), new \DateInterval('PT10M')); + + $this->assertTrue($response->getAccessToken() instanceof AccessTokenEntityInterface); + $this->assertTrue($response->getRefreshToken() instanceof RefreshTokenEntityInterface); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\OAuthServerException + * @expectedExceptionCode 7 + */ + public function testRefreshTokenRepositoryFailToPersist() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeEntity = new ScopeEntity(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + $scopeRepositoryMock->method('finalizeScopes')->willReturnArgument(0); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('getNewRefreshToken')->willReturn(new RefreshTokenEntity()); + $refreshTokenRepositoryMock->method('persistNewRefreshToken')->willThrowException(OAuthServerException::serverError('something bad happened')); + + $grant = new AuthCodeGrant( + $this->getMock(AuthCodeRepositoryInterface::class), + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setRefreshTokenRepository($refreshTokenRepositoryMock); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + + $request = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'redirect_uri' => 'http://foo/bar', + 'code' => $this->cryptStub->doEncrypt( + json_encode( + [ + 'auth_code_id' => uniqid(), + 'expire_time' => time() + 3600, + 'client_id' => 'foo', + 'user_id' => 123, + 'scopes' => ['foo'], + 'redirect_uri' => 'http://foo/bar', + ] + ) + ), + ] + ); + + /** @var StubResponseType $response */ + $response = $grant->respondToAccessTokenRequest($request, new StubResponseType(), new \DateInterval('PT10M')); + + $this->assertTrue($response->getAccessToken() instanceof AccessTokenEntityInterface); + $this->assertTrue($response->getRefreshToken() instanceof RefreshTokenEntityInterface); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + * @expectedExceptionCode 100 + */ + public function testRefreshTokenRepositoryFailToPersistUniqueNoInfiniteLoop() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeEntity = new ScopeEntity(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + $scopeRepositoryMock->method('finalizeScopes')->willReturnArgument(0); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('getNewRefreshToken')->willReturn(new RefreshTokenEntity()); + $refreshTokenRepositoryMock->method('persistNewRefreshToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + + $grant = new AuthCodeGrant( + $this->getMock(AuthCodeRepositoryInterface::class), + $this->getMock(RefreshTokenRepositoryInterface::class), + new \DateInterval('PT10M') + ); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setRefreshTokenRepository($refreshTokenRepositoryMock); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + + $request = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'grant_type' => 'authorization_code', + 'client_id' => 'foo', + 'redirect_uri' => 'http://foo/bar', + 'code' => $this->cryptStub->doEncrypt( + json_encode( + [ + 'auth_code_id' => uniqid(), + 'expire_time' => time() + 3600, + 'client_id' => 'foo', + 'user_id' => 123, + 'scopes' => ['foo'], + 'redirect_uri' => 'http://foo/bar', + ] + ) + ), + ] + ); + + /** @var StubResponseType $response */ + $response = $grant->respondToAccessTokenRequest($request, new StubResponseType(), new \DateInterval('PT10M')); + + $this->assertTrue($response->getAccessToken() instanceof AccessTokenEntityInterface); + $this->assertTrue($response->getRefreshToken() instanceof RefreshTokenEntityInterface); + } +} diff --git a/tests/Grant/ImplicitGrantTest.php b/tests/Grant/ImplicitGrantTest.php index fbf60b8c..0600d4c6 100644 --- a/tests/Grant/ImplicitGrantTest.php +++ b/tests/Grant/ImplicitGrantTest.php @@ -3,6 +3,8 @@ namespace LeagueTests\Grant; use League\OAuth2\Server\CryptKey; +use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Grant\ImplicitGrant; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\ClientRepositoryInterface; @@ -295,4 +297,76 @@ class ImplicitGrantTest extends \PHPUnit_Framework_TestCase $grant->completeAuthorizationRequest($authRequest); } + + public function testAccessTokenRepositoryUniqueConstraintCheck() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + /** @var AccessTokenRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject $accessTokenRepositoryMock */ + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->expects($this->at(0))->method('persistNewAccessToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + $accessTokenRepositoryMock->expects($this->at(1))->method('persistNewAccessToken')->willReturnSelf(); + + $grant = new ImplicitGrant(new \DateInterval('PT10M')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + + $this->assertTrue($grant->completeAuthorizationRequest($authRequest) instanceof RedirectResponse); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\OAuthServerException + * @expectedExceptionCode 7 + */ + public function testAccessTokenRepositoryFailToPersist() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + /** @var AccessTokenRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject $accessTokenRepositoryMock */ + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->method('persistNewAccessToken')->willThrowException(OAuthServerException::serverError('something bad happened')); + + $grant = new ImplicitGrant(new \DateInterval('PT10M')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + + $grant->completeAuthorizationRequest($authRequest); + } + + /** + * @expectedException \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + * @expectedExceptionCode 100 + */ + public function testAccessTokenRepositoryFailToPersistUniqueNoInfiniteLoop() + { + $authRequest = new AuthorizationRequest(); + $authRequest->setAuthorizationApproved(true); + $authRequest->setClient(new ClientEntity()); + $authRequest->setGrantTypeId('authorization_code'); + $authRequest->setUser(new UserEntity()); + + /** @var AccessTokenRepositoryInterface|\PHPUnit_Framework_MockObject_MockObject $accessTokenRepositoryMock */ + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->method('persistNewAccessToken')->willThrowException(UniqueTokenIdentifierConstraintViolationException::create()); + + $grant = new ImplicitGrant(new \DateInterval('PT10M')); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setPublicKey(new CryptKey('file://' . __DIR__ . '/../Stubs/public.key')); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + + $grant->completeAuthorizationRequest($authRequest); + } }