Day 10 把 Vue 與 ASP.NET Core API 拆成兩個應用程式。這樣一來,除了網頁,手機 App 或其他系統也能透過 HTTP 呼叫後端。不過,呼叫得到 API,不代表所有功能都能使用。查看自己的專案、修改他人的 Task,以及調整帳號角色,本來就應該有不同的權限。
所以,後端得先回答兩個問題:這個請求是誰送的?他能不能做這件事?前者是身分驗證(Authentication),後者是授權(Authorization)。JWT 常出現在這段流程中,卻不等於整套登入與權限機制。
JWT 的全名是 JSON Web Token。按照 RFC 7519 的定義,它是一種簡短、適合在 URL-safe 環境中傳遞的 Claims 表示格式。Claim 就是發行者對某個主體的一項聲明,例如「使用者編號是 123」或「系統角色是 User」。
使用者登入成功後,伺服器可以簽發一顆 JWT Access Token。之後前端每次呼叫受保護的 API,都要把它放在 HTTP Header 一起送出:
GET /api/v1/projects HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>
Bearer 的意思很直接:誰拿到這顆 Token,誰就能拿它嘗試呼叫 API。因此 Token 必須透過 HTTPS 傳輸,也不能寫進 Log、URL 或錯誤訊息。
本文談的是 API 常用的簽章型 JWT。它的外觀是一串由兩個句點分隔的文字,因此可以分成三段。下面為了方便閱讀才斷行,實際傳輸時會放在同一行:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJMb3VpcyIsInJvbGUiOiJVc2VyIn0
.
<signature>
Header.Payload.Signature
typ: JWT 和 alg: RS256。這裡有一個很容易搞混的地方:Header 與 Payload 通常只是 Base64url 編碼,不是加密。任何拿到 Token 的人都能解碼內容,所以 Payload 不應放密碼、身分證字號、信用卡號或其他敏感資料。簽章用來保護完整性與確認來源,不會把內容變成只有伺服器才看得懂的密文。
| Claim | 用途 |
|---|---|
iss |
Issuer,識別 Token 發行者 |
sub |
Subject,通常放使用者的穩定識別碼 |
aud |
Audience,指定這顆 Token 要給哪個 API 使用 |
exp |
Expiration Time,到期後不再接受 |
nbf |
Not Before,在指定時間之前不可使用 |
iat |
Issued At,記錄簽發時間 |
jti |
JWT ID,識別單一 Token |
表格裡的 Claim 很常見,但不是每顆 JWT 都必須全部包含;應用程式還是要自行定義必填項目。我們也可以加入 role 或 permission 這類自訂 Claim。不過,前端解碼後看到 role: Admin,只能用來調整畫面,不能當成授權結果。真正的 Token 驗證與權限檢查,仍然要由後端完成。
把 Token 解碼,只能讀到裡面的內容,還不能證明它可信。收到 Access Token 後,API 還要檢查:
alg 必須在伺服器明確允許的演算法清單內。iss 是否為預期的發行者,aud 是否包含目前的 API。exp 是否已過期,nbf 是否已到可使用時間。前三項用來確認 Token 的來源、用途與有效期,最後一項則回到當下的業務狀態。就算 JWT 有效,也不代表使用者能存取所有資源。例如一般 User 雖然已經登入,還是不能編輯自己未參與專案的 Task。
這兩種失敗情況在 HTTP 裡也有不同的表達方式。缺少 Token、簽章錯誤或 Token 過期時,通常回傳 401 Unauthorized;身分已確認但權限不足,則回傳 403 Forbidden。Day 12 談 API 契約時,會再看到這兩種狀態碼。
Access Token 一旦外洩,攻擊者在它到期前都能冒用持有者的身分呼叫 API。縮短有效期可以減少受影響的時間,但如果 Token 一過期就要重新登入,使用起來又很麻煩。因此,實務上常會搭配壽命較長、受到更嚴格保護的 Refresh Token,用它換取新的 Access Token。
Refresh Token 不一定是 JWT。它可以是一串隨機文字,從外觀看不出任何資訊,並由伺服器保存對應狀態。這麼做後,系統就能實作以下兩個機制:
如果已經用過的 Refresh Token 又出現,它可能曾被複製。這時伺服器應拒絕請求,撤銷同一系列的 Token,再要求使用者重新登入。這是 OAuth 2.0 Security Best Current Practice 建議的 Refresh Token 重放偵測做法之一。
ProjectManagementWeb 將兩種 Token 分開保存:
| 類型 | 有效期 | 前端保存位置 | 用途 |
|---|---|---|---|
| RSA JWT Access Token | 預設 15 分鐘 | Vue 應用程式的記憶體 | 透過 Authorization: Bearer 呼叫業務 API |
| 隨機 Refresh Token | 預設 7 天 | PMW-REFRESH HttpOnly Cookie |
供 Refresh 與 Logout 端點更新或撤銷 Token |
可以把 Access Token 想成一張有效期只有 15 分鐘的公司識別證。後端發證時,會在裡面寫上使用者編號 sub、Token 編號 jti、名稱 name、角色 role 與證件版本 token_version。每一筆 permission 就像這張證可以開啟的門,沒有對應權限,就不能進入。證件上還會註明由誰簽發、要交給哪個系統,以及何時過期。
API 收到這張識別證後,不會看到名字就直接放行。它會先檢查 RSA 簽章是不是真的,再核對發行者、使用對象與有效期。這些都通過後,還要確認帳號目前仍是啟用狀態,而且 token_version 與資料庫一致。
Vue 拿到 Access Token 後,只會暫時放在應用程式的記憶體,就像把識別證拿在手上,不會另外抄一份放進 Local Storage。這麼做雖然不能解決所有網頁攻擊,但可以減少 Token 被長期保留與竊取的風險。
Refresh Token 則像「換新證的憑據」。後端會產生一組 64 位元組的高熵隨機值,再把原文裝進名為 PMW-REFRESH 的 Cookie。JavaScript 因為 HttpOnly 讀不到它,資料庫也只保留像指紋一樣的 SHA-256 hash。這顆 Cookie 的 Path 是 /api/v1/auth,設定了 SameSite=Lax;使用 HTTPS 時,還會加上 Secure。
不過,憑據放在 HttpOnly Cookie 裡,不代表就可以忽略 CSRF。瀏覽器還是會自動帶上這顆 Cookie,所以系統另外準備了一組核對碼。前端會先呼叫 GET /api/v1/security/csrf-token 取得它,發送註冊、登入、Refresh、登出與 Email 驗證相關的 POST 請求時,再將它放進 X-CSRF-TOKEN。至於一般業務 API,用的仍然是 Bearer Access Token,不會把 Refresh Token Cookie 當成身分憑證。
如果業務 API 回傳 401,就像服務人員說「這張證不能用了」。前端會拿 Refresh Token 去換一張新的 Access Token,成功後再把原本的請求重送一次。為了避免多個請求一起擠到櫃檯前換證,前端同一時間只會發出一個 Refresh 請求。否則多次 Rotation 互相影響,剛換到的 Token 也可能立刻被撤銷。
token_version 則像識別證的版次。當帳號被停用或系統角色變更,後端會把版次加一,同時撤銷這個帳號尚未失效的 Refresh Token。使用者如果再拿舊 Access Token 呼叫 API,就會因為證件版本與資料庫對不上而被拒絕。這會多一次資料庫查詢,卻不必等到 15 分鐘後,才能阻止舊 Token 繼續使用。JWT 可以做無狀態驗證,但這個專案更在意帳號異動後,舊證件能不能及時失效。
JWT 處理的是 Claims 如何以簡短格式傳遞,並透過簽章驗證完整性與來源。它不會加密 Payload,也不會自動處理權限、撤銷、Token 儲存或 CSRF。這些邊界還是要由應用程式自己設計;只是能把 Token 解碼,還遠遠不夠。
身分與權限的邊界釐清後,下一步是把「要呼叫哪個資源」、「使用什麼 HTTP 方法」、「成功或失敗會收到什麼」說清楚。Day 12 會從這裡接著談 RESTful API 與 API 契約。