Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,58 @@

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;

/**
* @author spvycf & F00lish
*/
@Slf4j
public class WxPayValidator implements Validator {
/**
* 默认允许的应答时间戳偏差(秒),与微信支付官方 SDK 保持一致:5 分钟。
*/
public static final long DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = TimeUnit.MINUTES.toSeconds(5);

private final Verifier verifier;
private final long timestampToleranceSeconds;
private final LongSupplier currentTimeSeconds;

/**
* 使用默认的时间戳容差({@link #DEFAULT_TIMESTAMP_TOLERANCE_SECONDS})构造校验器。
*
* @param verifier 平台证书验签器
*/
public WxPayValidator(Verifier verifier) {
this(verifier, DEFAULT_TIMESTAMP_TOLERANCE_SECONDS);
}

/**
* 构造校验器并指定允许的应答时间戳偏差。
* <p>
* 校验签名之前会先比较应答头 Wechatpay-Timestamp 与本地时间,
* 二者之差(绝对值)超过容差的应答视为验签失败,以防止重放攻击。
* </p>
*
* @param verifier 平台证书验签器
* @param timestampToleranceSeconds 允许的时间戳偏差(秒);传 0 或负数表示关闭时间戳校验,
* 仅建议在本地时钟确实无法与微信服务器保持同步的环境中使用
*/
public WxPayValidator(Verifier verifier, long timestampToleranceSeconds) {
this(verifier, timestampToleranceSeconds, () -> TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()));
}

/**
* 供单元测试注入固定时钟使用。
*
* @param verifier 平台证书验签器
* @param timestampToleranceSeconds 允许的时间戳偏差(秒),0 或负数表示关闭校验
* @param currentTimeSeconds 当前时间(秒)的来源
*/
WxPayValidator(Verifier verifier, long timestampToleranceSeconds, LongSupplier currentTimeSeconds) {
this.verifier = verifier;
this.timestampToleranceSeconds = timestampToleranceSeconds;
this.currentTimeSeconds = currentTimeSeconds;
}

@Override
Expand All @@ -34,15 +76,52 @@ public final boolean validate(CloseableHttpResponse response) throws IOException
Header timestamp = response.getFirstHeader("Wechatpay-TimeStamp");
Header nonce = response.getFirstHeader("Wechatpay-Nonce");

// todo: check timestamp
if (timestamp == null || nonce == null || serialNo == null || sign == null) {
return false;
}

if (!isTimestampFresh(timestamp.getValue())) {
return false;
}

String message = buildMessage(response);
return verifier.verify(serialNo.getValue(), message.getBytes(StandardCharsets.UTF_8), sign.getValue());
}

/**
* 校验应答时间戳是否在允许的偏差范围内。
*
* @param timestamp 应答头 Wechatpay-Timestamp 的值(秒级时间戳)
* @return 时间戳合法且与本地时间之差不超过容差时返回 true;容差为 0 或负数时恒为 true
*/
private boolean isTimestampFresh(String timestamp) {
if (this.timestampToleranceSeconds <= 0) {
return true;
}

final long responseTime;
try {
responseTime = Long.parseLong(timestamp.trim());
} catch (NumberFormatException e) {
log.warn("微信支付应答时间戳格式非法,拒绝该应答: Wechatpay-Timestamp={}", timestamp);
return false;
}

// 秒级 Unix 时间戳不可能为负;先拒绝负值,后面的减法和 Math.abs 才不会溢出
if (responseTime < 0) {
log.warn("微信支付应答时间戳为负数,拒绝该应答: Wechatpay-Timestamp={}", timestamp);
return false;
}

long offset = Math.abs(this.currentTimeSeconds.getAsLong() - responseTime);
if (offset > this.timestampToleranceSeconds) {
log.warn("微信支付应答时间戳超出允许范围,拒绝该应答以防止重放: Wechatpay-Timestamp={}, 与本地时间相差 {} 秒, 允许偏差 {} 秒",
timestamp, offset, this.timestampToleranceSeconds);
return false;
}
return true;
}

