# SCS-022　認證與授權（SSO／JWT）程式設計規格

| 項目 | 內容 |
|---|---|
| 文件編號 | SCS-022 |
| 版本 | **1.11**（新增 **§7.11 使用者權杖轉傳（Token Relay）**——BEAM PMO 2026-09-21 17:10 核定採 B）⚠️ 1.10 版執行後**未回填本欄**（仍記 1.9），本次一併更正 |
| 狀態 | **已核定（規格先行·跨域）**——依 BEAM PMO 2026-09-19 指示「接著依序完成 W-3 SSO」 |
| 對應需求 | `BRD-003`：Authentication **21 項**、SSO **1 項**（皆對應 `SAD-005 §3`） |
| 技術棧 | Keycloak 26.4（OIDC）＋ Spring Security **7.1.1**（由 Spring Boot 4.1.1 BOM 管理，**實測**） |
| 關聯文件 | `SAD-005 §3`（身分驗證）、`SAD-005 §4`（授權）、`CSP-003 §3`（錯誤封套）、`CR-004`（請求層錯誤碼）、`SDD-007 §5`（頁面層級權限） |
| 適用範圍 | **17 個領域服務** ＋ **`api-gateway`／`bff-web`**（跨域規格，非單一服務）。後二者位於 `backend/platform/`（**非** `services/`），自 2026-09-21 起**組態已對齊**（`CR-010` v1.2／v1.3）；惟其 `oauth2-resource-server` 依賴**尚未引入**，故入站驗證責任仍在領域服務 |

> **為何需要本文件**：`SCS-001`～`SCS-021` **無一涵蓋認證／SSO／閘道**，
> 僅 `SAD-005 §3` 有原則性記載。依 `GOV-003` 條件 9「**無 SCS 不寫程式**」，
> W-3 SSO 接線前必須先立本規格。

---

## 1. 對應需求

| 來源 | 項目數 | 驗證方式 |
|---|---:|---|
| `BRD-003` Authentication | 21 | 登入、MFA、鎖定策略測試 |
| `BRD-003` SSO | 1 | SSO 登入流程端到端測試 |

> ⚠️ **本規格僅能驗證其中一部分**。MFA、密碼政策、帳號鎖定屬
> **身分提供者（Keycloak）**之組態，非本專案程式碼；
> 可驗證者為「**服務端是否正確拒絕未授權請求**」。

---

## 2. 服務職責與邊界

| 元件 | 職責 | 本期是否實作 |
|---|---|:---:|
| **Keycloak（IdP）** | 使用者認證、MFA、密碼政策、帳號鎖定、核發 JWT | ✅ 容器已啟用（本期強化組態） |
| **API 閘道** | 驗證簽章與有效期、轉發角色（`SAD-005 §3.3` 之 GW） | ❌ **尚未建立**（見 §3） |
| **領域服務** | 驗證 JWT、抽取角色、端點層授權、資料層級權限 | ✅ 本期 |
| **前端（React 18 + Vite）** | 導向 IdP、攜帶 Bearer Token | ✅ 本期（見 **§7.8**，PKCE） |

---

## 3. ⚠️ 與 `SAD-005 §3.3` 之偏差（誠實揭露）

`SAD-005 §3.3` 之時序圖假設存在 **API 閘道（GW）**，由其驗證簽章與有效期後轉發角色。

> #### ⚠️ 本處原記載有誤，2026-09-21 更正
>
> 原寫「`backend/services/` 下僅有 17 個領域服務，**並無 gateway／bff 模組**」——**此敘述錯誤**。
> 實測：二模組**確實存在**，位置為 **`backend/platform/api-gateway`** 與 **`backend/platform/bff-web`**
> （平台層，與 `common-spring` 等並列，**不**在 `services/` 下），且各有 `application.yml`／
> `application-local.yml` 與 `LayeringTest`。
>
> **連鎖影響（為何要鄭重更正）**：本句連同 §2「適用範圍：全部 17 個領域服務」
> 使 gateway／bff 被**排除在認證規格之外** → `CR-010` v1.1 執行時僅套用 17 個領域服務，
> 二模組之組態**未對齊**（已於 `CR-010` v1.2／v1.3 補正）。
> 錯誤陳述會讓後續工作**重複漏掉同一批模組**，故一併更正，並於下方明列二者之**目前狀態**。

| 模組 | 位置 | 目前狀態（2026-09-21 實測） |
|---|---|---|
| `api-gateway`（8100） | `backend/platform/api-gateway` | 骨架（僅 `ApiGatewayApplication` ＋ `LayeringTest`）；為 **WebFlux**（`spring-cloud-starter-gateway-server-webflux`）；**無** `oauth2-resource-server` 依賴 |
| `bff-web`（8101） | `backend/platform/bff-web` | 骨架（僅 `BffWebApplication` ＋ `LayeringTest`）；為 **Servlet**（`spring-boot-starter-web` ＋ OpenFeign）；**無** `oauth2-resource-server` 依賴 |

本期之替代設計：

| `SAD-005 §3.3` | 本期實作 | 偏差性質 |
|---|---|---|
| GW 驗證簽章與有效期 | **各領域服務自建 OAuth2 Resource Server** 驗證 | **架構偏差** |
| GW 轉發角色給 SVC | 服務自行由 JWT 之 `realm_access.roles` 抽取 | 同上 |
| SVC 資料層級權限檢查 | 不變 | — |

> **處置**：本期以「各服務自建 resource server」先行；
> gateway／bff 模組**已存在**（見上方更正），但其**路由／聚合邏輯尚未實作**，
> 且尚未引入 `oauth2-resource-server` 依賴，故驗證責任**仍留在各領域服務**。
> 此偏差**不影響**安全性——JWT 驗證屬冪等純函式，
> 在閘道或服務端執行皆同等有效；差別僅在**責任集中與否**。
>
> ⚠️ **另一項已揭露之落差（非本節範圍，記於 `CR-010 §9.5`）**：前端（5173）目前
> **直接呼叫領域服務**（如 `statement-service` 8092），**未經 gateway（8100）**——
> 即「藍圖裡有 gateway」≠「流量實際跑在 gateway 上」。

---

## 4. 身分提供者（Keycloak）組態

### 4.1 既有基礎設施（**已實測 2026-09-19 22:50**）

| 項目 | 實測結果 |
|---|---|
| 容器 | `cc-keycloak`（`quay.io/keycloak/keycloak:26.4`，profile `iam`） |
| realm | **`cardcore` 匯入成功**（`Realm 'cardcore' imported`） |
| OIDC discovery | `GET /realms/cardcore/.well-known/openid-configuration` → **HTTP 200** |
| 客戶端 | `cardcore-web`（public，PKCE S256）、`cardcore-services`（M2M，service account） |
| 受眾（audience） | `cardcore-api`（由 protocol mapper 注入） |

### 4.2 本期須對齊 `SAD-005 §3.1` 之組態修正

| `SAD-005 §3.1` 要求 | 原 realm 組態 | 本期修正 |
|---|---|---|
| JWT 有效期 15 分鐘 | `accessTokenLifespan: 900` | ✅ 已符合 |
| 閒置逾時 15 分鐘 | `ssoSessionIdleTimeout: 1800`（30 分） | ⚠️ **改為 900** |
| 密碼長度 ≥ 12 | 無密碼政策 | ⚠️ **新增 `passwordPolicy`** |
| 登入失敗 5 次鎖定 15 分鐘 | 無暴力破解防護 | ⚠️ **新增 `bruteForceProtected`** |
| MFA | 無 | ⬜ **未實作**（見 §10） |

### 4.3 🔴 角色定義不一致（**待 BEAM PMO 裁定，不擅自變更**）

| 來源 | 角色 |
|---|---|
| `SAD-005 §4.2` 角色權限矩陣（**已核定文件**） | `CSR`、`CSR_SUP`、`CREDIT`、`CREDIT_MGR`、`OPS`、`RISK`、`FIN`、`AUDIT`、`ADMIN`（**9 個**） |
| `ops/local/config/keycloak-realm.json` | `cardcore-customer`、`cardcore-csr`、`cardcore-ops`、`cardcore-batch`（**4 個**） |

**兩者對不上，且非單純命名差異**（數量 9 vs 4、語意亦不同）。

