這一課完成後:你會建立 users 與 sessions,註冊時安全地雜湊密碼,登入後發出 session cookie,透過 /api/me 辨認目前使用者,並讓某些 API 只有登入者或資料擁有者可以操作。

先拆開兩個常被混在一起的詞

Authentication 在回答:「你是誰?」

Authorization 在回答:「你可以做什麼?」

Authentication → identity
Authorization → permission

例如使用者成功登入,只代表 backend 知道他是 user 42;並不代表他可以刪除 user 17 的 task。

Auth 不是前端顯示一個登入按鈕就完成

真正的判斷必須發生在 backend。前端把「刪除」按鈕藏起來只是 UX,不是權限控制。

Frontend 可以隱藏按鈕
但 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。

Node.js crypto 官方文件

先做 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")
  };
}

資料流:

Plain password + random salt → scrypt → derived hash → database

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"
}

最基本的流程:

Parse body → Validate → normalize email → check duplicate → hash password → INSERT user

至少驗證:

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:

Login 成功 → 建立 random session token → Browser 保存 token → 後續 request 自動帶 token → Backend 查 session → 得到 user

這堂課使用 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 與短期限。

MDN Set-Cookie

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;
}
Cookie → session token hash → sessions table → users table → current user

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:

Cookie → current user id → INSERT task.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:

Authenticated user = 3
↓
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 怎麼分?

401 → 沒有有效身份 / 需要登入
403 → 知道你是誰,但你沒有這個權限

有些 owner-scoped API 會故意對「不是你的 resource」回 404,避免透露該 resource 是否存在。這是 API design 決策之一。

這堂課先掌握語意,不需要把所有策略一次背完。

Session 和 JWT 都只是身份傳遞方案,不是 Auth 本身

JWT 常被拿來當「Auth 的同義詞」,但它只是 token format / mechanism 的一種。

Auth 問題:如何建立與驗證 identity?
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 經過什麼?

Login credentials
↓ 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 限制資料存取。