diff --git a/README.md b/README.md index 648aa57..1f621d0 100644 --- a/README.md +++ b/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` 的接口 | 传 `['with_token' => false]`。 | | POST JSON | 默认行为,直接传 `$params`。 | @@ -441,8 +442,13 @@ $refund = $payment->post('v3/refund/domestic/refunds', [ 回调验签与解密: ```php +$rawBody = file_get_contents('php://input') ?: ''; +$body = json_decode($rawBody, true) ?: []; + $data = $payment->post('decrypt_notification', [], [ 'headers' => $headers, + // 微信支付 APIv3 验签必须使用原始 JSON body,不要先 json_decode 后重新编码。 + 'raw_body' => $rawBody, '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 组件。 diff --git a/src/Platform/Alipay/PlatformClient.php b/src/Platform/Alipay/PlatformClient.php index 4ea474c..913a5af 100644 --- a/src/Platform/Alipay/PlatformClient.php +++ b/src/Platform/Alipay/PlatformClient.php @@ -41,12 +41,17 @@ class PlatformClient 'form_params' => $params, 'headers' => ['Accept' => 'application/json'], ]); - $payload = json_decode((string)$response->getBody(), true); + $body = (string)$response->getBody(); + $payload = json_decode($body, true); if (!is_array($payload)) { throw new WechatException('支付宝网关响应格式无效'); } $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') { throw new WechatException((string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败')); } @@ -54,6 +59,34 @@ class PlatformClient return $data; } + /** + * 验证支付宝异步通知签名;业务处理回调前应先调用该方法。 + * + * @param array $params 支付宝通知完整参数,包含 sign/sign_type。 + */ + public function verifyNotify(array $params): bool + { + return $this->verify($params); + } + + /** + * 验证支付宝参数签名;通知验签会排除 sign 与 sign_type,其他参数按字典序拼接。 + * + * @param array $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 { return 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?' . http_build_query([ @@ -134,16 +167,8 @@ class PlatformClient /** @param array $params */ protected function sign(array $params): string { - ksort($params); - $pairs = []; - 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-----"; + $data = $this->buildSignContent($params); + $privateKey = $this->normalizePrivateKey($this->config->privateKey); $resource = openssl_pkey_get_private($privateKey); if ($resource === false) { throw new WechatException('支付宝私钥无效'); @@ -156,4 +181,109 @@ class PlatformClient 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 $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('支付宝响应签名节点不完整'); + } } diff --git a/src/Platform/Wechat/PaymentClient.php b/src/Platform/Wechat/PaymentClient.php index b5df91b..e1742a6 100644 --- a/src/Platform/Wechat/PaymentClient.php +++ b/src/Platform/Wechat/PaymentClient.php @@ -7,6 +7,7 @@ namespace We\Platform\Wechat; use GuzzleHttp\ClientInterface; use We\Config\WechatPaymentConfig; use We\Exception\SignatureException; +use We\Exception\WechatException; use We\Support\JsonClient; use We\Support\PayCrypto; use We\Support\Signature; @@ -50,14 +51,20 @@ final class PaymentClient /** * @param array $headers - * @param array $body + * @param array|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。 * @return array */ - 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 */ - $resource = $body['resource'] ?? []; + $resource = $payload['resource'] ?? []; return PayCrypto::decryptResource($this->config->apiV3Key, $resource); } @@ -75,9 +82,10 @@ final class PaymentClient return $this->refund($params); } if ($uri === 'decrypt_notification') { + $body = $options['raw_body'] ?? $options['body'] ?? $params; return $this->decryptNotification( 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 { - $timestamp = (string)($headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? ''); - $nonce = (string)($headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? ''); - $signature = (string)($headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? ''); + $timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp'); + $nonce = $this->headerValue($headers, 'Wechatpay-Nonce'); + $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"; $publicKey = $this->config->platformPublicKey !== '' ? $this->config->platformPublicKey : $this->config->platformCertificate; if ($publicKey === '') { @@ -139,4 +151,18 @@ final class PaymentClient throw new SignatureException('微信支付回调验签失败'); } } + + /** + * @param array $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 ''; + } } diff --git a/src/Support/JsonClient.php b/src/Support/JsonClient.php index 2b2301c..6941d0e 100644 --- a/src/Support/JsonClient.php +++ b/src/Support/JsonClient.php @@ -43,10 +43,20 @@ final class JsonClient */ public function send(string $method, string $uri, array $options = []): ResponseInterface { + $this->assertRelativeUri($uri); try { return $this->http->request($method, $uri, $options); } catch (GuzzleException $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('接口路径必须是相对路径'); + } + } } diff --git a/src/Support/PayCrypto.php b/src/Support/PayCrypto.php index 0eb7b2e..21172c1 100644 --- a/src/Support/PayCrypto.php +++ b/src/Support/PayCrypto.php @@ -14,6 +14,11 @@ final class PayCrypto */ 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); if ($ciphertext === false || strlen($ciphertext) <= 16) { throw new WechatException('微信支付回调密文无效'); diff --git a/src/Tests/AlipayPlatformClientTest.php b/src/Tests/AlipayPlatformClientTest.php new file mode 100644 index 0000000..5d6418c --- /dev/null +++ b/src/Tests/AlipayPlatformClientTest.php @@ -0,0 +1,108 @@ +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); + } +} diff --git a/src/Tests/JsonClientTest.php b/src/Tests/JsonClientTest.php new file mode 100644 index 0000000..223f141 --- /dev/null +++ b/src/Tests/JsonClientTest.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/src/Tests/PaymentClientTest.php b/src/Tests/PaymentClientTest.php new file mode 100644 index 0000000..9b7577a --- /dev/null +++ b/src/Tests/PaymentClientTest.php @@ -0,0 +1,95 @@ + $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']]; + } +}