- 本期**不擅自修改** `SAD-005 §4.2`（已核定文件），也**不擅自擴充 realm 角色**
  （角色屬業務授權模型，須由業務與資安共同定義）。
- 本期之技術接線以 **realm 既有 4 角色**為準，僅驗證「**服務端是否正確拒絕未授權請求**」。
- **待裁定事項**（已列 `HANDOVER §3.C`）：
  1. 以 `SAD-005 §4.2` 之 9 角色為準 → 須據此重建 realm 角色與群組；或
  2. 以 realm 之 4 角色為準 → 須回頭修訂 `SAD-005 §4.2`。

---

## 5. 程式結構

| 套件／類別 | 位置 | 職責 |
|---|---|---|
| `SecurityConfig` | `platform/common-spring` | `SecurityFilterChain`：JWT 驗證、端點授權、錯誤回應 |
| `JwtRoleConverter` | `platform/common-spring` | `realm_access.roles` → `GrantedAuthority` |
| `SecurityProperties` | `platform/common-spring` | `cardcore.security.*` 組態（啟用開關、公開路徑） |
| 各服務 `application*.yml` | 各服務 | `spring.security.oauth2.resourceserver.jwt.issuer-uri` |

> **為何放 `common-spring`**：17 個服務皆需相同設定，比照 `GlobalExceptionHandler`
> 之作法（集中於平台模組，避免逐服務重工——同 W-1 `KeyProvider` 之教訓）。

---

## 6. 授權規則

### 6.1 公開路徑（無須認證）

| 路徑 | 理由 |
|---|---|
| `/actuator/health`、`/actuator/health/**` | 健康檢查（K8s probe） |
| `/actuator/info` | 版本資訊，無敏感資料 |
| `/actuator/prometheus` | ⚠️ **僅本地／內網**——見 §10 |

### 6.2 需認證路徑

其餘一律須攜帶有效 Bearer Token。

### 6.3 角色與端點 —— ⚠️ **本期僅實作「認證」，不實作「角色授權」**

**原設計**（本節初稿）擬依下表限制端點：

| 角色 | 可存取 | 備註 |
|---|---|---|
| `cardcore-csr` | 查詢類端點（`GET`） | 對應 `SAD-005 §4.2` 之 CSR |
| `cardcore-ops` | 全部端點 | 對應 OPS |
| `cardcore-batch` | 批次相關寫入 | 服務帳號（M2M） |
| `cardcore-customer` | 不開放 | 持卡人應經 BFF，不直連領域服務（見 §10） |

**實際未實作**，理由（並經實作時再確認）：

1. 角色定義本身尚未定案（見 §4.3：realm 4 角色 vs `SAD-005 §4.2` 9 角色）。
   在角色尚未定案前寫死授權規則，等於**把待裁定的業務決策偷偷寫進程式碼**。
2. `SAD-005 §4.2` 明載 CSR 可執行掛失／鎖卡等**寫入**操作
   （如「掛失／鎖卡：CSR ✅」），與上表「CSR 僅 `GET`」**直接矛盾**——
   若照上表實作，將造成與核定文件不一致。

> **故本期之授權規則僅有一條：`anyRequest().authenticated()`**
> （任何<b>有效</b>權杖皆可存取，角色僅抽出备用）。
> `CC-PLT-4030` 與 `AccessDeniedHandler` 已就位，
> 待 §4.3 角色模型裁定後填入 `hasRole(...)` 規則即可生效。
> 此為<b>刻意</b>之保守設計——寧可暫時不限制，也不要限制錯。

---

## 7. 關鍵邏輯

### 7.1 JWT 驗證

- 簽章：由 `issuer-uri` 自動取得 JWKS（`spring.security.oauth2.resourceserver.jwt.issuer-uri`）。
- 驗證項目：簽章、`exp`／`nbf` 有效期、**`aud` 須含 `cardcore-api`**。
- 演算法：Keycloak 預設 **RS256**。

### 7.2 角色抽取

- Keycloak 之 realm 角色位於 **`realm_access.roles`**（非頂層 `roles`）。
- 轉換為 `GrantedAuthority` 時**加上 `ROLE_` 前綴**（Spring Security 慣例）。
- client 角色（`resource_access`）**本期不使用**。

### 7.3 錯誤回應（對齊 `CR-004`）

| 情境 | HTTP | 錯誤碼 |
|---|:--:|---|
| 無 `Authorization` 標頭／格式非 `Bearer`／權杖過期／簽章不符 | **401** | **`CC-PLT-4010`**（未認證） |
| 已認證但權限不足 | **403** | **`CC-PLT-4030`**（無權限） |

> **`CC-PLT-4010`／`CC-PLT-4030` 為本期新增**：`CR-004` 之 `CC-PLT-4xxx`
> 五碼僅涵蓋 400／404／405，無 401／403；且認證攔截發生在**進入控制器之前**，
> 不在 `GlobalExceptionHandler` 之 `@ControllerAdvice` 範圍內。
> 依 `CSP-003 §2`「不得自創錯誤碼，新增碼須先擴充 `SDD-006 §3.4`」程序辦理：
> 已補登 `SDD-006 §3.4` 並重跑 `tools/gen_error_codes.py`（**112 → 114 碼**）。
>
> **⚠️ 實測修正（重要）**：`oauth2ResourceServer` **自帶**
> `BearerTokenAuthenticationEntryPoint`，會**蓋掉**
> `exceptionHandling()` 設定之 entry point，
> 且其預設回應**主體為空**（實測 `Content-Length: 0`）——
> 違反 `CSP-003 §3`「所有回應一律套用 `ApiResponse` 封套」。
> 故必須在 `oauth2ResourceServer(...)` 內**再設一次**相同 handler（見實作註解）。
>
> `WWW-Authenticate` 回應 `Bearer`；失敗時另附 RFC 6750 錯誤碼
> （如 `Bearer error="invalid_token"`），
> **不含** `error_description`——避免外洩函式庫內部訊息（`CSP-004 §6`）。

### 7.4 組態開關

```yaml
cardcore:
  security:
    enabled: true              # **預設 true（fail-closed）**——見下方「v1.7 改為 fail-closed」
    permit-all:                # 公開路徑
      - /actuator/health/**
      - /actuator/info
```

> #### ⚠️ v1.7（2026-09-20，BEAM PMO 核定）：改為 **fail-closed（預設 `true`）**
>
> 原設計刻意預設 `false`，理由為「避免服務引入 `common-spring` 後**意外**被要求認證」。
> 該理由**本末倒置**：其失敗模式是**靜默放行**——
> 與 Flyway 未顯式引入 starter、loki exporter 不存在、OTel 手動組合不匯出
> 同屬「**靜默不做**」型缺陷（見 `SCS-020 §9.2`），是最危險的一類。
>
> **改為 fail-closed 後之三種情境（皆已實測，見 §9.2）**
>
> | 情境 | 行為 | 實測 |
> |---|---|---|
> | 未設定（預設） | **要求認證** → 無憑證回 **401 `CC-PLT-4010`** | ✅ |
> | 啟用但**無** `issuer-uri` | **啟動即失敗**：`required a bean of type 'JwtDecoder'`（大聲失敗，勝過靜默放行） | ✅ |
> | 本地開發 | 由 `application-local.yml` **顯式**停用（`enabled: ${CARDCORE_SECURITY_ENABLED:false}`），並記 **WARN** | ✅ 17/17 |
>
> **停用責任歸屬**：停用只能**顯式**宣告，且僅限本地檔
> （該檔「僅供本地，禁止流入 SIT/UAT/PROD」）。
> 前端 `VITE_AUTH_ENABLED` 亦同步改為預設 `true`（見 §7.8 組態表）——
> 兩端預設值**必須同調**，否則「前端關、後端開」會全部 401。

### 7.9　出站（服務間呼叫）憑證——`client_credentials`（`CR-010`，2026-09-20 BEAM PMO 核定採 A）

> **入站與出站是兩條腿。** §7.4 處理的是「別人打進來要不要認證」；
> 本節處理「我打給別人要不要帶憑證」。**缺了後者，一啟用認證就全掛**。

**問題（實測，非推測）**：`CARDCORE_SECURITY_ENABLED=true` 下執行帳單週期 →
`statement`→`posting`、`posting`→`pricing` **全數 401**；
批次因而回 `GENERATED` 但 **`statementCount=0`**（正常完成卻什麼都沒做）。
根因：`ServiceClientHttpClient` 之建構子僅收逾時組態，**無任何憑證來源**，
呼叫端亦從未帶 `Authorization` 標頭——**端到端都沒有這個能力**。

