iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 11 篇

Day 11|10:05 撤了客服,10:06 舊 token 為什麼還能看全部訂單

  • 分享至 

  • xImage
  •  

一開始設計 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 到底應該放什麼?

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 和權限就各自負責自己的事情。

AuthorizationManager 怎麼判斷?

判斷分四步,由上往下,先命中就先返回:

// 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,是為了避免有人誤刪資料後,再也沒有人能修改權限。
  • 沒登記的 API 登入即可使用。 這讓 RBAC 可以逐步導入,要管制哪支 API 就登記哪支;也因此沒有任何角色的一般使用者,仍然可以開團、下單、查自己的訂單。

關鍵在於角色是每次請求透過 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 才知道。

換來的是權限規則的可調整性:管理員在資料庫調整角色與資源的關係就會即時生效,不需要修改程式碼、重新部署。


上一篇
Day 10|token 過期之後,哪些請求該擋?
下一篇
Day 12|換掉網址裡的 ID,就看得到別人的餘額?!
系列文
做一個團購後端,順便搞懂那些事 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言