支付宝支付通知回调使用RSA2验签失败如何排查
结论
支付宝异步通知使用 RSA2 验签时,传入的对象不是原始请求报文,也不是整段 urldecode 后的字符串,而是正确解析得到的参数数组。参数值解码后应保持原样,再将数组直接传给 rsaCheckV1()。支付宝 SDK 会自行排除 sign 和 sign_type,按参数名排序并拼接待验签字符串。
当前代码的解析方式大体可行,但需要重点检查两处:
$this->pubilc_key是否写错了。如果实际属性名是$this->public_key,alipayrsaPublicKey可能没有取得有效公钥。- 不要直接验证完整的请求字符串,也不要先对整个请求执行
urldecode(),再拆分参数。
RSA 签名只能用来验签,不能用来解密。密钥工具显示“解密失败”,不代表签名或公钥有问题。测试时应使用工具中的“验签”功能,并选择 SHA256withRSA,也就是支付宝所称的 RSA2。
为什么直接验证请求字符串会失败
支付宝通知的签名原文按以下规则生成:
- 从表单请求中读取全部参数。
- 排除
sign和sign_type。 - 忽略值为空的参数。
- 按参数名进行 ASCII 升序排序。
- 使用解码后的参数值拼接成
key=value&key=value。 - 使用支付宝公钥和
SHA256withRSA验证sign。
所以下面两种写法都不正确:
// 错误:直接对 URL 编码后的原始请求体验签
$aop->rsaCheckV1($poststr, null, 'RSA2');
// 错误:先解码整个请求体,再按 & 拆分
$decoded = urldecode($poststr);
第二种写法的风险更大。参数值本身可能含有编码后的 &、= 或 +。如果先解码整个请求体,这些字符就会和表单分隔符混在一起,改变原有的参数边界。
例如,请求中包含:
gmt_create=2023-03-23+15%3A39%3A53
按照 application/x-www-form-urlencoded 规则解析后,参数值应为:
2023-03-23 15:39:53
这里的 + 表示空格,不是普通加号。签名中的 Base64 加号会编码为 %2B,解析后才恢复成 +。因此,不建议自行解码和拆分整段报文。
推荐的参数解析和验签代码
PHP 正常接收表单回调时,应优先使用 $_POST:
<?php
$aop = new AopClient();
$aop->gatewayUrl = 'https://openapi.alipay.com/gateway.do';
$aop->appId = $this->appid;
$aop->rsaPrivateKey = $this->private_key;
// 确认这里不是 pubilc_key 的拼写错误
$aop->alipayrsaPublicKey = $this->public_key;
$aop->apiVersion = '1.0';
$aop->signType = 'RSA2';
$aop->postCharset = 'UTF-8';
$aop->format = 'json';
$params = $_POST;
if (empty($params)) {
$rawBody = file_get_contents('php://input');
parse_str($rawBody, $params);
}
$signType = !empty($params['sign_type'])
? $params['sign_type']
: 'RSA2';
$verified = $aop->rsaCheckV1($params, null, $signType);
if (!$verified) {
// 记录必要的排查信息,但不要在生产日志中长期暴露完整敏感参数
throw new RuntimeException('支付宝通知验签失败');
}
// 验签成功后,再校验业务参数
if (($params['app_id'] ?? '') !== $this->appid) {
throw new RuntimeException('支付宝通知 app_id 不匹配');
}
if (($params['trade_status'] ?? '') === 'TRADE_SUCCESS') {
// 按 out_trade_no 查询本地订单
// 核对 total_amount、seller_id 等信息
// 使用幂等方式更新订单状态
}
echo 'success';
parse_str() 会按照 PHP 的表单规则处理百分号编码和 +,比手动组合 explode() 与 urldecode() 更稳妥。支付宝使用的是固定字段名,因此 parse_str() 对参数名中点号和空格的特殊处理通常不会造成影响。
如果框架对请求参数做过二次转换,应尽量读取框架提供的原始 POST 参数集合,避免数据经过 JSON 序列化、HTML 转义、类型转换或字符集转换。
保留手工解析时应如何修改
如果确实无法使用 $_POST 或 parse_str(),应先按 & 拆分请求体,再分别解码参数名和参数值:
<?php
$rawBody = file_get_contents('php://input');
$params = [];
foreach (explode('&', $rawBody) as $pair) {
if ($pair === '') {
continue;
}
$item = explode('=', $pair, 2);
$name = urldecode($item[0]);
$value = urldecode($item[1] ?? '');
$params[$name] = $value;
}
$signType = $params['sign_type'] ?? 'RSA2';
$verified = $this->aop->rsaCheckV1(
$params,
null,
$signType
);
这与问题中的写法比较接近。不过,仍建议优先使用 PHP 已经解析好的表单参数,以减少边界条件和编码差异带来的问题。
不要把 urldecode() 改成 rawurldecode()。两者处理 + 的方式不同,而支付宝通知使用的是表单编码规则:
urldecode('2023-03-23+15%3A39%3A53');
// 2023-03-23 15:39:53
rawurldecode('2023-03-23+15%3A39%3A53');
// 2023-03-23+15:39:53
后一种结果会改变签名原文,最终导致验签失败。
公钥配置需要重点检查
示例代码中有一处很可能是拼写错误:
$aop->alipayrsaPublicKey = $this->pubilc_key;
pubilc 很可能应为 public。如果类中没有这个属性,PHP 可能返回 null 或产生相关告警,SDK 随后也就无法加载正确的支付宝公钥。
调用验签前可以先检查公钥配置:
if (empty($this->public_key)) {
throw new RuntimeException('支付宝公钥未配置');
}
$aop->alipayrsaPublicKey = trim($this->public_key);
这里必须配置“支付宝公钥”,不能误用以下内容:
- 应用公钥;
- 应用私钥;
- 支付宝公钥证书的序列号;
- 其他应用对应的支付宝公钥;
- 与当前环境不匹配的公钥,例如生产环境使用沙箱公钥。
在普通公钥模式下,题目中这种去掉 PEM 头尾的 Base64 公钥通常可以直接赋给 alipayrsaPublicKey,但具体行为仍取决于所用 PHP SDK 的版本。如果使用公钥证书模式,则应按照相应 SDK 的证书验签方式配置,不能继续套用普通公钥模式。
如何定位仍然失败的原因
如果传入参数数组后仍然验签失败,可以按下面的顺序检查。
1. 确认回调内容没有被修改
验签必须使用支付宝实际 POST 到回调地址的字段原值。以下操作都可能改变签名原文:
- 对参数执行
trim(); - 删除或合并空格;
- 将金额转换为浮点数;
- 将
0.00转换为0; - 重新格式化 XML、JSON 等字段;
- 对中文重新编码;
- 对参数进行 HTML 转义;
- 将参数序列化后再反序列化;
- 从日志复制报文时引入换行、转义或截断。
例如:
point_amount=0.00
验签前不能转换成:
point_amount=0
所有字段在验签前都应保持字符串形式。
2. 检查 sign 是否完整
使用 2048 位 RSA 密钥时,RSA2 签名经过 Base64 编码后通常较长。需要确认:
sign没有被日志系统截断;%2B已正确恢复为+;%2F已正确恢复为/;%3D%3D已正确恢复为==;- 没有错误保留
+,也没有重复解码并将其变成空格。
正确解析后,题目中的 sign 在数组中应类似:
Bc3EJ7pnfpy6JQ4mQNaIRWqQ0Z5dssi+g2y6r2d...N1FxKA==
如果对整个请求体重复执行 urldecode(),签名中已经恢复的 + 可能再次被转换为空格。
3. 输出 SDK 实际使用的待验签字符串
排查时,可以复制参数数组,删除签名字段后,再调用 SDK 生成签名内容的方法。不同 SDK 版本中相关方法的可见性可能不同,下面的代码只适用于可以调用 getSignContent() 的版本:
$debugParams = $params;
unset($debugParams['sign'], $debugParams['sign_type']);
$signContent = $aop->getSignContent($debugParams);
检查生成的内容时,应关注:
- 参数是否按名称排序;
sign和sign_type是否已经排除;- 时间字段中的空格是否仍然存在;
- 中文是否使用 UTF-8;
- 金额格式是否保持原样;
fund_bill_list是否被重新格式化。
调试时不要自行调整参数顺序,再把拼接后的字符串传给 rsaCheckV1()。这个方法需要接收参数数组。
4. 检查字符集
当前配置为:
$aop->postCharset = 'UTF-8';
这个配置通常没有问题,但还要确认 PHP 收到的 subject=旅游产品 确实是 UTF-8,并且中间件没有将其转换为 GBK、GB2312 或其他字符集。
5. 区分“验签失败”和“解密失败”
RSA2 通知签名的处理方式是:
支付宝私钥签名 → 商户使用支付宝公钥验签
不是:
使用支付宝公钥解密 sign
因此,在密钥工具中选择“RSA 解密”后出现解密失败是正常的。测试时应选择签名验证,并使用以下配置:
算法:SHA256withRSA
公钥:支付宝公钥
签名值:URL 解码后的 sign
原文:排除 sign、sign_type 后排序拼接的字符串
验签成功后还要做业务校验
RSA2 验签成功,只能说明通知内容由对应的支付宝私钥签名,不能据此立即把订单标记为支付成功。还应至少检查:
app_id是否等于当前应用 ID;out_trade_no是否存在;total_amount是否与本地订单金额一致;seller_id是否属于当前收款账户;trade_status是否为预期状态;- 同一通知是否已经处理,确保接口具有幂等性。
全部处理成功后,应输出纯文本:
success
如果没有返回支付宝要求的成功响应,支付宝可能继续重试通知。
正确的处理方式是优先使用 $_POST。必要时,对原始请求体调用一次 parse_str(),再把得到的参数数组传给 rsaCheckV1()。当前案例还应先确认 $this->pubilc_key 的拼写及其实际取值。即使后台配置的公钥正确,只要程序没有将它正确赋给 alipayrsaPublicKey,验签仍会一直失败。
备注:内容仅供参考。
