(如果你也遇到了同样的问题,签名加密代码修正后的见我的Github。程序员,以发现他人的bug为荣,而且是这么重要公司的这么重要程序里面的这么严重的bug。)
坑爹的百度,浪费了我两天的时间,花在了这个bug身上。对百度的印象,就是停留在经常打电话过来,问加入不加入他们的推广服务,不是啥好印象。不过总体觉得,百度的技术应该是可以的。所以很难想象,他们官方给的PHP示例加密程序,里面有bug。
我是在集成百度云SMS接口的时候,发现的bug。由于百度没有提供SMS的PHP版本的SDK,所以我先试用了一下java的SDK。测试成功。
package com.yaiyuan;
import java.util.HashMap;
import java.util.Map;
import com.baidubce.auth.DefaultBceCredentials;
import com.baidubce.services.sms.SmsClient;
import com.baidubce.services.sms.SmsClientConfiguration;
import com.baidubce.services.sms.model.SendMessageV2Request;
import com.baidubce.services.sms.model.SendMessageV2Response;
public class SMSTest {
public static void main(String[] args) {
// 相关参数定义
String endPoint = "http://sms.bj.baidubce.com"; // SMS服务域名,可根据环境选择具体域名
String accessKeyId = "615c2ed1777777777777777770"; // 发送账号安全认证的Access Key ID
String secretAccessKy = "a6e71bcfb3b8888888888888b219755d"; // 发送账号安全认证的Secret Access Key
// ak、sk等config
SmsClientConfiguration config = new SmsClientConfiguration();
config.setCredentials(new DefaultBceCredentials(accessKeyId, secretAccessKy));
config.setEndpoint(endPoint);
config.setProxyHost("127.0.0.1");
config.setProxyPort(8888);
// 实例化发送客户端
SmsClient smsClient = new SmsClient(config);
// 定义请求参数
String invokeId = "rMi6adsf-CmVg-tKmu"; // 发送使用签名的调用ID
String phoneNumber = "18888888889"; // 要发送的手机号码(只能填写一个手机号)
phoneNumber = args[0];
String templateCode = "smsTpl:e7476122a1c24e3712312304ae906"; // 本次发送使用的模板Code
Map<String, String> vars =
new HashMap<String, String>(); // 若模板内容为:您的验证码是${code},在${time}分钟内输入有效
String content = args[1];
vars.put("code", content);
// vars.put("time", "30");
//实例化请求对象
SendMessageV2Request request = new SendMessageV2Request();
request.withInvokeId(invokeId)
.withPhoneNumber(phoneNumber)
.withTemplateCode(templateCode)
.withContentVar(vars);
// 发送请求
SendMessageV2Response response = smsClient.sendMessage(request);
// 解析请求响应 response.isSuccess()为true 表示成功
if (response != null && response.isSuccess()) {
// submit success
} else {
// fail
}
}
}
这些代码是直接拷贝的百度的例子,运行成功。Java代码本地的运行成功,让我对于采用百度云SMS有了初步的信息。
最坏的一种情况是,我用java的servelet封装一下,PHP通过http请求java,java再去请求百度的SMS接口。这个我是会的。所以有了一个保底的,也就不怕了。
研究百度的API,将他们的API相关文档阅读了一遍,觉得做的不错。我也很想体验一下,通过底层代码,直接调用他们的API的方式。百度的加密措施,做的还是不错的。体现了他们的技术性。
根据他们的官方提示:
https://cloud.baidu.com/doc/Reference/AuthenticationMechanism.html
然后找到他们的PHP的加密示例代码:
https://cloud.baidu.com/doc/Reference/AuthenticationMechanism.html#Sample.20Code
从他们代码的写法上面来看,这是他们的PHP SDK里面的签名代码,拿了过来。里面的示例程序,调用的bj.bcebos.com。我想代码应该是成功的。
我下载到本地,不断的修改代码。完全改造为SMS的,然后生成加密的签名。生成了,可以工作。
我将签名过后的信息,放到header里面,POST过去,以json的形式,这里用的PHP的CURL。经过多次的测试,我确信,传过去的数据,该传的都传了。
百度返回过来的还是401,认证失败。为了保证数据的正确性,我试用了Fiddler进行监控:
主要是查看我Post的数据对不对,调试,调试再调试,头都大了。就是不行。能想到的都想到了。
最后,我将java程序的执行,也交给Fiddler进行代理,然后比较PHP,java里面的异同。得到类似于下面的数据,我给的这个数据只是一个示例,java的和这个结构一样:
POST http://sms.bj.baidubce.com/bce/v2/message HTTP/1.1
Accept: */*
Connection: Keep-Alive
Content-Type: application/json
Host: sms.bj.baidubce.com
x-bce-date: 2016-10-22T10:44:55Z
Content-Length: 150
x-bce-content-sha256: 1996b5868b91c104f25ea6d76ce4a0c4d499df2c2de784085907b7ce74b43d55
Authorization: bce-auth-v1/61232ed1071231238580f9123140/2016-10-22T10:44:55Z/1800/host/cc50e7cb690a5a05642e0238921e6bcd4b23f1d7c39f9384fa7ce0c3a255de3f
Accept-Encoding: gzip,deflate
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9) Gecko/2008052906 Firefox/3.0
Date: Sat, 22 Oct 2016 10:44:55 GMT
{"invokeId":"rMasdf-CmVg-tKmu","phoneNumber":"18588882049","templateCode":"smsTpl:e7412312337b3b0de19d04ae906","contentVar":{"code":"abc1234"}}
没有发现什么异常。
我最后想到了一个办法,就是把PHP的参数,和java保持一模一样,然后比较两者直接的加密过后的数据是否一致:
当我将两者之间的加密前的数据逐一保持一致的时候,我发现了百度官方PHP示例代码里面竟然有bug。有兴趣的可以看我下面的代码,里面包含很多的调试信息。具体的调试信息就不说了。Bug是这样的:
1,百度的HEADERS_TO_SIGN有一个默认值,百度以前早期的程序,比如BOS直接使用默认值就行了。所以不会发现bug
2,百度后期的一些产品,比如SMS,就需要自己去设置这个HEADERS_TO_SIGN,当我们自己去设置这个参数的时候,百度的加密程序,就不正常了。
百度作为这么大的一个公司,推出了很多的云服务,而且PHP是一个中小站长喜欢的语言。这么长的时间内,很多服务都没有PHP的SDK版本,官方没有,而且没有第三方的。我想,不是没有PHP程序员,尝试过这样的事情,而是尝试了,失败了。对于百度内部的程序员来说,如果熟悉他们的接口,为一个服务封装一个PHP的SDK供大家调用,最多也就花上一天的时间。但是,估计有人写过,但是遇到了同样的问题;对于外部第三方的程序员,应该也有人写过类似的接口,估计花了不少的时间,也没有搞定。这样一个bug,也不知道造成了百度多少个潜在客户的流失,浪费了多少个程序员的开发时间。
百度把自己的PHP代码,封装成BaiduBce.phar文件,也非常的不人道。无法看到源代码,无法进行调试。百度的接口程序,本来就是给人调用的,竟然保留所有的权利,仅以apache协议开源,怎么也得以GPL开源啊。
附件是调试过的代码,里面有大量的调试信息。
namespace BaiduBce\Auth;
class SignOption
{
const EXPIRATION_IN_SECONDS = 'expirationInSeconds';
const HEADERS_TO_SIGN = 'headersToSign';
const TIMESTAMP = 'timestamp';
const DEFAULT_EXPIRATION_IN_SECONDS = 1800;
const MIN_EXPIRATION_IN_SECONDS = 300;
const MAX_EXPIRATION_IN_SECONDS = 129600;
}
class HttpUtil
{
// 根据RFC 3986,除了:
// 1.大小写英文字符
// 2.阿拉伯数字
// 3.点'.'、波浪线'~'、减号'-'以及下划线'_'
// 以外都要编码
public static $PERCENT_ENCODED_STRINGS;
//填充编码数组
public static function __init()
{
HttpUtil::$PERCENT_ENCODED_STRINGS = array();
for ($i = 0; $i < 256; ++$i) {
HttpUtil::$PERCENT_ENCODED_STRINGS[$i] = sprintf("%%%02X", $i);
}
//a-z不编码
foreach (range('a', 'z') as $ch) {
HttpUtil::$PERCENT_ENCODED_STRINGS[ord($ch)] = $ch;
}
//A-Z不编码
foreach (range('A', 'Z') as $ch) {
HttpUtil::$PERCENT_ENCODED_STRINGS[ord($ch)] = $ch;
}
//0-9不编码
foreach (range('0', '9') as $ch) {
HttpUtil::$PERCENT_ENCODED_STRINGS[ord($ch)] = $ch;
}
//以下4个字符不编码
HttpUtil::$PERCENT_ENCODED_STRINGS[ord('-')] = '-';
HttpUtil::$PERCENT_ENCODED_STRINGS[ord('.')] = '.';
HttpUtil::$PERCENT_ENCODED_STRINGS[ord('_')] = '_';
HttpUtil::$PERCENT_ENCODED_STRINGS[ord('~')] = '~';
}
//在uri编码中不能对'/'编码
public static function urlEncodeExceptSlash($path)
{
return str_replace("%2F", "/", HttpUtil::urlEncode($path));
}
//使用编码数组编码
public static function urlEncode($value)
{
$result = '';
for ($i = 0; $i < strlen($value); ++$i) {
$result .= HttpUtil::$PERCENT_ENCODED_STRINGS[ord($value[$i])];
}
return $result;
}
//生成标准化QueryString
public static function getCanonicalQueryString(array $parameters)
{
//没有参数,直接返回空串
if (count($parameters) == 0) {
return '';
}
$parameterStrings = array();
foreach ($parameters as $k => $v) {
//跳过Authorization字段
if (strcasecmp('Authorization', $k) == 0) {
continue;
}
if (!isset($k)) {
throw new \InvalidArgumentException(
"parameter key should not be null"
);
}
if (isset($v)) {
//对于有值的,编码后放在=号两边
$parameterStrings[] = HttpUtil::urlEncode($k)
. '=' . HttpUtil::urlEncode((string) $v);
} else {
//对于没有值的,只将key编码后放在=号的左边,右边留空
$parameterStrings[] = HttpUtil::urlEncode($k) . '=';
}
}
//按照字典序排序
sort($parameterStrings);
//使用'&'符号连接它们
return implode('&', $parameterStrings);
}
//生成标准化uri
public static function getCanonicalURIPath($path)
{
//空路径设置为'/'
if (empty($path)) {
return '/';
} else {
//所有的uri必须以'/'开头
if ($path[0] == '/') {
return HttpUtil::urlEncodeExceptSlash($path);
} else {
return '/' . HttpUtil::urlEncodeExceptSlash($path);
}
}
}
//生成标准化http请求头串
public static function getCanonicalHeaders($headers)
{
//print 'getCanonicalHeaders:'.var_export($headers, true);
//如果没有headers,则返回空串
if (count($headers) == 0) {
return '';
}
$headerStrings = array();
foreach ($headers as $k => $v) {
//跳过key为null的
if ($k === null) {
continue;
}
//如果value为null,则赋值为空串
if ($v === null) {
$v = '';
}
//trim后再encode,之后使用':'号连接起来
$headerStrings[] = HttpUtil::urlEncode(strtolower(trim($k))) . ':' . HttpUtil::urlEncode(trim($v));
}
//字典序排序
sort($headerStrings);
//用'\n'把它们连接起来
return implode("\n", $headerStrings);
}
}
HttpUtil::__init();
class SampleSigner
{
const BCE_AUTH_VERSION = "bce-auth-v1";
const BCE_PREFIX = 'x-bce-';
//不指定headersToSign情况下,默认签名http头,包括:
// 1.host
// 2.content-length
// 3.content-type
// 4.content-md5
public static $defaultHeadersToSign;
public static function __init()
{
SampleSigner::$defaultHeadersToSign = array(
"host",
"content-length",
"content-type",
"content-md5",
);
}
//签名函数
public function sign(
array $credentials,
$httpMethod,
$path,
$headers,
$params,
$options = array()
) {
//设定签名有效时间
if (!isset($options[SignOption::EXPIRATION_IN_SECONDS])) {
//默认值1800秒
$expirationInSeconds = SignOption::DEFAULT_EXPIRATION_IN_SECONDS;
} else {
$expirationInSeconds = $options[SignOption::EXPIRATION_IN_SECONDS];
}
//解析ak sk
$accessKeyId = $credentials['ak'];
$secretAccessKey = $credentials['sk'];
//设定时间戳,注意:如果自行指定时间戳需要为UTC时间
if (!isset($options[SignOption::TIMESTAMP])) {
//默认值当前时间
$timestamp = new \DateTime();
} else {
$timestamp = $options[SignOption::TIMESTAMP];
}
$timestamp->setTimezone(new \DateTimeZone("GMT"));
//生成authString
$authString = SampleSigner::BCE_AUTH_VERSION . '/' . $accessKeyId . '/'
. $timestamp->format("Y-m-d\TH:i:s\Z") . '/' . $expirationInSeconds;
//使用sk和authString生成signKey
$signingKey = hash_hmac('sha256', $authString, $secretAccessKey);
//生成标准化URI
$canonicalURI = HttpUtil::getCanonicalURIPath($path);
//生成标准化QueryString
$canonicalQueryString = HttpUtil::getCanonicalQueryString($params);
//填充headersToSign,也就是指明哪些header参与签名
$headersToSign = null;
if (isset($options[SignOption::HEADERS_TO_SIGN])) {
$headersToSign = $options[SignOption::HEADERS_TO_SIGN];
}
//生成标准化header
$canonicalHeader = HttpUtil::getCanonicalHeaders(
SampleSigner::getHeadersToSign($headers, $headersToSign)
);
//整理headersToSign,以';'号连接
$signedHeaders = '';
if ($headersToSign !== null) {
$signedHeaders = strtolower(
//trim(implode(";", array_keys($headersToSign)))
trim(implode(";", $headersToSign))
);
}
//组成标准请求串
$canonicalRequest = "$httpMethod\n$canonicalURI\n"
. "$canonicalQueryString\n$canonicalHeader";
//$canonicalRequest = "$httpMethod\n$canonicalURI\n\nhost:sms.bj.baidubce.com";
//print var_export($canonicalRequest, true);
//使用signKey和标准请求串完成签名
$signature = hash_hmac('sha256', $canonicalRequest, $signingKey);
//组成最终签名串
$authorizationHeader = "$authString/$signedHeaders/$signature";
return $authorizationHeader;
}
//根据headsToSign过滤应该参与签名的header
public static function getHeadersToSign($headers, $headersToSign)
{
//print 'headers:' .var_export($headers, true);
//print 'headersToSign:' .var_export($headersToSign, true);
//value被trim后为空串的header不参与签名
$filter_empty = function($v) {
return trim((string) $v) !== '';
};
$headers = array_filter($headers, $filter_empty);
//处理headers的key:去掉前后的空白并转化成小写
$trim_and_lower = function($str){
return strtolower(trim($str));
};
$temp = array();
$process_keys = function($k, $v) use(&$temp, $trim_and_lower) {
$temp[$trim_and_lower($k)] = $v;
};
array_map($process_keys, array_keys($headers), $headers);
//array_map($process_keys, array_keys($headersToSign), $headersToSign);
$headers = $temp;
//print 'headers123:' .var_export($headers, true);
//取出headers的key以备用
$header_keys = array_keys($headers);
// print 'header_keys:' .var_export($header_keys, true);
$filtered_keys = null;
if ($headersToSign !== null) {
//如果有headersToSign,则根据headersToSign过滤
//预处理headersToSign:去掉前后的空白并转化成小写
$headersToSign = array_map($trim_and_lower, $headersToSign);
//print 'headersToSign4321:' .var_export($headersToSign, true);
//只选取在headersToSign里面的header
$filtered_keys = array_intersect_key($header_keys, $headersToSign);
} else {
//如果没有headersToSign,则根据默认规则来选取headers
$filter_by_default = function($k) {
return SampleSigner::isDefaultHeaderToSign($k);
};
$filtered_keys = array_filter($header_keys, $filter_by_default);
}
//print 'headersToSign123:' .var_export($headersToSign, true);
//print 'filtered_keys123:' .var_export($filtered_keys, true);
//print 'headers4321:' .var_export($headers, true);
//$filtered_keys = array('host');
//返回需要参与签名的header
return array_intersect_key($headers, array_flip($filtered_keys));
}
//检查header是不是默认参加签名的:
//1.是host、content-type、content-md5、content-length之一
//2.以x-bce开头
public static function isDefaultHeaderToSign($header)
{
$header = strtolower(trim($header));
if (in_array($header, SampleSigner::$defaultHeadersToSign)) {
return true;
}
return substr_compare($header, SampleSigner::BCE_PREFIX, 0, strlen(SampleSigner::BCE_PREFIX)) == 0;
}
}
SampleSigner::__init();
//header('Content-Type:text/html;charset=utf-8');
$data = array(
"invokeId" => "rMiasdaB-Casg-tKmu",
"phoneNumber" => "18888888849",
"templateCode" => "smsTpl:e74761231231230de19d04ae906",
"contentVar" => array(
"code" => "abc1234",
),
);
$json_data = json_encode($data);
//签名示范代码
$signer = new SampleSigner();
$credentials = array("ak" => "615c2ed17777777777a10740","sk" => "a6e71bcfb388888888888819755d");
$httpMethod = "POST";
$path = "/bce/v2/message";
//$params = array("partNumber" => 9, "uploadId" => "VXBsb2FkIElpZS5tMnRzIHVwbG9hZA");
$params = array();
$timestamp = new \DateTime();
$timestamp->setTimezone(new \DateTimeZone("GMT"));
//$DateTimeZone = timezone_open ( 'Asia/Chongqing' );
// $timestamp->setTimezone( $DateTimeZone );
//$time_int = strtotime("2016-10-21T09:55:22Z");
//$timestamp->setTimestamp($time_int);
$datetime = $timestamp->format("Y-m-d\TH:i:s\Z");
$datetime_gmt = $timestamp->format("D, d M Y H:i:s T");
//echo $datetime;
/*
$headers = array("Host" => "sms.bj.baidubce.com",
"Content-Length" => strlen($json_data),
"Content-Type" => "application/json",
"x-bce-date" => $datetime);
*/
$headers = array("Host" => "sms.bj.baidubce.com");
$str_sha256 = hash('sha256', $json_data);
$headers['x-bce-content-sha256'] = $str_sha256;
$headers['Content-Length'] = strlen($json_data);
$headers['Content-Type'] = "application/json";
$headers['x-bce-date'] = $datetime;
//$timestamp->setTimestamp(1430123029);
$options = array(SignOption::TIMESTAMP => $timestamp,
//SignOption::EXPIRATION_IN_SECONDS => 1800,
//SignOption::HEADERS_TO_SIGN =>$headers,
/*
SignOption::HEADERS_TO_SIGN =>array(
'host',
//'x-bce-date',
//'Content-Length',
'x-bce-content-sha256',
),
*/
);
$ret = $signer->sign($credentials, $httpMethod, $path, $headers, $params, $options);
//print $ret;
//print var_export($ret, true);
$headers['Authorization'] = $ret;
//print '\n' .$str_sha256;
$headers_curl = array(
'Content-Type:application/json',
'Host:sms.bj.baidubce.com',
'x-bce-date:' . $datetime,
'Content-Length:' . strlen($json_data),
'x-bce-content-sha256:' . $str_sha256,
'Authorization:' . $ret,
"Accept-Encoding: gzip,deflate",
'User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9) Gecko/2008052906 Firefox/3.0',
'Date:' .$datetime_gmt,
);
$url = 'http://sms.bj.baidubce.com/bce/v2/message';
//$url = '/bce/v2/message';
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, FALSE);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
//curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
//curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_POSTFIELDS, $json_data);
curl_setopt($curl, CURLOPT_HEADER, 1);
curl_setopt($curl,CURLOPT_PROXY,'127.0.0.1:8888');//设置代理服务器
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers_curl);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
$result = curl_exec($curl);
$errorno = curl_errno($curl);
curl_close($curl);
print var_export($result, true);