伺服器端密碼金鑰驗證

總覽

以下概略說明密碼金鑰驗證須採取的重要步驟:

密碼金鑰驗證流程

  • 定義使用密碼金鑰進行驗證所需的驗證問題和其他選項。請將這些資訊傳送給用戶端,以便將這些資訊傳送到用戶端驗證通話 (navigator.credentials.get 網頁版)。使用者確認密碼金鑰驗證後,系統會解析密碼金鑰驗證呼叫並傳回憑證 (PublicKeyCredential)。憑證會包含驗證宣告
  • 驗證驗證宣告。
  • 如果驗證宣告有效,請驗證使用者。

以下各節將深入說明每個步驟的詳細資訊。

建立挑戰

實際上,驗證問題是由隨機位元組陣列,以 ArrayBuffer 物件表示。

// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8

為確保挑戰能實現目標,您必須:

  1. 確認同一驗證機制不會重複使用。每次嘗試登入時都會產生新的驗證問題。每次嘗試登入後捨棄驗證,不論成功與否。在一段時間後捨棄挑戰。請勿在回覆中重複接受相同的挑戰。
  2. 確認驗證方式經過加密處理。挑戰內容應該幾乎不可能猜測。.如要在伺服器端建立加密編譯安全驗證問題,最好使用您信任的 FIDO 伺服器端程式庫。如要自行設計挑戰,請使用技術堆疊中內建的加密編譯功能,或尋找專為加密編譯用途設計的程式庫。例如 Node.js 中的 iso-crypto 或 Python 中的 secrets。根據規格,挑戰長度必須至少為 16 個位元組,才視為安全。

建立挑戰後,請將其儲存在使用者的工作階段中,以便日後驗證。

建立憑證要求選項

建立憑證要求選項做為 publicKeyCredentialRequestOptions 物件。

如要這麼做,請使用 FIDO 伺服器端程式庫。通常會提供公用程式函式,讓您建立這些選項。SimpleWebAuthn 提供範例,例如:generateAuthenticationOptions

publicKeyCredentialRequestOptions 應包含驗證密碼金鑰所需的一切資訊。將這項資訊傳遞至 FIDO 伺服器端程式庫中負責建立 publicKeyCredentialRequestOptions 物件的函式。

publicKeyCredentialRequestOptions 的部分欄位可以是常數。其他屬性則應在伺服器上動態定義:

  • rpId:預期與憑證相關聯的 RP ID,例如 example.com。你在這裡提供的 RP ID 必須與憑證相關聯的 RP ID 相符,驗證程序才能成功。如要填入 RP ID,請使用您在 publicKeyCredentialCreationOptions 註冊憑證時所設定的 RP ID 值。
  • challenge:密碼金鑰供應商在要求驗證時要簽署的一段資料,用來證明使用者持有密碼金鑰。查看建立挑戰瞭解詳情。
  • allowCredentials:此驗證可接受的憑證陣列。傳遞空白陣列,讓使用者從瀏覽器顯示的清單中選取可用的密碼金鑰。詳情請參閱「從 RP 伺服器擷取驗證問題」和「可探索憑證深入探索」。
  • userVerification:指出使用裝置螢幕鎖定功能的使用者驗證是否為「必要」、「建議」或「不建議」。參閱從 RP 伺服器擷取驗證問題
  • timeout:使用者完成驗證所需的時間 (以毫秒為單位)。這個值應該合理較大,且比 challenge 的生命週期短。建議的預設值是 5 分鐘,但您可以視需要增加 10 分鐘 (仍然在建議範圍內)。如果預期使用者會使用混合式工作流程,而逾時設定就很合理,因為這類工作流程通常需要較長時間。如果作業逾時,系統會擲回 NotAllowedError

建立 publicKeyCredentialRequestOptions 後,請將其傳送給用戶端。

伺服器傳送 publicKeyCredentialCreationOptions
伺服器傳送的選項。challenge 解碼是在用戶端進行。

程式碼範例:建立憑證要求選項

我們在範例中使用了 SimpleWebAuthn 程式庫。以下將建立憑證要求選項至其 generateAuthenticationOptions 函式。

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';

router.post('/signinRequest', csrfCheck, async (req, res) => {

  // Ensure you nest 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 {
    // Use the generateAuthenticationOptions function from SimpleWebAuthn
    const options = await generateAuthenticationOptions({
      rpID: process.env.HOSTNAME,
      allowCredentials: [],
    });
    // Save the challenge in the user session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).json({ error: e.message });
  }
});

驗證並登入使用者

