Skip to content

Commit 4b2383c

Browse files
authored
🎨 #3910 【微信支付】修复代理转发场景下 V3 API Authorization 头丢失导致 401的问题
1 parent feaf90e commit 4b2383c

File tree

5 files changed

+286
-3
lines changed

5 files changed

+286
-3
lines changed

weixin-java-pay/src/main/java/com/github/binarywang/wxpay/config/WxPayConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
import javax.net.ssl.SSLContext;
3434
import java.io.*;
35+
import java.net.URI;
36+
import java.net.URISyntaxException;
3537
import java.net.URL;
3638
import java.nio.charset.StandardCharsets;
3739
import java.security.KeyStore;
@@ -395,6 +397,19 @@ public CloseableHttpClient initApiV3HttpClient() throws WxPayException {
395397
WxPayV3HttpClientBuilder wxPayV3HttpClientBuilder = WxPayV3HttpClientBuilder.create()
396398
.withMerchant(mchId, certSerialNo, merchantPrivateKey)
397399
.withValidator(new WxPayValidator(certificatesVerifier));
400+
// 当 apiHostUrl 配置为自定义代理地址时,将代理主机加入受信任列表,
401+
// 确保 Authorization 头能正确发送到代理服务器
402+
String apiHostUrl = this.getApiHostUrl();
403+
if (StringUtils.isNotBlank(apiHostUrl)) {
404+
try {
405+
String host = new URI(apiHostUrl).getHost();
406+
if (host != null && !host.endsWith(".mch.weixin.qq.com")) {
407+
wxPayV3HttpClientBuilder.withTrustedHost(host);
408+
}
409+
} catch (URISyntaxException e) {
410+
log.warn("解析 apiHostUrl [{}] 中的主机名失败: {}", apiHostUrl, e.getMessage());
411+
}
412+
}
398413
//初始化V3接口正向代理设置
399414
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, wxPayHttpProxy);
400415

weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/SignatureExec.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,27 @@
1515
import org.apache.http.util.EntityUtils;
1616

1717
import java.io.IOException;
18+
import java.util.Collections;
19+
import java.util.Set;
1820

1921
public class SignatureExec implements ClientExecChain {
2022
final ClientExecChain mainExec;
2123
final Credentials credentials;
2224
final Validator validator;
25+
/**
26+
* 额外受信任的主机列表,这些主机(如反向代理)也需要携带微信支付 Authorization 头
27+
*/
28+
final Set<String> trustedHosts;
2329

2430
SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) {
31+
this(credentials, validator, mainExec, Collections.emptySet());
32+
}
33+
34+
SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec, Set<String> trustedHosts) {
2535
this.credentials = credentials;
2636
this.validator = validator;
2737
this.mainExec = mainExec;
38+
this.trustedHosts = trustedHosts != null ? trustedHosts : Collections.emptySet();
2839
}
2940

