From 43b05b3bd471c896781ae54250fbd460567c103a Mon Sep 17 00:00:00 2001 From: James ZHANG <1631685+TheNorthMemory@users.noreply.github.com> Date: Mon, 23 May 2022 18:45:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96APIv3=E4=B8=8A=E7=9A=84?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E7=89=B9=E6=AE=8A=E6=8E=A5=E5=8F=A3=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE=E9=80=BB=E8=BE=91=EF=BC=8C=E5=9B=BD=E5=86=85=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E8=87=AA=E5=8A=A8=E5=BF=BD=E7=95=A5=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=EF=BC=8C=E6=B5=B7=E5=A4=96=E6=8C=89spec=E4=BB=85=E9=AA=8C?= =?UTF-8?q?=E8=AF=81RSA=E7=AD=BE=E5=90=8D=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Two skipping pharses of the response's validation whose are located in the mainland. One special Rsa::verify onto the downloading API which is located in the overseas. Close wechatpay-apiv3/wechatpay-php#94 * bump to v1.4.5 --- CHANGELOG.md | 9 +- README.md | 10 +- README_APIv2.md | 38 +---- composer.json | 2 +- src/ClientDecoratorInterface.php | 2 +- src/ClientJsonTrait.php | 38 ++++- .../MerchantService/Images/DownloadTest.php | 45 ++++++ tests/OpenAPI/V3/StatementsTest.php | 137 ++++++++++++++++++ 8 files changed, 235 insertions(+), 46 deletions(-) create mode 100644 tests/OpenAPI/V3/StatementsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d3be36d..527aa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # 变更历史 +## [1.4.5](../../compare/v1.4.4...v1.4.5) - 2022-05-21 + +- 新增`APIv3`请求/响应特殊验签逻辑,国内两个下载接口自动忽略验签,海外商户账单下载仅验RSA签名,详见 [#94](https://github.com/wechatpay-apiv3/wechatpay-php/issues/94); +- 新增`APIv3`[海外商户账单下载](https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml)测试用例,示例说明如何验证流`SHA1`摘要; + ## [1.4.4](../../compare/v1.4.3...v1.4.4) - 2022-05-19 -- 新增`APIv3`[客诉图片下载](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter10_2_18.shtml)测试用例,示例说明如何避免[double-pctencoded](https://github.com/guzzle/uri-template/issues/18)问题; +- 新增`APIv3`[客诉图片下载](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter10_2_18.shtml)测试用例,示例说明如何避免[double pct-encoded](https://github.com/guzzle/uri-template/issues/18)问题; - PHP内置函数`hash`方法在`PHP8`变更了返回值逻辑,代之为抛送`ValueError`异常,优化`MediaUtilTest`测试用例,以兼容`PHP7`; -- 新增`APIv2`请求/响应白名单`URL`及调整验签逻辑,对于白名单内的请求,已知无`sign`返回,应用侧自动忽略验签; +- 新增`APIv2`请求/响应白名单`URL`及调整验签逻辑,对于白名单内的请求,已知无`sign`返回,应用侧自动忽略验签,详见 [#92](https://github.com/wechatpay-apiv3/wechatpay-php/issues/92); ## [1.4.3](../../compare/v1.4.2...v1.4.3) - 2022-01-04 diff --git a/README.md b/README.md index 1283cd0..a2da5b2 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,9 @@ ## 项目状态 -当前版本为 `1.4.4` 测试版本。项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)。如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)。 - -为了向广大开发者提供更好的使用体验,微信支付诚挚邀请您将**使用微信支付 API v3 SDK**中的感受反馈给我们。本问卷可能会占用您不超过2分钟的时间,感谢您的支持。 - -问卷系统使用的腾讯问卷,您可以点击[这里](https://wj.qq.com/s2/8779987/8dae/),或者扫描以下二维码参与调查。 - -[![PHP SDK Questionnaire](https://user-images.githubusercontent.com/1812516/126434257-834ef6ab-e66b-4aa2-9104-8e37d7a14b93.png)](https://wj.qq.com/s2/8779987/8dae/) +当前版本为 `1.4.5` 测试版本。 +项目版本遵循 [语义化版本号](https://semver.org/lang/zh-CN/)。 +如果你使用的版本 `<=v1.3.2`,升级前请参考 [升级指南](UPGRADING.md)。 ## 环境要求 diff --git a/README_APIv2.md b/README_APIv2.md index aff5c15..cd339dc 100644 --- a/README_APIv2.md +++ b/README_APIv2.md @@ -103,16 +103,8 @@ $res = $instance // 特殊接入点,仅对本次请求有效 'base_uri' => 'https://fraud.mch.weixin.qq.com/', ]) -// 返回无sign字典,默认只能从异常通道获取返回值 -->otherwise(static function($e) { - // 更多`$e`异常类型判断是必须的,这里仅列出可能的两种情况,请根据实际对接过程调整并增加 - if ($e instanceof \GuzzleHttp\Promise\RejectionException) { - return Transformer::toArray((string)$e->getReason()->getBody()); - } - if ($e instanceof \Psr\Http\Message\MessageInterface) { - return Transformer::toArray((string)$e->getBody()); - } - return []; +->then(static function($response) { + return Transformer::toArray((string)$response->getBody()); }) ->wait(); print_r($res); @@ -147,13 +139,6 @@ $res = $instance ->then(static function($response) { return Transformer::toArray((string)$response->getBody()); }) -->otherwise(static function($e) { - // 更多`$e`异常类型判断是必须的,这里仅列出一种可能情况,请根据实际对接过程调整并增加 - if ($e instanceof \GuzzleHttp\Promise\RejectionException) { - return Transformer::toArray((string)$e->getReason()->getBody()); - } - return []; -}) ->wait(); print_r($res); ``` @@ -186,13 +171,6 @@ $res = $instance ->then(static function($response) { return Transformer::toArray((string)$response->getBody()); }) -->otherwise(static function($e) { - // 更多`$e`异常类型判断是必须的,这里仅列出一种可能情况,请根据实际对接过程调整并增加 - if ($e instanceof \GuzzleHttp\Promise\RejectionException) { - return Transformer::toArray((string)$e->getReason()->getBody()); - } - return []; -}) ->wait(); print_r($res); ``` @@ -212,16 +190,8 @@ $res = $instance // 通知SDK不接受沙箱环境重定向,仅对本次请求有效 'allow_redirects' => false, ]) -// 返回无sign字典,只能从异常通道获取返回值 -->otherwise(static function($e) { - // 更多`$e`异常类型判断是必须的,这里仅列出可能的两种情况,请根据实际对接过程调整并增加 - if ($e instanceof \GuzzleHttp\Promise\RejectionException) { - return Transformer::toArray((string)$e->getReason()->getBody()); - } - if ($e instanceof \Psr\Http\Message\MessageInterface) { - return Transformer::toArray((string)$e->getBody()); - } - return []; +->then(static function($response) { + return Transformer::toArray((string)$response->getBody()); }) ->wait(); print_r($res); diff --git a/composer.json b/composer.json index 97a7d1a..d8129a0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "wechatpay/wechatpay", - "version": "1.4.4", + "version": "1.4.5", "description": "[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP", "type": "library", "keywords": [ diff --git a/src/ClientDecoratorInterface.php b/src/ClientDecoratorInterface.php index f69930f..6e4a594 100644 --- a/src/ClientDecoratorInterface.php +++ b/src/ClientDecoratorInterface.php @@ -14,7 +14,7 @@ interface ClientDecoratorInterface /** * @var string - This library version */ - public const VERSION = '1.4.4'; + public const VERSION = '1.4.5'; /** * @var string - The HTTP transfer `xml` based protocol diff --git a/src/ClientJsonTrait.php b/src/ClientJsonTrait.php index 7957f21..32d217f 100644 --- a/src/ClientJsonTrait.php +++ b/src/ClientJsonTrait.php @@ -13,6 +13,8 @@ use function sprintf; use function array_key_exists; use function array_keys; +use function strcasecmp; +use function strncasecmp; use GuzzleHttp\Client; use GuzzleHttp\Middleware; @@ -30,6 +32,7 @@ const WechatpaySerial = 'Wechatpay-Serial'; const WechatpaySignature = 'Wechatpay-Signature'; const WechatpayTimestamp = 'Wechatpay-Timestamp'; +const WechatpayStatementSha1 = 'Wechatpay-Statement-Sha1'; /** * JSON based Client interface for sending HTTP requests. @@ -88,6 +91,13 @@ public static function signer(string $mchid, string $serial, $privateKey): calla protected static function assertSuccessfulResponse(array &$certs): callable { return static function (ResponseInterface $response, RequestInterface $request) use(&$certs): ResponseInterface { + if ( + 0 === strcasecmp($url = $request->getUri()->getPath(), '/v3/billdownload/file') + || (0 === strncasecmp($url, '/v3/merchant-service/images/', 28) && 0 !== strcasecmp($url, '/v3/merchant-service/images/upload')) + ) { + return $response; + } + if (!($response->hasHeader(WechatpayNonce) && $response->hasHeader(WechatpaySerial) && $response->hasHeader(WechatpaySignature) && $response->hasHeader(WechatpayTimestamp))) { throw new RequestException(sprintf( @@ -117,10 +127,16 @@ protected static function assertSuccessfulResponse(array &$certs): callable ), $request, $response); } + $isOverseas = 0 === strcasecmp($url, '/hk/v3/statements') && $response->hasHeader(WechatpayStatementSha1); + $verified = false; try { $verified = Crypto\Rsa::verify( - Formatter::response($timestamp, $nonce, static::body($response)), + Formatter::response( + $timestamp, + $nonce, + $isOverseas ? static::digestBody($response) : static::body($response) + ), $signature, $certs[$serial] ); } catch (\Exception $exception) {} @@ -135,6 +151,26 @@ protected static function assertSuccessfulResponse(array &$certs): callable }; } + /** + * Downloading the reconciliation was required the client to format the `WechatpayStatementSha1` digest string as `JSON`. + * + * There was also sugguestion that to validate the response streaming's `SHA1` digest whether or nor equals to `WechatpayStatementSha1`. + * Here may contains with or without `gzip` parameter. Both of them are validating the plain `CSV` stream. + * Keep the same logic with the mainland's one(without `SHA1` validation). + * If someone needs this feature built-in, contrubiting is welcome. + * + * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml + * @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/en/fusion_wallet/QuickPay/chapter8_5.shtml + * + * @param ResponseInterface $response - The response instance + * + * @return string - The JSON string + */ + protected static function digestBody(ResponseInterface $response): string + { + return sprintf('{"sha1":"%s"}', $response->getHeader(WechatpayStatementSha1)[0]); + } + /** * APIv3's verifier middleware stack * diff --git a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php index b65791f..eff0402 100644 --- a/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php +++ b/tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php @@ -103,6 +103,12 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo $this->mock->reset(); $this->mock->append($respondor); $this->mock->append($respondor); + $this->mock->append($respondor); + + $response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([ + 'media_slot_url' => $slot, + ]); + self::responseAssertion($response); $response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([ 'handler' => $stack, @@ -143,6 +149,13 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res $this->mock->reset(); $this->mock->append($respondor); $this->mock->append($respondor); + $this->mock->append($respondor); + + $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([ + 'media_slot_url' => $slot, + ])->then(static function (ResponseInterface $response) { + self::responseAssertion($response); + })->wait(); $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([ 'handler' => $stack, @@ -177,19 +190,37 @@ public function testUseStandardGuzzleHttpClient(array $config, string $slot, Res $this->mock->reset(); + $this->mock->append($respondor); + $response = $apiv3Client->request('GET', $relativeUrl); + self::responseAssertion($response); + $this->mock->append($respondor); $response = $apiv3Client->request('GET', $relativeUrl, ['handler' => $stack]); self::responseAssertion($response); + $this->mock->append($respondor); + $response = $apiv3Client->request('GET', $fullUri); + self::responseAssertion($response); + $this->mock->append($respondor); $response = $apiv3Client->request('GET', $fullUri, ['handler' => $stack]); self::responseAssertion($response); + $this->mock->append($respondor); + /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */ + $response = $apiv3Client->get($relativeUrl); + self::responseAssertion($response); + $this->mock->append($respondor); /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */ $response = $apiv3Client->get($relativeUrl, ['handler' => $stack]); self::responseAssertion($response); + $this->mock->append($respondor); + /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */ + $response = $apiv3Client->get($fullUri); + self::responseAssertion($response); + $this->mock->append($respondor); /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */ $response = $apiv3Client->get($fullUri, ['handler' => $stack]); @@ -199,17 +230,31 @@ public function testUseStandardGuzzleHttpClient(array $config, string $slot, Res self::responseAssertion($response); }; + $this->mock->append($respondor); + /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */ + $response = $apiv3Client->getAsync($fullUri)->then($asyncAssertion)->wait(); + $this->mock->append($respondor); /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */ $response = $apiv3Client->getAsync($fullUri, ['handler' => $stack])->then($asyncAssertion)->wait(); + $this->mock->append($respondor); + /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */ + $response = $apiv3Client->getAsync($relativeUrl)->then($asyncAssertion)->wait(); + $this->mock->append($respondor); /** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */ $response = $apiv3Client->getAsync($relativeUrl, ['handler' => $stack])->then($asyncAssertion)->wait(); + $this->mock->append($respondor); + $response = $apiv3Client->requestAsync('GET', $relativeUrl)->then($asyncAssertion)->wait(); + $this->mock->append($respondor); $response = $apiv3Client->requestAsync('GET', $relativeUrl, ['handler' => $stack])->then($asyncAssertion)->wait(); + $this->mock->append($respondor); + $response = $apiv3Client->requestAsync('GET', $fullUri)->then($asyncAssertion)->wait(); + $this->mock->append($respondor); $response = $apiv3Client->requestAsync('GET', $fullUri, ['handler' => $stack])->then($asyncAssertion)->wait(); } diff --git a/tests/OpenAPI/V3/StatementsTest.php b/tests/OpenAPI/V3/StatementsTest.php new file mode 100644 index 0000000..fe6261f --- /dev/null +++ b/tests/OpenAPI/V3/StatementsTest.php @@ -0,0 +1,137 @@ +mock = new MockHandler(); + + return HandlerStack::create($this->mock); + } + + /** + * @param array $config + * @return array{\WeChatPay\BuilderChainable} + */ + private function newInstance(array $config): array + { + $instance = Builder::factory($config + ['handler' => $this->guzzleMockStack(),]); + + return [$instance]; + } + + /** + * @return array> + */ + public function mockDataProvider(): array + { + $mchid = '1230000109'; + $mchSerial = rtrim(file_get_contents(sprintf(self::FIXTURES, 'mock.serial.txt')) ?: ''); + $mchPrivateKey = Rsa::from(sprintf(self::FIXTURES, 'mock.pkcs8.key')); + $platSerial = Formatter::nonce(40); + $platPublicKey = Rsa::from(sprintf(self::FIXTURES, 'mock.spki.pem'), Rsa::KEY_TYPE_PUBLIC); + + $stream = new LazyOpenStream(sprintf(self::FIXTURES, 'bill.ALL.csv'), 'rb'); + + $platPrivateKey = Rsa::from(sprintf(self::FIXTURES, 'mock.pkcs1.key')); + $signature = Rsa::sign(Formatter::response( + $timestamp = (string)Formatter::timestamp(), + $nonce = Formatter::nonce(), + sprintf('{"sha1":"%s"}', $digest = Utils::hash($stream, 'SHA1')) + ), $platPrivateKey); + + return [ + 'configuration with base_uri' => [ + [ + 'base_uri' => 'https://api.mch.weixin.qq.com/hk/', + 'mchid' => $mchid, + 'serial' => $mchSerial, + 'privateKey' => $mchPrivateKey, + 'certs' => [$platSerial => $platPublicKey] + ], + new Response(200, [ + 'Content-Type' => 'text/plain', + 'Wechatpay-Timestamp' => $timestamp, + 'Wechatpay-Nonce' => $nonce, + 'Wechatpay-Signature' => $signature, + 'Wechatpay-Serial' => $platSerial, + 'Wechatpay-Statement-Sha1' => $digest, + ], $stream), + ], + ]; + } + + /** + * @dataProvider mockDataProvider + * @param array $config + * @param ResponseInterface $respondor + */ + public function testGet(array $config, ResponseInterface $respondor): void + { + [$endpoint] = $this->newInstance($config); + + $this->mock->reset(); + $this->mock->append($respondor); + + $response = $endpoint->chain('v3/statements')->get([ + 'date' => '20180103', + ]); + self::responseAssertion($response); + } + + /** + * @param ResponseInterface $response + */ + private static function responseAssertion(ResponseInterface $response): void + { + self::assertTrue($response->hasHeader('Content-Type')); + self::assertStringStartsWith('text/plain', $response->getHeaderLine('Content-Type')); + self::assertTrue($response->hasHeader('Wechatpay-Statement-Sha1')); + self::assertNotEmpty($digest = $response->getHeaderLine('Wechatpay-Statement-Sha1')); + self::assertTrue(strlen($digest) === 40); + self::assertEquals($digest, Utils::hash($response->getBody(), 'SHA1')); + } + + /** + * @dataProvider mockDataProvider + * @param array $config + * @param ResponseInterface $respondor + */ + public function testGetAsync(array $config, ResponseInterface $respondor): void + { + [$endpoint] = $this->newInstance($config); + + $this->mock->reset(); + $this->mock->append($respondor); + + $endpoint->chain('v3/statements')->getAsync([ + 'date' => '20180103', + ])->then(static function (ResponseInterface $response) { + self::responseAssertion($response); + $response->getBody()->close();// cleanup the opening file handler + })->wait(); + } +}