Next.js 中的授權與 Session 管理
此文件針對前端從後端取得 Session(Token 或 Session ID)後,需要儲存、延長與驗證 Session 的情境。如果專案使用函式庫或提供者(Auth.js、Better Auth、Clerk 等),Session 機制由它們處理,但第 2、6–10 節的規則仍然適用。程式碼範例請參閱 references/implementation.md。
規則優先順序。 使用者指令與專案的 agent 檔案權重更高。如果後端有不同要求(例如自行設定 Cookie),請遵循後端規範,並在 README 或 ADR 中記錄差異。
符號說明:MUST — 不得違反;SHOULD — 預設應遵守,偏離需有理由。
1. 模型選擇
| 模型 | 適用時機 |
|---|---|
| BFF: Next 的 Route Handler 從後端取得 Token,並放入 HttpOnly Cookie | 後端在回應主體中回傳 Token。Next 的預設選項 |
| 後端自行設定 HttpOnly Cookie | 前端與 API 位於相同網站,且後端受你控制 |
| 函式庫或提供者 | 需要 OAuth、MFA、使用者管理 — 不要自行實作 |
| Access Token 在記憶體,Refresh Token 在 HttpOnly Cookie | 無 BFF 的 SPA |
Token 存放在 localStorage/sessionStorage | 禁止: 任何 XSS 都會導致 Session 被竊取 |
第二個選擇是 BFF 代理哪些請求:
| 選項 | 優點 | 缺點 |
|---|---|---|
| 僅代理 login / refresh / logout,其他請求直接帶 Cookie 發送給 API | BFF 不成為瓶頸;程式碼較少 | API 需從 Cookie 讀取 Token 並設定 CORS;Cookie 必須能送達 API 的網域 |
| 所有請求皆透過 BFF | API 不需處理 Cookie 與 CORS;單一來源 | 每個請求都多一次躍點;BFF 需要擴展 |
選擇結果需記錄在 ADR 中。
2. Cookie(MUST)
- 所有能提供存取權限的 Token(access、refresh、session id)都必須設定
HttpOnly。 - 正式環境必須設定
Secure。瀏覽器允許在本機http://localhost使用Secure,但其他 http 主機則不行。 - 預設使用
SameSite=Lax。Strict對抗 CSRF 更安全,但使用者若透過郵件或其他網站的連結訪問,首次點擊時會顯示未登入狀態 — 請謹慎選擇並記錄原因。 - 設定
Path=/;生命週期應根據 Token 的exp或後端回應來決定,不要「憑感覺」設定。 - SHOULD:在 Cookie 名稱中加入
__Host-前綴(需要Secure、Path=/、無Domain)— 可防止從子網域竄改 Cookie。 - Cookie 只能在伺服器端(Route Handler、Server Action、
proxy.ts)設定或刪除。Server Component 無法設定 Cookie。 - 不可從 JavaScript 讀取的 Cookie 是 Session 的唯一載體。一個不帶
HttpOnly、僅作為 UI 提示用的is_authenticated標誌,絕對不能用於存取權限的判斷。
3. BFF:Route Handler(MUST)
- 登入請求需經由 schema(
zod)驗證;格式錯誤的回應400。 - 會修改狀態的處理器需檢查
Origin是否與應用程式的 origin 一致:SameSite=Lax無法防禦來自同網站子網域的請求。Server Actions 會自行比較Origin與Host。 - 需將客戶端的上下文(context)傳遞給後端:
X-Forwarded-For、User-Agent— 否則後端的 Rate Limit 只會看到伺服器的 IP。 - 後端的錯誤應轉換為包含
kind與狀態碼(401、403、422、附帶Retry-After的429、503)的回應,不得直接暴露後端的錯誤訊息。 - Refresh: 如果後端拒絕 Refresh Token — 刪除 Cookie 並回應
401。如果後端無法連線(網路問題、5xx)— 不更動 Cookie,回應503:暫時性錯誤不應導致使用者登出。 - Logout: 在後端撤銷 Token(記錄錯誤但不顯示給使用者),無論如何都必須刪除 Cookie。
4. 檢查 Session 的位置(MUST)
| 位置 | 功能 | 不做什麼 |
|---|---|---|
proxy.ts(Next 16+,之前為 middleware.ts) | 樂觀檢查 Cookie 是否存在:將未登入者重新導向至登入頁面,並將已登入者導離登入頁面 | 不查詢資料庫或 API;非唯一防護 |
Data Access Layer(server-only 模組中的 verifySession()) | 在資料存取處進行驗證:Server Component、Route Handler、Server Action | — |
| Layout | 不做任何事:Layout 在導覽時不會重新渲染 | 不檢查存取權限 |
| 後端 | 事實來源:在每個請求驗證 Token | — |
- 每個 Route Handler 與 Server Action 都是公開的進入點,必須自行呼叫
verifySession()。 proxy.ts應檢查 Refresh Cookie(或 Session ID):Access Token 可能已過期,但 Session 仍然有效。- 路徑比對應以區段為單位:
/app與/app/…匹配,但/apple不匹配。 - 若專案啟用
cacheComponents(Next 16.4 預設),Session 讀取與使用者資料快取有各自的規則:在撰寫 DAL 之前,請先閱讀node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md。 - 權限(角色、所有權)需根據驗證過的資料來決定:透過簽章(JWKS、
jose)或後端回應。不可信賴未經驗證的 JWT payload 作為存取依據。 - Server Component 無法更新 Cookie。如果在伺服器端渲染時 Access Token 已過期:要麼由
proxy.ts更新 Session(它可以在回應中設定 Cookie — 僅對文件導覽(document navigation)這麼做,不要用於 prefetch,並需考慮 Refresh Token 輪換),要麼頁面渲染客戶端載入,由客戶端執行 Refresh。
5. 客戶端:無競爭條件的 Refresh(MUST)
- 「未授權」的判定: HTTP
401,或者在 GraphQL 回應(狀態碼200)中,至少有一個錯誤包含extensions.code === "UNAUTHENTICATED"(使用errors.some,而非errors[0])。僅當後端不提供錯誤碼時,才比對訊息文字,且需寫在單一函式中,加上註解與測試。 - 單次飛行(Single-flight): 所有同時收到 401 的請求應等待同一個 Refresh 請求,並使用其結果。Promise 應在
finally中清除。 - 原始請求只會重試一次。再次收到 401 表示 Session 已失效,不應再次嘗試 Refresh。
- Refresh 請求本身不應經過攔截器(否則會造成遞迴)。
- Refresh 的結果需區分三種情況:成功(
true)→ 重試原始請求;被拒絕(false)→ Session 已過期;網路或 5xx 錯誤(例外)→ 回傳network錯誤,不登出帳號。 - 在登出期間應封鎖 Refresh:否則,一個在登出瞬間收到 401 的請求,可能會立刻恢復 Session。
- Session 過期時:清除請求快取,將使用者導向登入頁面,並附上當前頁面的
callbackUrl。 - 要重試的請求主體必須是可重複讀取的(字串、
FormData),不可使用一次性ReadableStream。
6. 登入後的重新導向(MUST)
callbackUrl(next、returnTo)必須經由getSafeRedirect(raw, fallback, appOrigin)函式驗證:此函式應拒絕反斜線(\)與控制字元(瀏覽器會將/\evil.com與/\<tab>/evil.com轉換為//evil.com),使用new URL(raw, appOrigin)進行解析,比較 origin,並僅回傳路徑部分。僅檢查「是否以/開頭且不是//」是不夠的。- 此函式的完整測試案例請參閱
references/implementation.md的第 7 節。 - 登入後,Server Component 必須能看見新的 Cookie:應使用完整導覽(
window.location.assign)或router.replace搭配router.refresh()。
7. 登出(MUST)
- 伺服器需刪除 Cookie(第 3 節)。
- 客戶端需清除請求快取(
queryClient.clear())以及所有儲存使用者資料的客戶端 Store:下一個使用者在同一個分頁中,不應短暫看到前一個使用者的資料。 - 導覽至登入頁面時,不應允許「上一頁」回到已快取的受保護畫面(使用
replace或完整導覽)。 - 是否顯示登出確認訊息由產品決定;但請求進行期間必須鎖定按鈕。
- SHOULD:其他分頁應能得知登出事件(透過
BroadcastChannel或針對標誌的storage事件),否則它們會持續顯示資料,直到遇上第一個 401。
8. 連結中的 Token(密碼重設、Email 驗證)
- Token 只能從 URL 讀取一次並存入狀態中,然後應立即替換掉含有 Token 的 URL(
router.replace(pathname)):如此 Token 不會留在瀏覽歷史中,也不會透過Referer標頭傳送出去。 - 這些頁面應設定
Referrer-Policy: no-referrer,且不應載入任何第三方腳本(分析工具、小工具)。 - Token 不得被記錄。一次性與有效期限由後端負責;前端應顯示易懂的「連結已過期」畫面,並提供「重新發送」的操作。
9. 登入與恢復表單
- 密碼恢復功能應對已註冊與未註冊的 Email 回傳相同的回應(「如果帳號存在,已寄送郵件」),不得洩漏哪些 Email 已註冊。如果後端會洩漏,請記錄此限制。
- 請求進行時應鎖定送出按鈕;重複點擊不會建立第二個請求。
429狀態碼:如果回應中有Retry-After,應顯示包含等待時間的訊息。- 欄位屬性:Email 使用
type="email"搭配autoComplete="email";密碼在登入時使用autoComplete="current-password",在註冊與變更時使用"new-password";不應封鎖貼上功能。 - 「顯示密碼」按鈕:
type="button",需有aria-label與aria-pressed屬性,且不應從密碼欄位移走焦點。 - 「Email 或密碼錯誤」的訊息不應具體說明哪一項錯誤。
- 伺服器端錯誤需根據錯誤碼對應到對應的欄位;通用的錯誤應顯示在表單上方。
10. 資料與日誌
- 與使用者相關的回應不應進入共用快取(
"use cache"、CDN)。伺服器上的 QueryClient 應在每個請求中重新建立。 - 日誌中不得包含 Token、密碼、Email,最多只能記錄使用者 ID 與錯誤的
kind。 - Session Token 的 payload 應最小化:僅包含 ID 與角色,不包含個人資料。
11. 測試
- 單元測試:
getSafeRedirect(完整測試案例來自 references);「未授權」判定(401、GraphQL 錯誤碼、空主體、非 JSON 回應)。 - 單次飛行測試: N 個同時收到 401 的請求 → 只發送一個 Refresh,N 次重試;Refresh 被拒絕 → 沒有重試,只呼叫一次
onSessionExpired;Refresh 因網路錯誤失敗 → 回傳network錯誤,不登出;Refresh 成功後重試又收到 401 → 不再次 Refresh;在登出期間 → 不執行 Refresh。 - Route Handler 測試: Cookie 旗標設定正確;Refresh 被拒絕 → Cookie 被刪除;後端無法連線 → Cookie 保留,回應
503;請求主體格式錯誤 → 回應400;來源 Origin 不符 → 回應403。 - 端到端測試: 登入 → 訪問受保護頁面 → 登出 → 無法訪問受保護頁面;Access Token 過期(測試環境中設定短 TTL)→ 隱式 Refresh;密碼重設連結 → Token 從網址列消失。
12. 完成定義
- 沒有任何 Token 可從 JavaScript 讀取;Session 不使用
localStorage。 - Cookie 已設定:
HttpOnly、正式環境的Secure、SameSite、Path、生命週期基於 Token。 - Refresh 流程能區分後端拒絕與後端無法連線兩種情況。
- 單次飛行機制,包含結果處理、單次重試、登出時封鎖 — 已由測試覆蓋。
-
proxy.ts僅做樂觀檢查;DAL 在 handlers、actions 與 server components 中呼叫。 -
getSafeRedirect包含完整的測試案例。 - 登出時清除客戶端快取與 Store。
- 連結中的 Token 已從網址列移除。
- 日誌中不包含 Token 與個人資料。
- 模型與代理選擇已記錄在 ADR 中。