跳至主要内容

ADR-023: auth 登入引擎上收框架 capability(契約驅動、最小實作偏見)

ADR 編號: 023 狀態: 已接受 (Accepted) 決策日期: 2026-07-22 決策者: Development Team 取代: ADR-018 決策二、決策五(其餘決策續存,見「與 ADR-018 的存廢對照」) 被取代: 無


2026-08-21 錯誤揭露修訂:本 ADR 中把 LoginPolicy#allows=false 定義為引擎內不透明 BadCredentialsException 的段落已被 response-boundary policy 取代。引擎現在一律保留實際 LoginPolicyDeniedException 與原因;框架/參考實作預設 MaximalErrorDisclosurePolicy,應用可 注入 MinimalErrorDisclosurePolicy 或自訂政策。這項修訂不改變 capability/wire 分層決策。


摘要

app-server feature/auth 的登入引擎(LoginService orchestration)上收 appfuse-server jar,成為正式 capability:引擎對帳號的全部需求以 UserDetails 式最小契約AuthPrincipalAuthPrincipalLookup SPI)表達,租戶經既有 TenantAware 探詢、wire DTO 歸消費端。feature/auth slice 縮為參考接線(entity、repository、adapter、HTTP 殼、SecurityConfig)——auth 對實作的偏見降到最低,安全修正的下行管道從檔案 sync 升級為 Maven 依賴。


背景 (Context)

問題陳述

auth feature 的 seam 集合(AuthController wire 殼、Account 結構、RoleHierarchyService、seed)實質上是檔案粒度的參考實作——消費端修改機率高、修改後即退出同步。而引擎(LoginService)與這些 seam 同住同一 module 的同一 package,「殼薄、邏輯歸引擎」的界線只靠空體紀律與 review 維持。這條防線已實證失守過一次:

  • AuthController 曾累積至 371 行、吞入整條登入 orchestration,2026-07-22 才以人工重構抽出 LoginService(commit d009a802)。「殼裡長邏輯」不是假設性風險——它發生了,且靠事後矯正而非機制攔截。

同時,引擎的下層元件其實大半已在 jarJwtTokenProviderActingClaimsLoginActingContributorsecurity.auth)、TokenBlacklistStoresecurity.blacklist)、LoginLockoutsecurity.lockout)、雙模 resource server(ADR-009)。LoginService 是最後一塊留在應用層的 orchestration——它對 slice 內類別的依賴只剩 AccountRepository(帳號載入)、UserResponse(wire DTO 組裝)、LoginPolicy(把關接點)、TokenBlacklistService(jar store 的薄包裝)。

動機(為何是現在)

  • SNAPSHOT 窗口appfuse-server 尚在 4.0.0-SNAPSHOT、下游專案數少。契約重塑的遷移成本此刻趨近於零,隨 fleet 成長單調上升;ADR-022 剛示範過「賭錯了要回收」的成本形狀,契約層的刀應趁現在下。
  • 最小偏見是框架既定哲學ADR-014 開宗明義——不做 autoconfiguration 是為了不對消費端施加偏見。auth 是最基礎的能力,卻是目前偏見最重的一塊(引擎與參考殼、參考實體同居一 package)。Spring Security 的 UserDetailsUserDetailsService 已證明數十年:登入引擎可以完全契約驅動、對帳號結構零假設。
  • 近期演進軌跡一致:mail 能力上收 jar、security 貢獻點 SPI 上收、LoginService 抽出——本 ADR 是同一方向的收官。LoginService 剛被抽成 263 行的內聚 orchestration,正處於最易上收的狀態。

