iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 28

Day 28 — 跨裝置同步(二):Passkey 登入與資料保護

  • 分享至 

  • xImage
  •  

今天要解的問題

Day 27 的同步 API 假設了一個 requireUser(request, env)。今天把它實作出來。

需求很不典型,所以值得先講清楚:

  • 不想要使用者的 email。
  • 不想要使用者的名字、頭像、生日。
  • 只需要一個穩定的識別碼,讓同一個人的不同裝置能對上。

換句話說:我要的是「這是同一個人」,不是「這個人是誰」。

這個需求排除了大部分常見方案。

方案比較

方案 我需要保存什麼 問題
Email + 密碼 email、密碼雜湊 要做寄信、重設密碼、密碼強度…而且我拿到了不需要的 email
OAuth(Google/GitHub) provider id(+ 幾乎必然拿到 email) 依賴第三方、使用者的閱讀紀錄與真實身分掛鉤
Magic link email 同上,還是要寄信
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,那是規範允許的,不能因此拒絕。

Session:httpOnly cookie

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

功能驗證:

  • 在 Mac 上用 Touch ID 註冊 → 完成幾課 → 同步。
  • 在 iPhone 上點「登入」→ 不需要輸入任何識別碼,直接出現 passkey → 登入 → 進度出現。(這是 residentKey: "required" 的驗收點。)
  • 移除倒數第二個 passkey → 可以;移除最後一個 → 按鈕 disabled。
  • 匯出帳號資料 → 檢查 JSON 確實只有 id、時間戳、進度
  • 刪除帳號 → 雲端資料消失、cookie 清除、本地進度仍在
  • 登出後網站功能完全正常(Day 27 的回歸)。
  • file:// 直開 → 登入區塊隱藏,其餘功能完整。

釣魚測試(如果有第二個網域可以測):把前端部署到另一個網域,嘗試登入 → 應該失敗(origin mismatch)。這是 WebAuthn 最值得親眼看一次的性質。

小結與明天預告

今天的重點:

  1. 需求決定方案:我要的是「同一個人」不是「這個人是誰」,所以 passkey 勝過 OAuth 與 email。
  2. 不收集就不會洩漏attestation: "none"、裝置標籤只到「iOS 裝置」粒度、資料庫零個資欄位。
  3. WebAuthn 必驗四件事:type、origin、challenge 一次性、signCount 不倒退。origin 檢查是抗釣魚的核心。
  4. session token 存雜湊不存原文,四個 cookie 屬性各擋一種攻擊。SameSite=Strict 讓 CSRF token 變得不必要。
  5. 真刪除不做 soft delete;匯出讓「只存這些」變成可稽核的。
  6. 資料最小化的代價要誠實說明(無法帳號恢復),並提供替代方案(Day 17 的匯出)。
  7. rp.id 不能改、file:// 不支援 WebAuthn、Safari 要求新鮮的使用者手勢。

明天做可觀測性與成本控管——在不放任何第三方追蹤的前提下,怎麼知道網站有沒有壞、跑得快不快、花了多少錢。


上一篇
Day 27 — 跨裝置同步(一):Serverless 後端與衝突合併
下一篇
Day 29 — 可觀測性與成本控管
系列文
知識圖譜 : 技能樹式學習歷程29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言