Skip to content

Instantly share code, notes, and snippets.

@skwirrel
Last active June 25, 2020 07:26
Show Gist options
  • Save skwirrel/dd95cdce81006ff21a4c3da7111e7952 to your computer and use it in GitHub Desktop.
Save skwirrel/dd95cdce81006ff21a4c3da7111e7952 to your computer and use it in GitHub Desktop.
A simple API request maker and validator with HMAC based authentication.

Introduction

This Gist defines a mechanism for authenticating and signing API requests.

The authentication is passed from client to server via an "Authentication" HTTP Header. This includes a Hashed Message Authentication Code (HMAC). The HMAC is computed from all of the message parameters (request type, request URL, GET string, POST Parameters and/or POST body).

Secret key

The data is signed with a pre-shared secret key provided to the client via some secure channel. In the case of server-to-server communication the secret key is likely to be hard-coded into a configuration file. In the case of browser-to-server communication the key ID and secret key would be passed to the client at the end of a successful login process.

The key ID enables the server to communicate with and identify a number of different client accounts. The server must have some mechanism for storing the keys of a number of client accounts and retrieving these based on the key ID. In the code below this is achieved by the secretKeyLookupCallback which takes a key ID and returns the corresponding secret key.

Timestamps

Each request is timestamped with the timestamp built into the HMAC. This allows the server to identify stale messages. It is up to the server to decide how old a messages must be to be considered "stale".

Authentication

The authentication header has the following format:

Authentication: HMAC <time>:<keyID>:<HMAC>

Where the placeholders have the following meanings:

<time>	: The time that the message was generated - expressed as a unix timestamp (i.e. number of seconds since 1/1/1970 )
<keyID>	: A constant KeyID assigned by the service provider to the API consumer
<HMAC>	: The base64 encoded SHA256 HMAC computed from the message parameters (see below) using a pre-shared constant secret key 

Message parameters

The text used to compute the message digest includes all of the following joined together with a colon symbol separating each field:

Method		: The HTTP method for the request i.e. GET, POST, DELETE, PUT etc
Path		: The URL-encoded full path of the API endpoint on the server
Time		: The time that the message was generated - expressed as a unix timestamp (i.e. no. seconds since 1/1/1970 ) - this must be the same as included in the Authentication header above
Parameters	: An SHA256 hash represented as 64 lower case hexits (e.g. 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e730 ) computed from the either:
	In the case of POST data: (i.e. content-type X-WWW-FORM-URLENCODED or MULTIPART/FORM-DATA) The URL-encoded query string obtained by encoding all the POST data as if they had been listed on a query string in ascending alphabetical order of parameter name
	In the case of GET data: The actual query string passed (in whatever order it was supplied to the browser)
	In the case of raw POST: The actual post body

N.B. parameters cannot be past using both POST fields and parameters passed on the query string at the same time (if a POST request is used data on the query string will be ignored).

