前面幾天,我開始理解登入、Token,以及後端怎麼確認目前的使用者是誰。
但接著我想到另一個問題:
使用者登入後,就代表他可以操作系統裡的所有資料嗎?
答案當然不是。
當時我最在意的,不是按鈕能不能按,而是不同帳號之間的資料能不能真正被隔開。
以 PawPal 來說,每一位使用者都有自己的會員資料、寵物資料,以及和自己帳號或寵物相關的私有紀錄。
如果一個帳號可以看到另一個帳號的資料,甚至修改或刪除對方的內容,就不只是功能做錯,而是直接牽涉到使用者隱私。
所以我開始思考:
我要怎麼確保每個帳號,只能看到和操作屬於自己的資料?
這也是我第一次真正開始理解「權限控管」。
一開始,我很容易把「登入驗證」和「權限控管」想成同一件事。
只要使用者登入成功,系統已經知道他是合法會員,我就會直覺覺得後面的功能應該都可以繼續執行。
但後來我才理解,這兩件事其實不一樣。
Authentication
→ 確認你是誰
Authorization
→ 確認你可以看什麼、做什麼
登入驗證只能確認這位使用者的身分。
但就算身分已經確認,也不代表他可以查看、修改或刪除所有人的資料。
例如:
使用者 A 已經登入
→ 可以取得使用者 A 的寵物資料
但不代表
→ 可以取得使用者 B 的寵物資料
而 PawPal 的權限控管,也不是以 admin、member 這種角色權限為主。
它比較接近:
ownership-based authorization
也就是:
這筆資料是不是屬於目前登入的會員?
所以 Day 13 解決的是:
你是誰?
到了 Day 14,我開始面對的問題則是:
知道你是誰之後,你到底可以操作哪些資料?
當時我想到的情況其實很多。
例如:
但其中最讓我在意的還是:
我不能看到其他帳號的私有資料。
如果後端已經把不屬於這位會員的資料回傳出去,就算前端最後沒有把它顯示在畫面上,那些資料其實還是已經離開後端了。
所以比起:
前端拿到全部資料
→ 再自己篩選
更合理的做法應該是:
確認目前登入者
→ 後端直接限制查詢範圍
→ 只取得屬於目前會員的資料
也就是:
不該看到的私有資料,一開始就不應該被回傳。
前端當然可以根據目前使用者的狀態決定哪些按鈕要顯示。
例如不是自己的資料時,不顯示編輯或刪除按鈕。
PawPal 的評論功能裡也有類似的做法。
當評論屬於目前登入者時,畫面才會出現自己的編輯、刪除操作。
這樣可以改善使用體驗。
但我後來才理解:
畫面沒有按鈕,不代表後端真的禁止這個操作。
因為 API 並不只能透過畫面上的按鈕呼叫。
如果真正的權限限制只做在前端,使用者還是有可能直接對 API 發出 Request。
所以兩邊的責任其實不同:
前端
→ 決定畫面要提供哪些操作入口
後端
→ 真正決定這次資料操作能不能成功
前端可以讓操作流程合理。
但真正保護資料的限制,還是要放在後端。
以 PawPal 的寵物清單為例。
如果我要取得目前會員的寵物,後端不是先查出整個 pets 資料表,再交給前端過濾。
而是直接根據目前登入者限制查詢範圍。
概念上可以先理解成:
SELECT *
FROM pets
WHERE user_id = 目前登入者的 ID;
這裡是簡化示意。
PawPal 實際上會把 middleware 確認後的 req.userId 傳到後面的資料查詢流程,再用 user_id 限制可以取得的資料。
所以整個概念比較接近:
目前登入者
→ req.userId
→ 查詢 user_id 符合的 pets
→ 只回傳自己的寵物
這時我才真正發現:
Authorization 不一定是在資料查完之後才做。
有時候,權限本身就是查詢條件的一部分。
一開始我原本以為,修改或刪除資料的權限判斷可能會是:
先找到資料
→ 查出這筆資料的 owner
→ 和目前登入者比較
→ 相同才允許操作
這是一種合理的作法。
但重新看 PawPal Repository 後,我才發現 pets 的主要實作方式不是這樣。
以修改寵物來說,概念更接近:
UPDATE pets
SET ...
WHERE id = 指定的寵物 ID
AND user_id = 目前登入者 ID;
刪除也是:
DELETE FROM pets
WHERE id = 指定的寵物 ID
AND user_id = 目前登入者 ID;
也就是 SQL 不只看:
這筆資料的 id 對不對?
還會一起確認:
這筆資料的 user_id
是不是目前登入者?
所以就算知道別人的寵物 ID,只要 user_id 不符合目前登入者,這筆資料一樣不會被修改或刪除。
這讓我第一次很具體地看到:
權限控管其實可以直接存在 SQL 的條件裡。
不是一定要先把資料查出來,再另外寫一段 JavaScript 比對 owner。
我以前會直覺覺得:
沒有權限
→ 403 Forbidden
但重新看 PawPal 實作後,我才發現事情沒有這麼固定。
以 pets 來說:
WHERE id = petId
AND user_id = req.userId
如果資料不存在,當然查不到。
但如果資料真的存在,只是它屬於另一位會員,因為 user_id 不符合,一樣查不到。
所以最後都可能得到:
404 找不到寵物
也就是對這個登入者而言:
沒有一筆符合「這個 ID,而且是你的」的資料。
這也讓我理解,權限錯誤並不是所有情況都一定使用相同的 HTTP Status。
比起死記:
Authorization = 403
我現在更在意的是:
後端是否真的阻止了不該成功的操作?
前一篇已經介紹過 PawPal 的 authenticateToken。
到了這篇,我比較在意的是它和 Authorization 到底怎麼分工。
整個流程可以簡化成:
Request
→ authenticateToken
→ 確認登入者
→ req.userId
→ Controller
→ Service / SQL
→ 限制 ownership
authenticateToken 主要負責的是 Authentication。
它做完的事情比較像:
我已經知道這個 Request 是哪一位會員送出的。
但它不會直接判斷:
這隻寵物是不是你的?
這筆紀錄是不是你的?
真正的資料所有權限制,主要還是在後面的 Service 和 SQL。
例如:
WHERE user_id = req.userId
或:
WHERE id = petId
AND user_id = req.userId
這樣我才真正把兩件事分開:
Authentication
→ 確認目前使用者的身分
Authorization
→ 利用這個身分限制可以操作的資料
剛開始理解受保護 API 時,我也差點走到另一個極端。
我會覺得:
只要和會員或評論有關,好像全部都應該要求登入。
但 PawPal 實際上還是會區分公開資料和會員私有資料。
例如自己的:
會員資料
寵物資料
寵物修改與刪除
自己的評論修改與刪除
這些操作就需要知道目前登入者是誰。
但像醫院的公開評論列表:
GET /api/v1/hospitals/:hospital_id/reviews
本來就是要讓使用者查看的公開內容,因此不需要登入。
所以真正要判斷的不是:
這支 API 有沒有碰到會員資料?
而是:
這次 Request 是否涉及會員私有資料,或只能由資料本人執行的操作?
/reviews/me 是另一個很直覺的例子除了 pets,我覺得 PawPal 的醫院評論也很適合理解 ownership。
評論修改與刪除 API 使用:
/reviews/me
像是:
PATCH /api/v1/hospitals/:hospital_id/reviews/me
DELETE /api/v1/hospitals/:hospital_id/reviews/me
光從 /me 就很容易理解:
這次操作的是「我自己的評論」。
後端也會把:
hospital_id
+
user_id
一起當作條件。
所以同一間醫院可以有很多會員留下評論,但目前登入者只能修改或刪除自己的那一筆。
不過這篇我還是以 pets 當成主要案例。
因為從:
取得列表
取得單筆
新增
修改
刪除
都可以看到 ownership 的影響。
評論則讓我看到,同一個觀念也會出現在其他功能裡。
如果現在重新處理權限控管,我不會只確認:
這支 API 有沒有加 middleware?
而是會沿著整條資料流去看。
這支 API 是否需要登入
→ middleware 是否取得目前登入者
→ Controller 是否正確使用 req.userId
→ Service / SQL 是否限制資料 ownership
→ 查詢、修改、刪除是否都綁定目前登入者
→ 沒有符合資料時,操作是否確實停止
因為只完成其中一層,還是不夠。
只做 Authentication:
知道你是誰
不代表已經確認:
這筆資料是你的
只在前端隱藏按鈕:
使用者比較不容易點到
也不代表:
後端真的拒絕這次 Request
而如果 SQL 只有:
WHERE id = petId
它只回答:
這筆資料存在嗎?
加入:
AND user_id = req.userId
才多回答了一個非常重要的問題:
這筆資料也是你的嗎?
這次我真正理解的是:
user_id 納入查詢條件;修改或刪除特定資料時,也會和資料 ID 一起限制。authenticateToken 主要負責 Authentication,ownership 則主要由後續 Service / SQL 限制。403,但不該成功的操作一定不能成功。這次最讓我有感的,是權限控管並不是「把某個按鈕鎖起來」。
真正保護的是:
這筆資料只能被正確的人取得和操作。
以前我可能只會問:
這個功能有沒有成功?
開始碰到會員資料後,我才多了一個習慣:
這筆資料是誰的?
以及:
現在這個使用者,真的有資格操作它嗎?
而重新回頭看 Repository 後,我又多理解了一件事。
有時候權限並不會出現在一個叫做:
checkPermission()
的函式裡。
它可能只是 SQL 裡的一個:
AND user_id = req.userId
看起來只是多一個條件。
但真正保護的,是不同帳號之間的資料邊界。
從這裡開始,我才慢慢理解:
功能可以正常執行還不夠,它還必須只對正確的人執行。
功能完成後,真正讓我頭痛的,往往不是畫面做不出來。
而是它明明看起來應該要能動,實際上卻一直出錯。
有時候資料沒有更新,有時候畫面完全沒有反應,也有時候錯誤訊息已經出現在眼前,我卻還是不知道問題到底在哪裡。
下一篇,我會從 PawPal 的真實錯誤案例出發,整理自己怎麼透過 console、錯誤訊息和排查流程,一步一步找到問題。
下一篇:
Day 15|Debug 到懷疑人生的那一天。