一開始設計 JWT 時,把 role 放進 token 看起來很合理,之後每次請求只要驗證 JWT,就可以直接知道使用者是不是客服,不需要再查資料庫,這個做法看起來很省事、很直觀,但後來發現一個問題:「如果角色會被修改,JWT 裡的角色就可能過期。」。
想像一個公司內部人事異動的情境,10:00 時Alice是客服,但那並不代表 10:06 時Alice還是客服。
假設 Alice 原本是客服,客服可以查看全系統的訂單,Alice可以幫忙處理訂單爭議:
10:00 Alice 是客服
→ 簽發 JWT,裡面有 role=CUSTOMER_SERVICE
10:05 Alice 輪調到其他部門,管理員撤銷 Alice 的客服角色
→ 資料庫已經改成 Alice 不是客服
10:06 Alice 拿著 10:00 簽發的 JWT
→ 呼叫 GET /admin/orders/get_all_orders
問題來了,這時Alice的 JWT 裡仍然寫著:
role = CUSTOMER_SERVICE
所以如果系統只相信 JWT,10:06 仍然會把 Alice 當成客服。
這時候就會變成資料庫記錄Alice已經不是客服,但是JWT在簽發後內容就不會自己更新,只要 token 還沒過期,它裡面的 role 就會一直是客服,假設 token 還剩 15 分鐘,那麼 Alice 最多可能在接下來 15 分鐘內,繼續看到全系統每一筆訂單、每個人點了什麼,這對「權限修改後立即生效」的需求來說是個問題。
JWT 比較適合回答:「你是誰?」而不是:「你現在可以做什麼?」因此,最後我把 role 從 JWT 拿掉,只留下使用者身分與 token 本身需要的資訊:
Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId)
.issuedAt(now)
.expiration(expiry)
.signWith(privateKey)
.compact();
驗證 JWT 後,Spring Security 建立的 Authentication 也不放任何角色:
UsernamePasswordAuthenticationToken.authenticated(claims.getSubject(), null, List.of())
這裡的 List.of() 是空的,整個流程變成先做JWT 驗證,確認「這個人是 Alice」後,再去查「Alice 現在有什麼權限」,把「身分認證」和「權限授權」這兩件事分開。
這個部分就交給 RBAC(Role-Based Access Control)了,在這個專案裡,權限關係拆成四張表:
| Table | 用來記錄什麼 |
|---|---|
roles |
系統有哪些角色,例如 CUSTOMER_SERVICE、ACCOUNTANT |
resources |
哪些 API 需要被管制 |
user_roles |
哪個使用者擁有哪些角色 |
role_resources |
哪個角色可以使用哪些 API |
roles 只定義系統角色:
| 角色 | 職能 | 可以使用的 API |
|---|---|---|
SUPER_ADMIN |
系統管理員 | 全部 |
ADMIN_STAFF |
行政 | 分類的新增、修改、刪除 |
CUSTOMER_SERVICE |
客服 | /admin/orders/**(查看全系統訂單) |
ACCOUNTANT |
會計 | /admin/transactions/**(查看任一使用者的交易紀錄) |
團主、團員不在這張表裡。任何人都可以開團,開團的人就是那一團的團主,這個身分跟著訂單走,記在 orders.created_by,不是管理員指派的角色。一般使用者沒有任何角色。
代表 Alice 目前擁有 CUSTOMER_SERVICE的角色,而 CUSTOMER_SERVICE這個角色可以查看全系統訂單,如果管理員把 Alice 的 CUSTOMER_SERVICE 移除,下一次請求重新查詢權限時,就會得到新的結果,完全不需要等待舊 JWT 過期。
既然權限不是 JWT 的工作,就需要一個地方統一判斷。
Spring Security 6 提供 AuthorizationManager 介面,這個專案用 DynamicAuthorizationManager 實作它,所有請求的授權判斷都集中在這裡。一個請求進來會依序經過:
HTTP Request
↓
JwtAuthenticationFilter 驗證 JWT,回答「你是誰?」
↓
DynamicAuthorizationManager 查角色,回答「你現在可以做什麼?」
│
└─ RbacCacheService
├─ Redis 有 → 直接回傳
└─ Redis 沒有 → 查 DB,寫回 Redis 再回傳
↓
Controller
這樣 JWT 和權限就各自負責自己的事情。
判斷分四步,由上往下,先命中就先返回:
// 1. 沒登入 → 拒絕
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
return DENIED;
}
// 2. 超級管理員 → 放行
List<String> roles = cacheService.getUserRoles(auth.getName());
if (roles.contains(SUPER_ADMIN)) {
return GRANTED;
}
// 3. API 沒登記在 resources → 放行
boolean managed = cacheService.getAllResources().stream()
.anyMatch(r -> matches(r, method, path));
if (!managed) {
return GRANTED;
}
// 4. 有登記 → 任一角色被授權才放行
boolean allowed = roles.stream()
.flatMap(role -> cacheService.getRoleResources(role).stream())
.anyMatch(r -> matches(r, method, path));
return allowed ? GRANTED : DENIED;
其中第 2、3 步是刻意留的例外:
role_resources,是為了避免有人誤刪資料後,再也沒有人能修改權限。關鍵在於角色是每次請求透過 cacheService 現查,不是從 token 讀。開頭 10:06 的情境有一支測試直接驗證:全程用同一顆 token,撤銷客服角色前可以查,撤銷後的下一個請求就被擋下。
grantRoles("alice", "CUSTOMER_SERVICE");
String sameToken = jwtUtils.generateAccessToken("alice");
mockMvc.perform(get("/admin/orders/get_all_orders")
.header("Authorization", "Bearer " + sameToken))
.andExpect(status().isOk());
grantRoles("alice");
mockMvc.perform(get("/admin/orders/get_all_orders")
.header("Authorization", "Bearer " + sameToken))
.andExpect(status().isForbidden());
代價有兩個。第一,每次請求都要查角色與授權規則,這正是當初把 role 放進 JWT 想省掉的成本,所以平常請求走 Redis 快取,管理員修改權限時再刪掉對應的快取,下一次請求就會回 DB 取得最新規則。第二,看 Controller 已經看不出誰能呼叫這個 API,不像 @PreAuthorize("hasRole('ADMIN')") 直接寫在方法上,得去查 resources 與 role_resources 才知道。
換來的是權限規則的可調整性:管理員在資料庫調整角色與資源的關係就會即時生效,不需要修改程式碼、重新部署。