認證回答「你是誰」,授權回答「你能做什麼」。前者做錯會登不進去,後者做錯不會有任何症狀——直到有人做了他不該做的事。
這篇是三件在實作前就該定下來的事。
一、預設角色是拒絕
最貴的一個錯誤不在規則寫得對不對,而在沒有規則時的預設行為。
只檢查登入狀態的守衛長這樣:
if (!loggedIn.value) return navigateTo('/login')
// 通過
有 OAuth 社交登入的系統上,這等於任何有 Google 帳號的人都能進來——因為註冊不需要任何內部審核,而「已登入」是他註冊完就自動獲得的狀態。
所以使用者表要有一個明確的「已登入但還沒有權限」狀態,我們用 unauthorized,而且它是資料庫欄位的預設值,不是應用程式判斷出來的:
const publicPages = ['/login', '/register', '/forbidden']
if (publicPages.includes(to.path)) return
if (!loggedIn.value) return navigateTo('/login')
if (user.value?.role === 'unauthorized') return navigateTo('/forbidden')
同樣的原則延伸到路由:沒有明確標示需要什麼權限的路由,預設應該是需要登入,而不是預設公開。公開頁面用白名單列舉,因為公開頁面的數量是可數的,需要保護的頁面則會一直長。
二、兩層檢查的分工
Client 端與 Server 端都要檢查,但它們不是互為備援——它們負責的是不同的事:
| 負責 | 失效後果 | |
|---|---|---|
| Client middleware | 體驗:不要讓人點進一個馬上會失敗的頁面 | 使用者看到一個空白頁或錯誤 |
| Server handler | 安全:實際擋住操作 | 未授權的人真的做了那件事 |
這個分工有個直接的推論:Client 端的檢查再完整也不能取代 Server 端,因為 API 可以被直接呼叫,瀏覽器裡的任何判斷都不在你的控制範圍內。
伺服器端的檢查就寫在需要它的那一支 handler 上,讓權限需求跟著端點走:
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event, {
user: { role: ['admin', 'manager'] },
})
// 到這裡 user 保證存在且角色符合
})
有些權限不是靠角色就能判斷的——例如「資源的擁有者或管理員可以編輯」。這種要在取得資源之後判:
const isOwner = resource?.owner_id === user.id
const isAdmin = ['admin', 'manager'].includes(user.role)
if (!isOwner && !isAdmin) {
throw createError({ statusCode: 403, message: '您沒有權限編輯此資源' })
}
注意這裡必須先讀資源才能判斷,也就是說讀取本身要先被允許。權限模型設計時要意識到這種「為了判斷而讀」的路徑存在,否則會寫出一個自己把自己擋住的規則。
三、權限定義只能有一份
權限邏輯最容易的死法是散開:middleware 裡有一份角色比對、選單元件裡有一份、每支 API 各寫各的。它們一開始一致,然後某次需求變更只改了其中兩處。
集中成一份定義,其他地方都引用它:
// app/utils/permission.ts
export const PERMISSIONS = {
MANAGE_USERS: ['admin', 'manager'],
EDIT_SETTINGS: ['admin'],
VIEW_DATA: ['admin', 'manager', 'staff'],
} as const
export function can(role: string | undefined, permission: keyof typeof PERMISSIONS) {
if (!role) return false
return (PERMISSIONS[permission] as readonly string[]).includes(role)
}
介面上的顯示與否也走同一份定義,這樣「看得到但點了會失敗」的狀況就不會出現:
<UButton v-if="can(user?.role, 'MANAGE_USERS')" to="/manage/users">
使用者管理
</UButton>
這裡要提醒一件事:用權限控制 UI 顯示是體驗,不是安全。按鈕藏起來不代表對應的 API 被擋住——那件事只有伺服器端的檢查算數。兩者共用同一份定義的好處是它們不會互相矛盾,不是其中一個可以省略。
為什麼不用階層數值
常見的做法是給角色編號(admin 4、manager 3、staff 2),比大小來判斷。它在角色真的是線性的時候很好用,但多數系統的角色遲早不是線性的——出現一個「只能看報表但不能改資料」的稽核角色時,它在數值軸上放哪裡都不對。
用權限清單而不是角色階層,是為了讓那一天到來時不必重寫整個模型:新角色只是在幾個 PERMISSIONS 條目裡被列進去或不列進去。代價是定義稍微囉嗦一點,而這個代價在第一次新增非線性角色時就回本了。
判斷你現在的模型撐不撐得住,有一個很快的方法:找一個「權限比較少但多一項特權」的角色,試著把它放進你的模型。 放得進去就沒事,放不進去——而你遲早會遇到這種角色——那要改的就不只是一個設定,是每一處寫著角色比大小的程式碼。