第 4 週 · 關聯、登入與佈署
身分驗證:雜湊、session、token
密碼只存雜湊,登入後發一把隨機權杖,放在 HttpOnly cookie 與 KV 之前先把概念走完。
正在打開這一關…
學習目標
- 說明為什麼資料庫不能存明文密碼
- 分得清鹽、胡椒、雜湊
- 說明 session 權杖與把它放進 cookie 的理由
留言要屬於某個人,就得先知道「現在這個人是誰」。這一課做最小的登入觀念:註冊時把密碼變成不能還原的雜湊;登入成功發一把隨機權杖;之後的請求帶著這把權杖。
用生活例子理解
- 雜湊像絞肉機。同樣的肉進去,得到同樣的結果,但不能把肉餡變回肉。密碼用 Web Crypto 的 PBKDF2,故意算比較慢,讓別人難以高速猜。
- 鹽是每位使用者專屬的隨機佐料,存進資料庫。如此兩個人就算密碼相同,雜湊也不同。
- 胡椒是整座廚房才有的秘密,放在 secret,不放進資料庫。資料庫被拷走時,對方還少這一罐。
- **權杖(token)**是手環號碼,不是密碼。它是一長串隨機字,猜不中。
- Session 是「這把手環目前對應哪一位使用者」。手環可以作廢(登出),密碼不必改。
生活比喻:密碼是你記得的暗語。手環是今天入場的號碼牌。暗語不寫在號碼牌上,號碼牌遺失就換一張,不必把暗語印出來。
Cookie 用來讓瀏覽器自動帶上手環。加上:
HttpOnly:網頁裡的 JavaScript 讀不到,降低被惡意腳本偷走的機會。SameSite=Lax:別的網站表單比較不會偷偷帶上你的手環。Secure:只在 HTTPS 傳送。本機http://localhost先不要加,否則瀏覽器不會保存。線上看協定是https:再加。Path=/:整個網站都會帶。Max-Age:幾秒後過期。
這是入門,不是完整的安全課程。我們還沒做速率限制、第二因素、或更完整的 CSRF 權杖。SameSite 是起步,不是終點。
比較雜湊時用固定時間的比對,避免從「比較花了多久」猜對了幾個字。案例裡有一個小函式 safeEqual。
動手做
大約 30 分鐘。可以先不接資料庫,在筆記把流程畫成六格:
- 註冊:檢查帳號與密碼長度 → 產生鹽 → 雜湊 → 存使用者。
- 登入:依帳號找出鹽與雜湊 → 用同一套再雜湊一次 → 相同就發權杖。
- 之後的
POST /api/messages:沒有權杖就401。 - 寫下密碼最短 8 個字、帳號 3 到 20 個英數的規則。
- 回答:資料庫被看見時,攻擊者直接看到的應該是雜湊而不是密碼。胡椒為什麼還要放在 secret?
- 看期末範例
examples/04-guestbook/src/auth.js,對照你的六格,圈出鹽存在哪、權杖存在哪。
今天的案例把 session 先放在記憶體 Map,讓你看清流程。KV 那一課會換成會過期的儲存。不要把這個 Map 佈署給真的使用者。
案例
function toHex(buffer) {
return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function randomHex(bytes = 16) {
const buffer = new Uint8Array(bytes);
crypto.getRandomValues(buffer);
return toHex(buffer);
}
export async function hashPassword(password, saltHex, pepper) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const salt = encoder.encode(`${saltHex}:${pepper}`);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
key,
256,
);
return toHex(bits);
}
export function safeEqual(left, right) {
if (left.length !== right.length) return false;
let mismatch = 0;
for (let index = 0; index < left.length; index += 1) {
mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return mismatch === 0;
}function sessionCookie(token, secure) {
const parts = [
`session=${token}`,
"HttpOnly",
"Path=/",
"SameSite=Lax",
"Max-Age=604800",
];
if (secure) parts.push("Secure");
return parts.join("; ");
}登入成功時:
const token = randomHex(32);
sessions.set(token, { userId: user.id, username: user.username });
return new Response(JSON.stringify({ username: user.username }), {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"set-cookie": sessionCookie(token, new URL(request.url).protocol === "https:"),
},
});fetch 若要讓瀏覽器保存並帶回 cookie,前端要加 credentials: "include"。同一來源時,同站請求預設就會帶 cookie;跨來源則必須兩邊都同意。期末範例把頁面與 API 放在同一個 Worker,用預設即可。
常見錯誤
- 用 SHA-256 直接雜湊一次密碼。它太快,專用硬體可以大量猜。PBKDF2 的迭代就是在拖時間。
- 資料庫存明文,覺得「反正沒有人看得到資料庫」。備份、紀錄、誤貼的 SQL 都會讓它被看到。
- 權杖用
Math.random。那不是密碼學亂數。用crypto.getRandomValues。 - 把權杖放在
localStorage又讓每個腳本都讀得到。入門用HttpOnlycookie。 - 在 HTTP 的本機加上
Secure,然後以為登入壞了。
自我檢查
- 鹽和胡椒分別放在哪裡?
- 權杖為什麼可以刪掉,而不必修改密碼?
HttpOnly擋的是誰的讀取?