先釐清架構關係

這個系統的服務邊界:

Channel(Web-Backend)         ← 獨立服務,有自己的前端與業務邏輯
    ↓ 呼叫
Traffic-Controller             ┐
    ↓ 轉發                     ├── 強耦合,同機部署
Query-API                      ┘

其他上游系統 A ────────────────┐
其他上游系統 B ────────────────┤→ Traffic-Controller → Query-API
其他上游系統 C ────────────────┘

Channel 是外部獨立服務,它有自己的前端、自己的業務邏輯、自己的 JWT 機制。Query-API + Traffic-Controller 是強耦合的組合,同機部署,對外提供共享的查詢能力。


為什麼 Query-API 不能直接信任上游的 token

Query-API 被設計成可供多個不同上游系統使用的共享查詢服務,不是只服務 Channel 一個呼叫方。

這帶來一個根本問題:每個上游系統都有自己的 JWT 格式和 secret。

如果 Query-API 直接驗 Channel 的 token:

  • 它就必須持有 Channel 的 JWT secret
  • 當上游系統 B、C 也要接進來,Query-API 就要再持有 B、C 的 secret
  • 每新增一個上游,Query-API 就要改一次驗證邏輯

這樣 Query-API 就和每個上游系統緊密耦合了,完全不符合「共享服務」的定位。

正確的設計是:Query-API 定義自己的 JWT 規格(queryApiSecret),任何想呼叫它的上游系統,都必須在呼叫前把自己的 token 轉換成 Query-API JWT,Query-API 只驗自己認識的格式。

上游系統 A(用自己的 JWT)
    ↓ 轉換成 Query-API JWT
Query-API 驗 Query-API JWT ← 只要維護一套驗證邏輯

上游系統 B(用自己的 JWT)
    ↓ 轉換成 Query-API JWT
Query-API 驗 Query-API JWT ← 同一套,不需要改

三套 JWT 各自的角色

前端 / 外部平台
      ↓ Channel JWT (HS512)
Channel(Web-Backend)  ← 獨立服務,自己管自己的認證
      ↓ FeignClientInterceptor 轉換成 Query-API JWT
Traffic-Controller      ← 透傳(不驗、只 log)
      ↓
Query-API ──── 來自各上游系統 → @JwtValidation(Query-API JWT)
         └──── 外部直接呼叫  → @ExternalJwtValidation(External JWT)
JWT 用途 誰簽發 誰驗證
Channel JWT Channel 前端 ↔ Channel Backend Channel Backend(SSO 後簽發) Channel Backend 入口攔截器
Query-API JWT 各上游系統 → Query-API 各上游系統(呼叫前轉換) Query-API 攔截器
External JWT 外部系統直接呼叫 Query-API 外部系統 Query-API 攔截器

三組 secret 獨立,Query-API 只需要維護自己的兩組(queryApiSecret、externalApiSecret)。Channel 的 secret 輪替不影響 Query-API,反之亦然。


Channel 側:轉換的責任在 FeignClientInterceptor

Channel 呼叫 Query-API 之前,必須把自己的 Channel JWT 轉換成 Query-API JWT。這件事放在 FeignClientInterceptor,對所有 Feign 呼叫透明生效:

@Override
public void apply(RequestTemplate template) {
    Optional<RequestAttributes> attrs =
        Optional.ofNullable(RequestContextHolder.getRequestAttributes());

    attrs.ifPresent(a -> {
        HttpServletRequest request = (HttpServletRequest)
            a.resolveReference(RequestAttributes.REFERENCE_REQUEST);

        String authorization = request.getHeader("Authorization");

        String data, profile;
        if (authorization != null) {
            // 從 Channel JWT 解出業務身份
            data = channelJwtUtil.extractData(authorization);
            profile = channelJwtUtil.extractProfile(authorization);
        } else {
            data = "Invalid data";
            profile = "Invalid profile";
        }

        // 用 queryApiSecret 重新簽發
        String queryApiJwt = queryApiJwtUtil.generateToken(data, profile);

        template.header("Authorization", "Bearer " + queryApiJwt);
        template.header("x-core-transactionid", UUID.randomUUID().toString());
        template.header("x-core-traceid", UUID.randomUUID().toString());
    });
}

Query-API JWT 只放 data 和 profile(業務身份),把 isDarkMode、channelCode 這些 Channel 的 UI 狀態去掉。Query-API 作為共享服務,不應該對某個特定 channel 的前端狀態有任何認知。

為什麼轉換放在 Feign 層而不是 Controller/Service?