限制條件

  • ADR-017:jar 不得擁有 @Entity、不得假設租戶欄位存在(租戶一律經 TenantAware 探詢)。
  • ADR-014:capability 須以 SPI 暴露組合點、配對偶 core slice、中性不變量以 ArchUnit 強制。
  • 不做 autoconfiguration。
  • wire 契約(/api/v1/auth/* 的路徑、DTO 形狀、錯誤映射)與行為零變化

假設前提

  • 登入 orchestration 的形狀 fleet 穩定:隔離 variant 實測 auth delta = 0(方法論 ADR-009);FEDERATED 部署差異由「不曝露本地 login」+雙模 resource server 承擔,無專案 fork 過 LoginService
  • UserResponse 的形狀屬 wire(per-project),非引擎需求——引擎回傳引擎級結果即可。

考量的方案 (Options Considered)

方案 A: 維持現狀(引擎住 slice、空體紀律+review 守界線)

說明: LoginService 留在 feature/auth,繼續以空體紀律、javadoc 宣告與 review 維持「殼薄」。

優點:

  • ✅ 零遷移成本
  • ✅ 引擎與 seam 同 package,開發時就近可讀

缺點:

  • ❌ 「殼裡長邏輯」防線是紀律不是機制,已實證失守一次(371 行事件)
  • ❌ 混合所有權 package(canonical + seam 同居)持續存在,目錄位置無法表達所有權
  • ❌ 引擎同步依賴 /feature sync 檔案覆蓋;若日後 auth 域被下游深度客製(ADR-022 的路徑),安全修正失去下行管道

評分: 2


方案 B: slice 內佈局重整(seam 搬出 feature package)

說明: 引擎留 feature/auth,把 seam(AccountAuthControllerSecurityConfig…)搬到 feature 外的專屬 namespace,使 feature package 100% canonical。

優點:

  • ✅ feature package 純化,sync 語意簡化

缺點:

  • ❌ canonical → seam 的編譯邊(AccountRepositoryAccountUserResponse 引用 RoleHierarchyService)迫使二選一:上泛型/工廠機器(ADR-018 決策二否決的方案 A——同 module 內付跨 artifact 的成本),或為 seam namespace 開 ArchUnit 豁免——混合所有權換個形式存在,還多出多位置 provision
  • ❌ 同步面零增益:seam 本來就不在同步範圍,搬家不改變任何下行能力
  • ❌ 「殼裡長邏輯」依然靠紀律——殼與引擎仍同 artifact,想寫就寫得進去

評分: 2


方案 C: 引擎上收 jar capability + UserDetails 式契約(採用)

說明: LoginService 移入 io.leandev.appfuse.security.auth,對帳號的需求以 AuthPrincipal 契約+ AuthPrincipalLookup SPI 表達;slice 縮為參考接線。

優點:

  • 機制取代紀律:引擎在另一個 artifact,殼裡物理上寫不進引擎邏輯
  • 同步管道升級:安全修正隨 Maven 版本下行(強於檔案 sync;即使下游整域客製 auth 殼,引擎修正照樣到達)
  • 偏見最小化:引擎對帳號的假設收斂為最小契約;端點形狀、DTO、帳號結構全數歸消費端
  • ✅ ADR-018 決策二否決泛型機器的前提(「同 module 不必付跨 artifact 成本」)自然消失——跨 artifact 邊界成立後,契約機器成為本來就該付的成本
  • ✅ ADR-014 對偶完備:login 終於是正式 capability(SPI 邊界+core slice 對偶+中性)

缺點:

  • ❌ jar 公開 API 面擴大,進入 CHANGELOG 契約與 deprecation cycle 義務
  • ❌ 契約設計風險前置:AuthPrincipal 每多一個成員就是一分偏見,錯的契約比錯的實作難改
  • ❌ 一次性遷移(jar + app-server + 測試 + catalog)

評分: 5


決策 (Decision)

方案 C,分五個決策落地。

決策一:LoginService 上收 io.leandev.appfuse.security.auth

  • login / refresh / logout 三操作的 orchestration 移入 jar:authenticate → 互動式登入資格檢查 → 登入政策 → 代行合併 → 簽發;刷新的黑名單檢查+重載當前權限重鑄;登出的 session 黑名單。audit log 語意原樣保留。
  • currentUser 不上收——它是「載帳號、組 DTO」的 wire 面查詢,非引擎 orchestration,留在消費端殼(殼自己讀自己的 repository、組自己的 UserResponse)。
  • 引擎回傳引擎級結果LoginResult(accessToken, refreshToken, principal)),不再組 UserResponse——wire DTO 的形狀與組裝歸消費端殼。RoleHierarchyService 依賴隨之自然剝離(它只服務 DTO 組裝)。
  • 黑名單直接消費 jar 的 TokenBlacklistStore(slice 的 TokenBlacklistService 薄包裝不再是引擎依賴)。

決策二:AuthPrincipal 契約 + AuthPrincipalLookup SPI(UserDetails 式)

引擎對帳號的全部需求以最小契約表達:

/// 引擎對「可登入主體」的最小契約;帳號結構、持久化、擴充欄位一概不假設
public interface AuthPrincipal extends UserDetails {

/// 本主體可否使用互動式登入/刷新(服務帳號拒絕的一般化——引擎不認識「服務帳號」概念)
default boolean interactiveLoginAllowed() { return true; }
}

@FunctionalInterface
public interface AuthPrincipalLookup {
Optional<? extends AuthPrincipal> findByUsername(String username);
}
  • 契約收窄原則usernameenabled 等旗標、authorities 皆由 UserDetails 既有成員承擔;唯一新增成員是 interactiveLoginAllowed()(把「服務帳號不可互動登入」一般化為引擎級資格判定,引擎不再 import 應用的 serviceAccount 概念)。idemailnamephoneprimaryRole 不進契約——那是參考殼的 UserResponse 需求,殼自己從自己的 Account 取。
  • 租戶不進契約:引擎以 instanceof TenantAware 探詢 tenantId claim(ADR-017 決策三的既有 pattern)。tenant 專案的 principal adapter 實作 TenantAware 即得 claim;ownership 專案天然無租戶 claim,零設定。
  • LoginPolicy 隨引擎上收 jar,簽章由 allows(AccountBase, String) 改為 allows(AuthPrincipal, String)(ADR-018 決策五的接點語意不變)。政策實作需要 per-project 欄位時 downcast 自己的 principal 型別——政策與 principal adapter 同屬消費端,downcast 是安全的。

決策三:feature/auth slice 縮為參考接線

上收後 slice 剩下的每個檔,身分都變得誠實:

身分說明
AccountBasecanonicalschema 承重不變量的載體(username unique、認證欄位、role join);不上收 jar——它綁 AuditableBaseRole @ManyToMany(皆應用層 entity),上收會拉整串 Role/Authority 進 jar,違反 ADR-017 決策一
AccountRepositoryAccountDetailsService、seed contributorcanonical參考接線;AccountDetailsService(或專屬 adapter bean)兼任 AuthPrincipalLookup 實作
AccountUserDetailscanonical改 implements AuthPrincipalinteractiveLoginAllowed() = !serviceAccount)+ TenantAware
AccountAuthControllerSecurityConfigRoleHierarchyService、seed 資料seam(不變)AuthController 改呼叫 jar LoginServiceLoginRequestUserResponse 等 wire DTO 隨殼走,session wire 投影後續收斂至 controller presentation 層的 AuthSessionResponseAssembler
  • 引擎不再編譯依賴 AccountBase(契約已隔開)——slice 的 canonical 集縮小且純化:留下的 canonical 全是「參考接線」性質,「引擎住在消費端」的混合狀態終結。
  • features.json 的 auth 條目同步更新(canonical 集移除 LoginServiceLoginPolicy;seams 不變)。

決策四:CHANGELOG 與 API 契約義務

  • jar CHANGELOG.md[Unreleased] 記 Added:AuthPrincipalAuthPrincipalLookupLoginService(capability 化)、LoginPolicy(上收+簽章)。
  • SNAPSHOT 期無 deprecation cycle 義務(m-changelog-format.md pre-release 例外;app-server 非發布套件,slice 側類別直接移除)。4.0.0 正式版後上述簽章即凍結——本 ADR 是凍結前最後的自由修改窗口,契約面須在接受本 ADR 時一併定稿。

決策五:與 ADR-018 的存廢對照

ADR-018 決策處置理由
決策一(AccountBase canonical + Account seam)續存結構契約仍是 slice 的事;空體紀律照舊(改為保障「殼與參考接線」不誤依賴擴充欄位)
決策二(FQN seam 型別引用、否決泛型機器)由本 ADR 取代否決前提是「canonical 與 seam 同 module」;引擎上收後跨 artifact 邊界成立,契約機器成為必要且正當的成本
決策三(email 唯一性下放、重複容忍)續存屬 slice 參考接線層,不受影響
決策四(@Version per-entity opt-in)續存同上
決策五(LoginPolicy 接點)由本 ADR 修訂接點語意不變;宿主上收 jar、簽章改 AuthPrincipal

本 ADR 接受後,於 ADR-018 標頭補「決策二、五由 ADR-023 取代/修訂」。

修訂:LoginPolicy 兩種拒絕通道(2026-07-23,fleet 實測回饋)

FU-71 fleet 實測閘(ict 結構升級)暴露原契約的拒絕通道太窄:allowsfalse 被定死為 「引擎回不透明 401、不洩漏拒絕原因」,但多前端專案的產品行為(ict 三前端,有整合測試背書) 要求政策拒絕回 403 + 明確訊息——使用者已通過帳密認證,「此帳號分類不可登入該前端」 不構成帳號探測面,且前端需要可辨識訊息引導使用者改用正確入口。

修訂為兩種拒絕通道(4.0.0 凍結前定稿):

通道語意引擎行為
allowsfalse不透明拒絕(預設;拒絕原因涉帳號性質時用此)BadCredentialsException(與帳密錯誤不可區分)→ 殼映射 401
LoginPolicyDeniedException(jar 新增,可子類化)可辨識拒絕audit log 後原樣傳播,消費端殼捕捉映射專屬狀態碼與訊息

不改 allows 簽章(boolean 保留簡單情境的零負擔);不採「回 enum/result 物件」—— 那會把單前端專案也拖進 result 組裝。例外傳播是 Spring Security 認證鏈的慣用語彙, AuthenticationException 子類化天然支援 per-project 脈絡欄位。


後果 (Consequences)

正面

  • 殼裡長不出邏輯:引擎在另一個 artifact,「371 行事件」在結構上不可能重演——機制取代紀律。
  • 安全修正下行管道升級:lockout 繞過、token 處理、刷新權限重鑄等修正隨 Maven 版本自動到達全 fleet,不再依賴 /feature sync 檔案覆蓋,也不再受下游客製 auth 殼的影響。
  • 偏見最小化:帳號結構、端點形狀、DTO、角色詞彙全數歸消費端;引擎的假設收斂為一張 UserDetails 式契約——auth 成為框架中偏見最低的基礎能力,對齊其重要性。
  • 概念負擔下降:seam 機制在 auth 域的角色縮小(殼與結構宣告),「住在 feature 裡但歸你所有」的認知摩擦隨 canonical 集純化而減少。

負面/代價(誠實列出)

  • 契約凍結風險AuthPrincipalAuthPrincipalLookupLoginService 簽章在 4.0.0 後受 deprecation cycle 約束;契約設計錯誤的修正成本遠高於實作錯誤。緩解:契約收窄原則(僅一個新增成員)、SNAPSHOT 期實測至少一個下游後再發正式版。
  • LoginPolicy 實作改 downcast:原簽章直接給 AccountBase,上收後政策實作須 downcast 取 per-project 欄位——多一步、但型別安全性由「政策與 adapter 同屬消費端」保障。
  • 一次性遷移成本:jar 新增類、slice 改接線、AuthControllerTest 等測試遷移、catalog 更新、既有下游(如 ict)對齊。SNAPSHOT + fleet 小是選擇此刻動手的理由。

遷移步驟

  1. jar:新增 AuthPrincipalAuthPrincipalLookupLoginServiceLoginPolicy 移入 io.leandev.appfuse.security.auth(剝離 UserResponseRoleHierarchyService 依賴、改 TokenBlacklistStore、租戶改 TenantAware 探詢);ArchUnit 中性檢查涵蓋新類(ADR-014 規範三)。
  2. app-serverAccountUserDetails implements AuthPrincipalTenantAwareAccountDetailsService 兼任 lookup;AuthController 改呼叫 jar LoginService;session wire 投影由 controller presentation 層的 AuthSessionResponseAssembler 統一組裝(含 /me,service 不依賴 controller DTO);刪除 slice 側 LoginServiceLoginPolicyfeatures.json 更新。
  3. 測試AuthControllerTest@Import jar LoginService;「刷新不得退回 claim 複製」迴歸守衛續綠;新增 interactiveLoginAllowed 資格判定的契約測試。
  4. CHANGELOG[Unreleased] Added 條目(決策四)。
  5. 文檔:ADR-018 標頭補取代備註;docs-server/guides/auth/* 對齊新契約。

相關

  • ADR-009(雙模 resource server——token 驗證面已在 jar)
  • ADR-014(capability ↔ core slice 對偶;本 ADR 使 login 成為正式 capability)
  • ADR-017(jar 不擁有 entity、TenantAware 探詢——本 ADR 的契約設計依其兩原則)
  • ADR-018(決策二、五由本 ADR 取代/修訂;其餘續存)
  • ADR-022(姊妹決策:admin 面「無穩定核心」→ 降級業務層;本 ADR 是 auth 面「核心穩定」→ 上收 jar——兩者合成「引擎歸框架、資源實作歸消費端」的完整佈局)
  • 21-feature-surface.md 紀律四(feature 出能力與接點,資源實作歸消費端)