|
@@ -0,0 +1,136 @@
|
|
|
|
|
+package com.payment.platform.core.security;
|
|
|
|
|
+
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 签名生成器回归测试 — 对齐 Python 原版 json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
+ *
|
|
|
|
|
+ * 基准数据来自生产调用方反馈的"按文档签名失败"样例:
|
|
|
|
|
+ * 请求体 payee_info = {identity_type: ALIPAY_LOGON_ID, name: 李照如, identity: 17527527677}
|
|
|
|
|
+ * 文档签名串中 payee_info 段 (键已按字典序排序, 中文为原始 UTF-8 百分号编码):
|
|
|
|
|
+ * payee_info=%7B%22identity%22%3A%2217527527677%22%2C%22identity_type%22%3A%22ALIPAY_LOGON_ID%22%2C%22name%22%3A%22%E6%9D%8E%E7%85%A7%E5%A6%82%22%7D
|
|
|
|
|
+ * 解码后 = {"identity":"17527527677","identity_type":"ALIPAY_LOGON_ID","name":"李照如"}
|
|
|
|
|
+ */
|
|
|
|
|
+class SignatureGeneratorTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 调用方文档签名样例中的 payee_info 段(键排序 + 原始 UTF-8 编码后的 ground truth) */
|
|
|
|
|
+ private static final String EXPECTED_PAYEE_INFO_ENCODED =
|
|
|
|
|
+ "%7B%22identity%22%3A%2217527527677%22%2C%22identity_type%22%3A%22ALIPAY_LOGON_ID%22%2C%22name%22%3A%22%E6%9D%8E%E7%85%A7%E5%A6%82%22%7D";
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 期望签名 — 由独立工具 (PowerShell System.Security.Cryptography.HMACSHA256) 对完整 sign_str 预计算:
|
|
|
|
|
+ * sign_str = account_book_id=2088880266599294&amount=0.20&payee_info=<上述段>
|
|
|
|
|
+ * HMAC-SHA256("test-secret-123", sign_str) 小写 hex
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final String EXPECTED_SIGNATURE =
|
|
|
|
|
+ "eeb135015ed8e35311b60870c741d7b390f40919374e924dba5e9546e58ae5e8";
|
|
|
|
|
+
|
|
|
|
|
+ /** 构造与调用方请求体字段顺序一致的嵌套 Map (LinkedHashMap 保持 JSON 解析后的插入序) */
|
|
|
|
|
+ private Map<String, Object> sampleRequest() {
|
|
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
|
|
+ data.put("account_book_id", "2088880266599294");
|
|
|
|
|
+ data.put("amount", "0.20");
|
|
|
|
|
+ Map<String, Object> payeeInfo = new LinkedHashMap<>();
|
|
|
|
|
+ payeeInfo.put("identity_type", "ALIPAY_LOGON_ID"); // 故意用请求体原序, 非字典序
|
|
|
|
|
+ payeeInfo.put("name", "李照如");
|
|
|
|
|
+ payeeInfo.put("identity", "17527527677");
|
|
|
|
|
+ data.put("payee_info", payeeInfo);
|
|
|
|
|
+ return data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 核心回归: 嵌套 dict 必须按键排序 (Python sort_keys=True),
|
|
|
|
|
+ * 中文必须为原始 UTF-8 编码 (Python 文档规范, 非 ensure_ascii 转义)。
|
|
|
|
|
+ * 修复前 JSONUtil.toJsonStr 输出 {"identity_type":"ALIPAY_LOGON_ID","name":"李照如","identity":"..."}
|
|
|
|
|
+ * (请求体原序) → 与调用方文档签名不一致 → 401 Invalid Signature。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void nestedDictKeysAreSortedLikePython() {
|
|
|
|
|
+ String signature = SignatureGenerator.generateSignature("test-secret-123", sampleRequest());
|
|
|
|
|
+ // 签名串中 payee_info 段必须与调用方文档签名完全一致
|
|
|
|
|
+ assertTrue(signature != null && !signature.isEmpty());
|
|
|
|
|
+ assertEquals("eeb135015ed8e35311b60870c741d7b390f40919374e924dba5e9546e58ae5e8",
|
|
|
|
|
+ signature, "完整签名必须等于独立预计算值 (含排序后的 payee_info 段)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 逐段断言: 单独验证 payee_info 的编码结果 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void payeeInfoEncodedMatchesDocumentedExample() {
|
|
|
|
|
+ Map<String, Object> data = Map.of("payee_info", sampleRequest().get("payee_info"));
|
|
|
|
|
+ String signature = SignatureGenerator.generateSignature("test-secret-123", data);
|
|
|
|
|
+ // HMAC 无法直接反推 sign_str, 改为用 verifySignature + 手工构造期望签名来验证编码段
|
|
|
|
|
+ // 这里通过构造"期望签名字符串"反向验证: 计算按文档编码的 sign_str 的 HMAC
|
|
|
|
|
+ // sign_str = payee_info=<EXPECTED_PAYEE_INFO_ENCODED>
|
|
|
|
|
+ String expectedSignStr = "payee_info=" + EXPECTED_PAYEE_INFO_ENCODED;
|
|
|
|
|
+ String expectedSig = hmacSha256Hex("test-secret-123", expectedSignStr);
|
|
|
|
|
+ assertEquals(expectedSig, signature);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 空嵌套对象被过滤 (Python L45-46), 空 body 时签名 = HMAC("") */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void emptyNestedObjectIsFiltered() {
|
|
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
|
|
+ data.put("payee_info", Map.of()); // 空 dict → 过滤
|
|
|
|
|
+ data.put("tags", List.of()); // 空 list → 过滤
|
|
|
|
|
+ data.put("remark", ""); // 空串 → 过滤
|
|
|
|
|
+ data.put("amount", "0.20"); // 保留
|
|
|
|
|
+ String signature = SignatureGenerator.generateSignature("test-secret-123", data);
|
|
|
|
|
+ assertEquals(hmacSha256Hex("test-secret-123", "amount=0.20"), signature,
|
|
|
|
|
+ "空值/空集合必须被排除, 只对保留参数签名");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 嵌套 list 同样按键排序 (list 内 dict 元素递归排序), 且序列化后整体再过 URL 编码 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void nestedListItemsAreSorted() {
|
|
|
|
|
+ Map<String, Object> inner = new LinkedHashMap<>();
|
|
|
|
|
+ inner.put("b", "2");
|
|
|
|
|
+ inner.put("a", "1");
|
|
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
|
|
+ data.put("items", List.of(inner));
|
|
|
|
|
+ String signature = SignatureGenerator.generateSignature("test-secret-123", data);
|
|
|
|
|
+ // 排序后 JSON = [{"a":"1","b":"2"}] → URL 编码 → sign_str =
|
|
|
|
|
+ // items=%5B%7B%22a%22%3A%221%22%2C%22b%22%3A%222%22%7D%5D
|
|
|
|
|
+ String signStr = "items=%5B%7B%22a%22%3A%221%22%2C%22b%22%3A%222%22%7D%5D";
|
|
|
|
|
+ assertEquals(hmacSha256Hex("test-secret-123", signStr), signature);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** urlEncode 对齐 Python quote(safe=""): ~ 保留, * 转义为 %2A */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void urlEncodeAlignsWithPythonQuote() {
|
|
|
|
|
+ assertEquals("a~b%2Ac%20d", SignatureGenerator.urlEncode("a~b*c d"),
|
|
|
|
|
+ "~ 保留、* 转义 %2A、空格 %20 — 对齐 urllib.parse.quote(safe=\"\")");
|
|
|
|
|
+ assertEquals("order_title%3D%E6%94%B6%E7%9B%8A", SignatureGenerator.urlEncode("order_title=收益"),
|
|
|
|
|
+ "中文按 UTF-8 字节大写 %XX, 与调用方文档示例一致");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 验证流程: 正确签名通过, 篡改后失败 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void verifySignatureRoundTrip() {
|
|
|
|
|
+ Map<String, Object> data = sampleRequest();
|
|
|
|
|
+ String signature = SignatureGenerator.generateSignature("test-secret-123", data);
|
|
|
|
|
+ assertTrue(SignatureGenerator.verifySignature("test-secret-123", data, signature));
|
|
|
|
|
+ assertFalse(SignatureGenerator.verifySignature("test-secret-123", data, "deadbeef"));
|
|
|
|
|
+ assertFalse(SignatureGenerator.verifySignature("wrong-secret", data, signature));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 工具: 与 Java 实现无关的独立 HMAC 计算 (镜像 Python hmac.new) ----
|
|
|
|
|
+
|
|
|
|
|
+ private static String hmacSha256Hex(String secret, String data) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
|
|
|
|
+ mac.init(new javax.crypto.spec.SecretKeySpec(
|
|
|
|
|
+ secret.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"));
|
|
|
|
|
+ return java.util.HexFormat.of().formatHex(mac.doFinal(data.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new RuntimeException(e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|