Channel 所有對 Query-API 的呼叫都走 Feign,在 interceptor 統一處理,沒有漏網路徑,新增 API 不用額外處理。其他想接入 Query-API 的上游系統,也各自在自己的 Feign 或 HTTP client 中完成同樣的轉換,Query-API 端完全不需要感知。


Channel 自己的 JWT 驗證

Channel JWT 在 SSO 登入後簽發,有效期 1 小時:

// ChannelJwtUtil.java(Channel 側)
public String generateToken(String data, String profile,
                             Boolean isDarkMode, String channelCode) {
    return Jwts.builder()
        .claim("data", data)
        .claim("profile", profile)
        .claim("isDarkMode", isDarkMode)
        .claim("channelCode", channelCode)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + channelExpiration))
        .signWith(Keys.hmacShaKeyFor(channelSecret.getBytes()), SignatureAlgorithm.HS512)
        .compact();
}

Channel Backend 入口攔截器驗 Channel JWT:

@Override
public boolean preHandle(HttpServletRequest request,
                          HttpServletResponse response,
                          Object handler) throws Exception {
    if (handler instanceof HandlerMethod method) {
        if (method.hasMethodAnnotation(JwtValidation.class)) {
            String token = request.getHeader("Authorization");
            if (!channelJwtUtil.isValid(token)) {
                response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid token");
                return false;
            }
        }
    }
    return true;
}

這層驗證完全在 Channel 服務內部,Query-API 對此毫無感知。


Query-API 側:Annotation 驅動的驗證入口

Query-API 同時服務兩種呼叫方:來自各上游系統(帶 Query-API JWT)和外部系統直接呼叫(帶 External JWT)。用 annotation 標記每個 endpoint 的信任模型:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtValidation {}        // 上游服務路徑(Query-API JWT)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExternalJwtValidation {}  // 外部直接呼叫路徑
@PostMapping("/get-asset-overview/v1")
@JwtValidation                            // ← 只接受 Query-API JWT
public RestInternalResponseWrapper<GetAssetOverviewResponse> getAssetOverview(...) { ... }

@PostMapping("/external/get-asset-summary/v1")
@ExternalJwtValidation                    // ← 只接受 External JWT
public RestInternalResponseWrapper<GetAssetSummaryResponse> getAssetSummaryExternal(...) { ... }
@Override
public boolean preHandle(HttpServletRequest request,
                          HttpServletResponse response,
                          Object handler) throws Exception {
    if (handler instanceof HandlerMethod method) {
        String token = request.getHeader("Authorization");

        if (method.hasMethodAnnotation(JwtValidation.class)) {
            if (!queryApiJwtUtil.isValid(token)) {
                response.sendError(401, "Invalid Query-API token");
                return false;
            }
        } else if (method.hasMethodAnnotation(ExternalJwtValidation.class)) {
            if (!externalApiJwtUtil.isValid(token)) {
                response.sendError(401, "Invalid External token");
                return false;
            }
        }

        enrichRequestAttributes(request, token, method);
    }
    return true;
}

看到 endpoint 上的 annotation,就能一眼判斷這個 API 預期的呼叫方是誰。


完整鏈路

前端請求 (Authorization: Bearer <channel-jwt>)
    ↓
Channel RequestInterceptor
    @JwtValidation → channelJwtUtil.isValid()
    ↓
FeignClientInterceptor(Channel 側的職責)
    channelJwtUtil.extractData/Profile()
    queryApiJwtUtil.generateToken()
    → Authorization: Bearer <query-api-jwt>
    ↓
Traffic-Controller (WebFlux)
    reactiveJwtUtil.extractData/Profile() → debug log only
    透傳 Authorization header
    ↓
Query-API RequestInterceptor
    @JwtValidation → queryApiJwtUtil.isValid()
    解析 claims → 補入 access log

轉換的責任在呼叫方(Channel)——這是關鍵設計原則。Query-API 作為被呼叫的共享服務,不應該去適配每個上游系統的 token 格式;上游系統要接入,就必須自己完成格式轉換,符合 Query-API 定義的規格。


幾個已知的不足

問題 說明
只驗簽名和過期 缺少 iss、aud 驗證,token 理論上可跨服務重放
對稱加密(HS256/HS512) 驗證端需要持有 secret,分發壓力較高;可評估改 RS256
無主動撤銷機制 Token 過期前無法失效,可引入 Redis Token 黑名單
Feign fallback 值 取不到 token 時用 "Invalid data" 繼續走,應改為 fail-fast