Project

General

Profile

CR #211 » CR-010_服務間呼叫缺授權標頭.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

CR-010 服務間呼叫未帶授權標頭 → 啟用認證後全數 401(含批次靜默零產出)

項目 內容
編號 CR-010
版本 1.3(2026-09-20 核定、執行完成並實測通過;2026-09-21 補齊 gateway/bff 組態與 issuer-uri)
狀態 ✅ 已核定/已執行並實測通過
提出日期 2026-09-20
核定日期 2026-09-20(BEAM PMO:採 A——Keycloak 服務帳號(client_credentials))
提出者 WorkBuddy AI
影響文件 SCS-022(新增 §7.9)、SAD-005 §3.2 對照、CSP-001 §8.5、common-spring 組態、ops/local/.env.example
關聯 HANDOVER #C-41/#C-42、#A-61(於帳單環境重建時實測發現)、SCS-022 §10 #7(mTLS 未實作)
嚴重度 🔴 P0(功能全面不可用)——只要認證一開,所有服務間呼叫全數失敗;且批次靜默回成功

1. 背景

CR-009 修復後重跑帳單環境時,批次回 GENERATED 但 statementCount=0
(「正常完成」卻什麼都沒做)。查日誌可見:

計息失敗,跳過該帳戶不產生帳單:account=018f2a3b-…-0010 cycle=M01 reason=計息失敗:HTTP 401

追查後確認:statement → posting、posting → pricing 全部 401。
根因不在計息、不在 CR-009,而在服務間 HTTP 客戶端從未帶授權標頭。


2. 實測證據(2026-09-20)

2.1 服務間呼叫全數 401

於 CARDCORE_SECURITY_ENABLED=true 下執行帳單週期:

呼叫 結果
statement-service(8092) → posting-service(8087) HTTP 401
posting-service(8087) → pricing-service(8086) HTTP 401(posting 轉為 503 CC-PLT-5005)

2.2 Keycloak 端其實已經備妥(本次實測,非推測)

POST http://localhost:8180/realms/cardcore/protocol/openid-connect/token
grant_type=client_credentials
client_id=cardcore-services
client_secret=cardcore-services-dev-secret
→ HTTP 200
  expires_in = 900
  aud        = ["cardcore-api", "account"]
  azp        = cardcore-services
  typ        = Bearer

ops/local/config/keycloak-realm.json 中 cardcore-services 之設定:
publicClient: false(confidential)、serviceAccountsEnabled: true、
standardFlowEnabled: false、protocol mapper audience-cardcore-api。

結論:IdP 端早就支援,缺的是程式端——沒有任何程式去取這個 token。

2.3 程式端確實沒有授權處理

// ServiceClientHttpClient.send(common-spring)
HttpRequest request = requestFactory.get().timeout(...).build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
  • 建構子僅收 ServiceClientProperties(逾時/重試),無任何憑證來源;
  • 呼叫端(如 PostingServiceClient.get)僅 .uri(...).GET(),無 Authorization 標頭;
  • common-spring 之 build.gradle.kts 亦無 spring-security-oauth2-client 依賴
    (且刻意讓 spring-security 維持 compileOnly)。

→ 這是端到端都沒有這個能力,不是「忘了傳參數」。


3. 為何現在才爆

時期 現象
認證關閉(本地常態) 服務間呼叫不需 token → 缺陷存在但看不出來
#C-34 17/17 實測 只測「入站有無 401」,未測服務間呼叫 → 缺陷未被觸及
本次以 CARDCORE_SECURITY_ENABLED=true 跑批次 服務間呼叫全數 401 → 爆發

→ 「認證接線完成(17/17 實測通過)」與「認證可常態啟用」是兩件事。
後者還需要**出站(outbound)**這一條腿。


4. 選項與建議