**IdP 端其實早已備妥**（實測）：Keycloak realm `cardcore` 之
`cardcore-services` 為 confidential client、`serviceAccountsEnabled=true`、
具 `audience-cardcore-api` mapper。實測 `client_credentials` 取 token：
`HTTP 200`、`expires_in=900`、`aud=["cardcore-api","account"]`。

#### 組態（`cardcore.security.outbound.*`）

| 鍵 | 預設 | 說明 |
|---|---|---|
| `token-uri` | 無（**必填**） | `${KEYCLOAK_TOKEN_URI}`，本地預設 Keycloak token 端點 |
| `client-id` | `cardcore-services` | M2M client |
| `client-secret` | 無（**必填**） | `${CARDCORE_M2M_CLIENT_SECRET}`，**不給預設值、不得進 repo** |
| `cache-skew` | `30s` | 提前多久視為過期（吸收時鐘誤差） |
| `connect-timeout`／`request-timeout` | `2s`／`5s` | 取 token 之逾時，**與業務呼叫分開**（見下） |

> ⚠️ **逾時不得沿用業務呼叫之預設值**：取 token 是基礎設施呼叫，
> 不應重試（4xx 表憑證有誤），且 IdP 可能冷啟動，故請求逾時較寬（5s vs 3s）。
> 呼應既有教訓——**每接一個外部系統都要重新檢視預設值**。

#### 類別（`platform/common-spring`）

| 類別 | 職責 |
|---|---|
| `security.outbound.OutboundTokenProvider` | 介面：`authorizationHeaderValue()` |
| `security.outbound.ClientCredentialsTokenProvider` | 取 token、**快取**、到期前續取；失敗**拋錯不降級** |
| `security.outbound.NoOpOutboundTokenProvider` | 本地顯式停用時使用，記 **WARN** |
| `security.outbound.OutboundTokenAutoConfiguration` | 組裝 bean 與 fail-closed 守門 |
| `client.ServiceClientHttpClient` | 送出前帶 `Authorization`；憑證來源 **requireNonNull** |

#### fail-closed 三情境（皆已實測）

| 情境 | 行為 |
|---|---|
| 入站啟用 ＋ 出站憑證齊備 | 取 token 並帶出 |
| 入站啟用 ＋ 出站憑證**缺漏** | **啟動期失敗**（`IllegalStateException`，訊息直接列出缺什麼） |
| 入站**顯式停用**（本地） | 不帶憑證，記 **WARN**（可與「忘了設定」區分） |

> **為何不是「取不到 token 就照發」**：那正是失敗即放行（fail-open），
> 與 §7.6 已處置之「宣告啟用卻沒依賴 → 靜默放行」**同型**。
>
> **為何與入站共用 `cardcore.security.enabled` 而不另立開關**：
> 兩邊必須同調——入站要認證、出站卻不帶憑證 → 全部 401；
> 反之「以為有鎖其實沒鎖」更危險。理由與前後端 `VITE_AUTH_ENABLED` 同調一致（§7.4）。

#### 實測（2026-09-20，真實三服務）

以 `CARDCORE_SECURITY_ENABLED=true` 啟動 `statement`(8092)／`posting`(8087)／`pricing`(8086)：

| 步驟 | 結果 |
|---|---|
| 三服務啟動日誌 | 三個皆見「服務間呼叫憑證已啟用：client_credentials」＋「認證機制已啟用：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`） |

#### 憑證管理

- `client-secret` **一律**由環境變數 `CARDCORE_M2M_CLIENT_SECRET` 提供。
- 本地置於 `~/.config/cardcore/m2m.env`（**權限 600、在 repo 外**），
  由 `ops/local/env.sh`「若存在才載入」。
- SIT／UAT／PROD 須由金鑰管理系統（Vault／KMS）注入。
- ⚠️ **既有狀況**：`ops/local/config/keycloak-realm.json` 內含一組開發用
  `cardcore-services-dev-secret`（本機 Keycloak 匯入用，**非本次引入**），
  真實環境不得比照。

### 7.5 ⚠️ Spring Boot 4 自動配置模組化陷阱（**實測，非推測**）

`SecurityAutoConfiguration` 需以 `ObjectMapper` 寫出錯誤封套。
Spring Boot **4** 已將自動配置與測試自動配置**拆分為獨立模組**，
套件路徑與 3.x 不同——逐項皆為**解開 jar 實測所得**：

| 類別 | Spring Boot 3.x | **Spring Boot 4.1.1（實測）** | 所需依賴 |
|---|---|---|---|
| `JacksonAutoConfiguration` | `org.springframework.boot.autoconfigure.jackson` | **`org.springframework.boot.jackson.autoconfigure`** | `spring-boot-jackson` |
| `@AutoConfigureMockMvc` | `org.springframework.boot.test.autoconfigure.web.servlet` | **`org.springframework.boot.webmvc.test.autoconfigure`** | `spring-boot-webmvc-test` |

> **第二個陷阱（更危險）**：即使套件正確，以 `@AutoConfiguration(after = JacksonAutoConfiguration.class)`
> 排序**仍無法保證** `ObjectMapper` bean 可取——實測啟動失敗
> （`ObjectMapper` 尚未註冊）。
> 故改為 **`ObjectProvider<ObjectMapper>` 惰性取得**：
> 有應用程式之 `ObjectMapper` 就用它（序列化格式一致）；沒有則退回內建實例。
> **徹底去除自動配置順序依賴**——此類「靜默型」缺陷不會在編譯期報錯，
> 只會在執行期以奇怪的方式失敗（同 `Spring Boot 4 + Flyway` 之教訓）。

### 7.6 ⚠️ 依賴守門：宣告啟用卻沒依賴時，**啟動期失敗**（fail-closed）

**問題（實測發現，非推測）**：`SecurityAutoConfiguration` 以
`@ConditionalOnClass({SecurityFilterChain, HttpSecurity, Jwt})` 宣告。
服務若**未**引入 `spring-boot-starter-oauth2-resource-server`，該自動配置會被
**靜默跳過**——此時即使設定 `cardcore.security.enabled=true`，
**所有請求仍全部放行**，且啟動日誌不會有任何錯誤。

> 實測紀錄（2026-09-19）：`card-service` 設 `CARDCORE_SECURITY_ENABLED=true`
> 但未引入依賴 → **無 token 亦回 HTTP 200**，且日誌無任何警示。

此為**失敗即放行（fail-open）**：認證宣告已啟用卻未生效，
等同系統在管理者不知情的情況下裸奔——**信用卡核心不可接受**。

**對策**：新增 `SecurityDependencyGuardAutoConfiguration`——
當「`cardcore.security.enabled=true`」**且**「classpath 無 `Jwt`」時，
**於啟動期拋 `IllegalStateException`**，並於訊息中直接給出處理方式。
依賴存在時本類不生效（由 `SecurityAutoConfiguration` 接手）；
未宣告啟用時亦不生效（維持「預設關閉」之設計）。

> ⚠️ 本類**不得 import 任何 Spring Security 類別**——
> 其存在目的即在「依賴缺席」時仍能載入，故僅以類別名字串
> （`@ConditionalOnMissingClass`）判定。

**實測驗證**：`card-service` 於缺依賴時重啟 → **啟動失敗**並顯示上述訊息；
補上依賴後重啟 → `認證機制已啟用：JWT（OAuth2 Resource Server）`，
隨後 401／200／偽造 token／公開路徑四情境皆如預期。

### 7.7 單一服務之導入步驟（onboarding）

| 步驟 | 動作 | 本期狀態 |
|---:|---|---|
| ① | `build.gradle.kts` 加入 `implementation("spring-boot-starter-oauth2-resource-server")` | ✅ **17/17 領域服務已完成**（2026-09-20，本次補齊其餘 15 個）。⚠️ `api-gateway`／`bff-web` **仍未引入**（見下表） |
| ② | `application-local.yml` 設定 `spring.security.oauth2.resourceserver.jwt.issuer-uri` | ✅ **19/19 已完成**（2026-09-21：`api-gateway`／`bff-web` 依下方「預先就位」慣例補齊） |
| ③ | 設 `cardcore.security.enabled` | ✅ **預設即為 true（fail-closed，v1.7 起）**；本地由 `application-local.yml` 顯式停用（**19/19**）；`CARDCORE_SECURITY_ENABLED=true` 可於本地開啟驗證 |

> ② 與 ① 分離之理由：組態先行就位，使「啟用」退化為單一開關；
> 未導入依賴者若誤設開關，由 §7.6 之守門於啟動期擋下，**不會靜默放行**。

**`api-gateway`／`bff-web` 之導入狀態（2026-09-21 實測）**：

| 步驟 | 狀態 | 說明 |
|---|---|---|
| ② `issuer-uri` | ✅ **已就位**（19/19） | 依「**含尚未導入依賴者，預先就位**」之既有慣例補上，使未來補 ① 時不遺漏 |
| ③ `cardcore.security.enabled` ＋ `outbound` | ✅ **已就位**（19/19，`CR-010` v1.2） | 本地顯式停用；一旦啟用，由 `SecurityDependencyGuardAutoConfiguration` **啟動期失敗**並明指缺 ① |
| ① `oauth2-resource-server` 依賴 | ⚪ **未導入** | 二模組仍為骨架；承擔入站驗證職責前須補。**此為已知落差，非缺陷**——守門使其「大聲失敗」而非靜默放行 |

> ⚠️ **非本地環境（SIT／UAT／PROD）刻意不在 `application.yml` 設 `cardcore.security`**：
> 依 §7.4「停用只能**顯式**宣告，且**僅限本地檔**」，非本地即以**預設 true（fail-closed）** 運作。
> 實測 19 份 `application.yml` 皆**無**該區段——**此為設計如此，不是缺漏**；
> 憑證一律由環境變數（KMS）注入，不進 repo。

**③ 之單一開關**：`CARDCORE_SECURITY_ENABLED` 經 Spring Boot 寬鬆綁定對應
`cardcore.security.enabled`，故**無須逐服務改 yml**——一次環境變數即全域生效：

```bash
export CARDCORE_SECURITY_ENABLED=true   # 後端 17 服務
export VITE_AUTH_ENABLED=true           # 前端（必須同調，見 §7.8）
```

### 7.7.1 逐服務實測結果（2026-09-20，17/17）

以 `CARDCORE_SECURITY_ENABLED=true` 逐一起 jar 實測（探測路徑 `/api/v1/sso-probe`，
該路徑不存在，故**帶 token 之 404 即代表通過認證**、未達授權階段）：

| 服務 | 埠 | 無 token | 帶 token | `/actuator/health` |
|---|---:|---:|---:|---:|
| authorization-service | 8081 | 401 | 404 | 200 |
| card-service | 8082 | 401 | 404 | 200 |
| limit-service | 8083 | 401 | 404 | 200 |
| party-service | 8084 | 401 | **200**（真實資料） | 200 |
| product-service | 8085 | 401 | 404 | 200 |
| pricing-service | 8086 | 401 | 404 | 200 |
| posting-service | 8087 | 401 | 404 | 200 |
| payment-service | 8088 | 401 | 404 | 200 |
| installment-service | 8089 | 401 | 404 | 200 |
| rewards-service | 8091 | 401 | 404 | 200 |
| statement-service | 8092 | 401 | 404 | 200 |
| dispute-service | 8093 | 401 | 404 | 200 |
| clearing-service | 8094 | 401 | 404 | 200 |
| merchant-service | 8095 | 401 | 404 | 200 |
| migration-service | 8096 | 401 | 404 | 200 |
| notification-service | 8097 | 401 | 404 | 200 |
| report-service | 8098 | 401 | 404 | 200 |

> `party-service` 係以**真實存在的持卡人**驗證（`P001`），故為 200 而非 404。

---

### 7.8　前端（WebUI）取得 Token——PKCE（`HANDOVER #C-37`，BEAM PMO 2026-09-20 核定採 A）

