From a2c8905c7a9906016e8e0676ca12a1c5bbd4e1c3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sun, 11 Oct 2026 21:58:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E8=A7=84=E8=8C=83=C2=A73?= =?UTF-8?q?=EF=BC=9A=E5=A4=96=E8=A7=82=E5=81=8F=E5=A5=BD=E5=90=8C=E6=BA=90?= =?UTF-8?q?=E8=AF=BB=E7=AB=AF=E7=82=B9=20/api/appearance=EF=BC=88=E8=87=AA?= =?UTF-8?q?=E5=BB=BA=E5=90=8C=E6=9E=84=E5=93=91=E7=AE=A1=E9=81=93=EF=BC=8C?= =?UTF-8?q?503=E9=93=81=E5=BE=8B=EF=BC=8C=E5=9B=9E=E7=8E=AF=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE=E9=93=BE=20userbase=E2=86=92userhome=EF=BC=8Cenv=20OP?= =?UTF-8?q?ENGW=5FAPPEARANCE=5FAPP=5FKEY=20=E7=82=B9=E7=87=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/filter/RoutingFilter.java | 10 +- .../sso/AppearanceProxyController.java | 225 ++++++++++++++++++ src/main/resources/application.yml | 5 + .../sso/AppearanceProxyControllerTest.java | 147 ++++++++++++ .../gateway/sso/SsoSurfaceGuardTest.java | 13 +- 5 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java diff --git a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java index b53b041..7b61cc2 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java @@ -47,6 +47,13 @@ public class RoutingFilter implements Filter { /** springdoc UI 传统入口;{@link #isSelfPath} 的 equals+前缀双条件均不覆盖此路径,须单独 equals 判定 */ public static final String SWAGGER_UI_HTML = "/swagger-ui.html"; + /** + * 外观偏好同源读端点(对接规范 §3;{@code AppearanceProxyController}):YschTheme 通道 init + * 固定打「serviceHost + /api/appearance」裸路径,不在 {@code /api/internal} 圈内—— + * 须与 {@link #INTERNAL_PREFIX} 同级旁路,否则首段 api 会被当 target 解析 → 裸 404。 + */ + public static final String APPEARANCE_PATH = "/api/appearance"; + /** 路由产物 attribute({@link TargetRouter.Routing}) */ public static final String ATTR_ROUTING = "yh.gateway.routing"; /** 注册表策略 attribute({@link RouteSpec}) */ @@ -103,7 +110,8 @@ public class RoutingFilter implements Filter { || isSelfPath(uri, ACTUATOR_PREFIX) || isSelfPath(uri, API_DOCS_PREFIX) || isSelfPath(uri, SWAGGER_UI_PREFIX) - || (uri != null && uri.equals(SWAGGER_UI_HTML)); + || (uri != null && uri.equals(SWAGGER_UI_HTML)) + || (uri != null && uri.equals(APPEARANCE_PATH)); } private static boolean isSelfPath(String uri, String prefix) { diff --git a/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java b/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java new file mode 100644 index 0000000..b5519a9 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java @@ -0,0 +1,225 @@ +package com.yunhai.platform.gateway.sso; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.gateway.auth.GatewayKeyService; +import com.yunhai.platform.gateway.filter.SignVerifyFilter; +import com.yunhai.platform.utils.crypto.SignUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.jasig.cas.client.util.AbstractCasFilter; +import org.jasig.cas.client.validation.Assertion; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * 外观偏好同源读端点 {@code GET /api/appearance}(对接规范 §3 外观契约,网关端实现)。 + *

+ * 为什么要有:YschTheme 通道 init 的异步补齐段按「相对本应用 serviceHost 的 + * /api/appearance」取服务端真源(manifest.appearance),收敛 yh-theme / yh-avatar / + * yh-logo-url 三键。Notice/svc-hall/flow-center 由各自后端的 AppearanceProxyController 提供, + * 网关按同构形态自建(平台 starter {@code yh-appearance-spring-boot-starter} 尚在 + * feat/appearance-sdk 分支未发布,且网关无 YschOpenCall/BoaUser 六件套——规范文档 §7 + * 允许"自建同构哑管道,503 铁律不可省")。 + *

+ * 链路:浏览器(通道 init,同源带 OPENGW_SESSION)→ 本端点从 CAS 会话取登录名 + * (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后 + * 回环打网关真实 oapi 链(同 ProxyTestController 形态:Sanitize→路由→验签→转发): + * ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance} + * 取生效外观;②的响应体【整体透传】(裸 payload {theme, avatar, logoUrl}), + * 字段演进只在 UserHome+通道两端,本端零改动。 + *

+ * 三态失败语义:无 CAS 会话/会话断言缺失 → 401;未配 + * {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome + * 不可达、user 解析不到、上游非 200、响应非 JSON —— 一律 sendError(503) 直写绕过 + * advice/全局异常处理器。init 对任何非 2xx【静默保键】:网关/userhome 故障不会 + * 清空浏览器本地观感(503 铁律,Notice 线上事故 0721cf3 教训)。绝不 200+全 null。 + *

+ * 信封豁免:返回类型 String + produces=application/json 走 StringHttpMessageConverter, + * {@code ApiResponseAdvice.supports()} 对该 converter 直接跳过——裸 payload 透传不被 + * {code:0} 信封二次包装(init 见 code 字段会当异常形态跳过收敛)。 + *

+ * 本路径不在 CAS 过滤器圈(urlPattern=/api/internal/admin/*)内,鉴权由本类自查会话断言 + * 完成——CAS 面未装配(开关关)时恒 401,行为与"未登录"一致,无需额外防线。 + */ +@Tag(name = "GatewayAppearance", description = "网关治理面外观偏好同源读(通道 init 消费)") +@RestController +public class AppearanceProxyController { + + private static final Logger LOG = LoggerFactory.getLogger(AppearanceProxyController.class); + + /** open_api_target 注册表中的目标码(与路由表同源,勿写死 host——寻址归注册表热重载) */ + static final String TARGET_USERBASE = "userbase"; + static final String TARGET_USERHOME = "userhome"; + + /** 回环两段各自超时:连接 2s / 读 3s——init 总超时 3s,本端点必须更快失败 */ + private static final int CONNECT_TIMEOUT_MS = 2_000; + private static final int READ_TIMEOUT_MS = 3_000; + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private GatewayKeyService keyService; + + @Value("${yh.gateway.appearance.app-key:}") + private String appKey; + + /** 回环基址覆盖(单测注入本地 HttpServer 用);null = 按请求 localAddr:localPort 推导 */ + private String loopbackBaseOverride; + + void setLoopbackBase(String base) { + this.loopbackBaseOverride = base; + } + + void setKeyService(GatewayKeyService keyService) { + this.keyService = keyService; + } + + void setAppKey(String appKey) { + this.appKey = appKey; + } + + @Operation(operationId = "GatewayAppearance.appearance", summary = "当前登录人生效外观(裸 payload 透传)") + @GetMapping(value = "/api/appearance", produces = "application/json") + public String appearance(HttpServletRequest req, HttpServletResponse res) throws IOException { + String loginName = principalOf(req); + if (loginName == null || loginName.isEmpty()) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "login required"); + return null; + } + if (appKey == null || appKey.trim().isEmpty()) { + // 灰度关:不配 app-key 即整组静默(init 401/503 均保键),与 CAS 开关同哲学 + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance proxy disabled"); + return null; + } + String secret = keyService.getSecret(appKey.trim()); // fail-closed:未知/禁用/Redis 异常一律 null + if (secret == null) { + LOG.warn("[appearance] 密钥链解析不到 appKey={}(503,init 侧静默不动本地键)", appKey); + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); + return null; + } + + try { + String base = loopbackBase(req); + JsonNode user = signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName", + "loginName=" + urlEncode(loginName), appKey.trim(), secret); + if (user == null || user.isNull() || !user.path("id").canConvertToLong()) { + LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName); + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); + return null; + } + String payload = signedGetBody(base, TARGET_USERHOME, "/oapi/appearance", + "userId=" + user.path("id").asLong(), appKey.trim(), secret); + if (payload == null) { + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); + return null; + } + return payload; + } catch (Exception e) { + // 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封, + // init 会把意外形态当脏值跳过——语义上浏览器必须拿到非 2xx) + LOG.warn("[appearance] 上游外观链路不可达(503,init 侧静默不动本地键)login={}:{}", + loginName, e.toString()); + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); + return null; + } + } + + /** 会话断言里的登录名;无会话/未校验返回 null(同 SsoSessionController.principalOf) */ + private static String principalOf(HttpServletRequest req) { + HttpSession session = req.getSession(false); + if (session == null) { + return null; + } + Object attribute = session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION); + if (!(attribute instanceof Assertion)) { + return null; + } + return ((Assertion) attribute).getPrincipal() == null + ? null : ((Assertion) attribute).getPrincipal().getName(); + } + + /** 回环 GET 并解析为 JSON(非 200/非 JSON 返回 null,fail-closed) */ + private JsonNode signedGetJson(String base, String target, String strippedPath, String query, + String key, String secret) throws IOException { + String body = signedGetBody(base, target, strippedPath, query, key, secret); + if (body == null) { + return null; + } + try { + return mapper.readTree(body); + } catch (Exception e) { + return null; + } + } + + /** + * 签名回环 GET:canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget} + * (与验签侧同一实现,杜绝双写漂移),时间戳毫秒(F1 契约)。200 返体,其余返 null。 + */ + private String signedGetBody(String base, String target, String strippedPath, String query, + String key, String secret) throws IOException { + String pathAndQuery = "/" + target + strippedPath + (query == null || query.isEmpty() ? "" : "?" + query); + String timestamp = String.valueOf(System.currentTimeMillis()); + String canonical = SignVerifyFilter.buildCanonicalTarget(strippedPath, + query == null || query.isEmpty() ? null : query); + String signature = SignUtil.signRequest("GET", canonical, timestamp, secret); + + try (CloseableHttpClient client = HttpClients.createDefault()) { + RequestBuilder rb = RequestBuilder.get() + .setUri(URI.create(base + pathAndQuery)) + .setConfig(RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT_MS) + .setSocketTimeout(READ_TIMEOUT_MS) + .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS) + .setRedirectsEnabled(false) + .build()) + .addHeader("X-App-Key", key) + .addHeader("X-Timestamp", timestamp) + .addHeader("X-Signature", signature); + try (CloseableHttpResponse up = client.execute(rb.build())) { + int status = up.getStatusLine().getStatusCode(); + String body = up.getEntity() == null ? "" : EntityUtils.toString(up.getEntity(), StandardCharsets.UTF_8); + return status == 200 ? body : null; + } + } + } + + private static String urlEncode(String v) { + try { + return URLEncoder.encode(v, "UTF-8"); + } catch (Exception e) { + return v; + } + } + + /** 同 ProxyTestController:取本机直打地址(不经 Caddy),天然内部入口口径(一律验签) */ + private String loopbackBase(HttpServletRequest req) { + if (loopbackBaseOverride != null) { + return loopbackBaseOverride; + } + String host = req.getLocalAddr(); + if (host != null && host.contains(":") && !host.startsWith("[")) { + host = "[" + host + "]"; + } + return "http://" + host + ":" + req.getLocalPort(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a021aae..e2cc7c2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,6 +81,11 @@ yh: # 机器面(/api/internal/** 除浏览器面)的共享内部令牌;值只在应用 env,不进代码不进库。 # 空 = 不校验(灰度窗口态:网关先上、env 后配,期间不打断 projman 的路由同步)。 internal-token: ${OPENGW_INTERNAL_TOKEN:} + appearance: + # 外观偏好同源读端点 /api/appearance(对接规范 §3;AppearanceProxyController): + # 用网关注册在自身密钥链里的 appKey 签名回环 oapi 链(userbase byLoginName → userhome appearance)。 + # 空 = 端点恒 503(灰度关,通道 init 静默保键);上线值由平台应用 env 注入。 + app-key: ${OPENGW_APPEARANCE_APP_KEY:} sso: # 治理面浏览器 SSO(CAS 会话)总开关。默认关——由平台应用 env OPENGW_SSO_ENABLED=true 点燃, # 回滚 = 清掉该 env 重新 Apply(一次重启,不回退镜像、不改 Caddy)。 diff --git a/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java b/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java new file mode 100644 index 0000000..93cb404 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java @@ -0,0 +1,147 @@ +package com.yunhai.platform.gateway.sso; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import com.yunhai.platform.gateway.auth.GatewayKeyService; +import org.jasig.cas.client.authentication.AttributePrincipal; +import org.jasig.cas.client.util.AbstractCasFilter; +import org.jasig.cas.client.validation.Assertion; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道): + *

+ * 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。 + */ +class AppearanceProxyControllerTest { + + private static final String SECRET = "probe-secret-0123456789"; + private static final String PAYLOAD = + "{\"theme\":{\"theme\":\"blue\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}," + + "\"avatar\":\"text\",\"logoUrl\":\"http://cdn/logo.png\"}"; + + private HttpServer server; + private final Map stubBodies = new HashMap<>(); + private final Map stubStatus = new HashMap<>(); + + private AppearanceProxyController controller; + private MockMvc mvc; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", new StubHandler()); + server.start(); + + GatewayKeyService keyService = Mockito.mock(GatewayKeyService.class); + Mockito.when(keyService.getSecret("gw-appearance")).thenReturn(SECRET); + Mockito.when(keyService.getSecret("gw-unknown")).thenReturn(null); + + controller = new AppearanceProxyController(); + controller.setKeyService(keyService); + controller.setLoopbackBase("http://127.0.0.1:" + server.getAddress().getPort()); + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + private class StubHandler implements HttpHandler { + @Override + public void handle(HttpExchange ex) throws IOException { + String path = ex.getRequestURI().getPath(); + int status = stubStatus.getOrDefault(path, 200); + String body = stubBodies.getOrDefault(path, "{}"); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json"); + ex.sendResponseHeaders(status, status == 204 ? -1 : bytes.length); + if (status != 204) { + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + } + } + + private static MockHttpSession sessionWithPrincipal(String name) { + MockHttpSession session = new MockHttpSession(); + AttributePrincipal principal = Mockito.mock(AttributePrincipal.class); + Mockito.when(principal.getName()).thenReturn(name); + Assertion assertion = Mockito.mock(Assertion.class); + Mockito.when(assertion.getPrincipal()).thenReturn(principal); + session.setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, assertion); + return session; + } + + @Test + void noSession_is401() throws Exception { + controller.setAppKey("gw-appearance"); + mvc.perform(get("/api/appearance")).andExpect(status().isUnauthorized()); + } + + @Test + void disabledAppKey_is503() throws Exception { + controller.setAppKey(""); + mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin"))) + .andExpect(status().isServiceUnavailable()); + } + + @Test + void keyNotInChain_is503() throws Exception { + controller.setAppKey("gw-unknown"); + mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin"))) + .andExpect(status().isServiceUnavailable()); + } + + @Test + void happyPath_passesThroughBarePayload() throws Exception { + controller.setAppKey("gw-appearance"); + stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}"); + stubBodies.put("/userhome/oapi/appearance", PAYLOAD); + + mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin"))) + .andExpect(status().isOk()) + .andExpect(content().json(PAYLOAD)); + } + + @Test + void userNotFound_is503() throws Exception { + controller.setAppKey("gw-appearance"); + stubBodies.put("/userbase/oapi/user/byLoginName", "null"); + mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost"))) + .andExpect(status().isServiceUnavailable()); + } + + @Test + void upstreamError_is503() throws Exception { + controller.setAppKey("gw-appearance"); + stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353}"); + stubStatus.put("/userhome/oapi/appearance", 404); + mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin"))) + .andExpect(status().isServiceUnavailable()); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java b/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java index e3fd9c0..b1678c5 100644 --- a/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java +++ b/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java @@ -36,6 +36,14 @@ class SsoSurfaceGuardTest { private static final Set MACHINE_PATHS = new TreeSet<>(Collections.singletonList("/api/internal/routes/refresh")); + /** + * 自守卫浏览器端点:不在 CAS url-pattern 圈内(通道 init 固定打「serviceHost+/api/appearance」 + * 裸路径,无法挪进 /api/internal/admin/*),由控制器自查会话断言(无会话 401、 + * 上游不可达 503,见 AppearanceProxyController 三态语义)。 + */ + private static final Set SELF_GUARDED_BROWSER_PATHS = + new TreeSet<>(Collections.singletonList("/api/appearance")); + /** 必须留在鉴权面之外的路径(本次线上探针实测过的形态) */ private static final String[] MUST_STAY_OUTSIDE = { "/notice/oapi/x", @@ -137,8 +145,9 @@ class SsoSurfaceGuardTest { Set paths = allControllerPaths(); assertThat(paths).isNotEmpty(); for (String path : paths) { - assertThat(matched(properties.getUrlPattern(), path) || MACHINE_PATHS.contains(path)) - .as("映射 %s 既不在 CAS 治理面(%s)内,也未登记为机器面", path, properties.getUrlPattern()) + assertThat(matched(properties.getUrlPattern(), path) || MACHINE_PATHS.contains(path) + || SELF_GUARDED_BROWSER_PATHS.contains(path)) + .as("映射 %s 既不在 CAS 治理面(%s)内,也未登记为机器面/自守卫浏览器端点", path, properties.getUrlPattern()) .isTrue(); } }