<?
/*
Copyright BAFTA 2019
Authoritative source: https://gist.github.com/skwirrel/dd95cdce81006ff21a4c3da7111e7952
This class handles signing and validating API requests.
It handles passing data in as a raw POST blob (e.g.JSON), or as an associative array (GET or POST style).
It also handles other request methods (PUT, DELETE - anything you want)
To make requests you do this...
$apiHandler = new SignedRequester( $baseUrl, $keyId, $secretKey );
list( $error, $httpResponseCode, $responseBody) = $apiHandler->GET($path, $array_of_params);
list( $error, $httpResponseCode, $responseBody) = $apiHandler->GET($path, $query_string);
list( $error, $httpResponseCode, $responseBody) = $apiHandler->POST($path, $array_of_params);
// N.B. raw_post_data shouldn't be stupidly large as it has to be loaded entirely into RAM for hashing
list( $error, $httpResponseCode, $responseBody) = $apiHandler->POST($path, $raw_post_data);
list( $error, $httpResponseCode, $responseBody) = $apiHandler->DELETE($path, $params);
list( $error, $httpResponseCode, $responseBody) = $apiHandler->SOME_OTHER_METHOD($path, $params);
$error will be false on success.
At the other end you validate requests list this...
list( $error, $method, $data ) = SignedRequester::validate( $secretKeyLookupCallback, $hashValiditySeconds );
$error will be false on success.
$method and $data will both be empty ('') on error.
$secretKeyLookupCallback is a callback which gets passed the keyId as a parameter and must return the secret key
(or empty string on error e.g. keyId is invalid).
*/
/* Sample code for testing purposes
if (isset($_REQUEST['mode']) && $_REQUEST['mode'] == 'test') {
list($error, $method, $data) = signedRequester::validate( function(){ return 'SECRET'; }, 60 );
if ($error===false) $error = 'OK';
echo "$error ($method)<br />";
echo "data: <pre>"; print_r($data); echo '</pre>';
exit;
}
$path = $_SERVER["SCRIPT_URL"];
$base = substr($_SERVER["SCRIPT_URI"],0,strlen($path)*-1);
echo "base=$base path=$path<br />";
$apiHandler = new SignedRequester( $base, 123, 'SECRET' );
echo "<hr />SIMPLE GET<br /><br />";
list( $error, $code, $response ) = $apiHandler->GET($path,array('foo'=>'bar','mode'=>'test'));
echo "Code: $code<br />";
echo "Response:<blockQuote>$response</blockQuote>";
echo "<hr />QUERY STRING<br /><br />";
list( $error, $code, $response ) = $apiHandler->GET($path,'foo=bar&mode=test');
echo "Code: $code<br />";
echo "Response:<blockQuote>$response</blockQuote>";
echo "<hr />SIMPLE POST<br /><br />";
list( $error, $code, $response ) = $apiHandler->POST($path,array('foo'=>'bar','mode'=>'test'));
echo "Code: $code<br />";
echo "Response:<blockQuote>$response</blockQuote>";
echo "<hr />RAW POST<br /><br />";
list( $error, $code, $response ) = $apiHandler->POST($path.'?mode=test', 'This is the raw post data');
echo "Code: $code<br />";
echo "Response:<blockQuote>$response</blockQuote>";
echo "<hr />RAW DELETE<br /><br />";
list( $error, $code, $response ) = $apiHandler->DELETE($path.'?mode=test', 'This is the raw post data');
echo "Code: $code<br />";
echo "Response:<blockQuote>$response</blockQuote>";
exit;
*/
class SignedRequester {
private $baseUrl;
private $keyId;
private $secretKey;
const authHeaderName = 'Authentication';
function __construct($baseUrl, $keyId, $secretKey) {
$this->baseUrl = $baseUrl;
$this->keyId = $keyId;
$this->secretKey = $secretKey;
}
public function request() {
$args = func_get_args();
return call_user_func_array(array($this, array_shift($args)), $args);
}
public function __call($method, $arguments) {
$debug = false;
list( $path, $params ) = $arguments;
$curl = curl_init();
$url = $this->baseUrl.$path;
if (!isset($params)) $params='';
$method = preg_replace('/[^A-Z]/','',strtoupper($method));
$now = time();
$fullPath = parse_url($this->baseUrl,PHP_URL_PATH).$path;
$digestString = $method.':'.rawUrlEncode($fullPath).':'.$now.':';
if (is_array($params)) {
ksort($params);
$query = http_build_query($params);
$digestString.=hash('sha256',$query);
} else {
if (strlen($params)) $digestString.=hash('sha256',$params);
$query = $params;
}
if ($method=='GET') {
if (strlen($query)) $url .= '?'.$query;
} else {
curl_setopt($curl,CURLOPT_POSTFIELDS,$params);
}
if ($debug) echo "Digest string = \"$digestString\"<br />";
$hmac = base64_encode(hash_hmac('sha256',$digestString,$this->secretKey,true));
$authHeader = self::authHeaderName.': HMAC '.$now.':'.rawUrlEncode($this->keyId).':'.$hmac;
if ($debug) echo "Auth header=$authHeader<br />";
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, array($authHeader));
$response = curl_exec($curl);
if( $response === false) {
return array( curl_error($curl), 0, '');
}
$responseCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
return array( false, $responseCode, $response );
}
public static function validate( $secretKeyLookupCallback, $hashValiditySeconds ) {
$debug = false;
$method = strtoupper($_SERVER['REQUEST_METHOD']);
if ($debug) echo "Received incoming $method request<br />";
$path = $_SERVER['REQUEST_URI'];
if (!isset($_SERVER['HTTP_'.strtoupper(self::authHeaderName)])) return array('Request is missging authentication header','','' );
$authData = $_SERVER['HTTP_'.strtoupper(self::authHeaderName)];
if ( substr($authData,0,5)!=='HMAC ' ) return array('Invalid authentication token','','' );
$authData = trim(substr($authData,5));
list( $time, $keyId, $hmac ) = explode( ':', $authData.'::::' );
if ( !(strlen($time) && strlen($keyId) && strlen($time)) ) return array('Invalid authentication token','','' );
if ($time<(time()-$hashValiditySeconds)) return array('Token timed out - check clocks are in sync','','' );
$secretKey = $secretKeyLookupCallback( $keyId );
if (!strlen($secretKey)) return array('Unknown key ID','','' );
$paramsHash = '';
$return = '';
if ($method=='GET') {
$query = $_SERVER["QUERY_STRING"];
if (strlen($query)) {
// Chop the query string (and the question mark) of the end of the path
$path = substr($path, 0, (strlen($query)+1) * -1);
$paramsHash = hash('sha256',$_SERVER["QUERY_STRING"]);
}
$return = &$_GET;
} else {
$contentType = strtoupper(trim($_SERVER['CONTENT_TYPE']));
if (strpos($contentType,'APPLICATION/X-WWW-FORM-URLENCODED')===0 || strpos($contentType,'MULTIPART/FORM-DATA')===0) {
$params = $_POST;
ksort($params);
if ($debug) echo "Query data = \"".http_build_query($params)."\"<br />";
$paramsHash = hash('sha256',http_build_query($params));
$return = &$_POST;
} else if ($method='POST') {
$rawPostData = file_get_contents('php://input');
$paramsHash = hash('sha256',$rawPostData);
$return = &$rawPostData;
}
}
$digestString = $method.':'.urlEncode($path).':'.$time.':'.$paramsHash;
if ($debug) echo "Reconstructed digest string = \"$digestString\"<br />";
$desiredHmac = base64_encode(hash_hmac('sha256',$digestString,$secretKey,true));
if (!hash_equals( $desiredHmac, $hmac )) return array( 'Unauthorized','','' );
return array( false, $method, $return );
}
}
/*
A Javascript implementation of a client to make authenticated requests
*/
function SignedRequester(baseUrl, keyId, secretKey) {
this.baseUrl = baseUrl;
this.keyId = keyId;
this.secretKey = secretKey;
this.authHeaderName = 'Authentication';
}
SignedRequester.prototype.hash_hmac = function( string, encoding ) {
if (!encoding) encoding = CryptoJS.enc.Hex;
return CryptoJS.HmacSHA256(string, this.secretKey).toString(encoding);
}
SignedRequester.prototype.hash = function( string, encoding ) {
if (!encoding) encoding = CryptoJS.enc.Hex;
return CryptoJS.SHA256(string).toString(encoding);
}
SignedRequester.prototype.post = function( path, data, success, error ) {
var fullUrl = this.baseUrl + '/' + path;
var now = Math.round(new Date().getTime()/1000);
var digestString = 'POST:' + encodeURIComponent(fullUrl) + ':' + now + ':';
var query = '';
Object.keys(data).sort().forEach(function(key) {
query += encodeURIComponent(key)+'='+encodeURIComponent(data[key])+'&';
});
if (query.length) query = query.substr(0,query.length-1);
digestString += this.hash(query);
var hmac = this.hash_hmac(digestString,CryptoJS.enc.Base64);
var authHeader = 'HMAC ' + now + ':' + encodeURIComponent(this.keyId) + ':' + hmac;
var headers = {};
headers[this.authHeaderName] = authHeader;
$.ajax({
type: "POST",
url: fullUrl,
data: data,
headers: headers
})
.done(success)
.error(error);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment