diff options
author | cash <cash@36083f99-b078-4883-b0ff-0f9b5a30f544> | 2009-10-19 11:59:44 +0000 |
---|---|---|
committer | cash <cash@36083f99-b078-4883-b0ff-0f9b5a30f544> | 2009-10-19 11:59:44 +0000 |
commit | 985fad83ae06027c9ba92915b6f253815e7537cc (patch) | |
tree | ed32fd8354b73c3484b4ab77cb1ebe8103631c0a | |
parent | 6ed8a8dd29c699c1ff345f9827f5f685c15e85e6 (diff) | |
download | elgg-985fad83ae06027c9ba92915b6f253815e7537cc.tar.gz elgg-985fad83ae06027c9ba92915b6f253815e7537cc.tar.bz2 |
first version of new REST api
git-svn-id: http://code.elgg.org/elgg/trunk@3562 36083f99-b078-4883-b0ff-0f9b5a30f544
-rw-r--r-- | engine/lib/api.php | 1051 | ||||
-rw-r--r-- | engine/tests/services/api.php | 91 | ||||
-rw-r--r-- | languages/en.php | 11 | ||||
-rw-r--r-- | services/api/rest.php | 33 |
4 files changed, 717 insertions, 469 deletions
diff --git a/engine/lib/api.php b/engine/lib/api.php index 301922636..493e2a47f 100644 --- a/engine/lib/api.php +++ b/engine/lib/api.php @@ -280,175 +280,200 @@ class ElggHMACCache extends ElggCache { } } -// API Call functions ///////////////////////////////////////////////////////////////////// +// Primary Services API Server functions ///////////////////////////////////////////////////////////////////// /** - * An array holding methods. + * A global array holding API methods. * The structure of this is - * $METHODS = array ( - * "api.method" => array ( + * $API_METHODS = array ( + * $method => array ( * "function" = 'my_function_callback' * "call_method" = 'GET' | 'POST' * "parameters" = array ( - * "variable" = array ( // NB, the order is the same as defined by your function callback + * "variable" = array ( // NB, the order should be the same as the function callback * type => 'int' | 'bool' | 'float' | 'string' * required => true (default) | false * ) * ) - * "require_auth_token" => true (default) | false + * "require_api_auth" => true | false (default) + * "require_user_auth" => true | false (default) * "description" => "Some human readable description" * ) * ) */ -$METHODS = array(); +$API_METHODS = array(); /** - * Get the request method. - */ -function get_call_method() { - return $_SERVER['REQUEST_METHOD']; -} - -/** - * This function analyses all expected parameters for a given method, returning them in an associated array from - * input. + * Expose a function as a services api call. * - * This ensures that they are sanitised and that no superfluous commands are registered. It also means that - * hmacs work through the page handler. + * Limitations: Currently can not expose functions which expect objects. * - * @param string $method The method - * @return Array containing commands and values, including method and api + * @param string $method The api name to expose - for example "myapi.dosomething" + * @param string $function Your function callback. + * @param array $parameters (optional) list of parameters in the same order as in your function, with optional parameters last. + * This array should be in the format + * "variable" = array ( + * type => 'int' | 'bool' | 'float' | 'string' | 'array' + * required => true (default) | false + * ) + * @param string $description (optional) human readable description of the function. + * @param string $call_method (optional) Define what http method must be used for this function. Default: GET + * @param bool $require_api_auth (optional) (default is false) Does this method require API authorization? (example: API key) + * @param bool $require_user_auth (optional) (default is false) Does this method require user authorization? + * @return bool */ -function get_parameters_for_method($method) { - global $CONFIG, $METHODS; +function expose_function($method, $function, array $parameters = NULL, $description = "", $call_method = "GET", $require_api_auth = false, $require_user_auth = false) { + global $API_METHODS; - $method = sanitise_string($method); - $sanitised = array(); + if (($method == "") || ($function == "")) { + throw new InvalidParameterException(elgg_echo('InvalidParameterException:APIMethodOrFunctionNotSet')); + } + + // does not check whether this method has already been exposed - good idea? + $API_METHODS[$method] = array(); + + // does not check whether callable - done in execute_method() + $API_METHODS[$method]["function"] = $function; - foreach ($CONFIG->input as $k => $v) { - if ((isset($METHODS[$method]['parameters'][$k])) || ($k == 'auth_token') || ($k == 'method')) { - // Make things go through the sanitiser - $sanitised[$k] = get_input($k); + if ($parameters != NULL && !is_array($parameters)) { + throw new InvalidParameterException(sprintf(elgg_echo('InvalidParameterException:APIParametersNotArray'), $method)); + } + + if ($parameters != NULL) { + // ensure the required flag is set correctly in default case + foreach ($parameters as $key => $value) { + if (!array_key_exists('required', $value)) { + $parameters[$key]['required'] = true; + } } + + $API_METHODS[$method]["parameters"] = $parameters; } - return $sanitised; -} - -/** - * Obtain a token for a user. - * - * @param string $username The username - * @param string $password The password - */ -function obtain_user_token($username, $password) { - global $CONFIG; + $call_method = strtoupper($call_method); + switch ($call_method) { + case 'POST' : + $API_METHODS[$method]["call_method"] = 'POST'; + break; + case 'GET' : + $API_METHODS[$method]["call_method"] = 'GET'; + break; + default : + throw new InvalidParameterException(sprintf(elgg_echo('InvalidParameterException:UnrecognisedHttpMethod'), $call_method, $method)); + } - $site = $CONFIG->site_id; - $user = get_user_by_username($username); - $time = time(); - $time += 60*60; - $token = md5(rand(). microtime() . $username . $password . $time . $site); + $API_METHODS[$method]["description"] = $description; - if (!$user) { - return false; - } + $API_METHODS[$method]["require_api_auth"] = $require_api_auth; - if (insert_data("INSERT into {$CONFIG->dbprefix}users_apisessions - (user_guid, site_guid, token, expires) values - ({$user->guid}, $site, '$token', '$time') on duplicate key update token='$token', expires='$time'")) { - return $token; - } + $API_METHODS[$method]["require_user_auth"] = $require_user_auth; + return true; +} - return false; +/** + * Unregister an API method + * @param $method The api name that was exposed + */ +function unexpose_function($method) { + global $API_METHODS; + + if (isset($API_METHODS[$method])) { + unset($API_METHODS[$method]); + } } /** - * Validate a token against a given site. - * - * A token registered with one site can not be used from a different apikey(site), so be aware of this - * during development. - * - * @param int $site The ID of the site - * @param string $token The Token. - * @return mixed The user id attached to the token or false. + * Check that the method call has the proper API and user authentication + * @return bool */ -function validate_user_token($site, $token) { - global $CONFIG; +function authenticate_method($method) { + global $API_METHODS; + + // method must be exposed + if (!isset($API_METHODS[$method])) { + throw new APIException(sprintf(elgg_echo('APIException:MethodCallNotImplemented'), $method)); + } + + // make sure that POST variables are available if relevant + if (get_call_method() === 'POST') { + include_post_data(); + } + + // check API authentication if required + if ($API_METHODS[$method]["require_api_auth"] == true) { + if (api_authenticate() == false) { + throw new APIException(elgg_echo('APIException:APIAuthenticationFailed')); + } + } + + // check user authentication if required + if ($API_METHODS[$method]["require_user_auth"] == true) { + if (pam_authenticate() == false) { + throw new APIException(elgg_echo('APIException:UserAuthenticationFailed')); + } + } + + return true; +} - $site = (int)$site; - $token = sanitise_string($token); +$API_AUTH_HANDLERS = array(); - if (!$site) { - throw new ConfigurationException(elgg_echo('ConfigurationException:NoSiteID')); - } +/** + * Register an API authorization handler + * + * @param $handler + * @param $importance + * @return bool + */ +function register_api_auth_handler($handler, $importance = "sufficient") { + global $API_AUTH_HANDLERS; - $time = time(); + if (is_callable($handler)) { + $API_AUTH_HANDLERS[$handler] = new stdClass; - $user = get_data_row("SELECT * from {$CONFIG->dbprefix}users_apisessions - where token='$token' and site_guid=$site and $time < expires"); + $API_AUTH_HANDLERS[$handler]->handler = $handler; + $API_AUTH_HANDLERS[$handler]->importance = strtolower($importance); - if ($user) { - return $user->user_guid; + return true; } - return false; + return false; } /** - * Expose an arbitrary function as an api call. - * - * Limitations: Currently can not expose functions which expect objects. - * - * @param string $method The api name to expose this as, eg "myapi.dosomething" - * @param string $function Your function callback. - * @param array $parameters Optional list of parameters in the same order as in your function, with optional parameters last. - * This array should be in the format - * "variable" = array ( - * type => 'int' | 'bool' | 'float' | 'string' | 'array' - * required => true (default) | false - * ) - * @param string $description Optional human readable description of the function. - * @param string $call_method Define what call method should be used for this function. - * @param bool $require_auth_token Whether this requires a user authentication token or not (default is true). - * @param bool $anonymous Can anonymous (non-authenticated in any way) users execute this call. + * Authenticate an API method call + * * @return bool */ -function expose_function($method, $function, array $parameters = NULL, $description = "", $call_method = "GET", $require_auth_token = true, $anonymous = false) { - global $METHODS; - - if (($method!="") && ($function!="")) { - $METHODS[$method] = array(); +function api_authenticate() { + global $API_AUTH_HANDLERS; - $METHODS[$method]["function"] = $function; + $authenticated = false; - if ($parameters!=NULL) { - $METHODS[$method]["parameters"] = $parameters; - } + foreach ($API_AUTH_HANDLERS as $k => $v) { + $handler = $v->handler; + $importance = $v->importance; - $call_method = strtoupper($call_method); - switch ($call_method) { - case 'POST' : - $METHODS[$method]["call_method"] = 'POST'; - break; - case 'GET' : - $METHODS[$method]["call_method"] = 'GET'; - break; - default : - throw new InvalidParameterException(sprintf(elgg_echo('InvalidParameterException:UnrecognisedMethod'), $method)); + try { + // Execute the handler + if ($handler()) { + $authenticated = true; + } else { + // If this is required then abort. + if ($importance == 'required') { + return false; + } + } + } catch (Exception $e) { + // If this is required then abort. + if ($importance == 'required') { + return false; + } } - - $METHODS[$method]["description"] = $description; - - $METHODS[$method]["require_auth_token"] = $require_auth_token; - - $METHODS[$method]["anonymous"] = $anonymous; - - return true; } - return false; + return $authenticated; } /** @@ -456,165 +481,375 @@ function expose_function($method, $function, array $parameters = NULL, $descript * A method is a function which you have previously exposed using expose_function. * * @param string $method Method, e.g. "foo.bar" - * @param array $parameters Array of parameters in the format "variable" => "value", thse will be sanitised before being fed to your handler. - * @param string $token The authentication token to authorise this method call. * @return GenericResult The result of the execution. * @throws APIException, SecurityException */ -function execute_method($method, array $parameters, $token = "") { - global $METHODS, $CONFIG; +function execute_method($method) { + global $API_METHODS, $CONFIG; - // Sanity check - $method = sanitise_string($method); - $token = sanitise_string($token); + // method must be exposed + if (!isset($API_METHODS[$method])) { + throw new APIException(sprintf(elgg_echo('APIException:MethodCallNotImplemented'), $method)); + } - // See if we can find the method handler - if ((isset($METHODS[$method]["function"])) && (is_callable($METHODS[$method]["function"]))) { - // See if this is being made with the right call method - if (strcmp(get_call_method(), $METHODS[$method]["call_method"])==0) { - $serialised_parameters = ""; - - // If we have parameters then we need to sanitise the parameters. - if ((isset($METHODS[$method]["parameters"])) && (is_array($METHODS[$method]["parameters"]))) { - foreach ($METHODS[$method]["parameters"] as $key => $value) { - - if ((is_array($value)) && (isset($value['type']))) { - // Check that the variable is present in the request - if ((!isset($parameters[$key])) - || ((!isset($value['required'])) - || ($value['required']==true))) { - throw new APIException(sprintf(elgg_echo('APIException:MissingParameterInMethod'), $key, $method)); - } else { - // Avoid debug error - if (isset($parameters[$key])) { - // Set variables casting to type. - switch (strtolower($value['type'])) { - case 'int': - case 'integer' : - $serialised_parameters .= "," . (int)trim($parameters[$key]); break; - case 'bool': - case 'boolean': - if (strcasecmp(trim($parameters[$key]), "false")==0) { - $parameters[$key]=''; - } - - $serialised_parameters .= "," . (bool)trim($parameters[$key]); - break; - case 'string': - $serialised_parameters .= ",'" . (string)mysql_real_escape_string(trim($parameters[$key])) . "'"; - break; - case 'float': - $serialised_parameters .= "," . (float)trim($parameters[$key]); - break; - case 'array': - $array = "array("; - - if (is_array($parameters[$key])) { - foreach ($parameters[$key] as $k => $v) { - $k = sanitise_string($k); - $v = sanitise_string($v); - - $array .= "'$k'=>'$v',"; - } - - $array = trim($array,","); - } else { - throw APIException(sprintf(elgg_echo('APIException:ParameterNotArray'), $key)); - } - - $array .= ")"; - - $serialised_parameters .= $array; - break; - - default : - throw new APIException(sprintf(elgg_echo('APIException:UnrecognisedTypeCast'), $value['type'], $key, $method)); - } - } - } - } else { - throw new APIException(sprintf(elgg_echo('APIException:InvalidParameter'), $key, $method)); - } - } - } + // function must be callable + if (!(isset($API_METHODS[$method]["function"])) || !(is_callable($API_METHODS[$method]["function"]))) { + throw new APIException(sprintf(elgg_echo('APIException:MethodCallNotImplemented'), $method)); + } + + // check http call method + if (strcmp(get_call_method(), $API_METHODS[$method]["call_method"]) != 0) { + throw new CallException(sprintf(elgg_echo('CallException:InvalidCallMethod'), $method, $API_METHODS[$method]["call_method"])); + } + + $parameters = get_parameters_for_method($method); + + if (verify_parameters($method, $parameters) == false) { + // error + return false; + } + + $serialised_parameters = serialise_parameters($method, $parameters); + + // Execute function: Construct function and calling parameters + $function = $API_METHODS[$method]["function"]; + $serialised_parameters = trim($serialised_parameters, ", "); + + $result = eval("return $function($serialised_parameters);"); + + // Sanity check result + // If this function returns an api result itself, just return it + if ($result instanceof GenericResult) { + return $result; + } - // Execute function: Construct function and calling parameters - $function = $METHODS[$method]["function"]; - $serialised_parameters = trim($serialised_parameters, ", "); + if ($result === false) { + throw new APIException(sprintf(elgg_echo('APIException:FunctionParseError'), $function, $serialised_parameters)); + } - $result = eval("return $function($serialised_parameters);"); + if ($result === NULL) { + // If no value + throw new APIException(sprintf(elgg_echo('APIException:FunctionNoReturn'), $function, $serialised_parameters)); + } - // Sanity check result - // If this function returns an api result itself, just return it - if ($result instanceof GenericResult) { - return $result; - } + // Otherwise assume that the call was successful and return it as a success object. + return SuccessResult::getInstance($result); +} - if ($result === FALSE) { - throw new APIException(sprintf(elgg_echo('APIException:FunctionParseError'), $function, $serialised_parameters)); - } +/** + * Get the request method. + */ +function get_call_method() { + return $_SERVER['REQUEST_METHOD']; +} + +/** + * This function analyses all expected parameters for a given method + * + * This function sanitizes the input parameters and returns them in + * an associated array. + * + * @param string $method The method + * @return array containing parameters as key => value + */ +function get_parameters_for_method($method) { + global $API_METHODS; - if ($result === NULL) { - // If no value - throw new APIException(sprintf(elgg_echo('APIException:FunctionNoReturn'), $function, $serialised_parameters)); + $sanitised = array(); + + // if there are parameters, sanitize them + if (isset($API_METHODS[$method]['parameters'])) { + foreach ($API_METHODS[$method]['parameters'] as $k => $v) { + $v = get_input($k); // Make things go through the sanitiser + if ($v !== '') { + $sanitised[$k] = $v; } + } + } + + return $sanitised; +} + - // Otherwise assume that the call was successful and return it as a success object. - return SuccessResult::getInstance($result); +function get_post_data() { + global $GLOBALS; + + $postdata = ''; + if (isset($GLOBALS['HTTP_RAW_POST_DATA'])) + $postdata = $GLOBALS['HTTP_RAW_POST_DATA']; + + // Attempt another method to return post data (incase always_populate_raw_post_data is switched off) + if (!$postdata) { + $postdata = file_get_contents('php://input'); + } + + return $postdata; +} - } else { - throw new CallException(sprintf(elgg_echo('CallException:InvalidCallMethod'), $method, $METHODS[$method]["call_method"])); +/** + * This fixes the post parameters that are munged due to page handler + */ +function include_post_data() { + + $postdata = get_post_data(); + + if (isset($postdata)) { + parse_str($postdata, $query_arr); + if (is_array($query_arr)) { + foreach($query_arr as $name => $val) { + set_input($name, $val); + } } } +} - // Return an error if not found - throw new APIException(sprintf(elgg_echo('APIException:MethodCallNotImplemented'), $method)); +/** + * Verify that the required parameters are present + * @param $method + * @param $parameters + * @return true on success or exception + */ +function verify_parameters($method, $parameters) { + global $API_METHODS; + + // are there any parameters for this method + if (!(isset($API_METHODS[$method]["parameters"]))) { + return true; // no so return + } + + // check that the parameters were registered correctly and all required ones are there + foreach ($API_METHODS[$method]['parameters'] as $key => $value) { + // must be array to describe parameter in expose and type must be defined + if (!is_array($value) || !isset($value['type'])) { + throw new APIException(sprintf(elgg_echo('APIException:InvalidParameter'), $key, $method)); + } + + // Check that the variable is present in the request if required + $is_param_required = !isset($value['required']) || $value['required']; + if ($is_param_required && !array_key_exists($key, $parameters)) { + throw new APIException(sprintf(elgg_echo('APIException:MissingParameterInMethod'), $key, $method)); + } + } + + return true; } -// System functions /////////////////////////////////////////////////////////////////////// +/** + * Serialize an array of parameters for an API method call + * + * @param $method + * @param $parameters + * @return unknown_type + */ +function serialise_parameters($method, $parameters) { + global $API_METHODS; + + // are there any parameters for this method + if (!(isset($API_METHODS[$method]["parameters"]))) { + return ''; // if not, return + } + + $serialised_parameters = ""; + foreach ($API_METHODS[$method]['parameters'] as $key => $value) { + + // avoid warning on parameters that are not required and not present + if (!isset($parameters[$key])) { + continue; + } + + // Set variables casting to type. + switch (strtolower($value['type'])) + { + case 'int': + case 'integer' : + $serialised_parameters .= "," . (int)trim($parameters[$key]); + break; + case 'bool': + case 'boolean': + // change word false to boolean false + if (strcasecmp(trim($parameters[$key]), "false") == 0) { + $parameters[$key] = false; + } + + $serialised_parameters .= "," . (bool)trim($parameters[$key]); + break; + case 'string': + $serialised_parameters .= ",'" . (string)mysql_real_escape_string(trim($parameters[$key])) . "'"; + break; + case 'float': + $serialised_parameters .= "," . (float)trim($parameters[$key]); + break; + case 'array': + // we can handle an array of strings, maybe ints, definitely not booleans or other arrays + $array = "array("; + if (!is_array($parameters[$key])) + { + throw APIException(sprintf(elgg_echo('APIException:ParameterNotArray'), $key)); + } + + foreach ($parameters[$key] as $k => $v) + { + $k = sanitise_string($k); + $v = sanitise_string($v); + + $array .= "'$k'=>'$v',"; + } + + $array = trim($array,","); + + $array .= ")"; + + $serialised_parameters .= $array; + break; + default: + throw new APIException(sprintf(elgg_echo('APIException:UnrecognisedTypeCast'), $value['type'], $key, $method)); + } + } + + return $serialised_parameters; +} + +// API authorization handlers ///////////////////////////////////////////////////////////////////// /** - * Simple api to return a list of all api's installed on the system. + * Confirm that the call includes a valid API key + * @return true if good API key - otherwise throws exception */ -function list_all_apis() { - global $METHODS; - return $METHODS; +function api_auth_key() { + global $CONFIG; + + // check that an API key is present + $api_key = get_input('api_key'); + if ($api_key == "") { + throw new APIException(elgg_echo('APIException:MissingAPIKey')); + } + + // check that it is active + $api_user = get_api_user($CONFIG->site_id, $api_key); + if (!$api_user) { + throw new APIException(elgg_echo('APIException:MissingAPIKey')); + } + + return trigger_plugin_hook('api_key', 'use', $api_key, true); } -// Expose some system api functions -expose_function("system.api.list", "list_all_apis", NULL, elgg_echo("system.api.list"), "GET", false); /** - * The auth.gettoken API. - * This API call lets a user log in, returning an authentication token which can be used - * in leu of a username and password login from then on. - * - * @param string username Username - * @param string password Clear text password + * + * @return true if success - otherwise throws exception */ -function auth_gettoken($username, $password) { - if (authenticate($username, $password)) { - $token = obtain_user_token($username, $password); - if ($token) { - return $token; +function api_auth_hmac() { + global $CONFIG; + + // Get api header + $api_header = get_and_validate_api_headers(); + + // Pull API user details + $api_user = get_api_user($CONFIG->site_id, $api_header->api_key); + + if (!$api_user) { + throw new SecurityException(elgg_echo('SecurityException:InvalidAPIKey'), ErrorResult::$RESULT_FAIL_APIKEY_INVALID); + } + + // Get the secret key + $secret_key = $api_user->secret; + + // get the query string + $query = substr($_SERVER['REQUEST_URI'], strpos($_SERVER['REQUEST_URI'], '?') + 1); + + // calculate expected HMAC + $hmac = calculate_hmac( $api_header->hmac_algo, + $api_header->time, + $api_header->api_key, + $secret_key, + $params, + $api_header->method == 'POST' ? $api_header->posthash : ""); + + + if (!(strcmp($api_header->hmac, $hmac) == 0) && !($api_header->hmac) && !($hmac)) { + throw new SecurityException("HMAC is invalid. {$api_header->hmac} != [calc]$hmac"); + } + + // Now make sure this is not a replay + if (cache_hmac_check_replay($hmac)) { + throw new SecurityException(elgg_echo('SecurityException:DupePacket')); + } + + // Validate post data + if ($api_header->method=="POST") { + $postdata = get_post_data(); + $calculated_posthash = calculate_posthash($postdata, $api_header->posthash_algo); + + if (strcmp($api_header->posthash, $calculated_posthash)!=0) { + throw new SecurityException(sprintf(elgg_echo('SecurityException:InvalidPostHash'), $calculated_posthash, $api_header->posthash)); } } - throw new SecurityException(elgg_echo('SecurityException:authenticationfailed')); + return true; } -// The authentication token api -expose_function("auth.gettoken", "auth_gettoken", array( - "username" => array ( - 'type' => 'string' - ), - "password" => array ( - 'type' => 'string' - ) -), elgg_echo('auth.gettoken'), "GET", false, false); +// HMAC ///////////////////////////////////////////////////////////////////// +/** + * This function looks at the super-global variable $_SERVER and extracts the various + * header variables needed to pass to the validation functions after performing basic validation. + * + * @return stdClass Containing all the values. + * @throws APIException Detailing any error. + */ +function get_and_validate_api_headers() { + $result = new stdClass; -// PAM AUTH HMAC functions //////////////////////////////////////////////////////////////// + $result->method = get_call_method(); + // Only allow these methods + if (($result->method != "GET") && ($result->method != "POST")) { + throw new APIException(elgg_echo('APIException:NotGetOrPost')); + } + + $result->api_key = $_SERVER['HTTP_X_ELGG_APIKEY']; + if ($result->api_key == "") { + throw new APIException(elgg_echo('APIException:MissingAPIKey')); + } + + $result->hmac = $_SERVER['HTTP_X_ELGG_HMAC']; + if ($result->hmac == "") { + throw new APIException(elgg_echo('APIException:MissingHmac')); + } + + $result->hmac_algo = $_SERVER['HTTP_X_ELGG_HMAC_ALGO']; + if ($result->hmac_algo == "") { + throw new APIException(elgg_echo('APIException:MissingHmacAlgo')); + } + + $result->time = $_SERVER['HTTP_X_ELGG_TIME']; + if ($result->time == "") { + throw new APIException(elgg_echo('APIException:MissingTime')); + } + + // Basic timecheck, think about making this smaller if we get loads of users and the cache gets really big. + if (($result->time<(microtime(true)-86400.00)) || ($result->time>(microtime(true)+86400.00))) { + throw new APIException(elgg_echo('APIException:TemporalDrift')); + } + + if ($result->method == "POST") { + $result->posthash = $_SERVER['HTTP_X_ELGG_POSTHASH']; + if ($result->posthash == "") { + throw new APIException(elgg_echo('APIException:MissingPOSTHash')); + } + + $result->posthash_algo = $_SERVER['HTTP_X_ELGG_POSTHASH_ALGO']; + if ($result->posthash_algo == "") { + throw new APIException(elgg_echo('APIException:MissingPOSTAlgo')); + } + + $result->content_type = $_SERVER['CONTENT_TYPE']; + if ($result->content_type == "") { + throw new APIException(elgg_echo('APIException:MissingContentType')); + } + } + + return $result; +} /** * Map various algorithms to their PHP equivs. @@ -707,6 +942,8 @@ function cache_hmac_check_replay($hmac) { return true; } +// API key functions ///////////////////////////////////////////////////////////////////// + /** * Find an API User's details based on the provided public api key. These users are not users in the traditional sense. * @@ -764,105 +1001,20 @@ function create_api_user($site_guid) { return false; } -/** - * This function looks at the super-global variable $_SERVER and extracts the various - * header variables needed to pass to the validation functions after performing basic validation. - * - * @return stdClass Containing all the values. - * @throws APIException Detailing any error. - */ -function get_and_validate_api_headers() { - $result = new stdClass; - - $result->method = get_call_method(); - // Only allow these methods - if (($result->method != "GET") && ($result->method!= "POST")) { - throw new APIException(elgg_echo('APIException:NotGetOrPost')); - } - - $result->api_key = $_SERVER['HTTP_X_ELGG_APIKEY']; - if ($result->api_key == "") { - throw new APIException(elgg_echo('APIException:MissingAPIKey')); - } - - $result->hmac = $_SERVER['HTTP_X_ELGG_HMAC']; - if ($result->hmac == "") { - throw new APIException(elgg_echo('APIException:MissingHmac')); - } - - $result->hmac_algo = $_SERVER['HTTP_X_ELGG_HMAC_ALGO']; - if ($result->hmac_algo == "") { - throw new APIException(elgg_echo('APIException:MissingHmacAlgo')); - } - - $result->time = $_SERVER['HTTP_X_ELGG_TIME']; - if ($result->time == "") { - throw new APIException(elgg_echo('APIException:MissingTime')); - } - - // Basic timecheck, think about making this smaller if we get loads of users and the cache gets really big. - if (($result->time<(microtime(true)-86400.00)) || ($result->time>(microtime(true)+86400.00))) { - throw new APIException(elgg_echo('APIException:TemporalDrift')); - } - - //$_SERVER['QUERY_STRING']; - $result->get_variables = get_parameters_for_method(get_input('method')); - if ($result->get_variables == "") { - throw new APIException(elgg_echo('APIException:NoQueryString')); - } - - if ($result->method=="POST") { - $result->posthash = $_SERVER['HTTP_X_ELGG_POSTHASH']; - if ($result->posthash == "") { - throw new APIException(elgg_echo('APIException:MissingPOSTHash')); - } - - $result->posthash_algo = $_SERVER['HTTP_X_ELGG_POSTHASH_ALGO']; - if ($result->posthash_algo == "") { - throw new APIException(elgg_echo('APIException:MissingPOSTAlgo')); - } - - $result->content_type = $_SERVER['CONTENT_TYPE']; - if ($result->content_type == "") { - throw new APIException(elgg_echo('APIException:MissingContentType')); - } - } - - return $result; -} +// User Authorization functions //////////////////////////////////////////////////////////////// /** - * Return a sanitised form of the POST data sent to the script - * - * @return string - */ -function get_post_data() { - global $GLOBALS; - - $postdata = $GLOBALS['HTTP_RAW_POST_DATA']; - - // Attempt another method to return post data (incase always_populate_raw_post_data is switched off) - if (!$postdata) { - $postdata = file_get_contents('php://input'); - } - - return $postdata; -} - -// PAM functions ////////////////////////////////////////////////////////////////////////// - -/** - * Function that examines whether an authentication token is present returning true if it is, OR the requested - * method doesn't require one. - * - * If a token is present and a validated user id is returned, that user is logged in to the current session. + * Check the user token + * This examines whether an authentication token is present and returns true if + * it is present and is valid. The user gets logged in so with the current + * session code of Elgg, that user will be logged out of all other sessions. * * @param unknown_type $credentials + * @return bool */ function pam_auth_usertoken($credentials = NULL) { - global $METHODS, $CONFIG; + global $CONFIG; - $method = get_input('method'); $token = get_input('auth_token'); $validated_userid = validate_user_token($CONFIG->site_id, $token); @@ -889,28 +1041,7 @@ function pam_auth_usertoken($credentials = NULL) { if (!login($u)) { return false; } - } - - if ((!$METHODS[$method]["require_auth_token"]) || ($validated_userid) || (isloggedin())) { - return true; - } else { - throw new SecurityException(elgg_echo('SecurityException:AuthTokenExpired'), ErrorResult::$RESULT_FAIL_AUTHTOKEN); - } - - return false; -} - -/** - * Test to see whether a given function has been declared as anonymous access (it doesn't require any auth token) - * - * @param unknown_type $credentials - */ -function pam_auth_anonymous_method($credentials = NULL) { - global $METHODS, $CONFIG; - - $method = get_input('method'); - - if ((isset($METHODS[$method]["anonymous"])) && ($METHODS[$method]["anonymous"])) { + return true; } @@ -925,80 +1056,60 @@ function pam_auth_session($credentials = NULL) { } /** - * Secure authentication through headers and HMAC. + * Obtain a token for a user. + * + * @param string $username The username + * @param string $password The password */ -function pam_auth_hmac($credentials = NULL) { +function obtain_user_token($username, $password) { global $CONFIG; - // Get api header - $api_header = get_and_validate_api_headers(); - - // Pull API user details - $api_user = get_api_user($CONFIG->site_id, $api_header->api_key); - - if ($api_user) { - // Get the secret key - $secret_key = $api_user->secret; - - // Serialise parameters - $encoded_params = array(); - foreach ($api_header->get_variables as $k => $v) { - $encoded_params[] = urlencode($k).'='.urlencode($v); - } - $params = implode('&', $encoded_params); - - // Validate HMAC - $hmac = calculate_hmac($api_header->hmac_algo, - $api_header->time, - $api_header->api_key, - $secret_key, - $params, - $api_header->method == 'POST' ? $api_header->posthash : ""); - - if ((strcmp($api_header->hmac, $hmac) == 0) && ($api_header->hmac) && ($hmac)) { - // Now make sure this is not a replay - if (!cache_hmac_check_replay($hmac)) { - - // Validate post data - if ($api_header->method=="POST") { - $postdata = get_post_data(); - $calculated_posthash = calculate_posthash($postdata, $api_header->posthash_algo); + $site = $CONFIG->site_id; + $user = get_user_by_username($username); + $time = time(); + $time += 60*60; // token is good for one hour + $token = md5(rand(). microtime() . $username . $password . $time . $site); - if (strcmp($api_header->posthash, $calculated_posthash)!=0) { - throw new SecurityException(sprintf(elgg_echo('SecurityException:InvalidPostHash'), $calculated_posthash, $api_header->posthash)); - } - } + if (!$user) { + return false; + } - // If we've passed all the checks so far then we can be reasonably certain that the request is authentic, so return this fact to the PAM engine. - return true; - } else { - throw new SecurityException(elgg_echo('SecurityException:DupePacket')); - } - } else { - throw new SecurityException("HMAC is invalid. {$api_header->hmac} != [calc]$hmac = {$api_header->hmac_algo}(**SECRET KEY**, time:{$api_header->time}, apikey:{$api_header->api_key}, get_vars:{$params}" . ($api_header->method=="POST"? "posthash:$api_header->posthash}" : ")")); - } - } else { - throw new SecurityException(elgg_echo('SecurityException:InvalidAPIKey'),ErrorResult::$RESULT_FAIL_APIKEY_INVALID); + if (insert_data("INSERT into {$CONFIG->dbprefix}users_apisessions + (user_guid, site_guid, token, expires) values + ({$user->guid}, $site, '$token', '$time') on duplicate key update token='$token', expires='$time'")) { + return $token; } return false; } /** - * A bit of a hack. Basically, this combines session and hmac, so that one of them must evaluate to true in order - * to proceed. + * Validate a token against a given site. * - * This ensures that this and auth_token are evaluated separately. + * A token registered with one site can not be used from a different apikey(site), so be aware of this + * during development. * - * @param unknown_type $credentials + * @param int $site The ID of the site + * @param string $token The Token. + * @return mixed The user id attached to the token or false. */ -function pam_auth_session_or_hmac($credentials = NULL) { - if (pam_auth_session($credentials)) { - return true; +function validate_user_token($site, $token) { + global $CONFIG; + + $site = (int)$site; + $token = sanitise_string($token); + + if (!$site) { + throw new ConfigurationException(elgg_echo('ConfigurationException:NoSiteID')); } - if (pam_auth_hmac($credentials)) { - return true; + $time = time(); + + $user = get_data_row("SELECT * from {$CONFIG->dbprefix}users_apisessions + where token='$token' and site_guid=$site and $time < expires"); + + if ($user) { + return $user->user_guid; } return false; @@ -1006,10 +1117,6 @@ function pam_auth_session_or_hmac($credentials = NULL) { // Client api functions /////////////////////////////////////////////////////////////////// -$APICLIENT_LAST_CALL = NULL; -$APICLIENT_LAST_CALL_RAW = ""; -$APICLIENT_LAST_ERROR = NULL; - /** * Utility function to serialise a header array into its text representation. * @@ -1038,7 +1145,7 @@ function serialise_api_headers(array $headers) { * @return stdClass The unserialised response object */ function send_api_call(array $keys, $url, array $call, $method = 'GET', $post_data = '', $content_type = 'application/octet-stream') { - global $APICLIENT_LAST_CALL, $APICLIENT_LAST_CALL_RAW, $APICLIENT_LAST_ERROR, $CONFIG; + global $CONFIG; $headers = array(); $encoded_params = array(); @@ -1114,15 +1221,9 @@ function send_api_call(array $keys, $url, array $call, $method = 'GET', $post_da // Send the query and get the result and decode. elgg_log("APICALL: $url"); - $APICLIENT_LAST_CALL_RAW = file_get_contents($url, false, $context); + $results = file_get_contents($url, false, $context); - $APICLIENT_LAST_CALL = unserialize($APICLIENT_LAST_CALL_RAW); - - if (($APICLIENT_LAST_CALL) && ($APICLIENT_LAST_CALL->status!=0)) { - $APICLIENT_LAST_ERROR = $APICLIENT_LAST_CALL; - } - - return $APICLIENT_LAST_CALL; + return $results; } /** @@ -1158,7 +1259,40 @@ function send_api_post_call($url, array $call, array $keys, $post_data, $content * @param string $api_key Your api key */ function get_standard_api_key_array($secret_key, $api_key) { - return array('public' => $api_key, 'private' => $api_key); + return array('public' => $api_key, 'private' => $secret_key); +} + +// System functions /////////////////////////////////////////////////////////////////////// + +/** + * Simple api to return a list of all api's installed on the system. + */ +function list_all_apis() { + global $API_METHODS; + + // sort first + ksort($API_METHODS); + + return $API_METHODS; +} + +/** + * The auth.gettoken API. + * This API call lets a user log in, returning an authentication token which can be used + * in leu of a username and password login from then on. + * + * @param string username Username + * @param string password Clear text password + */ +function auth_gettoken($username, $password) { + if (authenticate($username, $password)) { + $token = obtain_user_token($username, $password); + if ($token) { + return $token; + } + } + + throw new SecurityException(elgg_echo('SecurityException:authenticationfailed')); } // Error handler functions //////////////////////////////////////////////////////////////// @@ -1214,25 +1348,13 @@ function __php_api_exception_handler($exception) { error_log("*** FATAL EXCEPTION (API) *** : " . $exception); - page_draw($exception->getMessage(), elgg_view("api/output", - array('result' => ErrorResult::getInstance( - $exception->getMessage(), - $exception->getCode() == 0 ? ErrorResult::$RESULT_FAIL : $exception->getCode(), - $exception) - )) - ); + $code = $exception->getCode() == 0 ? ErrorResult::$RESULT_FAIL : $exception->getCode(); + $result = new ErrorResult($exception->getMessage(), $code, NULL); + + page_draw($exception->getMessage(), elgg_view("api/output", array("result" => $result))); } -// Initialisation & pagehandler /////////////////////////////////////////////////////////// - -/** - * Initialise the API subsystem. - * - */ -function api_init() { - // Register a page handler, so we can have nice URLs - register_page_handler('api','api_endpoint_handler'); -} +// Initialisation ///////////////////////////////////////////////////////////// /** * Register a page handler for the various API endpoints. @@ -1256,4 +1378,39 @@ function api_endpoint_handler($page) { } } +/** + * Unit tests for API + */ +function api_unit_test($hook, $type, $value, $params) { + global $CONFIG; + $value[] = $CONFIG->path . 'engine/tests/services/api.php'; + return $value; +} + +/** + * Initialise the API subsystem. + * + */ +function api_init() { + // Register a page handler, so we can have nice URLs + register_page_handler('api','api_endpoint_handler'); + + register_plugin_hook('unit_test', 'system', 'api_unit_test'); + + // expose the list of api methods + expose_function("system.api.list", "list_all_apis", NULL, elgg_echo("system.api.list"), "GET", false, false); + + // The authentication token api + expose_function("auth.gettoken", + "auth_gettoken", array( + 'username' => array ('type' => 'string'), + 'password' => array ('type' => 'string'), + ), + elgg_echo('auth.gettoken'), + 'POST', + false, + false); +} + + register_elgg_event_handler('init','system','api_init'); diff --git a/engine/tests/services/api.php b/engine/tests/services/api.php new file mode 100644 index 000000000..cf8a16bf5 --- /dev/null +++ b/engine/tests/services/api.php @@ -0,0 +1,91 @@ +<?php
+/**
+ * Elgg Test Services - General API and REST
+ *
+ * @package Elgg
+ * @subpackage Test
+ * @author Curverider Ltd
+ * @link http://elgg.org/
+ */
+class ElggCoreServicesApiTest extends ElggCoreUnitTest {
+
+ /**
+ * Called after each test method.
+ */
+ public function tearDown() {
+ global $API_METHODS;
+ $this->swallowErrors();
+ $API_METHODS = array();
+ }
+
+ public function testExposeFunctionNoMethod() {
+ $this->expectException('InvalidParameterException');
+ expose_function();
+ }
+
+ public function testExposeFunctionNoFunction() {
+ $this->expectException('InvalidParameterException');
+ expose_function('test');
+ }
+
+ public function testExposeFunctionBadParameters() {
+ $this->expectException('InvalidParameterException');
+ expose_function('test', 'test', 'BAD');
+ }
+
+ public function testExposeFunctionBadHttpMethod() {
+ $this->expectException('InvalidParameterException');
+ expose_function('test', 'test', null, '', 'BAD');
+ }
+
+ public function testExposeFunctionSuccess() {
+ global $API_METHODS;
+ $parameters = array('param1' => array('type' => 'int', 'required' => true));
+ $method['function'] = 'foo';
+ $method['parameters'] = $parameters;
+ $method['call_method'] = 'GET';
+ $method['description'] = '';
+ $method['require_api_auth'] = false;
+ $method['require_user_auth'] = false;
+
+ $this->assertTrue(expose_function('test', 'foo', $parameters));
+ $this->assertIdentical($method, $API_METHODS['test']);
+ }
+
+ public function testApiMethodNotImplemented() {
+ global $CONFIG;
+
+ $results = send_api_get_call($CONFIG->wwwroot . 'pg/api/rest/json/', array('method' => 'bad.method'));
+ $obj = json_decode($results);
+ $this->assertIdentical(sprintf(elgg_echo('APIException:MethodCallNotImplemented'), 'bad.method'), $obj->api[0]->message);
+ }
+
+ public function testVerifyParameters() {
+ $this->registerFunction();
+
+ $parameters = array('param1' => 0);
+ $this->assertTrue(verify_parameters('test', $parameters));
+
+ $parameters = array('param2' => true);
+ $this->expectException('APIException');
+ $this->assertTrue(verify_parameters('test', $parameters));
+ }
+
+ public function testserialise_parameters() {
+
+ }
+
+ protected function registerFunction($api_auth = false, $user_auth = false) {
+ $parameters = array('param1' => array('type' => 'int', 'required' => true),
+ 'param2' => array('type' => 'bool', 'required' => false), );
+ $method['function'] = 'foo';
+ $method['parameters'] = $parameters;
+ $method['call_method'] = 'GET';
+ $method['description'] = '';
+ $method['require_api_auth'] = $api_auth;
+ $method['require_user_auth'] = $user_auth;
+
+ expose_function('test', 'foo', $parameters);
+ }
+
+}
diff --git a/languages/en.php b/languages/en.php index d4092f8dc..c93f36fd7 100644 --- a/languages/en.php +++ b/languages/en.php @@ -116,18 +116,21 @@ $english = array( 'InvalidParameterException:DoesNotBelongOrRefer' => "Does not belong to entity or refer to entity.", 'InvalidParameterException:MissingParameter' => "Missing parameter, you need to provide a GUID.", - 'SecurityException:APIAccessDenied' => "Sorry, API access has been disabled by the administrator.", - 'SecurityException:NoAuthMethods' => "No authentication methods were found that could authenticate this API request.", 'APIException:ApiResultUnknown' => "API Result is of an unknown type, this should never happen.", - 'ConfigurationException:NoSiteID' => "No site ID has been specified.", - 'InvalidParameterException:UnrecognisedMethod' => "Unrecognised call method '%s'", + 'SecurityException:APIAccessDenied' => "Sorry, API access has been disabled by the administrator.", + 'SecurityException:NoAuthMethods' => "No authentication methods were found that could authenticate this API request.", + 'InvalidParameterException:APIMethodOrFunctionNotSet' => "Method or function not set in call in expose_method()", + 'InvalidParameterException:APIParametersNotArray' => "Parameters must be array in call to expose method '%s'", + 'InvalidParameterException:UnrecognisedHttpMethod' => "Unrecognised http method %s for api method '%s'", 'APIException:MissingParameterInMethod' => "Missing parameter %s in method %s", 'APIException:ParameterNotArray' => "%s does not appear to be an array.", 'APIException:UnrecognisedTypeCast' => "Unrecognised type in cast %s for variable '%s' in method '%s'", 'APIException:InvalidParameter' => "Invalid parameter found for '%s' in method '%s'.", 'APIException:FunctionParseError' => "%s(%s) has a parsing error.", 'APIException:FunctionNoReturn' => "%s(%s) returned no value.", + 'APIException:APIAuthenticationFailed' => "Method call failed the API Authentication", + 'APIException:UserAuthenticationFailed' => "Method call failed the User Authentication", 'SecurityException:AuthTokenExpired' => "Authentication token either missing, invalid or expired.", 'CallException:InvalidCallMethod' => "%s must be called using '%s'", 'APIException:MethodCallNotImplemented' => "Method call '%s' has not been implemented.", diff --git a/services/api/rest.php b/services/api/rest.php index dfa6cd3a5..a569e7e26 100644 --- a/services/api/rest.php +++ b/services/api/rest.php @@ -27,30 +27,27 @@ if ((isset($CONFIG->disable_api)) && ($CONFIG->disable_api == true)) { throw new SecurityException(elgg_echo('SecurityException:APIAccessDenied')); } -// Register some default PAM methods, plugins can add their own -register_pam_handler('pam_auth_session_or_hmac'); // Command must either be authenticated by a hmac or the user is already logged in -register_pam_handler('pam_auth_usertoken', 'required'); // Either token present and valid OR method doesn't require one. -register_pam_handler('pam_auth_anonymous_method'); // Support anonymous functions +// plugins should return true to control what API and user authentication handlers are registered +if (trigger_plugin_hook('rest', 'init', null, false) == false) { + // check session - this usually means a REST call from a web browser + register_pam_handler('pam_auth_session'); + // user token can also be used for user authentication + register_pam_handler('pam_auth_usertoken'); + + // for api authentication, we default to a simple API key check + register_api_auth_handler('api_auth_key'); +} // Get parameter variables $method = get_input('method'); $result = null; -// Authenticate session -if (pam_authenticate()) { - // Authenticated somehow, now execute. - $token = ""; - $params = get_parameters_for_method($method); // Use $CONFIG->input instead of $_REQUEST since this is called by the pagehandler - if (isset($params['auth_token'])) { - $token = $params['auth_token']; - } - - $result = execute_method($method, $params, $token); -} else { - throw new SecurityException(elgg_echo('SecurityException:NoAuthMethods')); -} +// this will throw an exception if authentication fails +authenticate_method($method); + +$result = execute_method($method); + -// Finally output if (!($result instanceof GenericResult)) { throw new APIException(elgg_echo('APIException:ApiResultUnknown')); } |