评论

微信小程序php服务端api签名

对接同城派送,发现微信小程序服务端api签名的相关坑,接口对接中发现数据加密签名生成中的问题,这里记录一下,便于后期开发与使用,注意相关字段使用base64编码,相关参数传递完整,`签名使用PSS填充

因业务需要,需要对接同城派送,
发现微信小程序服务端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填充方式签名,

composer require phpseclib/phpseclib

因此需要安装第三方加密扩展包 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 = ''; // 注意使用最新版token获取接口
$url = 'https://api.weixin.qq.com/cgi-bin/express/intracity/querystore?access_token=' . $access_token;
$params = [ // 请求参数
    "wx_store_id" => '4000000000000******',
];
 // 查询门店(数据的加密签名验签,扩展包中已处理)
$re = (new \fengkui\Xcx\Wechat($config))->request($url, $params);
最后一次编辑于  04-29  
点赞 2
收藏
评论

4 个评论

  • 拱兴龙
    拱兴龙
    09-12

    不小心开启这个 安全API功能,怎么关闭,或者不启用这个功能

    09-12
    赞同
    回复
  • --
    --
    04-28

    老哥能帮我看看为啥报错40234吗,修改了一下代码不知道哪里出问题了,您那边有空的话我私信您

    04-28
    赞同
    回复 1
    • 安之若素~枫
      安之若素~枫
      04-29
      改了哪里?可以发我看看,私聊我也可以
      04-29
      回复
  • 小新
    小新
    04-03

    老哥,能帮忙看下我这个签名哪里有问题吗?万分感谢

    https://developers.weixin.qq.com/community/develop/doc/0002ce56284780381d51c794663400

    04-03
    赞同
    回复 1
    • 安之若素~枫
      安之若素~枫
      04-10
      试了一下你的签名和加密都没有问题,使用我自己的http请求,和guzzlehttp的    (new \GuzzleHttp\Client())->request("POST", $url, $data)  请求都是成功的;所以有可能是你的  \HttpClient::request() 的问题
      04-10
      回复
  • sophy🕊🕊
    sophy🕊🕊
    发表于移动端
    2023-12-29
    进去看一下有没有人
    2023-12-29
    赞同
    回复 1
登录 后发表内容