> **為何需要**：後端 17 服務之驗證機制已於 §7.1～§7.7 完成並實測，
> 但 `frontend/` 未帶任何 `Authorization` 標頭，
> 致 `cardcore.security.enabled` **常態必須維持 false**——
> 否則 WebUI 全數 401。
>
> ⚠️ **v1.7 起此限制已解除**：本節之 PKCE 已完成，前端已可帶 token，
> 故上述「常態必須維持 false」之理由**不再成立**——
> 已於 v1.7 改為 **fail-closed（預設 true）**，詳 §7.4。
> 本節為「前端如何取得 token」之規格。

**已備妥之基礎設施（實測 2026-09-20 07:50，非推測）**

| 項目 | 實測結果 |
|---|---|
| Keycloak client `cardcore-web` | **已存在**：`publicClient=true`、`standardFlowEnabled=true`、<br>`directAccessGrantsEnabled=**false**`（不開 password grant）、`webOrigins=http://localhost:5173` |
| `redirectUris` | `http://localhost:5173/*`——**與 Vite dev server 埠號一致** |
| 發現端點 | `grant_types_supported` 含 `authorization_code`；`code_challenge_methods_supported` 含 **`S256`** |

→ **IdP 端已就位**，本期僅須於前端實作授權碼流程。

**流程（Authorization Code + PKCE，RFC 7636）**

| 步驟 | 內容 |
|---|---|
| ① | 產生 `code_verifier`（43～128 字元之高熵亂數）與 `state`；<br>`code_challenge = BASE64URL(SHA256(code_verifier))`，方法固定 **`S256`**（不用 `plain`） |
| ② | 導向 `authorization_endpoint`，帶 `response_type=code`、`code_challenge`、`code_challenge_method=S256`、`state` |
| ③ | IdP 回導 `redirect_uri?code=…&state=…`；**先比對 `state`**（防 CSRF），不符即中止 |
| ④ | 以 `code` ＋ `code_verifier` 向 `token_endpoint` 交換 token（**不帶 client_secret**——public client 沒有） |
| ⑤ | 取得後**立即移除** `code_verifier`（一次性，不得留存） |

**Token 存放（安全取捨）**

| 項目 | 存放位置 | 理由 |
|---|---|---|
| access token | **記憶體**（模組層變數） | 不落地即不受 XSS 竊取持久憑證；重整頁面即失效 |
| refresh token | **`sessionStorage`** | 關掉分頁即清除；若存記憶體則重整就必須重新登入，demo 體驗過差 |
| `code_verifier`／`state` | **`sessionStorage`**（用後即刪） | 流程跨導頁，必須暫存；一次性 |

> ⚠️ **不得使用 `localStorage`**——它跨分頁持久且同源任何腳本皆可讀，
> 一旦發生 XSS 即為**長期**憑證外洩。此為 SPA 之最低安全底線。

**換發與失效**

| 情境 | 處理 |
|---|---|
| access token 將屆期（**提前 60 秒**） | 自動以 refresh token 換發 |
| 收到後端 **401 `CC-PLT-4010`** | **僅重試一次**：先換發再重送原請求；仍 401 才導向登入 |
| refresh 失敗 | 清除 token，導向 IdP 重新認證 |
| 登出 | 呼叫 `end_session_endpoint` 並清除本端 token |

> 401 之所以「只重試一次」：若無條件重試，token 真的失效時會形成請求風暴。

**不引第三方 OIDC 套件（刻意決定）**

本期**手寫**精簡 PKCE 流程，不導入 `keycloak-js`／`oidc-client-ts`。理由：
① 銀行案資安審查，每多一個第三方套件就多一份待審相依；
② 所需僅「授權碼＋PKCE＋refresh＋登出」，手寫約百餘行即可，可控性高於引入框架；
③ 前端既有風格為裸 `fetch`，引入套件會造成兩種呼叫風格並存。

**組態開關**

| 變數 | 預設 | 說明 |
|---|---|---|
| `VITE_AUTH_ENABLED` | **`true`**（v1.7 起，與後端同調） | 關閉時不導向 IdP、不帶標頭——**與後端 `cardcore.security.enabled` 必須同調**；兩端預設值**皆為 fail-closed**，本地由 `.env.development` 顯式停用 |
| `VITE_KEYCLOAK_URL` | `http://localhost:8180` | IdP 根位址 |
| `VITE_KEYCLOAK_REALM` | `cardcore` | realm |
| `VITE_KEYCLOAK_CLIENT_ID` | `cardcore-web` | public client |

