我們已經擁有 Serverless 執行環境 (Day 08) 和高安全性的資料庫 (Day 09)。現在,我們需要實現 API 規格中定義的核心業務邏輯:生成註冊/驗證選項,並驗證來自客戶端的加密回應。
這項任務涉及大量的密碼學操作與對 WebAuthn 規範的精確實現。直接從零開始編寫這些邏輯不僅耗時,且極易引入安全漏洞。因此,業界的最佳實踐是採用經過良好審查和維護的開源函式庫。
今天的目標是:
在資安工程領域,一個黃金法則是「不要自己造輪子,尤其是密碼學的輪子 (Don't roll your own crypto)」。對於 WebAuthn,這點尤其適用,原因如下:
attestationObject
和 authenticatorData
。這些格式的解析必須精確到每一個位元組,任何錯誤都可能導致驗證失敗或安全繞過。packed
, fido-u2f
, tpm
等),用以驗證驗證器的來源與屬性。每種格式都有其獨特的驗證邏輯。一個成熟的函式庫會為我們處理這些差異。總結來說,採用一個受信任的函式庫,是確保我們後端實現的安全性、合規性與穩健性的最有效途徑。
在 Node.js/TypeScript 生態系中,有幾個廣受好評的 FIDO2/WebAuthn 函式庫可供選擇。我們主要評估以下兩個:
特性 | @simplewebauthn/server | fido2-lib |
---|---|---|
語言支援 | TypeScript 原生 | JavaScript (提供型別定義檔) |
API 設計 | 現代化,基於 Promise 和 async/await | 功能完整,API 相對傳統 |
活躍度 | 非常活躍,緊跟最新標準 | 成熟穩定,維護中 |
文件 | 清晰易懂,提供大量範例 | 較為技術性,偏向規格文件 |
優點 | 1. 型別安全,與我們的技術棧完美契合。<br>2. API 設計直觀,學習曲線平緩。<br>3. 輕量級,專注於核心 WebAuthn 流程。 | 1. 非常成熟,經過大量生產環境驗證。<br>2. 支援廣泛的證明格式與擴充功能。 |
缺點 | 對於一些極其罕見的擴充功能支援可能不如 fido2-lib 全面。 |
對於 TypeScript 開發者,整合體驗可能稍遜一籌。 |
決策:
在本專案中,我們選擇 @simplewebauthn/server
。
理由:
安裝:
現在,將此函式庫安裝到我們的 Firebase Functions 專案中。打開終端機,進入 functions
目錄並執行:
cd functions
npm install @simplewebauthn/server
好的,這是一個非常好的問題。深入理解函式庫的架構與整合方式,是確保我們能正確、安全地使用它的關鍵。
我將完全重寫並擴充 Day 10 的第四章節,詳細闡述 @simplewebauthn/server
的運作模式、初始化配置,以及最重要的——它如何與我們在 Day 09 設計的 Firestore 資料庫結構進行串接。
選擇 @simplewebauthn/server
之後,我們需要理解其核心設計哲學,才能正確地將它整合到我們的 Serverless 架構中。
@simplewebauthn/server
本身是一個無狀態 (Stateless) 的函式庫。這意味著它不會為我們儲存任何會話 (Session) 資訊或數據。它的核心職責是:
這種無狀態的設計對於 Firebase Functions 這類 Serverless 環境是絕佳的,因為函式的執行個體可能是短暫的。我們(開發者)的責任,就是提供函式庫所需的上下文(Context),並處理它的輸出結果(例如,將新憑證存入 Firestore)。
整個互動流程可以被視為我們應用程式與函式庫之間的一系列精確的「問答」:
函式庫的配置並非一個全域性的 init()
呼叫,而是在每次呼叫其函式時,將 RP (信賴方) 的相關資訊作為參數傳入。這再次強化了其無狀態的特性。
我們首先在 functions/src/index.ts
的頂部定義這些共用配置:
// functions/src/index.ts
// ... 其他 import ...
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoUint8Array } from '@simplewebauthn/server/helpers';
// 1. Relying Party (RP) 配置
// RP ID 必須是使用者在瀏覽器地址欄中看到的有效域名,但不包含協議或端口。
// 根據規範,它通常是 eTLD+1 (例如 'google.com' 而非 'accounts.google.com')。
// 'localhost' 是開發時的特例。
const rpID = process.env.NODE_ENV === 'production'
? 'your-production-domain.com' // TODO: 部署前替換成你的域名
: 'localhost';
const rpName = 'FIDO 零信任鐵人賽';
// 2. 預期的來源 (Origin)
// 這是完整的來源 URL,瀏覽器會嚴格比對此值以防禦釣魚攻擊。
const expectedOrigin = process.env.NODE_ENV === 'production'
? `https://${rpID}`
: `http://${rpID}:5000`; // 假設本地 Web 開發伺服器運行在 5000 port
這是整個後端實作的核心。我們需要明確定義函式庫與 Firestore 之間的數據流動。
1. 註冊流程 (Registration) 的數據串接
生成選項: 當我們的 API (/generate-registration-options
) 被呼叫時:
@simplewebauthn/server
的 generateRegistrationOptions()
函式,傳入 rpID
、rpName
和使用者資訊。challenge
的選項物件。challenge
。在 Serverless 環境下,一個可靠的做法是將 challenge
與使用者 ID、過期時間一同寫入 Firestore 的一個臨時集合中(例如 /challenges/{randomId}
)。驗證回應: 當我們的驗證 API (/verify-registration
) 被呼叫時:
challenge
。@simplewebauthn/server
的 verifyRegistrationResponse()
,並將以下資訊作為參數傳入:
attestationResponse
。expectedChallenge
。expectedOrigin
和 rpID
。verified: true
以及一個 registrationInfo
物件。registrationInfo
中的數據持久化到 Firestore。這一步是將理論與實踐串連起來的關鍵:
// 假設 'registrationInfo' 是驗證成功後的回傳物件
if (verified && registrationInfo) {
const { credentialID, credentialPublicKey, counter } = registrationInfo;
// 根據 Day 09 的設計,建立一個新的 document
// credentialID 是一個 Uint8Array,需要轉換成 Base64 字串儲存
const newCredential = {
publicKey: Buffer.from(credentialPublicKey).toString('base64'),
signCount: counter,
transports: response.response.transports || [], // 從客戶端回應中取得
createdAt: new Date(),
};
const credentialIdBase64 = Buffer.from(credentialID).toString('base64');
// 寫入路徑:/users/{userId}/credentials/{credentialIdBase64}
await db.collection('users').doc(userId).collection('credentials').doc(credentialIdBase64).set(newCredential);
}
2. 驗證流程 (Authentication) 的數據串接
生成選項: 當我們的 API (/generate-authentication-options
) 被呼叫時:
/users/{userId}/credentials
子集合下的所有憑證文件,取得它們的 ID。generateAuthenticationOptions()
,並將從 Firestore 查到的憑證 ID 列表傳入 allowCredentials
參數。challenge
。驗證回應: 當我們的驗證 API (/verify-authentication
) 被呼叫時:
credentialID
,並用它在 Firestore 中找到對應的憑證文件。publicKey
和 signCount
。challenge
。verifyAuthenticationResponse()
,並將以下關鍵資訊傳入:
assertionResponse
。expectedChallenge
、expectedOrigin
、rpID
。signCount
以及 publicKey
。signCount
(新的計數器必須大於舊的)。signCount
。這是防止重放攻擊的關鍵閉環:
// 假設 'verification' 是驗證成功後的回傳物件
if (verified) {
const { newCounter } = verification.authenticationInfo;
// 更新 Firestore 中對應憑證的 signCount
await db.collection('users').doc(userId).collection('credentials').doc(credentialIdBase64).update({
signCount: newCounter
});
}
今天,我們深入剖析了 @simplewebauthn/server
的無狀態架構,並制定了其與 Firestore 互動的完整策略。我們明確了開發者的職責——管理狀態(challenge
)與數據持久化(儲存憑證、更新 signCount
),以及函式庫的職責——處理所有複雜的密碼學驗證。
這個清晰的職責劃分與數據流動模型,是我們接下來實作具體 API 端點的基礎。我們現在已經準備好,將開始編寫第一個真正的 FIDO API 端點。