3041
protected HttpEntity newRepeatableEntity(HttpEntity entity) throws IOException {
@@ -56,7 +67,8 @@ protected void convertToRepeatableRequestEntity(HttpRequestWrapper request) thro
5667
public CloseableHttpResponse execute(HttpRoute route, HttpRequestWrapper request,
5768
HttpClientContext context, HttpExecutionAware execAware)
5869
throws IOException, HttpException {
59-
if (request.getURI().getHost() != null && request.getURI().getHost().endsWith(".mch.weixin.qq.com")) {
70+
String host = request.getURI().getHost();
71+
if (host != null && (host.endsWith(".mch.weixin.qq.com") || trustedHosts.contains(host))) {
6072
return executeWithSignature(route, request, context, execAware);
6173
} else {
6274
return mainExec.execute(route, request, context, execAware);

weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/WxPayV3HttpClientBuilder.java

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33

44
import java.security.PrivateKey;
5+
import java.util.Collections;
6+
import java.util.HashSet;
7+
import java.util.Set;
58

69
import com.github.binarywang.wxpay.v3.auth.PrivateKeySigner;
710
import com.github.binarywang.wxpay.v3.auth.WxPayCredentials;
@@ -12,6 +15,10 @@
1215
public class WxPayV3HttpClientBuilder extends HttpClientBuilder {
1316
private Credentials credentials;
1417
private Validator validator;
18+
/**
19+
* 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头
20+
*/
21+
private final Set<String> trustedHosts = new HashSet<>();
1522

1623
static final String OS = System.getProperty("os.name") + "/" + System.getProperty("os.version");
1724
static final String VERSION = System.getProperty("java.version");
@@ -47,6 +54,39 @@ public WxPayV3HttpClientBuilder withValidator(Validator validator) {
4754
return this;
4855
}
4956

57+
/**
58+
* 添加受信任的主机,对该主机的请求也会携带微信支付 Authorization 头.
59+
* 适用于通过反向代理(如 Nginx)转发微信支付 API 请求的场景,
60+
* 当 apiHostUrl 配置为代理地址时,需要将代理主机加入受信任列表,
61+
* 以确保 Authorization 头能正确传递到代理服务器。
62+
* 若传入值包含端口(如 "proxy.company.com:8080"),会自动提取主机名部分。
63+
*
64+
* @param host 受信任的主机(可含端口),例如 "proxy.company.com" 或 "proxy.company.com:8080"
65+
* @return 当前 Builder 实例
66+
*/
67+
public WxPayV3HttpClientBuilder withTrustedHost(String host) {
68+
if (host == null) {
69+
return this;
70+
}
71+
String trimmed = host.trim();
72+
if (trimmed.isEmpty()) {
73+
return this;
74+
}
75+
// 若包含端口号(如 "host:8080"),只取主机名部分
76+
int colonIdx = trimmed.lastIndexOf(':');
77+
if (colonIdx > 0) {
78+
String portPart = trimmed.substring(colonIdx + 1);
79+
boolean isPort = !portPart.isEmpty() && portPart.chars().allMatch(Character::isDigit);
80+
if (isPort) {
81+
trimmed = trimmed.substring(0, colonIdx);
82+
}
83+
}
84+
if (!trimmed.isEmpty()) {
85+
this.trustedHosts.add(trimmed);
86+
}
87+
return this;
88+
}
89+
5090
@Override
5191
public CloseableHttpClient build() {
5292
if (credentials == null) {
@@ -61,6 +101,7 @@ public CloseableHttpClient build() {
61101

62102
@Override
63103
protected ClientExecChain decorateProtocolExec(final ClientExecChain requestExecutor) {
64-
return new SignatureExec(this.credentials, this.validator, requestExecutor);
104+
return new SignatureExec(this.credentials, this.validator, requestExecutor,
105+
Collections.unmodifiableSet(new HashSet<>(this.trustedHosts)));
65106
}
66107
}

weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/AutoUpdateCertificatesVerifier.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import java.io.ByteArrayInputStream;
2424
import java.io.IOException;
25+
import java.net.URI;
26+
import java.net.URISyntaxException;
2527
import java.nio.charset.StandardCharsets;
2628
import java.security.GeneralSecurityException;
2729
import java.security.cert.CertificateExpiredException;
@@ -154,8 +156,21 @@ private void autoUpdateCert() throws IOException, GeneralSecurityException {
154156
.withCredentials(credentials)
155157
.withValidator(verifier == null ? response -> true : new WxPayValidator(verifier));
156158

159+
// 当 payBaseUrl 配置为自定义代理地址时,将代理主机加入受信任列表,
160+
// 确保 Authorization 头能正确发送到代理服务器
161+
if (this.payBaseUrl != null && !this.payBaseUrl.isEmpty()) {
162+
try {
163+
String host = new URI(this.payBaseUrl).getHost();
164+
if (host != null && !host.endsWith(".mch.weixin.qq.com")) {
165+
wxPayV3HttpClientBuilder.withTrustedHost(host);
166+
}
167+
} catch (URISyntaxException e) {
168+
log.warn("解析 payBaseUrl [{}] 中的主机名失败: {}", this.payBaseUrl, e.getMessage());
169+
}
170+
}
171+
157172
//调用自定义扩展设置设置HTTP PROXY对象
158-
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder,this.wxPayHttpProxy);
173+
HttpProxyUtils.initHttpProxy(wxPayV3HttpClientBuilder, this.wxPayHttpProxy);
159174

160175
//增加自定义扩展点,子类可以设置其他构造参数
161176
this.customHttpClientBuilder(wxPayV3HttpClientBuilder);
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package com.github.binarywang.wxpay.v3;
2+
3+
import org.apache.http.HttpException;
4+
import org.apache.http.ProtocolVersion;
5+
import org.apache.http.client.methods.CloseableHttpResponse;
6+
import org.apache.http.client.methods.HttpGet;
7+
import org.apache.http.client.methods.HttpRequestWrapper;
8+
import org.apache.http.client.protocol.HttpClientContext;
9+
import org.apache.http.impl.execchain.ClientExecChain;
10+
import org.apache.http.message.BasicHttpResponse;
11+
import org.apache.http.message.BasicStatusLine;
12+
import org.testng.annotations.Test;
13+
14+
import java.io.IOException;
15+
import java.util.Collections;
16+
import java.util.HashSet;
17+
import java.util.Set;
18+
import java.util.concurrent.atomic.AtomicBoolean;
19+
20+
import static org.testng.Assert.*;
21+
22+
/**
23+
* 测试 SignatureExec 的受信任主机功能,确保在代理转发场景下正确添加 Authorization 头
24+
*
25+
* @author GitHub Copilot
26+
*/
27+
public class SignatureExecTrustedHostTest {
28+
29+
/**
30+
* 最简 CloseableHttpResponse 实现,仅用于单元测试
31+
*/
32+
private static class StubCloseableHttpResponse extends BasicHttpResponse implements CloseableHttpResponse {
33+
StubCloseableHttpResponse() {
34+
super(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
35+
}
36+
37+
@Override
38+
public void close() {
39+
}
40+
}
41+
42+
/**
43+
* 创建一个测试用的 Credentials,始终返回固定 schema 和 token
44+
*/
45+
private static Credentials createTestCredentials() {
46+
return new Credentials() {
47+
@Override
48+
public String getSchema() {
49+
return "WECHATPAY2-SHA256-RSA2048";
50+
}
51+
52+
@Override
53+
public String getToken(HttpRequestWrapper request) {
54+
return "test_token";
55+
}
56+
};
57+
}
58+
59+
/**
60+
* 创建一个 ClientExecChain,记录请求是否携带了 Authorization 头
61+
*/
62+
private static ClientExecChain trackingExec(AtomicBoolean authHeaderAdded) {
63+
return (route, request, context, execAware) -> {
64+
if (request.containsHeader("Authorization")) {
65+
authHeaderAdded.set(true);
66+
}
67+
return new StubCloseableHttpResponse();
68+
};
69+
}
70+
71+
/**
72+
* 测试:对微信官方主机(以 .mch.weixin.qq.com 结尾)的请求应该添加 Authorization 头
73+
*/
74+
@Test
75+
public void testWechatOfficialHostShouldAddAuthorizationHeader() throws IOException, HttpException {
76+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
77+
SignatureExec signatureExec = new SignatureExec(
78+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
79+
);
80+
81+
HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates");
82+
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
83+
84+
assertTrue(authHeaderAdded.get(), "请求微信官方接口时应该添加 Authorization 头");
85+
}
86+
87+
/**
88+
* 测试:对非微信主机且不在受信任列表中的请求,不应该添加 Authorization 头
89+
*/
90+
@Test
91+
public void testUntrustedProxyHostShouldNotAddAuthorizationHeader() throws IOException, HttpException {
92+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
93+
SignatureExec signatureExec = new SignatureExec(
94+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
95+
);
96+
97+
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
98+
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
99+
100+
assertFalse(authHeaderAdded.get(), "不受信任的代理主机请求不应该添加 Authorization 头");
101+
}
102+
103+
/**
104+
* 测试:对在受信任列表中的代理主机请求,应该添加 Authorization 头.
105+
* 这是修复代理转发场景下 Authorization 头丢失问题的核心功能
106+
*/
107+
@Test
108+
public void testTrustedProxyHostShouldAddAuthorizationHeader() throws IOException, HttpException {
109+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
110+
Set<String> trustedHosts = new HashSet<>();
111+
trustedHosts.add("proxy.company.com");
112+
SignatureExec signatureExec = new SignatureExec(
113+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts
114+
);
115+
116+
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
117+
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
118+
119+
assertTrue(authHeaderAdded.get(), "受信任的代理主机请求应该添加 Authorization 头");
120+
}
121+
122+
/**
123+
* 测试:WxPayV3HttpClientBuilder 的 withTrustedHost 方法支持链式调用
124+
*/
125+
@Test
126+
public void testWithTrustedHostSupportsChainingCall() {
127+
WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create();
128+
// 方法应该返回同一实例以支持链式调用
129+
WxPayV3HttpClientBuilder result = builder.withTrustedHost("proxy.company.com");
130+
assertSame(result, builder, "withTrustedHost 应该返回当前 Builder 实例(支持链式调用)");
131+
}
132+
133+
/**
134+
* 测试:withTrustedHost 传入含端口的地址时应自动提取主机名并正确影响签名行为
135+
*/
136+
@Test
137+
public void testWithTrustedHostWithPortShouldStripPort() throws IOException, HttpException {
138+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
139+
SignatureExec signatureExec = new SignatureExec(
140+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), Collections.emptySet()
141+
);
142+
// 直接验证:SignatureExec 的主机匹配逻辑使用 URI.getHost(),不含端口
143+
// 因此只要 trustedHosts 中存有 "proxy.company.com",对 proxy.company.com:8080 的请求也应签名
144+
Set<String> trustedHosts = new HashSet<>();
145+
trustedHosts.add("proxy.company.com");
146+
SignatureExec execWithPort = new SignatureExec(
147+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded), trustedHosts
148+
);
149+
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/pay/transactions/native");
150+
execWithPort.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
151+
assertTrue(authHeaderAdded.get(), "含端口的代理请求匹配受信任主机后应添加 Authorization 头");
152+
}
153+
154+
/**
155+
* 测试:withTrustedHost 传入空值不应该抛出异常
156+
*/
157+
@Test
158+
public void testWithTrustedHostNullOrEmptyShouldNotThrow() {
159+
WxPayV3HttpClientBuilder builder = WxPayV3HttpClientBuilder.create();
160+
// 传入 null 和空字符串不应该抛出异常
161+
builder.withTrustedHost(null);
162+
builder.withTrustedHost("");
163+
}
164+
165+
/**
166+
* 测试:withTrustedHost 传入带端口的地址(如 "proxy.company.com:8080")时应自动提取主机名.
167+
* WxPayV3HttpClientBuilder 应将端口剥离后存入受信任列表,
168+
* 使得发往该主机的请求(URI.getHost() 不含端口)也能正确匹配并携带 Authorization 头
169+
*/
170+
@Test
171+
public void testWithTrustedHostBuilderStripsPort() throws IOException, HttpException {
172+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
173+
// 传入带端口的主机,builder 应自动提取主机名
174+
SignatureExec signatureExec = new SignatureExec(
175+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded),
176+
Collections.singleton("proxy.company.com")
177+
);
178+
HttpGet httpGet = new HttpGet("http://proxy.company.com:8080/v3/certificates");
179+
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
180+
assertTrue(authHeaderAdded.get(), "builder 自动提取主机名后,对应代理请求应携带 Authorization 头");
181+
}
182+
183+
/**
184+
* 测试:SignatureExec 的旧构造函数(不带 trustedHosts)应该仍然有效
185+
*/
186+
@Test
187+
public void testBackwardCompatibilityWithOldConstructor() throws IOException, HttpException {
188+
AtomicBoolean authHeaderAdded = new AtomicBoolean(false);
189+
// 使用旧的三参数构造函数
190+
SignatureExec signatureExec = new SignatureExec(
191+
createTestCredentials(), response -> true, trackingExec(authHeaderAdded)
192+
);
193+
194+
// 微信官方主机仍然应该添加 Authorization 头
195+
HttpGet httpGet = new HttpGet("https://api.mch.weixin.qq.com/v3/certificates");
196+
signatureExec.execute(null, HttpRequestWrapper.wrap(httpGet), HttpClientContext.create(), null);
197+
198+
assertTrue(authHeaderAdded.get(), "使用旧构造函数时,请求微信官方接口仍应添加 Authorization 头");
199+
}
200+
}

0 commit comments

Comments
 (0)