# 方案 做法 優點 代價/風險
A ⭐ Keycloak 服務帳號(client_credentials) 各服務以 M2M client 向 Keycloak 取 token,快取至到期前,帶於服務間呼叫 IdP 端已備妥(§2.2);不新增依賴(JDK HttpClient 即可);集中於 common-spring,17 服務不必重工 token 有 900s 生命週期,須處理快取與續取;client secret 須妥善管理(不放 repo)
B mTLS 雙向 TLS 驗服務身分 SAD-005 §3.2 之原始要求 須憑證簽發/輪替機制(本機無 CA 基礎設施);且 §3.2 要求的是 mTLS + Service Token,二者並非擇一
C 內部路徑免認證(網路邊界隔離) 服務間端點列入 permitAll,僅外部入口驗證 最簡單、無 token 管理 一旦內網被穿透即全裸;信用卡核心不可接受;且違反 SAD-005 §3.2

建議採 A(BEAM PMO 2026-09-20 核定)。

⚠️ A 不排除 B:SAD-005 §3.2 要求「mTLS + Service Token」。
本次補的是 Service Token 那一半;mTLS 仍待基礎設施(憑證簽發與輪替),
維持 SCS-022 §10 #7 之揭露,不因本次而宣稱已完成。


5. 設計(採 A)

5.1 組態(前置詞 cardcore.security.outbound)

鍵 預設 說明
enabled 隨 cardcore.security.enabled 出站認證與入站同調——入站要認證,出站就必然要帶 token
token-uri 無(必填) Keycloak token 端點
client-id 無(必填) cardcore-services
client-secret 無(必填) 由環境變數 CARDCORE_M2M_CLIENT_SECRET 提供,不放 repo
cache-skew 30s 提前多久視為過期(吸收時鐘誤差與網路時間)
connect-timeout / request-timeout 2s / 5s 取 token 之逾時(與業務呼叫分開,見 §5.4)

5.2 類別(platform/common-spring)

類別 職責
OutboundTokenProvider(介面) String authorizationHeaderValue()——回 Bearer xxx;無則 null
ClientCredentialsTokenProvider 向 Keycloak 取 token、快取(雙重檢查鎖定)、到期前 cacheSkew 續取;失敗拋錯不降級
NoOpOutboundTokenProvider 本地顯式停用時使用;建構即記 WARN(使「刻意停用」可與「忘了設定」區分),authorizationHeaderValue() 回 null → 不帶標頭
OutboundTokenAutoConfiguration 組裝 bean;入站啟用卻未設出站憑證 → 啟動期失敗(fail-closed)。於 AutoConfiguration.imports 登錄
ServiceClientHttpClient 既有類別,本次改造:建構子增 OutboundTokenProvider(requireNonNull);送出前帶 Authorization;取不到憑證時不送出請求

5.3 fail-closed 三情境

情境 行為
cardcore.security.enabled=true 且出站憑證已設定 正常取 token 並帶出
cardcore.security.enabled=true 但出站憑證未設定 啟動期失敗(IllegalStateException,訊息直接給出該設什麼)
cardcore.security.enabled=false(本地顯式停用) 不取 token、不帶標頭,並記 WARN(使「刻意停用」與「忘了設定」在日誌上可區分)

為何不是「取不到 token 就照發不帶標頭的請求」:
那正是「失敗即放行(fail-open)」,與 SCS-022 §7.6 已處置之
「宣告啟用卻沒依賴 → 靜默放行」同型,屬最危險的一類缺陷。

5.4 逾時不得沿用業務呼叫之預設值

取 token 是基礎設施呼叫,語意與業務呼叫不同:

  • 不應重試(token 端點 4xx 表憑證有誤,重試無益且拖長批次);
  • 逾時須獨立組態(業務呼叫為連線 2s/請求 3s;取 token 建議 2s/5s,
    因 IdP 可能冷啟動)。

呼應既有的 #13 教訓:每接一個外部系統都要重新檢視預設值,勿沿用全域值。


6. 影響範圍