> 組態一律經 `import.meta.env` 注入，**不得硬編於程式碼**（`CSP-001 §8 B6`）。

**實測證據（2026-09-20，真實 Keycloak ＋ 真實 Oracle ＋ 真實 Chrome）**

① **協定層**（`tools/verify_sso_pkce.py`，僅用標準函式庫）：

| 情境 | 結果 |
|---|---|
| 無 token | **401** `CC-PLT-4010` |
| 帶 PKCE 取得之 token | **200**，回傳真實持卡人 `P001`（個資欄位皆為遮蔽值） |
| **偽造** token（末 6 碼改寫） | **401** `CC-PLT-4010` |
| refresh_token 換發 | 200，取得新 access token |

> 該腳本並含**反向驗證**：對**未**啟用安全性之服務執行時會判定 ❌
> （實測 `posting-service` 即為此情形——故其同時為 `HANDOVER #C-34`
> 「SSO 推廣至其餘 15 服務」之實證）。

② **瀏覽器層**（真實 Chrome headless，Vite dev server 5173）：

| 步驟 | 結果 |
|---|---|
| 未登入訪問前端 | 導向 Keycloak 授權端點 |
| 輸入 `demo.holder` | 回導 5173，前端完成交換後**清除網址上的 `code`** |
| token 存放 | refresh 於 `sessionStorage` ✅；**`localStorage` 為空** ✅ |
| 執行前端自己的 `fetchParty()` | **200**，取回 `P001／王小明`；送出的 `/party-api` 請求**帶 Bearer** |
| **重整**（access token 存於記憶體已失） | 以 refresh token **重建 session 成功**，不必重打帳密 |

> 瀏覽器層之驗證係**直接 `import('/src/api/parties.ts')` 執行前端程式碼**，
> 非另寫一份等效邏輯——故驗證到的確實是交付出去的那份程式。

---

### 7.10　服務間呼叫 ACL（白名單）——`CR-011` 階段二（2026-09-21）

> **本節為「授權」之第二層**：§7.1–7.4 解決「**是不是有效權杖**」（認證），
> §7.9 解決「**呼叫時有沒有帶權杖**」（出站憑證），本節解決「**這個權杖的持有者能不能打這個服務**」。

#### 7.10.1 為何需要

`CR-010` 之後，任何持有有效 M2M 權杖者可呼叫**任何**服務的任何端點——
等於「有鑰匙就能開所有的門」。`SAD-002 §5` 已定義服務互動矩陣（誰可以同步呼叫誰），
但該矩陣**從未被程式讀取**，形同具文。本節將其落實為可執行之白名單。

#### 7.10.2 單一真實來源

```
SAD-002 §5 互動矩陣  ──(tools/gen_service_acl.py --write)──▶  cardcore-service-acl.yml
```

- 輸出：`backend/platform/common-spring/src/main/resources/cardcore-service-acl.yml`（**19 條目**）
- **⚠️ 本檔不得手改**。改動流程：修 `SAD-002 §5` → 重跑產生器 → 重跑測試。
  直接編輯 yml 會造成「規格與實作兩份真相」。
- 轉置規則：矩陣為「呼叫者 ＼ 被呼叫者」，本檔為「被呼叫端 → 允許之 `azp`」；**只取同步（`S`）**，
  事件（`A`）走 Kafka、不受本機制管制。

#### 7.10.3 判斷規則（`ServiceCallerAclManager`）

| 順序 | 情境 | 結果 |
|---|---|---|
| 1 | 未認證／匿名 | **拒絕**（401） |
| 2 | 使用者 token（`azp` 非 `cc-` 開頭） | **放行**——交由角色授權，本 ACL 不管制 |
| 3 | 服務帳號（`azp` ＝ `cc-<模組名>`）且列於本服務之允許清單 | **放行** |
| 4 | 服務帳號但**不在**允許清單 | **拒絕**（403 `CC-PLT-4030`） |

**⚠️ 規則 2 之理由**：若一併管制使用者 token，將**擋掉所有使用者請求**
（`azp` 為前端 client，不在互動矩陣內）。使用者能做什麼屬「角色授權」，與「誰打來」正交，須分開。

#### 7.10.4 fail-closed

| 情境 | 行為 |
|---|---|
| ACL 資源不存在 | 啟動期拋錯 |
| ACL 解析失敗 | 啟動期拋錯（**不靜默回傳空 ACL**） |
| `spring.application.name` 未設定 | 啟動期拋錯 |
| 本服務不在 ACL 中（＝`SAD-002 §5` 未涵蓋） | 啟動期拋錯 |

> 「沒有設定」與「設定為空」**必須可區分**：後者代表「本服務不接受任何同步呼叫」（如 `statement-service`），
> 前者代表「規格有缺漏」——都當成空集合會讓缺漏**靜默通過**。

#### 7.10.5 🔴 已知陷阱：`AnonymousAuthenticationToken.isAuthenticated()` 為 `true`

Spring Security 於無憑證請求時放入 `AnonymousAuthenticationToken`，且其 `isAuthenticated()` **回傳 `true`**
（刻意設計，使其能參與授權判斷）。因此：

```java
// ❌ 錯誤：匿名請求之 auth 非空且 isAuthenticated()==true，
//         會落入「非服務帳號一律放行」而被放行（實測回 200）
if (auth == null || !auth.isAuthenticated()) { return deny; }

// ✅ 正確：顯式排除匿名
if (auth == null || !auth.isAuthenticated()
        || auth instanceof AnonymousAuthenticationToken) { return deny; }
```

> 此缺陷係由**過濾器層級**測試（`ServiceAclFilterTest ④`）抓出——
> 單元測試之負樣本誤用 `TestingAuthenticationToken(false)`，非真實型別，故未發現。
> **教訓：負樣本必須覆蓋所有判斷點，且必須用真實型別。**

#### 7.10.6 實測（2026-09-21，真實 Keycloak token → 真實 `posting-service`）

| # | 呼叫者 | 結果 |
|---|---|---|
| ① | 無憑證 | `401` `CC-PLT-4010` |
| ② | `cc-statement-service`（矩陣 S） | `404` `CC-POST-4040` —— **ACL 通過**（查無分戶，業務錯誤） |
| ③ | `cc-clearing-service`（矩陣非 S） | `403` `CC-PLT-4030` ✅ |
| ④ | `cc-bff-web` | `403` `CC-PLT-4030` ⚠️ —— 見 §10 |

啟動日誌：`服務間呼叫 ACL 已載入（19 個條目，本服務允許 4 個呼叫者）`；
拒絕時記 `⛔ 服務間呼叫遭拒：本服務=posting-service 呼叫者=cc-clearing-service`，traceId 與 HTTP 回應一致。

#### 7.10.7 測試（判定力已驗證）

| 測試 | 例數 | 驗證層級 |
|---|---|---|
| `ServiceAclTest` | 10 | 單元（解析／fail-closed／`AuthorizationManager` 決策） |
| `ServiceAclFilterTest` | 4 | **過濾器層級**（走完整 filter chain，防「沒掛上 `HttpSecurity`」之假綠） |

負樣本驗證：決策一律放行 → **5 例轉紅**；一律拒絕 → **3 例轉紅**。
`common-spring` 測試總數 71 → **85**，全綠。

---

### 7.11　使用者權杖轉傳（Token Relay）——**gateway／bff 採 B**（BEAM PMO 2026-09-21 17:10 核定）

> **設計依據**：`SAD-005 §3.4`（身分轉傳原則）／`CR-011 §11`。
> 本節為其**程式設計規格**；裁定內容見 `CR-011 §11`。

#### 7.11.1 為何需要（問題陳述）

`CR-011` 階段二上線後，實測 `cc-bff-web`／`cc-api-gateway` 呼叫領域服務 → **403 `CC-PLT-4030`**。
根因：`SAD-002 §5` 互動矩陣**不含 gateway／bff**，故二者之允許清單皆為 `[]`（產生器轉置之正確結果）。

兩個選項中，**BEAM PMO 核定採 B**——不為其開 M2M 白名單，而令其**轉傳使用者權杖**：

