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,网关自建同构哑管道): + *