產出 變更
common-spring 新增 OutboundTokenProvider/ClientCredentialsTokenProvider/NoOpOutboundTokenProvider/OutboundTokenAutoConfiguration;SecurityProperties 增巢狀 Outbound;ServiceClientHttpClient 帶標頭
ServiceClientHttpClient 建構子增 OutboundTokenProvider(requireNonNull;本地停用時注入的是 NoOpOutboundTokenProvider,不是 null)
19 個模組 application-local.yml(17 領域服務 + api-gateway/bff-web) 出站組態(client-secret: ${CARDCORE_M2M_CLIENT_SECRET:} 不給預設值)。實測:src 下 19 份全數含 outbound 區段
tools/gen_service_skeleton.py T_APP_LOCAL_YML(17 領域服務)與 T_APP_LOCAL_YML_CORE(gateway/bff)兩個模板皆同步加出站區段(下次建骨架即自帶,不會因重跑而遺漏)
ops/local/env.sh 條件載入 ~/.config/cardcore/m2m.env(存在才載入;不存在時記警告)
ops/local/.env.example 記載 CARDCORE_M2M_CLIENT_ID/CARDCORE_M2M_CLIENT_SECRET(留空)
~/.config/cardcore/m2m.env repo 外、權限 600(呼應 #9 教訓:憑證進過版控後,即便刪除仍留存於歷史)
SCS-022 新增 §7.9(出站憑證);§10 #7 更新(mTLS 仍未實作)→ 升 1.8
SAD-005 §3.2 新增「實作狀態對照」+ fail-closed 說明;併修既有結構缺陷(§10 第 7–10 項誤置於 §11)→ 升 1.2
測試 ClientCredentialsTokenProviderTest(5 例,含負樣本)/OutboundTokenGuardTest(3 例)/ServiceClientHttpClientTest(+3 例)

7. 風險揭露

7.1 修復前之風險(已解除)

⚠️ 修復前,認證一旦常態啟用,所有跨服務功能全面不可用
(授權、入帳、計息、帳單、分期…皆仰賴服務間呼叫)
→ 已於本次修復解除:三服務以 CARDCORE_SECURITY_ENABLED=true 實測,
帶 M2M token 後週期執行成功(201 GENERATED、statementCount=1),
服務日誌 401 次數 0(詳 §9.3)。

7.2 修復後仍存在之風險(未解除,誠實揭露)

# 風險 說明
1 僅完成權杖半邊,mTLS 仍未實作 SAD-005 §3.2 設計為「mTLS + Service Token」雙軌,本次僅補上 Service Token。mTLS 須基礎設施(憑證簽發/輪替/佈署)先就緒
2 服務對服務之端點白名單未實作 目前為「持有有效服務權杖即可呼叫任何端點」,未依 SAD-002 §5 逐端點限縮 → 與「最小權限(SP-02)」仍有落差
3 尚未於任何客戶環境啟用 本機實測通過,未於 SIT/UAT/PROD 驗證;真實環境之 client secret 須由金鑰管理系統提供
4 client-secret 明文見於 ops/local/config/keycloak-realm.json 既有狀況,非本次引入。該檔為本機 Keycloak 匯入用;真實環境憑證一律走 ~/.config/cardcore/m2m.env(repo 外、權限 600),不得進版控
5 權杖快取為每服務程序各自持有 目前無分散式快取;多執行個體下各持一份,IdP 負載為「執行個體數 × 續取頻率」。expires_in=900、提前 30s 續取,單執行個體約 15 分鐘 1 次——可接受,但未於多執行個體實測

8. 核定結果

# 核定 日期
4(方案選擇) ✅ 採 A——Keycloak 服務帳號(client_credentials) 2026-09-20

9. 執行結果(真實環境實測,2026-09-20)

9.1 程式

產出 內容
security/outbound/OutboundTokenProvider 介面:authorizationHeaderValue();無憑證時回 null(僅本地顯式停用)
security/outbound/ClientCredentialsTokenProvider 表單 POST 取 token、快取、到期前 cacheSkew 續取;失敗即拋錯不降級
security/outbound/NoOpOutboundTokenProvider 本地顯式停用;建構時即記 WARN
security/outbound/OutboundTokenAutoConfiguration 組裝 bean + 入站啟用卻缺出站憑證 → 啟動期失敗
SecurityProperties.Outbound 巢狀組態,isConfigured() 判定三項必備設定
ServiceClientHttpClient 送出前帶 Authorization;憑證來源 requireNonNull
ServiceClientAutoConfiguration 以 ObjectProvider 惰性注入(去自動配置順序耦合)
17 個服務 application-local.yml 由產生器模板同步補上出站區段(client-secret 不給預設值)
ops/local/env.sh/.env.example/~/.config/cardcore/m2m.env 憑證外置(600),「若存在才載入」

9.2 單測與判定力

common-spring 60 → 71 測試、0 失敗:

測試類 例數 核心
ClientCredentialsTokenProviderTest 5 ①表單內容 ②快取命中(IdP 僅被呼叫 1 次)③到期續取 ④IdP 非 200 拋錯 ⑤缺 expires_in 拋錯
OutboundTokenGuardTest 3 憑證齊備→建立/缺漏→啟動期失敗/入站停用→NoOp
ServiceClientHttpClientTest +3 帶 Authorization/取不到憑證不送出請求/null provider 拋 NPE

★ 負樣本實測(證明非假綠):

  • 令 CachedToken.isUsable() 恆回 false → 案例 ②(快取命中)精準轉紅
    (IdP 被呼叫 3 次而非 1 次),其餘 4 例不受影響。
  • ⚠️ 第一次負樣本失敗了:我只關掉外層的快取讀取,
    但雙重檢查鎖定的 synchronized 區塊內還有第二處判定,故仍命中快取、測試全綠。
    ——若未察覺即為假綠;改關 isUsable() 才真正測到。

9.3 真實端到端(三服務,認證啟用)

以 CARDCORE_SECURITY_ENABLED=true 啟動 statement(8092)/posting(8087)/pricing(8086):

步驟 結果
三服務啟動日誌 皆見「服務間呼叫憑證已啟用:client_credentials(client=cardcore-services)」+「認證機制已啟用:JWT」
無 token 呼叫批次端點 401 CC-PLT-4010——入站確有鎖
以 client_credentials 取 token 200(長 1,307)
帶 token 執行 2026-12 週期 201 GENERATED、processed=1、statementCount=1
三服務日誌之 401 次數 0/0/0
DB 新增 STMT2026120010:prev=71,309(正確銜接 11 月)/new=72,341

對比修復前:同一操作為 statementCount=0,且日誌全為「計息失敗:HTTP 401」。

9.4 一併處理:#C-42 批次零產出防呆

情境 處置
帳戶清單為空 記 WARN(明載週期代碼與主檔可能未建)
全部帳戶被跳過(statementCount=0 && processedCount>0) 記 ERROR(明載常見成因:服務間認證失敗/IdP 不可用/posting 異常)
回應 CycleRunView 加值新增 skippedCount(=processedCount − statementCount),純加值非 breaking

statement-service 19 → 22 測試、0 失敗(新增 StatementZeroOutputTest 3 例);
★ 負樣本:令 skippedCount 恆為 0 → 案例 ② 精準轉紅、①③不受影響。
契約由 tools/gen_openapi.py 重產 → 72 端點 0 錯誤,且 skippedCount
僅出現於 statement-service.yaml(未誤動其他服務)。

9.5 補齊 api-gateway/bff-web 之認證組態(2026-09-21)

v1.1 執行時僅套用於 17 個領域服務;api-gateway 與 bff-web 之
application-local.yml 係由另一模板 T_APP_LOCAL_YML_CORE 產生,
未含 cardcore.security 區段 → 不受開關影響(v1.1 §6 已列為已知落差)。
本次依組態先行對齊原則補齊(非新設計,屬既有核定設計之套用範圍補正,故未另立 CR)。

項 處置
T_APP_LOCAL_YML_CORE 模板 同步加入 cardcore.security.enabled + outbound 區段(與領域服務版同文)
兩個既有檔 api-gateway/bff-web 之 application-local.yml 一併補上(產生器只寫不存在的檔,故須兩邊都動)
覆蓋率實測 src 下 19/19 份 application-local.yml 全數含 outbound 區段
模板渲染實測 以 string.Template.safe_substitute 渲染 CORE 模板 → ${CARDCORE_SECURITY_ENABLED:false}/${CARDCORE_M2M_CLIENT_SECRET:}/${KEYCLOAK_TOKEN_URI:...} 皆完整保留(未被當佔位符吃掉)
gen_service_skeleton.py --check 25 模組、缺漏 0

啟動實測(api-gateway,兩情境證明組態真被讀到且開關可區分):

情境 結果
CARDCORE_SECURITY_ENABLED=true 啟動失敗(fail-closed)——SecurityDependencyGuardAutoConfiguration 明確拋出「classpath 上找不到 Spring Security…請加入 spring-boot-starter-oauth2-resource-server」。此即組態已生效之證(若未讀到組態,會靜默起來)
CARDCORE_SECURITY_ENABLED=false 正常啟動,Netty started on port 8100、Started ApiGatewayApplication in 6.719s

⚠️ 揭露(非阻礙):api-gateway/bff-web 之 build.gradle.kts 目前尚未引入
spring-boot-starter-oauth2-resource-server,故兩模組於「宣告啟用」時會以上述
fail-closed 方式明確失敗,而非靜默放行——此為守門預期行為。
待此二模組實際承擔入站驗證職責時,須補上該依賴(屬另一項工作,未含於本次)。

9.6 追加:issuer-uri 亦須一併對齊(2026-09-21)

v1.2 只補了 cardcore.security,漏了 spring.security.oauth2.resourceserver.jwt.issuer-uri——
實測 17 個領域服務之 application-local.yml 皆有該設定,gateway/bff 沒有。
依 SCS-022 §7.7 ② 之既有慣例「含尚未導入依賴者,預先就位」,二者應比照補上。

項 處置
T_APP_LOCAL_YML_CORE 模板 加入 spring.security.oauth2.resourceserver.jwt.issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8180/realms/cardcore}
兩個既有檔 api-gateway/bff-web 同步補上
覆蓋率實測 19/19 份 src 組態皆含 issuer-uri
模板比對實測 兩模板(CORE/領域服務)去註解後實質 key 完全一致;差異僅為註解文字與 CORE 不應有之 datasource/redis/kafka/jpa(無資料庫模組)
啟動實測 重新 bootJar 後以 CARDCORE_SECURITY_ENABLED=false 啟動 → 正常(Netty started on port 8100、Started ApiGatewayApplication in 7.915s)→ 新增區段無副作用

