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 的公開資料),過期設短,並允許登入時順手更新。
Cookie 的安全三屬性#
| 屬性 | 防的攻擊 | 說明 |
|---|---|---|
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;
技術要點
- 註冊即登入與否:上述註冊成功後不直接發 cookie,分離兩步可讓信箱確認流程自然插入。
- HMAC 驗證 middleware:
await hmac(body, secret)與 cookie 中簽名比對,並檢查expiryTime。建議封裝成app.use("/api/*", authMiddleware)。 - 不比對的原因訊息:登入失敗回「帳號或密碼錯誤」是可枚舉性(enumerability)的止血。
- Turnstile 前端:只需在表單
<div class="cf-turnstile" data-sitekey="...">插一段 script,token 連同表單一起 POST 上來於turnstile欄位。
小結與練習#
本節把「無狀態協定如何記得人」的歷史問題翻譯成三個 Worker routes:註冊(hash 密碼)、登入(簽名 cookie)、登出(清 cookie)。PBKDF2 的迭代次數是「用量 vs 響應速度」的取捨;Turnstile 是把 2014 年圖形驗證碼(CAPTCHA 影像識別)被機器學習攻破後,重新思考為「行為特徵 + 隱私友善」的演進。
練習
- 把登入 middleware 補完:讀
sessioncookie,驗 HMAC 與過期,把userId塞進c.set("userId", ...)。(提示:Hono 用getCookie。) - 把 PBKDF2 迭代次數升到 600,000 會讓登入慢多少?(實際量測後填在 todo 註記裡。)
- 加入「五次登入失敗鎖 15 分鐘」:用 D1 計數表或 Durable Object。