├── .gitignore ├── Components └── Oauth2 │ ├── Entities │ ├── AccessTokenEntity.php │ ├── AuthCodeEntity.php │ ├── ClientEntity.php │ ├── RefreshTokenEntity.php │ ├── ScopeEntity.php │ └── UserEntity.php │ ├── GenerateResult.php │ ├── Repositories │ ├── AccessTokenRepository.php │ ├── AuthCodeRepository.php │ ├── ClientRepository.php │ ├── RefreshTokenRepository.php │ ├── ScopeRepository.php │ └── UserRepository.php │ ├── Request.php │ ├── Response.php │ └── Stream.php ├── Exceptions └── HttpException.php ├── LICENSE ├── Models ├── AccessTokens.php ├── Books.php ├── Clients.php ├── RefreshTokens.php └── Users.php ├── Modules └── V1 │ ├── Controllers │ ├── AccessTokenController.php │ ├── AuthorizeController.php │ ├── BaseController.php │ ├── ExampleController.php │ └── RestController.php │ └── Routes │ ├── collections │ ├── access_token.php │ ├── authorize.php │ └── example.php │ └── routeLoader.php ├── README.md ├── Responses ├── CsvResponse.php ├── JsonResponse.php └── Response.php ├── autoload.php ├── cache └── README ├── composer.json ├── config └── config.ini ├── data ├── database.db ├── mysql.sql └── sqlite3.sql ├── public ├── .htaccess └── index.php ├── services.php └── ssl ├── private.key └── public.key /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | composer.phar 4 | vendor 5 | -------------------------------------------------------------------------------- /Components/Oauth2/Entities/AccessTokenEntity.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Entities; 11 | 12 | use League\OAuth2\Server\Entities\AccessTokenEntityInterface; 13 | use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; 14 | use League\OAuth2\Server\Entities\Traits\EntityTrait; 15 | use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; 16 | 17 | class AccessTokenEntity implements AccessTokenEntityInterface 18 | { 19 | use AccessTokenTrait, TokenEntityTrait, EntityTrait; 20 | } -------------------------------------------------------------------------------- /Components/Oauth2/Entities/AuthCodeEntity.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Entities; 11 | 12 | use League\OAuth2\Server\Entities\ClientEntityInterface; 13 | use League\OAuth2\Server\Entities\Traits\ClientTrait; 14 | use League\OAuth2\Server\Entities\Traits\EntityTrait; 15 | 16 | class ClientEntity implements ClientEntityInterface 17 | { 18 | use EntityTrait, ClientTrait; 19 | 20 | public function setName($name) 21 | { 22 | $this->name = $name; 23 | } 24 | 25 | public function setRedirectUri($uri) 26 | { 27 | $this->redirectUri = $uri; 28 | } 29 | } -------------------------------------------------------------------------------- /Components/Oauth2/Entities/RefreshTokenEntity.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Entities; 11 | 12 | use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; 13 | use League\OAuth2\Server\Entities\Traits\EntityTrait; 14 | use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; 15 | 16 | class RefreshTokenEntity implements RefreshTokenEntityInterface 17 | { 18 | use RefreshTokenTrait, EntityTrait; 19 | } -------------------------------------------------------------------------------- /Components/Oauth2/Entities/ScopeEntity.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Entities; 11 | 12 | use League\OAuth2\Server\Entities\ScopeEntityInterface; 13 | use League\OAuth2\Server\Entities\Traits\EntityTrait; 14 | 15 | class ScopeEntity implements ScopeEntityInterface 16 | { 17 | use EntityTrait; 18 | 19 | public function jsonSerialize() 20 | { 21 | return $this->getIdentifier(); 22 | } 23 | } -------------------------------------------------------------------------------- /Components/Oauth2/Entities/UserEntity.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Entities; 11 | 12 | use League\OAuth2\Server\Entities\UserEntityInterface; 13 | 14 | class UserEntity implements UserEntityInterface 15 | { 16 | private $user; 17 | 18 | public function __construct($userArray) 19 | { 20 | $this->user = $userArray; 21 | } 22 | 23 | /** 24 | * Return the user's identifier. 25 | * 26 | * @return mixed 27 | */ 28 | public function getIdentifier() 29 | { 30 | return $this->user['id']; 31 | } 32 | } -------------------------------------------------------------------------------- /Components/Oauth2/GenerateResult.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Repositories; 11 | 12 | use League\OAuth2\Server\Entities\AccessTokenEntityInterface; 13 | use League\OAuth2\Server\Entities\ClientEntityInterface; 14 | use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; 15 | use Phalcon2Rest\Components\Oauth2\Entities\AccessTokenEntity; 16 | 17 | class AccessTokenRepository implements AccessTokenRepositoryInterface 18 | { 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) 23 | { 24 | // Some logic here to save the access token to a database 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function revokeAccessToken($tokenId) 31 | { 32 | // Some logic here to revoke the access token 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function isAccessTokenRevoked($tokenId) 39 | { 40 | return false; // Access token hasn't been revoked 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) 47 | { 48 | 49 | $accessToken = new AccessTokenEntity(); 50 | $accessToken->setClient($clientEntity); 51 | foreach ($scopes as $scope) { 52 | $accessToken->addScope($scope); 53 | } 54 | $accessToken->setUserIdentifier($userIdentifier); 55 | 56 | return $accessToken; 57 | } 58 | } -------------------------------------------------------------------------------- /Components/Oauth2/Repositories/AuthCodeRepository.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Repositories; 11 | 12 | use League\OAuth2\Server\Repositories\ClientRepositoryInterface; 13 | use Phalcon\Security; 14 | use Phalcon2Rest\Components\Oauth2\Entities\ClientEntity; 15 | use Phalcon\Di\FactoryDefault as Di; 16 | use Phalcon2Rest\Models\Clients; 17 | 18 | class ClientRepository implements ClientRepositoryInterface 19 | { 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function getClientEntity($clientIdentifier, $grantType, $clientSecret = null, $mustValidateSecret = true) 24 | { 25 | $di = new Di(); 26 | /** @var Security $security */ 27 | $security = $di->getShared('security'); 28 | $client = Clients::query() 29 | ->where("id = :id:") 30 | ->bind([ 31 | 'id' => $clientIdentifier 32 | ]) 33 | ->limit(1) 34 | ->execute() 35 | ->toArray(); 36 | $correctDetails = false; 37 | if (count($client) === 1) { 38 | $client = current($client); 39 | if ($mustValidateSecret) { 40 | 41 | if ($security->checkHash($clientSecret, $client['secret'])) { 42 | $correctDetails = true; 43 | } else { 44 | $security->hash(rand()); 45 | 46 | } 47 | } else { 48 | $correctDetails = true; 49 | } 50 | } else { 51 | // prevent timing attacks 52 | $security->hash(rand()); 53 | } 54 | 55 | if ($correctDetails) { 56 | $clientEntity = new ClientEntity(); 57 | $clientEntity->setIdentifier($clientIdentifier); 58 | $clientEntity->setName($client['name']); 59 | $clientEntity->setRedirectUri($client['redirect_url']); 60 | return $clientEntity; 61 | } 62 | return null; 63 | } 64 | } -------------------------------------------------------------------------------- /Components/Oauth2/Repositories/RefreshTokenRepository.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Repositories; 11 | 12 | use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; 13 | use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; 14 | use Phalcon2Rest\Components\Oauth2\Entities\RefreshTokenEntity; 15 | 16 | class RefreshTokenRepository implements RefreshTokenRepositoryInterface 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntityInterface) 22 | { 23 | // Some logic to persist the refresh token in a database 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function revokeRefreshToken($tokenId) 30 | { 31 | // Some logic to revoke the refresh token in a database 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function isRefreshTokenRevoked($tokenId) 38 | { 39 | return false; // The refresh token has not been revoked 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function getNewRefreshToken() 46 | { 47 | return new RefreshTokenEntity(); 48 | } 49 | } -------------------------------------------------------------------------------- /Components/Oauth2/Repositories/ScopeRepository.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Repositories; 11 | 12 | use League\OAuth2\Server\Entities\ClientEntityInterface; 13 | use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; 14 | use Phalcon2Rest\Components\Oauth2\Entities\ScopeEntity; 15 | 16 | class ScopeRepository implements ScopeRepositoryInterface 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function getScopeEntityByIdentifier($scopeIdentifier) 22 | { 23 | $scopes = [ 24 | 'basic' => [ 25 | 'description' => 'Basic details about you', 26 | ], 27 | 'email' => [ 28 | 'description' => 'Your email address', 29 | ], 30 | ]; 31 | 32 | if (array_key_exists($scopeIdentifier, $scopes) === false) { 33 | return; 34 | } 35 | 36 | $scope = new ScopeEntity(); 37 | $scope->setIdentifier($scopeIdentifier); 38 | 39 | return $scope; 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function finalizeScopes( 46 | array $scopes, 47 | $grantType, 48 | ClientEntityInterface $clientEntity, 49 | $userIdentifier = null 50 | ) { 51 | return $scopes; 52 | } 53 | } -------------------------------------------------------------------------------- /Components/Oauth2/Repositories/UserRepository.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright Copyright (c) Alex Bilbie 5 | * @license http://mit-license.org/ 6 | * 7 | * @link https://github.com/thephpleague/oauth2-server 8 | */ 9 | 10 | namespace Phalcon2Rest\Components\Oauth2\Repositories; 11 | 12 | use League\OAuth2\Server\Entities\ClientEntityInterface; 13 | use League\OAuth2\Server\Repositories\UserRepositoryInterface; 14 | use Phalcon\Di\FactoryDefault as Di; 15 | use Phalcon\Security; 16 | use Phalcon2Rest\Components\Oauth2\Entities\UserEntity; 17 | use Phalcon2Rest\Models\Users; 18 | 19 | class UserRepository implements UserRepositoryInterface 20 | { 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function getUserEntityByUserCredentials( 25 | $username, 26 | $password, 27 | $grantType, 28 | ClientEntityInterface $clientEntity 29 | ) { 30 | $di = new Di(); 31 | /** @var Security $security */ 32 | $security = $di->getShared('security'); 33 | $user = Users::query() 34 | ->where("username = :username:") 35 | ->bind([ 36 | 'username' => $username 37 | ]) 38 | ->limit(1) 39 | ->execute() 40 | ->toArray(); 41 | $correctDetails = false; 42 | if (count($user) === 1) { 43 | $user = current($user); 44 | if ($security->checkHash($password, $user['password'])) { 45 | $correctDetails = true; 46 | } else { 47 | $security->hash(rand()); 48 | } 49 | } else { 50 | // prevent timing attacks 51 | $security->hash(rand()); 52 | } 53 | if ($correctDetails) { 54 | //$scope = new ScopeEntity(); 55 | //$scope->setIdentifier('email'); 56 | //$scopes[] = $scope; 57 | 58 | return new UserEntity($user); 59 | } 60 | return null; 61 | } 62 | } -------------------------------------------------------------------------------- /Components/Oauth2/Request.php: -------------------------------------------------------------------------------- 1 | request = $request; 16 | } 17 | 18 | public function getParsedBody() 19 | { 20 | $input = file_get_contents("php://input"); 21 | $result = []; 22 | $variables = explode('&', $input); 23 | foreach ($variables as $variable) { 24 | $param = explode('=', $variable); 25 | if (count($param) === 2) { 26 | $result[$param[0]] = $param[1]; 27 | } 28 | } 29 | return $result; 30 | } 31 | 32 | public function getProtocolVersion() 33 | { 34 | // TODO: Implement getProtocolVersion() method. 35 | } 36 | 37 | public function withProtocolVersion($version) 38 | { 39 | // TODO: Implement withProtocolVersion() method. 40 | } 41 | 42 | public function getHeaders() 43 | { 44 | // TODO: Implement getHeaders() method. 45 | } 46 | 47 | public function hasHeader($name) 48 | { 49 | // TODO: Implement hasHeader() method. 50 | } 51 | 52 | public function getHeader($name) 53 | { 54 | $header = []; 55 | if ($name === 'authorization') { 56 | $name = 'HTTP_AUTHORIZATION'; 57 | } 58 | if ( 59 | ($value = $this->request->getServer($name)) !== NULL || 60 | ($value = $this->request->getHeader($name)) !== NULL 61 | ) { 62 | $header[] = $value; 63 | } 64 | return $header; 65 | } 66 | 67 | public function getHeaderLine($name) 68 | { 69 | // TODO: Implement getHeaderLine() method. 70 | } 71 | 72 | public function withHeader($name, $value) 73 | { 74 | // TODO: Implement withHeader() method. 75 | } 76 | 77 | public function withAddedHeader($name, $value) 78 | { 79 | // TODO: Implement withAddedHeader() method. 80 | } 81 | 82 | public function withoutHeader($name) 83 | { 84 | // TODO: Implement withoutHeader() method. 85 | } 86 | 87 | public function getBody() 88 | { 89 | // TODO: Implement getBody() method. 90 | } 91 | 92 | public function withBody(StreamInterface $body) 93 | { 94 | // TODO: Implement withBody() method. 95 | } 96 | 97 | public function getRequestTarget() 98 | { 99 | // TODO: Implement getRequestTarget() method. 100 | } 101 | 102 | public function withRequestTarget($requestTarget) 103 | { 104 | // TODO: Implement withRequestTarget() method. 105 | } 106 | 107 | public function getMethod() 108 | { 109 | // TODO: Implement getMethod() method. 110 | } 111 | 112 | public function withMethod($method) 113 | { 114 | // TODO: Implement withMethod() method. 115 | } 116 | 117 | public function getUri() 118 | { 119 | // TODO: Implement getUri() method. 120 | } 121 | 122 | public function withUri(UriInterface $uri, $preserveHost = false) 123 | { 124 | // TODO: Implement withUri() method. 125 | } 126 | 127 | public function getServerParams() 128 | { 129 | // TODO: Implement getServerParams() method. 130 | } 131 | 132 | public function getCookieParams() 133 | { 134 | // TODO: Implement getCookieParams() method. 135 | } 136 | 137 | public function withCookieParams(array $cookies) 138 | { 139 | // TODO: Implement withCookieParams() method. 140 | } 141 | 142 | public function getQueryParams() 143 | { 144 | return $_REQUEST; 145 | } 146 | 147 | public function withQueryParams(array $query) 148 | { 149 | // TODO: Implement withQueryParams() method. 150 | } 151 | 152 | public function getUploadedFiles() 153 | { 154 | // TODO: Implement getUploadedFiles() method. 155 | } 156 | 157 | public function withUploadedFiles(array $uploadedFiles) 158 | { 159 | // TODO: Implement withUploadedFiles() method. 160 | } 161 | 162 | public function withParsedBody($data) 163 | { 164 | // TODO: Implement withParsedBody() method. 165 | } 166 | 167 | public function getAttributes() 168 | { 169 | // TODO: Implement getAttributes() method. 170 | } 171 | 172 | public function getAttribute($name, $default = null) 173 | { 174 | // TODO: Implement getAttribute() method. 175 | } 176 | 177 | public function withAttribute($name, $value) 178 | { 179 | $_SERVER[$name] = $value; 180 | return $this; 181 | } 182 | 183 | public function withoutAttribute($name) 184 | { 185 | // TODO: Implement withoutAttribute() method. 186 | } 187 | } -------------------------------------------------------------------------------- /Components/Oauth2/Response.php: -------------------------------------------------------------------------------- 1 | response = $response; 18 | } 19 | 20 | public function getProtocolVersion() 21 | { 22 | // TODO: Implement getProtocolVersion() method. 23 | } 24 | 25 | public function withProtocolVersion($version) 26 | { 27 | // TODO: Implement withProtocolVersion() method. 28 | } 29 | 30 | public function getHeaders() 31 | { 32 | // TODO: Implement getHeaders() method. 33 | } 34 | 35 | public function hasHeader($name) 36 | { 37 | // TODO: Implement hasHeader() method. 38 | } 39 | 40 | public function getHeader($name) 41 | { 42 | return $this->response->getHeaders()->get($name); 43 | } 44 | 45 | public function getHeaderLine($name) 46 | { 47 | // TODO: Implement getHeaderLine() method. 48 | } 49 | 50 | public function withHeader($name, $value) 51 | { 52 | $this->response->setHeader($name, $value); 53 | return $this; 54 | } 55 | 56 | public function withAddedHeader($name, $value) 57 | { 58 | // TODO: Implement withAddedHeader() method. 59 | } 60 | 61 | public function withoutHeader($name) 62 | { 63 | // TODO: Implement withoutHeader() method. 64 | } 65 | 66 | public function getBody() 67 | { 68 | $this->stream = new Stream(); 69 | return $this->stream; 70 | } 71 | 72 | public function withBody(StreamInterface $body) 73 | { 74 | // TODO: Implement withBody() method. 75 | } 76 | 77 | public function getStatusCode() 78 | { 79 | // TODO: Implement getStatusCode() method. 80 | } 81 | 82 | public function withStatus($code, $reasonPhrase = '') 83 | { 84 | $this->response->setStatusCode($code, $reasonPhrase); 85 | return $this; 86 | } 87 | 88 | public function getReasonPhrase() 89 | { 90 | // TODO: Implement getReasonPhrase() method. 91 | } 92 | 93 | public function getToken() 94 | { 95 | return $this->stream->getToken(); 96 | } 97 | } -------------------------------------------------------------------------------- /Components/Oauth2/Stream.php: -------------------------------------------------------------------------------- 1 | token; 13 | } 14 | 15 | public function getToken() 16 | { 17 | return $this->token; 18 | } 19 | 20 | public function close() 21 | { 22 | // TODO: Implement close() method. 23 | } 24 | 25 | public function detach() 26 | { 27 | // TODO: Implement detach() method. 28 | } 29 | 30 | public function getSize() 31 | { 32 | // TODO: Implement getSize() method. 33 | } 34 | 35 | public function tell() 36 | { 37 | // TODO: Implement tell() method. 38 | } 39 | 40 | public function eof() 41 | { 42 | // TODO: Implement eof() method. 43 | } 44 | 45 | public function isSeekable() 46 | { 47 | // TODO: Implement isSeekable() method. 48 | } 49 | 50 | public function seek($offset, $whence = SEEK_SET) 51 | { 52 | // TODO: Implement seek() method. 53 | } 54 | 55 | public function rewind() 56 | { 57 | // TODO: Implement rewind() method. 58 | } 59 | 60 | public function isWritable() 61 | { 62 | // TODO: Implement isWritable() method. 63 | } 64 | 65 | public function write($string) 66 | { 67 | // TODO: Implement write() method. 68 | $this->token = $string; 69 | } 70 | 71 | public function isReadable() 72 | { 73 | // TODO: Implement isReadable() method. 74 | } 75 | 76 | public function read($length) 77 | { 78 | // TODO: Implement read() method. 79 | } 80 | 81 | public function getContents() 82 | { 83 | // TODO: Implement getContents() method. 84 | } 85 | 86 | public function getMetadata($key = null) 87 | { 88 | // TODO: Implement getMetadata() method. 89 | } 90 | } -------------------------------------------------------------------------------- /Exceptions/HttpException.php: -------------------------------------------------------------------------------- 1 | message = $message; 24 | $this->devMessage = (array_key_exists('dev', $errorArray) ? $errorArray['dev'] : ''); 25 | $this->errorCode = (array_key_exists('internalCode', $errorArray) ? $errorArray['internalCode'] : ''); 26 | $this->code = $code; 27 | $this->additionalInfo = (array_key_exists('more', $errorArray) ? $errorArray['more'] : ''); 28 | $this->response = $this->getResponseDescription($code); 29 | } 30 | 31 | public function send() { 32 | $di = Di::getDefault(); 33 | 34 | $res = $di->get('response'); 35 | $req = $di->get('request'); 36 | 37 | //query string, filter, default 38 | if (!$req->get('suppress_response_codes', null, null)) { 39 | $res->setStatusCode($this->getCode(), $this->response)->sendHeaders(); 40 | } else { 41 | $res->setStatusCode('200', 'OK')->sendHeaders(); 42 | } 43 | 44 | $error = [ 45 | 'errorCode' => $this->getCode(), 46 | 'userMessage' => $this->getMessage(), 47 | 'devMessage' => $this->devMessage, 48 | 'more' => $this->additionalInfo, 49 | 'applicationCode' => $this->errorCode, 50 | ]; 51 | 52 | if (!$req->get('type') || $req->get('type') === 'json') { 53 | $response = new JsonResponse(); 54 | $response->send($error, true); 55 | return false; 56 | } elseif ($req->get('type') === 'csv') { 57 | $response = new CsvResponse(); 58 | $response->send(array($error)); 59 | return false; 60 | } 61 | 62 | error_log('HTTPException: ' . $this->getFile() . ' at ' . $this->getLine()); 63 | 64 | return true; 65 | } 66 | 67 | protected function getResponseDescription($code) { 68 | $codes = [ 69 | 70 | // Informational 1xx 71 | 100 => 'Continue', 72 | 101 => 'Switching Protocols', 73 | 74 | // Success 2xx 75 | 200 => 'OK', 76 | 201 => 'Created', 77 | 202 => 'Accepted', 78 | 203 => 'Non-Authoritative Information', 79 | 204 => 'No Content', 80 | 205 => 'Reset Content', 81 | 206 => 'Partial Content', 82 | 83 | // Redirection 3xx 84 | 300 => 'Multiple Choices', 85 | 301 => 'Moved Permanently', 86 | 302 => 'Found', // 1.1 87 | 303 => 'See Other', 88 | 304 => 'Not Modified', 89 | 305 => 'Use Proxy', 90 | // 306 is deprecated but reserved 91 | 307 => 'Temporary Redirect', 92 | 93 | // Client Error 4xx 94 | 400 => 'Bad Request', 95 | 401 => 'Unauthorized', 96 | 402 => 'Payment Required', 97 | 403 => 'Forbidden', 98 | 404 => 'Not Found', 99 | 405 => 'Method Not Allowed', 100 | 406 => 'Not Acceptable', 101 | 407 => 'Proxy Authentication Required', 102 | 408 => 'Request Timeout', 103 | 409 => 'Conflict', 104 | 410 => 'Gone', 105 | 411 => 'Length Required', 106 | 412 => 'Precondition Failed', 107 | 413 => 'Request Entity Too Large', 108 | 414 => 'Request-URI Too Long', 109 | 415 => 'Unsupported Media Type', 110 | 416 => 'Requested Range Not Satisfiable', 111 | 417 => 'Expectation Failed', 112 | 429 => 'Too Many Requests', 113 | 114 | // Server Error 5xx 115 | 500 => 'Internal Server Error', 116 | 501 => 'Not Implemented', 117 | 502 => 'Bad Gateway', 118 | 503 => 'Service Unavailable', 119 | 504 => 'Gateway Timeout', 120 | 505 => 'HTTP Version Not Supported', 121 | 509 => 'Bandwidth Limit Exceeded' 122 | ]; 123 | 124 | $result = (array_key_exists($code, $codes) ? 125 | $codes[$code] : 126 | 'Unknown Status Code' 127 | ); 128 | 129 | return $result; 130 | } 131 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) Stanimir Stoyanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Models/AccessTokens.php: -------------------------------------------------------------------------------- 1 | hasMany('userId', 'Users', 'id'); 27 | } 28 | } -------------------------------------------------------------------------------- /Models/Books.php: -------------------------------------------------------------------------------- 1 | hasMany('userId', 'Users', 'id'); 27 | } 28 | } -------------------------------------------------------------------------------- /Models/Users.php: -------------------------------------------------------------------------------- 1 | hasMany("id", "AuthorizedClients", "userId"); 33 | } 34 | 35 | /** 36 | * Validates a model before submitting it for creation or deletion. Our Princess model 37 | * must not be born before now, as we don't support future princesses. 38 | * @return bool 39 | * @throws HttpException If the validation failed 40 | */ 41 | public function validation() { 42 | $this->validate(new Uniqueness(array( 43 | "field" => "username", 44 | "message" => "Value of field 'username' is already present in another record" 45 | ))); 46 | if ($this->validationHasFailed() == true) { 47 | throw new HttpException( 48 | $this->appendMessage($this->getMessages()), 49 | 417 50 | ); 51 | } 52 | 53 | return true; 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /Modules/V1/Controllers/AccessTokenController.php: -------------------------------------------------------------------------------- 1 | di->get('authorizationServer'); 15 | $allowedGrandTypes = ['client_credentials', 'password', 'refresh_token']; 16 | $error = null; 17 | $result = []; 18 | $grant_type = $this->request->getPost('grant_type'); 19 | $request = new Request($this->request); 20 | $response = new Response($this->response); 21 | switch($grant_type) { 22 | case 'password': 23 | try { 24 | // Try to respond to the request 25 | $server->respondToAccessTokenRequest($request, $response); 26 | $result = $response->getToken(); 27 | } catch (OAuthServerException $exception) { 28 | $error = [ 29 | $exception->getMessage(), 30 | $exception->getHttpStatusCode(), 31 | null, 32 | [ 33 | 'dev' => $exception->getHint(), 34 | ] 35 | ]; 36 | } catch (\Exception $exception) { 37 | $error = [ 38 | 'Unknown error', 39 | 500, 40 | [ 41 | 'dev' => $exception->getMessage(), 42 | 'internalCode' => 'P1005', 43 | 'more' => '' 44 | ] 45 | ]; 46 | } 47 | break; 48 | case 'client_credentials': 49 | try { 50 | // Try to respond to the request 51 | $server->respondToAccessTokenRequest($request, $response); 52 | $result = $response->getToken(); 53 | } catch (OAuthServerException $exception) { 54 | $error = [ 55 | $exception->getMessage(), 56 | $exception->getHttpStatusCode(), 57 | null, 58 | [ 59 | 'dev' => $exception->getHint(), 60 | ] 61 | ]; 62 | } catch (\Exception $exception) { 63 | $error = [ 64 | 'Unknown error', 65 | 500, 66 | [ 67 | 'dev' => $exception->getMessage(), 68 | 'internalCode' => 'P1003', 69 | 'more' => '' 70 | ] 71 | ]; 72 | } 73 | break; 74 | case 'refresh_token': 75 | try { 76 | // Try to respond to the request 77 | $server->respondToAccessTokenRequest($request, $response); 78 | $result = $response->getToken(); 79 | } catch (OAuthServerException $exception) { 80 | $error = [ 81 | $exception->getMessage(), 82 | $exception->getHttpStatusCode(), 83 | null, 84 | [ 85 | 'dev' => $exception->getHint(), 86 | ] 87 | ]; 88 | } catch (\Exception $exception) { 89 | $error = [ 90 | 'Unknown error', 91 | 500, 92 | [ 93 | 'dev' => $exception->getMessage(), 94 | 'internalCode' => 'P1003', 95 | 'more' => '' 96 | ] 97 | ]; 98 | } 99 | 100 | break; 101 | default: 102 | $error = [ 103 | "The grant type is not allowed {$grant_type}", 104 | 400, 105 | [ 106 | 'dev' => "Allowed grant types are: " . implode(', ', $allowedGrandTypes), 107 | 'internalCode' => 'P1001', 108 | 'more' => '' 109 | ] 110 | ]; 111 | } 112 | if ($error !== null && is_array($error) && count($error) === 3) { 113 | throw new HttpException( 114 | $error[0], 115 | $error[1], 116 | null, 117 | $error[2] 118 | ); 119 | } 120 | return json_decode($result, true); 121 | } 122 | } -------------------------------------------------------------------------------- /Modules/V1/Controllers/AuthorizeController.php: -------------------------------------------------------------------------------- 1 | di->get('authorizationServer'); 17 | $allowedResponseTypes = ['code', 'token']; 18 | $error = null; 19 | $result = []; 20 | $response_type = $this->request->getPost('response_type'); 21 | $request = new Request($this->request); 22 | $response = new Response($this->response); 23 | switch($response_type) { 24 | case 'code': 25 | try { 26 | $authRequest = $server->validateAuthorizationRequest($request); 27 | 28 | // The auth request object can be serialized and saved into a user's session. 29 | // You will probably want to redirect the user at this point to a login endpoint. 30 | 31 | // Assuming user ID 1 has been logged-in... 32 | $user = Users::findFirst(['id' => 1])->toArray(); 33 | 34 | // Once the user has logged in set the user on the AuthorizationRequest 35 | $authRequest->setUser(new UserEntity($user)); // an instance of UserEntityInterface 36 | 37 | // At this point you should redirect the user to an authorization page. 38 | // This form will ask the user to approve the client and the scopes requested. 39 | 40 | // Once the user has approved or denied the client update the status 41 | // (true = approved, false = denied) 42 | $authRequest->setAuthorizationApproved(true); 43 | 44 | // Return the HTTP redirect response 45 | $url = $server->completeAuthorizationRequest($authRequest, $response)->getHeader('Location'); 46 | $this->response->redirect($url); 47 | } catch (OAuthServerException $exception) { 48 | $error = [ 49 | $exception->getMessage(), 50 | $exception->getHttpStatusCode(), 51 | null, 52 | [ 53 | 'dev' => $exception->getHint(), 54 | ] 55 | ]; 56 | 57 | } catch (\Exception $exception) { 58 | $error = [ 59 | 'Unknown error', 60 | 500, 61 | [ 62 | 'dev' => $exception->getMessage(), 63 | 'internalCode' => 'P1005', 64 | 'more' => '' 65 | ] 66 | ]; 67 | } 68 | break; 69 | case 'token': 70 | try { 71 | // Validate the HTTP request and return an AuthorizationRequest object. 72 | $authRequest = $server->validateAuthorizationRequest($request); 73 | // The auth request object can be serialized and saved into a user's session. 74 | // You will probably want to redirect the user at this point to a login endpoint. 75 | 76 | // for simplicity we assume that user with id 1 has been logged-in 77 | $user = Users::findFirst(['id' => 1])->toArray(); 78 | 79 | // Once the user has logged in set the user on the AuthorizationRequest 80 | $authRequest->setUser(new UserEntity($user)); // an instance of UserEntityInterface 81 | 82 | // At this point you should redirect the user to an authorization page. 83 | // This form will ask the user to approve the client and the scopes requested. 84 | 85 | // Once the user has approved or denied the client update the status 86 | // (true = approved, false = denied) 87 | $authRequest->setAuthorizationApproved(true); 88 | 89 | // Return the HTTP redirect response 90 | $redirectUrl = $server->completeAuthorizationRequest($authRequest, $response)->getHeader('Location'); 91 | $this->response->redirect($redirectUrl); 92 | } catch (OAuthServerException $exception) { 93 | switch ($exception->getCode()) { 94 | case 9: 95 | $url = $exception->generateHttpResponse($response)->getHeader('Location'); 96 | $this->response->redirect($url); 97 | break; 98 | default: 99 | $error = [ 100 | $exception->getMessage(), 101 | $exception->getHttpStatusCode(), 102 | null, 103 | [ 104 | 'dev' => $exception->getHint(), 105 | ] 106 | ]; 107 | } 108 | } catch (\Exception $exception) { 109 | $error = [ 110 | 'Unknown error', 111 | 500, 112 | [ 113 | 'dev' => $exception->getMessage(), 114 | 'internalCode' => 'P1003', 115 | 'more' => '' 116 | ] 117 | ]; 118 | } 119 | break; 120 | default: 121 | $error = [ 122 | "The response type is not allowed {$response_type}", 123 | 400, 124 | [ 125 | 'dev' => "Allowed response types are: " . implode(', ', $allowedResponseTypes), 126 | 'internalCode' => 'P1001', 127 | 'more' => '' 128 | ] 129 | ]; 130 | } 131 | if ($error !== null && is_array($error) && count($error) === 3) { 132 | throw new HttpException( 133 | $error[0], 134 | $error[1], 135 | null, 136 | $error[2] 137 | ); 138 | } 139 | return json_decode($result, true); 140 | } 141 | } -------------------------------------------------------------------------------- /Modules/V1/Controllers/BaseController.php: -------------------------------------------------------------------------------- 1 | setDI($di); 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /Modules/V1/Controllers/ExampleController.php: -------------------------------------------------------------------------------- 1 | ['id', 'author', 'title', 'year'], 15 | 'partials' => ['id', 'author', 'title', 'year'] 16 | ]; 17 | 18 | public function get() { 19 | if ($this->isSearch) { 20 | $results = $this->search(); 21 | } else { 22 | $results = Books::find()->toArray(); 23 | } 24 | 25 | return $this->respond($results); 26 | } 27 | 28 | public function getOne($id) { 29 | return $this->respond(Books::findFirst($id)->toArray()); 30 | } 31 | 32 | public function post() { 33 | return ['Post / stub']; 34 | } 35 | 36 | /** 37 | * @param int $id 38 | * @return array 39 | */ 40 | public function delete($id) { 41 | return ['Delete / stub']; 42 | } 43 | 44 | /** 45 | * @param int $id 46 | * @return array 47 | */ 48 | public function put($id) { 49 | return ['Put / stub']; 50 | } 51 | 52 | /** 53 | * @param int $id 54 | * @return array 55 | */ 56 | public function patch($id) { 57 | return ['Patch / stub']; 58 | } 59 | 60 | public function search() { 61 | $results = []; 62 | $allBooks = Books::find()->toArray(); 63 | foreach ($allBooks as $record) { 64 | $match = true; 65 | foreach ($this->searchFields as $field => $value) { 66 | if (strpos($record[$field], $value) === FALSE) { 67 | $match = false; 68 | } 69 | } 70 | if ($match) { 71 | $results[] = $record; 72 | } 73 | } 74 | 75 | return $results; 76 | } 77 | 78 | public function respond($results) { 79 | if (count($results) > 0) { 80 | if ($this->isPartial) { 81 | $newResults = []; 82 | $remove = array_diff(array_keys($results[0]), $this->partialFields); 83 | foreach ($results as $record) { 84 | $newResults[] = $this->array_remove_keys($record, $remove); 85 | } 86 | $results = $newResults; 87 | } 88 | if ($this->offset) { 89 | $results = array_slice($results, $this->offset); 90 | } 91 | if ($this->limit) { 92 | $results = array_slice($results, 0, $this->limit); 93 | } 94 | } 95 | return $results; 96 | } 97 | 98 | private function array_remove_keys($array, $keys = []) { 99 | 100 | // If array is empty or not an array at all, don't bother 101 | // doing anything else. 102 | if (empty($array) || (! is_array($array))) { 103 | return $array; 104 | } 105 | 106 | // At this point if $keys is not an array, we can't do anything with it. 107 | if (!is_array($keys)) { 108 | return $array; 109 | } 110 | 111 | // array_diff_key() expected an associative array. 112 | $assocKeys = array(); 113 | foreach($keys as $key) { 114 | $assocKeys[$key] = true; 115 | } 116 | 117 | return array_diff_key($array, $assocKeys); 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /Modules/V1/Controllers/RestController.php: -------------------------------------------------------------------------------- 1 | [], 67 | 'partials' => [] 68 | ]; 69 | 70 | /** 71 | * Constructor, calls the parse method for the query string by default. 72 | * @param boolean $parseQueryString true Can be set to false if a controller needs to be called 73 | * from a different controller, bypassing the $allowedFields parse 74 | */ 75 | public function onConstruct($parseQueryString = true) { 76 | if ($parseQueryString){ 77 | $this->parseRequest($this->allowedFields); 78 | } 79 | return; 80 | } 81 | 82 | /** 83 | * Parses out the search parameters from a request. 84 | * Unparsed, they will look like this: 85 | * (name:Benjamin Framklin,location:Philadelphia) 86 | * Parsed: 87 | * ['name'=>'Benjamin Franklin', 'location'=>'Philadelphia'] 88 | * @param string $unparsed Unparsed search string 89 | * @return array An array of fieldname=>value search parameters 90 | */ 91 | protected function parseSearchParameters($unparsed) { 92 | 93 | // Strip parentheses that come with the request string 94 | $unparsed = trim($unparsed, '()'); 95 | 96 | // Now we have an array of "key:value" strings. 97 | $splitFields = explode(',', $unparsed); 98 | $mapped = []; 99 | 100 | // Split the strings at their colon, set left to key, and right to value. 101 | foreach ($splitFields as $field) { 102 | $splitField = explode(':', $field); 103 | $mapped[$splitField[0]] = $splitField[1]; 104 | } 105 | 106 | return $mapped; 107 | } 108 | 109 | /** 110 | * Parses out partial fields to return in the response. 111 | * Unparsed: 112 | * (id,name,location) 113 | * Parsed: 114 | * ['id', 'name', 'location'] 115 | * @param string $unparsed Un-parsed string of fields to return in partial response 116 | * @return array Array of fields to return in partial response 117 | */ 118 | protected function parsePartialFields($unparsed) { 119 | return explode(',', trim($unparsed, '()')); 120 | } 121 | 122 | /** 123 | * Main method for parsing a query string. 124 | * Finds search paramters, partial response fields, limits, and offsets. 125 | * Sets Controller fields for these variables. 126 | * 127 | * @param array $allowedFields Allowed fields array for search and partials 128 | * @return boolean Always true if no exception is thrown 129 | * @throws HttpException If some of the fields requested are not allowed 130 | */ 131 | protected function parseRequest($allowedFields) { 132 | $request = $this->di->get('request'); 133 | $searchParams = $request->get('q', null, null); 134 | $fields = $request->get('fields', null, null); 135 | 136 | // Set limits and offset, elsewise allow them to have defaults set in the Controller 137 | $this->limit = ($request->get('limit', null, null)) ?: $this->limit; 138 | $this->offset = ($request->get('offset', null, null)) ?: $this->offset; 139 | 140 | // If there's a 'q' parameter, parse the fields, then determine that all the fields in the search 141 | // are allowed to be searched from $allowedFields['search'] 142 | if ($searchParams) { 143 | $this->isSearch = true; 144 | $this->searchFields = $this->parseSearchParameters($searchParams); 145 | 146 | // This handy snippet determines if searchFields is a strict subset of allowedFields['search'] 147 | if (array_diff(array_keys($this->searchFields), $this->allowedFields['search'])) { 148 | throw new HttpException( 149 | "The fields you specified cannot be searched.", 150 | 401, 151 | null, 152 | [ 153 | 'dev' => 'You requested to search fields that are not available to be searched.', 154 | 'internalCode' => 'S1000', 155 | 'more' => '' // Could have link to documentation here. 156 | ] 157 | ); 158 | } 159 | } 160 | 161 | // If there's a 'fields' parameter, this is a partial request. Ensures all the requested fields 162 | // are allowed in partial responses. 163 | if ($fields) { 164 | $this->isPartial = true; 165 | $this->partialFields = $this->parsePartialFields($fields); 166 | 167 | // Determines if fields is a strict subset of allowed fields 168 | if (array_diff($this->partialFields, $this->allowedFields['partials'])) { 169 | throw new HttpException( 170 | "The fields you asked for cannot be returned.", 171 | 401, 172 | null, 173 | [ 174 | 'dev' => 'You requested to return fields that are not available to be returned in partial responses.', 175 | 'internalCode' => 'P1000', 176 | 'more' => '' // Could have link to documentation here. 177 | ] 178 | ); 179 | } 180 | } 181 | 182 | return true; 183 | } 184 | 185 | /** 186 | * Provides a base CORS policy for routes like '/users' that represent a Resource's base url 187 | * Origin is allowed from all urls. Setting it here using the Origin header from the request 188 | * allows multiple Origins to be served. It is done this way instead of with a wildcard '*' 189 | * because wildcard requests are not supported when a request needs credentials. 190 | * 191 | * @return true 192 | */ 193 | public function optionsBase() { 194 | $response = $this->di->get('response'); 195 | $methods = []; 196 | foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) { 197 | if (method_exists($this, $method)) { 198 | array_push($methods, strtoupper($method)); 199 | if ($method === 'get') { 200 | array_push($methods, 'HEAD'); 201 | } 202 | } 203 | } 204 | array_push($methods, 'OPTIONS'); 205 | $response->setHeader('Access-Control-Allow-Methods', implode(', ', $methods)); 206 | $response->setHeader('Access-Control-Allow-Origin', '*'); 207 | $response->setHeader('Access-Control-Allow-Credentials', 'true'); 208 | $response->setHeader('Access-Control-Allow-Headers', "origin, x-requested-with, content-type"); 209 | $response->setHeader('Access-Control-Max-Age', '86400'); 210 | return true; 211 | } 212 | 213 | /** 214 | * Provides a CORS policy for routes like '/users/123' that represent a specific resource 215 | * 216 | * @return true 217 | */ 218 | public function optionsOne() { 219 | $response = $this->di->get('response'); 220 | $response->setHeader('Access-Control-Allow-Methods', 'GET, PUT, PATCH, DELETE, OPTIONS, HEAD'); 221 | $response->setHeader('Access-Control-Allow-Origin', $this->di->get('request')->header('Origin')); 222 | $response->setHeader('Access-Control-Allow-Credentials', 'true'); 223 | $response->setHeader('Access-Control-Allow-Headers', "origin, x-requested-with, content-type"); 224 | $response->setHeader('Access-Control-Max-Age', '86400'); 225 | return true; 226 | } 227 | 228 | /** 229 | * Should be called by methods in the controllers that need to output results to the HTTP Response. 230 | * Ensures that arrays conform to the patterns required by the Response objects. 231 | * 232 | * @param array $recordsArray Array of records to format as return output 233 | * @return array Output array. If there are records (even 1), every record will be an array ex: [['id'=>1],['id'=>2]] 234 | * @throws HttpException If there is a problem with the records 235 | */ 236 | protected function respond($recordsArray) { 237 | 238 | if (!is_array($recordsArray)) { 239 | // This is bad. Throw a 500. Responses should always be arrays. 240 | throw new HttpException( 241 | "An error occurred while retrieving records.", 242 | 500, 243 | null, 244 | array( 245 | 'dev' => 'The records returned were malformed.', 246 | 'internalCode' => 'RESP1000', 247 | 'more' => '' 248 | ) 249 | ); 250 | } 251 | 252 | // No records returned, so return an empty array 253 | if (count($recordsArray) === 0) { 254 | return []; 255 | } 256 | 257 | return [$recordsArray]; 258 | 259 | } 260 | 261 | } -------------------------------------------------------------------------------- /Modules/V1/Routes/collections/access_token.php: -------------------------------------------------------------------------------- 1 | setPrefix('/v1/access_token') 15 | // Must be a string in order to support lazy loading 16 | ->setHandler('\Phalcon2Rest\Modules\V1\Controllers\AccessTokenController') 17 | ->setLazy(true); 18 | 19 | // Set Access-Control-Allow headers. 20 | $exampleCollection->options('/', 'optionsBase'); 21 | 22 | $exampleCollection->post('/', 'post'); 23 | 24 | return $exampleCollection; 25 | }); -------------------------------------------------------------------------------- /Modules/V1/Routes/collections/authorize.php: -------------------------------------------------------------------------------- 1 | setPrefix('/v1/authorize') 15 | // Must be a string in order to support lazy loading 16 | ->setHandler('\Phalcon2Rest\Modules\V1\Controllers\AuthorizeController') 17 | ->setLazy(true); 18 | 19 | // Set Access-Control-Allow headers. 20 | $exampleCollection->options('/', 'optionsBase'); 21 | 22 | $exampleCollection->post('/', 'post'); 23 | 24 | return $exampleCollection; 25 | }); -------------------------------------------------------------------------------- /Modules/V1/Routes/collections/example.php: -------------------------------------------------------------------------------- 1 | setPrefix('/v1/example') 19 | // Must be a string in order to support lazy loading 20 | ->setHandler('\Phalcon2Rest\Modules\V1\Controllers\ExampleController') 21 | ->setLazy(true); 22 | 23 | // Set Access-Control-Allow headers. 24 | $exampleCollection->options('/', 'optionsBase'); 25 | $exampleCollection->options('/{id}', 'optionsOne'); 26 | 27 | // First parameter is the route, which with the collection prefix here would be GET /example/ 28 | // Second parameter is the function name of the Controller. 29 | $exampleCollection->get('/', 'get'); 30 | // This is exactly the same execution as GET, but the Response has no body. 31 | $exampleCollection->head('/', 'get'); 32 | 33 | // $id will be passed as a parameter to the Controller's specified function 34 | $exampleCollection->get('/{id:[0-9]+}', 'getOne'); 35 | $exampleCollection->head('/{id:[0-9]+}', 'getOne'); 36 | $exampleCollection->post('/', 'post'); 37 | $exampleCollection->delete('/{id:[0-9]+}', 'delete'); 38 | $exampleCollection->put('/{id:[0-9]+}', 'put'); 39 | $exampleCollection->patch('/{id:[0-9]+}', 'patch'); 40 | 41 | return $exampleCollection; 42 | }); -------------------------------------------------------------------------------- /Modules/V1/Routes/routeLoader.php: -------------------------------------------------------------------------------- 1 | ex: q=(name:Jonhson,city:Oklahoma) 56 | 57 | **Partial Responses** 58 | 59 | 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. 60 | 61 | > ex: fields=(id,name,location) 62 | 63 | **Limit and Offset** 64 | 65 | Often used to paginate large result sets. Offset is the record to start from, and limit is the number of records to return. 66 | 67 | > ex: limit=20&offset=20 will return results 21 to 40 68 | 69 | **Return Type** 70 | 71 | Overrides any accept headers. JSON is assumed otherwise. Return type handler must be implemented. 72 | 73 | > ex: type=csv 74 | 75 | **Suppressed Error Codes** 76 | 77 | Some clients require all responses to be a 200 (Flash, for example), even if there was an application error. 78 | With this parameter included, the application will always return a 200 response code, and clients will be 79 | responsible for checking the response body to ensure a valid response. 80 | 81 | > ex: suppress_error_codes=true 82 | 83 | Installation 84 | ------------ 85 | **Getting composer** 86 | ``` 87 | curl -sS getcomposer.org/installer | php 88 | ``` 89 | 90 | **Installing the project & dependencies (expecting phalcon2 to be loaded as a module!)** 91 | ``` 92 | php composer.phar create-project stratoss/phalcon2rest MyAPI --stability dev --no-interaction 93 | ``` 94 | 95 | **Public / Private keys used for JWT signing** 96 | Sample keys are generated in the `ssl` folder, you must regenerate your own set before going to production! 97 | 98 | ``` 99 | openssl genrsa -out private.key 2048 100 | openssl rsa -in private.key -pubout -out public.key 101 | ``` 102 | 103 | Responses 104 | --------- 105 | 106 | All route controllers must return an array. This array is used to create the response object. 107 | 108 | **Retrieving access token using password grant** 109 | 110 | ``` 111 | curl https://domain/v1/access_token --data "grant_type=password&client_id=1&client_secret=pass2&username=stan&password=pass&scope=basic" 112 | 113 | { 114 | "tokenType": "Bearer", 115 | "expiresIn": 3600, 116 | "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImUyY2ExYjhiM2ZlODRhM2Q2ZGFmZjZhNmFiZmQwMjJiNzQxMGYxOWNmODAzMWVkOThlNjc4YzVjN2Q3Mjc4MTk0NDFhYzMyNTc5OTM1OTIxIn0.eyJhdWQiOiIxIiwianRpIjoiZTJjYTFiOGIzZmU4NGEzZDZkYWZmNmE2YWJmZDAyMmI3NDEwZjE5Y2Y4MDMxZWQ5OGU2NzhjNWM3ZDcyNzgxOTQ0MWFjMzI1Nzk5MzU5MjEiLCJpYXQiOjE0NjI3MjkxMTgsIm5iZiI6MTQ2MjcyOTExOCwiZXhwIjoxNDYyNzMyNzE4LCJzdWIiOiIxIiwic2NvcGVzIjpbImJhc2ljIl19.VtKS7k1WiebSHiYwSrYJ9D8G90BQE81UkCNr8RI3-Ul3XaUw8kF1mMR-Q7YeO4pISUT48Gu5Sj6fTSsqF1n0Qz3axR-qcJstSwy_T0VZDNQYYdGGoXSRWabiIkLA50lbaPd8YGPLZO3IijgvyZxC3Miz7iUxxz1XOHvzHiDZP5s", 117 | "refreshToken": "U8PSWA\/xs\/Qw7\/\/Do76KM61+R6tUQwoex4YmnH0HjUCjzDKR22mRvSSPzkgbuR6EqjaaAp\/QWdBcYjgZj5XOyXAH9LM7C\/uBr4YeLUeTvzf7SylqIlLOuKe6aOZzD+L5ztSNdMCzjCQ1PZSWtk+hm7Ik1lUfXe1A\/09qI+hfG+p5cXHvow26ZSWz42uYaoEV5MAh+VBvR2vWx4WrAa6JJ\/6QISJRu+KTUXJmiGBbzhZiHZJ7+pyTeLiFk\/euT8aTa2tIz5WMogQDhMfzzPadOZY88jrc9mPuwxOv8DPKnysAlYHklnEQzESoP4MmKP0D9yrbLx80eE3PdEnj1hPYcpLIb8Dvc1IK+rIzg9+71HSW5XH54npY7MlLbNPDAB02bdX5SDWJhT8XwN8zqZQiLOUwBgP\/ESjW5dI7ckLzmmguqRPbd3TtNoYGultyFUCIxKH1FVNqJrxgVqJk083KXqFAzcsZw6cxgvies3djGxcPGCzyNRlxuEU8+ZoMJ0u0" 118 | } 119 | ``` 120 | 121 | **Retrieving access token using client_credentials grant** 122 | 123 | ``` 124 | curl https://domain/v1/access_token --data "grant_type=client_credentials&client_id=1&client_secret=pass2&scope=basic" 125 | 126 | { 127 | "tokenType": "Bearer", 128 | "expiresIn": 3600, 129 | "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImQwYjU1YjJiMjhhYmIxOTMwODk3OTg2ZTIxN2RkMTkwZGY1YTlkYzk2NGJmZjljY2ZjMWQ0NGI4Y2RiNjU0OTAyYjMwM2M1ZDliMzY5ZWQxIn0.eyJhdWQiOiIxIiwianRpIjoiZDBiNTViMmIyOGFiYjE5MzA4OTc5ODZlMjE3ZGQxOTBkZjVhOWRjOTY0YmZmOWNjZmMxZDQ0YjhjZGI2NTQ5MDJiMzAzYzVkOWIzNjllZDEiLCJpYXQiOjE0NjI3MjE4NzcsIm5iZiI6MTQ2MjcyMTg3NywiZXhwIjoxNDYyNzI1NDc3LCJzdWIiOiIiLCJzY29wZXMiOlsiYmFzaWMiXX0.KDkaVBMBX4UelYJ4UoknjgrssEaqpQPj2MPe4ArIppIc0BYNA-5xxVWCSu8rSGKO7QAVM2XSyiux3yq8NoClgtaPlPtpZN6pcSfwGo9MSM6IwQanpd978pwPCi-tXXl4mlViph9sgxPioJ3CzCBoJTpeEtRnEm6nxMUgLnncXps" 130 | } 131 | ``` 132 | 133 | **Exchanging refresh token for a new set of refresh token + access token** 134 | 135 | ``` 136 | curl https://domain/v1/access_token --data "client_id=1&client_secret=pass2&grant_type=refresh_token&scope=basic&refresh_token=YOUR_REFRESH_TOKEN" 137 | 138 | { 139 | "tokenType": "Bearer", 140 | "expiresIn": 3600, 141 | "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImY0NGRiMzQ1MzE0MDdlMWY2MWU0N2NkODQ2ZjIxOTRiMjNiZWZhNzZmOWVjYWY5ZDIyMWFhYTg5MTVhMDhjOGFhMzkzYTdmMGI4NGEwNjQ1In0.eyJhdWQiOiIxIiwianRpIjoiZjQ0ZGIzNDUzMTQwN2UxZjYxZTQ3Y2Q4NDZmMjE5NGIyM2JlZmE3NmY5ZWNhZjlkMjIxYWFhODkxNWEwOGM4YWEzOTNhN2YwYjg0YTA2NDUiLCJpYXQiOjE0NjI3MjIzMjIsIm5iZiI6MTQ2MjcyMjMyMiwiZXhwIjoxNDYyNzI1OTIyLCJzdWIiOiIxIiwic2NvcGVzIjpbImJhc2ljIl19.COJ5kAWEEjZyKN_k1N0sgiLJzpEtlgT9H3oJpeicQ-bZteuABZ3sYWCgBY2FrRm6Q8ouMra9WXj38NnwYRgOusRq2H1JL-3redvTu8LitPljNLYritSAuPivOVY4e6FVjQHeuXfIl37rmKIHUXmJcUSJRh1XOqW9mXJGggiXhlI", 142 | "refreshToken": "muYBWN8fSzSL2UCQU0FCq7EZrJ7bPBmJxLsHOTzBSoHJn0gT+ilWyeJzvOqrlVJel4V8K7HIOQfExbKB5l0UrwzFo5UDCz5qj72wWgUn8aJWY09LfGZAs6Qsx\/INLmg6y7petQdtWspAPWlaid8OBk2w5IsqQ7kLFATHCA9fWIg3HWRrc8RPkWeBgOZ5ekRO1dGnmzDm+HLmt8hvIq7uiNDRINYYDwmYh50Ifkv8iJhxL7Pj351KPg43G9pB6L8mNfVizx71c3cofuHlTYMc4S5pt9PFBg7kbtR+qYAD5Wpm3jK204HTpx\/lYyVtEZuFou8O+7ssWlWCSXf7wogxPy9fMuRgXzONnqUn8XHDJEBOxZIIVeu7AAgsWKGJvNrLVY+81oa8BQL1MdCqxQs8vVnHgzq9+bnrjZPlhcvm\/jhWzeCx6X\/fjdneTsZXOZXLK0OpCYNkyOaT2xC5H3RI2+jRGU0HCXGJTmuBlz4Kx48fdUuy2DwF\/DL+LS2mWE6o" 143 | } 144 | ``` 145 | 146 | **Retrieving access token using implicit grant** 147 | 148 | Checkout `Modules/V1/Controllers/AuthorizeController.php`. Extra step must be taken in order to auth the user. 149 | 150 | For simplicity assuming that the process started with a POST request to `https://domain/v1/authorize` 151 | sending POST data "response_type=token&client_id=1&scope=basic" 152 | and successful client auth, the response would be a redirect to 153 | 154 | `http://example.com/super-app#access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6IjBjZDUxMDRkZDg2YTg0OThhZWUyZGQzOGNlYzgzYzRkMTU4MmE4YjM4ZmZjYWY3ZDQ2MjZiZTY0NWUxN2Q0MjJjZWJmMDRlNWY2YjBjNWUxIn0.eyJhdWQiOiIxIiwianRpIjoiMGNkNTEwNGRkODZhODQ5OGFlZTJkZDM4Y2VjODNjNGQxNTgyYThiMzhmZmNhZjdkNDYyNmJlNjQ1ZTE3ZDQyMmNlYmYwNGU1ZjZiMGM1ZTEiLCJpYXQiOjE0NjI3ODg0NzcsIm5iZiI6MTQ2Mjc4ODQ3NywiZXhwIjoxNDYyNzkyMDc3LCJzdWIiOiIxIiwic2NvcGVzIjpbImJhc2ljIl19.mI8E7KVG6NGxBqbZ6nVojtOXbvRCQzjnNcBSRHAbF2SyoKQlQTGAfDmGNxozfKoNh7G60Il84NKYVvwhC3S3-jLhsEgVA0UePnVnGq4V84M0yMBtLJY3puLSIOAoAGuvUjMjSlxNJnqXZ68R3oD1vi3dmA7MVeSELbii2apAyo4&token_type=bearer&expires_in=3600` 155 | 156 | **Retrieving access code using authorization code grant** 157 | 158 | Checkout `Modules/V1/Controllers/AuthorizeController.php`. Extra step must be taken in order to auth the user. 159 | 160 | For simplicity assuming that the process started with a POST request to `https://domain/v1/authorize` 161 | sending POST data "response_type=code&client_id=1&scope=basic" 162 | and successful client auth, the response would be a redirect to 163 | 164 | `http://example.com/super-app?code=ReoVHgGRnMj6IVhAUDUvunKKCi2BvGxfsJ8nGMj%2FIO2ITr6u7%2FJ7epKAIEG%2F0KZMk5Cc5GhWouG8zYHgGwzAHSztOS%2FKKp8krH5rm6e4pIkmhYvy9TCDUF1fdSo0axfZTQm1V9Ja8Ww3GN%2BeMvpmoKCXPNB8VEOs7smkTI9EGJGjVC2bS26ZJKWGuIV1UqyUKEeSiNfvhAqzeZWF2fXhGDDxmawtIPo7C3Vhs9ZW035P%2FKcugRxdT5t5MTkB%2BgRllqNGLo1DCnXvSB9E9H6KOEraMMYdqzcX4YNX8TseBrJINBJM7JUZkjFqQ176DXfnI7ULN7R%2FUJrRwWNdPMuHwQ%3D%3D` 165 | 166 | **JSON** 167 | 168 | JSON is the default response type. The responses will look like this: 169 | 170 | ``` 171 | curl "https://domain/v1/example?q=(id:3)&fields=(author,title,year)" \ 172 | -H "Authorization: Bearer YOUR_ACCESS_TOKEN" 173 | 174 | [ 175 | { 176 | "author": "Stanimir Stoyanov", 177 | "title": "OAuth2 with Phalcon", 178 | "year": "2016" 179 | } 180 | ] 181 | 182 | curl "https://domain/v1/example?q=(year:2010)&fields=(author,title)" \ 183 | -H "Authorization: Bearer YOUR_ACCESS_TOKEN" 184 | 185 | [ 186 | { 187 | "author": "John Doe", 188 | "title": "Greatest book" 189 | }, 190 | { 191 | "author": "John Doe", 192 | "title": "Book of books" 193 | } 194 | ] 195 | ``` 196 | 197 | An envelope can be included in responses via the 'envelope=true' query parameter. This will return the record set and the meta information as the body. 198 | 199 | ``` 200 | curl "https://domain/v1/example?q=(year:2010)&fields=(author,title)" \ 201 | -H "Authorization: Bearer YOUR_ACCESS_TOKEN" 202 | 203 | { 204 | "_meta": { 205 | "status": "SUCCESS", 206 | "count": 2 207 | }, 208 | "records": [ 209 | { 210 | "author": "John Doe", 211 | "title": "Greatest book" 212 | }, 213 | { 214 | "author": "John Doe", 215 | "title": "Book of books" 216 | } 217 | ] 218 | } 219 | ``` 220 | Often times, database field names are snake_cased. However, when working with an API, developers 221 | generally prefer JSON fields to be returned in camelCase (many API requests are from browsers, in JS). 222 | This project will by default convert all keys in a records response from snake_case to camelCase. 223 | 224 | This can be turned off for your API by setting the JSONResponse's function "convertSnakeCase(false)". 225 | 226 | **CSV** 227 | 228 | 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. 229 | 230 | ``` 231 | curl "https://domain/v1/example?q=(year:2010)&fields=(id,author,title)&type=csv" \ 232 | -H "Authorization: Bearer YOUR_ACCESS_TOKEN" 233 | 234 | id,author,title 235 | 1,"John Doe","Greatest book" 236 | 2,"John Doe","Book of books" 237 | ``` 238 | 239 | Errors 240 | ------- 241 | 242 | Phalcon2Rest\Exceptions\HttpException extends PHP's native exceptions. Throwing this type of exception 243 | returns a nicely formatted JSON response to the client. 244 | 245 | ``` 246 | throw new \Phalcon2Rest\Exceptions\HttpException( 247 | 'Could not return results in specified format', 248 | 403, 249 | null 250 | array( 251 | 'dev' => 'Could not understand type specified by type paramter in query string.', 252 | 'internalCode' => 'NF1000', 253 | 'more' => 'Type may not be implemented. Choose either "csv" or "json"' 254 | ) 255 | ); 256 | ``` 257 | 258 | Returns this: 259 | 260 | ``` 261 | { 262 | "devMessage": "Could not understand type specified by type paramter in query string.", 263 | "error": 403, 264 | "errorCode": "NF1000", 265 | "more": "Type may not be implemented. Choose either \"csv\" or \"json\"", 266 | "userMessage": "Could not return results in specified format" 267 | } 268 | ``` 269 | 270 | 271 | Example Controller 272 | ------------------- 273 | 274 | The Example Controller sets up a route at /example and implements all of the above query parameters. 275 | You can mix and match any of these queries: 276 | 277 | > api.example.local/v1/example?q=(author:Stanimir Stoyanov) 278 | 279 | > api.example.local/v1/example?fields=(id,title) 280 | 281 | > api.example.local/v1/example/1?fields=(author)&envelope=false 282 | 283 | > api.example.local/v1/example?type=csv 284 | 285 | > api.example.local/v1/example?q=(year:2010)&offset=1&limit=2&type=csv&fields=(id,author) 286 | 287 | Rate Limiting 288 | -------------- 289 | 290 | There are 3 rate limiters implemented, configured in `config/config.ini` 291 | 292 | > How many request for access token are permitted 293 | 294 | [access_token_limits] 295 | 296 | r1 = 5 297 | 298 | This line sets a limit of 1 request per 5 seconds per IP for `/access_token` & `/authorize` endpoints. 299 | 300 | > How many unauthorized requests 301 | 302 | [api_unauthorized_limits] 303 | 304 | r10 = 60 305 | 306 | This line sets a limit of 10 requests per 1 minute per IP for every other request, when 307 | authorization header is missing / is invalid. OPTIONS requests are counted here too. 308 | 309 | > Everything else 310 | 311 | [api_common_limits] 312 | 313 | r600 = 3600 314 | 315 | 600 requests per hour for all authorized consumers. Users and clients are counted separately here. 316 | 317 | **Tracking the rate limiter** 318 | 319 | Each requests returns the X-Rate-Limit-* headers, e.g. 320 | 321 | ``` 322 | HTTP/1.1 200 OK 323 | 324 | X-Rate-Limit-Limit: 600 325 | X-Rate-Limit-Remaining: 599 326 | X-Rate-Limit-Reset: 3600 327 | X-Record-Count: 2 328 | X-Status: SUCCESS 329 | E-Tag: 6385b20e0a8a3fb0edd588d630573f00 330 | ``` 331 | 332 | When the limit is reached: 333 | ``` 334 | < HTTP/1.1 429 Too Many Requests 335 | < X-Rate-Limit-Limit: 600 336 | < X-Rate-Limit-Remaining: 0 337 | < X-Rate-Limit-Reset: 3355 338 | < X-Status: ERROR 339 | < E-Tag: f22e9815ad32e143287944e727627e9c 340 | < 341 | 342 | { 343 | "errorCode": 429, 344 | "userMessage": "Too Many Requests", 345 | "devMessage": "You have reached your limit. Please try again after 3355 seconds.", 346 | "more": "", 347 | "applicationCode": "P1010" 348 | } 349 | ``` 350 | 351 | What about CORS? 352 | ================ 353 | 354 | By extending a controller with RestController we're providing base CORS policy. 355 | 356 | The controller will be inspected and Access-Control-Allow-Methods will be populated with all valid methods found. 357 | 358 | ``` 359 | curl -I -X OPTIONS https://domain/v1/authorize 360 | 361 | Access-Control-Allow-Methods: POST, OPTIONS 362 | Access-Control-Allow-Origin: * 363 | Access-Control-Allow-Credentials: true 364 | Access-Control-Allow-Headers: origin, x-requested-with, content-type 365 | ``` 366 | 367 | ``` 368 | curl -I -X OPTIONS https://domain/v1/example 369 | 370 | Access-Control-Allow-Methods: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS 371 | Access-Control-Allow-Origin: * 372 | Access-Control-Allow-Credentials: true 373 | Access-Control-Allow-Headers: origin, x-requested-with, content-type 374 | ``` 375 | 376 | Please note that there is no way to safely authorize the user with the OPTIONS method, so those requests 377 | are counted in the rate limiter as unauthorized ones. 378 | 379 | Performance optimization 380 | ======================== 381 | 382 | By default FileCache is used, which is extremely slow. Consider using memcached or redis. 383 | In the project we're using sqlite3 as database. Consider using MySQL/PostgreSQL/MongoDB 384 | or something else with caching up-front. 385 | 386 | Anything else? 387 | ============== 388 | 389 | > Revocation of access/refresh tokens are not implemented as this is strongly individual. 390 | 391 | Check out `Components\Oauth2\Repositories\AccessTokenRepository.php` and `Components\Oauth2\Repositories\RefreshTokenRepository.php` 392 | 393 | Each access has unique identifier (jti) which could be used for revocation. 394 | 395 | The tokens could be easily debugged using tool like [JWT.io][jwt.io] 396 | 397 | [phalcon]: http://phalconphp.com/index 398 | [phalconDocs]: http://docs.phalconphp.com/en/latest/ 399 | [apigeeBook]: https://blog.apigee.com/detail/announcement_new_ebook_on_web_api_design 400 | [OAuth2]: https://github.com/thephpleague/oauth2-server 401 | [cmoore4]: https://github.com/cmoore4/phalcon-rest/ 402 | [oauth2doc]: https://oauth2.thephpleague.com/ 403 | [jwt.io]: https://jwt.io/ -------------------------------------------------------------------------------- /Responses/CsvResponse.php: -------------------------------------------------------------------------------- 1 | di->get('response'); 15 | // Headers for a CSV 16 | $response->setHeader('Content-type', 'application/csv'); 17 | 18 | // By default, filename is just a timestamp. You should probably change this. 19 | $response->setHeader('Content-Disposition', 'attachment; filename="'.time().'.csv"'); 20 | $response->setHeader('Pragma', 'no-cache'); 21 | $response->setHeader('Expires', '0'); 22 | 23 | // We write directly to out, which means we don't ever save this file to disk. 24 | $handle = fopen('php://output', 'w'); 25 | 26 | // The keys of the first result record will be the first line of the CSV (headers) 27 | if ($this->headers) { 28 | fputcsv($handle, array_keys($records[0])); 29 | } 30 | 31 | // Write each record as a csv line. 32 | foreach ($records as $line) { 33 | fputcsv($handle, $line); 34 | } 35 | 36 | fclose($handle); 37 | 38 | return $this; 39 | } 40 | 41 | public function useHeaderRow($headers){ 42 | $this->headers = (bool) $headers; 43 | return $this; 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /Responses/JsonResponse.php: -------------------------------------------------------------------------------- 1 | di->get('response'); 22 | $success = ($error ? 'ERROR' : 'SUCCESS'); 23 | 24 | // If the query string 'envelope' is set to true, use the envelope. 25 | // Instead, return headers. 26 | $request = $this->di->get('request'); 27 | if ($request->get('envelope', null, null) === 'true') { 28 | $this->envelope = true; 29 | } 30 | 31 | 32 | // Most devs prefer camelCase to snake_Case in JSON, but this can be overridden here 33 | if ($this->snake) { 34 | $records = $this->arrayKeysToSnake($records); 35 | } 36 | 37 | $etag = md5(serialize($records)); 38 | 39 | if ($this->envelope) { 40 | // Provide an envelope for JSON responses. '_meta' and 'records' are the objects. 41 | $message = []; 42 | $message['_meta'] = [ 43 | 'status' => $success, 44 | 'count' => ($error ? 1 : count($records)) 45 | ]; 46 | 47 | // Handle 0 record responses, or assign the records 48 | if($message['_meta']['count'] === 0){ 49 | // This is required to make the response JSON return an empty JS object. Without 50 | // this, the JSON return an empty array: [] instead of {} 51 | $message['records'] = new \stdClass(); 52 | } else { 53 | $message['records'] = $records; 54 | } 55 | 56 | } else { 57 | if ($success !== 'ERROR') { 58 | $response->setHeader('X-Record-Count', count($records)); 59 | } 60 | $response->setHeader('X-Status', $success); 61 | $message = $records; 62 | } 63 | 64 | $response->setContentType('application/json'); 65 | $response->setHeader('E-Tag', $etag); 66 | 67 | // HEAD requests are detected in the parent constructor. HEAD does everything exactly the 68 | // same as GET, but contains no body. 69 | if (!$this->head && $message !== null) { 70 | $response->setJsonContent($message, JSON_PRETTY_PRINT); 71 | } 72 | 73 | $response->send(); 74 | 75 | return $this; 76 | } 77 | 78 | public function convertSnakeCase($snake) { 79 | $this->snake = (bool) $snake; 80 | return $this; 81 | } 82 | 83 | public function useEnvelope($envelope) { 84 | $this->envelope = (bool) $envelope; 85 | return $this; 86 | } 87 | 88 | } -------------------------------------------------------------------------------- /Responses/Response.php: -------------------------------------------------------------------------------- 1 | setDI($di); 15 | if(strtolower($this->di->get('request')->getMethod()) === 'head'){ 16 | $this->head = true; 17 | } 18 | } 19 | 20 | /** 21 | * In-Place, recursive conversion of array keys in snake_Case to camelCase 22 | * @param array $snakeArray Array with snake_keys 23 | * @return array 24 | */ 25 | protected function arrayKeysToSnake($snakeArray){ 26 | foreach($snakeArray as $k=>$v){ 27 | if (is_array($v)){ 28 | $v = $this->arrayKeysToSnake($v); 29 | } 30 | $snakeArray[$this->snakeToCamel($k)] = $v; 31 | if($this->snakeToCamel($k) != $k){ 32 | unset($snakeArray[$k]); 33 | } 34 | } 35 | return $snakeArray; 36 | } 37 | 38 | /** 39 | * Replaces underscores with spaces, uppercases the first letters of each word, 40 | * lowercases the very first letter, then strips the spaces 41 | * @param string $val String to be converted 42 | * @return string Converted string 43 | */ 44 | protected function snakeToCamel($val) { 45 | return (strtoupper($val) === $val ? $val : str_replace(' ', '', lcfirst(ucwords(str_replace('_', ' ', $val))))); 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | $path) { 16 | $composerNamespaces[rtrim($namespace, '\\')] = $path[0]; 17 | } 18 | } else { 19 | $composerNamespaces = []; 20 | } 21 | 22 | $composerAutoloadFilesPath = __DIR__ . '/vendor/composer/autoload_files.php'; 23 | if (file_exists($composerAutoloadFilesPath) && is_file($composerAutoloadFilesPath)) { 24 | $allFiles = include($composerAutoloadFilesPath); 25 | foreach ($allFiles as $file) { 26 | include($file); 27 | } 28 | } 29 | $namespaces = array_merge([ 30 | 'Phalcon2Rest\Exceptions' => __DIR__ . '/Exceptions/', 31 | 'Phalcon2Rest\Responses' => __DIR__ . '/Responses/', 32 | 'Phalcon2Rest\Components' => __DIR__ . '/Components/', 33 | 'Phalcon2Rest\Modules' => __DIR__ . '/Modules/', 34 | 'Phalcon2Rest\Models' => __DIR__ . '/Models/' 35 | ], $composerNamespaces); 36 | $loader->registerNamespaces($namespaces)->register(); -------------------------------------------------------------------------------- /cache/README: -------------------------------------------------------------------------------- 1 | File cache will be contained in this folder 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stratoss/phalcon2rest", 3 | "version":"1.0.0", 4 | "description": "Phalcon2 project with OAuth2, JWT and rate limiting", 5 | "keywords": ["phalcon", "phalcon2", "oauth", "api", "oauth2", "JWT"], 6 | "type": "project", 7 | "license": "MIT", 8 | "support": { 9 | "issues": "https://github.com/stratoss/phalcon2rest/issues", 10 | "wiki": "https://github.com/stratoss/phalcon2rest/wiki", 11 | "source": "https://github.com/stratoss/phalcon2rest" 12 | }, 13 | "authors": [ 14 | { 15 | "name": "Stanimir Stoyanov", 16 | "email": "stanimir@datacentrix.org" 17 | } 18 | ], 19 | "require": { 20 | "league/oauth2-server": "5.*" 21 | }, 22 | "minimum-stability": "dev" 23 | } 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /config/config.ini: -------------------------------------------------------------------------------- 1 | [application] 2 | 3 | [versions] 4 | v1 = V1 5 | 6 | ; defining the limits per IP to /access_token in the format requests = seconds 7 | ; this limit should be really low, as the tokens have high lifetime (1 hour) 8 | [access_token_limits] 9 | r1 = 5 10 | 11 | ;returns empty response after that 12 | [api_unauthorized_limits] 13 | r10 = 60 14 | 15 | ; per user 16 | [api_common_limits] 17 | r600 = 3600 18 | 19 | [oauth] 20 | public = ssl/public.key 21 | private = ssl/private.key 22 | ; 1 hour 23 | accessTokenLifetime = PT1H 24 | ; 1 month 25 | refreshTokenLifetime = P1M 26 | ; 10 min 27 | authorizationCodeLifetime = PT10M -------------------------------------------------------------------------------- /data/database.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stratoss/phalcon2rest/aa27f8d533f33f2d238ef667b20f811a6f64024d/data/database.db -------------------------------------------------------------------------------- /data/mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `access_tokens` ( 2 | `userId` bigint(20) UNSIGNED NOT NULL, 3 | `tokenId` varchar(80) NOT NULL, 4 | `isRevoked` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', 5 | `expiry` int(10) UNSIGNED NOT NULL 6 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 7 | 8 | CREATE TABLE `clients` ( 9 | `id` bigint(20) UNSIGNED NOT NULL, 10 | `secret` varchar(64) NOT NULL, 11 | `name` varchar(64) NOT NULL, 12 | `redirect_url` varchar(128) NOT NULL, 13 | `is_confidential` tinyint(1) UNSIGNED NOT NULL 14 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 15 | 16 | INSERT INTO `clients` (`id`, `secret`, `name`, `redirect_url`, `is_confidential`) VALUES 17 | (1, '$2y$10$5m1jvrkBZDkCZDfyJrv0A.TlkETpwpWjzx29ZxzlolwGtBXaHOkJa', 'Super App', 'http://example.com/super-app', 1); 18 | 19 | CREATE TABLE `refresh_tokens` ( 20 | `userId` bigint(20) UNSIGNED NOT NULL, 21 | `tokenId` varchar(80) NOT NULL, 22 | `isRevoked` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', 23 | `expiry` int(10) UNSIGNED NOT NULL 24 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 25 | 26 | CREATE TABLE `users` ( 27 | `id` bigint(20) UNSIGNED NOT NULL, 28 | `username` varchar(64) NOT NULL, 29 | `password` varchar(64) NOT NULL, 30 | `access` tinyint(1) UNSIGNED NOT NULL DEFAULT '1' 31 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 32 | 33 | CREATE TABLE `books` ( 34 | `id` bigint(20) UNSIGNED NOT NULL, 35 | `author` varchar(64) NOT NULL, 36 | `title` varchar(64) NOT NULL, 37 | `year` int(11) NOT NULL 38 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 39 | 40 | INSERT INTO `users` (`id`, `username`, `password`, `access`) VALUES 41 | (1, 'stan', '$2y$10$8yjhRKQmDIXYl/pAbloBD.5vuGr/xkzCLeJCw5H5sycD8QbcDfZzC', 1); 42 | 43 | INSERT INTO `books` (`id`, `author`, `title`, `year`) VALUES 44 | (1, 'John Doe', 'Greatest book', 2010), 45 | (2, 'John Doe', 'Book of books', 2010), 46 | (3, 'Stanimir Stoyanov', 'OAuth2 with Phalcon', 2016); 47 | 48 | ALTER TABLE `access_tokens` 49 | ADD UNIQUE KEY `tokenId` (`tokenId`), 50 | ADD KEY `userId` (`userId`); 51 | 52 | ALTER TABLE `clients` 53 | ADD UNIQUE KEY `id` (`id`); 54 | 55 | ALTER TABLE `refresh_tokens` 56 | ADD UNIQUE KEY `tokenId` (`tokenId`), 57 | ADD KEY `userId` (`userId`); 58 | 59 | ALTER TABLE `users` 60 | ADD UNIQUE KEY `id` (`id`); 61 | 62 | ALTER TABLE `clients` 63 | MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; 64 | 65 | ALTER TABLE `users` 66 | MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; 67 | 68 | ALTER TABLE `access_tokens` 69 | ADD CONSTRAINT `access_tokens_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`); 70 | 71 | ALTER TABLE `refresh_tokens` 72 | ADD CONSTRAINT `refresh_tokens_ibfk_1` FOREIGN KEY (`userId`) REFERENCES `users` (`id`); 73 | 74 | ALTER TABLE `books` 75 | ADD UNIQUE KEY `id` (`id`); 76 | 77 | ALTER TABLE `books` 78 | MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; -------------------------------------------------------------------------------- /data/sqlite3.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE access_tokens ( 2 | userId bigint NOT NULL, 3 | tokenId varchar NOT NULL, 4 | isRevoked tinyint NOT NULL DEFAULT '0', 5 | expiry int NOT NULL 6 | ); 7 | 8 | CREATE TABLE "clients" ( 9 | "id" bigint NOT NULL, 10 | "secret" varchar NOT NULL, 11 | "name" varchar NOT NULL, 12 | "redirect_url" varchar NOT NULL, 13 | "is_confidential" tinyint NOT NULL 14 | ); 15 | INSERT INTO "clients" VALUES (1,'$2y$10$5m1jvrkBZDkCZDfyJrv0A.TlkETpwpWjzx29ZxzlolwGtBXaHOkJa','Super App','http://example.com/super-app',1); 16 | 17 | CREATE TABLE "refresh_tokens" ( 18 | "userId" bigint NOT NULL, 19 | "tokenId" varchar NOT NULL, 20 | "isRevoked" tinyint NOT NULL DEFAULT '0', 21 | "expiry" int NOT NULL 22 | ); 23 | 24 | CREATE TABLE "users" ( 25 | "id" bigint NOT NULL, 26 | "username" varchar NOT NULL, 27 | "password" varchar NOT NULL, 28 | "access" tinyint NOT NULL DEFAULT '1' 29 | ); 30 | 31 | INSERT INTO "users" VALUES (1,'stan','$2y$10$8yjhRKQmDIXYl/pAbloBD.5vuGr/xkzCLeJCw5H5sycD8QbcDfZzC',1); 32 | 33 | create table "books" ( 34 | "id" bigint not null, 35 | "author" varchar not null, 36 | "title" varchar not null, 37 | "year" int not null 38 | ); 39 | 40 | INSERT INTO "books" VALUES (1,'John Doe','Greatest book',2010); 41 | INSERT INTO "books" VALUES (2,'John Doe','Book of books',2010); 42 | INSERT INTO "books" VALUES (3,'Stanimir Stoyanov','OAuth2 with Phalcon',2016); -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine on 2 | RewriteCond %{REQUEST_FILENAME} !-f 3 | RewriteCond %{REQUEST_FILENAME} !-d 4 | RewriteRule ^(.*)$ index.php?_url=/$1 [QSA,L] 5 | 6 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | getShared('authorizationServer'))); 11 | /** 12 | * Out application is a Micro application, so we mush explicitly define all the routes. 13 | * For APIs, this is ideal. This is as opposed to the more robust MVC Application 14 | * @var $app 15 | */ 16 | $app = new Phalcon\Mvc\Micro(); 17 | $app->setDI($di); 18 | 19 | 20 | /** 21 | * Mount all of the collections, which makes the routes active. 22 | */ 23 | foreach($di->get('collections') as $collection){ 24 | $app->mount($collection); 25 | } 26 | 27 | /** 28 | * The base route return the list of defined routes for the application. 29 | * This is not strictly REST compliant, but it helps to base API documentation off of. 30 | * By calling this, you can quickly see a list of all routes and their methods. 31 | */ 32 | $app->get('/', function() use ($app){ 33 | $routes = $app->getRouter()->getRoutes(); 34 | $routeDefinitions = [ 35 | 'GET' => [], 36 | 'POST' => [], 37 | 'PUT' => [], 38 | 'PATCH' => [], 39 | 'DELETE' => [], 40 | 'HEAD' => [], 41 | 'OPTIONS' => [] 42 | ]; 43 | /* @var $route Phalcon\Mvc\Router\Route */ 44 | foreach($routes as $route){ 45 | $method = $route->getHttpMethods(); 46 | $routeDefinitions[$method][] = $route->getPattern(); 47 | } 48 | return $routeDefinitions; 49 | }); 50 | 51 | /** 52 | * Before every request, make sure user is authenticated. 53 | * Returning true in this function resumes normal routing. 54 | * Returning false stops any route from executing. 55 | */ 56 | 57 | $app->before(function () use ($app, $di) { 58 | $config = $app->config; 59 | // getting access token is permitted ;) 60 | if (strpos($app->request->getURI(), '/access_token') !== FALSE || 61 | strpos($app->request->getURI(), '/authorize') !== FALSE || 62 | $app->request->isOptions() 63 | ) { 64 | return $di->getShared('rateLimits', ['access_token', $app->request->getClientAddress(), $app]); 65 | } 66 | 67 | $accessTokenRepository = new \Phalcon2Rest\Components\Oauth2\Repositories\AccessTokenRepository(); // instance of AccessTokenRepositoryInterface 68 | $publicKeyPath = 'file://' . __DIR__ . '/../' . $config->oauth['public']; 69 | try { 70 | $server = new \League\OAuth2\Server\ResourceServer( 71 | $accessTokenRepository, 72 | $publicKeyPath 73 | ); 74 | 75 | $auth = new \League\OAuth2\Server\Middleware\ResourceServerMiddleware($server); 76 | $auth(new \Phalcon2Rest\Components\Oauth2\Request($app->request), new \Phalcon2Rest\Components\Oauth2\Response($app->response), function(){}); 77 | if (isset($_SERVER['oauth_access_token_id']) && 78 | isset($_SERVER['oauth_client_id']) && 79 | isset($_SERVER['oauth_user_id']) && 80 | isset($_SERVER['oauth_scopes']) 81 | ) { 82 | // TODO: save somewhere the user_id and scopes for future validations, e.g. /users/1/edit 83 | // TODO: should be accessible only if the user_id is 1 or the scope is giving permissions, e.g. admin 84 | if (strlen($_SERVER['oauth_client_id']) > 0) { 85 | return $di->getShared('rateLimits', ['api_common', 'client'.$_SERVER['oauth_client_id'], $app]); 86 | } else { 87 | return $di->getShared('rateLimits', ['api_common', 'user'.$_SERVER['oauth_user_id'], $app]); 88 | } 89 | 90 | } 91 | } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) { 92 | } 93 | $rateLimit = $di->getShared('rateLimits', ['api_unauthorized', $app->request->getClientAddress(), $app]); 94 | if ($rateLimit === false) { 95 | return false; 96 | } 97 | throw new \Phalcon2Rest\Exceptions\HttpException( 98 | 'Unauthorized', 99 | 401, 100 | false, 101 | [ 102 | 'dev' => 'The bearer token is missing or is invalid', 103 | 'internalCode' => 'P1008', 104 | 'more' => '' 105 | ] 106 | ); 107 | }); 108 | 109 | /** 110 | * After a route is run, usually when its Controller returns a final value, 111 | * the application runs the following function which actually sends the response to the client. 112 | * 113 | * The default behavior is to send the Controller's returned value to the client as JSON. 114 | * However, by parsing the request querystring's 'type' paramter, it is easy to install 115 | * different response type handlers. Below is an alternate csv handler. 116 | * 117 | * TODO: add versions 118 | */ 119 | $app->after(function() use ($app) { 120 | 121 | // OPTIONS have no body, send the headers, exit 122 | if($app->request->getMethod() == 'OPTIONS'){ 123 | $app->response->setStatusCode('200', 'OK'); 124 | $app->response->send(); 125 | return; 126 | } 127 | 128 | // Respond by default as JSON 129 | if(!$app->request->get('type') || $app->request->get('type') == 'json'){ 130 | 131 | // Results returned from the route's controller. All Controllers should return an array 132 | $records = $app->getReturnedValue(); 133 | $response = new \Phalcon2Rest\Responses\JsonResponse(); 134 | $response->useEnvelope(false) 135 | ->convertSnakeCase(true) 136 | ->send($records); 137 | 138 | return; 139 | } 140 | elseif($app->request->get('type') == 'csv'){ 141 | 142 | $records = $app->getReturnedValue(); 143 | $response = new \Phalcon2Rest\Responses\CsvResponse(); 144 | $response->useHeaderRow(true)->send($records); 145 | 146 | return; 147 | } 148 | else { 149 | throw new \Phalcon2Rest\Exceptions\HttpException( 150 | 'Could not return results in specified format', 151 | 403, 152 | false, 153 | array( 154 | 'dev' => 'Could not understand type specified by type parameter in query string.', 155 | 'internalCode' => 'NF1000', 156 | 'more' => 'Type may not be implemented. Choose either "csv" or "json"' 157 | ) 158 | ); 159 | } 160 | }); 161 | 162 | /** 163 | * The notFound service is the default handler function that runs when no route was matched. 164 | * We set a 404 here unless there's a suppress error codes. 165 | */ 166 | $app->notFound(function () use ($app) { 167 | throw new \Phalcon2Rest\Exceptions\HttpException( 168 | 'Not Found.', 169 | 404, 170 | false, 171 | array( 172 | 'dev' => 'That route was not found on the server.', 173 | 'internalCode' => 'NF1000', 174 | 'more' => 'Check route for misspellings.' 175 | ) 176 | ); 177 | }); 178 | 179 | /** 180 | * If the application throws an HttpException, send it on to the client as json. 181 | * Elsewise, just log it. 182 | * TODO: Improve this. 183 | */ 184 | set_exception_handler(function($exception) use ($app){ 185 | //HttpException's send method provides the correct response headers and body 186 | /* @var $exception Phalcon2Rest\Exceptions\HttpException */ 187 | if(is_a($exception, 'Phalcon2Rest\\Exceptions\\HttpException')){ 188 | $exception->send(); 189 | } 190 | //error_log($exception); 191 | //error_log($exception->getTraceAsString()); 192 | }); 193 | 194 | $app->handle(); 195 | -------------------------------------------------------------------------------- /services.php: -------------------------------------------------------------------------------- 1 | setShared('config', function() { 32 | return new IniConfig(__DIR__ . "/config/config.ini"); 33 | }); 34 | 35 | /** 36 | * Return array of the Collections, which define a group of routes, from 37 | * routes/collections. These will be mounted into the app itself later. 38 | */ 39 | $availableVersions = $di->getShared('config')->versions; 40 | 41 | $allCollections = []; 42 | foreach ($availableVersions as $versionString => $versionPath) { 43 | $currentCollections = include('Modules/' . $versionPath . '/Routes/routeLoader.php'); 44 | $allCollections = array_merge($allCollections, $currentCollections); 45 | } 46 | $di->set('collections', function() use ($allCollections) { 47 | return $allCollections; 48 | }); 49 | 50 | // As soon as we request the session service, it will be started. 51 | $di->setShared('session', function() { 52 | $session = new \Phalcon\Session\Adapter\Files(); 53 | $session->start(); 54 | return $session; 55 | }); 56 | 57 | /** 58 | * The slowest option! Consider using memcached/redis or another faster caching system than file... 59 | * Using the file cache just for the sake of the simplicity here 60 | */ 61 | $di->setShared('cache', function() { 62 | //Cache data for one day by default 63 | $frontCache = new \Phalcon\Cache\Frontend\Data(array( 64 | 'lifetime' => 3600 65 | )); 66 | 67 | //File cache settings 68 | $cache = new \Phalcon\Cache\Backend\File($frontCache, array( 69 | 'cacheDir' => __DIR__ . '/cache/' 70 | )); 71 | 72 | return $cache; 73 | }); 74 | 75 | $di->setShared('rateLimits', function($limitType, $identifier, $app) { 76 | /** @var \Phalcon\Cache\Backend\File $cache */ 77 | $cache = $app->cache; 78 | /** @var \Phalcon\Config\Adapter\Ini $config */ 79 | $config = $app->config; 80 | $limitName = $limitType . '_limits'; 81 | if (property_exists($config, $limitName)) { 82 | foreach ($config->{$limitName} as $limit => $seconds) { 83 | $limit = substr($limit, 1, strlen($limit)); 84 | $cacheName = $limitName . $identifier; 85 | 86 | if ($cache->exists($cacheName, $seconds)) { 87 | $rate = $cache->get($cacheName, $seconds); 88 | /** 89 | * using FileCache with many concurrent connections 90 | * around 10% of the time boolean is returned instead of the real cache data. 91 | */ 92 | if (gettype($rate) === 'boolean') { 93 | throw new \Phalcon2Rest\Exceptions\HttpException( 94 | 'Server error', 95 | 500, 96 | null, 97 | [ 98 | 'dev' => 'Please try again in a moment', 99 | 'internalCode' => 'P1011', 100 | 'more' => '' 101 | ] 102 | ); 103 | } 104 | $rate['remaining']--; 105 | $resetAfter = $rate['saved'] + $seconds - time(); 106 | if ($rate['remaining'] > -1) { 107 | $cache->save($cacheName, $rate, $resetAfter); 108 | } 109 | } else { 110 | $rate = ['remaining' => $limit - 1, 'saved' => time()]; 111 | $cache->save($cacheName, $rate, $seconds); 112 | $resetAfter = $seconds; 113 | } 114 | 115 | $app->response->setHeader('X-Rate-Limit-Limit', $limit); 116 | $app->response->setHeader('X-Rate-Limit-Remaining', ($rate['remaining'] > -1 ? $rate['remaining'] : 0) . ' '); 117 | $app->response->setHeader('X-Rate-Limit-Reset', $resetAfter . ' '); 118 | 119 | if ($rate['remaining'] > -1) { 120 | return true; 121 | } else { 122 | throw new \Phalcon2Rest\Exceptions\HttpException( 123 | 'Too Many Requests', 124 | 429, 125 | null, 126 | [ 127 | 'dev' => 'You have reached your limit. Please try again after ' . $resetAfter . ' seconds.', 128 | 'internalCode' => 'P1010', 129 | 'more' => '' 130 | ] 131 | ); 132 | } 133 | } 134 | } 135 | return false; 136 | }); 137 | 138 | /** 139 | * Database setup. Here, we'll use a simple SQLite database of Disney Princesses. 140 | */ 141 | $di->set('db', function() { 142 | return new \Phalcon\Db\Adapter\Pdo\Sqlite(array( 143 | 'dbname' => __DIR__ . '/data/database.db' 144 | )); 145 | }); 146 | 147 | /** 148 | * If our request contains a body, it has to be valid JSON. This parses the 149 | * body into a standard Object and makes that available from the DI. If this service 150 | * is called from a function, and the request body is nto valid JSON or is empty, 151 | * the program will throw an Exception. 152 | */ 153 | $di->setShared('requestBody', function() use ($app) { 154 | $in = trim($app->request->getJsonRawBody()); 155 | // JSON body could not be parsed, throw exception 156 | if($in === '') { 157 | throw new HttpException( 158 | 'There was a problem understanding the data sent to the server by the application.', 159 | 409, 160 | array( 161 | 'dev' => 'The JSON body sent to the server was unable to be parsed.', 162 | 'internalCode' => 'REQ1000', 163 | 'more' => '' 164 | ) 165 | ); 166 | } 167 | 168 | return $in; 169 | }); 170 | 171 | $di->setShared('resourceServer', function() use ($di) { 172 | $config = $di->getShared('config'); 173 | $server = new ResourceServer( 174 | new AccessTokenRepository(), // instance of AccessTokenRepositoryInterface 175 | 'file://' . __DIR__ . '/' . $config->oauth['public'] // the authorization server's public key 176 | ); 177 | return $server; 178 | }); 179 | 180 | $di->set('security', function () { 181 | $security = new \Phalcon\Security(); 182 | 183 | // Set the password hashing factor to 12 rounds 184 | $security->setWorkFactor(12); 185 | 186 | return $security; 187 | }, true); 188 | 189 | $di->setShared('authorizationServer', function() use ($di) { 190 | $config = $di->getShared('config'); 191 | $server = new AuthorizationServer( 192 | new ClientRepository(), // instance of ClientRepositoryInterface 193 | new AccessTokenRepository(), // instance of AccessTokenRepositoryInterface 194 | new ScopeRepository(), // instance of ScopeRepositoryInterface 195 | 'file://' . __DIR__ . '/' . $config->oauth['private'], // path to private key 196 | 'file://' . __DIR__ . '/' . $config->oauth['public'] // path to public key 197 | ); 198 | 199 | $userRepository = new UserRepository(); 200 | $refreshTokenRepository = new RefreshTokenRepository(); 201 | $authCodeRepository = new AuthCodeRepository(); 202 | 203 | $accessTokenLifetime = new \DateInterval($config->oauth['accessTokenLifetime']); 204 | $refreshTokenLifetime = new \DateInterval($config->oauth['refreshTokenLifetime']); 205 | $authorizationCodeLifetime = new \DateInterval($config->oauth['authorizationCodeLifetime']); 206 | 207 | /** 208 | * Using client_id & client_secret & username & password 209 | * 210 | */ 211 | $passwordGrant = new PasswordGrant( 212 | $userRepository, 213 | $refreshTokenRepository 214 | ); 215 | $passwordGrant->setRefreshTokenTTL($refreshTokenLifetime); 216 | $server->enableGrantType( 217 | $passwordGrant, 218 | $accessTokenLifetime 219 | ); 220 | 221 | /** 222 | * Using client_id & client_secret 223 | */ 224 | $clientCredentialsGrant = new ClientCredentialsGrant(); 225 | $server->enableGrantType( 226 | $clientCredentialsGrant, 227 | $accessTokenLifetime 228 | ); 229 | 230 | /** 231 | * Using client_id & client_secret 232 | */ 233 | $refreshTokenGrant = new RefreshTokenGrant($refreshTokenRepository); 234 | $refreshTokenGrant->setRefreshTokenTTL($refreshTokenLifetime); 235 | $server->enableGrantType( 236 | $refreshTokenGrant, 237 | $accessTokenLifetime 238 | ); 239 | 240 | /** 241 | * Using response_type=code & client_id & redirect_uri & state 242 | */ 243 | $authCodeGrant = new AuthCodeGrant( 244 | $authCodeRepository, 245 | $refreshTokenRepository, 246 | $authorizationCodeLifetime 247 | ); 248 | $authCodeGrant->setRefreshTokenTTL($refreshTokenLifetime); 249 | $server->enableGrantType( 250 | $authCodeGrant, 251 | $accessTokenLifetime 252 | ); 253 | 254 | /** 255 | * Using response_type=token & client_id & redirect_uri & state 256 | */ 257 | $server->enableGrantType( 258 | new ImplicitGrant($accessTokenLifetime), 259 | $accessTokenLifetime 260 | ); 261 | return $server; 262 | }); -------------------------------------------------------------------------------- /ssl/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQCxFh/iHxQ4eGTZw5LGckKTMv4v4Swuu2JGVB+F/wigbdzNEiOD 3 | d3Ii6LTKlsGtxQUBc8CnSMM+ld+FGHvBDRUaLXQiCxp1+KNERHAbnjA0kY249EKr 4 | 9lWb+D/FczsJz/gMAfiLmRxM+s8SYIXaqh6zhNj1Dfbdr69qGXpOfJWaLwIDAQAB 5 | AoGAUt4TlXENuU89glnuuUaGuPNH14f7cPLnDhoXllC97LT8ekperAqdMpDK6XKa 6 | t4JW0VMleCKomwTvUA0g/DnvAUVJAZ6wfiAeQleEIOO7RynCmUbqqlBUGEZp33Rk 7 | jTxO1xdEG3Y6NlS/SWpeqej7MJP2eirhQ2BnjIDeIqYYaeECQQDjTe2ER9Z/qtpX 8 | 5GbepGJzE+TcO1OUOm9HAFR+A5j3rb59dgKlhKJnRZMjv18RxfHUtjtP5gyVwfPU 9 | yBpYVajTAkEAx3E9xzbRr48/ozwARvlZRcLTpas+6kiDUW803uDyjqo8o15q8q1M 10 | W9zaemECOdggOMZZ+BGQ4PCo6tJtzc+vtQJAb+U+1W2f1D1BOx8+3L9Dj67tbNTv 11 | sfqKKQOqlFYlCVhIe+6KIv0GDZyccG6W2GL/R11mGVEARQCzjb3r6ixQ7QJBAJlb 12 | 8F8xPge7JPoF90icEBNefpSTm2tXmvKRipwfaSRerwYIYkB9FYxFxRH5albEY/KE 13 | Q0ZHa5osNBds+9YYb0kCQEJgDQ+BUowTfnQQVbyedcZty3Z0kbHbvIWj3bS2MNPR 14 | CNul5jATTke284UPbgEWUV7D7zPVwWdtb+eF5OGUvag= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /ssl/public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxFh/iHxQ4eGTZw5LGckKTMv4v 3 | 4Swuu2JGVB+F/wigbdzNEiODd3Ii6LTKlsGtxQUBc8CnSMM+ld+FGHvBDRUaLXQi 4 | Cxp1+KNERHAbnjA0kY249EKr9lWb+D/FczsJz/gMAfiLmRxM+s8SYIXaqh6zhNj1 5 | Dfbdr69qGXpOfJWaLwIDAQAB 6 | -----END PUBLIC KEY----- 7 | --------------------------------------------------------------------------------