9.7 ★ 根因:SCS-022 之錯誤陳述使二模組被排除(2026-09-21 發現並更正)

補齊過程中追出為什麼 v1.1 會漏掉這兩個模組——不是疏忽,是文件寫錯:

出處 原記載 實測
SCS-022 §3 「backend/services/ 下僅有 17 個領域服務,並無 gateway/bff 模組」 ❌ 錯誤。二模組確實存在,位於 backend/platform/api-gateway 與 backend/platform/bff-web
SCS-022 §2 適用範圍 「全部 17 個領域服務」 因而排除 gateway/bff

→ 連鎖結果:CR-010 v1.1 依規格只套用 17 個領域服務,二模組未被納入。
錯誤陳述若不更正,後續任何「全部服務」類工作都會重複漏掉同一批模組。

已更正:SCS-022 升 1.9——§3 更正並補列二模組狀態表、§2 適用範圍納入、
§7.7 步驟 ②/③ 改為 19/19、新增「gateway/bff 導入狀態」表。

9.8 非本地環境(application.yml)刻意不設 cardcore.security——係設計,非缺漏

實測 19 份 application.yml(非本地主組態)皆無 cardcore.security 區段。
查證 SCS-022 §7.4:「停用只能顯式宣告,且僅限本地檔」→
非本地即以預設 true(fail-closed) 運作,憑證由環境變數(KMS)注入。

