diff --git a/readme.md b/readme.md index ea70c5a4..17b76ccd 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,7 @@ composer require 'php-twinfield/twinfield:^3.0' You need to set up a `\PhpTwinfield\Secure\AuthenticatedConnection` class with your credentials. When using basic username and password authentication, the `\PhpTwinfield\Secure\WebservicesAuthentication` class should be used, as follows: +#### Username and password ```php $connection = new Secure\WebservicesAuthentication("username", "password", "organization"); ``` @@ -30,6 +31,7 @@ $office = Office::fromCode("someOfficeCode"); $officeApi = new \PhpTwinfield\ApiConnectors\OfficeApiConnector($connection); $officeApi->setOffice($office); ``` +#### oAuth2 In order to use OAuth2 to authenticate with Twinfield, one should use the `\PhpTwinfield\Secure\Provider\OAuthProvider` to retrieve an `\League\OAuth2\Client\Token\AccessToken` object, and extract the refresh token from this object. Furthermore, it is required to set up a default `\PhpTwinfield\Office`, that will be used during requests to Twinfield. **Please note:** when a different office is specified when sending a request through one of the `ApiConnectors`, this Office will override the default. @@ -47,6 +49,44 @@ $office = \PhpTwinfield\Office::fromCode("someOfficeCode"); $connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office); ``` + +In the case you have an existing accessToken object you may pass that to the constructor or set it, to limit the amount of access token and validate requests, since the accessToken is valid for 1 hour. + +```php +$provider = new OAuthProvider([ + 'clientId' => 'someClientId', + 'clientSecret' => 'someClientSecret', + 'redirectUri' => 'https://example.org/' +]); +$accessToken = $provider->getAccessToken("authorization_code", ["code" => ...]); +$refreshToken = $accessToken->getRefreshToken(); +$office = \PhpTwinfield\Office::fromCode("someOfficeCode"); + +$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office, $accessToken); +``` +or +```php +$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office); +$connection->setAccessToken($accessToken); +``` + +Setting an access token will force a new validation request on every login. + +It's also possible to provide callables, that will be called when a new access token is validated. +This way you're able to store the new 'validated' access token object and your application can re-use the token within an hour. +This way you can optimize the number of requests. + +**Be aware to store your access token in an appropriate and secure way. E.g. encrypting it.** + +```php +$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office, $accessToken); +$connection->registerAfterValidateCallback( + function(\League\OAuth2\Client\Token\AccessTokenInterface $accessToken) { + // Store the access token. + } +); +``` + For more information about retrieving the initial `AccessToken`, please refer to: https://github.com/thephpleague/oauth2-client#usage ### Getting data from the API diff --git a/src/Secure/OpenIdConnectAuthentication.php b/src/Secure/OpenIdConnectAuthentication.php index 36ee7a3f..a1d0aca8 100644 --- a/src/Secure/OpenIdConnectAuthentication.php +++ b/src/Secure/OpenIdConnectAuthentication.php @@ -3,6 +3,7 @@ namespace PhpTwinfield\Secure; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use League\OAuth2\Client\Token\AccessTokenInterface; use PhpTwinfield\Office; use PhpTwinfield\Secure\Provider\InvalidAccessTokenException; use PhpTwinfield\Secure\Provider\OAuthException; @@ -21,7 +22,7 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection private $provider; /** - * @var null|string + * @var AccessTokenInterface|null */ private $accessToken; @@ -36,9 +37,19 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection private $office; /** - * @var string + * @var null|string + */ + private $cluster = null; + + /** + * @var array */ - private $cluster; + private $afterValidateCallbacks = []; + + /** + * @var bool + */ + private $hasValidatedAccessToken = false; /** * The office code that is part of the Office object that is passed here will be @@ -48,11 +59,17 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection * Please note that for most calls an office is mandatory. If you do not supply it * you have to pass it with every request, or call setOffice. */ - public function __construct(OAuthProvider $provider, string $refreshToken, ?Office $office) + public function __construct( + OAuthProvider $provider, + string $refreshToken, + ?Office $office = null, + ?AccessTokenInterface $accessToken = null + ) { $this->provider = $provider; $this->refreshToken = $refreshToken; $this->office = $office; + $this->accessToken = $accessToken; } public function setOffice(?Office $office) @@ -66,10 +83,21 @@ protected function getCluster(): ?string return $this->cluster; } - protected function getSoapHeaders() + protected function setCluster(?string $cluster): self + { + $this->cluster = $cluster; + return $this; + } + + /** + * @throws InvalidAccessTokenException + */ + protected function getSoapHeaders(): \SoapHeader { + $this->throwExceptionMissingAccessToken(); + $headers = [ - "AccessToken" => $this->accessToken, + "AccessToken" => $this->getAccessToken()->getToken(), ]; // Watch out. When you don't supply an Office and do an authenticated call you will get an @@ -91,12 +119,26 @@ protected function getSoapHeaders() */ protected function login(): void { - if ($this->accessToken === null) { + // Refresh the token when it's not set or is set, but expired or incomplete. + if (!$this->hasAccessToken() || $this->isExpiredAccessToken()) { $this->refreshToken(); } - $validationResult = $this->validateToken(); - $this->cluster = $validationResult["twf.clusterUrl"]; + // There's no need to validate the access token if it's already validated and still valid. + if (!$this->hasValidatedAccessToken()) { + $validationResult = $this->validateToken(); + $this->setCluster($validationResult["twf.clusterUrl"]); + } + } + + public function hasValidatedAccessToken(): bool + { + return $this->hasValidatedAccessToken; + } + + protected function setValidatedAccessToken(bool $validated = true): void + { + $this->hasValidatedAccessToken = $validated; } /** @@ -109,34 +151,142 @@ protected function login(): void */ protected function validateToken(): array { - $validationUrl = "https://login.twinfield.com/auth/authentication/connect/accesstokenvalidation?token="; - $validationResult = @file_get_contents($validationUrl . urlencode($this->accessToken)); + $this->setValidatedAccessToken(false); + $this->throwExceptionMissingAccessToken(); - if ($validationResult === false) { - throw new InvalidAccessTokenException("Access token is invalid."); - } + $accessToken = $this->getAccessToken(); + $validationResult = $this->getValidationResult($accessToken); + $this->callAfterValidateCallbacks($accessToken); $resultDecoded = \json_decode($validationResult, true); if (\json_last_error() !== JSON_ERROR_NONE) { throw new OAuthException("Error while decoding JSON: " . \json_last_error_msg()); } + return $resultDecoded; } + /** + * @throws InvalidAccessTokenException + */ + protected function throwExceptionMissingAccessToken(): void + { + if (!$this->hasAccessToken()) { + throw new InvalidAccessTokenException("Missing access token."); + } + } + + /** + * @throws InvalidAccessTokenException + */ + protected function getValidationResult(AccessTokenInterface $accessToken): string + { + $validationUrl = "https://login.twinfield.com/auth/authentication/connect/accesstokenvalidation?token="; + $validationResult = @file_get_contents($validationUrl . urlencode($accessToken->getToken())); + + if ($validationResult === false) { + throw new InvalidAccessTokenException("Access token is invalid."); + } + + return $validationResult; + } + /** * @throws OAuthException */ protected function refreshToken(): void { + // If you pass an empty refresh token, it will try to derive it from the accessToken object + // If that is set. + $refreshToken = !empty($this->refreshToken) + ? $this->refreshToken + : ($this->hasAccessToken() + ? $this->getAccessToken()->getRefreshToken() ?? null + : null + ); + try { $accessToken = $this->provider->getAccessToken( "refresh_token", - ["refresh_token" => $this->refreshToken] + ["refresh_token" => $refreshToken] ); } catch (IdentityProviderException $e) { throw new OAuthException($e->getMessage(), 0, $e); } - $this->accessToken = $accessToken->getToken(); + $this->setAccessToken($accessToken); + } + + /** + * Validate if there's an access token, and it's not expired. + * Will return true when there's no access token or expired is not set. + * + * @return bool + */ + protected function isExpiredAccessToken(): bool + { + $accessToken = $this->getAccessToken(); + if ($accessToken instanceof AccessTokenInterface) { + try { + return $accessToken->hasExpired(); + } + catch (\Exception $e) {} + } + + return true; + } + + /** + * @return AccessTokenInterface|null + */ + public function getAccessToken(): ?AccessTokenInterface + { + return $this->accessToken; + } + + public function setAccessToken(?AccessTokenInterface $accessToken): self + { + $this->setValidatedAccessToken(false); + $this->accessToken = $accessToken; + + return $this; + } + + public function hasAccessToken(): bool + { + return $this->getAccessToken() instanceof AccessTokenInterface; + } + + /** + * Register callbacks that will be invoked with the accessToken after a new access token is fetched. + * You may use this callback to store the acquired access token. + * + * Be aware, this access token grants access to the entire twinfield administration. + * Please store it in a safe place, preferable encrypted. + * + * @param callable $callable + * @return $this + */ + public function registerAfterValidateCallback(callable $callable): self + { + $this->afterValidateCallbacks[] = $callable; + + return $this; + } + + protected function getAfterValidateCallbacks(): array + { + return $this->afterValidateCallbacks; + } + + protected function callAfterValidateCallbacks(AccessTokenInterface $accessToken): void + { + $callbacks = $this->getAfterValidateCallbacks(); + + if (!empty($callbacks)) { + foreach ($callbacks as $callback) { + $callback($accessToken); + } + } } } \ No newline at end of file diff --git a/tests/UnitTests/Secure/OpenIdConnectionAuthenticationTest.php b/tests/UnitTests/Secure/OpenIdConnectionAuthenticationTest.php index 52b108ab..2bddadb4 100644 --- a/tests/UnitTests/Secure/OpenIdConnectionAuthenticationTest.php +++ b/tests/UnitTests/Secure/OpenIdConnectionAuthenticationTest.php @@ -2,8 +2,10 @@ namespace PhpTwinfield\UnitTests\Secure; +use Closure; use Eloquent\Liberator\Liberator; use League\OAuth2\Client\Token\AccessToken; +use League\OAuth2\Client\Token\AccessTokenInterface; use PhpTwinfield\Office; use PhpTwinfield\Secure\OpenIdConnectAuthentication; use PhpTwinfield\Secure\Provider\InvalidAccessTokenException; @@ -71,18 +73,21 @@ public function testRefreshAccessTokenWhenAccessTokenIsNullAndLoginIsSuccessful( ); $openIdConnect = Liberator::liberate($openIdConnect); - $this->assertNull($openIdConnect->accessToken); + $this->assertNull($openIdConnect->getAccessToken()); $openIdConnect->login(); - $this->assertEquals("stub", $openIdConnect->accessToken); + $this->assertEquals("stub", $openIdConnect->getAccessToken()); $this->assertEquals("someClusterUrl", $openIdConnect->getCluster()); } public function testRefreshAndAccessTokenAreSetLoginSuccess() { $openIdConnect = $this->getMockBuilder(OpenIdConnectAuthentication::class) - ->setConstructorArgs([ $this->openIdProvider, "refresh", null ]) + ->setConstructorArgs([ $this->openIdProvider, "refresh", null, new AccessToken([ + 'access_token' => 'test', + 'expires_in' => time() + 1000, + ]) ]) ->setMethods(["validateToken", "refreshToken"]) ->getMock(); @@ -96,7 +101,6 @@ public function testRefreshAndAccessTokenAreSetLoginSuccess() ->method("refreshToken"); $openIdConnect = Liberator::liberate($openIdConnect); - $openIdConnect->accessToken = "test"; $openIdConnect->login(); @@ -122,4 +126,145 @@ public function testInvalidTokenLogin() $this->assertEquals("someClusterUrl", $openIdConnect->getCluster()); } + + public function testSetAccessToken(): void + { + $accessTokenMock = $this->createMock(AccessTokenInterface::class); + $openIdConnect = new OpenIdConnectAuthentication($this->openIdProvider, 'test'); + + $result = $openIdConnect->setAccessToken($accessTokenMock); + + $this->assertEquals($accessTokenMock, $openIdConnect->getAccessToken()); + $this->assertInstanceOf(OpenIdConnectAuthentication::class, $result); + } + + public function testGetAccessTokenSetFromConstructor(): void + { + $accessTokenMock = $this->createMock(AccessTokenInterface::class); + $openIdConnect = new OpenIdConnectAuthentication( + $this->openIdProvider, + 'test', + null, + $accessTokenMock + ); + + $this->assertEquals($accessTokenMock, $openIdConnect->getAccessToken()); + } + + public function testHasAccessToken(): void + { + $accessTokenMock = $this->createMock(AccessTokenInterface::class); + $openIdConnect = new OpenIdConnectAuthentication( + $this->openIdProvider, + 'test', + null, + $accessTokenMock + ); + + $this->assertTrue($openIdConnect->hasAccessToken()); + } + + public function testHasNoAccessToken(): void + { + $openIdConnect = new OpenIdConnectAuthentication($this->openIdProvider, 'test'); + + $this->assertFalse($openIdConnect->hasAccessToken()); + } + + public function testIsExpiredAccessTokenWithoutToken(): void + { + $openIdConnect = new OpenIdConnectAuthentication($this->openIdProvider, 'test'); + + $openIdConnect = Liberator::liberate($openIdConnect); + $result = $openIdConnect->isExpiredAccessToken(); + + $this->assertTrue($result); + } + + public function testIsExpiredAccessTokenWithToken(): void + { + $accessTokenMock = $this->createMock(AccessTokenInterface::class); + $accessTokenMock->expects($this->once()) + ->method('hasExpired') + ->willReturn(true); + + $openIdConnect = new OpenIdConnectAuthentication( + $this->openIdProvider, + 'test', + null, + $accessTokenMock + ); + + $openIdConnect = Liberator::liberate($openIdConnect); + $result = $openIdConnect->isExpiredAccessToken(); + + $this->assertTrue($result); + } + + public function testIsNotExpired(): void + { + $accessTokenMock = $this->createMock(AccessTokenInterface::class); + $accessTokenMock->expects($this->once()) + ->method('hasExpired') + ->willReturn(false); + + $openIdConnect = new OpenIdConnectAuthentication( + $this->openIdProvider, + 'test', + null, + $accessTokenMock + ); + + $openIdConnect = Liberator::liberate($openIdConnect); + $result = $openIdConnect->isExpiredAccessToken(); + + $this->assertFalse($result); + } + + public function testRegisterAfterValidateCallback(): void + { + $openIdConnect = new OpenIdConnectAuthentication($this->openIdProvider, 'test'); + $openIdConnect->registerAfterValidateCallback(function() {}); + + $openIdConnect = Liberator::liberate($openIdConnect); + $callbacks = $openIdConnect->getAfterValidateCallbacks(); + + $this->assertCount(1, $callbacks); + $this->assertInstanceOf(Closure::class, $callbacks[0]); + } + + public function testValidateTokenWithMissingAccessToken(): void + { + $this->expectException(InvalidAccessTokenException::class); + + $openIdConnect = new OpenIdConnectAuthentication($this->openIdProvider, 'test'); + $openIdConnect = Liberator::liberate($openIdConnect); + $openIdConnect->validateToken(); + } + + public function testCallAfterValidateCallbacks(): void + { + $accessToken = $this->createMock(AccessTokenInterface::class); + + $openIdConnect = $this->getMockBuilder(OpenIdConnectAuthentication::class) + ->setConstructorArgs([$this->openIdProvider, "refresh", null, $accessToken]) + ->setMethods(["getValidationResult"]) + ->getMock(); + $openIdConnect->expects($this->once()) + ->method('getValidationResult') + ->willReturn(json_encode(['test'])); + + $callableMock = $this->getMockBuilder('DoesNotExists') + ->setMockClassName('Foo') + ->setMethods(['callback']) + ->getMock(); + $callableMock->expects($this->once()) + ->method('callback') + ->with($accessToken); + + $openIdConnect->registerAfterValidateCallback([$callableMock, 'callback']); + + $openIdConnect = Liberator::liberate($openIdConnect); + $openIdConnect->validateToken(); + } }