背景介绍
当要限制http接口只能被特定的客户端调用的时候,我们就需要验证接口调用者的身份。可以通过http提供的Authorization头传递用户名密码的方式来完成身份验证,虽然用户名和密码经过base64编码后人眼看不出来,但很容易被解析出来。 现在普遍的做法是使用签名来验证身份,也就是通讯的双方约定好一个密钥,调用方将请求的数据使用该密钥生成一个签名并作为参数传递,服务方用同样的密钥对请求数据加密,然后和调用方传递过来的签名进行比较。 几乎每个公司内部都会使用一套自己的验签规则,不同系统应该使用一套统一的验签规则。
签名算法介绍
这里介绍一种极其简单但安全有效的签名算法,它有以下特点:
- 验签规则是语言无关的
- 对中间人攻击免疫,也即使窃听到http请求的内容也无法伪造客户端重新发起请求
- 请求参数包含时间戳,服务端会和当前的时间戳比较,如果相差超过一定时长就认为是无效的
- 签名使用md5单向hash散列生成
- 签名的生成依赖key,但是key只有客户端和服务端才知道并不会通过参数传递,攻击者无法获取key,就不可能伪造出正确的签名
- 不同的调用方使用不同的appid和appkey组合访问,使用这种组合的好处是:
- 区分不同的调用者,方便统计和追踪问题,appid用于区别不同的调用者
- 当要禁掉某个调用者的时候直接删掉对应的appid和appkey,如果只用一个统一的key则只能让所有其他的调用者都跟着修改key
具体的签名规则如下:
- 在参数中增加当前的时间戳(ts)和调用方id(appid)
- 生成签名
- 在query_string后面追加&appkey=
得到临时的temp_query_string,appkey最好是纯ascii字符 - 通过md5(temp_query_string>)计算出签名sign
- 在query_string后面追加&appkey=
- 在query_string后面添加&sign=
作为最终的query string
使用php实现的简单实例如下
<?php
/**
* client.php
*
* Created by PhpStorm.
* User: yxr
* Date: 16/12/28
* Time: 下午4:30
*/
$url = 'http://localhost/server.php';
$params = [
'name' => 'yanxr',
'age' => 25
];
$appid = 'client1';
$appkey = '1234567890';
echo request($url, $params, $appid, $appkey);
// below are common functions which should be placed in a base class
function request($url, $params, $appid, $appkey, $method='GET')
{
$qry_str = sign($params, $appid, $appkey);
// echo $qry_str . '<br>';
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, '3');
if ($method == 'GET') {
curl_setopt($ch, CURLOPT_URL, $url . '?' . $qry_str);
} elseif ($method == 'POST') {
curl_setopt($ch, CURLOPT_URL, $url);
// Set request method to POST
curl_setopt($ch, CURLOPT_POST, 1);
// Set query data here with CURLOPT_POSTFIELDS
curl_setopt($ch, CURLOPT_POSTFIELDS, $qry_str);
} else {
throw new Exception('wrong method parameter');
}
$ret = trim(curl_exec($ch));
curl_close($ch);
return $ret;
}
function sign($params, $appid, $appkey)
{
$params['ts'] = time();
$params['appid'] = $appid;
$qry_str = http_build_query($params);// http_build_query默认使用x-www-form-urlencoded编码
$sign = md5($qry_str . '&appkey=' . $appkey);
return $qry_str . '&sign=' . $sign;
}
<?php
/**
* server.php
*
* Created by PhpStorm.
* User: yxr
* Date: 16/12/28
* Time: 下午4:57
*/
if (!checkSignature($_REQUEST)) {
die('you sucks');
}
$my_age = 25;
if (isset($_REQUEST['name'])) {
echo "hello {$_REQUEST['name']} <br>";
if (isset($_REQUEST['age'])) {
$not = $_REQUEST['age'] == $my_age? '' : 'not ';
echo "I am {$not}as old as you";
}
} else {
echo 'hello world';
}
function checkSignature($params)
{
$max_interval = 3; // max time interval not more than 3 seconds
// appid => appkey
$app = [
'client1' => '1234567890',
'client2' => '1029384756'
];
if (!isset($params['appid']) || !isset($app[$params['appid']])) {
error_log("wrong appid\n", 3, "/tmp/my-errors.log");
return false;
}
if (!isset($params['ts']) || abs($params['ts'] - $_SERVER['REQUEST_TIME']) > $max_interval) {
error_log("wrong ts\n", 3, "/tmp/my-errors.log");
return false;
}
if (!isset($params['sign'])) {
error_log("sign not set\n", 3, "/tmp/my-errors.log");
return false;
}
$origin_sign = $params['sign'];
unset($params['sign']);
$qry_str = http_build_query($params);
$sign = md5($qry_str . '&appkey=' . $app[$params['appid']]);
if ($origin_sign != $sign) {
error_log("wrong signature: 1.check your appkey 2.check your sign generation method\n", 3, "/tmp/my-errors.log");
}
return $origin_sign == $sign;
}
改进
将第2步中生成签名的方式稍加变动即可得到另一种签名规则
- 将所有请求参数按参数名排序后的值用|串联
- 在后面追加&
- 使用md5计算出签名
使用php实现的简单实例如下
<?php
/**
* client.php
*
* Created by PhpStorm.
* User: yxr
* Date: 16/12/28
* Time: 下午4:30
*/
$url = 'http://localhost/server.php';
$params = [
'name' => 'yanxr',
'age' => 25
];
$appid = 'client1';
$appkey = '1234567890';
echo request($url, $params, $appid, $appkey, 'POST');
// below are common functions which should be placed in a base class
function request($url, $params, $appid, $appkey, $method='GET')
{
$params['_appid'] = $appid;
$params['_ts'] = time();
$params['_sign'] = sign($params, $appkey);
$qry_str = http_build_query($params);
// echo $qry_str . '<br>';
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, '3');
if ($method == 'GET') {
curl_setopt($ch, CURLOPT_URL, $url . '?' . $qry_str);
} elseif ($method == 'POST') {
curl_setopt($ch, CURLOPT_URL, $url);
// Set request method to POST
curl_setopt($ch, CURLOPT_POST, 1);
// Set query data here with CURLOPT_POSTFIELDS
curl_setopt($ch, CURLOPT_POSTFIELDS, $qry_str);
} else {
throw new Exception('wrong method parameter');
}
$ret = trim(curl_exec($ch));
curl_close($ch);
return $ret;
}
function sign($params, $appkey)
{
ksort($params);
$str = implode('|', $params);
return md5($str . '|' . $appkey);
}
<?php
/**
* server.php
*
* Created by PhpStorm.
* User: yxr
* Date: 16/12/28
* Time: 下午4:57
*/
if (!checkSignature($_REQUEST)) {
die('you sucks');
}
$my_age = 25;
if (isset($_REQUEST['name'])) {
echo "hello {$_REQUEST['name']} <br>";
if (isset($_REQUEST['age'])) {
$not = $_REQUEST['age'] == $my_age? '' : 'not ';
echo "I am {$not}as old as you";
}
} else {
echo 'hello world';
}
function checkSignature($params)
{
$max_interval = 3; // max time interval not more than 3 seconds
// appid => appkey
$app = [
'client1' => '1234567890',
'client2' => '1029384756'
];
if (!isset($params['_appid']) || !isset($app[$params['_appid']])) {
error_log("wrong appid\n", 3, "/tmp/my-errors.log");
return false;
}
if (!isset($params['_ts']) || abs($params['_ts'] - $_SERVER['REQUEST_TIME']) > $max_interval) {
error_log("wrong ts\n", 3, "/tmp/my-errors.log");
return false;
}
if (!isset($params['_sign'])) {
error_log("sign not set\n", 3, "/tmp/my-errors.log");
return false;
}
$origin_sign = $params['_sign'];
unset($params['_sign']);
ksort($params);
$str = implode('|', $params);
$sign = md5($str . '|' . $app[$params['_appid']]);
if ($origin_sign != $sign) {
error_log("wrong signature: 1.check your appkey 2.check your sign generation method\n", 3, "/tmp/my-errors.log");
}
return $origin_sign == $sign;
}
哪种方式更合适
我 推荐第二种方式,因为第一种方式会有坑:
- 签名基于query string生成,query string有不同的urlencode方式,服务端在生成签名的时候一般不是基于原始的query string,而是去掉sign参数后重新构建新的query string,这就需要保证参数的顺序和urlencode方式与原始的请求一致。上面的实现中$_REQUEST数组的顺序与请求参数一致,并且请求参数是通过application/x-www-form-urlencoded编码,这是form表单的默认方式,也是curl以及php中http_build_query默认用的编码
- 另一方面,客户端一般使用http类库,不会手动调用http_build_query创建query string,这就需要在生成签名的时候单独调用一次http_build_query,该函数具有urlencode的功能,并不是简单的把所有参数用&拼起来。如果使用的语言没有类似的函数,就只能手动编码了。