⚠️ 此點一度被誤判為「各服務組態之落差」而準備補上——若未先查證規格即動手,
將把正確的 fail-closed 設計改掉(在非本地檔寫入 enabled,或硬加區段)。
呼應既有教訓 #17「未查證之事不得寫成待辦」:此為幽靈落差,已撤銷、未動手。


10. 修訂記錄

版本 日期 說明
1.0 2026-09-20 初版。帳單環境重建時實測發現服務間呼叫全數 401;經查 IdP 端(Keycloak cardcore-services)早已備妥,缺口全在程式端。提列三方案並建議採 A;BEAM PMO 核定採 A
1.1 2026-09-20 執行完成並實機實測:①common-spring 60 → 71 測試 0 失敗(ClientCredentialsTokenProviderTest 5/OutboundTokenGuardTest 3/ServiceClientHttpClientTest +3);★ 負樣本第一次失敗(只關外層快取讀取,未察 synchronized 內還有第二處判定 → 差點變假綠),改關 isUsable() 後案例 ② 精準轉紅;②17 服務 application-local.yml 由產生器模板同步補出站區段(--force 會覆寫骨架,未執行);③憑證外置 ~/.config/cardcore/m2m.env(600);④真實三服務端到端(認證啟用):無 token 401、帶 token 執行 2026-12 週期 statementCount=1、三服務 401 次數 0、新增 STMT2026120010(prev=71,309/new=72,341);⑤一併完成 #C-42 零產出防呆(skippedCount 加值、WARN/ERROR 日誌),statement-service 19 → 22 測試 0 失敗,契約重產 72 端點 0 錯誤且僅動 statement-service.yaml;⑥SCS-022 升 1.8(新增 §7.9、更新 §10 #7:mTLS 仍未實作)
1.2 2026-09-21 補齊 api-gateway/bff-web 之認證組態(v1.1 §6 所列之已知落差):依「組態先行對齊」原則,將 cardcore.security(含 outbound)同步至 T_APP_LOCAL_YML_CORE 模板與兩個既有檔——產生器只寫不存在的檔,故模板+既有檔兩邊都動。覆蓋率實測 19/19;模板渲染實測 ${...} 佔位未被 string.Template 吃掉;--check 25 模組缺漏 0。啟動實測(api-gateway)兩情境:=true → fail-closed 啟動失敗並明指缺 oauth2-resource-server(此即組態已生效之證,未讀到者會靜默起來);=false → 正常啟動(Netty started on port 8100)。⚠️ 揭露:此二模組尚未引入 oauth2-resource-server,承擔入站驗證職責前須補依賴(另一項工作)。本次未另立 CR——非新設計,屬既有核定設計之套用範圍補正
1.3 2026-09-21 追加 §9.6:v1.2 只補 cardcore.security,漏了 spring.security.oauth2.resourceserver.jwt.issuer-uri(實測 17 領域服務皆有、gateway/bff 無)→ 依 SCS-022 §7.7 ②「含尚未導入依賴者,預先就位」慣例補齊;模板+既有檔兩邊都動;覆蓋率 19/19;兩模板去註解後實質 key 完全一致;重新 bootJar 後以 =false 啟動正常(8100)→ 無副作用。§9.7 追出根因:SCS-022 §3 原寫「並無 gateway/bff 模組」係錯誤陳述(二模組實位於 backend/platform/),連同 §2 適用範圍使二者被排除於認證規格外→ 連鎖導致 v1.1 僅套用 17 個;已於 SCS-022 1.9 更正。§9.8 撤銷一項幽靈落差:19 份 application.yml(非本地)無 cardcore.security 係設計如此(§7.4 停用僅限本地檔、非本地走預設 fail-closed),一度誤判為落差,查證後未動手
    (1-1/1)