diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java new file mode 100644 index 0000000..d6c8360 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java @@ -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,不得更宽)。 + *

+ * 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 / + * 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4), + * observe=false 裸 401(F5 契约)。判定结果经 request attribute 传递 + * ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。 + *

+ * 链序第 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 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(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java new file mode 100644 index 0000000..7ae15c4 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java @@ -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); + } + } +} diff --git a/src/test/resources/shared-sign-vectors/v1.json b/src/test/resources/shared-sign-vectors/v1.json new file mode 100644 index 0000000..eef79bd --- /dev/null +++ b/src/test/resources/shared-sign-vectors/v1.json @@ -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=" + } + ] +}