T1.2: SignVerifyFilter v1(canonical 剥 target 首段+解码排序 query,复用 starter SignUtil ±5min;observe 只记不拦/非 observe 裸 401;共享向量为 starter 真实代码执行产出 v1.json 逐字节对拍)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.route.GatewayProperties;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SignVerifyFilter v1 判定用例(T1.2 TDD):canonical 与 starter 逐字节一致
|
||||
* (剥 target 首段后的 path + 解码排序 query + timestamp,{@code shared-sign-vectors/v1.json} 同源对拍);
|
||||
* observe=true 只记不拦(verdict=FAIL_OBSERVED 放行),observe=false 坏签名裸 401;
|
||||
* 时间窗 ±5min(F2,不得更宽)。
|
||||
*/
|
||||
class SignVerifyFilterTest {
|
||||
|
||||
private static final String SECRET = "sec-alpha-0123456789abcdef";
|
||||
|
||||
private SignVerifyFilter filter(boolean observe) {
|
||||
SignVerifyFilter f = new SignVerifyFilter();
|
||||
GatewayKeyService keyService = mock(GatewayKeyService.class);
|
||||
when(keyService.getSecret("app1")).thenReturn(SECRET);
|
||||
GatewayProperties props = new GatewayProperties();
|
||||
props.setObserve(observe);
|
||||
f.setKeyService(keyService);
|
||||
f.setProperties(props);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 模拟已过 SanitizeFilter 的网关请求:attribute 契约 + 三签名头 */
|
||||
private MockHttpServletRequest request(String method, String strippedPath, String query,
|
||||
String appKey, String ts, String sign) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest(method, "/userbase" + strippedPath);
|
||||
if (query != null) {
|
||||
req.setQueryString(query);
|
||||
}
|
||||
req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, strippedPath);
|
||||
if (appKey != null) {
|
||||
req.addHeader("X-App-Key", appKey);
|
||||
}
|
||||
if (ts != null) {
|
||||
req.addHeader("X-Timestamp", ts);
|
||||
}
|
||||
if (sign != null) {
|
||||
req.addHeader("X-Signature", sign);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
private MockHttpServletResponse run(SignVerifyFilter f, MockHttpServletRequest req,
|
||||
MockFilterChain chain) throws Exception {
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
f.doFilter(req, res, chain);
|
||||
return res;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 合法签名判定PASS且放行() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
|
||||
MockHttpServletResponse res = run(filter(true), req, chain);
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
assertThat(chain.getRequest()).as("链条继续").isNotNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("供 T1.4 身份注入").isEqualTo("app1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 合法签名含排序query判定PASS() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
// 客户端按解码排序后 query 签名(starter sortRawQuery 同语义)
|
||||
String sign = SignUtil.signRequest("GET", "/oapi/user/byId?id=7&name=zhang san", ts, SECRET);
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/user/byId", "name=zhang%20san&id=7", "app1", ts, sign);
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
assertThat(res.getStatus()).as("乱序 query 经网关归一后仍 PASS").isEqualTo(200);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 坏签名observe下放行且记FAIL() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
|
||||
MockHttpServletResponse res = run(filter(true), req, chain);
|
||||
assertThat(res.getStatus()).as("观察模式不拦").isEqualTo(200);
|
||||
assertThat(chain.getRequest()).isNotNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))
|
||||
.as("台账 verdict=FAIL(观察形态)").isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 坏签名非observe下裸401() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
|
||||
assertThat(chain.getRequest()).as("链条中断").isNull();
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 头缺失判定FAIL() throws Exception {
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
assertThat(res.getStatus()).as("三头缺失 = FAIL 裸 401").isEqualTo(401);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未知appKey判定FAIL() throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
// appKey=ghost:mock 的 getSecret 返回 null(未知/禁用 fail-closed)
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "ghost", ts, sign);
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 时间窗超出判定FAIL() throws Exception {
|
||||
// ±5min(F2):6min 前签名 + 正确算法也必须 FAIL
|
||||
String ts = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L);
|
||||
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
|
||||
MockHttpServletResponse res = run(filter(false), req, chain);
|
||||
assertThat(res.getStatus()).as("时间窗超出 = FAIL 裸 401").isEqualTo(401);
|
||||
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 共享向量与starter逐字节一致() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode root;
|
||||
try (InputStream in = getClass().getResourceAsStream("/shared-sign-vectors/v1.json")) {
|
||||
assertThat(in).as("共享向量资源存在").isNotNull();
|
||||
root = mapper.readTree(in);
|
||||
}
|
||||
for (JsonNode v : root.get("vectors")) {
|
||||
String secret = v.get("secret").asText();
|
||||
String method = v.get("method").asText();
|
||||
String path = v.get("pathAfterStrip").asText();
|
||||
String rawQuery = v.get("rawQuery").isNull() ? null : v.get("rawQuery").asText();
|
||||
String ts = v.get("timestamp").asText();
|
||||
String expectedTarget = v.get("canonicalTarget").asText();
|
||||
String expectedSign = v.get("expectedSignature").asText();
|
||||
|
||||
String target = SignVerifyFilter.buildCanonicalTarget(path, rawQuery);
|
||||
assertThat(target).as("canonical target 构造同源(%s %s)", method, rawQuery).isEqualTo(expectedTarget);
|
||||
assertThat(SignUtil.signRequest(method, target, ts, secret))
|
||||
.as("期望签名逐字节一致(%s %s)", method, path)
|
||||
.isEqualTo(expectedSign);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"protocol": "v1",
|
||||
"canonical": "METHOD\\npath[?decoded-sorted-query]\\ntimestamp -> HmacSHA256 -> Base64",
|
||||
"source": "由 yh-app-auth-spring-boot-starter 真实代码(SignCanonical.sortRawQuery/buildTarget,反射调用)+ yh-utils-core SignUtil.signRequest 于 2026-09-21 执行生成(starter jar 1.0.0-SNAPSHOT,与 AppAuthFilter.buildSignedTarget 同一链路);三方对拍基线(§9 A4)",
|
||||
"vectors": [
|
||||
{
|
||||
"secret": "sec-alpha-0123456789abcdef",
|
||||
"method": "GET",
|
||||
"pathAfterStrip": "/oapi/message/list",
|
||||
"rawQuery": null,
|
||||
"timestamp": "1758441600000",
|
||||
"canonicalTarget": "/oapi/message/list",
|
||||
"expectedSignature": "Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4="
|
||||
},
|
||||
{
|
||||
"secret": "sec-alpha-0123456789abcdef",
|
||||
"method": "GET",
|
||||
"pathAfterStrip": "/oapi/user/byId",
|
||||
"rawQuery": "id=7&name=zhang%20san",
|
||||
"timestamp": "1758441600001",
|
||||
"canonicalTarget": "/oapi/user/byId?id=7&name=zhang san",
|
||||
"expectedSignature": "PfmaaIf+D+S4qcbcstvOzvhfDYOR8IQJlqTtX2/LfvM="
|
||||
},
|
||||
{
|
||||
"secret": "sec-bravo-9876543210fedcba",
|
||||
"method": "POST",
|
||||
"pathAfterStrip": "/oapi/msg/sendUserMsg",
|
||||
"rawQuery": "b=2&a=1&a=0",
|
||||
"timestamp": "1758441600002",
|
||||
"canonicalTarget": "/oapi/msg/sendUserMsg?a=0&a=1&b=2",
|
||||
"expectedSignature": "Z8psqh0LER3OKQLzuJM2aqUpjHoZCcg/Ru5N5zT0rc8="
|
||||
},
|
||||
{
|
||||
"secret": "sec-bravo-9876543210fedcba",
|
||||
"method": "DELETE",
|
||||
"pathAfterStrip": "/oapi/group/delete",
|
||||
"rawQuery": "gid=3",
|
||||
"timestamp": "1758441600003",
|
||||
"canonicalTarget": "/oapi/group/delete?gid=3",
|
||||
"expectedSignature": "oGBfhZrmge5w3RSiVOjEvkhcTyz27G8W8yCfcBqTYJ0="
|
||||
},
|
||||
{
|
||||
"secret": "sec-alpha-0123456789abcdef",
|
||||
"method": "GET",
|
||||
"pathAfterStrip": "/oapi/OAStu/list",
|
||||
"rawQuery": "page=1&size=20",
|
||||
"timestamp": "1758441600004",
|
||||
"canonicalTarget": "/oapi/OAStu/list?page=1&size=20",
|
||||
"expectedSignature": "e4063mlXWxa3aB/yyXV7Umzvsn8o0hUIIzxdgNuQ5I0="
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user