1
0
mirror of synced 2026-03-24 22:03:02 +08:00

🎨 #3910 【微信支付】修复代理转发场景下 V3 API Authorization 头丢失导致 401的问题

This commit is contained in:
Copilot
2026-03-12 23:01:10 +08:00
committed by GitHub
parent feaf90e361
commit 4b2383c14e
5 changed files with 286 additions and 3 deletions

View File

@@ -32,6 +32,8 @@ import org.apache.http.ssl.SSLContexts;
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 class WxPayConfig {
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);

View File

@@ -15,16 +15,27 @@ import org.apache.http.impl.execchain.ClientExecChain;
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<String> trustedHosts;
SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec) {
this(credentials, validator, mainExec, Collections.emptySet());
}
SignatureExec(Credentials credentials, Validator validator, ClientExecChain mainExec, Set<String> 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 @@ public class SignatureExec implements ClientExecChain {
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);

View File

@@ -2,6 +2,9 @@ package com.github.binarywang.wxpay.v3;
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 @@ import org.apache.http.impl.execchain.ClientExecChain;
public class WxPayV3HttpClientBuilder extends HttpClientBuilder {
private Credentials credentials;
private Validator validator;
/**
* 额外受信任的主机列表,用于代理转发场景:对这些主机的请求也会携带微信支付 Authorization 头
*/
private final Set<String> 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 class WxPayV3HttpClientBuilder extends HttpClientBuilder {
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 class WxPayV3HttpClientBuilder extends HttpClientBuilder {
@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)));
}
}

View File

@@ -22,6 +22,8 @@ import org.apache.http.util.EntityUtils;
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 @@ public class AutoUpdateCertificatesVerifier implements Verifier {
.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);

View File

@@ -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<String> 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<String> 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 头");
}
}