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 式最小契約(AuthPrincipal + AuthPrincipalLookup 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(commitd009a802)。「殼裡長邏輯」不是假設性風險——它發生了,且靠事後矯正而非機制攔截。
同時,引擎的下層元件其實大半已在 jar:JwtTokenProvider、ActingClaims、LoginActingContributor(security.auth)、TokenBlacklistStore(security.blacklist)、LoginLockout(security.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 的
UserDetails/UserDetailsService已證明數十年:登入引擎可以完全契約驅動、對帳號結構零假設。 - 近期演進軌跡一致: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(Account、AuthController、SecurityConfig…)搬到 feature 外的專屬 namespace,使 feature package 100% canonical。
優點:
- ✅ feature package 純化,sync 語意簡化
缺點:
- ❌ canonical → seam 的編譯邊(
AccountRepository綁Account、UserResponse引用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);
}
- 契約收窄原則:
username、enabled等旗標、authorities皆由UserDetails既有成員承擔;唯一新增成員是interactiveLoginAllowed()(把「服務帳號不可互動登入」一般化為引擎級資格判定,引擎不再 import 應用的serviceAccount概念)。id/email/name/phone/primaryRole不進契約——那是參考殼的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 剩下的每個檔,身分都變得誠實:
| 檔 | 身分 | 說明 |
|---|---|---|
AccountBase | canonical | schema 承重不變量的載體(username unique、認證欄位、role join);不上收 jar——它綁 AuditableBase 與 Role @ManyToMany(皆應用層 entity),上收會拉整串 Role/Authority 進 jar,違反 ADR-017 決策一 |
AccountRepository、AccountDetailsService、seed contributor | canonical | 參考接線;AccountDetailsService(或專屬 adapter bean)兼任 AuthPrincipalLookup 實作 |
AccountUserDetails | canonical | 改 implements AuthPrincipal(interactiveLoginAllowed() = !serviceAccount)+ TenantAware |
Account、AuthController、SecurityConfig、RoleHierarchyService、seed 資料 | seam(不變) | AuthController 改呼叫 jar LoginService;LoginRequest/UserResponse 等 wire DTO 隨殼走,session wire 投影後續收斂至 controller presentation 層的 AuthSessionResponseAssembler |
- 引擎不再編譯依賴
AccountBase(契約已隔開)——slice 的 canonical 集縮小且純化:留下的 canonical 全是「參考接線」性質,「引擎住在消費端」的混合狀態終結。 features.json的 auth 條目同步更新(canonical 集移除LoginService/LoginPolicy;seams 不變)。
決策四:CHANGELOG 與 API 契約義務
- jar
CHANGELOG.md的[Unreleased]記 Added:AuthPrincipal、AuthPrincipalLookup、LoginService(capability 化)、LoginPolicy(上收+簽章)。 - SNAPSHOT 期無 deprecation cycle 義務(
m-changelog-format.mdpre-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 結構升級)暴露原契約的拒絕通道太窄:allows 回 false 被定死為
「引擎回不透明 401、不洩漏拒絕原因」,但多前端專案的產品行為(ict 三前端,有整合測試背書)
要求政策拒絕回 403 + 明確訊息——使用者已通過帳密認證,「此帳號分類不可登入該前端」
不構成帳號探測面,且前端需要可辨識訊息引導使用者改用正確入口。
修訂為兩種拒絕通道(4.0.0 凍結前定稿):
| 通道 | 語意 | 引擎行為 |
|---|---|---|
allows 回 false | 不透明拒絕(預設;拒絕原因涉帳號性質時用此) | 拋 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 集純化而減少。
負面/代價(誠實列出)
- 契約凍結風險:
AuthPrincipal/AuthPrincipalLookup/LoginService簽章在4.0.0後受 deprecation cycle 約束;契約設計錯誤的修正成本遠高於實作錯誤。緩解:契約收窄原則(僅一個新增成員)、SNAPSHOT 期實測至少一個下游後再發正式版。 LoginPolicy實作改 downcast:原簽章直接給AccountBase,上收後政策實作須 downcast 取 per-project 欄位——多一步、但型別安全性由「政策與 adapter 同屬消費端」保障。- 一次性遷移成本:jar 新增類、slice 改接線、
AuthControllerTest等測試遷移、catalog 更新、既有下游(如 ict)對齊。SNAPSHOT + fleet 小是選擇此刻動手的理由。
遷移步驟
- jar:新增
AuthPrincipal、AuthPrincipalLookup;LoginService、LoginPolicy移入io.leandev.appfuse.security.auth(剝離UserResponse/RoleHierarchyService依賴、改TokenBlacklistStore、租戶改TenantAware探詢);ArchUnit 中性檢查涵蓋新類(ADR-014 規範三)。 - app-server:
AccountUserDetailsimplementsAuthPrincipal+TenantAware;AccountDetailsService兼任 lookup;AuthController改呼叫 jarLoginService;session wire 投影由 controller presentation 層的AuthSessionResponseAssembler統一組裝(含/me,service 不依賴 controller DTO);刪除 slice 側LoginService/LoginPolicy;features.json更新。 - 測試:
AuthControllerTest改@ImportjarLoginService;「刷新不得退回 claim 複製」迴歸守衛續綠;新增interactiveLoginAllowed資格判定的契約測試。 - CHANGELOG:
[Unreleased]Added 條目(決策四)。 - 文檔: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 出能力與接點,資源實作歸消費端)