| | 做法 | 後果 |
|---|---|---|
| A（不採） | 於矩陣新增 gateway／bff 兩列 | 取得「可呼叫所有服務」之**超級身分**；服務端**失去使用者身分** |
| **B（採）** | **轉傳使用者權杖**，不以 M2M 呼叫領域服務 | 現行 ACL **無須修改**；`azp` 非 `cc-` 本就不受管制（§7.10 規則 3） |

#### 7.11.2 組態模式（`cardcore.security.outbound.mode`）

| 模式 | 適用 | 憑證需求 | 行為 |
|---|---|---|---|
| `client_credentials`（**預設**） | 17 個領域服務；非使用者情境（批次／排程／事件消費） | `token-uri`／`client-id`／`client-secret` **三項必填** | 由 `ClientCredentialsTokenProvider` 取 M2M 權杖 |
| `token-relay` | **僅 `api-gateway`／`bff-web`** | **無**（不得也不需要設定 client 憑證） | 由 `RelayingOutboundTokenProvider` 轉傳當前使用者權杖 |

> ⚠️ **為何以「模式切換」而非「刪除 gateway／bff 之出站組態」**：
> `OutboundTokenAutoConfiguration` 之 fail-closed 為「入站啟用 ＋ 出站憑證**未**設定 → **啟動期失敗**」。
> 直接刪除組態會使二者在 `enabled=true` 時**起不來**。故必須新增模式，讓「不需要 M2M 憑證」成為**可被表達的合法狀態**。

#### 7.11.3 `RelayingOutboundTokenProvider` 行為規格

| # | 情境 | 行為 |
|---|---|---|
| 1 | 當前身分為 `JwtAuthenticationToken` | 回 `Bearer <原權杖字串>`（原樣轉傳，**不重新簽發、不改內容**） |
| 2 | 無身分／匿名（`AnonymousAuthenticationToken`） | **拋 `IllegalStateException`**（fail-closed） |
| 3 | 身分型別非 JWT | **拋 `IllegalStateException`**——不靜默降級為不帶標頭 |

> ⚠️ **情境 2／3 之所以拋錯而非回 `null`**：回 `null` 會讓呼叫「不帶 `Authorization` 照發」，
> 屬**失敗即放行（fail-open）**，與 `§7.6` 已處置之「宣告啟用卻沒依賴 → 靜默放行」同型，不可接受。
> 批次情境下該錯誤還會被「跳過該帳戶、批次繼續」吸收成**靜默零產出**（`CR-010` 之 P0 實例）。

#### 7.11.4 與服務間 ACL（§7.10）之關係

轉傳後領域服務見到之 `azp` 為 **`cardcore-web`**（非 `cc-`）→ 判定「非服務帳號」→ **放行**，
交由角色授權。**故白名單無須任何修改**——這是 B 方案之關鍵優勢。

#### 7.11.5 已知限制（誠實揭露）

| # | 限制 |
|---|---|
| 1 | **僅適用於同步、同執行緒**之呼叫。`SecurityContext` 預設不跨執行緒傳遞；`@Async`／反應式（`WebFlux`）情境須另行處理 |
| 2 | gateway／bff 目前**仍為骨架**（無路由、無 Feign client），本節係**機制先行**；實際轉傳行為**尚未有端到端實測**（無呼叫端可測） |
| 3 | 轉傳之權杖若於飛行途中過期，被呼叫端將回 **401**；本機制**不主動刷新**（應由前端負責，見 §7.8） |
| 4 | 領域服務無法區分「直接來自瀏覽器」與「經 bff 轉傳」——二者之 `azp` 皆為 `cardcore-web`。若日後需要區分，須另於權杖中加入來源宣告 |


## 8. 外部依賴與降級

| 依賴 | 失敗處理 |
|---|---|
| Keycloak 不可用（JWKS 抓取失敗） | **啟動期**：JWKS 快取未建立 → 服務**啟動失敗**（fail-fast，不靜默放行） |
| Keycloak 事後不可用 | **運行期**：已快取之 JWKS 仍可驗證 → 既有 token 仍可用；**換 key 後**始失效 |
| 時鐘偏移 | 依賴 NTP；`exp` 驗證容許預設之時鐘偏移 |

> ⚠️ **不提供「繞過認證」之降級**——認證失敗一律拒絕，不得放行（資安底線）。

---

## 9. 測試案例

| 編號 | 情境 | 預期 |
|---|---|---|
| `UT-SEC-001` | 無 `Authorization` 標頭 | 401 **`CC-PLT-4010`** |
| `UT-SEC-002` | 格式錯誤（非 `Bearer`） | 401 **`CC-PLT-4010`** |
| `UT-SEC-003` | 有效 token | 200 且可取得資料（**角色未納入判定**，見 §6.3） |
| `UT-SEC-004` | 有效 token、**角色不符** | ⬜ **未實作**——見 §6.3，待角色模型裁定（`CC-PLT-4030` 目前不會被觸發） |
| `UT-SEC-005` | **過期** token | 401 |
| `UT-SEC-006` | **簽章不符**（偽造 token） | 401 |
| `UT-SEC-007` | 公開路徑 `/actuator/health` | 200 **無須** token |
| `UT-SEC-008` | 關閉開關（`enabled: false`） | 全部路徑放行（**本地顯式停用**用；本地／測試不得仰賴預設值） |
| `UT-SEC-010` | **不設定開關**（驗證 fail-closed） | **401 `CC-PLT-4010`**——證明「忘記設定」時是拒絕、不是放行。見 `SecurityFailClosedTest`（已含**反向驗證**：預設改回 `false` 時該測試**必紅**） |
| `IT-SEC-001` | **向 Keycloak 取得真實 token** 並呼叫服務 | 200（端到端，證明非自嗨） |
| `UT-SEC-009` | **角色授權（403）** | ⬜ **未實作**——見 §6.3，待角色模型裁定 |

### 9.1 實測證據（2026-09-19，向 Keycloak 取得真實權杖）

| # | 情境 | 實測結果 |
|---:|---|---|
| ① | 無 `Authorization` 標頭 | `401` `CC-PLT-4010`（含封套） ✅ |
| ② | **有效**權杖（Keycloak 核發） | **`200`**，回傳真實資料 `P001`／`0912****78`／`k***@example.com` ✅ |
| ③ | 公開路徑 `/actuator/health` | `200`（無須權杖） ✅ |
| ④ | 偽造權杖（簽章不符） | `401` `CC-PLT-4010`；`WWW-Authenticate: Bearer error="invalid_token"`；**主體含封套** ✅ |
| ⑤ | 格式錯誤（`Basic` 而非 `Bearer`） | `401` `CC-PLT-4010` ✅ |
| ⑥ | **過期**權杖（逾 15 分鐘） | `401` `CC-PLT-4010` ✅ |

> 權杖宣告實測：`iss=http://localhost:8180/realms/cardcore`、
> `aud=cardcore-api`、`realm_access.roles=["cardcore-csr"]`
> ——**確認角色位於巢狀之 `realm_access.roles`**（見 §7.2）。

---

### 9.2 fail-closed 實測證據（`HANDOVER #C-24`，2026-09-20 12:10）

以 `card-service`（埠 8082）＋ 真實 Oracle ＋ 真實 Keycloak 26.4 實測三種情境：

| # | 情境 | 實測結果 |
|---:|---|---|
| ① | **本地預設**（`application-local.yml` 顯式 `enabled: false`） | 啟動日誌 `認證機制【已顯式停用】`；`/actuator/health` **200**；發卡請求**未被認證擋下**（400／201，取決於請求內容，非 401） ✅ |
| ② | `CARDCORE_SECURITY_ENABLED=true` | 啟動日誌 `認證機制已啟用：JWT（OAuth2 Resource Server）`；<br>無 token → **401** 且主體 `{"error":{"code":"CC-PLT-4010","message":"未提供有效之認證憑證"}}`；<br>**帶真實 Keycloak token** → **201**（真的放行並建卡）；`/actuator/health`（permitAll）**200** ✅ |
| ③ | 啟用但**未給** `issuer-uri` | **啟動失敗**（`APPLICATION FAILED TO START`）：<br>`Method cardcoreSecurityFilterChain ... required a bean of type 'org.springframework.security.oauth2.jwt.JwtDecoder'`；<br>結束碼 **1**、埠無監聽 ✅ |

> **③ 之意義**：fail-closed 有兩種表現——「拒絕請求（401）」與「啟動失敗」。
> 後者看似粗暴，但**遠勝靜默放行**：組態缺失會在**部署當下**被發現，
> 而不是等到被稽核或被打穿才發現。

