commit 0d37392bbe624f16c1b8d172f9d03c05808485d7 Author: ErickSkrauch Date: Fri Nov 25 14:26:29 2016 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c9c8f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/build +/vendor +composer.phar +composer.lock +.DS_Store diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ee3882a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: php + +php: + - 5.5 + - 5.6 + - 7.0 + - hhvm + +before_script: + - travis_retry composer self-update + - travis_retry composer install --no-interaction --prefer-source --dev + - travis_retry phpenv rehash + +script: + - ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd91054 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All Notable changes to `oauth2-ely` will be documented in this file + +## 1.0.0 - 2016-11-25 + +### Added +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c769bb3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/elyby/league-oauth2-ely). + +## Pull Requests + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the README and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow SemVer. Randomly breaking public APIs is not an option. + +- **Create topic branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. + +- **Ensure tests pass!** - Please run the tests (see below) before submitting your pull request, and make sure they pass. We won't accept a patch until all tests pass. + +- **Ensure no coding standards violations** - Please run PHP Code Sniffer using the PSR-2 standard (see below) before submitting your pull request. A violation will cause the build to fail, so please make sure there are no violations. We can't accept a patch if the build fails. + +## Running Tests + +``` bash +$ ./vendor/bin/phpunit +``` + +## Running PHP Code Sniffer + +``` bash +$ ./vendor/bin/phpcs src --standard=psr2 -sp +``` + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..54d2aba --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Ely.by + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0383f2 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Ely.by Provider for OAuth 2.0 Client + +This package provides Ely.by OAuth 2.0 support for the PHP League's +[OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client). + +## Installation + +To install, use composer: + +``` +composer require ely/oauth2-client +``` + +## Usage + +Usage is the same as The League's OAuth client, using `\Ely\OAuth2\Client\Provider` as the provider. You can find +more information in [League repository README](https://github.com/thephpleague/oauth2-client#authorization-code-grant). + +You can get your own `clientId` and `clientSecret` at [Ely.by Account OAuth2 registration page](#). + +```php + '{elyby-client-id}', + 'clientSecret' => '{elyby-client-secret}', + 'redirectUri' => 'http://example.com/callback-uri', +]); +``` + +We suggest to put this provider object into service locator for access it at any time or mock for testing. +In code below we think, that `$provider` contains our provider object. + +### Authorization Code Flow + +First of all, you must generate redirect user to route, which will set state session value and redirect user to Ely.by +authorization page. This can be done by such code, placed into controller: + +```php +getAuthorizationUrl(); +$_SESSION['oauth2state'] = $provider->getState(); +header('Location: ' . $authUrl); +exit(); +``` + +Note, that `getAuthorizationUrl()` takes as argument array of overriding parameters. For example, if you want request +additional scopes and change app description, then you must pass `scope` and `description` keys with needed values: + +```php +getAuthorizationUrl([ + 'scope' => ['account_info', 'account_email'], + 'description' => 'My super application!', +]); +``` + +After user finish authentication and authorization on Ely.by Account site, he will be redirected back, on `redirectUri`, +that you specified in Provider configuration. Inside redirectUri handler you must check for errors and state matches. +If all checks passed normal, then try to exchange received `auth_code` to `access_token`. This can be done by code +like below: + +```php +getAccessToken(new \League\OAuth2\Client\Grant\AuthorizationCode(), [ + 'code' => $_GET['code'], + ]); + + // Optional: Now you have a token you can look up a users account data + try { + // We got an access token, let's now get the user's details + $account = $provider->getResourceOwner($token); + + // Use these details to create a new profile + printf('Hello %s!', $account->getUsername()); + } catch (\Ely\OAuth2\Client\Exception\IdentityProviderException $e) { + // Failed to get user details + echo 'Cannot get user account identity. The error is ' . $e->getMessage(); + } + + // Use this to interact with an API on the users behalf + echo $token->getToken(); +} +``` + +## Refreshing a Token + +Refresh tokens are only provided to applications which request offline access. You can specify offline access by +setting the `scope` option on authorization url generating: + +```php +getAuthorizationUrl([ + 'scope' => ['account_info', 'account_email', 'offline_access'], +]); +``` + +It is important to note that the refresh token is only returned on the first request after this it will be null. +You should securely store the refresh token when it is returned: + +```php +getAccessToken('authorization_code', [ + 'code' => $code +]); + +// persist the token in a database +$refreshToken = $token->getRefreshToken(); +``` + +Now you have everything you need to refresh an access token using a refresh token: + +```php +getAccessToken(new League\OAuth2\Client\Grant\RefreshToken(), [ + 'refresh_token' => $refreshToken, +]); +``` + +## Testing + +```bash +$ ./vendor/bin/phpunit +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Credits + +This package was designed and developed within the [Ely.by](http://ely.by) project team. We also thank all the +[contributors](link-contributors) for their help. + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + +[link-contributors]: ../../contributors diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8a8a52e --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "ely/oauth2-client", + "description": "Ely.by provider for league/oauth2-client", + "license": "MIT", + "keywords": ["authorization", "authentication", "client", "league", "oauth", "oauth2", "ely", "elyby"], + "homepage": "https://github.com/elyby/league-oauth2-ely", + "type": "library", + "authors": [ + { + "name": "Ely.by team", + "email": "team@ely.by" + }, + { + "name": "ErickSkrauch", + "email": "erickskrauch@ely.by" + } + ], + "require": { + "league/oauth2-client": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.0", + "mockery/mockery": "~0.9" + }, + "autoload": { + "psr-4": { + "Ely\\OAuth2\\Client\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Ely\\OAuth2\\Client\\Test\\": "tests/src/" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b486c71 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + + + + ./tests/ + + + + + ./ + + ./vendor + ./tests + + + + diff --git a/src/Exception/IdentityProviderException.php b/src/Exception/IdentityProviderException.php new file mode 100644 index 0000000..8928407 --- /dev/null +++ b/src/Exception/IdentityProviderException.php @@ -0,0 +1,9 @@ +clientId; + } + + /** + * @inheritdoc + */ + public function getBaseAccessTokenUrl(array $params) + { + return 'https://account.ely.by/api/oauth2/v1/token'; + } + + /** + * @inheritdoc + */ + public function getResourceOwnerDetailsUrl(AccessToken $token) + { + return 'https://account.ely.by/api/account/v1/info'; + } + + /** + * @inheritdoc + */ + protected function getAuthorizationParameters(array $options) + { + $params = parent::getAuthorizationParameters($options); + // client_id applied to base url + // approval_prompt not supported + unset($params['client_id'], $params['approval_prompt']); + + return $params; + } + + /** + * @inheritdoc + */ + protected function getDefaultScopes() + { + return [ + 'account_info', + ]; + } + + /** + * @inheritdoc + */ + protected function checkResponse(ResponseInterface $response, $data) + { + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200 || isset($data['message'])) { + throw new IdentityProviderException($data['message'] ?: $response->getReasonPhrase(), $statusCode, $response); + } + } + + /** + * Generate a user object from a successful user details request. + * + * @param array $response + * @param AccessToken $token + * @return ResourceOwner + */ + protected function createResourceOwner(array $response, AccessToken $token) + { + return new ResourceOwner($response); + } +} diff --git a/src/ResourceOwner.php b/src/ResourceOwner.php new file mode 100644 index 0000000..83006c6 --- /dev/null +++ b/src/ResourceOwner.php @@ -0,0 +1,120 @@ +response = $response; + } + + /** + * Get resource owner id + * + * @return string + */ + public function getId() + { + return $this->response['id']; + } + + /** + * Get resource owner UUID + * + * @return string + */ + public function getUuid() + { + return $this->response['uuid']; + } + + /** + * Get resource owner current username + * + * @return string + */ + public function getUsername() + { + return $this->response['username']; + } + + /** + * Get resource owner confirmed E-mail. If you do not have permission 'account_email', + * then you will get null + * + * @return string|null + */ + public function getEmail() + { + return isset($this->response['email']) ? $this->response['email'] : null; + } + + /** + * Get resource owner registration date. + * + * @return DateTime + */ + public function getRegisteredAt() + { + return new DateTime('@' . $this->response['registeredAt']); + } + + /** + * Link to resource owner Ely.by profile + * + * @return string + */ + public function getProfileLink() + { + return $this->response['profileLink']; + } + + /** + * Get resource owner preferred language, that he used on Ely.by + * Language codes correspond to ISO 639-1 standard + * + * @return string + */ + public function getPreferredLanguage() + { + return $this->response['preferredLanguage']; + } + + /** + * Get resource owner current skin url. + * Remember that this is not a direct link to skin file. + * + * @return string + */ + public function getSkinUrl() + { + return "http://skinsystem.ely.by/skins/{$this->getUsername()}.png"; + } + + /** + * Return all of the owner details available as an array. + * + * @return array + */ + public function toArray() + { + return array_merge($this->response, [ + 'skinUrl' => $this->getSkinUrl(), + ]); + } +} diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php new file mode 100644 index 0000000..bb40322 --- /dev/null +++ b/tests/ProviderTest.php @@ -0,0 +1,161 @@ +provider = new Provider([ + 'clientId' => 'mock_client_id', + 'clientSecret' => 'mock_secret', + 'redirectUri' => 'none', + ]); + } + + public function tearDown() + { + m::close(); + parent::tearDown(); + } + + public function testGetResourceOwnerDetailsUrl() + { + $url = $this->provider->getResourceOwnerDetailsUrl(new AccessToken(['access_token' => 'mock_token'])); + $uri = parse_url($url); + $this->assertEquals('/api/account/v1/info', $uri['path']); + } + + public function testGetAuthorizationUrl() + { + $url = $this->provider->getAuthorizationUrl(); + $uri = parse_url($url); + parse_str($uri['query'], $query); + + $this->assertEquals('/oauth2/v1/mock_client_id', $uri['path']); + $this->assertArrayNotHasKey('client_id', $query); + $this->assertArrayHasKey('redirect_uri', $query); + $this->assertArrayHasKey('state', $query); + $this->assertArrayHasKey('scope', $query); + $this->assertArrayHasKey('response_type', $query); + $this->assertArrayNotHasKey('approval_prompt', $query); + $this->assertNotNull($this->provider->getState()); + } + + public function testScopes() + { + $options = ['scope' => ['minecraft_server_session', 'account_info']]; + + $url = $this->provider->getAuthorizationUrl($options); + + $this->assertContains(urlencode(implode(',', $options['scope'])), $url); + } + + public function testGetBaseAccessTokenUrl() + { + $params = []; + + $url = $this->provider->getBaseAccessTokenUrl($params); + $uri = parse_url($url); + + $this->assertEquals('/api/oauth2/v1/token', $uri['path']); + } + + public function testGetAccessToken() + { + /** @var m\Mock|ResponseInterface $response */ + $response = m::mock(ResponseInterface::class); + $response->shouldReceive('getBody')->andReturn($this->getAccessTokenResponse()); + $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $response->shouldReceive('getStatusCode')->andReturn(200); + + /** @var m\Mock|ClientInterface $client */ + $client = m::mock(ClientInterface::class); + $client->shouldReceive('send')->times(1)->andReturn($response); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + + $this->assertEquals('mock_access_token', $token->getToken()); + $this->assertNotNull($token->getExpires()); + $this->assertNull($token->getRefreshToken()); + } + + /** + * @expectedException \Ely\OAuth2\Client\Exception\IdentityProviderException + * @expectedExceptionMessageRegExp /Exception message .+/ + */ + public function testExceptionThrownWhenErrorObjectReceived() + { + $name = 'Error ' . uniqid(); + $message = 'Exception message ' . uniqid(); + $status = rand(400, 600); + /** @var m\Mock|ResponseInterface $postResponse */ + $postResponse = m::mock(ResponseInterface::class); + $postResponse->shouldReceive('getBody')->andReturn(json_encode([ + 'name' => $name, + 'message' => $message, + 'status' => $status, + 'code' => 0, + ])); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn($status); + + /** @var m\Mock|ClientInterface $client */ + $client = m::mock(ClientInterface::class); + $client->shouldReceive('send') + ->times(1) + ->andReturn($postResponse); + $this->provider->setHttpClient($client); + $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + } + + public function testGetResourceOwner() + { + /** @var m\Mock|ResponseInterface $postResponse */ + $postResponse = m::mock(ResponseInterface::class); + $postResponse->shouldReceive('getBody')->andReturn($this->getAccessTokenResponse()); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); + + /** @var m\Mock|ResponseInterface $userResponse */ + $userResponse = m::mock(ResponseInterface::class); + $userResponse->shouldReceive('getBody')->andReturn( + file_get_contents(__DIR__ . '/data/identity-info-response.json') + ); + $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + /** @var m\Mock|ClientInterface $client */ + $client = m::mock(ClientInterface::class); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $userResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $user = $this->provider->getResourceOwner($token); + + $this->assertInstanceOf(ResourceOwner::class, $user); + } + + private function getAccessTokenResponse() + { + return json_encode([ + 'access_token' => 'mock_access_token', + 'token_type' => 'bearer', + 'expires_in' => 3600, + ]); + } +} diff --git a/tests/ResourceOwnerTest.php b/tests/ResourceOwnerTest.php new file mode 100644 index 0000000..137d4a7 --- /dev/null +++ b/tests/ResourceOwnerTest.php @@ -0,0 +1,84 @@ +assertEquals(1, $this->createModel()->getId()); + } + + public function testGetUuid() + { + $this->assertEquals('ffc8fdc9-5824-509e-8a57-c99b940fb996', $this->createModel()->getUuid()); + } + + public function testGetUsername() + { + $this->assertEquals('ErickSkrauch', $this->createModel()->getUsername()); + } + + public function testGetEmail() + { + $this->assertEquals('erickskrauch@ely.by', $this->createModel()->getEmail()); + $this->assertNull($this->createModelWithoutEmail()->getEmail()); + } + + public function testGetRegisteredAt() + { + $registeredAt = $this->createModel()->getRegisteredAt(); + $this->assertInstanceOf(\DateTime::class, $registeredAt); + $this->assertEquals(1470566470, $registeredAt->getTimestamp()); + } + + public function testGetProfileLink() + { + $this->assertEquals('http://ely.by/u1', $this->createModel()->getProfileLink()); + } + + public function testGetPreferredLanguage() + { + $this->assertEquals('be', $this->createModel()->getPreferredLanguage()); + } + + public function testGetSkinUrl() + { + $this->assertEquals('http://skinsystem.ely.by/skins/ErickSkrauch.png', $this->createModel()->getSkinUrl()); + } + + public function testToArray() + { + $array = $this->createModel()->toArray(); + $this->assertTrue(is_array($array)); + $this->assertEquals(1, $array['id']); + $this->assertEquals('ffc8fdc9-5824-509e-8a57-c99b940fb996', $array['uuid']); + $this->assertEquals('ErickSkrauch', $array['username']); + $this->assertEquals('erickskrauch@ely.by', $array['email']); + $this->assertEquals(1470566470, $array['registeredAt']); + $this->assertEquals('http://ely.by/u1', $array['profileLink']); + $this->assertEquals('http://skinsystem.ely.by/skins/ErickSkrauch.png', $array['skinUrl']); + + $array = $this->createModelWithoutEmail()->toArray(); + $this->assertArrayNotHasKey('email', $array); + } + + private function createModelWithoutEmail() + { + $params = $this->getAllResponseParams(); + unset($params['email']); + + return new ResourceOwner($params); + } + + private function createModel() + { + return new ResourceOwner($this->getAllResponseParams()); + } + + private function getAllResponseParams() + { + return json_decode(file_get_contents(__DIR__ . '/data/identity-info-response.json'), true); + } +} diff --git a/tests/data/identity-info-response.json b/tests/data/identity-info-response.json new file mode 100644 index 0000000..7bae8c1 --- /dev/null +++ b/tests/data/identity-info-response.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "uuid": "ffc8fdc9-5824-509e-8a57-c99b940fb996", + "username": "ErickSkrauch", + "registeredAt": 1470566470, + "profileLink": "http:\/\/ely.by\/u1", + "preferredLanguage": "be", + "email": "erickskrauch@ely.by" +}