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

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

在以前 J2EE / WebLogic 的時代,認證與會話管理是典型的 Stateful HTTP Session(搭配 JSESSIONID 與 JAAS 容器認證)。
以傳統商用 PLM(如 Oracle Agile PLM)為例,雖然架構上支援 WebLogic 多節點叢集(Cluster)來達成高可用性與負載平衡,但實務上的建置與維運卻極為困難且痛苦:
這些限制讓傳統 PLM 無論是在硬體採購、架構建置還是後續的維護與故障排查上,成本都極為高昂且困難。
Mini-PLM 採用 Stateless JWT 認證,徹底告別 Session 狀態同步的噩夢:後端完全無狀態,未來加幾台 API 節點都不需要任何 Session 共享機制。Token 的合法性只靠密鑰簽章驗證,任何一台後端節點收到請求都能獨立完成身分核可,前端流量只需要最標準簡單的反向代理(如 Nginx 或 Cloud Load Balancer)進行 Round-Robin 派發即可,天然具備極低成本的水平擴展(Scale out)能力。
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")
設計重點:
hasRole("ADMIN") 並註明原因。防的是未來有人加一條涵蓋廣義路徑的寬鬆規則時,這幾支端點被靜默降權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 帳號直接登入。