protected final String buildMessage(CloseableHttpResponse response) throws IOException {
String timestamp = response.getFirstHeader("Wechatpay-TimeStamp").getValue();
String nonce = response.getFirstHeader("Wechatpay-Nonce").getValue();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.github.binarywang.wxpay.v3.auth;

import org.apache.http.ProtocolVersion;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.testng.annotations.Test;

import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.concurrent.TimeUnit;

import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;

/**
* 测试 WxPayValidator 对应答时间戳的新鲜度校验(防重放)。
*
* @author sisyfy-Zhang
*/
public class WxPayValidatorTest {

/**
* 固定的"当前时间"(秒),便于构造超前/滞后的应答时间戳
*/
private static final long NOW_SECONDS = 1_700_000_000L;

private static final long FIVE_MINUTES = TimeUnit.MINUTES.toSeconds(5);
private static final long SIX_MINUTES = TimeUnit.MINUTES.toSeconds(6);
private static final long TEN_MINUTES = TimeUnit.MINUTES.toSeconds(10);

/**
* 最简 CloseableHttpResponse 实现,仅用于单元测试
*/
private static class StubCloseableHttpResponse extends BasicHttpResponse implements CloseableHttpResponse {
StubCloseableHttpResponse() {
super(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
}

@Override
public void close() {
}
}

/**
* 永远验签通过的 Verifier,用于把测试聚焦在时间戳校验上
*/
private static final Verifier ALWAYS_VALID = new Verifier() {
@Override
public boolean verify(String serialNumber, byte[] message, String signature) {
return true;
}

@Override
public X509Certificate getValidCertificate() {
return null;
}
};

private static WxPayValidator validator(long toleranceSeconds) {
return new WxPayValidator(ALWAYS_VALID, toleranceSeconds, () -> NOW_SECONDS);
}

private static CloseableHttpResponse jsonResponse(String timestamp) {
StubCloseableHttpResponse response = new StubCloseableHttpResponse();
response.setHeader("Content-Type", ContentType.APPLICATION_JSON.toString());
if (timestamp != null) {
response.setHeader("Wechatpay-Timestamp", timestamp);
}
response.setHeader("Wechatpay-Nonce", "nonce");
response.setHeader("Wechatpay-Signature", "signature");
response.setHeader("Wechatpay-Serial", "SERIAL");
response.setEntity(new StringEntity("{\"code\":\"OK\"}", ContentType.APPLICATION_JSON));
return response;
}

@Test
public void testFreshTimestampPasses() throws IOException {
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS))));
}

@Test
public void testTimestampAtToleranceBoundaryPasses() throws IOException {
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - FIVE_MINUTES))));
assertTrue(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS + FIVE_MINUTES))));
}

@Test
public void testStaleTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - SIX_MINUTES))),
"滞后超过容差的应答应被判定为验签失败");
}

@Test
public void testFutureTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS + SIX_MINUTES))),
"超前超过容差的应答应被判定为验签失败");
}

@Test
public void testNonNumericTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse("not-a-number")));
}

@Test
public void testNegativeTimestampIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse("-1")));
// 差值会溢出成 Long.MIN_VALUE,Math.abs 仍为负数;若不先拒绝负值,该时间戳会被误判为新鲜
long overflowing = Long.MIN_VALUE + NOW_SECONDS;
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(String.valueOf(overflowing))),
"导致减法溢出的时间戳必须被拒绝");
}

@Test
public void testMissingTimestampHeaderIsRejected() throws IOException {
assertFalse(validator(FIVE_MINUTES).validate(jsonResponse(null)));
}

@Test
public void testCustomToleranceIsHonoured() throws IOException {
assertTrue(validator(TEN_MINUTES).validate(jsonResponse(String.valueOf(NOW_SECONDS - SIX_MINUTES))),
"容差放宽到 10 分钟后,滞后 6 分钟的应答应通过");
}

@Test
public void testZeroToleranceDisablesTimestampCheck() throws IOException {
assertTrue(validator(0).validate(jsonResponse(String.valueOf(NOW_SECONDS - TEN_MINUTES))),
"容差为 0 表示关闭时间戳校验");
assertTrue(validator(0).validate(jsonResponse("not-a-number")),
"关闭校验后不再解析时间戳");
}

@Test
public void testDefaultConstructorUsesFiveMinutes() throws IOException {
long nowSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
WxPayValidator validator = new WxPayValidator(ALWAYS_VALID);
assertTrue(validator.validate(jsonResponse(String.valueOf(nowSeconds))));
assertFalse(validator.validate(jsonResponse(String.valueOf(nowSeconds - SIX_MINUTES))));
}

@Test
public void testNonJsonResponseSkipsValidation() throws IOException {
StubCloseableHttpResponse response = new StubCloseableHttpResponse();
response.setHeader("Content-Type", ContentType.APPLICATION_OCTET_STREAM.toString());
// 非 JSON 应答(如下载账单)不做验签,这是既有行为,时间戳校验不应改变它
assertTrue(validator(FIVE_MINUTES).validate(response));
}
}
1 change: 1 addition & 0 deletions weixin-java-pay/src/test/resources/testng.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<class name="com.github.binarywang.wxpay.util.SignUtilsTest"/>
<class name="com.github.binarywang.wxpay.v3.SignatureExecTrustedHostTest"/>
<class name="com.github.binarywang.wxpay.v3.auth.AutoUpdateCertificatesVerifierPublicKeyModeTest"/>
<class name="com.github.binarywang.wxpay.v3.auth.WxPayValidatorTest"/>
<class name="com.github.binarywang.wxpay.v3.util.RsaCryptoUtilTest"/>
</classes>
</test>
Expand Down
Loading