Skip to content

Commit

Permalink
optim README and APIv2 response's validator (#144)
Browse files Browse the repository at this point in the history
* bump to v1.4.11

- 优化README关于`微信支付平台证书`及`微信支付平台公钥`内容;
- 精细化处理APIv2返回值,对于`返回状态码`及/或`业务结果`不为`SUCCESS`抛出异常处理;
- 统一名词: `微信支付公钥`
  • Loading branch information
TheNorthMemory authored Dec 27, 2024
1 parent 717f36e commit 6f2994f
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 19 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# 变更历史

## [1.4.10](../../compare/v1.4.9...v1.4.10) - 2024-09-11
## [1.4.11](../../compare/v1.4.9...v1.4.10) - 2024-12-23

-`APIv2`服务端返回值做精细判断,对于`return_code`(返回状态码)及/或`result_code`(业务结果)有key且值不为`SUCCESS`的情形,抛出客户端`RejectionException`异常,并加入[AuthcodetoopenidTest.php](./tests/OpenAPI/V2/Tools/AuthcodetoopenidTest.php)异常处理示例。

## [1.4.10](../../compare/v1.4.9...v1.4.10) - 2024-09-19

- 客户端在`RSA`非对称加解密方案上,不再支持`OPENSSL_PKCS1_PADDING`填充模式,相关记录见[这里](https://github.com/wechatpay-apiv3/wechatpay-php/issues/133)
- 增加[`#[\SensitiveParameter]`](https://www.php.net/manual/zh/class.sensitiveparameter.php)参数注解,加强信息安全;
Expand Down
67 changes: 51 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

## 项目状态

当前版本为 `1.4.10` 版。
当前版本为 `1.4.11` 版。
项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)
如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)

Expand Down Expand Up @@ -64,11 +64,11 @@ composer require wechatpay/wechatpay
+ **证书序列号**。每个证书都有一个由 CA 颁发的唯一编号,即证书序列号。

+ **微信支付平台公钥**,是微信支付平台的公钥,用于应答及回调通知的数据签名,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接下载。
+ **微信支付公钥**,用于应答及回调通知的数据签名,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接下载。

