這一課完成後:你會建立 users 與 sessions,註冊時安全地雜湊密碼,登入後發出 session cookie,透過 /api/me 辨認目前使用者,並讓某些 API 只有登入者或資料擁有者可以操作。
先拆開兩個常被混在一起的詞
Authentication 在回答:「你是誰?」
Authorization 在回答:「你可以做什麼?」
Authorization → permission
例如使用者成功登入,只代表 backend 知道他是 user 42;並不代表他可以刪除 user 17 的 task。
Auth 不是前端顯示一個登入按鈕就完成
真正的判斷必須發生在 backend。前端把「刪除」按鈕藏起來只是 UX,不是權限控制。
但 Backend 必須再次檢查權限
任何人都可以自己用 curl、DevTools 或另一個程式直接呼叫你的 API。
先建立 users table
在 SQLite schema 加入:
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
注意:這裡沒有 password column。
Database 不應該保存使用者原始密碼。
密碼不能用一般 hash 一次就算完
密碼需要專門的 password hashing / key derivation。這課用 Node 內建的 scrypt。
Node 官方把 scrypt 描述為故意消耗計算與記憶體成本的 password-based key derivation function;salt 應盡量唯一,官方文件建議使用隨機且至少 16 bytes 的 salt。
先做 hashPassword()
const {
randomBytes,
scrypt,
timingSafeEqual,
createHash
} = require("node:crypto");
function scryptAsync(password, salt) {
return new Promise((resolve, reject) => {
scrypt(password, salt, 64, (error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(derivedKey);
});
});
}
async function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const derivedKey = await scryptAsync(password, salt);
return {
salt,
hash: derivedKey.toString("hex")
};
}
資料流:
Salt 不需要保密,所以可以跟 hash 一起存。
登入時不是「解密」密碼
Password hash 是單向流程。登入時拿使用者剛輸入的密碼,加上 database 裡原本那個 salt,再算一次。
async function verifyPassword(password, salt, storedHash) {
const derivedKey = await scryptAsync(password, salt);
const storedBuffer = Buffer.from(storedHash, "hex");
if (derivedKey.length !== storedBuffer.length) {
return false;
}
return timingSafeEqual(derivedKey, storedBuffer);
}
timingSafeEqual() 用 constant-time algorithm 比較 secret-like values,避免直接用一般字串比較帶來不必要的 timing leak。
重點不是自己設計密碼學。這堂課只是讓你看懂 Auth 內部資料流;正式產品應優先使用成熟 Auth library / provider,並跟著它們的 security guidance。
POST /api/register:先做註冊
Request:
POST /api/register
Content-Type: application/json
{
"email": "student@example.com",
"password": "example-password"
}
最基本的流程:
至少驗證:
if (
typeof body.email !== "string" ||
typeof body.password !== "string"
) {
sendJson(response, 400, { error: "Invalid input" });
return;
}
const email = body.email.trim().toLowerCase();
if (email === "" || body.password.length < 8) {
sendJson(response, 400, { error: "Invalid input" });
return;
}
這裡的 8 只是課程最低門檻,不是完整密碼政策。
用 prepared statement 建立 user
const insertUser = db.prepare(`
INSERT INTO users (email, password_hash, password_salt)
VALUES (?, ?, ?)
`);
const { salt, hash } = await hashPassword(body.password);
try {
const result = insertUser.run(email, hash, salt);
sendJson(response, 201, {
id: Number(result.lastInsertRowid),
email
});
} catch (error) {
sendJson(response, 409, {
error: "Account already exists"
});
}
不要把 password_hash 或 salt 回給 client。Client 不需要它們。
登入成功之後,需要一個「之後還認得你」的方法
如果每個 request 都重新傳 email + password,會很糟。
常見做法之一是 session:
這堂課使用 opaque session,而不是 JWT。因為它比較容易先看懂「session id 指向 server-side state」的概念。
建立 sessions table
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
注意我們連原始 session token 都不直接存 database,而是存 token hash。
建立 session token
function createSessionToken() {
return randomBytes(32).toString("hex");
}
function hashSessionToken(token) {
return createHash("sha256").update(token).digest("hex");
}
Session token 本身是隨機秘密;database 只保存它的 hash。就算 sessions table 洩漏,也不應該直接拿到可立刻使用的原始 session token。
POST /api/login
先找 user:
const selectUserByEmail = db.prepare(`
SELECT id, email, password_hash, password_salt
FROM users
WHERE email = ?
`);
再驗證:
const user = selectUserByEmail.get(email);
if (!user) {
sendJson(response, 401, {
error: "Invalid email or password"
});
return;
}
const passwordOk = await verifyPassword(
body.password,
user.password_salt,
user.password_hash
);
if (!passwordOk) {
sendJson(response, 401, {
error: "Invalid email or password"
});
return;
}
故意讓「email 不存在」和「password 錯誤」回同一種外部訊息,可以少洩漏一點帳號存在資訊。
建立 session row
const token = createSessionToken();
const tokenHash = hashSessionToken(token);
const expiresAt = new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000
).toISOString();
const insertSession = db.prepare(`
INSERT INTO sessions (token_hash, user_id, expires_at)
VALUES (?, ?, ?)
`);
insertSession.run(tokenHash, user.id, expiresAt);
現在 backend 有了一個「token → user」的持久對應。
把 session token 放進 HttpOnly cookie
Response header:
response.setHeader(
"Set-Cookie",
`session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`
);
HttpOnly 讓前端 JavaScript 無法透過 document.cookie 直接讀這個 cookie;SameSite=Lax 限制許多跨站情境;Path=/ 讓它適用整個網站。
正式 HTTPS production 還應加上 Secure。MDN 對 session cookie 也建議限制 scope、使用 HttpOnly、Secure、適當 SameSite 與短期限。
Cookie 到底怎麼回到 backend?
登入 response 設定 cookie 後,browser 會在符合條件的後續 request 自動帶:
Cookie: session=...
Backend 可以從:
request.headers.cookie
取得 Cookie header。
先寫一個很小的 cookie parser
function parseCookies(request) {
const header = request.headers.cookie ?? "";
const result = {};
for (const part of header.split(";")) {
const [rawKey, ...rest] = part.trim().split("=");
if (!rawKey) {
continue;
}
result[rawKey] = rest.join("=");
}
return result;
}
這只是課程用的最小 parser。正式產品通常交給成熟 HTTP / cookie library。
把 session 轉回 user
const selectUserBySession = db.prepare(`
SELECT users.id, users.email, sessions.expires_at
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token_hash = ?
`);
function getCurrentUser(request) {
const cookies = parseCookies(request);
const token = cookies.session;
if (!token) {
return null;
}
const tokenHash = hashSessionToken(token);
const user = selectUserBySession.get(tokenHash);
if (!user) {
return null;
}
if (new Date(user.expires_at).getTime() <= Date.now()) {
return null;
}
return {
id: user.id,
email: user.email
};
}
現在每一個 request 都可以問:「這次 request 對應到哪個 user?」
GET /api/me:最小的 authenticated route
if (request.method === "GET" && url.pathname === "/api/me") {
const user = getCurrentUser(request);
if (!user) {
sendJson(response, 401, {
error: "Authentication required"
});
return;
}
sendJson(response, 200, user);
return;
}
401 Unauthorized 在 HTTP 命名上有點容易誤導;這裡實際表達的是「需要有效 authentication」。
Logout:讓 server-side session 失效
Logout 不應該只是前端把畫面切回登入頁。
Backend 要刪掉 session:
const deleteSession = db.prepare(`
DELETE FROM sessions
WHERE token_hash = ?
`);
Route 裡:
const cookies = parseCookies(request);
const token = cookies.session;
if (token) {
deleteSession.run(hashSessionToken(token));
}
response.setHeader(
"Set-Cookie",
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
);
sendJson(response, 200, { ok: true });
這樣 browser 的 cookie 和 server-side session 都失效。
Authentication 完成了,Authorization 還沒
現在 backend 知道 request 是 user 3,但 task 仍然沒有 owner。
替 tasks 加上:
user_id INTEGER NOT NULL
建立 task 時,不要讓 client 自己決定 user_id:
Client 傳:
{
"title": "My private task",
"user_id": 999
}
Backend 也不應該照單全收。Owner identity 應由 authenticated session 決定。
只查目前使用者自己的 tasks
const selectTasksForUser = db.prepare(`
SELECT id, title, done, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
`);
const tasks = selectTasksForUser.all(user.id);
這已經是 authorization:
↓
Query 只允許 user_id = 3
↓
別人的 rows 不會被回傳
更新 / 刪除也必須檢查 owner
不要:
UPDATE tasks SET done = ? WHERE id = ?
改成把 owner 一起放進條件:
UPDATE tasks
SET done = ?
WHERE id = ? AND user_id = ?
刪除同理:
DELETE FROM tasks
WHERE id = ? AND user_id = ?
這比「先查 task,再相信前端說它屬於誰」更接近真正的權限邊界。
401 和 403 怎麼分?
403 → 知道你是誰,但你沒有這個權限
有些 owner-scoped API 會故意對「不是你的 resource」回 404,避免透露該 resource 是否存在。這是 API design 決策之一。
這堂課先掌握語意,不需要把所有策略一次背完。
Session 和 JWT 都只是身份傳遞方案,不是 Auth 本身
JWT 常被拿來當「Auth 的同義詞」,但它只是 token format / mechanism 的一種。
Session:opaque token + server-side state
JWT:signed token 裡攜帶 claims
兩者都有適合情境與風險。初學時先把 session data flow 搞懂,比「因為大家都用 JWT 所以我也用」重要。
不要自己做這些 production 級功能
真正產品還會處理:
Email verification。
Password reset。
Rate limiting / brute-force protection。
Multi-factor authentication。
Session rotation / revocation。
CSRF 防護。
Account recovery。
安全稽核與異常登入。
所以這堂課的 Auth 是「理解模型 + 建立最小本機實作」,不是宣稱這 100 行就能取代成熟身份系統。
小挑戰:替 tasks API 加 owner boundary
需求 1:未登入呼叫 GET /api/tasks 必須失敗。
需求 2:登入 user A 建立兩筆 tasks。
需求 3:登出,再登入 user B。
需求 4:user B 不可以看到 user A 的 tasks。
需求 5:user B 不可以 PATCH / DELETE user A 的 task。
需求 6:GET /api/me 能正確回目前登入者。
需求 7:logout 後原 session 不再有效。
測試 Auth 時,不要只看「登入成功」
至少測:
重複 email 註冊。
錯誤 password。
完全沒有 cookie。
亂造 session token。
過期 session。
已 logout 的 session。
跨 user 存取 task。
Security boundary 的測試重點往往是「不該成功的 request 有沒有真的失敗」。
最後收斂:一次 authenticated request 經過什麼?
↓ password verify
建立 random session
↓ Set-Cookie
Browser 保存 cookie
↓ 後續 request
Backend 解析 cookie
↓ session → user
Authentication 完成
↓ owner / role / permission check
Authorization 完成
↓ database operation
到這裡,你的 Web App 已經第一次有「不同使用者看到不同資料」的能力。
下一課會處理邊界上的其他問題:environment variables、secrets、validation、錯誤回應、CORS,以及為什麼本機能跑不代表可以直接丟上線。
完成條件:你能說清 authentication / authorization 的差別,知道原始密碼不能落地,能建立與驗證 session cookie,並讓 database query 根據 current user 限制資料存取。