🎨 #3910 【微信支付】修复代理转发场景下 V3 API Authorization 头丢失导致 401的问题
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 头");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user