<?php declare(strict_types=1);
|
|
namespace WeChatPay;
|
|
use function abs;
|
use function intval;
|
use function is_string;
|
use function is_resource;
|
use function is_object;
|
use function is_array;
|
use function implode;
|
use function count;
|
use function sprintf;
|
use function array_key_exists;
|
use function array_keys;
|
use function strcasecmp;
|
use function strncasecmp;
|
|
use GuzzleHttp\Client;
|
use GuzzleHttp\Middleware;
|
use GuzzleHttp\HandlerStack;
|
use GuzzleHttp\Exception\RequestException;
|
use GuzzleHttp\Promise\PromiseInterface;
|
use Psr\Http\Message\RequestInterface;
|
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\MessageInterface;
|
|
/** @var int - The maximum clock offset in second */
|
const MAXIMUM_CLOCK_OFFSET = 300;
|
|
const WechatpayNonce = 'Wechatpay-Nonce';
|
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.
|
*/
|
trait ClientJsonTrait
|
{
|
/**
|
* @var array<string, string|array<string, string>> - The defaults configuration whose pased in `GuzzleHttp\Client`.
|
*/
|
protected static $defaults = [
|
'base_uri' => 'https://api.mch.weixin.qq.com/',
|
'headers' => [
|
'Accept' => 'application/json, text/plain, application/x-gzip, application/pdf, image/png, image/*;q=0.5',
|
'Content-Type' => 'application/json; charset=utf-8',
|
],
|
];
|
|
abstract protected static function body(MessageInterface $message): string;
|
|
abstract protected static function withDefaults(array ...$config): array;
|
|
/**
|
* APIv3's signer middleware stack
|
*
|
* @param string $mchid - The merchant ID
|
* @param string $serial - The serial number of the merchant certificate
|
* @param \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string $privateKey - The merchant private key.
|
*
|
* @return callable(RequestInterface)
|
*/
|
public static function signer(string $mchid, string $serial, $privateKey): callable
|
{
|
return static function (RequestInterface $request) use ($mchid, $serial, $privateKey): RequestInterface {
|
$nonce = Formatter::nonce();
|
$timestamp = (string) Formatter::timestamp();
|
$signature = Crypto\Rsa::sign(Formatter::request(
|
$request->getMethod(), $request->getRequestTarget(), $timestamp, $nonce, static::body($request)
|
), $privateKey);
|
|
return $request->withHeader('Authorization', Formatter::authorization(
|
$mchid, $nonce, $signature, $timestamp, $serial
|
));
|
};
|
}
|
|
/**
|
* Assert the HTTP `20X` responses fit for the business logic, otherwise thrown a `\GuzzleHttp\Exception\RequestException`.
|
*
|
* The `30X` responses were handled by `\GuzzleHttp\RedirectMiddleware`.
|
* The `4XX, 5XX` responses were handled by `\GuzzleHttp\Middleware::httpErrors`.
|
*
|
* @param array<string,\OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string> $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
|
* @return callable(ResponseInterface,RequestInterface)
|
* @throws RequestException
|
*/
|
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(
|
Exception\WeChatPayException::EV3_RES_HEADERS_INCOMPLETE,
|
WechatpayNonce, WechatpaySerial, WechatpaySignature, WechatpayTimestamp
|
), $request, $response);
|
}
|
|
[$nonce] = $response->getHeader(WechatpayNonce);
|
[$serial] = $response->getHeader(WechatpaySerial);
|
[$signature] = $response->getHeader(WechatpaySignature);
|
[$timestamp] = $response->getHeader(WechatpayTimestamp);
|
|
$localTimestamp = Formatter::timestamp();
|
|
if (abs($localTimestamp - intval($timestamp)) > MAXIMUM_CLOCK_OFFSET) {
|
throw new RequestException(sprintf(
|
Exception\WeChatPayException::EV3_RES_HEADER_TIMESTAMP_OFFSET,
|
MAXIMUM_CLOCK_OFFSET, $timestamp, $localTimestamp
|
), $request, $response);
|
}
|
|
if (!array_key_exists($serial, $certs)) {
|
throw new RequestException(sprintf(
|
Exception\WeChatPayException::EV3_RES_HEADER_PLATFORM_SERIAL,
|
$serial, WechatpaySerial, implode(',', array_keys($certs))
|
), $request, $response);
|
}
|
|
$isOverseas = (0 === strcasecmp($url, '/hk/v3/statements') || 0 === strcasecmp($url, '/v3/global/statements')) && $response->hasHeader(WechatpayStatementSha1);
|
|
$verified = false;
|
try {
|
$verified = Crypto\Rsa::verify(
|
Formatter::response(
|
$timestamp,
|
$nonce,
|
$isOverseas ? static::digestBody($response) : static::body($response)
|
),
|
$signature, $certs[$serial]
|
);
|
} catch (\Exception $exception) {}
|
if ($verified === false) {
|
throw new RequestException(sprintf(
|
Exception\WeChatPayException::EV3_RES_HEADER_SIGNATURE_DIGEST,
|
$timestamp, $nonce, $signature, $serial
|
), $request, $response, $exception ?? null);
|
}
|
|
return $response;
|
};
|
}
|
|
/**
|
* 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
|
* @see https://pay.weixin.qq.com/wiki/doc/api_external/ch/apis/chapter3_1_6.shtml
|
* @see https://pay.weixin.qq.com/wiki/doc/api_external/en/apis/chapter3_1_6.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
|
*
|
* @param array<string,\OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string> $certs The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
|
* @return callable(callable(RequestInterface,array))
|
*/
|
public static function verifier(array &$certs): callable
|
{
|
$assert = static::assertSuccessfulResponse($certs);
|
return static function (callable $handler) use ($assert): callable {
|
return static function (RequestInterface $request, array $options = []) use ($assert, $handler): PromiseInterface {
|
return $handler($request, $options)->then(static function(ResponseInterface $response) use ($assert, $request): ResponseInterface {
|
return $assert($response, $request);
|
});
|
};
|
};
|
}
|
|
/**
|
* Create an APIv3's client
|
*
|
* Mandatory \$config array paramters
|
* - mchid: string - The merchant ID
|
* - serial: string - The serial number of the merchant certificate
|
* - privateKey: \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string - The merchant private key.
|
* - certs: array{string, \OpenSSLAsymmetricKey|\OpenSSLCertificate|object|resource|string} - The wechatpay platform serial and certificate(s), `[$serial => $cert]` pair
|
*
|
* @param array<string,string|int|bool|array|mixed> $config - The configuration
|
* @throws \WeChatPay\Exception\InvalidArgumentException
|
*/
|
public static function jsonBased(array $config = []): Client
|
{
|
if (!(
|
isset($config['mchid']) && is_string($config['mchid'])
|
)) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_MCHID_IS_MANDATORY); }
|
|
if (!(
|
isset($config['serial']) && is_string($config['serial'])
|
)) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_SERIAL_IS_MANDATORY); }
|
|
if (!(
|
isset($config['privateKey']) && (is_string($config['privateKey']) || is_resource($config['privateKey']) || is_object($config['privateKey']))
|
)) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_PRIVATEKEY_IS_MANDATORY); }
|
|
if (!(
|
isset($config['certs']) && is_array($config['certs']) && count($config['certs'])
|
)) { throw new Exception\InvalidArgumentException(Exception\ERR_INIT_CERTS_IS_MANDATORY); }
|
|
if (array_key_exists($config['serial'], $config['certs'])) {
|
throw new Exception\InvalidArgumentException(sprintf(
|
Exception\ERR_INIT_CERTS_EXCLUDE_MCHSERIAL, implode(',', array_keys($config['certs'])), $config['serial']
|
));
|
}
|
|
/** @var HandlerStack $stack */
|
$stack = isset($config['handler']) && ($config['handler'] instanceof HandlerStack) ? (clone $config['handler']) : HandlerStack::create();
|
$stack->before('prepare_body', Middleware::mapRequest(static::signer((string)$config['mchid'], $config['serial'], $config['privateKey'])), 'signer');
|
$stack->before('http_errors', static::verifier($config['certs']), 'verifier');
|
$config['handler'] = $stack;
|
|
unset($config['mchid'], $config['serial'], $config['privateKey'], $config['certs'], $config['secret'], $config['merchant']);
|
|
return new Client(static::withDefaults($config));
|
}
|
}
|