对接规范§3:外观偏好同源读端点 /api/appearance(自建同构哑管道,503铁律,回环验签链 userbase→userhome,env OPENGW_APPEARANCE_APP_KEY 点燃)
build / build-push (push) Successful in 2m11s

This commit is contained in:
chenxing
2026-10-11 21:58:54 +08:00
parent 7e3690e523
commit a2c8905c7a
5 changed files with 397 additions and 3 deletions
@@ -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) {
@@ -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 外观契约,网关端实现)。
* <p>
* <b>为什么要有</b>: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 铁律不可省")。
* <p>
* <b>链路</b>:浏览器(通道 init,同源带 OPENGW_SESSION)→ 本端点从 CAS 会话取登录名
* (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后
* <b>回环打网关真实 oapi 链</b>(同 ProxyTestController 形态:Sanitize→路由→验签→转发):
* ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance}
* 取生效外观;②的响应体【整体透传】(裸 payload {theme, avatar, logoUrl}),
* 字段演进只在 UserHome+通道两端,本端零改动。
* <p>
* <b>三态失败语义</b>:无 CAS 会话/会话断言缺失 → 401;未配
* {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome
* 不可达、user 解析不到、上游非 200、响应非 JSON —— 一律 sendError(503) 直写绕过
* advice/全局异常处理器。init 对任何非 2xx【静默保键】:网关/userhome 故障不会
* 清空浏览器本地观感(503 铁律,Notice 线上事故 0721cf3 教训)。绝不 200+全 null。
* <p>
* <b>信封豁免</b>:返回类型 String + produces=application/json 走 StringHttpMessageConverter,
* {@code ApiResponseAdvice.supports()} 对该 converter 直接跳过——裸 payload 透传不被
* {code:0} 信封二次包装(init 见 code 字段会当异常形态跳过收敛)。
* <p>
* 本路径不在 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();
}
}
+5
View File
@@ -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)。
@@ -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,网关自建同构哑管道):
* <ul>
* <li>无会话断言 → 401;未配 app-key / 密钥链解析不到 → 503(绝不 200+兜底值);</li>
* <li>happy path:byLoginName 换 userId → appearance 裸 payload 整体透传(无信封);</li>
* <li>上游任一环节非 200 / 解析不到 userId → 503(init 侧静默保键语义的前提)。</li>
* </ul>
* 回环用 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<String, String> stubBodies = new HashMap<>();
private final Map<String, Integer> 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());
}
}
@@ -36,6 +36,14 @@ class SsoSurfaceGuardTest {
private static final Set<String> 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<String> 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<String> 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();
}
}