iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

30天打造一套企業PLM系列 第 6

Day 6:JWT Stateless 認證與 Spring Security

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260823/20161290oW2BGnKNbi.jpg

系列:30 天打造企業級 PLM|面向:全端|素材:Security 模組、JWT 認證過濾器、前端 Auth Store

問題場景

進入第二週,先把門鎖裝好。企業系統的認證授權有兩個老問題:一是 session 黏在單機上,未來想橫向擴展就麻煩了;二是端點授權規則會隨功能一直長,長到有一天連自己都看不出「這個人到底能做什麼」。今天講 Mini-PLM 怎麼解這兩題,其中第二題的答案,是一次痛定思痛的簡化。

https://ithelp.ithome.com.tw/upload/images/20260823/20161290hda3XP4dxr.png

商業邏輯設計

  • 權限說到底是組織的決定:「誰能做什麼」要讓業務單位看得懂、改得動,不該埋在工程師的設定檔裡
  • 權限粒度有管理成本:path等級粗略但好懂、功能等級居中但維護較麻煩、欄位等級最細但須完全客製。粒度不用追求一致,該細的地方才細

技術選型與取捨

架構演進:從 WebLogic Stateful Session 到現代 JWT Stateless

先把兩種架構的差異放在同一張圖上:左側是需要同步 Session 的傳統叢集,右側是每個 API 節點獨立驗證 JWT 的現代橫向擴展。

https://ithelp.ithome.com.tw/upload/images/20260823/201612906mGwqqG7qj.png

在以前 J2EE / WebLogic 的時代,認證與會話管理是典型的 Stateful HTTP Session(搭配 JSESSIONID 與 JAAS 容器認證)。

以傳統商用 PLM(如 Oracle Agile PLM)為例,雖然架構上支援 WebLogic 多節點叢集(Cluster)來達成高可用性與負載平衡,但實務上的建置與維運卻極為困難且痛苦:

  1. 叢集安裝與配置極其繁瑣:Agile PLM 的 Cluster 安裝過程非常複雜,需手動配置 WebLogic NodeManager、Managed Servers、分散式快取/JMS 與 File Manager 節點,各節點間的網路拓撲與環境參數只要稍有偏差,整組叢集就無法正常同步或啟動。
  2. 仰賴昂貴硬體或專屬 Proxy 進行派發:為了將請求派發到各個節點,企業必須額外採購昂貴的專屬硬體負載平衡器(Hardware Load Balancer,如 F5 BIG-IP)或架設代理伺服器來執行流量派發、分流與健康檢查。
  3. Session 複製風暴與 Sticky Session 綁架:由於底層採用 Stateful Session,必須配置 In-Memory Session Replication 或 JDBC Session 永續化。伺服器間 Multicast 網路稍有延遲、Session 未及時同步,使用者點下一頁就直接被踢出登入;若退而求其次在 Load Balancer 上強開 Session 黏滯(Sticky Session),又常常導致某個節點被少數重度操作用戶塞爆,其他節點卻閒置,完全失去負載平衡的實質效益。

這些限制讓傳統 PLM 無論是在硬體採購、架構建置還是後續的維護與故障排查上,成本都極為高昂且困難。

Mini-PLM 採用 Stateless JWT 認證,徹底告別 Session 狀態同步的噩夢:後端完全無狀態,未來加幾台 API 節點都不需要任何 Session 共享機制。Token 的合法性只靠密鑰簽章驗證,任何一台後端節點收到請求都能獨立完成身分核可,前端流量只需要最標準簡單的反向代理(如 Nginx 或 Cloud Load Balancer)進行 Round-Robin 派發即可,天然具備極低成本的水平擴展(Scale out)能力。

前端 token 管理:不用 axios interceptor 的理由

JWT 存在 Zustand 的 in-memory store,不落 localStorage,降低被 XSS 摸走的機會。API 呼叫用自家封裝 requestJson,token 手動傳入:

/** 以 JSON 形式呼叫 API(統一走 ApiError 解析)。 */
export const requestJson = async <T>(params: {
  url: string;
  method?: string;
  jwtToken?: string;   // ← 手動傳入,沒有 interceptor 自動夾帶
  headers?: Record<string, string>;
  data?: unknown;
}): Promise<T> => { /* ... */ };

不用 interceptor 是刻意的。自動夾帶 token 很方便,但也讓「這支 API 需不需要認證」變成隱性知識;手動傳入讓每個呼叫點的認證需求攤在眼前,也沒有「interceptor 掛在哪個 axios instance 上」的全域狀態問題。代價是每個 service 多一個參數,我們認為值得。

