iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Vibe Coding

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

Day 12|換掉網址裡的 ID,就看得到別人的餘額?!

  • 分享至 

  • xImage
  •  

使用者登入後,可以查看自己的帳戶餘額,也可以查看自己參與的團購訂單;客服則可以查看任何一張訂單。

原本的帳戶查詢 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 本身的需求。

  1. 只能看自己的資料:不要讓 client 傳 ID

帳戶餘額本來就只能看自己的,因此不需要讓 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 去查詢。

  1. 必須指定資料:查到後確認關係

訂單明細需要指定 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端就能從狀態碼的差異拼出系統裡有哪些訂單。

  1. 本來就能看全部:使用不同的端點

客服可以查看任何訂單,就不需要把客服的權限塞進一般使用者的查詢邏輯。
另外提供端點呼叫:

GET /admin/orders/get_order_detail

這個端點在 RBAC 中授權給客服角色(超級管理員則一律放行),Service 也不需要檢查「是不是訂單參與者」。

不同的資料範圍,讓 API 本身表達不同的授權規則。


其實這樣設計也有幾個代價。第一,資料範圍檢查不像 RBAC 一樣有統一的授權入口,新增查詢 API 時如果忘記檢查,系統本身不會提醒你;第二,目前的 isParticipant 是先從DB查出訂單,接著在後端判斷是否有權限,代表沒有權限的請求也會觸發一次資料查詢;第三,一般使用者看到的都是 403,無法分辨「訂單不存在」和「你不能看」,這是避免洩漏資料存在性所付出的代價。

回頭看最開始的問題,其實只是少了一個確認使用者可以存取的資料範圍判斷,如果端點只確認第一件事,就可能出現「Alice 的 token,卻查到 Bob 的餘額」,所以設計需要帶 ID 的 API 時,可以先思考這個 ID 是不是 client 真的有必要提供?

不需要,就從登入身分取得;需要,就在查到資料後確認使用者與資料的關係。


上一篇
Day 11|10:05 撤了客服,10:06 舊 token 為什麼還能看全部訂單
下一篇
Day 13|設計訂單狀態機
系列文
做一個團購後端,順便搞懂那些事 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言