navigator.credentials.get 在用戶端成功解析時,會傳回 PublicKeyCredential 物件。

伺服器傳送的 PublicKeyCredential 物件
navigator.credentials.get 會傳回 PublicKeyCredential

responseAuthenticatorAssertionResponse。代表密碼金鑰提供者對用戶端指示的回應,建立必要的項目,以便透過 RP 中的密碼金鑰進行驗證。內容如下:

  • response.authenticatorDataresponse.clientDataJSON:例如密碼金鑰註冊步驟。
  • response.signature 包含具有這些值的簽名。

PublicKeyCredential 物件傳送至伺服器。

在伺服器上執行以下操作:

資料庫結構定義
建議的資料庫結構定義。如要進一步瞭解這項設計,請參閱「伺服器端密碼金鑰註冊」一文。
  • 收集您需要驗證斷言和驗證使用者所需的資訊:
    • 產生驗證選項時,取得您在工作階段中儲存預期所儲存的驗證問題。
    • 取得預期的 origin 和 RP ID。
    • 在資料庫中找出使用者。在這種情況下,您不知道執行驗證要求的使用者是誰。做法有以下兩種:
      • 方法 1:使用 PublicKeyCredential 物件中的 response.userHandle。在「使用者」表格中,尋找與 userHandle 相符的 passkey_user_id
      • 選項 2:使用 PublicKeyCredential 物件中存在的憑證 id。在「公開金鑰憑證」表格中,找出與 PublicKeyCredential 物件中 id 憑證相符的憑證 id。然後在「Users」資料表中使用外鍵 passkey_user_id 找出對應的使用者。
    • 在您的資料庫中,找出與您收到的驗證宣告相符的公開金鑰憑證資訊。方法是在「公用金鑰憑證」表格中,找出與 PublicKeyCredential 物件中顯示的憑證 id 相符的憑證 id
  • 驗證驗證聲明。請將這個驗證步驟交給 FIDO 伺服器端程式庫,該程式庫通常會提供公用程式功能。SimpleWebAuthn 提供範例,例如:verifyAuthenticationResponse。如要瞭解運作原理,請參閱附錄:驗證回應驗證

  • 刪除驗證是否成功的驗證問題,以免發生重送攻擊。

  • 登入使用者帳戶。如果驗證成功,請更新工作階段資訊,將使用者標示為登入。建議您一併將 user 物件傳回給用戶端,讓前端可以使用與新登入使用者相關的資訊。

程式碼範例:驗證並登入使用者

我們在範例中使用了 SimpleWebAuthn 程式庫。接著,我們將驗證回應的驗證交給其 verifyAuthenticationResponse 函式。

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

router.post('/signinResponse', csrfCheck, async (req, res) => {
  const response = req.body;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;

  // 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 {
    // Find the credential stored to the database by the credential ID
    const cred = Credentials.findById(response.id);
    if (!cred) {
      throw new Error('Credential not found.');
    }
    // Find the user - Here alternatively we could look up the user directly
    // in the Users table via userHandle
    const user = Users.findByPasskeyUserId(cred.passkey_user_id);
    if (!user) {
      throw new Error('User not found.');
    }
    // Base64URL decode some values
    const authenticator = {
      credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
      credentialID: isoBase64URL.toBuffer(cred.id),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      authenticator,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('User verification failed.');
    }

    // Kill the challenge for this session.
    delete req.session.challenge;

    req.session.username = user.username;
    req.session['signed-in'] = 'yes';

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).json({ error: e.message });
  }
});

附錄:驗證回應驗證

驗證驗證回應包含下列檢查:

  • 確認 RP ID 與你的網站相符。
  • 確認要求的來源與網站的登入來源相符。如果是 Android 應用程式,請參閱「驗證來源」。
  • 確認裝置能夠提供你提供的挑戰。
  • 確認使用者在驗證期間確實遵循您強制要求 (RP) 的各項規定。如果您需要使用者驗證,請確認 authenticatorData 中的 uv (使用者已驗證) 旗標為 true。檢查 authenticatorData 中的 up (使用者存在) 標記是否為 true,因為使用者一律必須使用密碼金鑰。
  • 驗證簽名。如要驗證簽名,您需要:
    • 簽章,即已簽署的挑戰:response.signature
    • 用於驗證簽章的公開金鑰。
    • 原始已簽署資料。這是待驗證簽章的資料。
    • 用於建立簽章的加密編譯演算法。

如要進一步瞭解這些步驟,請參閱 SimpleWebAuthn 的 verifyAuthenticationResponse 原始碼,或查閱規格的完整清單。