+ **微信支付平台公钥ID**是微信支付平台公钥的唯一标识,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接查看。
+ **微信支付公钥ID**是微信支付公钥的唯一标识,可在 [微信支付商户平台](https://pay.weixin.qq.com) -> 账户中心 -> API安全 直接查看。

### 示例程序:微信支付平台证书下载
### 初始化一个APIv3客户端

```php
<?php
Expand All @@ -90,32 +90,67 @@ $merchantPrivateKeyInstance = Rsa::from($merchantPrivateKeyFilePath, Rsa::KEY_TY
// 「商户API证书」的「证书序列号」
$merchantCertificateSerial = '3775B6A45ACD588826D15E583A95F5DD********';

// 从本地文件中加载「微信支付平台证书」或者「微信支付平台公钥」,用来验证微信支付应答的签名
$platformCertificateOrPublicKeyFilePath = 'file:///path/to/wechatpay/certificate_or_publickey.pem';
$platformPublicKeyInstance = Rsa::from($platformCertificateOrPublicKeyFilePath, Rsa::KEY_TYPE_PUBLIC);
// 从本地文件中加载「微信支付平台证书」,可由内置CLI工具下载到,用来验证微信支付应答的签名
$platformCertificateFilePath = 'file:///path/to/wechatpay/certificate.pem';
$onePlatformPublicKeyInstance = Rsa::from($platformCertificateFilePath, Rsa::KEY_TYPE_PUBLIC);

// 「微信支付平台证书」的「证书序列号」或者是「微信支付平台公钥ID」
// 「平台证书序列号」及/或「平台公钥ID」可以从 商户平台 -> 账户中心 -> API安全 直接查询到
$platformCertificateSerialOrPublicKeyId = '7132D72A03E93CDDF8C03BBD1F37EEDF********';
// 「微信支付平台证书」的「平台证书序列号」
// 可以从「微信支付平台证书」文件解析,也可以在 商户平台 -> 账户中心 -> API安全 查询到
$platformCertificateSerial = '7132D72A03E93CDDF8C03BBD1F37EEDF********';

// 从本地文件中加载「微信支付公钥」,用来验证微信支付应答的签名
$platformPublicKeyFilePath = 'file:///path/to/wechatpay/publickey.pem';
$twoPlatformPublicKeyInstance = Rsa::from($platformPublicKeyFilePath, Rsa::KEY_TYPE_PUBLIC);

// 「微信支付公钥」的「微信支付公钥ID」
// 需要在 商户平台 -> 账户中心 -> API安全 查询
$platformPublicKeyId = 'PUB_KEY_ID_01142321349124100000000000********';

// 构造一个 APIv3 客户端实例
$instance = Builder::factory([
'mchid' => $merchantId,
'serial' => $merchantCertificateSerial,
'privateKey' => $merchantPrivateKeyInstance,
'certs' => [
$platformCertificateSerialOrPublicKeyId => $platformPublicKeyInstance,
$platformCertificateSerial => $onePlatformPublicKeyInstance,
$platformPublicKeyId => $twoPlatformPublicKeyInstance,
],
]);
```

### 示例,第一个请求:查询「微信支付平台证书」

```php
// 发送请求
$resp = $instance->chain('v3/certificates')->get(
/** @see https://docs.guzzlephp.org/en/stable/request-options.html#debug */
// ['debug' => true] // 调试模式
);
echo (string) $resp->getBody(), PHP_EOL;
try {
$resp = $instance->chain('v3/certificates')->get(
/** @see https://docs.guzzlephp.org/en/stable/request-options.html#debug */
// ['debug' => true] // 调试模式
);
echo (string) $resp->getBody(), PHP_EOL;
} catch(\Exception $e) {
// 进行异常捕获并进行错误判断处理
echo $e->getMessage(), PHP_EOL;
if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) {
$r = $e->getResponse();
echo $r->getStatusCode() . ' ' . $r->getReasonPhrase(), PHP_EOL;
echo (string) $r->getBody(), PHP_EOL, PHP_EOL, PHP_EOL;
}
echo $e->getTraceAsString(), PHP_EOL;
}
```

当程序进入「异常捕获」逻辑,输出形如:

```json
{
"code": "RESOURCE_NOT_EXISTS",
"message": "无可用的平台证书,请在商户平台-API安全申请使用微信支付公钥。可查看指引https://pay.weixin.qq.com/docs/merchant/products/platform-certificate/wxp-pub-key-guide.html"
}
```

即表示商户仅能运行在「微信支付公钥」模式,初始化即无需读取及配置`$platformCertificateSerial``$onePlatformPublicKeyInstance`等信息。

## 文档

### 同步请求
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wechatpay/wechatpay",
"version": "1.4.10",
"version": "1.4.11",
"description": "[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP",
"type": "library",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion src/ClientDecoratorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface ClientDecoratorInterface
/**
* @var string - This library version
*/
public const VERSION = '1.4.10';
public const VERSION = '1.4.11';

/**
* @var string - The HTTP transfer `xml` based protocol
Expand Down
9 changes: 9 additions & 0 deletions src/ClientXmlTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use function strlen;
use function sprintf;
use function in_array;
use function array_key_exists;

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
Expand Down Expand Up @@ -129,6 +130,14 @@ public static function transformResponse(
return $handler($request, $options)->then(static function(ResponseInterface $response) use ($secret) {
$result = Transformer::toArray(static::body($response));

if (!(array_key_exists('return_code', $result) && Crypto\Hash::equals('SUCCESS', $result['return_code']))) {
return Create::rejectionFor($response);
}

if (array_key_exists('result_code', $result) && !Crypto\Hash::equals('SUCCESS', $result['result_code'])) {
return Create::rejectionFor($response);
}

/** @var ?string $sign */
$sign = $result['sign'] ?? null;
$type = $sign && strlen($sign) === 64 ? Crypto\Hash::ALGO_HMAC_SHA256 : Crypto\Hash::ALGO_MD5;
Expand Down
135 changes: 135 additions & 0 deletions tests/OpenAPI/V2/Tools/AuthcodetoopenidTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php declare(strict_types=1);

namespace WeChatPay\Tests\OpenAPI\V2\Tools;

use function array_key_exists;

use PHPUnit\Framework\TestCase;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Promise\RejectionException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use WeChatPay\Builder;
use WeChatPay\Transformer;
use WeChatPay\Formatter;
use WeChatPay\Crypto\Hash;

class AuthcodetoopenidTest extends TestCase
{
private const SUCCESS = 'SUCCESS';

private const FAIL = 'FAIL';

/** @var MockHandler $mock */
private $mock;

private function guzzleMockStack(): HandlerStack
{
$this->mock = new MockHandler();

return HandlerStack::create($this->mock);
}

/**
* @param string $secret
* @return \WeChatPay\BuilderChainable
*/
private function prepareEnvironment(string $secret): \WeChatPay\BuilderChainable
{
$instance = Builder::factory([
'mchid' => '123',
'serial' => 'nop',
'privateKey' => 'any',
'certs' => ['any' => null],
'secret' => $secret,
'handler' => $this->guzzleMockStack(),
]);

$endpoint = $instance->chain('v2/tools/authcodetoopenid');

return $endpoint;
}

/**
* @return array<string,array{array<string,?string>,string,?class-string}>
*/
public function mockDataProvider(): array
{
$serverFail = [
'return_code' => self::FAIL,
'return_msg' => 'invalid reason',
];

$serviceFail = [
'return_code' => self::SUCCESS,
'return_msg' => '',
'appid' => '123',
'mch_id' => '123',
'nonce_str' => Formatter::nonce(),
'sign' => 'fake',
'result_code' => self::FAIL,
'err_code' => 'AUTH_CODE_INVALID',
];

$key1 = Formatter::nonce();
$serviceFalsy = [
'return_code' => self::SUCCESS,
'return_msg' => '',
'appid' => '123',
'mch_id' => '123',
'nonce_str' => Formatter::nonce(),
];
$serviceFalsy['sign'] = Hash::sign(Hash::ALGO_MD5, Formatter::queryStringLike(Formatter::ksort($serviceFalsy)), $key1);

$key2 = Formatter::nonce();
$serviceTruthy = [
'return_code' => self::SUCCESS,
'return_msg' => '',
'appid' => '123',
'mch_id' => '123',
'nonce_str' => Formatter::nonce(),
'result_code' => self::SUCCESS,
'err_code' => '',
'openid' => '123',
];
$serviceTruthy['sign'] = Hash::sign(Hash::ALGO_MD5, Formatter::queryStringLike(Formatter::ksort($serviceTruthy)), $key2);

return [
'return_code=FAIL then Exception occurred' => [$serverFail, Formatter::nonce(), RejectionException::class],
'return_code=SUCCESS && result_code=FAIL then Exception occurred' => [$serviceFail, Formatter::nonce(), RejectionException::class],
'return_code=SUCCESS && without `result_code` key then Passed with falsy data' => [$serviceFalsy, $key1, null],
'return_code=SUCCESS && result_code=SUCCESS then Passed with truthy data' => [$serviceTruthy, $key2, null],
];
}

/**
* @dataProvider mockDataProvider
* @param array<string,string> $data
* @param string $secret
* @param ?class-string $expected
*/
public function testResponseState(array $data, string $secret, ?string $expected = null): void
{
$endpoint = $this->prepareEnvironment($secret);

$this->mock->reset();
$this->mock->append(new Response(200, [], Transformer::toXml($data)));
if (is_null($expected)) {
$response = $endpoint->post(['xml' => ['appid' => '123', 'mch_id' => '123', 'auth_code' => '123']]);
$xml = Transformer::toArray((string) $response->getBody());
self::assertEquals($data, $xml);
} else {
try {
$endpoint->post(['xml' => ['appid' => '123', 'mch_id' => '123', 'auth_code' => '123']]);
} catch (\Throwable $e) {
self::assertInstanceOf($expected, $e);
if ($e instanceof RejectionException && ($response = $e->getReason()) instanceof ResponseInterface) {
$err = Transformer::toArray((string)$response->getBody());
//three cases, maybe return_code and/or result_code 'FAIL'
self::assertEquals(self::FAIL, $err['result_code'] ?? $err['return_code'] ?? '');
}
}
}
}
}

0 comments on commit 6f2994f

Please sign in to comment.