跳到主要內容

登入與 Session:不閃爍、不外洩、會過期

session 存哪裡、怎麼在伺服器就知道使用者是誰、token 怎麼續期而不讓使用者被踢出去,以及三種 UI 狀態。

約 6 分鐘 · auth-session.md

1. 存哪裡:cookie,不是 localStorage

localStorage cookie (HttpOnly)
JS 讀得到 是 → XSS 一發全拿
伺服器讀得到 否 → 第一幀一定是錯的
自動附在請求上
CSRF 風險 需要 SameSite

結論:session token 放 HttpOnly cookie

cookies.set('session', token, {
  httpOnly: true,   // JS 讀不到,XSS 偷不走
  secure: true,     // 只走 HTTPS
  sameSite: 'lax',  // 擋大部分 CSRF,又不會弄壞外部連結進站
  path: '/',
  maxAge: 60 * 60 * 24 * 30
});

sameSite: 'lax' 是預設的正確答案:從 Google 點連結進站時 cookie 仍會送出(否則使用者會發現自己「莫名其妙登出了」),但跨站 POST 不會。

2. 在伺服器就知道使用者是誰

這是「登入後畫面閃一下才變成已登入」的根治方法。

// hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session');
  event.locals.user = token ? await verifySession(token) : null;
  return resolve(event);
};
// +layout.server.ts
export const load = ({ locals }) => ({ user: locals.user });

於是伺服器輸出的 HTML 一開始就是正確的狀態。Next.js 在 Server Component 裡讀 cookies(),Nuxt 用 server middleware,概念相同。

反面示範(每個專案都會寫一次的錯):

// 伺服器渲染時 user 是 null → 先畫出「登入」按鈕
// hydrate 後才讀到 → 換成頭像
// 使用者看到閃爍,而且 CLS 分數變差
useEffect(() => { setUser(JSON.parse(localStorage.getItem('user'))) }, []);

3. 三種 UI 狀態,不是兩種

type SessionState =
  | { status: 'authenticated'; user: User }
  | { status: 'anonymous' }
  | { status: 'loading' };

loading 只會出現在純客戶端的情境(例如登入後的樂觀更新)。如果你照第 2 節做,伺服器渲染的頁面根本不需要 loading 狀態。

三種都要設計:

  • authenticated → 頭像 + 名字
  • anonymous → 登入按鈕
  • loading跟前兩者同尺寸的骨架,不是會轉的圈圈(尺寸不同會造成位移)

4. Token 續期

短命的 access token + 長命的 refresh token 是標準做法。難的是續期時不要讓使用者感覺到

三個必須處理的細節

一、併發請求只能續一次。 五個請求同時收到 401,如果各自去 refresh,你會產生五個新 session,而且多半有四個會失效。

let refreshing: Promise<string> | null = null;

async function getFreshToken(): Promise<string> {
  // 已經有人在續了就等他,不要自己再發一次
  refreshing ??= doRefresh().finally(() => { refreshing = null; });
  return refreshing;
}

二、續期失敗要乾淨地登出。 清 cookie、清 client 快取、導到登入頁,並且帶上原本要去的網址

redirect(303, `/login?next=${encodeURIComponent(url.pathname + url.search)}`);

三、next 參數必須驗證。 不驗證就是開放重導向漏洞:

const next = url.searchParams.get('next') ?? '/';
const safe = next.startsWith('/') && !next.startsWith('//') ? next : '/';

5. 登入表單

  • autocomplete="username"autocomplete="current-password" —— 沒有這兩個,密碼管理員不會運作,使用者會開始用弱密碼
  • 註冊用 autocomplete="new-password"
  • 不要分別提示「帳號不存在」與「密碼錯誤」 —— 那等於送給攻擊者一個帳號列舉工具。統一講「帳號或密碼不正確」
  • 錯誤訊息放在一個 aria-live 區域,不要每個欄位一個
  • 送出時按鈕 disabled,但寬度不能變,否則版面會跳

參考模組:登入卡驗證碼

6. 登出

登出必須是 POST,不是 GET。

GET 登出代表任何人都能用 <img src="/logout"> 把你的使用者登出。而且瀏覽器與各種預抓工具會自動請求 GET 連結 —— 有人真的因此讓所有使用者在瀏覽時被隨機登出。

<form method="POST" action="/logout">
  <button type="submit">登出</button>
</form>

伺服器端要做的:清 cookie、讓 session 在資料庫失效(只清 cookie 等於 token 還活著)、清掉客戶端快取。

7. 權限

  • 每一個 API 端點都要驗擁有權,不只是驗登入
  • 前端隱藏按鈕是體驗,不是安全 —— 後端仍然要擋
  • 權限錯誤回 404 而不是 403,可以避免洩漏「這個資源存在」

8. 檢查清單

  • session 在 HttpOnly + Secure + SameSite cookie
  • 伺服器渲染時就知道使用者是誰,畫面不閃
  • 三種 session 狀態都設計了,loading 骨架尺寸正確
  • refresh 有併發鎖
  • 續期失敗會乾淨登出並記住原本要去的頁面
  • next / redirect 參數有驗證
  • 登入表單有正確的 autocomplete
  • 錯誤訊息不區分帳號與密碼
  • 登出是 POST,而且會讓伺服器端 session 失效

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言