副作用要先知道:in-memory token 在頁面 reload 後就沒了。真實踩過一次:登入頁在「帳號語系與介面語系不同」時會 window.location.reload() 切語系,reload 把剛拿到的 token 洗掉。解法是登入資訊過渡性暫存 sessionStorage、App 啟動時接回。後來寫 E2E 截圖腳本,又被同一個機制咬了一次,截圖腳本裡就留著這段除錯註解。

踩坑記錄:授權規則表越長,越沒人敢動

早期的做法是每加一個功能,就在 Security 設定加一條 path 規則。半年後這張表長到什麼程度?每次要加規則,都得先讀懂前面幾十條的順序語意(Spring Security 按宣告順序匹配,先匹配先贏),改一條怕弄壞另一條,最後演變成沒人敢動。

痛定思痛,做了簡化:path 層只分三級,細部權限全部撤出 Security 設定

核心內容

三層授權(實際程式碼結構)

// WebSecurityConfig 檔頭註解即是規範:
// 1. permitAll()     — 靜態資源、登入/版本/健康檢查等公開端點
// 2. authenticated() — 一般業務 API(登入即可,細粒度由前端選單 + 後端業務邏輯控制)
// 3. hasRole("ADMIN") — 兜底規則,未明確列出的端點僅 ADMIN 可存取

// ==================== 1. 公開端點(permitAll) ====================
.antMatchers("/", "/login", "/index.html", "/favicon.ico", "/version.json").permitAll()
.antMatchers(HttpMethod.POST, "/api/auth/**").permitAll()

// ==================== 2. 登入即可存取(authenticated) ====================
.antMatchers("/api/forms/**").authenticated()
.antMatchers("/api/items/**").authenticated()
.antMatchers("/api/files/**").authenticated()
// 帳號鎖定/解鎖/強制登出:僅 ADMIN(須排在一般使用者端點 authenticated 之前)
.antMatchers("/api/users/*/lock", "/api/users/*/unlock",
             "/api/users/*/revoke-tokens").hasRole("ADMIN")
.antMatchers("/api/users/**").authenticated()

// ==================== 3. 兜底:僅 ADMIN(hasRole) ====================
.anyRequest().hasRole("ADMIN")

設計重點:

  1. 兜底規則本身就是白名單:任何「忘了設定」的新端點,預設 ADMIN only。寧可管理員發現打不通來問,也不要一般使用者默默打得通
  2. 順序敏感的地方寫註解自保:鎖定/解鎖端點必須排在一般使用者端點之前,這種順序依賴一律加中文註解。設定檔裡的順序 bug,沒有編譯器會救你
  3. 危險端點顯式列出:Config Migration 端點原本靠「沒被任何規則匹配」落入兜底,後來仍顯式寫出 hasRole("ADMIN") 並註明原因。防的是未來有人加一條涵蓋廣義路徑的寬鬆規則時,這幾支端點被靜默降權

細部權限:自訂 Privilege + 前端功能鍵

path 層變粗之後,細部權限(誰能編輯 BOM、誰能執行 Return-Pending、誰能看 Trigger Log)改由自訂的 Privilege 機制承接:DB 定義 privilege,角色掛 privilege,後端在業務邏輯層檢查,前端則依權限決定功能鍵要不要渲染。

兩層缺一不可。前端隱藏是體驗,沒權限的人根本看不到按鈕;後端檢查是安全,繞過 UI 直接打 API 一樣被擋。這個決策把複雜度從宣告式設定檔搬回可測試的程式碼,權限邏輯從此可以寫單元測試,這是設定檔永遠給不了的。

檔案下載的授權演進

檔案下載端點(含 uuid 下載路徑)曾經匿名可存取。「知道連結就能下載」在企業內網常被當成可接受,直到資安稽核問了一句:離職員工還留著連結呢?修補分了階段:先全面改 authenticated(),再往檔案級權限走(Day 25 細講)。教訓是下載端點是最容易被遺忘的攻擊面,因為它看起來只是個連結。

小結

今天做的事,說穿了就是把認證做成無狀態、把 token 的流向攤開、把授權規則簡化到看得懂:JWT stateless 為擴展鋪路,token 顯式傳遞換來認證需求可見,path 授權砍成三級之後,細粒度交給 Privilege。權限這條線後面還會展開兩次:Day 7 講 Role → Group → Account 的授權鏈,Day 8 講欄位級權限的資料傳輸難題。

明日 Day 7:LDAP 整合與 RBAC 設計,讓 AD 帳號直接登入。


上一篇
Day 5:REST API 設計與統一回應格式
下一篇
Day 7:LDAP 整合與 RBAC 設計
系列文
30天打造一套企業PLM7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言