diff --git a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java index cc88caf75..d636a56bb 100644 --- a/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java +++ b/weixin-java-pay/src/main/java/com/github/binarywang/wxpay/v3/auth/WxPayValidator.java @@ -11,16 +11,58 @@ import org.apache.http.util.EntityUtils; 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 { - private final Verifier verifier; + /** + * 默认允许的应答时间戳偏差(秒),与微信支付官方 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); + } + + /** + * 构造校验器并指定允许的应答时间戳偏差。 + *

+ * 校验签名之前会先比较应答头 Wechatpay-Timestamp 与本地时间, + * 二者之差(绝对值)超过容差的应答视为验签失败,以防止重放攻击。 + *

+ * + * @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 @@ -34,15 +76,52 @@ public class WxPayValidator implements Validator { 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(); diff --git a/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java new file mode 100644 index 000000000..d057b276e --- /dev/null +++ b/weixin-java-pay/src/test/java/com/github/binarywang/wxpay/v3/auth/WxPayValidatorTest.java @@ -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)); + } +} diff --git a/weixin-java-pay/src/test/resources/testng.xml b/weixin-java-pay/src/test/resources/testng.xml index 180aa0821..f4f94f059 100644 --- a/weixin-java-pay/src/test/resources/testng.xml +++ b/weixin-java-pay/src/test/resources/testng.xml @@ -53,6 +53,7 @@ +