T1.2: SignVerifyFilter v1(canonical 剥 target 首段+解码排序 query,复用 starter SignUtil ±5min;observe 只记不拦/非 observe 裸 401;共享向量为 starter 真实代码执行产出 v1.json 逐字节对拍)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package com.yunhai.platform.gateway.filter;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.GatewayKeyService;
|
||||
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
|
||||
import com.yunhai.platform.gateway.route.GatewayProperties;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* v1 验签过滤器(T1.2,共享向量):canonical 与 starter
|
||||
* {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致——
|
||||
* {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1),
|
||||
* 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法),
|
||||
* 时间窗 ±5min(F2,不得更宽)。
|
||||
* <p>
|
||||
* 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 /
|
||||
* 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4),
|
||||
* observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
|
||||
* ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。
|
||||
* <p>
|
||||
* 链序第 3 环(T1.6 定型):Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。
|
||||
*/
|
||||
@Component
|
||||
@Order(3)
|
||||
public class SignVerifyFilter implements Filter {
|
||||
|
||||
/** 判定结果 attribute(T1.5/T1.6 台账 verdict) */
|
||||
public static final String ATTR_VERDICT = "yh.gateway.verdict";
|
||||
/** 验签通过的 appKey(T1.4 身份注入唯一合法来源) */
|
||||
public static final String ATTR_AUTH_APPKEY = "yh.gateway.auth.appKey";
|
||||
|
||||
public static final String VERDICT_PASS = "PASS";
|
||||
public static final String VERDICT_FAIL = "FAIL";
|
||||
public static final String VERDICT_FAIL_OBSERVED = "FAIL_OBSERVED";
|
||||
public static final String VERDICT_ANON = "ANON";
|
||||
|
||||
@Autowired
|
||||
private GatewayKeyService keyService;
|
||||
|
||||
@Autowired
|
||||
private GatewayProperties properties;
|
||||
|
||||
/** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */
|
||||
void setKeyService(GatewayKeyService keyService) {
|
||||
this.keyService = keyService;
|
||||
}
|
||||
|
||||
void setProperties(GatewayProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
|
||||
boolean pass = false;
|
||||
String appKey = req.getHeader("X-App-Key");
|
||||
String timestamp = req.getHeader("X-Timestamp");
|
||||
String signature = req.getHeader("X-Signature");
|
||||
String strippedPath = (String) req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH);
|
||||
if (!isBlank(appKey) && !isBlank(timestamp) && !isBlank(signature) && strippedPath != null) {
|
||||
String secret = keyService.getSecret(appKey); // null = 未知/禁用/异常,fail-closed
|
||||
if (secret != null) {
|
||||
String target = buildCanonicalTarget(strippedPath, req.getQueryString());
|
||||
try {
|
||||
// 复用 starter 同一验签实现:签名比对 + 时间窗(默认 ±5min,F2)
|
||||
pass = SignUtil.verifyRequestWithExpire(req.getMethod(), target, timestamp, secret, signature);
|
||||
} catch (RuntimeException e) {
|
||||
pass = false; // 意外异常同样落 FAIL(判定组件绝不 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
req.setAttribute(ATTR_VERDICT, VERDICT_PASS);
|
||||
req.setAttribute(ATTR_AUTH_APPKEY, appKey);
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if (properties.isObserve()) {
|
||||
// R4:P1 只观察——记台账不拦(台账写入由 T1.5/T1.6 消费本 attribute)
|
||||
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL_OBSERVED);
|
||||
chain.doFilter(request, response);
|
||||
} else {
|
||||
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
|
||||
EnvelopeWriter.writeBare(res, 401);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical 的 target 部分 = path + (有 query 时 {@code ?解码排序query})——
|
||||
* 与 starter {@code SignCanonical.buildTarget(sortRawQuery(...))} 同语义(共享向量钉死)。
|
||||
*/
|
||||
static String buildCanonicalTarget(String pathAfterStrip, String rawQuery) {
|
||||
String sorted = sortRawQuery(rawQuery);
|
||||
return sorted.isEmpty() ? pathAfterStrip : pathAfterStrip + "?" + sorted;
|
||||
}
|
||||
|
||||
/** 复刻 starter SignCanonical.sortRawQuery:逐对解码、{@code k=v} 字典序排序、& 连接 */
|
||||
static String sortRawQuery(String rawQuery) {
|
||||
if (rawQuery == null || rawQuery.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> pairs = new ArrayList<>();
|
||||
for (String part : rawQuery.split("&")) {
|
||||
if (part.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int idx = part.indexOf('=');
|
||||
String k = idx < 0 ? part : part.substring(0, idx);
|
||||
String v = idx < 0 ? "" : part.substring(idx + 1);
|
||||
pairs.add(urlDecode(k) + "=" + urlDecode(v));
|
||||
}
|
||||
Collections.sort(pairs);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < pairs.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
sb.append(pairs.get(i));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String urlDecode(String s) {
|
||||
try {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -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