總覽
以下概略說明註冊密碼金鑰時須採取的重要步驟:
- 定義建立密碼金鑰的選項。請將這些金鑰傳送給用戶端,以便將對方傳送到密碼金鑰建立呼叫:WebAuthn API 呼叫
navigator.credentials.create
(網頁版),以及 Android (Android) 呼叫credentialManager.createCredential
。使用者確認建立密碼金鑰後,系統會解決密碼金鑰建立呼叫並傳回憑證PublicKeyCredential
。 - 驗證憑證並儲存在伺服器上。
以下各節將深入說明每個步驟的詳細資訊。
建立憑證建立選項
您必須在伺服器上建立 PublicKeyCredentialCreationOptions
物件。
如要這麼做,請使用 FIDO 伺服器端程式庫。通常會提供公用程式函式,讓您建立這些選項。SimpleWebAuthn 提供範例,例如:generateRegistrationOptions
。
PublicKeyCredentialCreationOptions
應包含建立密碼金鑰所需的各項資訊:使用者相關資訊、RP 資訊,以及您所建立的憑證屬性的設定。定義完所有項目後,視需要將這些內容傳送至 FIDO 伺服器端程式庫中負責建立 PublicKeyCredentialCreationOptions
物件的函式。
PublicKeyCredentialCreationOptions
的部分欄位可以是常數。其他屬性則應在伺服器上動態定義:
rpId
:如要在伺服器上填入 RP ID,請使用可提供網頁應用程式主機名稱的伺服器端函式或變數,例如example.com
。user.name
和user.displayName
:如要填入這些欄位,請使用已登入使用者的工作階段資訊。如果使用者正在註冊時建立密碼金鑰,則請使用新的使用者帳戶資訊。user.name
通常是電子郵件地址,而且對於 RP 而言是專屬值。user.displayName
是易記的名稱。請注意,並非所有平台都會使用displayName
。user.id
:建立帳戶時產生的隨機不重複字串。這個名稱必須永久有效,與可編輯的使用者名稱不同。User ID 可用來識別帳戶,但不得含有任何個人識別資訊 (PII)。系統中可能已有使用者 ID,但您可以視需要建立使用者 ID,專門用於密碼金鑰,避免產生任何個人識別資訊。excludeCredentials
:現有憑證 ID 清單,避免與密碼金鑰提供者複製密碼金鑰。如要填入這個欄位,請在資料庫中查詢這位使用者的現有憑證。詳情請參閱「禁止建立新的密碼金鑰 (如果有的話)」。challenge
:如要註冊憑證,就必須使用認證 (此為更進階的技巧,用於驗證密碼金鑰提供者的身分及其發出的資料),否則這項驗證問題與此無關。然而,即使您並未使用認證,挑戰仍是必要欄位。在這種情況下,為了簡單起見,您可以將這項挑戰設為單一0
。如需建立安全驗證方式的操作說明,請參閱「伺服器端密碼金鑰驗證」一文。
編碼和解碼
PublicKeyCredentialCreationOptions
包含 ArrayBuffer
的欄位,因此 JSON.stringify()
不支援這些欄位。也就是說,目前如要透過 HTTPS 提供 PublicKeyCredentialCreationOptions
,部分欄位必須在伺服器上使用 base64URL
手動編碼,然後在用戶端上解碼。
- 在伺服器上,編碼和解碼作業通常是由 FIDO 伺服器端程式庫負責。
- 在用戶端,目前需手動進行編碼和解碼。日後會變得更加簡單:只要使用 JSON 格式,即可將選項轉換為
PublicKeyCredentialCreationOptions
。方法您可以在 Chrome 中查看導入狀態。
範例程式碼:建立憑證建立選項
我們在範例中使用了 SimpleWebAuthn 程式庫。接下來,我們將把公開金鑰憑證選項的建立流程交給其 generateRegistrationOptions
函式。
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
const { user } = res.locals;
// Ensure you nest verification function calls in try/catch blocks.
// If something fails, throw an error with a descriptive error message.
// Return that message with an appropriate error code to the client.
try {
// `excludeCredentials` prevents users from re-registering existing
// credentials for a given passkey provider
const excludeCredentials = [];
const credentials = Credentials.findByUserId(user.id);
if (credentials.length > 0) {
for (const cred of credentials) {
excludeCredentials.push({
id: isoBase64URL.toBuffer(cred.id),
type: 'public-key',
transports: cred.transports,
});
}
}
// Generate registration options for WebAuthn create
const options = generateRegistrationOptions({
rpName: process.env.RP_NAME,
rpID: process.env.HOSTNAME,
userID: user.id,
userName: user.username,
userDisplayName: user.displayName || '',
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: true
},
});
// Keep the challenge in the session
req.session.challenge = options.challenge;
return res.json(options);
} catch (e) {
console.error(e);
return res.status(400).send({ error: e.message });
}
});
儲存公開金鑰
如果 navigator.credentials.create
在用戶端成功解析,表示已成功建立密碼金鑰。系統會傳回 PublicKeyCredential
物件。
PublicKeyCredential
物件包含 AuthenticatorAttestationResponse
物件,代表密碼金鑰提供者對用戶端操作說明建立密碼金鑰的回應。當中包含新憑證資訊,您必須做為 RP,以便日後驗證使用者。如要進一步瞭解 AuthenticatorAttestationResponse
,請參閱附錄:AuthenticatorAttestationResponse
。
將 PublicKeyCredential
物件傳送至伺服器。收到驗證後,請進行驗證。
請將這個驗證步驟交給 FIDO 伺服器端程式庫。通常會為此提供公用程式函式。SimpleWebAuthn 提供範例,例如:verifyRegistrationResponse
。如要瞭解運作原理,請參閱附錄:註冊回應驗證。
驗證成功後,請將憑證資訊儲存在資料庫中,方便使用者日後使用與該憑證相關聯的密碼金鑰進行驗證。
使用專屬資料表,查看與密碼金鑰相關聯的公開金鑰憑證。使用者只能設定一組密碼,但可以有多個密碼金鑰,例如透過 Apple iCloud 鑰匙圈同步處理密碼金鑰,以及透過 Google 密碼管理工具同步處理一組密碼金鑰。
以下結構定義範例可以用來儲存憑證資訊:
- 「使用者」資料表:
user_id
:主要使用者 ID。使用者的隨機唯一永久 ID。將此項目做為「使用者」資料表的主鍵。username
。使用者定義的使用者名稱,可能可供編輯。passkey_user_id
:密碼金鑰專屬且不含個人識別資訊的使用者 ID,在註冊選項中以user.id
表示。使用者之後嘗試進行驗證時,驗證器會在userHandle
中的驗證回應中提供這個passkey_user_id
。建議您不要將passkey_user_id
設為主鍵。主鍵在系統中廣泛使用,因此往往會變成真實的 PII。
- 「公開金鑰憑證」資料表:
id
:憑證 ID。將其做為「公開金鑰憑證」資料表的主鍵。public_key
:憑證的公開金鑰。passkey_user_id
:將此項目當做外鍵,與「使用者」資料表建立連結。backed_up
:如果密碼金鑰已由密碼金鑰提供者同步處理,系統會備份密碼金鑰。如果您日後想為擁有backed_up
密碼金鑰的使用者捨棄密碼,建議您儲存備份狀態。如要檢查密碼金鑰是否已備份,您可以查看authenticatorData
中的旗標,或使用通常可用於輕鬆存取這項資訊的 FIDO 伺服器端程式庫功能。儲存備份資格有助於解決潛在的使用者問題。name
:(選用) 提供憑證的顯示名稱,可讓使用者提供憑證自訂名稱。transports
:傳輸陣列。儲存傳輸資訊對於驗證使用者體驗來說相當實用。如果有傳輸機制可用,瀏覽器就能據此行為,並顯示與密碼金鑰提供者用於與用戶端通訊的傳輸相符的 UI (尤其是在allowCredentials
不是空白的重新驗證用途中)。
您可以儲存其他資訊,以便提供使用者體驗,包括密碼金鑰提供者、憑證建立時間和上次使用時間等項目。詳情請參閱「密碼金鑰使用者介面設計」。
範例程式碼:儲存憑證
我們在範例中使用了 SimpleWebAuthn 程式庫。
以下,我們將註冊回應的驗證工作交給其 verifyRegistrationResponse
函式。
import { isoBase64URL } from '@simplewebauthn/server/helpers';
router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
const expectedChallenge = req.session.challenge;
const expectedOrigin = getOrigin(req.get('User-Agent'));
const expectedRPID = process.env.HOSTNAME;
const response = req.body;
// This sample code is for registering a passkey for an existing,
// signed-in user
// Ensure you nest verification function calls in try/catch blocks.
// If something fails, throw an error with a descriptive error message.
// Return that message with an appropriate error code to the client.
try {
// Verify the credential
const { verified, registrationInfo } = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
requireUserVerification: false,
});
if (!verified) {
throw new Error('Verification failed.');
}
const { credentialPublicKey, credentialID } = registrationInfo;
// Existing, signed-in user
const { user } = res.locals;
// Save the credential
await Credentials.update({
id: base64CredentialID,
publicKey: base64PublicKey,
// Optional: set the platform as a default name for the credential
// (example: "Pixel 7")
name: req.useragent.platform,
transports: response.response.transports,
passkey_user_id: user.passkey_user_id,
backed_up: registrationInfo.credentialBackedUp
});
// Kill the challenge for this session
delete req.session.challenge;
return res.json(user);
} catch (e) {
delete req.session.challenge;
console.error(e);
return res.status(400).send({ error: e.message });
}
});
附錄:AuthenticatorAttestationResponse
AuthenticatorAttestationResponse
包含兩個重要物件:
response.clientDataJSON
是用戶端資料的 JSON 版本,其中的資料是瀏覽器所顯示的資料。其中包含 RP 來源、驗證問題和androidPackageName
(如果用戶端是 Android 應用程式)。您可以將clientDataJSON
設為 RP,藉此取得瀏覽器在create
要求執行時看到的資訊。response.attestationObject
包含兩項資訊:attestationStatement
,除非您使用認證,否則這與無關。authenticatorData
是密碼金鑰提供者所顯示的資料。作為 RP,讀取authenticatorData
可讓您存取密碼金鑰供應者看到的資料,並在執行create
要求時傳回。
authenticatorData
包含與新建密碼金鑰相關聯的公開金鑰憑證重要資訊:
- 公開金鑰憑證本身,以及專屬憑證 ID。
- 與憑證相關聯的 RP ID。
- 此標記說明密碼金鑰建立時間的使用者狀態:使用者是否確實存在,以及使用者是否已成功驗證 (請參閱
userVerification
)。 - AAGUID,用於識別密碼金鑰提供者。如果使用者已向多個密碼金鑰供應商註冊服務密碼金鑰,更適合採用密碼金鑰提供者顯示這項做法。
雖然 authenticatorData
以巢狀結構在 attestationObject
內,但無論您是否使用認證,都必須提供密碼金鑰實作中包含的資訊。authenticatorData
經過編碼,且其中包含以二進位格式編碼的欄位。伺服器端程式庫通常會處理剖析和解碼作業。如果您不是使用伺服器端程式庫,請考慮利用 getAuthenticatorData()
用戶端節省一些在伺服器端剖析及解碼作業。
附錄:註冊回應驗證
基本上,驗證註冊回應包含下列檢查:
- 確認 RP ID 與你的網站相符。
- 確認要求的來源是網站 (主網站網址、Android 應用程式) 的來源。
- 如果您需要進行使用者驗證,請確認使用者驗證旗標
authenticatorData.uv
為true
。檢查使用者狀態標記authenticatorData.up
是否為true
,因為密碼金鑰一律必須向使用者顯示。 - 確認客戶能夠提供您提供的驗證資訊。如果您不使用認證,這項檢查就不重要。不過,實作這項檢查是最佳做法,因為如果日後決定使用認證,這項檢查可確保程式碼已準備就緒。
- 確認尚未為任何使用者註冊憑證 ID。
- 確認密碼金鑰供應商用來建立憑證的演算法是你列出的演算法 (在
publicKeyCredentialCreationOptions.pubKeyCredParams
的每個alg
欄位中,通常在伺服器端定義,並不會向你顯示)。這可確保使用者只能透過您選擇允許的演算法進行註冊。
詳情請參閱 SimpleWebAuthn 的 verifyRegistrationResponse
原始碼,或查閱規格的完整清單。