使用者登入後,可以查看自己的帳戶餘額,也可以查看自己參與的團購訂單;客服則可以查看任何一張訂單。
原本的帳戶查詢 API 讓 client 傳入 userId:
@GetMapping("/order/get_user_account")
public ResponseEntity<Map<String, Object>> getUserAccount(
@RequestParam String userId) {
return ResponseEntity.ok(orderService.getUserAccount(userId));
}
Alice 正常查自己的資料沒有問題:
GET /order/get_user_account?userId=alice
Authorization: Bearer <alice 的 token>
但她只要把網址改成 Bob:
GET /order/get_user_account?userId=bob
Authorization: Bearer <alice 的 token>
如果伺服器直接依照 userId 查詢,就會把 Bob 的餘額回傳給 Alice。
這就是 IDOR(Insecure Direct Object Reference):伺服器相信 client 傳來的 ID,卻沒有確認「這個登入者是否有權限存取這筆資料」。
本專案的 DynamicAuthorizationManager 會根據 HTTP method 和 URI 判斷角色能不能呼叫端點:
GET /order/get_user_account
RBAC 能管 API,不能單獨決定資料範圍,所以它可以回答:「Alice 能不能呼叫這支 API?」,卻不負責判斷: 「Alice 能不能查看 userId=bob」,因為這需要知道「Bob 是誰」以及「Alice 和這筆資料有沒有關係」。例如訂單明細:
GET /order/get_order_detail?orderId=ord-001
要判斷 Alice 能不能看 ord-001,必須知道她是不是團主,或是否曾經在這張訂單裡點過品項。這些資訊通常只有查過資料庫才知道。所以可以把授權拆成兩層,一層負責判斷「能不能看這一筆資料?」,另一層負責確認「資料與使用者的關係」,換句話說,RBAC 解決「能不能進這扇門」,資料範圍檢查解決「進來之後能看什麼」。
知道資料範圍不能只靠 RBAC 後,接下來要看 API 本身的需求。
帳戶餘額本來就只能看自己的,因此不需要讓 client 指定 userId,直接從登入身分取得:
@GetMapping("/order/get_user_account")
public ResponseEntity<Map<String, Object>> getUserAccount(
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(orderService.getUserAccount(userId));
}
@AuthenticationPrincipal 取得的是登入後放進 SecurityContext 的 principal,而本專案的 principal 就是 JWT 裡的 sub,也就是使用者 ID。
因此查詢用的 userId 只來自驗證後的登入身分,而不是 client 自己傳來的。即使請求額外帶上:
GET /order/get_user_account?userId=bob
Controller 也沒有接收這個參數,自然不會拿 Bob 的 ID 去查詢。
訂單明細需要指定 orderId,所以可以讓 client 傳入,但查到資料後必須確認使用者是否有權限:
public OrderDetailResponse getOrderDetail(String orderId, String userId) {
OrderDetailResponse detail = orderMapper.getOrderDetail(orderId);
if (detail == null
|| detail.getOrderId() == null
|| !isParticipant(detail, userId)) {
throw new ForbiddenException("無權限查看此訂單");
}
return detail;
}
isParticipant 負責確認呼叫者是不是團主,或有在這張訂單裡點過品項。
「查不到」也寫在同一個 if 裡、回同一句 403,是刻意的:如果不存在回 404、無權限回 403,變成使用者可以換著 orderId 一個個試,client端就能從狀態碼的差異拼出系統裡有哪些訂單。
客服可以查看任何訂單,就不需要把客服的權限塞進一般使用者的查詢邏輯。
另外提供端點呼叫:
GET /admin/orders/get_order_detail
這個端點在 RBAC 中授權給客服角色(超級管理員則一律放行),Service 也不需要檢查「是不是訂單參與者」。
不同的資料範圍,讓 API 本身表達不同的授權規則。
其實這樣設計也有幾個代價。第一,資料範圍檢查不像 RBAC 一樣有統一的授權入口,新增查詢 API 時如果忘記檢查,系統本身不會提醒你;第二,目前的 isParticipant 是先從DB查出訂單,接著在後端判斷是否有權限,代表沒有權限的請求也會觸發一次資料查詢;第三,一般使用者看到的都是 403,無法分辨「訂單不存在」和「你不能看」,這是避免洩漏資料存在性所付出的代價。
回頭看最開始的問題,其實只是少了一個確認使用者可以存取的資料範圍判斷,如果端點只確認第一件事,就可能出現「Alice 的 token,卻查到 Bob 的餘額」,所以設計需要帶 ID 的 API 時,可以先思考這個 ID 是不是 client 真的有必要提供?
不需要,就從登入身分取得;需要,就在查到資料後確認使用者與資料的關係。