總覽
以下概略說明註冊密碼金鑰時須採取的重要步驟:
- 定義建立密碼金鑰的選項。將參數傳送給用戶端,以便將這些內容傳遞至密碼金鑰建立呼叫:WebAuthn API 在網站上呼叫
navigator.credentials.create
,Android 則呼叫credentialManager.createCredential
。使用者確認建立密碼金鑰後,系統就會解析密碼金鑰建立呼叫,並傳回憑證PublicKeyCredential
。 - 驗證憑證並儲存在伺服器上。
以下各節將深入說明每個步驟。
建立憑證建立選項
要對伺服器採取的第一步,就是建立 PublicKeyCredentialCreationOptions
物件。
如要執行這項作業,請使用 FIDO 伺服器端程式庫。通常會提供公用程式函式,可為您建立這些選項。SimpleWebAuthn 提供的方案,例如 generateRegistrationOptions
。
「PublicKeyCredentialCreationOptions
」應包含建立密碼金鑰所需的一切資訊,包括使用者相關資訊、RP 資訊,以及所建立憑證的屬性設定。定義所有上述選項後,請視需要將其傳遞至負責建立 PublicKeyCredentialCreationOptions
物件的 FIDO 伺服器端程式庫中的函式。
部分「PublicKeyCredentialCreationOptions
」欄位可以是常數。其他值則必須在伺服器上動態定義:
rpId
:如要在伺服器上填入 RP ID,請使用伺服器端函式或變數提供網頁應用程式主機名稱,例如example.com
。user.name
和user.displayName
:如要填入這些欄位,請使用已登入使用者的工作階段資訊。如果使用者在註冊時建立密碼金鑰,則使用新的使用者帳戶資訊。user.name
通常是電子郵件地址,對 RP 而言是獨一無二的。user.displayName
是易記的名稱。請注意,並非所有平台都使用displayName
。user.id
:建立帳戶時產生的隨機不重複字串。此名稱必須永久有效,不像使用者名稱可以修改。User ID 可用來識別帳戶,但不得包含任何個人識別資訊 (PII)。您的系統中可能已有使用者 ID,但您也可以視需要為密碼金鑰建立專屬的 ID,避免含有 PII。excludeCredentials
:現有憑證的清單以便防止複製密碼金鑰提供者的密碼金鑰。如要填入這個欄位,請在資料庫中查詢這位使用者的現有憑證。詳情請參閱「如果已有密碼金鑰,就禁止建立新的密碼金鑰」一文。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 密碼管理工具。
以下是可用來儲存憑證資訊的結構定義範例:
- 「Users」(使用者) 資料表:
user_id
:主要使用者 ID。使用者的隨機永久 ID。使用此鍵做為「使用者」資料表的主鍵。username
。使用者定義的使用者名稱,可能可以編輯。passkey_user_id
:密碼金鑰專屬 PII 使用者 ID,在註冊選項中以user.id
表示。使用者日後嘗試進行驗證時,驗證器會將這個passkey_user_id
出現在userHandle
的驗證回應中。建議您不要將passkey_user_id
設為主鍵。主鍵廣受使用,因此在系統中往往會成為真正的 PII。
- 公開金鑰憑證資料表:
id
:憑證 ID。將這組金鑰做為「Public key credentials」(公開金鑰憑證) 資料表的主金鑰。public_key
:憑證的公開金鑰。passkey_user_id
:做為外鍵,即可建立連結至「使用者」資料表的連結。backed_up
:如果密碼金鑰已與密碼金鑰供應商同步處理,系統就會備份密碼金鑰。如果您日後想要考慮為持有backed_up
密碼金鑰的使用者捨棄密碼,建議您儲存備份狀態。如要確認密碼金鑰是否已備份,請查看authenticatorData
中的旗標,或使用一般的 FIDO 伺服器端程式庫功能,方便您存取這些資訊。儲存備份資格條件有助於解決使用者可能提出的問題。name
(選用):可供使用者提供憑證自訂名稱的憑證顯示名稱。transports
:傳輸的陣列。儲存傳輸資訊對於驗證使用者體驗來說非常實用。可使用傳輸功能時,瀏覽器可根據密碼金鑰行為,並顯示與密碼金鑰供應器用於用戶端通訊的傳輸相符的使用者介面,尤其是重新驗證用途 (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 來源、挑戰,如果用戶端是 Android 應用程式,則會包含androidPackageName
。建議您身為 RP,clientDataJSON
可讓您存取瀏覽器在發出create
要求時看到的資訊。response.attestationObject
包含兩項資訊:attestationStatement
,除非您使用認證,否則並不相關。authenticatorData
是密碼金鑰供應商查看的資料。您可以藉由閱讀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
原始碼,或是參閱規格中完整的驗證清單。