mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 15:04:32 +08:00
安全: 修复支付回调验签与网关边界
This commit is contained in:
parent
8cef0b65cc
commit
1e1f0d231b
16
README.md
16
README.md
@ -105,6 +105,7 @@ $result = $official->call('cgi-bin/menu/get', [], 'GET');
|
|||||||
|
|
||||||
| 场景 | 写法 |
|
| 场景 | 写法 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 接口 path | 只传相对路径,如 `cgi-bin/user/get`;SDK 会拒绝 `https://...` 或 `//...`。 |
|
||||||
| 微信普通接口需要 `access_token` | 默认自动附加。 |
|
| 微信普通接口需要 `access_token` | 默认自动附加。 |
|
||||||
| 微信授权、登录等不需要 `access_token` 的接口 | 传 `['with_token' => false]`。 |
|
| 微信授权、登录等不需要 `access_token` 的接口 | 传 `['with_token' => false]`。 |
|
||||||
| POST JSON | 默认行为,直接传 `$params`。 |
|
| POST JSON | 默认行为,直接传 `$params`。 |
|
||||||
@ -441,8 +442,13 @@ $refund = $payment->post('v3/refund/domestic/refunds', [
|
|||||||
回调验签与解密:
|
回调验签与解密:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
$rawBody = file_get_contents('php://input') ?: '';
|
||||||
|
$body = json_decode($rawBody, true) ?: [];
|
||||||
|
|
||||||
$data = $payment->post('decrypt_notification', [], [
|
$data = $payment->post('decrypt_notification', [], [
|
||||||
'headers' => $headers,
|
'headers' => $headers,
|
||||||
|
// 微信支付 APIv3 验签必须使用原始 JSON body,不要先 json_decode 后重新编码。
|
||||||
|
'raw_body' => $rawBody,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
@ -588,6 +594,16 @@ $refund = $pay->post('refund', [
|
|||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
支付宝异步通知验签:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (!$pay->verifyNotify($_POST)) {
|
||||||
|
throw new RuntimeException('支付宝通知验签失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验签通过后再处理 trade_status、out_trade_no、trade_no 等业务字段。
|
||||||
|
```
|
||||||
|
|
||||||
## 框架集成建议
|
## 框架集成建议
|
||||||
|
|
||||||
- 在 Laravel、Hyperf、Symfony 等框架中,建议把 `Client` 注册为容器服务,缓存实现接入框架 Redis 或 Cache 组件。
|
- 在 Laravel、Hyperf、Symfony 等框架中,建议把 `Client` 注册为容器服务,缓存实现接入框架 Redis 或 Cache 组件。
|
||||||
|
|||||||
@ -41,12 +41,17 @@ class PlatformClient
|
|||||||
'form_params' => $params,
|
'form_params' => $params,
|
||||||
'headers' => ['Accept' => 'application/json'],
|
'headers' => ['Accept' => 'application/json'],
|
||||||
]);
|
]);
|
||||||
$payload = json_decode((string)$response->getBody(), true);
|
$body = (string)$response->getBody();
|
||||||
|
$payload = json_decode($body, true);
|
||||||
if (!is_array($payload)) {
|
if (!is_array($payload)) {
|
||||||
throw new WechatException('支付宝网关响应格式无效');
|
throw new WechatException('支付宝网关响应格式无效');
|
||||||
}
|
}
|
||||||
$node = str_replace('.', '_', $apiMethod) . '_response';
|
$node = str_replace('.', '_', $apiMethod) . '_response';
|
||||||
$data = is_array($payload[$node] ?? null) ? $payload[$node] : $payload;
|
$responseNode = is_array($payload[$node] ?? null) ? $node : (is_array($payload['error_response'] ?? null) ? 'error_response' : $node);
|
||||||
|
if ($this->config->alipayPublicKey !== '') {
|
||||||
|
$this->assertResponseSignature($body, $responseNode, (string)($payload['sign'] ?? ''));
|
||||||
|
}
|
||||||
|
$data = is_array($payload[$responseNode] ?? null) ? $payload[$responseNode] : $payload;
|
||||||
if (($data['code'] ?? '10000') !== '10000') {
|
if (($data['code'] ?? '10000') !== '10000') {
|
||||||
throw new WechatException((string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败'));
|
throw new WechatException((string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败'));
|
||||||
}
|
}
|
||||||
@ -54,6 +59,34 @@ class PlatformClient
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付宝异步通知签名;业务处理回调前应先调用该方法。
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params 支付宝通知完整参数,包含 sign/sign_type。
|
||||||
|
*/
|
||||||
|
public function verifyNotify(array $params): bool
|
||||||
|
{
|
||||||
|
return $this->verify($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付宝参数签名;通知验签会排除 sign 与 sign_type,其他参数按字典序拼接。
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
*/
|
||||||
|
public function verify(array $params): bool
|
||||||
|
{
|
||||||
|
$sign = (string)($params['sign'] ?? '');
|
||||||
|
if ($sign === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->config->alipayPublicKey === '') {
|
||||||
|
throw new WechatException('支付宝公钥不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->verifySignature($this->buildSignContent($params, true), $sign);
|
||||||
|
}
|
||||||
|
|
||||||
public function auth(string $redirectUri, string $scope = 'auth_user', string $state = ''): string
|
public function auth(string $redirectUri, string $scope = 'auth_user', string $state = ''): string
|
||||||
{
|
{
|
||||||
return 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?' . http_build_query([
|
return 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?' . http_build_query([
|
||||||
@ -134,16 +167,8 @@ class PlatformClient
|
|||||||
/** @param array<string,mixed> $params */
|
/** @param array<string,mixed> $params */
|
||||||
protected function sign(array $params): string
|
protected function sign(array $params): string
|
||||||
{
|
{
|
||||||
ksort($params);
|
$data = $this->buildSignContent($params);
|
||||||
$pairs = [];
|
$privateKey = $this->normalizePrivateKey($this->config->privateKey);
|
||||||
foreach ($params as $key => $value) {
|
|
||||||
if ($key === 'sign' || $value === null || $value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$pairs[] = $key . '=' . $value;
|
|
||||||
}
|
|
||||||
$data = implode('&', $pairs);
|
|
||||||
$privateKey = str_contains($this->config->privateKey, 'BEGIN') ? $this->config->privateKey : "-----BEGIN PRIVATE KEY-----\n" . chunk_split($this->config->privateKey, 64, "\n") . "-----END PRIVATE KEY-----";
|
|
||||||
$resource = openssl_pkey_get_private($privateKey);
|
$resource = openssl_pkey_get_private($privateKey);
|
||||||
if ($resource === false) {
|
if ($resource === false) {
|
||||||
throw new WechatException('支付宝私钥无效');
|
throw new WechatException('支付宝私钥无效');
|
||||||
@ -156,4 +181,109 @@ class PlatformClient
|
|||||||
|
|
||||||
return base64_encode($signature);
|
return base64_encode($signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertResponseSignature(string $body, string $node, string $sign): void
|
||||||
|
{
|
||||||
|
if ($sign === '') {
|
||||||
|
throw new WechatException('支付宝响应缺少签名');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支付宝同步响应的验签原文是响应节点的原始 JSON 片段,不能使用 json_decode 后重新编码的数组。
|
||||||
|
if (!$this->verifySignature($this->extractJsonValue($body, $node), $sign)) {
|
||||||
|
throw new WechatException('支付宝响应验签失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
*/
|
||||||
|
private function buildSignContent(array $params, bool $skipSignType = false): string
|
||||||
|
{
|
||||||
|
ksort($params);
|
||||||
|
$pairs = [];
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
if ($key === 'sign' || ($skipSignType && $key === 'sign_type') || $value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pairs[] = $key . '=' . (is_scalar($value) ? (string)$value : (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('&', $pairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySignature(string $source, string $signature): bool
|
||||||
|
{
|
||||||
|
$publicKey = $this->normalizePublicKey($this->config->alipayPublicKey);
|
||||||
|
$resource = openssl_pkey_get_public($publicKey);
|
||||||
|
if ($resource === false) {
|
||||||
|
throw new WechatException('支付宝公钥无效');
|
||||||
|
}
|
||||||
|
$decoded = base64_decode($signature, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$algo = strtoupper($this->config->signType) === 'RSA2' ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1;
|
||||||
|
|
||||||
|
return openssl_verify($source, $decoded, $resource, $algo) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePrivateKey(string $privateKey): string
|
||||||
|
{
|
||||||
|
return str_contains($privateKey, 'BEGIN') ? $privateKey : "-----BEGIN PRIVATE KEY-----\n" . chunk_split($privateKey, 64, "\n") . "-----END PRIVATE KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePublicKey(string $publicKey): string
|
||||||
|
{
|
||||||
|
return str_contains($publicKey, 'BEGIN') ? $publicKey : "-----BEGIN PUBLIC KEY-----\n" . chunk_split($publicKey, 64, "\n") . "-----END PUBLIC KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractJsonValue(string $json, string $key): string
|
||||||
|
{
|
||||||
|
if (preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*/', $json, $match, PREG_OFFSET_CAPTURE) !== 1) {
|
||||||
|
throw new WechatException('支付宝响应缺少签名节点: ' . $key);
|
||||||
|
}
|
||||||
|
$start = (int)$match[0][1] + strlen((string)$match[0][0]);
|
||||||
|
$length = strlen($json);
|
||||||
|
while ($start < $length && ctype_space($json[$start])) {
|
||||||
|
++$start;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $json[$start] ?? '';
|
||||||
|
if ($first !== '{' && $first !== '[') {
|
||||||
|
throw new WechatException('支付宝响应签名节点格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
$depth = 0;
|
||||||
|
$inString = false;
|
||||||
|
$escaped = false;
|
||||||
|
for ($i = $start; $i < $length; ++$i) {
|
||||||
|
$char = $json[$i];
|
||||||
|
if ($inString) {
|
||||||
|
if ($escaped) {
|
||||||
|
$escaped = false;
|
||||||
|
} elseif ($char === '\\') {
|
||||||
|
$escaped = true;
|
||||||
|
} elseif ($char === '"') {
|
||||||
|
$inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($char === '"') {
|
||||||
|
$inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($char === '{' || $char === '[') {
|
||||||
|
++$depth;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($char === '}' || $char === ']') {
|
||||||
|
--$depth;
|
||||||
|
if ($depth === 0) {
|
||||||
|
return substr($json, $start, $i - $start + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WechatException('支付宝响应签名节点不完整');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ namespace We\Platform\Wechat;
|
|||||||
use GuzzleHttp\ClientInterface;
|
use GuzzleHttp\ClientInterface;
|
||||||
use We\Config\WechatPaymentConfig;
|
use We\Config\WechatPaymentConfig;
|
||||||
use We\Exception\SignatureException;
|
use We\Exception\SignatureException;
|
||||||
|
use We\Exception\WechatException;
|
||||||
use We\Support\JsonClient;
|
use We\Support\JsonClient;
|
||||||
use We\Support\PayCrypto;
|
use We\Support\PayCrypto;
|
||||||
use We\Support\Signature;
|
use We\Support\Signature;
|
||||||
@ -50,14 +51,20 @@ final class PaymentClient
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,string> $headers
|
* @param array<string,string> $headers
|
||||||
* @param array<string,mixed> $body
|
* @param array<string,mixed>|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
public function decryptNotification(array $headers, array $body): array
|
public function decryptNotification(array $headers, array|string $body): array
|
||||||
{
|
{
|
||||||
$this->assertNotificationSignature($headers, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
// 微信支付 APIv3 签名串要求使用 HTTP 原始 body;不能先 json_decode 再重新编码,否则字段顺序或转义差异会导致验签失败。
|
||||||
|
$rawBody = is_string($body) ? $body : (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||||
|
$this->assertNotificationSignature($headers, $rawBody);
|
||||||
|
$payload = is_string($body) ? json_decode($body, true) : $body;
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
throw new WechatException('微信支付回调 JSON 无效');
|
||||||
|
}
|
||||||
/** @var array{ciphertext:string,nonce:string,associated_data?:string} $resource */
|
/** @var array{ciphertext:string,nonce:string,associated_data?:string} $resource */
|
||||||
$resource = $body['resource'] ?? [];
|
$resource = $payload['resource'] ?? [];
|
||||||
|
|
||||||
return PayCrypto::decryptResource($this->config->apiV3Key, $resource);
|
return PayCrypto::decryptResource($this->config->apiV3Key, $resource);
|
||||||
}
|
}
|
||||||
@ -75,9 +82,10 @@ final class PaymentClient
|
|||||||
return $this->refund($params);
|
return $this->refund($params);
|
||||||
}
|
}
|
||||||
if ($uri === 'decrypt_notification') {
|
if ($uri === 'decrypt_notification') {
|
||||||
|
$body = $options['raw_body'] ?? $options['body'] ?? $params;
|
||||||
return $this->decryptNotification(
|
return $this->decryptNotification(
|
||||||
is_array($options['headers'] ?? null) ? $options['headers'] : [],
|
is_array($options['headers'] ?? null) ? $options['headers'] : [],
|
||||||
is_array($options['body'] ?? null) ? $options['body'] : $params,
|
is_string($body) || is_array($body) ? $body : $params,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,9 +135,13 @@ final class PaymentClient
|
|||||||
|
|
||||||
private function assertNotificationSignature(array $headers, string $body): void
|
private function assertNotificationSignature(array $headers, string $body): void
|
||||||
{
|
{
|
||||||
$timestamp = (string)($headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? '');
|
$timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp');
|
||||||
$nonce = (string)($headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? '');
|
$nonce = $this->headerValue($headers, 'Wechatpay-Nonce');
|
||||||
$signature = (string)($headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? '');
|
$signature = $this->headerValue($headers, 'Wechatpay-Signature');
|
||||||
|
$serial = $this->headerValue($headers, 'Wechatpay-Serial');
|
||||||
|
if ($this->config->platformSerial !== '' && !hash_equals($this->config->platformSerial, $serial)) {
|
||||||
|
throw new SignatureException('微信支付平台序列号不匹配');
|
||||||
|
}
|
||||||
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
|
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
|
||||||
$publicKey = $this->config->platformPublicKey !== '' ? $this->config->platformPublicKey : $this->config->platformCertificate;
|
$publicKey = $this->config->platformPublicKey !== '' ? $this->config->platformPublicKey : $this->config->platformCertificate;
|
||||||
if ($publicKey === '') {
|
if ($publicKey === '') {
|
||||||
@ -139,4 +151,18 @@ final class PaymentClient
|
|||||||
throw new SignatureException('微信支付回调验签失败');
|
throw new SignatureException('微信支付回调验签失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $headers
|
||||||
|
*/
|
||||||
|
private function headerValue(array $headers, string $name): string
|
||||||
|
{
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
if (strcasecmp((string)$key, $name) === 0) {
|
||||||
|
return is_array($value) ? implode(',', array_map('strval', $value)) : (string)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,10 +43,20 @@ final class JsonClient
|
|||||||
*/
|
*/
|
||||||
public function send(string $method, string $uri, array $options = []): ResponseInterface
|
public function send(string $method, string $uri, array $options = []): ResponseInterface
|
||||||
{
|
{
|
||||||
|
$this->assertRelativeUri($uri);
|
||||||
try {
|
try {
|
||||||
return $this->http->request($method, $uri, $options);
|
return $this->http->request($method, $uri, $options);
|
||||||
} catch (GuzzleException $exception) {
|
} catch (GuzzleException $exception) {
|
||||||
throw new ApiException($exception->getMessage(), (int)$exception->getCode(), $exception);
|
throw new ApiException($exception->getMessage(), (int)$exception->getCode(), $exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertRelativeUri(string $uri): void
|
||||||
|
{
|
||||||
|
$uri = trim($uri);
|
||||||
|
// SDK 的微信类客户端都绑定了官方 base_uri,禁止传入绝对 URL,避免网关代调用场景被放大成 SSRF。
|
||||||
|
if (str_starts_with($uri, '//') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $uri) === 1) {
|
||||||
|
throw new ApiException('接口路径必须是相对路径');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@ final class PayCrypto
|
|||||||
*/
|
*/
|
||||||
public static function decryptResource(string $apiV3Key, array $resource): array
|
public static function decryptResource(string $apiV3Key, array $resource): array
|
||||||
{
|
{
|
||||||
|
foreach (['ciphertext', 'nonce'] as $field) {
|
||||||
|
if (!isset($resource[$field]) || !is_string($resource[$field]) || $resource[$field] === '') {
|
||||||
|
throw new WechatException('微信支付回调资源字段缺失: ' . $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
$ciphertext = base64_decode($resource['ciphertext'], true);
|
$ciphertext = base64_decode($resource['ciphertext'], true);
|
||||||
if ($ciphertext === false || strlen($ciphertext) <= 16) {
|
if ($ciphertext === false || strlen($ciphertext) <= 16) {
|
||||||
throw new WechatException('微信支付回调密文无效');
|
throw new WechatException('微信支付回调密文无效');
|
||||||
|
|||||||
108
src/Tests/AlipayPlatformClientTest.php
Normal file
108
src/Tests/AlipayPlatformClientTest.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use GuzzleHttp\Promise\Create;
|
||||||
|
use GuzzleHttp\Promise\PromiseInterface;
|
||||||
|
use GuzzleHttp\Psr7\Response;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\RequestInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use We\Config\AlipayPlatformConfig;
|
||||||
|
use We\Platform\Alipay\PlatformClient;
|
||||||
|
|
||||||
|
#[CoversClass(PlatformClient::class)]
|
||||||
|
final class AlipayPlatformClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void
|
||||||
|
{
|
||||||
|
[$privateKey, $publicKey] = self::keyPair();
|
||||||
|
$responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}';
|
||||||
|
$body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}';
|
||||||
|
$client = new PlatformClient(
|
||||||
|
new AlipayPlatformConfig('ali_app', $privateKey, $publicKey),
|
||||||
|
new AlipayFakeHttpClient($body),
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $client->request('alipay.trade.query', ['out_trade_no' => 'P202605040001']);
|
||||||
|
|
||||||
|
self::assertSame('TRADE202605040001', $data['trade_no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyNotify(): void
|
||||||
|
{
|
||||||
|
[$privateKey, $publicKey] = self::keyPair();
|
||||||
|
$params = [
|
||||||
|
'notify_time' => '2026-05-04 12:00:00',
|
||||||
|
'app_id' => 'ali_app',
|
||||||
|
'trade_status' => 'TRADE_SUCCESS',
|
||||||
|
'out_trade_no' => 'P202605040001',
|
||||||
|
'sign_type' => 'RSA2',
|
||||||
|
];
|
||||||
|
$params['sign'] = self::sign('app_id=ali_app¬ify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $privateKey);
|
||||||
|
$client = new PlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey));
|
||||||
|
|
||||||
|
self::assertTrue($client->verifyNotify($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private static function keyPair(): array
|
||||||
|
{
|
||||||
|
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||||||
|
self::assertNotFalse($resource);
|
||||||
|
openssl_pkey_export($resource, $privateKey);
|
||||||
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
self::assertIsArray($details);
|
||||||
|
|
||||||
|
return [$privateKey, (string)$details['key']];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sign(string $source, string $privateKey): string
|
||||||
|
{
|
||||||
|
$ok = openssl_sign($source, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
|
self::assertTrue($ok);
|
||||||
|
|
||||||
|
return base64_encode($signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AlipayFakeHttpClient implements ClientInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $body) {}
|
||||||
|
|
||||||
|
public function send(RequestInterface $request, array $options = []): ResponseInterface
|
||||||
|
{
|
||||||
|
return $this->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
|
||||||
|
{
|
||||||
|
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function request(string $method, $uri = '', array $options = []): ResponseInterface
|
||||||
|
{
|
||||||
|
return $this->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
|
||||||
|
{
|
||||||
|
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfig(?string $option = null): mixed
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function response(): ResponseInterface
|
||||||
|
{
|
||||||
|
return new Response(200, [], $this->body);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Tests/JsonClientTest.php
Normal file
34
src/Tests/JsonClientTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use We\Exception\ApiException;
|
||||||
|
use We\Support\JsonClient;
|
||||||
|
|
||||||
|
#[CoversClass(JsonClient::class)]
|
||||||
|
final class JsonClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSendRejectsAbsoluteUri(): void
|
||||||
|
{
|
||||||
|
$client = new JsonClient();
|
||||||
|
|
||||||
|
$this->expectException(ApiException::class);
|
||||||
|
$this->expectExceptionMessage('相对路径');
|
||||||
|
|
||||||
|
$client->send('GET', 'https://example.com/evil');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendRejectsNetworkPathUri(): void
|
||||||
|
{
|
||||||
|
$client = new JsonClient();
|
||||||
|
|
||||||
|
$this->expectException(ApiException::class);
|
||||||
|
$this->expectExceptionMessage('相对路径');
|
||||||
|
|
||||||
|
$client->send('GET', '//example.com/evil');
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Tests/PaymentClientTest.php
Normal file
95
src/Tests/PaymentClientTest.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace We\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use We\Config\WechatPaymentConfig;
|
||||||
|
use We\Exception\SignatureException;
|
||||||
|
use We\Platform\Wechat\PaymentClient;
|
||||||
|
use We\Support\Signature;
|
||||||
|
|
||||||
|
#[CoversClass(PaymentClient::class)]
|
||||||
|
final class PaymentClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDecryptNotificationUsesRawBodyForSignature(): void
|
||||||
|
{
|
||||||
|
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
||||||
|
$apiV3Key = str_repeat('k', 32);
|
||||||
|
$nonce = '123456789012';
|
||||||
|
$aad = 'transaction';
|
||||||
|
$plain = '{"out_trade_no":"T202605040001","trade_state":"SUCCESS"}';
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
|
||||||
|
self::assertIsString($cipher);
|
||||||
|
|
||||||
|
$rawBody = "{\n \"id\": \"notify-id\",\n \"resource\": {\n \"ciphertext\": \"" . base64_encode($cipher . $tag) . "\",\n \"nonce\": \"{$nonce}\",\n \"associated_data\": \"{$aad}\"\n }\n}";
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$notifyNonce = 'notify-nonce';
|
||||||
|
$headers = [
|
||||||
|
'Wechatpay-Timestamp' => $timestamp,
|
||||||
|
'Wechatpay-Nonce' => $notifyNonce,
|
||||||
|
'Wechatpay-Serial' => 'platform-serial',
|
||||||
|
'Wechatpay-Signature' => Signature::payV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
||||||
|
];
|
||||||
|
$client = new PaymentClient(new WechatPaymentConfig(
|
||||||
|
'wx_app',
|
||||||
|
'mch_id',
|
||||||
|
$apiV3Key,
|
||||||
|
'merchant-serial',
|
||||||
|
'merchant-private-key',
|
||||||
|
'',
|
||||||
|
$platformPublicKey,
|
||||||
|
'platform-serial',
|
||||||
|
));
|
||||||
|
|
||||||
|
$data = $client->decryptNotification($headers, $rawBody);
|
||||||
|
|
||||||
|
self::assertSame('T202605040001', $data['out_trade_no']);
|
||||||
|
self::assertSame('SUCCESS', $data['trade_state']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptNotificationRejectsPlatformSerialMismatch(): void
|
||||||
|
{
|
||||||
|
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
|
||||||
|
$rawBody = '{"resource":{"ciphertext":"invalid","nonce":"nonce"}}';
|
||||||
|
$timestamp = '1777600000';
|
||||||
|
$notifyNonce = 'notify-nonce';
|
||||||
|
$headers = [
|
||||||
|
'Wechatpay-Timestamp' => $timestamp,
|
||||||
|
'Wechatpay-Nonce' => $notifyNonce,
|
||||||
|
'Wechatpay-Serial' => 'other-serial',
|
||||||
|
'Wechatpay-Signature' => Signature::payV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
||||||
|
];
|
||||||
|
$client = new PaymentClient(new WechatPaymentConfig(
|
||||||
|
'wx_app',
|
||||||
|
'mch_id',
|
||||||
|
str_repeat('k', 32),
|
||||||
|
'merchant-serial',
|
||||||
|
'merchant-private-key',
|
||||||
|
'',
|
||||||
|
$platformPublicKey,
|
||||||
|
'platform-serial',
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->expectException(SignatureException::class);
|
||||||
|
$this->expectExceptionMessage('序列号');
|
||||||
|
|
||||||
|
$client->decryptNotification($headers, $rawBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private static function keyPair(): array
|
||||||
|
{
|
||||||
|
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||||||
|
self::assertNotFalse($resource);
|
||||||
|
openssl_pkey_export($resource, $privateKey);
|
||||||
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
self::assertIsArray($details);
|
||||||
|
|
||||||
|
return [$privateKey, (string)$details['key']];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user