**守門測試**：`platform/common-spring` 新增
`SecurityFailClosedTest`——刻意**不設定** `cardcore.security.enabled`，
斷言無憑證請求為 **401**。
已做**反向驗證**：將 `SecurityProperties.enabled` 暫改回 `false` 後，
該測試**確實轉紅**（`FAILED`），故非假驗證。

---

## 10. 已知限制（誠實揭露）

1. **無 API 閘道**——見 §3，驗證責任分散於各服務。
2. **角色定義不一致**——見 §4.3，**待 BEAM PMO 裁定**。
3. **MFA 未啟用**——`SAD-005 §3.1` 要求 MFA，Keycloak 需設定 Authentication Flow；
   本期未做（屬 IdP 組態，非程式碼）。
4. ~~**`401` 無專屬錯誤碼**~~ → **已解除**：本期已補登 `CC-PLT-4010`／`CC-PLT-4030`
   （`SDD-006 §3.4`，錯誤碼 **112 → 114**）。
   惟 **`CC-PLT-4030` 目前不會被觸發**——因角色授權未實作（見 §6.3）。
5. **`/actuator/prometheus` 未納入授權**——監控抓取通常不帶 token；
   本地可接受，**正式環境須由網路層（內網／Service Mesh）隔離**，不得以授權放行。
6. **持卡人（`cardcore-customer`）直連領域服務本期不開放**——
   持卡人應經 BFF（`SAD-001 AP-*`），直連會繞過資料層級權限。
7. **服務間認證之 mTLS 半邊仍未實作**——`SAD-005 §3.2` 要求「mTLS ＋ Service Token」；
   2026-09-20 之 **`CR-010`** 已補上 **Service Token**（`client_credentials`，見 §7.9），
   惟 **mTLS 仍待基礎設施**（憑證簽發與輪替），**未因本次而宣稱完成**。
8. **敏感操作再驗證**（`SAD-005 §3.1`）**未實作**——須搭配 `SDD-007 §5` 之頁面流程。
9. ~~**實測覆蓋僅 2/17 服務**~~ → ✅ **已解除（17/17）**：本次補齊其餘 15 個服務之
   `oauth2-resource-server` 依賴並**逐服務實測**，詳 **§7.7.1**。
   其**僅完成「認證」之實測**——授權（角色）仍未實作（見 §6.3），
   故帶 token 之結果為 404（未達授權階段）而非角色判斷之結果。
10. ~~**🔴 前端（WebUI）無 token 來源**~~ → ✅ **已解除**：本期實作 §7.8 之 Authorization Code + PKCE（**手寫、不引第三方套件**），access token 存**記憶體**、refresh token 存 `sessionStorage`、**不用 `localStorage`**；401 僅重試一次、refresh 失敗才重新導向。<br>⚠️ **仍存之限制**：SPA 於瀏覽器持有 token **非銀行生產之最終型態**——最安全者為「token 不進瀏覽器」之 BFF／Gateway 模式（httpOnly cookie），待 `C-12`（api-gateway）／`C-13`（bff-web）建立後應將此責任上收。本期定位為 **POC／展示環境**之可用方案，已於此明列以供審查。
11. ~~**Keycloak 健康端點 404**~~ → **已於 v1.6 查明並修正**（`HANDOVER #C-35`）：
    **端點本來就正常**，問題是它**不在主埠 8180，而在管理埠 9000**（官方文件：
    「exposed on the management port 9000 by default」），而該埠**未映射至主機**
    → 由主機探測 `/health*` 一律 404，易被誤判為「健康端點壞掉」。
    實測容器內 `9000/health/ready` → **200 `{"status":"UP"}`**（含資料庫連線檢查）。
    修正：①`docker-compose.yml` 映射 `9000:9000` ②補 `KC_METRICS_ENABLED=true`
    ③`prometheus.yml` 新增 `keycloak` job（原**完全沒有指標**）。
    現況：`/health`／`/health/ready`／`/health/live`／`/health/started` **皆 200**；
    Prometheus `up{job="keycloak"}=1`、**930 個序列**。
    回歸驗證：容器重建後權杖簽發仍正常（`demo.holder` → `cardcore-customer`）。
    ⚠️ 資安提醒：管理埠**不應**對外暴露——本機可接受，SIT 以上應限監控網段，
    或改以容器內 HEALTHCHECK 探測（免映射埠）。
12. ~~**⚠️ 認證「機制已備」但「常態未啟用」**~~ → **已於 v1.7 解除（BEAM PMO 2026-09-20 核定）**：
    `SecurityProperties.enabled` 預設改為 **true（fail-closed）**。
    原「忘記設環境變數即靜默放行」之風險**已消除**——
    未設定時為要求認證（401），連 `issuer-uri` 都沒給則**啟動即失敗**（§9.2 ③）。
    停用改為**只能顯式宣告**，且僅限 `application-local.yml`（17/17 皆已宣告），
    停用時記 **WARN** 使「刻意停用」與「忘了啟用」在日誌上可區分。
    前端 `VITE_AUTH_ENABLED` 同步改為預設 `true`（兩端必須同調，見 §7.4）。
13. ✅ **`api-gateway`／`bff-web` 之呼叫方式——BEAM PMO 2026-09-21 17:10 核定採 B（轉傳使用者權杖）**，並已執行完畢（`CR-011 §11`）：
    實測以 `cc-bff-web` 之權杖呼叫 `posting-service` → **403 `CC-PLT-4030`**。
    原因：`SAD-002 §5` 互動矩陣僅涵蓋 **17 個領域服務**，不含 gateway／bff →
    二者既不允許任何服務帳號呼叫，**也未被任何服務允許**。
    現況：兩模組皆為**骨架**（`api-gateway` 無路由設定、`bff-web` 無 Feign client），
    故**無立即故障**；但一旦 bff 以 M2M 呼叫領域服務即**全數 403**。
    **建議採「轉傳使用者權杖」而非放行 M2M**——gateway／BFF 係使用者之代理人，
    改用 M2M 會使服務端**失去使用者身分**，使用者層級授權與稽核皆無從實作；
    且現行設計已預留此路徑（使用者 token 之 `azp` 非 `cc-`，不受 ACL 管制）。
    ⚠️ 若採此方向，`CR-010 §9.5` 為二者補上的**出站 M2M 組態即不應被使用**，
    須於 `SAD-005` 明訂「轉傳原則」以免日後誤用。
    ★ **影響範圍複核（2026-09-21 15:50，實測）**：`frontend/vite.config.ts` 之 proxy **直連各領域服務**、並未經過 gateway／bff，且其註解明載「正式環境應統一由 API Gateway（8100）出口」→ **目前影響為 0，但係已預定之地雷**。`api-gateway`／`bff-web` 兩模組實為空殼（`src/main/java` 各僅 1 個 `Application` 類別，**無路由、無 Feign client**）。詳 `CR-011 §11`「影響範圍」與「附帶實測」（本 ACL 極嚴格：矩陣未畫到之同步呼叫一律 403）。

     ✅ **已執行**：`SAD-005` → **1.3**（新增 §3.4 身分轉傳原則）；本規格新增 **§7.11**；程式新增 `OutboundMode`／`RelayingOutboundTokenProvider`；gateway／bff 組態改為 `mode: token-relay` 並移除 client 憑證；`common-spring` **85 → 91** 測試 0 失敗。
---

## 11. 修訂記錄

