最近因业务需要,需要对接同城派送,
发现微信小程序服务端api签名的相关坑(文档),
这里记录一下,便于后期开发与使用。
在开发之前,我们先完善相关配置
// 完整配置
private static $config = array(
'appid' => 'xxxxxx',
'aes_sn' => 'xxxxxxxxxxxx',
'aes_key' => 'xxxxxxxxxxxx',
'rsa_sn' => 'xxxxxxxxxxxx',
'public_key' => './cert/rsa_public_key.txt',
'private_key' => './cert/rsa_private_key.txt',
'cert_sn' => 'xxxxxxxxxxxx',
'cert_key' => './cert/cert_key.cer',
);
随机生成对称秘钥,非对称秘钥,下载私钥
获取相关证书编号,下载微信平台证书
注意相关字段使用base64编码
$nonce = rtrim(base64_encode(random_bytes(16)), '='); // 16位随机字符
$addReq = ["_n" => $nonce, "_appid" => $config['appid'], "_timestamp" => $time]; // 添加字段
$realReq = array_merge($addReq, $req);
$realReq = json_encode($realReq);
//额外参数
$message = $url . "|" . $config['appid'] . "|" . $time . "|" . $config['aes_sn'];
$iv = random_bytes(12); // 12位随机字符
// 数据加密处理
$cipher = openssl_encrypt($realReq, "aes-256-gcm", base64_decode($config['aes_key']), OPENSSL_RAW_DATA, $iv, $tag, $message);
$iv = base64_encode($iv);
$data = base64_encode($cipher);
$authTag = base64_encode($tag);
$reqData = ["iv" => $iv, "data" => $data, "authtag" => $authTag]; => $authTag];
签名使用PSS填充方式,需要指定salt长度为32。(PSS签名中包含随机因子,因此每次签名结果都会变化)
因php本身不支持RSA-PSS填充方式签名,
因此需要安装第三方加密扩展包 phpseclib/phpseclib
composer require phpseclib/phpseclib
// 获取签名
$reqData = json_encode($reqData);
$payload = $url . "\n" . $config["appid"] . "\n" . $time . "\n" . $reqData; // 拼接字符串用双引号
// 使用phpseclib3\Crypt\RSA(phpseclib V3)版本生成签名
$signature = RSA::loadPrivateKey($config['private_key'])
->withPadding(RSA::SIGNATURE_PSS)
->withHash('sha256')
->withMGFHash('sha256')
->sign($payload);
$signature = base64_encode($signature);
完整示例代码
<?php
/**
* @Author: [FENG] <1161634940@qq.com>
* @Date: 2023-12-11T17:07:18+08:00
* @Last Modified by: [FENG] <1161634940@qq.com>
* @Last Modified time: 2023-12-12 17:28:24
*/
namespace app\common\logic;
use app\api\controller\Xcx;
use phpseclib3\Crypt\RSA;
use fengkui\Supports\Http;
use think\Cache;
use Exception;
/**
* 微信小程序服务端api签名
*/
class Wechat extends Base
{
// 完整配置
private static $config = array(
'appid' => 'xxxxxx',
'aes_sn' => 'xxxxxxxxxxxx',
'aes_key' => 'xxxxxxxxxxxx',
'rsa_sn' => 'xxxxxxxxxxxx',
'public_key' => './cert/rsa_public_key.txt',
'private_key' => './cert/rsa_private_key.txt',
'cert_sn' => 'xxxxxxxxxxxx',
'cert_key' => './cert/cert_key.cer',
);
/**
* [__construct 构造函数]
* @param [type] $config [传递小程序API相关配置]
*/
public function __construct($config=NULL){
$config && self::$config = array_merge(self::$config, $config);
self::$config['public_key'] = file_get_contents(self::$config['public_key']);
self::$config['private_key'] = file_get_contents(self::$config['private_key']);
self::$config['cert_key'] = file_get_contents(self::$config['cert_key']);
}
// 封装curl加密请求
public function request($url, $req)
{
$config = self::$config;
$time = time();
$nonce = rtrim(base64_encode(random_bytes(16)), '='); // 16位随机字符
$addReq = ["_n" => $nonce, "_appid" => $config['appid'], "_timestamp" => $time]; // 添加字段
$realReq = array_merge($addReq, $req);
$realReq = json_encode($realReq);
//额外参数
$message = $url . "|" . $config['appid'] . "|" . $time . "|" . $config['aes_sn'];
$iv = random_bytes(12); // 12位随机字符
// 数据加密处理
$cipher = openssl_encrypt($realReq, "aes-256-gcm", base64_decode($config['aes_key']), OPENSSL_RAW_DATA, $iv, $tag, $message);
$iv = base64_encode($iv);
$data = base64_encode($cipher);
$authTag = base64_encode($tag);
$reqData = ["iv" => $iv, "data" => $data, "authtag" => $authTag];
// 获取签名
$reqData = json_encode($reqData);
$payload = $url . "\n" . $config["appid"] . "\n" . $time . "\n" . $reqData; // 拼接字符串用双引号
// 使用phpseclib3\Crypt\RSA(phpseclib V3)版本生成签名
$signature = RSA::loadPrivateKey($config['private_key'])
->withPadding(RSA::SIGNATURE_PSS)
->withHash('sha256')
->withMGFHash('sha256')
->sign($payload);
$signature = base64_encode($signature);
$header = [
'Content-Type:application/json;charset=utf-8',
'Accept:application/json',
'Wechatmp-Appid:' . $config['appid'],
'Wechatmp-TimeStamp:' . $time,
'Wechatmp-Signature:' . $signature
];
$accessToken = $this->getAccessToken(); // url地址拼接token
$urls = $url . "?access_token=" . $accessToken;
// 封装的curl请求 httpRequest($url, $method="GET", $params='', $headers=[], $pem=[], $debug = false, $timeout = 60)
$response = Http::httpRequest($urls, "POST", $reqData, $header, [], true);
$result = json_decode($response['response'], true);
// 请求平台报错
if (isset($result['errcode'])) {
throw new \Exception("[" . $result['errcode'] . "] " . $result['errmsg']);
}
// 响应参数验签
$vertify = $this->verifySign($url, $response);
if (!$vertify) {
throw new \Exception("微信响应接口,验证签名失败");
}
// 参数解密
return $this->decryptToString($url, $response['response_header']['Wechatmp-TimeStamp'], $result);
}
// 获取access_token
public static function getAccessToken($type = 'wechat')
{
$access_token = Cache::get("access_token.$type");
if (!$access_token) {
// Cache::set("access_token.$type", '');
Xcx::getAccessToken($type);
$access_token = Cache::get("access_token.$type");
}
return $access_token;
}
// 验证签名
private function verifySign($url, $response)
{
$config = self::$config;
$headers = $response['response_header'];
$reTime = $headers['Wechatmp-TimeStamp'];
if ($config['appid'] != $headers['Wechatmp-Appid'] || time() - $reTime > 300){
throw new \ErrorException('返回值安全字段校验失败');
}
if ($config['cert_sn'] == $headers['Wechatmp-Serial']) {
$signature = $headers['Wechatmp-Signature'];
} elseif (isset($headers['Wechatmp-Serial-Deprecated']) && $config['cert_sn'] == $headers['Wechatmp-Serial-Deprecated']) {
$signature = $headers['Wechatmp-Signature-Deprecated'];
} else {
throw new \ErrorException('返回值sn不匹配');
}
$reData = $response['response'];
$payload = $url . "\n" . $config["appid"] . "\n" . $reTime . "\n" . $reData;
$payload = utf8_encode($payload);
$signature = base64_decode($signature);
$pkey = openssl_pkey_get_public($config['cert_key']);
$keyData = openssl_pkey_get_details($pkey);
$public_key = str_replace('-----BEGIN PUBLIC KEY-----', '', $keyData['key']);
$public_key = trim(str_replace('-----END PUBLIC KEY-----', '', $public_key));
$recode = RSA::loadPublicKey($public_key)
->withPadding(RSA::SIGNATURE_PSS)
->withHash('sha256')
->withMGFHash('sha256')
->verify($payload, $signature);
return $recode;
}
// 解析加密信息
private function decryptToString($url, $ts, $result)
{
$config = self::$config;
$message = $url . '|' . $config['appid'] . '|' . $ts . '|' . $config['aes_sn'];
$key = base64_decode($config['aes_key']);
$iv = base64_decode($result['iv']);
$data = base64_decode($result['data']);
$authTag = base64_decode($result['authtag']);
$result = openssl_decrypt($data, "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, $authTag, $message);
if (!$result) {
throw new Exception('加密字符串使用 aes-256-gcm 解析失败');
}
return json_decode($result, true) ?: '';
}
}
提供一份nodejs代码,便于开发进行调试测试
const crypto = require("crypto")
const fs = require('fs')
const request = require('request')
const querystring = require('querystring')
// 仅做演示,敏感信息请勿硬编码
function getCtx() {
const ctx = {
local_appid: "xxxxxx",
local_secret: "xxxxxxxxxxxx",
local_sym_sn: "xxxxxxxxxxxx",
local_sym_key: "xxxxxxxxxxxx",
local_sn: "xxxxxxxxxxxx",
local_private_key: "",
local_public_key: "",
local_cert_sn: "xxxxxxxxxxxx",
local_certificate: "",
url_path: "https://api.weixin.qq.com/cgi-bin/express/intracity/querystore",
}
ctx.local_public_key = fs.readFileSync('./cert/rsa_public_key.txt', 'utf8');
ctx.local_private_key = fs.readFileSync('./cert/rsa_private_key.txt', 'utf8');
ctx.local_certificate = fs.readFileSync('./cert/cert_key.cer', 'utf8');
return ctx
}
function getNewReq(ctx, req) {
const { local_sym_key, local_sym_sn, local_appid, url_path } = ctx // 开发者本地信息
const local_ts = Math.floor(Date.now() / 1000) //加密签名使用的统一时间戳
// const local_ts = 1703318009
const nonce = crypto.randomBytes(16).toString('base64').replace(/=/g, '')
// const nonce = '2UQFUeMOPON0r+38mq0NZQ'
const reqex = {
_n: nonce,
_appid: local_appid,
_timestamp: local_ts
}
const real_req = Object.assign({}, reqex, req) // 生成并添加安全校验字段
const plaintext = JSON.stringify(real_req)
const aad = `${url_path}|${local_appid}|${local_ts}|${local_sym_sn}`
const real_key = Buffer.from(local_sym_key, "base64")
const real_iv = crypto.randomBytes(12)
// const real_iv = 'GH34oiMIrulS2r6T'
const real_aad = Buffer.from(aad, "utf-8")
const real_plaintext = Buffer.from(plaintext, "utf-8")
const cipher = crypto.createCipheriv("aes-256-gcm", real_key, real_iv)
cipher.setAAD(real_aad)
const cipher_update = cipher.update(real_plaintext)
const cipher_final = cipher.final()
const real_ciphertext = Buffer.concat([cipher_update, cipher_final])
const real_authTag = cipher.getAuthTag()
const iv = real_iv.toString("base64")
const data = real_ciphertext.toString("base64")
const authtag = real_authTag.toString("base64")
const req_data = {
iv,
data,
authtag,
}
const new_req = {
req_ts: local_ts,
// req_data: JSON.stringify(req_data)
req_data: req_data
}
return new_req
}
function getSignature(ctx, req) {
const { local_private_key, local_sn, local_appid, url_path } = ctx // 开发者本地信息
var { req_ts, req_data } = req // 待请求API数据
var req_data = JSON.stringify(req_data)
const payload = `${url_path}\n${local_appid}\n${req_ts}\n${req_data}`
// console.log(payload);
const data_buffer = Buffer.from(payload, 'utf-8')
// console.log(payload);
const key_obj = {
key: local_private_key,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST // salt长度,需与SHA256结果长度(32)一致
}
const sig_buffer = ss_buffer = crypto.sign(
'RSA-SHA256',
data_buffer,
key_obj
)
const sig = sig_buffer.toString('base64')
// 最终请求头字段
return {
"Content-type": "application/json;charset=utf-8",
"Accept": "application/json",
"Wechatmp-Appid": local_appid,
"Wechatmp-TimeStamp": req_ts,
"Wechatmp-Signature": sig,
}
}
function querystore(access_token) {
const options = {
url: 'https://api.weixin.qq.com/cgi-bin/express/intracity/querystore?access_token=' + access_token,
// url: 'https://api.weixin.qq.com/wxa/getuserriskrank?access_token=' + access_token,
headers: header,
json: true,
body: req.req_data,
};
request.post(options, (error, response, body) => {
console.log(body);
// var headers = response.headers
// resp = {
// resp_ts: headers['wechatmp-timestamp'],
// resp_data: body
// }
// real_resp = getRealResp(ctx, resp);
// console.log(real_resp);
});
}
function getRid(access_token, rid = '') {
const options = {
url: 'https://api.weixin.qq.com/cgi-bin/openapi/rid/get?access_token=' + access_token,
json: true,
body: {
rid : rid
},
};
request.post(options, (error, response, body) => {
console.log(body);
});
}
function getAccessToken(ctx, rid = false) {
const { local_appid, local_secret } = ctx // 开发者本地信息
var getData = querystring.stringify({
grant_type : 'client_credential',
appid : local_appid,
secret : local_secret,
});
const options = {
url: 'https://api.weixin.qq.com/cgi-bin/token?' + getData,
json: true,
};
request.get(options, (error, response, body) => {
var access_token = body.access_token
if (rid) {
getRid(access_token, rid)
} else {
querystore(access_token)
}
});
}
function getRealResp(ctx, resp) {
const { local_sym_key, local_sym_sn, local_appid, url_path } = ctx // 开发者本地信息
const { resp_ts, resp_data } = resp // API响应数据,解密只需要响应头时间戳与响应数据
const { iv, data, authtag } = resp_data
const aad = `${url_path}|${local_appid}|${resp_ts}|${local_sym_sn}`
const real_aad = Buffer.from(aad, "utf-8")
const real_key = Buffer.from(local_sym_key, "base64")
const real_iv = Buffer.from(iv, "base64")
const real_data = Buffer.from(data, "base64")
const real_authtag = Buffer.from(authtag, "base64")
const decipher = crypto.createDecipheriv("aes-256-gcm", real_key, real_iv)
decipher.setAAD(real_aad)
decipher.setAuthTag(real_authtag)
let decipher_update = decipher.update(real_data)
let decipher_final
try {
decipher_final = decipher.final()
} catch (error) {
console.error("auth tag验证失败")
return {}
}
const real_deciphertext = Buffer.concat([decipher_update, decipher_final])
const deciphertext = real_deciphertext.toString("utf-8")
const real_resp = JSON.parse(deciphertext)
const local_ts = Math.floor(Date.now() / 1000)
if (
// 安全检查,根据业务实际需求判断
real_resp["_appid"] != local_appid || // appid不匹配
real_resp["_timestamp"] != resp_ts || // timestamp与Wechatmp-TimeStamp不匹配
local_ts - real_resp["_timestamp"] > 300 // 响应数据的时候与当前时间超过5分钟
) {
console.error("安全字段校验失败")
return {}
}
return real_resp
}
function checkLocalSignature(ctx, req, sign)
{
const { local_public_key, local_sn, local_appid, url_path } = ctx // 开发者本地信息
var { req_ts, req_data } = req // 待请求API数据
var req_data = JSON.stringify(req_data)
const payload = `${url_path}\n${local_appid}\n${req_ts}\n${req_data}`
const data_buffer = Buffer.from(payload, 'utf-8')
const sig_buffer = Buffer.from(sign, 'base64')
const key_obj = {
key: local_public_key,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST // salt长度,需与SHA256结果长度(32)一致
}
const result = crypto.verify(
'RSA-SHA256',
data_buffer,
key_obj,
sig_buffer
)
return result
}
const ctx = getCtx()
var req = {
wx_store_id: "",
out_store_id: "",
}
var req = getNewReq(ctx, req)
console.log(req);
const header = getSignature(ctx, req)
// console.log(header);
getAccessToken(ctx);
// console.log();
return false;
// var req = {
// req_ts: 1703411681,
// req_data: {
// iv: "xxxxxx",
// data: "xxxxxx",
// authtag: "xxxxxx"
// }
// }
// var sign = 'xxxxxx'
// res = checkLocalSignature(ctx, req, sign)
// console.log(res)
本文参考文档:
1、微信API加密,排坑整理,PHP版本
2、同城配送排坑贴,打工人永不加班!
3、Verify RSA-PSS With phpseclib
------------------------------------------------分割线------------------------------------------------
最新更新,已将request请求,集成到我自己的扩展包中
composer require fengkui/xcx
使用方法
<?php
require_once('./vendor/autoload.php');
$config = []; // 如上配置
$access_token = '';
$url = 'https://api.weixin.qq.com/cgi-bin/express/intracity/querystore?access_token=' . $access_token;
// access_token可不传,默认扩展包自动获取(注意access_token使用稳定版接口调用获取)
$params = [ // 请求参数
"wx_store_id" => '4000000000000******',
];
// 查询门店(数据的加密签名验签,扩展包中已处理)
$re = (new \fengkui\Xcx\Wechat($config))->request($url, $params);
本文为冯奎原创文章,转载无需和我联系,但请注明来自冯奎博客fengkui.net
最新评论