T1.2: SignVerifyFilter v1(canonical 剥 target 首段+解码排序 query,复用 starter SignUtil ±5min;observe 只记不拦/非 observe 裸 401;共享向量为 starter 真实代码执行产出 v1.json 逐字节对拍)

This commit is contained in:
chenxing
2026-09-21 09:13:18 +08:00
parent 949296eb67
commit 58a1bb5358
3 changed files with 383 additions and 0 deletions
@@ -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="
}
]
}