diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java index 88e544e67..1e0e8d2c4 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java @@ -32,6 +32,8 @@ import javax.net.ssl.SSLContext; import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.KeyStore; @@ -395,6 +397,19 @@ public CloseableHttpClient initApiV3HttpClient() throws WxPayException { WxPayV3HttpClientBuilder wxPayV3HttpClientBuilder = WxPayV3HttpClientBuilder.create() .withMerchant(mchId, certSerialNo, merchantPrivateKey) .withValidator(new WxPayValidator(certificatesVerifier)); + // 当 apiHostUrl 配置为自定义代理地址时,将代理主机加入受信任列表, + // 确保 Authorization 头能正确发送到代理服务器 + String apiHostUrl = this.getApiHostUrl(); + if (StringUtils.isNotBlank(apiHostUrl)) { + try { + String host = new URI(apiHostUrl).getHost(); + if (host != null && !host.endsWith(".mch.weixin.qq.com")) { + wxPayV3HttpClientBuilder.withTrustedHost(host); + } + } catch (URISyntaxException e) { + log.warn("解析 apiHostUrl [{}] 中的主机名失败: {}", apiHostUrl, e.getMessage()); + } + } //初始化V3接口正向代理设置 HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, wxPayHttpProxy); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java index 24d6f26eb..24c51028d 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java @@ -15,16 +15,27 @@ import org.apache.http.util.EntityUtils; import java.io.IOException; +import java.util.Collections; +import java.util.Set; public class SignatureExec implements ClientExecChain { final ClientExecChain mainExec; final Credentials credentials; final Validator validator; + /** + * 额外受信任的主机列表,这些主机(如反向代理)也需要携带微信支付 Authorization 头 + */ + final Set trustedHosts; SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) { + this(credentials, validator, mainExec, Collections.emptySet()); + } + + SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec, Set trustedHosts) { this.credentials = credentials; this.validator = validator; this.mainExec = mainExec; + this.trustedHosts = trustedHosts != null ? trustedHosts : Collections.emptySet(); } protected HttpEntity newRepeatableEntity(HttpEntity entity) throws IOException { @@ -56,7 +67,8 @@ protected void convertToRepeatableRequestEntity(HttpRequestWrapper request) thro public CloseableHttpResponse execute(HttpRoute route, HttpRequestWrapper request, HttpClientContext context, HttpExecutionAware execAware) throws IOException, HttpException { - if (request.getURI().getHost() != null && request.getURI().getHost().endsWith(".mch.weixin.qq.com")) { + String host = request.getURI().getHost(); + if (host != null && (host.endsWith(".mch.weixin.qq.com") || trustedHosts.contains(host))) { return executeWithSignature(route, request, context, execAware); } else { return mainExec.execute(route, request, context, execAware); diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java index c88c884f5..91baa1624 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java @@ -2,6 +2,9 @@ import java.security.PrivateKey; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import com.github.binarywang.wxpay.v3.auth.PrivateKeySigner; import com.github.binarywang.wxpay.v3.auth.WxPayCredentials; @@ -12,6 +15,10 @@ public class WxPayV3HttpClientBuilder extends HttpClientBuilder { private Credentials credentials; private Validator validator; + /** + * 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头 + */ + private final Set trustedHosts = new HashSet<>(); static final String OS = System.getProperty("os.name") + "/" + System.getProperty("os.version"); static final String VERSION = System.getProperty("java.version"); @@ -47,6 +54,39 @@ public WxPayV3HttpClientBuilder withValidator(Validator validator) { return this; } + /** + * 添加受信任的主机,对该主机的请求也会携带微信支付 Authorization 头. + * 适用于通过反向代理(如 Nginx)转发微信支付 API 请求的场景, + * 当 apiHostUrl 配置为代理地址时,需要将代理主机加入受信任列表, + * 以确保 Authorization 头能正确传递到代理服务器。 + * 若传入值包含端口(如 "proxy.company.com:8080"),会自动提取主机名部分。 + * + * @param host 受信任的主机(可含端口),例如 "proxy.company.com" 或 "proxy.company.com:8080" + * @return 当前 Builder 实例 + */ + public WxPayV3HttpClientBuilder withTrustedHost(String host) { + if (host == null) { + return this; + } + String trimmed = host.trim(); + if (trimmed.isEmpty()) { + return this; + } + // 若包含端口号(如 "host:8080"),只取主机名部分 + int colonIdx = trimmed.lastIndexOf(':'); + if (colonIdx > 0) { + String portPart = trimmed.substring(colonIdx + 1); + boolean isPort = !portPart.isEmpty() && portPart.chars().allMatch(Character::isDigit); + if (isPort) { + trimmed = trimmed.substring(0, colonIdx); + } + } + if (!trimmed.isEmpty()) { + this.trustedHosts.add(trimmed); + } + return this; + } + @Override public CloseableHttpClient build() { if (credentials == null) { @@ -61,6 +101,7 @@ public CloseableHttpClient build() { @Override protected ClientExecChain decorateProtocolExec(final ClientExecChain requestExecutor) { - return new SignatureExec(this.credentials, this.validator, requestExecutor); + return new SignatureExec(this.credentials, this.validator, requestExecutor, + Collections.unmodifiableSet(new HashSet<>(this.trustedHosts))); } } diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java index 1daf308e5..0757f58dc 100755 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java @@ -22,6 +22,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.cert.CertificateExpiredException; @@ -154,8 +156,21 @@ private void autoUpdateCert() throws IOException, GeneralSecurityException { .withCredentials(credentials) .withValidator(verifier == null ? response -> true : new WxPayValidator(verifier)); + // 当 payBaseUrl 配置为自定义代理地址时,将代理主机加入受信任列表, + // 确保 Authorization 头能正确发送到代理服务器 + if (this.payBaseUrl != null && !this.payBaseUrl.isEmpty()) { + try { + String host = new URI(this.payBaseUrl).getHost(); + if (host != null && !host.endsWith(".mch.weixin.qq.com")) { + wxPayV3HttpClientBuilder.withTrustedHost(host); + } + } catch (URISyntaxException e) { + log.warn("解析 payBaseUrl [{}] 中的主机名失败: {}", this.payBaseUrl, e.getMessage()); + } + } + //调用自定义扩展设置设置HTTP PROXY对象 - HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder,this.wxPayHttpProxy); + HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, this.wxPayHttpProxy); //增加自定义扩展点,子类可以设置其他构造参数 this.customHttpClientBuilder(wxPayV3HttpClientBuilder); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java new file mode 100644 index 000000000..4d9147d9e --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/SignatureExecTrustedHostTest.java @@ -0,0 +1,200 @@ +package com.github.binarywang.wxpay.v3; + +import org.apache.http.HttpException; +import org.apache.http.ProtocolVersion; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpRequestWrapper; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.impl.execchain.ClientExecChain; +import org.apache.http.message.BasicHttpResponse; +import org.apache.http.message.BasicStatusLine; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.testng.Assert.*; + +/** + * 测试 SignatureExec 的受信任主机功能,确保在代理转发场景下正确添加 Authorization 头 + * + * @author GitHub Copilot + */ +public class SignatureExecTrustedHostTest { + + /** + * 最简 CloseableHttpResponse 实现,仅用于单元测试 + */ + private static class StubCloseableHttpResponse extends BasicHttpResponse implements CloseableHttpResponse { + StubCloseableHttpResponse() { + super(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK")); + } + + @Override + public void close() { + } + } + + /** + * 创建一个测试用的 Credentials,始终返回固定 schema 和 token + */ + private static Credentials createTestCredentials() { + return new Credentials() { + @Override + public String getSchema() { + return "WECHATPAY2-SHA256-RSA2048"; + } + + @Override + public String getToken(HttpRequestWrapper request) { + return "test_token"; + } + }; + } + + /** + * 创建一个 ClientExecChain,记录请求是否携带了 Authorization 头 + */ + private static ClientExecChain trackingExec(AtomicBoolean authHeaderAdded) { + return (route, request, context, execAware) -> { + if (request.containsHeader("Authorization")) { + authHeaderAdded.set(true); + } + return new StubCloseableHttpResponse(); + }; + } + + /** + * 测试:对微信官方主机(以 .mch.weixin.qq.com 结尾)的请求应该添加 Authorization 头 + */ + @Test + public void testWechatOfficialHostShouldAddAuthorizationHeader() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet() + ); + + HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates"); + signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + + assertTrue(authHeaderAdded.get(), "请求微信官方接口时应该添加 Authorization 头"); + } + + /** + * 测试:对非微信主机且不在受信任列表中的请求,不应该添加 Authorization 头 + */ + @Test + public void testUntrustedProxyHostShouldNotAddAuthorizationHeader() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet() + ); + + HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates"); + signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + + assertFalse(authHeaderAdded.get(), "不受信任的代理主机请求不应该添加 Authorization 头"); + } + + /** + * 测试:对在受信任列表中的代理主机请求,应该添加 Authorization 头. + * 这是修复代理转发场景下 Authorization 头丢失问题的核心功能 + */ + @Test + public void testTrustedProxyHostShouldAddAuthorizationHeader() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + Set trustedHosts = new HashSet<>(); + trustedHosts.add("proxy.company.com"); + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts + ); + + HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates"); + signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + + assertTrue(authHeaderAdded.get(), "受信任的代理主机请求应该添加 Authorization 头"); + } + + /** + * 测试:WxPayV3HttpClientBuilder 的 withTrustedHost 方法支持链式调用 + */ + @Test + public void testWithTrustedHostSupportsChainingCall() { + WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create(); + // 方法应该返回同一实例以支持链式调用 + WxPayV3HttpClientBuilder result = builder.withTrustedHost("proxy.company.com"); + assertSame(result, builder, "withTrustedHost 应该返回当前 Builder 实例(支持链式调用)"); + } + + /** + * 测试:withTrustedHost 传入含端口的地址时应自动提取主机名并正确影响签名行为 + */ + @Test + public void testWithTrustedHostWithPortShouldStripPort() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet() + ); + // 直接验证:SignatureExec 的主机匹配逻辑使用 URI.getHost(),不含端口 + // 因此只要 trustedHosts 中存有 "proxy.company.com",对 proxy.company.com:8080 的请求也应签名 + Set trustedHosts = new HashSet<>(); + trustedHosts.add("proxy.company.com"); + SignatureExec execWithPort = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts + ); + HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/pay/transactions/native"); + execWithPort.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + assertTrue(authHeaderAdded.get(), "含端口的代理请求匹配受信任主机后应添加 Authorization 头"); + } + + /** + * 测试:withTrustedHost 传入空值不应该抛出异常 + */ + @Test + public void testWithTrustedHostNullOrEmptyShouldNotThrow() { + WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create(); + // 传入 null 和空字符串不应该抛出异常 + builder.withTrustedHost(null); + builder.withTrustedHost(""); + } + + /** + * 测试:withTrustedHost 传入带端口的地址(如 "proxy.company.com:8080")时应自动提取主机名. + * WxPayV3HttpClientBuilder 应将端口剥离后存入受信任列表, + * 使得发往该主机的请求(URI.getHost() 不含端口)也能正确匹配并携带 Authorization 头 + */ + @Test + public void testWithTrustedHostBuilderStripsPort() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + // 传入带端口的主机,builder 应自动提取主机名 + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded), + Collections.singleton("proxy.company.com") + ); + HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates"); + signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + assertTrue(authHeaderAdded.get(), "builder 自动提取主机名后,对应代理请求应携带 Authorization 头"); + } + + /** + * 测试:SignatureExec 的旧构造函数(不带 trustedHosts)应该仍然有效 + */ + @Test + public void testBackwardCompatibilityWithOldConstructor() throws IOException, HttpException { + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + // 使用旧的三参数构造函数 + SignatureExec signatureExec = new SignatureExec( + createTestCredentials(), response -> true, trackingExec(authHeaderAdded) + ); + + // 微信官方主机仍然应该添加 Authorization 头 + HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates"); + signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null); + + assertTrue(authHeaderAdded.get(), "使用旧构造函数时,请求微信官方接口仍应添加 Authorization 头"); + } +}