跳轉到

7.1 使用者的註冊與登入#

本節目標#

  • 理解 Session 與 JWT 兩種認證模型的取捨,並解釋在 Workers 環境為什麼常用「簽名 Cookie」折衷方案。
  • 正確設定 Cookie 的 HttpOnly、secure、sameSite 屬性,理解每個屬性對應的攻擊面。
  • 在 Worker 中實作註冊、登入、登出三個 routes,密碼以 PBKDF2(WebCrypto)hash。
  • 整合 Cloudflare Turnstile 人機驗證,擋掉機器人批量註冊。

發着肩膀的「重新回答同一個問題」#

1995 年的 HTTP 是無狀態協定, Netscape 為了「購物車」問題發明了 Cookie;之後伺服器把 session ID 存進自己的記憶體或資料庫(Session 模型),代價是水平擴展時要共享儲存。2010 年前後 SPA 與 API 經濟興起,JWT(2011,RFC 7519 於 2015 定稿)把「你是誰」簽進 token 本身,伺服器免查表。這是同一個問題的兩種答案:「狀態放伺服器,還是放證件上?」

Session(查表) JWT(自含簽名 token)
撤銷 刪表即可,即時生效 難;需等過期或黑名單
伺服器端 需共享儲存(D1/KV/DO) 無狀態,天然適合邊緣部署
大小 Cookie 只傳 ID 塞進 claims,較大
泄漏影響 只是一把 session 鑰匙 一張到處有效的通行證

Workers 本身無本地持久記憶體,每次請求可能落在不同 PoP 的不同 isolate——這使得無狀態的簽名 token 特別適合。本書折衷:用 HMAC-SHA256 簽名的自製小 token(不塞 JSON 的公開資料),過期設短,並允許登入時順手更新。

屬性 防的攻擊 說明
HttpOnly XSS 偷竊 Cookie document.cookie 讀不到
secure 中間人 sniffing 只在 HTTPS 傳送(Workers 全站皆 HTTPS,仍要明示)
sameSite CSRF(跨站自動帶上 Cookie) lax 預設;表單/JSON API 建議 lax 起步
path=/ — 限制作用範圍

理論根基:Cookie 被瀏覽器自動附帶,這是方便也是 CSRF 的根源;sameSite 是現代瀏覽器(2018 Chrome 80 起)把「自動附帶」收緊到同站請求的止血點。

實作:密碼 hash 與三個 routes#

WebCrypto 無內建 bcrypt/argon2,我們用 PBKDF2(RFC 8018):以較高的迭代次數(250,000)彌補單次 SHA-1 的快速性。鹽(salt)每次隨機產生,存進結果字串。

// src/auth.ts
const enc = (s: string) => new TextEncoder().encode(s);

export async function hashPassword(pw: string, salt?: string): Promise<string> {
  const s = salt ?? crypto.randomUUID().replaceAll("-", "");
  const key = await crypto.subtle.importKey("raw", enc(pw), "PBKDF2", false, ["deriveBits"]);
  const bits = await crypto.subtle.deriveBits(
    { name: "PBKDF2", salt: enc(s), iterations: 250_000, hash: "SHA-256" },
    key, 256
  );
  const hex = [...new Uint8Array(bits)].map(b => b.toString(16).padStart(2, "0")).join("");
  return `pbkdf2$sha256$250000$${s}$${hex}`;   // 算法、參數都存進字串,方便日後升級
}

export async function verifyPassword(pw: string, stored: string): Promise<boolean> {
  const [, , iters, salt, hash] = stored.split("$");
  return hashPassword(pw, salt).then(v => v.endsWith(hash) === false ? false : v.split("$")[4] === hash);
}

// 簽名 cookie token:payload.userId.expiryTime.signature(HMAC-SHA256)
export async function signToken(userId: string, secret: string): Promise<string> {
  const body = `${userId}.${Date.now() + 7 * 86400_000}`;
  const sig = await hmac(body, secret);
  return btoa(body + "." + sig);
}

export async function hmac(msg: string, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey("raw", enc(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, enc(msg)))]
    .map(b => b.toString(16).padStart(2, "0")).join("");
}
// src/routes.ts(以 Hono 示範;任何框架皆同)
import { Hono } from "hono";

const app = new Hono<{ Bindings: Env }>();

function cookie(name: string, value: string, maxAge = 7 * 86400) {
  return `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
}

app.post("/api/register", async (c) => {
  const { email, password, turnstile } = await c.req.json();
  const ok = await verifyTurnstile(turnstile!, c.env.TURNSTILE_SECRET, c.req.header("CF-Connecting-IP") ?? "");
  if (!ok) return c.json({ error: "人機驗證失敗" }, 400);
  if (password.length < 10) return c.json({ error: "密碼太短" }, 400);

  const exists = await c.env.DB.prepare("SELECT 1 FROM users WHERE email=?").bind(email).first();
  if (exists) return c.json({ error: "此信箱已註冊" }, 409);

  const userId = crypto.randomUUID();
  await c.env.DB.prepare("INSERT INTO users (id,email,password_hash,created_at) VALUES (?,?,?,?)")
    .bind(userId, email, await hashPassword(password), new Date().toISOString()).run();
  return c.json({ userId }, 201);
});

app.post("/api/login", async (c) => {
  const { email, password } = await c.req.json();
  const row: any = await c.env.DB.prepare("SELECT id,password_hash FROM users WHERE email=?")
    .bind(email).first();
  if (!row || !(await verifyPassword(password, row.password_hash)))
    return c.json({ error: "帳號或密碼錯誤" }, 401);   // 模糊訊息,避免信箱枚舉

  const token = await signToken(row.id, c.env.AUTH_SECRET);
  c.header("Set-Cookie", cookie("session", token));
  return c.json({ ok: true });
});

app.post("/api/logout", (c) => {
  c.header("Set-Cookie", cookie("session", "", 0));   // 清空的 Set-Cookie
  return c.json({ ok: true });
});

async function verifyTurnstile(token: string, secret: string, ip: string): Promise<boolean> {
  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({ secret, response: token, remoteip: ip }),
  });
  const j: any = await res.json();
  return j.success;
}

export default app;

技術要點

  1. 註冊即登入與否:上述註冊成功後不直接發 cookie,分離兩步可讓信箱確認流程自然插入。
  2. HMAC 驗證 middleware:await hmac(body, secret) 與 cookie 中簽名比對,並檢查 expiryTime。建議封裝成 app.use("/api/*", authMiddleware)。
  3. 不比對的原因訊息:登入失敗回「帳號或密碼錯誤」是可枚舉性(enumerability)的止血。
  4. Turnstile 前端:只需在表單 <div class="cf-turnstile" data-sitekey="..."> 插一段 script,token 連同表單一起 POST 上來於 turnstile 欄位。

小結與練習#

本節把「無狀態協定如何記得人」的歷史問題翻譯成三個 Worker routes:註冊(hash 密碼)、登入(簽名 cookie)、登出(清 cookie)。PBKDF2 的迭代次數是「用量 vs 響應速度」的取捨;Turnstile 是把 2014 年圖形驗證碼(CAPTCHA 影像識別)被機器學習攻破後,重新思考為「行為特徵 + 隱私友善」的演進。

練習

  1. 把登入 middleware 補完:讀 session cookie,驗 HMAC 與過期,把 userId 塞進 c.set("userId", ...)。(提示:Hono 用 getCookie。)
  2. 把 PBKDF2 迭代次數升到 600,000 會讓登入慢多少?(實際量測後填在 todo 註記裡。)
  3. 加入「五次登入失敗鎖 15 分鐘」:用 D1 計數表或 Durable Object。