| 版本 | 日期 | 說明 |
|---|---|---|
| 1.0 | 2026-09-19 | 初版。依 `GOV-003` 條件 9 於 W-3 動工前建立；揭露 3 項偏差（無閘道、角色不一致、401 無專屬碼） |
| 1.2 | 2026-09-20 | 推廣時發現 **fail-open** 缺陷並修復：新增 **§7.6 依賴守門**（`cardcore.security.enabled=true` 但 classpath 無 Spring Security 時**啟動期失敗**，實測：`card-service` 無 token 原回 200）；新增 **§7.7 單一服務導入步驟**（依賴／組態／開關三段分離，組態已 **17/17** 就位）；§10 更新實測覆蓋為 **2/17**，並新增第 10 項限制——**前端無 token 來源，故常態不得全域啟用**（否則 WebUI 8 頁全數 401） |
| 1.1 | 2026-09-19 | 實作完成並實機實測後回寫：①§7.3 補登 `CC-PLT-4010`／`CC-PLT-4030`（`SDD-006` 112 → **114** 碼）並記錄 `oauth2ResourceServer` **蓋掉** entry point 致回應主體為空之實測修正；②§7.5 新增 **Spring Boot 4 自動配置模組化陷阱**（套件路徑改置，及改用 `ObjectProvider<ObjectMapper>` 去除順序依賴）；③§9 補實測證據與 7 個自動化測試（`common-spring` **41 → 48**）；④§10 解除第 4 項限制、新增第 9／10 項（僅 `party-service` 實測、Keycloak 健康端點 404） |
| 1.3 | 2026-09-20 | WorkBuddy AI | 依 BEAM PMO 核定（`HANDOVER #C-37` 採 **A**）新增 **§7.8「前端取得 Token——PKCE」**：載明 IdP 端已備妥之實測事實（`cardcore-web` public client、`S256`、`directAccessGrants=false`）、授權碼＋PKCE 流程五步驟、**token 存放安全取捨**（access 存記憶體／refresh 存 `sessionStorage`／**禁用 `localStorage`**）、換發與 401 僅重試一次、以及**不引第三方 OIDC 套件**之理由。§2 前端職責改列本期、§10 #10 解除並明列 SPA 持 token 之限制 |
| 1.4 | 2026-09-20 | WorkBuddy AI | §7.8 補**實測證據**：協定層（`tools/verify_sso_pkce.py`，stdlib only，含「對未啟用安全性之服務應判 ❌」之反向驗證）與**瀏覽器層**（真實 Chrome 執行前端程式碼 `import('/src/api/parties.ts')`，驗證取得 Bearer、取回真實資料、重整後 refresh 重建 session、且 `localStorage` 為空） |
| 1.5 | 2026-09-20 | WorkBuddy AI | **§7.7 ① 補齊其餘 15 個服務**之 `oauth2-resource-server` 依賴（**17/17**），並新增 **§7.7.1 逐服務實測結果**（17/17：無 token 401／帶 token 非 401）；§7.7 ③ 補「單一開關 `CARDCORE_SECURITY_ENABLED`」之說明；§10 #9 解除、新增 **#12「機制已備但常態未啟用」**（預設仍 false，是否改 fail-closed 待定奪） |
| 1.6 | 2026-09-20 | WorkBuddy AI | `HANDOVER #C-35`：**§10 #11「Keycloak 健康端點 404」解除**——查明端點本來正常，僅因**位於管理埠 9000 且未映射**致由主機探測皆 404；修正為映射 `9000:9000` ＋ `KC_METRICS_ENABLED=true` ＋ `prometheus.yml` 新增 `keycloak` job。實測：四個健康端點皆 200、Prometheus `up=1`（930 序列）、容器重建後權杖簽發回歸正常 |
| 1.7 | 2026-09-20 | WorkBuddy AI | **BEAM PMO 核定：認證改為 fail-closed**——`SecurityProperties.enabled` 預設 `false` → **`true`**；17 個服務之 `application-local.yml` **顯式**停用（原仰賴預設值＝靜默放行）；產生器模板同步；前端 `VITE_AUTH_ENABLED` 預設同步改 `true`（兩端同調）。新增 **§9.2 fail-closed 實測**（三情境：本地放行／啟用後 401→帶 token 201／無 issuer-uri 則**啟動失敗**）與守門測試 `SecurityFailClosedTest`（含**反向驗證**）；§10 #12 解除 |
| 1.8 | 2026-09-20 | WorkBuddy AI | 依 **`CR-010`**（BEAM PMO 核定採 **A：Keycloak 服務帳號 `client_credentials`**）補上服務間呼叫之**出站憑證**：①**新增 §7.9**——組態 `cardcore.security.outbound.*`（client-secret 不給預設值、由環境變數提供）、四個類別（`OutboundTokenProvider`／`ClientCredentialsTokenProvider`（含快取續取）／`NoOpOutboundTokenProvider`／`OutboundTokenAutoConfiguration`）、fail-closed 三情境（**入站啟用卻缺出站憑證 → 啟動期失敗**）、真實三服務實測（無 token 401／帶 token 執行週期 `statementCount=1`／三服務 401 次數 0）；②`ServiceClientHttpClient` 送出前帶 `Authorization` 且憑證來源 **requireNonNull**；③17 個服務之 `application-local.yml` 由**產生器模板**同步補上出站區段；④憑證外置 `~/.config/cardcore/m2m.env`（600）＋ `env.sh` 條件載入＋ `.env.example` 記載；⑤**§10 #7 更新**：`SAD-005 §3.2` 之「mTLS ＋ Service Token」僅補上 **Service Token**，**mTLS 仍未實作**，不因本次而宣稱完成 |
| 1.9 | 2026-09-21 | 更正 §3 之**錯誤陳述**（原寫「並無 gateway／bff 模組」——實測二者位於 `backend/platform/`，**確實存在**），並補列其狀態表；因該句連同 §2 適用範圍使二模組被排除於認證規格外，連鎖導致 `CR-010` v1.1 僅套用 17 個領域服務（已於 v1.2／v1.3 補正）。②§2 適用範圍改為「17 領域服務 ＋ gateway／bff」。③§7.7 步驟 ② 改為 **19/19 已就位**（二模組依「預先就位」慣例補 `issuer-uri`）、③ 改為 19/19，並新增「gateway／bff 導入狀態」表（① 依賴**仍未導入**，已揭露）。④**明載非本地環境刻意不在 `application.yml` 設 `cardcore.security`**（依 §7.4 僅本地檔可顯式停用）→ 實測 19 份皆無該區段**係設計如此，非缺漏**，避免後人誤判為落差而補錯 |
| 1.10 | 2026-09-21 | WorkBuddy AI | 依 **`CR-011` 階段二**新增 **§7.10「服務間呼叫 ACL（白名單）」**：①以 `SAD-002 §5` 為**單一真實來源**，新增 `tools/gen_service_acl.py` 轉置產生 `cardcore-service-acl.yml`（19 條目，**不手改**）；②新增 `ServiceAcl`／`ServiceCallerAclManager`，掛載於 `SecurityAutoConfiguration` 之 `.access(...)`（取代 `.authenticated()`）；③明訂判斷規則（匿名／使用者 token／服務帳號在內／不在內）與 **fail-closed 四情境**；④**🔴 記錄陷阱 `AnonymousAuthenticationToken.isAuthenticated()` 為 `true`**——初版致無憑證請求被放行（實測 200），由過濾器層級測試抓出，單元測試因負樣本用錯型別而未發現；⑤實測（真 token → 真 `posting-service`）：無憑證 401／`cc-statement-service` 通過／`cc-clearing-service` 403；⑥負樣本驗證（一律放行 → 5 例轉紅、一律拒絕 → 3 例轉紅）。`common-spring` 測試 71 → **85** 全綠。⚠️ §10 新增 **#13「gateway／bff 被 ACL 擋下，待裁定」** |
| 1.11 | 2026-09-21 | WorkBuddy AI | 新增 **§7.11「使用者權杖轉傳（Token Relay）」**——`CR-011 §11` 之爭議經**BEAM PMO 2026-09-21 17:10 核定採 B**：gateway／bff 呼叫領域服務時**轉傳使用者權杖**，不以自身 M2M 身分替代。①`cardcore.security.outbound.mode` 增 `token-relay`（⚠️ 採「模式切換」而非刪除組態——fail-closed 會使刪除後啟動失敗）；②新增 `RelayingOutboundTokenProvider`（無身分／匿名／非 JWT **一律拋錯**，不回 null＝不 fail-open）；③`SAD-005` → **1.3**（新增 §3.4 身分轉傳原則；更正 §3.2「白名單未實作」之過時記載）；④gateway／bff 組態改 `mode: token-relay`，產生器 `T_APP_LOCAL_YML_CORE` 同步；⑤測試 `common-spring` **85 → 91** 0 失敗。★ **實測陷阱**：`JwtAuthenticationToken` **單引數**建構式之 `isAuthenticated()` 為 **false**（僅雙引數版本才設 true）——已立為測試案例。⚠️ 1.10 版執行後**未回填版本欄**，本次一併更正 |
