diff --git a/README.md b/README.md index b74eb9a..4c5ed80 100644 --- a/README.md +++ b/README.md @@ -156,13 +156,22 @@ DirectoryIndex index.php ``` -### Authentication +### Authentication and Authorization -Authentication is unique for each application. But tying your authentication mechanisms into RestServer is easy. By simply adding a method named `authorize` to your `Controller` all requests will call that method first. If `authorize()` returns false, the server will issue a 401 Unauthorized response. If `authorize()` returns true, the request continues on to call the correct controller action. All actions will run the authorization first unless you add `@noAuth` in the action's docs (I usually put it above the `@url` mappings). +Authentication is unique for each application. But tying your authentication mechanisms into RestServer is easy. By simply adding `authenticate` and `authorize` methods to your `Controller` all requests will call these methods first. If `authenticate()` or `authorize()` returns false, the server will issue a **401 Invalid credentials** or **403 Unauthorized** response respectively. If both `authenticate()` and `authorize()` returns true, the request continues on to call the correct controller action. All actions will run the authorization first unless you add `@noAuth` in the action's docs (I usually put it above the `@url` mappings). -Inside your authentication method you can use PHP's [`getallheaders`](http://php.net/manual/en/function.getallheaders.php) function or `$_COOKIE` depending on how you want to authorize your users. This is where you would load the user object from your database, and set it to `$this->user = getUserFromDatabase()` so that your action will have access to it later when it gets called. +You can select authentication and authorization methods as per your requirements you can implement only `autenticate` if you want to confirm client identity. Or you can implement both, then `authorize` can help to confirm if current client is allowed to access a certain action. For more details about authentication. and how to use `JWT` token as bearer header please check example file `TestAuthControll.php`. -RestServer is meant to be a simple mechanism to map your application into a REST API. The rest of the details are up to you. +Currently default authentication handler support **Basic** and **Bearer** headers based authentication. and pass `[username, password]` or `bearer token` respectively to `authenticate()` method in your controller. In case you want to authenticate clients using some other method like cookies, You can do that inside `authenticate` method. You may replace default authentication handler by passing your own implementation of `AuthServer` interface to RestServer instance. like + +```php + /** + * include following lines after $server = new RestServer($mode); + */ + $server->authHandler = new myAuthServer(); +``` + +RestServer is meant to be a simple mechanism to map your application into a REST API and pass requested data or headers. The rest of the details are up to you. ### Cross-origin resource sharing @@ -181,6 +190,17 @@ For security reasons, browsers restrict cross-origin HTTP or REST requests initi $server->allowedOrigin = '*'; ``` +### Working with files +Using Multipart with REST APIs is a bad idea and neither it is supported by `RestServer`. RestServer uses direct file upload approach like S3 services. you can upload one file per request without any additional form data. + +* **Upload:** +In file uploads action you may use two special parameters in method definition. `$data` and `$mime` first parameter will hold file content and the `$mime` parameter can provide details about file content type. + +* **Download:** +RestServer will start a file download in case a action return `SplFileInfo` object. + +For more details please check `upload` and `download` methods in example. + ### Throwing and Handling Errors You may provide errors to your API users easily by throwing an excetion with the class `RestException`. Example: diff --git a/example/TestAuthController.php b/example/TestAuthController.php new file mode 100644 index 0000000..7f1ca82 --- /dev/null +++ b/example/TestAuthController.php @@ -0,0 +1,152 @@ + array('email' => 'admin@domain.tld', 'password' => 'adminPass', 'role' => 'admin'), + 'user@domain.tld' => array('email' => 'user@domain.tld', 'password' => 'userPass', 'role' => 'user') + ); + + /** + * Security + */ + public $private_key = __DIR__ . DIRECTORY_SEPARATOR . 'testkey'; + public $public_key = __DIR__ . DIRECTORY_SEPARATOR . 'testkey.pub'; + public $hash_type = 'RS256'; + + /** + * Logged in user + */ + private $loggedUser = null; + + /** + * Check client credentials and return true if found valid, false otherwise + */ + public function authenticate($credentials, $auth_type) + { + switch ($auth_type) { + case 'Bearer': + $public_key = file_get_contents($this->public_key); + $token = JWT::decode($credentials, $public_key, array($this->hash_type)); + if ($token && !empty($token->username) && $this->listUser[$token->username]) { + $this->loggedUser = $this->listUser[$token->username]; + return true; + } + break; + + case 'Basic': + default: + $email = $credentials['username']; + if (isset($this->listUser[$email]) && $this->listUser[$email]['password'] == $credentials['password']) { + $this->loggedUser = $this->listUser[$email]; + return true; + } + break; + } + + return false; + } + + /** + * Check if current user is allowed to access a certain method + */ + public function authorize($method) + { + if ('admin' == $this->loggedUser['role']) { + return true; // admin can access everthing + + } else if ('user' == $this->loggedUser['role']) { + // user can access selected methods only + if (in_array($method, array('download'))) { + return true; + } + } + + return false; + } + + /** + * To get JWT token client can post his username and password to this method + * + * @url POST /login + * @noAuth + */ + public function login($data = array()) + { + $username = isset($data['username']) ? $data['username'] : null; + $password = isset($data['password']) ? $data['password'] : null; + + // only if we have valid user + if (isset($this->listUser[$username]) && $this->listUser[$username] == $password) { + $token = array( + "iss" => 'My Website', + "iat" => time(), + "nbf" => time(), + "exp" => time() + (60 * 60 * 24 * 30 * 12 * 1), // valid for one year + "username" => $this->listUser[$username]['email']; + ); + + // return jwt token + $private_key = file_get_contents($this->private_key); + return JWT::encode($token, $private_key, $this->hash_type); + } + + throw new RestException(401, "Invalid username or password"); + } + + /** + * Upload a file + * + * @url PUT /files/$filename + */ + public function upload($filename, $data, $mime) + { + $storage_dir = sys_get_temp_dir(); + $allowedTypes = array('pdf' => 'application/pdf', 'html' => 'plain/html', 'wav' => 'audio/wav'); + if (in_array($mime, $allowedTypes)) { + if (!empty($data)) { + $file_path = $storage_dir . DIRECTORY_SEPARATOR . $filename; + file_put_contents($file_path, $data); + return $filename; + } else { + throw new RestException(411, "Empty file"); + } + } else { + throw new RestException(415, "Unsupported File Type"); + } + } + + /** + * Download a file + * + * @url GET /files/$filename + */ + public function download($filename) + { + $storage_dir = sys_get_temp_dir(); + $file_path = $storage_dir . DIRECTORY_SEPARATOR . $filename; + if (file_exists($file_path)) { + return SplFileInfo($file_path); + } else { + throw new RestException(404, "File not found"); + } + } + +} diff --git a/example/TestController.php b/example/TestController.php index f5d3b45..0e37e2c 100644 --- a/example/TestController.php +++ b/example/TestController.php @@ -73,6 +73,44 @@ public function listUsers($query) return $users; // serializes object into JSON } + /** + * Upload a file + * + * @url PUT /files/$filename + */ + public function upload($filename, $data, $mime) + { + $storage_dir = sys_get_temp_dir(); + $allowedTypes = array('pdf' => 'application/pdf', 'html' => 'plain/html', 'wav' => 'audio/wav'); + if (in_array($mime, $allowedTypes)) { + if (!empty($data)) { + $file_path = $storage_dir . DIRECTORY_SEPARATOR . $filename; + file_put_contents($file_path, $data); + return $filename; + } else { + throw new RestException(411, "Empty file"); + } + } else { + throw new RestException(415, "Unsupported File Type"); + } + } + + /** + * Download a file + * + * @url GET /files/$filename + */ + public function download($filename) + { + $storage_dir = sys_get_temp_dir(); + $file_path = $storage_dir . DIRECTORY_SEPARATOR . $filename; + if (file_exists($file_path)) { + return SplFileInfo($file_path); + } else { + throw new RestException(404, "File not found"); + } + } + /** * Get Charts * diff --git a/source/Jacwright/RestServer/Auth/HTTPAuthServer.php b/source/Jacwright/RestServer/Auth/HTTPAuthServer.php index 72bfc7c..8573de4 100644 --- a/source/Jacwright/RestServer/Auth/HTTPAuthServer.php +++ b/source/Jacwright/RestServer/Auth/HTTPAuthServer.php @@ -8,16 +8,103 @@ public function __construct($realm = 'Rest Server') { $this->realm = $realm; } - public function isAuthorized($classObj) { + public function isAuthenticated($classObj) { + $auth_headers = $this->getAuthHeaders(); + + // Try to use bearer token as default + $auth_method = 'Bearer'; + $credentials = $this->getBearer($auth_headers); + + // TODO: add digest method + + // In case bearer token is not present try with Basic autentication + if (empty($credentials)) { + $auth_method = 'Basic'; + $credentials = $this->getBasic($auth_headers); + } + + if (method_exists($classObj, 'authenticate')) { + return $classObj->authenticate($credentials, $auth_method); + } + + return true; // original behavior + } + + public function unauthenticated($path) { + header("WWW-Authenticate: Basic realm=\"$this->realm\""); + throw new \Jacwright\RestServer\RestException(401, "Invalid credentials, access is denied to $path."); + } + + public function isAuthorized($classObj, $method) { if (method_exists($classObj, 'authorize')) { - return $classObj->authorize(); + return $classObj->authorize($method); } return true; } - public function unauthorized($classObj) { - header("WWW-Authenticate: Basic realm=\"$this->realm\""); - throw new \Jacwright\RestServer\RestException(401, "You are not authorized to access this resource."); + public function unauthorized($path) { + throw new \Jacwright\RestServer\RestException(403, "You are not authorized to access $path."); } + + /** + * Get username and password from header + */ + protected function getBasic($headers) { + // mod_php + if (isset($_SERVER['PHP_AUTH_USER'])) { + return array( + 'username' => $this->server_get('PHP_AUTH_USER'), + 'password' => $this->server_get('PHP_AUTH_PW') + ); + } else { // most other servers + if (!empty($headers)) { + list ($username, $password) = explode(':',base64_decode(substr($headers, 6))); + return array('username' => $username, 'password' => $password); + } + } + return array('username' => null, 'password' => null); + } + + /** + * Get access token from header + */ + protected function getBearer($headers) { + if (!empty($headers)) { + if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) { + return $matches[1]; + } + } + return null; + } + + /** + * Get username and password from header via Digest method + */ + protected function getDigest() { + if (false) { // TODO // currently not functional + return array('username' => null, 'password' => null); + } + return null; + } + + /** + * Get authorization header + */ + protected function getAuthHeaders() { + $headers = null; + if (isset($_SERVER['Authorization'])) { + $headers = trim($_SERVER["Authorization"]); + } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { //Nginx or fast CGI + $headers = trim($_SERVER["HTTP_AUTHORIZATION"]); + } else if (function_exists('apache_request_headers')) { + $requestHeaders = apache_request_headers(); + $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); + if (isset($requestHeaders['Authorization'])) { + $headers = trim($requestHeaders['Authorization']); + } + } + return $headers; + } + } diff --git a/source/Jacwright/RestServer/AuthServer.php b/source/Jacwright/RestServer/AuthServer.php index a34aa16..515ee09 100644 --- a/source/Jacwright/RestServer/AuthServer.php +++ b/source/Jacwright/RestServer/AuthServer.php @@ -2,15 +2,36 @@ namespace Jacwright\RestServer; interface AuthServer { + /** + * Indicates whether the requesting client is a recognized and authenticated party. + * + * @param object $classObj An instance of the controller for the path. + * + * @return bool True if authenticated, false if not. + */ + public function isAuthenticated($classObj); + + /** + * Handles the case where the client is not recognized party. + * This method must either return data or throw a RestException. + * + * @param string $path The requested path. + * + * @return mixed The response to send to the client + * + * @throws RestException + */ + public function unauthenticated($path); + /** * Indicates whether the client is authorized to access the resource. * - * @param string $path The requested path. * @param object $classObj An instance of the controller for the path. + * @param string $method The requested method. * * @return bool True if authorized, false if not. */ - public function isAuthorized($classObj); + public function isAuthorized($classObj, $method); /** * Handles the case where the client is not authorized. @@ -22,5 +43,5 @@ public function isAuthorized($classObj); * * @throws RestException */ - public function unauthorized($classObj); + public function unauthorized($path); } diff --git a/source/Jacwright/RestServer/RestFormat.php b/source/Jacwright/RestServer/RestFormat.php index 860e325..3e8246e 100644 --- a/source/Jacwright/RestServer/RestFormat.php +++ b/source/Jacwright/RestServer/RestFormat.php @@ -33,6 +33,9 @@ class RestFormat { const HTML = 'text/html'; const JSON = 'application/json'; const XML = 'application/xml'; + const FORM = 'application/x-www-form-urlencoded'; + const BASE64= 'base64'; + const FILE = 'application/octet-stream'; /** @var array */ static public $formats = array( @@ -41,5 +44,8 @@ class RestFormat { 'html' => RestFormat::HTML, 'json' => RestFormat::JSON, 'xml' => RestFormat::XML, + 'form' => RestFormat::FORM, + 'base64'=> RestFormat::BASE64, + 'file' => RestFormat::FILE ); } diff --git a/source/Jacwright/RestServer/RestServer.php b/source/Jacwright/RestServer/RestServer.php index 9e84b47..79d926e 100755 --- a/source/Jacwright/RestServer/RestServer.php +++ b/source/Jacwright/RestServer/RestServer.php @@ -35,6 +35,8 @@ use ReflectionObject; use ReflectionMethod; use DOMDocument; +use SplFileInfo; +use finfo; /** * Description of RestServer @@ -57,6 +59,12 @@ class RestServer { public $useCors = false; public $allowedOrigin = '*'; + /** + * Default format / mime type of request when there is none i.e no Content-Type present + */ + public $mimeDefault; + + protected $mime = null; // special parameter for mime type of posted data protected $data = null; // special parameter for post data protected $query = null; // special parameter for query string protected $map = array(); @@ -84,6 +92,10 @@ public function __construct($mode = 'debug') { $this->root = $dir; + // To retain the original behavior of RestServer + $this->mimeDefault = RestFormat::JSON; // from input + $this->format = RestFormat::JSON; // for output + // For backwards compatability, register HTTPAuthServer $this->setAuthHandler(new \Jacwright\RestServer\Auth\HTTPAuthServer); } @@ -124,11 +136,13 @@ public function handle() { } if ($this->method == 'PUT' || $this->method == 'POST' || $this->method == 'PATCH') { + $this->mime = $this->getMime(); $this->data = $this->getData(); } //preflight requests response - if ($this->method == 'OPTIONS' && getallheaders()->Access-Control-Request-Headers) { + $headers = (object)getallheaders(); + if ($this->method == 'OPTIONS' && $headers->Access-Control-Request-Headers) { $this->sendData($this->options()); } @@ -145,14 +159,21 @@ public function handle() { try { $this->initClass($obj); - if (!$noAuth && !$this->isAuthorized($obj)) { - $data = $this->unauthorized($obj); + if (!$noAuth && !$this->isAuthenticated($obj)) { + $data = $this->unauthenticated($this->url); + $this->sendData($data); + } else if (!$noAuth && !$this->isAuthorized($obj, $method)) { + $data = $this->unauthorized($this->url); $this->sendData($data); } else { $result = call_user_func_array(array($obj, $method), $params); if ($result !== null) { - $this->sendData($result); + if ($result instanceof SplFileInfo) { + $this->sendFile($result); + } else { + $this->sendData($result); + } } } } catch (RestException $e) { @@ -238,17 +259,34 @@ protected function initClass($obj) { } } - protected function unauthorized($obj) { + public function unauthenticated($path) { + if ($this->authHandler !== null) { + return $this->authHandler->unauthenticated($path); + } + + header("WWW-Authenticate: Basic realm=\"API Server\""); + throw new \Jacwright\RestServer\RestException(401, "Invalid credentials, access is denied to $path."); + } + + public function isAuthenticated($obj) { + if ($this->authHandler !== null) { + return $this->authHandler->isAuthenticated($obj); + } + + return true; + } + + protected function unauthorized($path) { if ($this->authHandler !== null) { - return $this->authHandler->unauthorized($obj); + return $this->authHandler->unauthorized($path); } - throw new RestException(401, "You are not authorized to access this resource."); + throw new RestException(403, "You are not authorized to access this resource."); } - protected function isAuthorized($obj) { + protected function isAuthorized($obj, $method) { if ($this->authHandler !== null) { - return $this->authHandler->isAuthorized($obj); + return $this->authHandler->isAuthorized($obj, $method); } return true; @@ -291,6 +329,10 @@ protected function findUrl() { if (!strstr($url, '$')) { if ($url == $this->url) { $params = array(); + if (isset($args['mime'])) { + $params += array_fill(0, $args['mime'] + 1, null); + $params[$args['mime']] = $this->mime; + } if (isset($args['data'])) { $params += array_fill(0, $args['data'] + 1, null); $params[$args['data']] = $this->data; @@ -310,6 +352,9 @@ protected function findUrl() { $params = array(); $paramMap = array(); + if (isset($args['mime'])) { + $params[$args['mime']] = $this->mime; + } if (isset($args['data'])) { $params[$args['data']] = $this->data; } @@ -466,13 +511,66 @@ public function getFormat() { return $format; } + public function getMime() { + $mime = $this->mimeDefault; + if (!empty($_SERVER["CONTENT_TYPE"])) { + $mime = strtolower(trim($_SERVER["CONTENT_TYPE"])); + } + return $mime; + } + public function getData() { $data = file_get_contents('php://input'); - $data = json_decode($data, $this->jsonAssoc); + + switch($this->mime) { + case RestFormat::FORM: + parse_str($data, $data); + break; + case RestFormat::JSON: + $data = json_decode($data, $this->jsonAssoc); + break; + case RestFormat::BASE64: + $this->mime = mime_content_type($data); + $data = file_get_contents($data); + break; + case RestFormat::XML: + case RestFormat::HTML: + case RestFormat::PLAIN: + case RestFormat::FILE: + default: // And for all other formats / mime types + // No action needed + break; + } return $data; } + public function sendFile($file) { + $filename = $file->getFilename(); + $filepath = $file->getRealPath(); + $size = $file->getSize(); + + $fInfo = new finfo(FILEINFO_MIME); + $content_type = $fInfo->file($filepath); + + $data = file_get_contents($filepath); + $filename_quoted = sprintf('"%s"', addcslashes($filename, '"\\')); + + header('Content-Description: File Transfer'); + header('Content-Type: ' . $content_type); + header('Content-Disposition: attachment; filename=' . $filename_quoted); + header('Content-Transfer-Encoding: binary'); + header('Connection: Keep-Alive'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Pragma: public'); + header('Content-Length: ' . $size); + if ($this->useCors) { + $this->corsHeaders(); + } + + echo $data; + } public function sendData($data) { header("Cache-Control: no-cache, must-revalidate"); @@ -565,15 +663,17 @@ private function corsHeaders() { $allowedOrigin = (array)$this->allowedOrigin; // if no origin header is present then requested origin can be anything (i.e *) $currentOrigin = !empty($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '*'; - if (in_array($currentOrigin, $allowedOrigin)) { + if (in_array($currentOrigin, $allowedOrigin) || in_array('*', $allowedOrigin)) { $allowedOrigin = array($currentOrigin); // array ; if there is a match then only one is enough } foreach($allowedOrigin as $allowed_origin) { // to support multiple origins header("Access-Control-Allow-Origin: $allowed_origin"); } header('Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS'); - header('Access-Control-Allow-Credential: true'); + header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Headers: X-Requested-With, content-type, access-control-allow-origin, access-control-allow-methods, access-control-allow-headers, Authorization'); + header('Access-Control-Expose-Headers: Content-Type, Content-Length, Content-Disposition'); + } private $codes = array(