Day 27 的同步 API 假設了一個 requireUser(request, env)。今天把它實作出來。
需求很不典型,所以值得先講清楚:
換句話說:我要的是「這是同一個人」,不是「這個人是誰」。
這個需求排除了大部分常見方案。
| 方案 | 我需要保存什麼 | 問題 |
|---|---|---|
| Email + 密碼 | email、密碼雜湊 | 要做寄信、重設密碼、密碼強度…而且我拿到了不需要的 email |
| OAuth(Google/GitHub) | provider id(+ 幾乎必然拿到 email) | 依賴第三方、使用者的閱讀紀錄與真實身分掛鉤 |
| Magic link | 同上,還是要寄信 | |
| Passkey(WebAuthn) | 公鑰 + credential id | ✅ 沒有密碼、沒有 email、沒有第三方 |
| 純裝置金鑰(無帳號) | 公鑰 | 換裝置無法接續(正是我要解的問題) |
選 Passkey。它剛好符合「只要識別、不要身分」:註冊時瀏覽器產生一對金鑰,私鑰留在裝置的安全區(或同步到使用者的密碼管理器),我只存公鑰。
而且 passkey 天生支援跨裝置:使用者的 iCloud Keychain / Google Password Manager / 1Password 會同步 passkey,所以手機上能用同一個 passkey 登入。這正好是我要的「跨裝置」,而且不需要我做任何事。
CREATE TABLE users (
id TEXT PRIMARY KEY, -- 隨機 UUID。不是 email、不是流水號
created_at INTEGER NOT NULL
);
CREATE TABLE credentials (
cred_id TEXT PRIMARY KEY, -- WebAuthn credential id(base64url)
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key BLOB NOT NULL, -- COSE 格式公鑰
sign_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
label TEXT -- 使用者自訂(「我的手機」),可為 NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- 隨機 token 的雜湊,不是 token 本身
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
整個資料庫沒有一個欄位是個資。 沒有 email、沒有名字、沒有 IP、沒有 user agent。
label 是使用者自己填的裝置暱稱(可選),用來在「管理我的裝置」頁面辨認。它是唯一可能含自由文字的欄位,所以要限制長度並在顯示時跳脫。
sessions.id 存的是 token 的 SHA-256 雜湊,不是 token 本身。理由跟密碼一樣:資料庫洩漏時,攻擊者拿到雜湊無法反推 token、無法冒用 session。
/* functions/api/auth/register-begin.js */
export async function onRequestPost({ request, env }) {
const challenge = crypto.getRandomValues(new Uint8Array(32));
const userId = crypto.randomUUID();
/* challenge 存短期 KV(5 分鐘),防重放 */
await env.KV.put(`challenge:${b64u(challenge)}`,
JSON.stringify({ userId, type: "register" }), { expirationTtl: 300 });
return json({
challenge: b64u(challenge),
rp: { name: "學徑 LearnPath", id: new URL(request.url).hostname },
user: {
id: b64u(new TextEncoder().encode(userId)),
name: `learner-${userId.slice(0, 8)}`, // ← 不是 email,是隨機標籤
displayName: "學徑使用者",
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: {
residentKey: "required", // discoverable credential:登入時不需先輸入帳號
userVerification: "preferred",
},
timeout: 60000,
attestation: "none", // ← 不要 attestation,見下
});
}
兩個關鍵決定:
attestation: "none"。 Attestation 會回傳認證器的型號資訊(「這是 YubiKey 5 NFC」)——那是我不需要的裝置指紋資料。要求 none 讓瀏覽器不送這些。不收集就不會洩漏。
residentKey: "required"。 這讓 credential 成為 discoverable——登入時使用者不需要先輸入任何識別碼,直接點「登入」就會出現他的 passkey 列表。這是「沒有 email」能成立的關鍵:如果不是 discoverable,我就需要一個帳號名來查詢該用哪個 credential。
前端:
/* js/models/auth.js */
const Auth = {
async register() {
const opts = await (await fetch("/api/auth/register-begin", { method: "POST" })).json();
const cred = await navigator.credentials.create({
publicKey: {
...opts,
challenge: b64uDecode(opts.challenge),
user: { ...opts.user, id: b64uDecode(opts.user.id) },
},
});
const res = await fetch("/api/auth/register-finish", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: cred.id,
rawId: b64u(new Uint8Array(cred.rawId)),
response: {
clientDataJSON: b64u(new Uint8Array(cred.response.clientDataJSON)),
attestationObject: b64u(new Uint8Array(cred.response.attestationObject)),
},
label: this.guessDeviceLabel(),
}),
});
if (!res.ok) throw new Error("註冊失敗");
this.setSignedIn(true);
/* 註冊完立刻把本地既有進度推上去 */
Sync.schedule(200);
},
guessDeviceLabel() {
const ua = navigator.userAgent;
if (/iPhone|iPad/.test(ua)) return "iOS 裝置";
if (/Android/.test(ua)) return "Android 裝置";
if (/Mac/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows";
return "裝置";
},
};
guessDeviceLabel() 刻意粗糙——只給「iOS 裝置」這種粒度,不存完整 user agent。能用粗粒度就不用細粒度。
註冊完立刻 Sync.schedule():使用者可能已經離線讀了 20 課,註冊的第一件事就該是把那些推上雲端。
/* functions/api/auth/register-finish.js(核心驗證) */
async function verifyClientData(clientDataJSON, expectedType, expectedOrigin, env) {
const data = JSON.parse(new TextDecoder().decode(b64uDecode(clientDataJSON)));
/* 1. type 必須符合(防把 register 的回應拿去當 login 用) */
if (data.type !== expectedType) throw new Error("type mismatch");
/* 2. origin 必須是我的網域(防釣魚站轉發) */
if (data.origin !== expectedOrigin) throw new Error("origin mismatch");
/* 3. challenge 必須是我發出且尚未使用的(防重放) */
const stored = await env.KV.get(`challenge:${data.challenge}`);
if (!stored) throw new Error("unknown or expired challenge");
await env.KV.delete(`challenge:${data.challenge}`); // 一次性
return { challengeData: JSON.parse(stored), clientData: data };
}
第 2 項(origin 檢查)是 WebAuthn 抗釣魚的核心機制。就算使用者被騙到 1earnpath.example.com,那個站發起的 WebAuthn 請求會帶上它自己的 origin,我這邊驗證就失敗。這是密碼永遠做不到的事——密碼可以被輸入到任何地方,passkey 綁定在 origin 上。
第 4 件事是登入時的 sign_count:
/* login-finish 裡 */
if (authData.signCount > 0 && authData.signCount <= cred.sign_count)
throw new Error("possible cloned authenticator");
await env.DB.prepare("UPDATE credentials SET sign_count = ? WHERE cred_id = ?")
.bind(authData.signCount, credId).run();
signCount 單調遞增,倒退代表 credential 可能被複製。注意 signCount > 0 的守衛——很多平台認證器(Touch ID、Windows Hello)永遠回 0,那是規範允許的,不能因此拒絕。
async function createSession(userId, env) {
const token = b64u(crypto.getRandomValues(new Uint8Array(32)));
const hash = await sha256hex(token);
const now = Date.now(), exp = now + 90 * 86400_000; // 90 天
await env.DB.prepare(
"INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
.bind(hash, userId, now, exp).run();
return [
`sid=${token}`,
"Path=/",
"HttpOnly", // JS 讀不到 → XSS 偷不走
"Secure", // 只走 HTTPS
"SameSite=Strict", // 完全不跨站帶送 → CSRF 免疫
`Max-Age=${90 * 86400}`,
].join("; ");
}
四個屬性各擋一種攻擊:
| 屬性 | 擋什麼 |
|---|---|
HttpOnly |
XSS 讀取 cookie(Day 9/12 已經在防 XSS,這是第二層) |
Secure |
明文傳輸攔截 |
SameSite=Strict |
CSRF |
Max-Age |
無限期有效的 session |
SameSite=Strict 讓我完全不需要 CSRF token。 Day 25 的 form-action 'none' 是第一層(沒有表單能被注入),SameSite=Strict 是第二層(跨站請求不帶 cookie)。兩層都是零成本。
代價:從外部連結點進來的第一個請求不帶 cookie(會顯示未登入,重整就好)。對這個網站可以接受——如果是電商就不行,那時要用 Lax 加 CSRF token。
export async function requireUser(request, env) {
const cookie = request.headers.get("cookie") || "";
const m = cookie.match(/(?:^|;\s*)sid=([A-Za-z0-9_-]+)/);
if (!m) return null;
const hash = await sha256hex(m[1]);
const row = await env.DB
.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?")
.bind(hash).first();
if (!row || row.expires_at < Date.now()) return null;
return row.user_id;
}
這是合規基本盤,但更重要的是它是對使用者的基本尊重。
/* functions/api/account.js */
export async function onRequestDelete({ request, env }) {
const userId = await requireUser(request, env);
if (!userId) return json({ error: "unauthorized" }, 401);
/* ON DELETE CASCADE 會連帶刪除 credentials 與 sessions */
await env.DB.batch([
env.DB.prepare("DELETE FROM progress WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM credentials WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM sessions WHERE user_id = ?").bind(userId),
env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId),
]);
return new Response(null, {
status: 204,
headers: { "set-cookie": "sid=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0" },
});
}
export async function onRequestGet({ request, env }) { // 匯出
const userId = await requireUser(request, env);
if (!userId) return json({ error: "unauthorized" }, 401);
const [user, progress, creds] = await Promise.all([
env.DB.prepare("SELECT id, created_at FROM users WHERE id = ?").bind(userId).first(),
env.DB.prepare("SELECT lesson_key, done_at FROM progress WHERE user_id = ?").bind(userId).all(),
env.DB.prepare("SELECT cred_id, created_at, label FROM credentials WHERE user_id = ?").bind(userId).all(),
]);
return new Response(JSON.stringify({
user, progress: progress.results, credentials: creds.results, // 不含 public_key
}, null, 2), {
headers: {
"content-type": "application/json",
"content-disposition": 'attachment; filename="learnpath-account.json"',
"cache-control": "no-store",
},
});
}
真刪除,不是 soft delete。 我沒有任何業務理由保留已刪帳號的資料。soft delete 是「假裝刪除」——對使用者是不誠實的。
刪除是不可逆的,所以 UI 要有明確的二次確認:
async deleteAccount() {
const typed = prompt('這會永久刪除你在雲端的進度紀錄與 passkey,無法復原。\n' +
'(你瀏覽器裡的本地進度不會被刪除。)\n\n' +
'確認請輸入「刪除」:');
if (typed !== "刪除") return;
const res = await fetch("/api/account", { method: "DELETE", credentials: "same-origin" });
if (res.ok) { this.setSignedIn(false); alert("已刪除雲端資料。本地進度保留。"); }
}
「本地進度不會被刪除」這句話很重要——它是 Day 27「本地是真相」設計的自然結果,而且對使用者是好消息(刪除雲端不等於失去學習紀錄)。
Day 17 已經有「匯出本地備份」了。現在有兩個匯出:
| Day 17 | 今天 | |
|---|---|---|
| 來源 | localStorage | 雲端資料庫 |
| 內容 | 進度 + XP + 徽章 + 考試紀錄 | 進度 + 帳號 metadata |
| 用途 | 換瀏覽器、離線備份 | 「你到底存了我什麼」的透明度 |
兩個都保留。前者是實用功能,後者是可稽核性——使用者能確認我說的「只存這些」是真的。
Passkey 沒有「忘記密碼」。如果使用者的所有 passkey 都遺失,帳號就無法登入。
我不做「恢復碼」機制(那又是一個要安全保存的祕密),而是在 UI 上明確說明並提供替代方案:
⚠️ 關於帳號安全
• 這個網站用 passkey 登入,沒有密碼、也沒有你的 email。
• 好處:不會被密碼外洩影響、無法被釣魚。
• 限制:如果你的 passkey 全部遺失,我們無法幫你恢復帳號
(因為我們沒有任何能驗證你身分的資料)。
建議:
① 在你常用的第二台裝置也註冊一個 passkey(設定 → 我的裝置 → 新增)。
② 定期用「匯出備份」把進度存成檔案(設定 → 資料)。
有那個檔案,就算帳號無法恢復,進度也能匯入新帳號。
這是「資料最小化」的必然代價,要誠實說出來,而不是假裝沒有。 而 Day 17 的匯出功能剛好是這個限制的解方——這是兩天前的設計今天發揮的第二個作用。
/* 設定頁:列出我的 passkey */
const creds = await (await fetch("/api/auth/credentials", { credentials: "same-origin" })).json();
root.innerHTML = creds.map(c => `
<div class="dev-row">
<span class="dev-label">${esc(c.label || "未命名裝置")}</span>
<span class="dev-date">新增於 ${new Date(c.created_at).toLocaleDateString("zh-TW")}</span>
<button class="btn small" data-del="${esc(c.cred_id)}"
${creds.length === 1 ? "disabled title='這是最後一個 passkey,移除後將無法登入'" : ""}>移除</button>
</div>`).join("");
最後一個 passkey 不能移除(會把自己鎖在門外)。esc() 用在 label 上——那是唯一的使用者自由文字欄位(本系列第四次做這件事:Day 9 localStorage、Day 12 搜尋、Day 15 glossary、今天)。
rp.id 必須是當前網域或其父網域,不能是完整 URL。
rp: { id: "learnpath.example.com" } // ✅
rp: { id: "https://learnpath.example.com" } // ❌ SecurityError
而且 rp.id 一旦決定就不能改——改了之後所有既有 passkey 全部失效(它們綁定在舊的 rp.id 上)。所以:如果你有 www 與 apex 兩個網域,rp.id 要設父網域(example.com),否則從另一個網域登入會找不到 credential。這也讓 Day 23 的「apex 為主 + www 301」決定變得更重要——單一規範網域讓這件事單純很多。
localhost 可以測試,file:// 不行。 WebAuthn 需要 secure context。http://localhost 被視為安全(瀏覽器特例),但 file:// 沒有 origin,直接 throw。所以 Day 1 的「file:// 可直開」在這個功能上必然失效——我讓登入 UI 在非 secure context 下隱藏:
if (!window.isSecureContext || !window.PublicKeyCredential) {
document.getElementById("auth-section")?.classList.add("hidden");
return; // 網站其餘功能完全不受影響
}
Safari 要求 WebAuthn 必須由使用者手勢觸發。 不能在 DOMContentLoaded 裡自動呼叫 navigator.credentials.get()(會被拒絕)。必須是點擊事件的直接後果——而且不能在 await 之後呼叫(手勢的「新鮮度」會過期):
// ❌ Safari 會拒絕
btn.onclick = async () => {
const opts = await fetch(...); // await 之後手勢過期
await navigator.credentials.get(...);
};
// ✅ 先拿 options 再綁事件,或用 conditional UI
實務解法:頁面載入時就預先取好 challenge(放進記憶體),點擊時直接用。
SameSite=Strict 讓「從 email 連結點進來」顯示未登入。 這是預期行為但會被當成 bug 回報。我在 UI 加了一句提示:「從外部連結進入時可能需要重整一次」。
D1 的 ON DELETE CASCADE 需要明確開啟外鍵約束。 SQLite 預設不強制外鍵:
PRAGMA foreign_keys = ON;
D1 在每個連線都要設。所以我在 delete 的 batch 裡明確刪除每張表,不依賴 cascade——顯式優於隱式,特別是刪除這種不可逆操作。
安全性驗證(今天的重點):
# 1. session cookie 屬性齊全
curl -si https://learnpath.example.com/api/auth/login-finish -X POST -d '{}' \
| grep -i '^set-cookie'
# 應含 HttpOnly、Secure、SameSite=Strict
# 2. 未登入不能取資料
curl -s https://learnpath.example.com/api/sync -X POST -d '{}' -w '\n%{http_code}\n'
# 401
# 3. 偽造 cookie 無效
curl -s https://learnpath.example.com/api/sync -X POST \
-H 'cookie: sid=fake-token-aaaa' -d '{"changes":{}}' -w '\n%{http_code}\n'
# 401(因為存的是雜湊,偽造 token 對不上)
# 4. API 回應不得被快取
curl -sI https://learnpath.example.com/api/sync | grep -i cache-control
# no-store
# 5. 資料庫真的沒有個資
npx wrangler d1 execute learnpath-db --command "PRAGMA table_info(users);"
# 只有 id 與 created_at
功能驗證:
residentKey: "required" 的驗收點。)file:// 直開 → 登入區塊隱藏,其餘功能完整。釣魚測試(如果有第二個網域可以測):把前端部署到另一個網域,嘗試登入 → 應該失敗(origin mismatch)。這是 WebAuthn 最值得親眼看一次的性質。
今天的重點:
attestation: "none"、裝置標籤只到「iOS 裝置」粒度、資料庫零個資欄位。SameSite=Strict 讓 CSRF token 變得不必要。rp.id 不能改、file:// 不支援 WebAuthn、Safari 要求新鮮的使用者手勢。明天做可觀測性與成本控管——在不放任何第三方追蹤的前提下,怎麼知道網站有沒有壞、跑得快不快、花了多少錢。
iThome鐵人賽