這是我們後端開發的第一步:將 API 規格轉化為可運行的 Serverless Function。今天的目標是實現 POST /generate-registration-options
這個端點。
根據我們在 Day 06 的設計,此 API 的職責如下:
@simplewebauthn/server
函式庫,生成一組包含安全 challenge
的註冊選項。challenge
與使用者資訊暫存,以待後續驗證。我們將一步步在 Firebase Functions 中完成這些邏輯。
為了讓我們的 Function 能夠與 Firestore 互動,我們需要在程式碼中初始化 Firebase Admin SDK。Admin SDK 讓我們能以後端管理員權限安全地存取 Firebase 服務,這將繞過我們在 Day 09 中為客戶端設定的嚴格安全規則。
在 functions/src/index.ts
的頂部加入初始化程式碼:
// functions/src/index.ts
import * as functions from "firebase-functions/v2";
import * as admin from "firebase-admin";
// 在所有 function 定義之前,初始化 Admin SDK
// Firebase 環境會自動提供必要的設定,因此不需傳入參數
admin.initializeApp();
const db = admin.firestore();
// ... Day 10 中定義的 rpID, rpName, expectedOrigin 等變數 ...
現在,我們來建立主要的 Function。我們將使用 https.onRequest
來建立一個標準的 HTTP 端點。
第一步是接收請求、驗證輸入,並檢查使用者是否存在於 Firestore 中。
// functions/src/index.ts
// ... 省略頂部 import 和配置 ...
export const generateRegistrationOptions = functions.https.onRequest(
{ region: "asia-east1", cors: [expectedOrigin] }, // 設置 CORS 允許前端來源
async (request, response) => {
// 1. 驗證請求方法與 Body 內容
if (request.method !== 'POST') {
response.status(405).send('Method Not Allowed');
return;
}
const { email, displayName } = request.body;
if (!email || !displayName) {
response.status(400).send('Missing email or displayName');
return;
}
// 2. 檢查使用者是否已存在
const usersRef = db.collection('users');
const userQuery = await usersRef.where('email', '==', email).get();
if (!userQuery.empty) {
response.status(409).send(`User with email ${email} already exists.`);
return;
}
// ... 後續邏輯將在此處添加 ...
}
);
cors: [expectedOrigin]
,這是一個重要的安全設置,它告知瀏覽器只允許來自我們前端網域的跨來源請求。確認是新使用者後,我們就可以呼叫 @simplewebauthn/server
來生成註冊選項了。
WebAuthn 規範要求每個使用者都有一個穩定且唯一的 id
,並且這個 id
必須是二進位格式 (Uint8Array
)。對於新使用者,我們可以使用一個隨機生成的 ID。
import { generateRegistrationOptions as generateOptions } from '@simplewebauthn/server';
import { randomBytes } from 'crypto';
// ... 在 generateRegistrationOptions function 內部,接續使用者查核邏輯 ...
// 3. 為新使用者生成唯一的 ID
const userId = randomBytes(32); // 生成 32 bytes 的隨機 ID
// 4. 呼叫函式庫生成選項
const options = await generateOptions({
rpName,
rpID,
userID: userId,
userName: email, // 建議使用不易變動的 email 作為 userName
userDisplayName: displayName,
// 告知客戶端我們偏好的加密演算法
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' }, // RS256
],
timeout: 60000,
attestationType: 'none', // 'none' 是最簡單的形式,不要求驗證器提供詳細的製造商證明
});
// ... 後續邏輯 ...
userId
: 我們使用 Node.js 內建的 crypto
模組來生成一個高熵的隨機 ID。generateOptions
: 我們傳入 Day 10 設定的 RP 資訊,以及本次請求的使用者資訊。函式庫會為我們生成一個包含 challenge
的複雜物件 options
。如 Day 10 所述,challenge
必須被暫存起來,以便在下一步的驗證 API 中進行比對。我們將其存入 Firestore 的一個臨時集合 registrationChallenges
中。
// ... 在 generateRegistrationOptions function 內部,接續選項生成邏輯 ...
// 5. 暫存 challenge 與使用者資訊
const challengeRef = db.collection('registrationChallenges').doc(email);
await challengeRef.set({
email,
userId: Buffer.from(userId).toString('base64'), // 將 Uint8Array 轉為 Base64 儲存
challenge: options.challenge, // options.challenge 已是 Base64URL 字串
createdAt: new Date(),
});
// 6. 將選項回傳給客戶端
// 函式庫已自動將 options 中的二進位數據 (如 challenge, user.id) 編碼為 Base64URL
response.status(200).json(options);
email
作為文件 ID,將 challenge
和新生成的 userId
(轉換為 Base64 字串以便儲存) 存入 Firestore。我們也加入 createdAt
時間戳,這在未來可用於清除過期的 challenge
。options
物件直接以 JSON 格式回傳給客戶端。@simplewebauthn/server
已貼心地將所有需要透過 JSON 傳輸的二進位數據(如 challenge
和 user.id
)編碼為 Base64URL 格式,前端可以直接使用。今天,我們成功地將 Day 06 的 API 規格轉化為一個完整、可運行的 Serverless Function。這個 API 端點完成了使用者查核、安全選項生成、以及 challenge
暫存等關鍵任務,為 FIDO 註冊流程提供了安全的起點。
客戶端現在已經擁有啟動使用者裝置上驗證器 (Authenticator) 所需的一切資訊。我們的下一個任務,將是建立註冊流程的第二部分:POST /verify-registration
API。這將是我們第一次處理來自驗證器的密碼學回應,並在驗證成功後,於 Firestore 中正式創建使用者與憑證記錄的時刻。