diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6ef218 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea + diff --git a/Components/Oauth2/Entities/AccessTokenEntity.php b/Components/Oauth2/Entities/AccessTokenEntity.php new file mode 100644 index 0000000..da480ee --- /dev/null +++ b/Components/Oauth2/Entities/AccessTokenEntity.php @@ -0,0 +1,20 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Entities; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; + +class AccessTokenEntity implements AccessTokenEntityInterface +{ + use AccessTokenTrait, TokenEntityTrait, EntityTrait; +} \ No newline at end of file diff --git a/Components/Oauth2/Entities/ClientEntity.php b/Components/Oauth2/Entities/ClientEntity.php new file mode 100644 index 0000000..e2f293c --- /dev/null +++ b/Components/Oauth2/Entities/ClientEntity.php @@ -0,0 +1,29 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Entities; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\Traits\ClientTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; + +class ClientEntity implements ClientEntityInterface +{ + use EntityTrait, ClientTrait; + + public function setName($name) + { + $this->name = $name; + } + + public function setRedirectUri($uri) + { + $this->redirectUri = $uri; + } +} \ No newline at end of file diff --git a/Components/Oauth2/Entities/RefreshTokenEntity.php b/Components/Oauth2/Entities/RefreshTokenEntity.php new file mode 100644 index 0000000..ddbcb1f --- /dev/null +++ b/Components/Oauth2/Entities/RefreshTokenEntity.php @@ -0,0 +1,19 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Entities; + +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; + +class RefreshTokenEntity implements RefreshTokenEntityInterface +{ + use RefreshTokenTrait, EntityTrait; +} \ No newline at end of file diff --git a/Components/Oauth2/Entities/ScopeEntity.php b/Components/Oauth2/Entities/ScopeEntity.php new file mode 100644 index 0000000..47b99cb --- /dev/null +++ b/Components/Oauth2/Entities/ScopeEntity.php @@ -0,0 +1,23 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Entities; + +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; + +class ScopeEntity implements ScopeEntityInterface +{ + use EntityTrait; + + public function jsonSerialize() + { + return $this->getIdentifier(); + } +} \ No newline at end of file diff --git a/Components/Oauth2/Entities/UserEntity.php b/Components/Oauth2/Entities/UserEntity.php new file mode 100644 index 0000000..53685ad --- /dev/null +++ b/Components/Oauth2/Entities/UserEntity.php @@ -0,0 +1,32 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Entities; + +use League\OAuth2\Server\Entities\UserEntityInterface; + +class UserEntity implements UserEntityInterface +{ + private $user; + + public function __construct($userArray) + { + $this->user = $userArray; + } + + /** + * Return the user's identifier. + * + * @return mixed + */ + public function getIdentifier() + { + return $this->user['id']; + } +} \ No newline at end of file diff --git a/Components/Oauth2/GenerateResult.php b/Components/Oauth2/GenerateResult.php new file mode 100644 index 0000000..9736e0e --- /dev/null +++ b/Components/Oauth2/GenerateResult.php @@ -0,0 +1,25 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Repositories; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use Phalcon2Rest\Components\Oauth2\Entities\AccessTokenEntity; + +class AccessTokenRepository implements AccessTokenRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) + { + // Some logic here to save the access token to a database + } + + /** + * {@inheritdoc} + */ + public function revokeAccessToken($tokenId) + { + // Some logic here to revoke the access token + } + + /** + * {@inheritdoc} + */ + public function isAccessTokenRevoked($tokenId) + { + return false; // Access token hasn't been revoked + } + + /** + * {@inheritdoc} + */ + public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) + { + + $accessToken = new AccessTokenEntity(); + $accessToken->setClient($clientEntity); + foreach ($scopes as $scope) { + $accessToken->addScope($scope); + } + $accessToken->setUserIdentifier($userIdentifier); + + return $accessToken; + } +} \ No newline at end of file diff --git a/Components/Oauth2/Repositories/ClientRepository.php b/Components/Oauth2/Repositories/ClientRepository.php new file mode 100644 index 0000000..8315012 --- /dev/null +++ b/Components/Oauth2/Repositories/ClientRepository.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Repositories; + +use League\OAuth2\Server\Repositories\ClientRepositoryInterface; +use Phalcon\Security; +use Phalcon2Rest\Components\Oauth2\Entities\ClientEntity; +use Phalcon\Di\FactoryDefault as Di; +use Phalcon2Rest\Models\Clients; + +class ClientRepository implements ClientRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function getClientEntity($clientIdentifier, $grantType, $clientSecret = null, $mustValidateSecret = true) + { + $di = new Di(); + /** @var Security $security */ + $security = $di->getShared('security'); + $client = Clients::query() + ->where("id = :id:") + ->bind([ + 'id' => $clientIdentifier + ]) + ->limit(1) + ->execute() + ->toArray(); + $correctDetails = false; + if (count($client) === 1) { + $client = current($client); + if ($mustValidateSecret) { + + if ($security->checkHash($clientSecret, $client['secret'])) { + $correctDetails = true; + } else { + $security->hash(rand()); + + } + } else { + $correctDetails = true; + } + } else { + // prevent timing attacks + $security->hash(rand()); + } + + if ($correctDetails) { + $clientEntity = new ClientEntity(); + $clientEntity->setIdentifier($clientIdentifier); + $clientEntity->setName($client['name']); + $clientEntity->setRedirectUri($client['redirect_url']); + return $clientEntity; + } + return null; + } +} \ No newline at end of file diff --git a/Components/Oauth2/Repositories/RefreshTokenRepository.php b/Components/Oauth2/Repositories/RefreshTokenRepository.php new file mode 100644 index 0000000..3cafabf --- /dev/null +++ b/Components/Oauth2/Repositories/RefreshTokenRepository.php @@ -0,0 +1,49 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Repositories; + +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use Phalcon2Rest\Components\Oauth2\Entities\RefreshTokenEntity; + +class RefreshTokenRepository implements RefreshTokenRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntityInterface) + { + // Some logic to persist the refresh token in a database + } + + /** + * {@inheritdoc} + */ + public function revokeRefreshToken($tokenId) + { + // Some logic to revoke the refresh token in a database + } + + /** + * {@inheritdoc} + */ + public function isRefreshTokenRevoked($tokenId) + { + return false; // The refresh token has not been revoked + } + + /** + * {@inheritdoc} + */ + public function getNewRefreshToken() + { + return new RefreshTokenEntity(); + } +} \ No newline at end of file diff --git a/Components/Oauth2/Repositories/ScopeRepository.php b/Components/Oauth2/Repositories/ScopeRepository.php new file mode 100644 index 0000000..f720568 --- /dev/null +++ b/Components/Oauth2/Repositories/ScopeRepository.php @@ -0,0 +1,53 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Repositories; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; +use Phalcon2Rest\Components\Oauth2\Entities\ScopeEntity; + +class ScopeRepository implements ScopeRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function getScopeEntityByIdentifier($scopeIdentifier) + { + $scopes = [ + 'basic' => [ + 'description' => 'Basic details about you', + ], + 'email' => [ + 'description' => 'Your email address', + ], + ]; + + if (array_key_exists($scopeIdentifier, $scopes) === false) { + return; + } + + $scope = new ScopeEntity(); + $scope->setIdentifier($scopeIdentifier); + + return $scope; + } + + /** + * {@inheritdoc} + */ + public function finalizeScopes( + array $scopes, + $grantType, + ClientEntityInterface $clientEntity, + $userIdentifier = null + ) { + return $scopes; + } +} \ No newline at end of file diff --git a/Components/Oauth2/Repositories/UserRepository.php b/Components/Oauth2/Repositories/UserRepository.php new file mode 100644 index 0000000..71129cf --- /dev/null +++ b/Components/Oauth2/Repositories/UserRepository.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace Phalcon2Rest\Components\Oauth2\Repositories; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Repositories\UserRepositoryInterface; +use Phalcon\Di\FactoryDefault as Di; +use Phalcon\Security; +use Phalcon2Rest\Components\Oauth2\Entities\UserEntity; +use Phalcon2Rest\Models\Users; + +class UserRepository implements UserRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function getUserEntityByUserCredentials( + $username, + $password, + $grantType, + ClientEntityInterface $clientEntity + ) { + $di = new Di(); + /** @var Security $security */ + $security = $di->getShared('security'); + $user = Users::query() + ->where("username = :username:") + ->bind([ + 'username' => $username + ]) + ->limit(1) + ->execute() + ->toArray(); + $correctDetails = false; + if (count($user) === 1) { + $user = current($user); + if ($security->checkHash($password, $user['password'])) { + $correctDetails = true; + } else { + $security->hash(rand()); + } + } else { + // prevent timing attacks + $security->hash(rand()); + } + if ($correctDetails) { + //$scope = new ScopeEntity(); + //$scope->setIdentifier('email'); + //$scopes[] = $scope; + + return new UserEntity($user); + } + return null; + } +} \ No newline at end of file diff --git a/Components/Oauth2/Request.php b/Components/Oauth2/Request.php new file mode 100644 index 0000000..7e79835 --- /dev/null +++ b/Components/Oauth2/Request.php @@ -0,0 +1,182 @@ +request = $request; + } + + public function getParsedBody() + { + $input = file_get_contents("php://input"); + $result = []; + $variables = explode('&', $input); + foreach ($variables as $variable) { + $param = explode('=', $variable); + if (count($param) === 2) { + $result[$param[0]] = $param[1]; + } + } + return $result; + } + + public function getProtocolVersion() + { + // TODO: Implement getProtocolVersion() method. + } + + public function withProtocolVersion($version) + { + // TODO: Implement withProtocolVersion() method. + } + + public function getHeaders() + { + // TODO: Implement getHeaders() method. + } + + public function hasHeader($name) + { + // TODO: Implement hasHeader() method. + } + + public function getHeader($name) + { + // TODO: Implement getHeader() method. + if ($name === 'authorization') { + return [$_SERVER['HTTP_AUTHORIZATION']]; + } + return []; + } + + public function getHeaderLine($name) + { + // TODO: Implement getHeaderLine() method. + } + + public function withHeader($name, $value) + { + // TODO: Implement withHeader() method. + } + + public function withAddedHeader($name, $value) + { + // TODO: Implement withAddedHeader() method. + } + + public function withoutHeader($name) + { + // TODO: Implement withoutHeader() method. + } + + public function getBody() + { + // TODO: Implement getBody() method. + } + + public function withBody(StreamInterface $body) + { + // TODO: Implement withBody() method. + } + + public function getRequestTarget() + { + // TODO: Implement getRequestTarget() method. + } + + public function withRequestTarget($requestTarget) + { + // TODO: Implement withRequestTarget() method. + } + + public function getMethod() + { + // TODO: Implement getMethod() method. + } + + public function withMethod($method) + { + // TODO: Implement withMethod() method. + } + + public function getUri() + { + // TODO: Implement getUri() method. + } + + public function withUri(UriInterface $uri, $preserveHost = false) + { + // TODO: Implement withUri() method. + } + + public function getServerParams() + { + // TODO: Implement getServerParams() method. + } + + public function getCookieParams() + { + // TODO: Implement getCookieParams() method. + } + + public function withCookieParams(array $cookies) + { + // TODO: Implement withCookieParams() method. + } + + public function getQueryParams() + { + // TODO: Implement getQueryParams() method. + } + + public function withQueryParams(array $query) + { + // TODO: Implement withQueryParams() method. + } + + public function getUploadedFiles() + { + // TODO: Implement getUploadedFiles() method. + } + + public function withUploadedFiles(array $uploadedFiles) + { + // TODO: Implement withUploadedFiles() method. + } + + public function withParsedBody($data) + { + // TODO: Implement withParsedBody() method. + } + + public function getAttributes() + { + // TODO: Implement getAttributes() method. + } + + public function getAttribute($name, $default = null) + { + // TODO: Implement getAttribute() method. + } + + public function withAttribute($name, $value) + { + // TODO: Implement withAttribute() method. + $_SERVER[$name] = $value; + return $this; + } + + public function withoutAttribute($name) + { + // TODO: Implement withoutAttribute() method. + } +} \ No newline at end of file diff --git a/Components/Oauth2/Response.php b/Components/Oauth2/Response.php new file mode 100644 index 0000000..f1ff0ea --- /dev/null +++ b/Components/Oauth2/Response.php @@ -0,0 +1,90 @@ +stream = new Stream(); + return $this->stream; + } + + public function withBody(StreamInterface $body) + { + // TODO: Implement withBody() method. + } + + public function getStatusCode() + { + // TODO: Implement getStatusCode() method. + } + + public function withStatus($code, $reasonPhrase = '') + { + // TODO: Implement withStatus() method. + return $this; + } + + public function getReasonPhrase() + { + // TODO: Implement getReasonPhrase() method. + } + + public function getToken() + { + return $this->stream->getToken(); + } +} \ No newline at end of file diff --git a/Components/Oauth2/Stream.php b/Components/Oauth2/Stream.php new file mode 100644 index 0000000..170fd21 --- /dev/null +++ b/Components/Oauth2/Stream.php @@ -0,0 +1,90 @@ +token; + } + + public function getToken() + { + return $this->token; + } + + public function close() + { + // TODO: Implement close() method. + } + + public function detach() + { + // TODO: Implement detach() method. + } + + public function getSize() + { + // TODO: Implement getSize() method. + } + + public function tell() + { + // TODO: Implement tell() method. + } + + public function eof() + { + // TODO: Implement eof() method. + } + + public function isSeekable() + { + // TODO: Implement isSeekable() method. + } + + public function seek($offset, $whence = SEEK_SET) + { + // TODO: Implement seek() method. + } + + public function rewind() + { + // TODO: Implement rewind() method. + } + + public function isWritable() + { + // TODO: Implement isWritable() method. + } + + public function write($string) + { + // TODO: Implement write() method. + $this->token = $string; + } + + public function isReadable() + { + // TODO: Implement isReadable() method. + } + + public function read($length) + { + // TODO: Implement read() method. + } + + public function getContents() + { + // TODO: Implement getContents() method. + } + + public function getMetadata($key = null) + { + // TODO: Implement getMetadata() method. + } +} \ No newline at end of file diff --git a/Exceptions/HttpException.php b/Exceptions/HttpException.php new file mode 100644 index 0000000..2d3676a --- /dev/null +++ b/Exceptions/HttpException.php @@ -0,0 +1,130 @@ +message = $message; + $this->devMessage = (array_key_exists('dev', $errorArray) ? $errorArray['dev'] : ''); + $this->errorCode = (array_key_exists('internalCode', $errorArray) ? $errorArray['internalCode'] : ''); + $this->code = $code; + $this->additionalInfo = (array_key_exists('more', $errorArray) ? $errorArray['more'] : ''); + $this->response = $this->getResponseDescription($code); + } + + public function send() { + $di = Di::getDefault(); + + $res = $di->get('response'); + $req = $di->get('request'); + + //query string, filter, default + if (!$req->get('suppress_response_codes', null, null)) { + $res->setStatusCode($this->getCode(), $this->response)->sendHeaders(); + } else { + $res->setStatusCode('200', 'OK')->sendHeaders(); + } + + $error = [ + 'errorCode' => $this->getCode(), + 'userMessage' => $this->getMessage(), + 'devMessage' => $this->devMessage, + 'more' => $this->additionalInfo, + 'applicationCode' => $this->errorCode, + ]; + + if (!$req->get('type') || $req->get('type') === 'json') { + $response = new JsonResponse(); + $response->send($error, true); + return false; + } elseif ($req->get('type') === 'csv') { + $response = new CsvResponse(); + $response->send(array($error)); + return false; + } + + error_log('HTTPException: ' . $this->getFile() . ' at ' . $this->getLine()); + + return true; + } + + protected function getResponseDescription($code) { + $codes = [ + + // Informational 1xx + 100 => 'Continue', + 101 => 'Switching Protocols', + + // Success 2xx + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + + // Redirection 3xx + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', // 1.1 + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + // 306 is deprecated but reserved + 307 => 'Temporary Redirect', + + // Client Error 4xx + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Requested Range Not Satisfiable', + 417 => 'Expectation Failed', + + // Server Error 5xx + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 509 => 'Bandwidth Limit Exceeded' + ]; + + $result = (array_key_exists($code, $codes) ? + $codes[$code] : + 'Unknown Status Code' + ); + + return $result; + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0074126 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (C) Stanimir Stoyanov + +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. \ No newline at end of file diff --git a/Models/AccessTokens.php b/Models/AccessTokens.php new file mode 100644 index 0000000..22521ec --- /dev/null +++ b/Models/AccessTokens.php @@ -0,0 +1,28 @@ +hasMany('userId', 'Users', 'id'); + } +} \ No newline at end of file diff --git a/Models/Books.php b/Models/Books.php new file mode 100644 index 0000000..0854cc9 --- /dev/null +++ b/Models/Books.php @@ -0,0 +1,23 @@ +hasMany('userId', 'Users', 'id'); + } +} \ No newline at end of file diff --git a/Models/Users.php b/Models/Users.php new file mode 100644 index 0000000..95bdf58 --- /dev/null +++ b/Models/Users.php @@ -0,0 +1,56 @@ +hasMany("id", "AuthorizedClients", "userId"); + } + + /** + * Validates a model before submitting it for creation or deletion. Our Princess model + * must not be born before now, as we don't support future princesses. + * @return bool + * @throws HttpException If the validation failed + */ + public function validation() { + $this->validate(new Uniqueness(array( + "field" => "username", + "message" => "Value of field 'username' is already present in another record" + ))); + if ($this->validationHasFailed() == true) { + throw new HttpException( + $this->appendMessage($this->getMessages()), + 417 + ); + } + + return true; + } + +} \ No newline at end of file diff --git a/Modules/V1/Controllers/AccessTokenController.php b/Modules/V1/Controllers/AccessTokenController.php new file mode 100644 index 0000000..39ae7e0 --- /dev/null +++ b/Modules/V1/Controllers/AccessTokenController.php @@ -0,0 +1,169 @@ +di->get('authorizationServer'); + $allowedGrandTypes = ['client_credentials', 'password']; + $error = null; + $result = []; + $grant_type = $this->request->getPost('grant_type'); + $request = new Request($this->request); + $response = new Response(); + switch($grant_type) { + case 'password': + try { + // Try to respond to the request + $server->respondToAccessTokenRequest($request, $response); + $result = $response->getToken(); + } catch (OAuthServerException $exception) { + switch($exception->getCode()) { + case 6: + $error = [ + 'Wrong credentials', + 401, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1007', + 'more' => '' + ] + ]; + break; + default: + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1006', + 'more' => '' + ] + ]; + } + + } catch (\Exception $exception) { + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1005', + 'more' => '' + ] + ]; + } + break; + case 'client_credentials': + try { + // Try to respond to the request + $server->respondToAccessTokenRequest($request, $response); + $result = $response->getToken(); + } catch (OAuthServerException $exception) { + switch ($exception->getCode()) { + case 2: + $error = [ + "Missing parameters", + 400, + [ + 'dev' => 'client_id, client_secret and scope must be sent as well', + 'internalCode' => 'P1002', + 'more' => '' + ] + ]; + break; + case 4: + $error = [ + 'Wrong credentials', + 401, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1007', + 'more' => '' + ] + ]; + break; + default: + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1004', + 'more' => '' + ] + ]; + } + } catch (\Exception $exception) { + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1003', + 'more' => '' + ] + ]; + } + break; + case 'refresh_token': + + try { + // Try to respond to the request + $server->respondToAccessTokenRequest($request, $response); + $result = $response->getToken(); + } catch (OAuthServerException $exception) { + switch ($exception->getCode()) { + default: + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1004', + 'more' => '' + ] + ]; + } + } catch (\Exception $exception) { + $error = [ + 'Unknown error', + 500, + [ + 'dev' => $exception->getMessage(), + 'internalCode' => 'P1003', + 'more' => '' + ] + ]; + } + + break; + default: + $error = [ + "The grant type is not allowed {$grant_type}", + 400, + [ + 'dev' => "Allowed grant types are: " . implode(', ', $allowedGrandTypes), + 'internalCode' => 'P1001', + 'more' => '' + ] + ]; + } + if ($error !== null && is_array($error) && count($error) === 3) { + throw new HttpException( + $error[0], + $error[1], + null, + $error[2] + ); + } + return json_decode($result, true); + } +} \ No newline at end of file diff --git a/Modules/V1/Controllers/BaseController.php b/Modules/V1/Controllers/BaseController.php new file mode 100644 index 0000000..826c9bf --- /dev/null +++ b/Modules/V1/Controllers/BaseController.php @@ -0,0 +1,18 @@ +setDI($di); + } + +} \ No newline at end of file diff --git a/Modules/V1/Controllers/ExampleController.php b/Modules/V1/Controllers/ExampleController.php new file mode 100644 index 0000000..8350dc2 --- /dev/null +++ b/Modules/V1/Controllers/ExampleController.php @@ -0,0 +1,120 @@ + ['id', 'author', 'title', 'year'], + 'partials' => ['id', 'author', 'title', 'year'] + ]; + + public function get() { + if ($this->isSearch) { + $results = $this->search(); + } else { + $results = Books::find()->toArray(); + } + + return $this->respond($results); + } + + public function getOne($id) { + return $this->respond(Books::findFirst($id)->toArray()); + } + + public function post() { + return ['Post / stub']; + } + + /** + * @param int $id + * @return array + */ + public function delete($id) { + return ['Delete / stub']; + } + + /** + * @param int $id + * @return array + */ + public function put($id) { + return ['Put / stub']; + } + + /** + * @param int $id + * @return array + */ + public function patch($id) { + return ['Patch / stub']; + } + + public function search() { + $results = []; + $allBooks = Books::find()->toArray(); + foreach ($allBooks as $record) { + $match = true; + foreach ($this->searchFields as $field => $value) { + if (strpos($record[$field], $value) === FALSE) { + $match = false; + } + } + if ($match) { + $results[] = $record; + } + } + + return $results; + } + + public function respond($results) { + if (count($results) > 0) { + if ($this->isPartial) { + $newResults = []; + $remove = array_diff(array_keys($results[0]), $this->partialFields); + foreach ($results as $record) { + $newResults[] = $this->array_remove_keys($record, $remove); + } + $results = $newResults; + } + if ($this->offset) { + $results = array_slice($results, $this->offset); + } + if ($this->limit) { + $results = array_slice($results, 0, $this->limit); + } + } + return $results; + } + + private function array_remove_keys($array, $keys = []) { + + // If array is empty or not an array at all, don't bother + // doing anything else. + if (empty($array) || (! is_array($array))) { + return $array; + } + + // At this point if $keys is not an array, we can't do anything with it. + if (!is_array($keys)) { + return $array; + } + + // array_diff_key() expected an associative array. + $assocKeys = array(); + foreach($keys as $key) { + $assocKeys[$key] = true; + } + + return array_diff_key($array, $assocKeys); + } + +} \ No newline at end of file diff --git a/Modules/V1/Controllers/RestController.php b/Modules/V1/Controllers/RestController.php new file mode 100644 index 0000000..bb7b35c --- /dev/null +++ b/Modules/V1/Controllers/RestController.php @@ -0,0 +1,263 @@ + [], + 'partials' => [] + ]; + + /** + * Constructor, calls the parse method for the query string by default. + * @param boolean $parseQueryString true Can be set to false if a controller needs to be called + * from a different controller, bypassing the $allowedFields parse + */ + public function __construct($parseQueryString = true) { + parent::__construct(); + if ($parseQueryString){ + $this->parseRequest($this->allowedFields); + } + + return; + } + + /** + * Parses out the search parameters from a request. + * Unparsed, they will look like this: + * (name:Benjamin Framklin,location:Philadelphia) + * Parsed: + * ['name'=>'Benjamin Franklin', 'location'=>'Philadelphia'] + * @param string $unparsed Unparsed search string + * @return array An array of fieldname=>value search parameters + */ + protected function parseSearchParameters($unparsed) { + + // Strip parentheses that come with the request string + $unparsed = trim($unparsed, '()'); + + // Now we have an array of "key:value" strings. + $splitFields = explode(',', $unparsed); + $mapped = []; + + // Split the strings at their colon, set left to key, and right to value. + foreach ($splitFields as $field) { + $splitField = explode(':', $field); + $mapped[$splitField[0]] = $splitField[1]; + } + + return $mapped; + } + + /** + * Parses out partial fields to return in the response. + * Unparsed: + * (id,name,location) + * Parsed: + * ['id', 'name', 'location'] + * @param string $unparsed Un-parsed string of fields to return in partial response + * @return array Array of fields to return in partial response + */ + protected function parsePartialFields($unparsed) { + return explode(',', trim($unparsed, '()')); + } + + /** + * Main method for parsing a query string. + * Finds search paramters, partial response fields, limits, and offsets. + * Sets Controller fields for these variables. + * + * @param array $allowedFields Allowed fields array for search and partials + * @return boolean Always true if no exception is thrown + * @throws HttpException If some of the fields requested are not allowed + */ + protected function parseRequest($allowedFields) { + $request = $this->di->get('request'); + $searchParams = $request->get('q', null, null); + $fields = $request->get('fields', null, null); + + // Set limits and offset, elsewise allow them to have defaults set in the Controller + $this->limit = ($request->get('limit', null, null)) ?: $this->limit; + $this->offset = ($request->get('offset', null, null)) ?: $this->offset; + + // If there's a 'q' parameter, parse the fields, then determine that all the fields in the search + // are allowed to be searched from $allowedFields['search'] + if ($searchParams) { + $this->isSearch = true; + $this->searchFields = $this->parseSearchParameters($searchParams); + + // This handy snippet determines if searchFields is a strict subset of allowedFields['search'] + if (array_diff(array_keys($this->searchFields), $this->allowedFields['search'])) { + throw new HttpException( + "The fields you specified cannot be searched.", + 401, + null, + [ + 'dev' => 'You requested to search fields that are not available to be searched.', + 'internalCode' => 'S1000', + 'more' => '' // Could have link to documentation here. + ] + ); + } + } + + // If there's a 'fields' paramter, this is a partial request. Ensures all the requested fields + // are allowed in partial responses. + if ($fields) { + $this->isPartial = true; + $this->partialFields = $this->parsePartialFields($fields); + + // Determines if fields is a strict subset of allowed fields + if (array_diff($this->partialFields, $this->allowedFields['partials'])) { + throw new HttpException( + "The fields you asked for cannot be returned.", + 401, + null, + [ + 'dev' => 'You requested to return fields that are not available to be returned in partial responses.', + 'internalCode' => 'P1000', + 'more' => '' // Could have link to documentation here. + ] + ); + } + } + + return true; + } + + /** + * Provides a base CORS policy for routes like '/users' that represent a Resource's base url + * Origin is allowed from all urls. Setting it here using the Origin header from the request + * allows multiple Origins to be served. It is done this way instead of with a wildcard '*' + * because wildcard requests are not supported when a request needs credentials. + * + * @return true + */ + public function optionsBase() { + $response = $this->di->get('response'); + $methods = []; + foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) { + if (method_exists($this, $method)) { + array_push($methods, strtoupper($method)); + if ($method === 'get') { + array_push($methods, 'HEAD'); + } + } + } + array_push($methods, 'OPTIONS'); + $response->setHeader('Access-Control-Allow-Methods', implode(', ', $methods)); + $response->setHeader('Access-Control-Allow-Origin', '*'); + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + $response->setHeader('Access-Control-Allow-Headers', "origin, x-requested-with, content-type"); + $response->setHeader('Access-Control-Max-Age', '86400'); + return true; + } + + /** + * Provides a CORS policy for routes like '/users/123' that represent a specific resource + * + * @return true + */ + public function optionsOne() { + $response = $this->di->get('response'); + $response->setHeader('Access-Control-Allow-Methods', 'GET, PUT, PATCH, DELETE, OPTIONS, HEAD'); + $response->setHeader('Access-Control-Allow-Origin', $this->di->get('request')->header('Origin')); + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + $response->setHeader('Access-Control-Allow-Headers', "origin, x-requested-with, content-type"); + $response->setHeader('Access-Control-Max-Age', '86400'); + return true; + } + + /** + * Should be called by methods in the controllers that need to output results to the HTTP Response. + * Ensures that arrays conform to the patterns required by the Response objects. + * + * @param array $recordsArray Array of records to format as return output + * @return array Output array. If there are records (even 1), every record will be an array ex: [['id'=>1],['id'=>2]] + * @throws HttpException If there is a problem with the records + */ + protected function respond($recordsArray) { + + if (!is_array($recordsArray)) { + // This is bad. Throw a 500. Responses should always be arrays. + throw new HttpException( + "An error occurred while retrieving records.", + 500, + null, + array( + 'dev' => 'The records returned were malformed.', + 'internalCode' => 'RESP1000', + 'more' => '' + ) + ); + } + + // No records returned, so return an empty array + if (count($recordsArray) === 0) { + return []; + } + + return [$recordsArray]; + + } + +} \ No newline at end of file diff --git a/Modules/V1/Routes/collections/example.php b/Modules/V1/Routes/collections/example.php new file mode 100644 index 0000000..a38d19e --- /dev/null +++ b/Modules/V1/Routes/collections/example.php @@ -0,0 +1,42 @@ +setPrefix('/v1/example') + // Must be a string in order to support lazy loading + ->setHandler('\Phalcon2Rest\Modules\V1\Controllers\ExampleController') + ->setLazy(true); + + // Set Access-Control-Allow headers. + $exampleCollection->options('/', 'optionsBase'); + $exampleCollection->options('/{id}', 'optionsOne'); + + // First parameter is the route, which with the collection prefix here would be GET /example/ + // Second parameter is the function name of the Controller. + $exampleCollection->get('/', 'get'); + // This is exactly the same execution as GET, but the Response has no body. + $exampleCollection->head('/', 'get'); + + // $id will be passed as a parameter to the Controller's specified function + $exampleCollection->get('/{id:[0-9]+}', 'getOne'); + $exampleCollection->head('/{id:[0-9]+}', 'getOne'); + $exampleCollection->post('/', 'post'); + $exampleCollection->delete('/{id:[0-9]+}', 'delete'); + $exampleCollection->put('/{id:[0-9]+}', 'put'); + $exampleCollection->patch('/{id:[0-9]+}', 'patch'); + + return $exampleCollection; +}); \ No newline at end of file diff --git a/Modules/V1/Routes/collections/oauth.php b/Modules/V1/Routes/collections/oauth.php new file mode 100644 index 0000000..38a38ad --- /dev/null +++ b/Modules/V1/Routes/collections/oauth.php @@ -0,0 +1,25 @@ +setPrefix('/v1/access_token') + // Must be a string in order to support lazy loading + ->setHandler('\Phalcon2Rest\Modules\V1\Controllers\AccessTokenController') + ->setLazy(true); + + // Set Access-Control-Allow headers. + $exampleCollection->options('/', 'optionsBase'); + + $exampleCollection->post('/', 'post'); + + return $exampleCollection; +}); \ No newline at end of file diff --git a/Modules/V1/Routes/routeLoader.php b/Modules/V1/Routes/routeLoader.php new file mode 100644 index 0000000..757a006 --- /dev/null +++ b/Modules/V1/Routes/routeLoader.php @@ -0,0 +1,27 @@ + ex: q=(name:Jonhson,city:Oklahoma) + +**Partial Responses** + +Partial responses are used to only return certain explicit fields from a record. They are determined by the 'fields' paramter, which is a list of field names separated by commas, enclosed in parenthesis. + +> ex: fields=(id,name,location) + +**Limit and Offset** + +Often used to paginate large result sets. Offset is the record to start from, and limit is the number of records to return. + +> ex: limit=20&offset=20 will return results 21 to 40 + +**Return Type** + +Overrides any accept headers. JSON is assumed otherwise. Return type handler must be implemented. + +> ex: type=csv + +**Suppressed Error Codes** + +Some clients require all responses to be a 200 (Flash, for example), even if there was an application error. +With this paramter included, the application will always return a 200 response code, and clients will be +responsible for checking the response body to ensure a valid response. + +> ex: suppress_error_codes=true + +Installation +------------ +**Getting composer** +``` +curl -sS getcomposer.org/installer | php +``` + +**Installing the project & dependencies (expecting phalcon2 to be loaded as a module!)** +``` +php composer.phar create-project stratoss/phalcon2rest MyAPI --stability dev --no-interaction +``` + +**Public / Private keys used for JWT signing** +Sample keys are generated in the `ssl` folder, you must regenerate your own set before going to production! + +``` +openssl genrsa -out private.key 1024 +openssl rsa -in private.key -pubout -out public.key +``` + +Responses +--------- + +**Retrieving access token using password grant** + +``` +curl https://domain/v1/access_token -X POST --data "grant_type=password&client_id=1&client_secret=pass2&username=stan&password=pass&scope=basic" +``` + +**Retrieving access token using client_credentials grant** + +``` +curl https://domain/v1/access_token -X POST --data "grant_type=client_credentials&client_id=1&client_secret=pass2&scope=basic" +``` + +``` +{ + "tokenType": "Bearer", + "expiresIn": 3600, + "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImQwYjU1YjJiMjhhYmIxOTMwODk3OTg2ZTIxN2RkMTkwZGY1YTlkYzk2NGJmZjljY2ZjMWQ0NGI4Y2RiNjU0OTAyYjMwM2M1ZDliMzY5ZWQxIn0.eyJhdWQiOiIxIiwianRpIjoiZDBiNTViMmIyOGFiYjE5MzA4OTc5ODZlMjE3ZGQxOTBkZjVhOWRjOTY0YmZmOWNjZmMxZDQ0YjhjZGI2NTQ5MDJiMzAzYzVkOWIzNjllZDEiLCJpYXQiOjE0NjI3MjE4NzcsIm5iZiI6MTQ2MjcyMTg3NywiZXhwIjoxNDYyNzI1NDc3LCJzdWIiOiIiLCJzY29wZXMiOlsiYmFzaWMiXX0.KDkaVBMBX4UelYJ4UoknjgrssEaqpQPj2MPe4ArIppIc0BYNA-5xxVWCSu8rSGKO7QAVM2XSyiux3yq8NoClgtaPlPtpZN6pcSfwGo9MSM6IwQanpd978pwPCi-tXXl4mlViph9sgxPioJ3CzCBoJTpeEtRnEm6nxMUgLnncXps" +} +``` + +**Exchanging refresh token for a new set of refresh token + access token** +``` +curl https://domain/v1/access_token -X POST --data "client_id=1&client_secret=pass2&grant_type=refresh_token&scope=basic&refresh_token=YOUR_REFRESH_TOKEN" + +{ + "tokenType": "Bearer", + "expiresIn": 3600, + "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImY0NGRiMzQ1MzE0MDdlMWY2MWU0N2NkODQ2ZjIxOTRiMjNiZWZhNzZmOWVjYWY5ZDIyMWFhYTg5MTVhMDhjOGFhMzkzYTdmMGI4NGEwNjQ1In0.eyJhdWQiOiIxIiwianRpIjoiZjQ0ZGIzNDUzMTQwN2UxZjYxZTQ3Y2Q4NDZmMjE5NGIyM2JlZmE3NmY5ZWNhZjlkMjIxYWFhODkxNWEwOGM4YWEzOTNhN2YwYjg0YTA2NDUiLCJpYXQiOjE0NjI3MjIzMjIsIm5iZiI6MTQ2MjcyMjMyMiwiZXhwIjoxNDYyNzI1OTIyLCJzdWIiOiIxIiwic2NvcGVzIjpbImJhc2ljIl19.COJ5kAWEEjZyKN_k1N0sgiLJzpEtlgT9H3oJpeicQ-bZteuABZ3sYWCgBY2FrRm6Q8ouMra9WXj38NnwYRgOusRq2H1JL-3redvTu8LitPljNLYritSAuPivOVY4e6FVjQHeuXfIl37rmKIHUXmJcUSJRh1XOqW9mXJGggiXhlI", + "refreshToken": "muYBWN8fSzSL2UCQU0FCq7EZrJ7bPBmJxLsHOTzBSoHJn0gT+ilWyeJzvOqrlVJel4V8K7HIOQfExbKB5l0UrwzFo5UDCz5qj72wWgUn8aJWY09LfGZAs6Qsx\/INLmg6y7petQdtWspAPWlaid8OBk2w5IsqQ7kLFATHCA9fWIg3HWRrc8RPkWeBgOZ5ekRO1dGnmzDm+HLmt8hvIq7uiNDRINYYDwmYh50Ifkv8iJhxL7Pj351KPg43G9pB6L8mNfVizx71c3cofuHlTYMc4S5pt9PFBg7kbtR+qYAD5Wpm3jK204HTpx\/lYyVtEZuFou8O+7ssWlWCSXf7wogxPy9fMuRgXzONnqUn8XHDJEBOxZIIVeu7AAgsWKGJvNrLVY+81oa8BQL1MdCqxQs8vVnHgzq9+bnrjZPlhcvm\/jhWzeCx6X\/fjdneTsZXOZXLK0OpCYNkyOaT2xC5H3RI2+jRGU0HCXGJTmuBlz4Kx48fdUuy2DwF\/DL+LS2mWE6o" +} +``` + +All route controllers must return an array. This array is used to create the response object. + +**JSON** + +JSON is the default response type. It comes with an envelope wrapper, so responses will look like this: + +``` +curl "https://domain/v1/example?q=(id:3)&fields=(author,title,year)" -H "Authorization: Bearer YOUR_ACCESS_TOKEN" + +[ + { + "author": "Stanimir Stoyanov", + "title": "OAuth2 with Phalcon", + "year": "2016" + } +] + +curl "https://domain/v1/example?q=(year:2010&fields=(author,title)" -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +[ + { + "author": "John Doe", + "title": "Greatest book" + }, + { + "author": "John Doe", + "title": "Book of books" + } +] +``` + +The envelope can be suppressed for responses via the 'envelope=false' query paramter. This will return just the record set by itself as the body, and the meta information via X- headers. + +Often times, database field names are snake_cased. However, when working with an API, developers +generally prefer JSON fields to be returned in camelCase (many API requests are from browsers, in JS). +This project will by default convert all keys in a records response from snake_case to camelCase. + +This can be turned off for your API by setting the JSONResponse's function "convertSnakeCase(false)". + +**CSV** + +CSV is the other implemented handler. It uses the first record's keys as the header row, and then creates a csv from each row in the array. The header row can be toggled off for responses. + +``` +curl "https://domain/v1/example?q=(year:2010)&fields=(id,author,title)&type=csv" -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +id,author,title +1,"John Doe","Greatest book" +2,"John Doe","Book of books" +``` + +Errors +------- + +PhalconRest\Exceptions\HttpException extends PHP's native exceptions. Throwing this type of exception +returns a nicely formatted JSON response to the client. + +``` +throw new \PhalconRest\Exceptions\HttpException( + 'Could not return results in specified format', + 403, + null + array( + 'dev' => 'Could not understand type specified by type paramter in query string.', + 'internalCode' => 'NF1000', + 'more' => 'Type may not be implemented. Choose either "csv" or "json"' + ) +); +``` + +Returns this: + +``` +{ + "devMessage": "Could not understand type specified by type paramter in query string.", + "error": 403, + "errorCode": "NF1000", + "more": "Type may not be implemented. Choose either \"csv\" or \"json\"", + "userMessage": "Could not return results in specified format" +} +``` + + +Example Controller +------------------- + +The Example Controller sets up a route at /example and implements all of the above query parameters. +You can mix and match any of these queries: + +> api.example.local/v1/example?q=(author:Stanimir Stoyanov) + +> api.example.local/v1/example?fields=(id,title) + +> api.example.local/v1/example/1?fields=(author)&envelope=false + +> api.example.local/v1/example?type=csv + +> api.example.local/v1/example?q=(year:2010)&offset=1&limit=2&type=csv&fields=(id,author) + +Rate Limiting +-------------- + +There are 3 rate limiters implemented, configured in `config/config.ini` + +> How many request for access token are permitted + +[access_token_limits] + +r1 = 5 + +means 1 request per 5 seconds + +> How many unauthorized requests + +[api_unauthorized_limits] + +r10 = 60 + +means 10 requests per 1 minute + +> Everything else + +[api_common_limits] + +r600 = 3600 + +means 600 requests per hour + +**Tracking the rate limiter** + +Each requests returns the X-Rate-Limit-* headers, e.g. + +``` +HTTP/1.1 200 OK + +X-Rate-Limit-Limit: 600 +X-Rate-Limit-Remaining: 599 +X-Rate-Limit-Reset: 3600 +X-Record-Count: 2 +X-Status: SUCCESS +E-Tag: 6385b20e0a8a3fb0edd588d630573f00 +``` + +When the limit is reached: +``` +< HTTP/1.1 429 Unknown Status Code +< X-Rate-Limit-Limit: 600 +< X-Rate-Limit-Remaining: 0 +< X-Rate-Limit-Reset: 3355 +< X-Status: ERROR +< E-Tag: f22e9815ad32e143287944e727627e9c +< + +{ + "errorCode": 429, + "userMessage": "Too Many Requests", + "devMessage": "You have reached your limit. Please try again after 3355 seconds.", + "more": "", + "applicationCode": "P1010" +} +``` + +[phalcon]: http://phalconphp.com/index +[phalconDocs]: http://docs.phalconphp.com/en/latest/ +[apigeeBook]: https://blog.apigee.com/detail/announcement_new_ebook_on_web_api_design +[OAuth2]: https://github.com/thephpleague/oauth2-server +[cmoore4]: https://github.com/cmoore4/phalcon-rest/ \ No newline at end of file diff --git a/Responses/CsvResponse.php b/Responses/CsvResponse.php new file mode 100644 index 0000000..85208e1 --- /dev/null +++ b/Responses/CsvResponse.php @@ -0,0 +1,46 @@ +di->get('response'); + // Headers for a CSV + $response->setHeader('Content-type', 'application/csv'); + + // By default, filename is just a timestamp. You should probably change this. + $response->setHeader('Content-Disposition', 'attachment; filename="'.time().'.csv"'); + $response->setHeader('Pragma', 'no-cache'); + $response->setHeader('Expires', '0'); + + // We write directly to out, which means we don't ever save this file to disk. + $handle = fopen('php://output', 'w'); + + // The keys of the first result record will be the first line of the CSV (headers) + if ($this->headers) { + fputcsv($handle, array_keys($records[0])); + } + + // Write each record as a csv line. + foreach ($records as $line) { + fputcsv($handle, $line); + } + + fclose($handle); + + return $this; + } + + public function useHeaderRow($headers){ + $this->headers = (bool) $headers; + return $this; + } + +} \ No newline at end of file diff --git a/Responses/JsonResponse.php b/Responses/JsonResponse.php new file mode 100644 index 0000000..204655b --- /dev/null +++ b/Responses/JsonResponse.php @@ -0,0 +1,88 @@ +di->get('response'); + $success = ($error ? 'ERROR' : 'SUCCESS'); + + // If the query string 'envelope' is set to false, do not use the envelope. + // Instead, return headers. + $request = $this->di->get('request'); + if ($request->get('envelope', null, null) === 'false') { + $this->envelope = false; + } + + + // Most devs prefer camelCase to snake_Case in JSON, but this can be overridden here + if ($this->snake) { + $records = $this->arrayKeysToSnake($records); + } + + $etag = md5(serialize($records)); + + if ($this->envelope) { + // Provide an envelope for JSON responses. '_meta' and 'records' are the objects. + $message = []; + $message['_meta'] = [ + 'status' => $success, + 'count' => ($error ? 1 : count($records)) + ]; + + // Handle 0 record responses, or assign the records + if($message['_meta']['count'] === 0){ + // This is required to make the response JSON return an empty JS object. Without + // this, the JSON return an empty array: [] instead of {} + $message['records'] = new \stdClass(); + } else { + $message['records'] = $records; + } + + } else { + if ($success !== 'ERROR') { + $response->setHeader('X-Record-Count', count($records)); + } + $response->setHeader('X-Status', $success); + $message = $records; + } + + $response->setContentType('application/json'); + $response->setHeader('E-Tag', $etag); + + // HEAD requests are detected in the parent constructor. HEAD does everything exactly the + // same as GET, but contains no body. + if (!$this->head) { + $response->setJsonContent($message, JSON_PRETTY_PRINT); + } + + $response->send(); + + return $this; + } + + public function convertSnakeCase($snake) { + $this->snake = (bool) $snake; + return $this; + } + + public function useEnvelope($envelope) { + $this->envelope = (bool) $envelope; + return $this; + } + +} \ No newline at end of file diff --git a/Responses/Response.php b/Responses/Response.php new file mode 100644 index 0000000..97ec026 --- /dev/null +++ b/Responses/Response.php @@ -0,0 +1,48 @@ +setDI($di); + if(strtolower($this->di->get('request')->getMethod()) === 'head'){ + $this->head = true; + } + } + + /** + * In-Place, recursive conversion of array keys in snake_Case to camelCase + * @param array $snakeArray Array with snake_keys + * @return array + */ + protected function arrayKeysToSnake($snakeArray){ + foreach($snakeArray as $k=>$v){ + if (is_array($v)){ + $v = $this->arrayKeysToSnake($v); + } + $snakeArray[$this->snakeToCamel($k)] = $v; + if($this->snakeToCamel($k) != $k){ + unset($snakeArray[$k]); + } + } + return $snakeArray; + } + + /** + * Replaces underscores with spaces, uppercases the first letters of each word, + * lowercases the very first letter, then strips the spaces + * @param string $val String to be converted + * @return string Converted string + */ + protected function snakeToCamel($val) { + return (strtoupper($val) === $val ? $val : str_replace(' ', '', lcfirst(ucwords(str_replace('_', ' ', $val))))); + } + +} \ No newline at end of file diff --git a/autoload.php b/autoload.php new file mode 100644 index 0000000..6f57488 --- /dev/null +++ b/autoload.php @@ -0,0 +1,36 @@ + $path) { + $composerNamespaces[rtrim($namespace, '\\')] = $path[0]; + } +} else { + $composerNamespaces = []; +} + +$composerAutoloadFilesPath = __DIR__ . '/vendor/composer/autoload_files.php'; +if (file_exists($composerAutoloadFilesPath) && is_file($composerAutoloadFilesPath)) { + $allFiles = include($composerAutoloadFilesPath); + foreach ($allFiles as $file) { + include($file); + } +} +$namespaces = array_merge([ + 'Phalcon2Rest\Exceptions' => __DIR__ . '/Exceptions/', + 'Phalcon2Rest\Responses' => __DIR__ . '/Responses/', + 'Phalcon2Rest\Components' => __DIR__ . '/Components/', + 'Phalcon2Rest\Modules' => __DIR__ . '/Modules/', + 'Phalcon2Rest\Models' => __DIR__ . '/Models/' +], $composerNamespaces); +$loader->registerNamespaces($namespaces)->register(); \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2c66d39 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "stratoss/phalcon2rest", + "version":"1.0.0", + "description": "Phalcon2 project with OAuth2, JWT and rate limiting", + "keywords": ["phalcon", "phalcon2", "oauth", "api", "oauth2", "JWT"], + "type": "project", + "license": "MIT", + "support": { + "issues": "https://github.com/stratoss/phalcon2rest/issues", + "wiki": "https://github.com/stratoss/phalcon2rest/wiki", + "source": "https://github.com/stratoss/phalcon2rest" + }, + "authors": [ + { + "name": "Stanimir Stoyanov", + "email": "stanimir@datacentrix.org" + } + ], + "require": { + "league/oauth2-server": "5.*" + }, + "minimum-stability": "dev" +} + + + + + + + diff --git a/config/config.ini b/config/config.ini new file mode 100644 index 0000000..844a8b4 --- /dev/null +++ b/config/config.ini @@ -0,0 +1,25 @@ +[application] + +[versions] +v1 = V1 + +; defining the limits per IP to /access_token in the format requests = seconds +; this limit should be really low, as the tokens have high lifetime (1 hour) +[access_token_limits] +r1 = 5 + +;returns empty response after that +[api_unauthorized_limits] +r10 = 60 + +; per user +[api_common_limits] +r600 = 3600 + +[oauth] +public = ssl/public.key +private = ssl/private.key +; 1 hour +accessTokenLifetime = PT1H +; 1 month +refreshTokenLifetime = P1M \ No newline at end of file diff --git a/data/database.db b/data/database.db new file mode 100644 index 0000000..0e5fd41 Binary files /dev/null and b/data/database.db differ diff --git a/data/mysql.sql b/data/mysql.sql new file mode 100644 index 0000000..9b90c34 --- /dev/null +++ b/data/mysql.sql @@ -0,0 +1,78 @@ +CREATE TABLE `access_tokens` ( + `userId` bigint(20) UNSIGNED NOT NULL, + `tokenId` varchar(80) NOT NULL, + `isRevoked` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `expiry` int(10) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `clients` ( + `id` bigint(20) UNSIGNED NOT NULL, + `secret` varchar(64) NOT NULL, + `name` varchar(64) NOT NULL, + `redirect_url` varchar(128) NOT NULL, + `is_confidential` tinyint(1) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT INTO `clients` (`id`, `secret`, `name`, `redirect_url`, `is_confidential`) VALUES +(1, '$2y$10$5m1jvrkBZDkCZDfyJrv0A.TlkETpwpWjzx29ZxzlolwGtBXaHOkJa', 'Super App', 'http://example.com/super-app', 1); + +CREATE TABLE `refresh_tokens` ( + `userId` bigint(20) UNSIGNED NOT NULL, + `tokenId` varchar(80) NOT NULL, + `isRevoked` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `expiry` int(10) UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `users` ( + `id` bigint(20) UNSIGNED NOT NULL, + `username` varchar(64) NOT NULL, + `password` varchar(64) NOT NULL, + `access` tinyint(1) UNSIGNED NOT NULL DEFAULT '1' +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `books` ( + `id` bigint(20) UNSIGNED NOT NULL, + `author` varchar(64) NOT NULL, + `title` varchar(64) NOT NULL, + `year` int(11) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +INSERT INTO `users` (`id`, `username`, `password`, `access`) VALUES +(1, 'stan', '$2y$10$8yjhRKQmDIXYl/pAbloBD.5vuGr/xkzCLeJCw5H5sycD8QbcDfZzC', 1); + +INSERT INTO `books` (`id`, `author`, `title`, `year`) VALUES +(1, 'John Doe', 'Greatest book', 2010), +(2, 'John Doe', 'Book of books', 2010), +(3, 'Stanimir Stoyanov', 'OAuth2 with Phalcon', 2016); + +ALTER TABLE `access_tokens` + ADD UNIQUE KEY `tokenId` (`tokenId`), + ADD KEY `userId` (`userId`); + +ALTER TABLE `clients` + ADD UNIQUE KEY `id` (`id`); + +ALTER TABLE `refresh_tokens` + ADD UNIQUE KEY `tokenId` (`tokenId`), + ADD KEY `userId` (`userId`); + +ALTER TABLE `users` + ADD UNIQUE KEY `id` (`id`); + +ALTER TABLE `clients` + MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; + +ALTER TABLE `users` + MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; + +ALTER TABLE `access_tokens` + ADD CONSTRAINT `access_tokens_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`); + +ALTER TABLE `refresh_tokens` + ADD CONSTRAINT `refresh_tokens_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`); + +ALTER TABLE `books` + ADD UNIQUE KEY `id` (`id`); + +ALTER TABLE `books` + MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; \ No newline at end of file diff --git a/data/sqlite3.sql b/data/sqlite3.sql new file mode 100644 index 0000000..4983b29 --- /dev/null +++ b/data/sqlite3.sql @@ -0,0 +1,42 @@ +CREATE TABLE access_tokens ( + userId bigint NOT NULL, + tokenId varchar NOT NULL, + isRevoked tinyint NOT NULL DEFAULT '0', + expiry int NOT NULL +); + +CREATE TABLE "clients" ( + "id" bigint NOT NULL, + "secret" varchar NOT NULL, + "name" varchar NOT NULL, + "redirect_url" varchar NOT NULL, + "is_confidential" tinyint NOT NULL +); +INSERT INTO "clients" VALUES (1,'$2y$10$5m1jvrkBZDkCZDfyJrv0A.TlkETpwpWjzx29ZxzlolwGtBXaHOkJa','Super App','http://example.com/super-app',1); + +CREATE TABLE "refresh_tokens" ( + "userId" bigint NOT NULL, + "tokenId" varchar NOT NULL, + "isRevoked" tinyint NOT NULL DEFAULT '0', + "expiry" int NOT NULL +); + +CREATE TABLE "users" ( + "id" bigint NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "access" tinyint NOT NULL DEFAULT '1' +); + +INSERT INTO "users" VALUES (1,'stan','$2y$10$8yjhRKQmDIXYl/pAbloBD.5vuGr/xkzCLeJCw5H5sycD8QbcDfZzC',1); + +create table "books" ( + "id" bigint not null, + "author" varchar not null, + "title" varchar not null, + "year" int not null +); + +INSERT INTO "books" VALUES (1,'John Doe','Greatest book',2010); +INSERT INTO "books" VALUES (2,'John Doe','Book of books',2010); +INSERT INTO "books" VALUES (3,'Stanimir Stoyanov','OAuth2 with Phalcon',2016); \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..bae9f6b --- /dev/null +++ b/public/index.php @@ -0,0 +1,189 @@ +getShared('authorizationServer'))); +/** + * Out application is a Micro application, so we mush explicitly define all the routes. + * For APIs, this is ideal. This is as opposed to the more robust MVC Application + * @var $app + */ +$app = new Phalcon\Mvc\Micro(); +$app->setDI($di); + + +/** + * Mount all of the collections, which makes the routes active. + */ +foreach($di->get('collections') as $collection){ + $app->mount($collection); +} + +/** + * The base route return the list of defined routes for the application. + * This is not strictly REST compliant, but it helps to base API documentation off of. + * By calling this, you can quickly see a list of all routes and their methods. + */ +$app->get('/', function() use ($app){ + $routes = $app->getRouter()->getRoutes(); + $routeDefinitions = [ + 'GET' => [], + 'POST' => [], + 'PUT' => [], + 'PATCH' => [], + 'DELETE' => [], + 'HEAD' => [], + 'OPTIONS' => [] + ]; + /* @var $route Phalcon\Mvc\Router\Route */ + foreach($routes as $route){ + $method = $route->getHttpMethods(); + $routeDefinitions[$method][] = $route->getPattern(); + } + return $routeDefinitions; +}); + +/** + * Before every request, make sure user is authenticated. + * Returning true in this function resumes normal routing. + * Returning false stops any route from executing. + */ + +$app->before(function () use ($app, $di) { + $config = $di->getShared('config'); + // getting access token is permitted ;) + if (strpos($app->request->getURI(), '/access_token') !== FALSE) { + return $di->getShared('rateLimits', ['access_token', $app->request->getClientAddress(), $app]); + } + + $accessTokenRepository = new \Phalcon2Rest\Components\Oauth2\Repositories\AccessTokenRepository(); // instance of AccessTokenRepositoryInterface + $publicKeyPath = 'file://' . __DIR__ . '/../' . $config->oauth['public']; + try { + $server = new \League\OAuth2\Server\ResourceServer( + $accessTokenRepository, + $publicKeyPath + ); + + $auth = new \League\OAuth2\Server\Middleware\ResourceServerMiddleware($server); + $auth(new \Phalcon2Rest\Components\Oauth2\Request($app->request), new \Phalcon2Rest\Components\Oauth2\Response(), function(){}); + if (isset($_SERVER['oauth_access_token_id']) && + isset($_SERVER['oauth_client_id']) && + isset($_SERVER['oauth_user_id']) && + isset($_SERVER['oauth_scopes']) + ) { + // TODO: save somewhere the user_id and scopes for future validations, e.g. /users/1/edit + // TODO: should be accessible only if the user_id is 1 or the scope is giving permissions, e.g. admin + if (strlen($_SERVER['oauth_client_id']) > 0) { + return $di->getShared('rateLimits', ['api_common', 'client'.$_SERVER['oauth_client_id'], $app]); + } else { + return $di->getShared('rateLimits', ['api_common', 'user'.$_SERVER['oauth_user_id'], $app]); + } + + } + } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) { + } + $rateLimit = $di->getShared('rateLimits', ['api_unauthorized', $app->request->getClientAddress(), $app]); + if ($rateLimit === false) { + return false; + } + throw new \Phalcon2Rest\Exceptions\HttpException( + 'Unauthorized', + 401, + false, + [ + 'dev' => 'The bearer token is missing or is invalid', + 'internalCode' => 'P1008', + 'more' => '' + ] + ); +}); + +/** + * After a route is run, usually when its Controller returns a final value, + * the application runs the following function which actually sends the response to the client. + * + * The default behavior is to send the Controller's returned value to the client as JSON. + * However, by parsing the request querystring's 'type' paramter, it is easy to install + * different response type handlers. Below is an alternate csv handler. + * + * TODO: add versions + */ +$app->after(function() use ($app) { + + // OPTIONS have no body, send the headers, exit + if($app->request->getMethod() == 'OPTIONS'){ + $app->response->setStatusCode('200', 'OK'); + $app->response->send(); + return; + } + + // Respond by default as JSON + if(!$app->request->get('type') || $app->request->get('type') == 'json'){ + + // Results returned from the route's controller. All Controllers should return an array + $records = $app->getReturnedValue(); + $response = new \Phalcon2Rest\Responses\JsonResponse(); + $response->useEnvelope(false) + ->convertSnakeCase(true) + ->send($records); + + return; + } + elseif($app->request->get('type') == 'csv'){ + + $records = $app->getReturnedValue(); + $response = new \Phalcon2Rest\Responses\CsvResponse(); + $response->useHeaderRow(true)->send($records); + + return; + } + else { + throw new \Phalcon2Rest\Exceptions\HttpException( + 'Could not return results in specified format', + 403, + array( + 'dev' => 'Could not understand type specified by type parameter in query string.', + 'internalCode' => 'NF1000', + 'more' => 'Type may not be implemented. Choose either "csv" or "json"' + ) + ); + } +}); + +/** + * The notFound service is the default handler function that runs when no route was matched. + * We set a 404 here unless there's a suppress error codes. + */ +$app->notFound(function () use ($app) { + throw new \Phalcon2Rest\Exceptions\HttpException( + 'Not Found.', + 404, + array( + 'dev' => 'That route was not found on the server.', + 'internalCode' => 'NF1000', + 'more' => 'Check route for misspellings.' + ) + ); +}); + +/** + * If the application throws an HttpException, send it on to the client as json. + * Elsewise, just log it. + * TODO: Improve this. + */ +set_exception_handler(function($exception) use ($app){ + //HttpException's send method provides the correct response headers and body + /* @var $exception Phalcon2Rest\Exceptions\HttpException */ + if(is_a($exception, 'Phalcon2Rest\\Exceptions\\HttpException')){ + $exception->send(); + } + //error_log($exception); + //error_log($exception->getTraceAsString()); +}); + +$app->handle(); diff --git a/services.php b/services.php new file mode 100644 index 0000000..2d82599 --- /dev/null +++ b/services.php @@ -0,0 +1,208 @@ +setShared('config', function() { + return new IniConfig(__DIR__ . "/config/config.ini"); +}); + +/** + * Return array of the Collections, which define a group of routes, from + * routes/collections. These will be mounted into the app itself later. + */ +$availableVersions = $di->getShared('config')->versions; + +$allCollections = []; +foreach ($availableVersions as $versionString => $versionPath) { + $currentCollections = include('Modules/' . $versionPath . '/Routes/routeLoader.php'); + $allCollections = array_merge($allCollections, $currentCollections); +} +$di->set('collections', function() use ($allCollections) { + return $allCollections; +}); + +// As soon as we request the session service, it will be started. +$di->setShared('session', function() { + $session = new \Phalcon\Session\Adapter\Files(); + $session->start(); + return $session; +}); + +/** + * The slowest option! Consider using memcached/redis or another faster caching system than file... + * Using the file cache just for the sake of the simplicity here + */ +$di->setShared('cache', function() { + //Cache data for one day by default + $frontCache = new \Phalcon\Cache\Frontend\Data(array( + 'lifetime' => 3600 + )); + + //File cache settings + $cache = new \Phalcon\Cache\Backend\File($frontCache, array( + 'cacheDir' => __DIR__ . '/cache/' + )); + + return $cache; +}); + +$di->setShared('rateLimits', function($limitType, $identifier, $app) use ($di) { + $cache = $di->getShared('cache'); + $config = $di->getShared('config'); + $limitName = $limitType . '_limits'; + if (property_exists($config, $limitName)) { + foreach ($config->{$limitName} as $limit => $seconds) { + $limit = substr($limit, 1, strlen($limit)); + $cacheName = $limitName . $identifier; + + if ($cache->exists($cacheName, $seconds)) { + $rate = $cache->get($cacheName, $seconds); + $rate['remaining']--; + $resetAfter = $rate['saved'] + $seconds - time(); + if ($rate['remaining'] > -1) { + $cache->save($cacheName, $rate, $resetAfter); + } + } else { + $rate = ['remaining' => $limit - 1, 'saved' => time()]; + $cache->save($cacheName, $rate, $seconds); + $resetAfter = $seconds; + } + + $app->response->setHeader('X-Rate-Limit-Limit', $limit); + $app->response->setHeader('X-Rate-Limit-Remaining', ($rate['remaining'] > -1 ? $rate['remaining'] : 0) . ' '); + $app->response->setHeader('X-Rate-Limit-Reset', $resetAfter . ' '); + + if ($rate['remaining'] > -1) { + return true; + } else { + throw new \Phalcon2Rest\Exceptions\HttpException( + 'Too Many Requests', + 429, + null, + [ + 'dev' => 'You have reached your limit. Please try again after ' . $resetAfter . ' seconds.', + 'internalCode' => 'P1010', + 'more' => '' + ] + ); + } + } + } + return false; +}); + +/** + * Database setup. Here, we'll use a simple SQLite database of Disney Princesses. + */ +$di->set('db', function() { + return new \Phalcon\Db\Adapter\Pdo\Sqlite(array( + 'dbname' => __DIR__ . '/data/database.db' + )); +}); + +/** + * If our request contains a body, it has to be valid JSON. This parses the + * body into a standard Object and makes that available from the DI. If this service + * is called from a function, and the request body is nto valid JSON or is empty, + * the program will throw an Exception. + */ +$di->setShared('requestBody', function() { + $in = file_get_contents('php://input'); + $in = json_decode($in, FALSE); + + // JSON body could not be parsed, throw exception + if($in === null){ + throw new HttpException( + 'There was a problem understanding the data sent to the server by the application.', + 409, + array( + 'dev' => 'The JSON body sent to the server was unable to be parsed.', + 'internalCode' => 'REQ1000', + 'more' => '' + ) + ); + } + + return $in; +}); + +$di->setShared('resourceServer', function() use ($di) { + $config = $di->getShared('config'); + $server = new ResourceServer( + new AccessTokenRepository(), // instance of AccessTokenRepositoryInterface + 'file://' . __DIR__ . '/' . $config->oauth['public'] // the authorization server's public key + ); + return $server; +}); + +$di->set('security', function () { + + $security = new \Phalcon\Security(); + + // Set the password hashing factor to 12 rounds + $security->setWorkFactor(12); + + return $security; +}, true); + +$di->setShared('authorizationServer', function() use ($di) { + $config = $di->getShared('config'); + $server = new AuthorizationServer( + new ClientRepository(), // instance of ClientRepositoryInterface + new AccessTokenRepository(), // instance of AccessTokenRepositoryInterface + new ScopeRepository(), // instance of ScopeRepositoryInterface + 'file://' . __DIR__ . '/' . $config->oauth['private'], // path to private key + 'file://' . __DIR__ . '/' . $config->oauth['public'] // path to public key + ); + + /** + * Using client_id & client_secret & username & password + * + */ + $passwordGrant = new PasswordGrant( + new UserRepository(), // instance of UserRepositoryInterface + new RefreshTokenRepository() // instance of RefreshTokenRepositoryInterface + ); + $passwordGrant->setRefreshTokenTTL(new \DateInterval($config->oauth['refreshTokenLifetime'])); + $server->enableGrantType( + $passwordGrant, + new \DateInterval($config->oauth['accessTokenLifetime']) + ); + + /** + * Using client_id & client_secret + */ + $clientCredentialsGrant = new ClientCredentialsGrant(); + $server->enableGrantType( + $clientCredentialsGrant, + new \DateInterval($config->oauth['accessTokenLifetime']) + ); + + $refreshTokenGrant = new RefreshTokenGrant(new RefreshTokenRepository()); + $refreshTokenGrant->setRefreshTokenTTL(new DateInterval($config->oauth['refreshTokenLifetime'])); + $server->enableGrantType($refreshTokenGrant, new DateInterval($config->oauth['accessTokenLifetime'])); + return $server; +}); \ No newline at end of file diff --git a/ssl/private.key b/ssl/private.key new file mode 100644 index 0000000..88b02b2 --- /dev/null +++ b/ssl/private.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCxFh/iHxQ4eGTZw5LGckKTMv4v4Swuu2JGVB+F/wigbdzNEiOD +d3Ii6LTKlsGtxQUBc8CnSMM+ld+FGHvBDRUaLXQiCxp1+KNERHAbnjA0kY249EKr +9lWb+D/FczsJz/gMAfiLmRxM+s8SYIXaqh6zhNj1Dfbdr69qGXpOfJWaLwIDAQAB +AoGAUt4TlXENuU89glnuuUaGuPNH14f7cPLnDhoXllC97LT8ekperAqdMpDK6XKa +t4JW0VMleCKomwTvUA0g/DnvAUVJAZ6wfiAeQleEIOO7RynCmUbqqlBUGEZp33Rk +jTxO1xdEG3Y6NlS/SWpeqej7MJP2eirhQ2BnjIDeIqYYaeECQQDjTe2ER9Z/qtpX +5GbepGJzE+TcO1OUOm9HAFR+A5j3rb59dgKlhKJnRZMjv18RxfHUtjtP5gyVwfPU +yBpYVajTAkEAx3E9xzbRr48/ozwARvlZRcLTpas+6kiDUW803uDyjqo8o15q8q1M +W9zaemECOdggOMZZ+BGQ4PCo6tJtzc+vtQJAb+U+1W2f1D1BOx8+3L9Dj67tbNTv +sfqKKQOqlFYlCVhIe+6KIv0GDZyccG6W2GL/R11mGVEARQCzjb3r6ixQ7QJBAJlb +8F8xPge7JPoF90icEBNefpSTm2tXmvKRipwfaSRerwYIYkB9FYxFxRH5albEY/KE +Q0ZHa5osNBds+9YYb0kCQEJgDQ+BUowTfnQQVbyedcZty3Z0kbHbvIWj3bS2MNPR +CNul5jATTke284UPbgEWUV7D7zPVwWdtb+eF5OGUvag= +-----END RSA PRIVATE KEY----- diff --git a/ssl/public.key b/ssl/public.key new file mode 100644 index 0000000..593fb45 --- /dev/null +++ b/ssl/public.key @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxFh/iHxQ4eGTZw5LGckKTMv4v +4Swuu2JGVB+F/wigbdzNEiODd3Ii6LTKlsGtxQUBc8CnSMM+ld+FGHvBDRUaLXQi +Cxp1+KNERHAbnjA0kY249EKr9lWb+D/FczsJz/gMAfiLmRxM+s8SYIXaqh6zhNj1 +Dfbdr69qGXpOfJWaLwIDAQAB +-----END PUBLIC KEY-----