サーバーサイド パスキー認証

概要

パスキー認証の主な手順の概要は次のとおりです。

パスキー認証フロー

  • パスキーによる認証に必要なチャレンジとその他のオプションを定義します。これらをクライアントに送信して、パスキー認証呼び出し(ウェブでは navigator.credentials.get)に渡せるようにします。ユーザーがパスキー認証を確認すると、パスキー認証呼び出しが解決され、認証情報(PublicKeyCredential)が返されます。認証情報には認証アサーションが含まれています。
  • 認証アサーションを検証します。
  • 認証アサーションが有効な場合は、ユーザーを認証します。

以降のセクションでは、各ステップの詳細について説明します。

チャレンジを作成する

実際には、チャレンジはランダムなバイトの配列であり、ArrayBuffer オブジェクトとして表されます。

// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8

チャレンジが目的を達成できるようにするには、次のことを行う必要があります。

  1. 同じチャレンジが 2 回以上使用されないようにします。ログイン試行ごとに新しいチャレンジを生成します。ログイン試行が成功したか失敗したかにかかわらず、ログイン試行のたびにチャレンジを破棄します。一定期間が経過したら、チャレンジを破棄します。レスポンスで同じチャレンジを複数回受け取ることはありません。
  2. チャレンジが暗号的に安全であることを確認する。チャレンジは、推測が実質的に不可能なものである必要があります。サーバーサイドで暗号的に安全なチャレンジを作成するには、信頼できる FIDO サーバーサイド ライブラリを使用することをおすすめします。独自のチャレンジを作成する場合は、技術スタックで利用可能な組み込みの暗号機能を使用するか、暗号化のユースケース向けに設計されたライブラリを探してください。たとえば、Node.js の iso-crypto や Python の secrets などがあります。仕様によると、安全とみなされるには、チャレンジの長さが 16 バイト以上である必要があります。

チャレンジを作成したら、後で検証できるようにユーザーのセッションに保存します。

認証情報作成リクエストのオプション

認証情報リクエスト オプションを publicKeyCredentialRequestOptions オブジェクトとして作成します。

これを行うには、FIDO サーバーサイド ライブラリを使用します。通常、これらのオプションを作成できるユーティリティ関数が提供されます。SimpleWebAuthn には、generateAuthenticationOptions などが用意されています。

publicKeyCredentialRequestOptions には、パスキー認証に必要なすべての情報が含まれている必要があります。この情報を、publicKeyCredentialRequestOptions オブジェクトの作成を担当する FIDO サーバーサイド ライブラリの関数に渡します。

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 {
  generateAuthenticationOptions,
} 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 を返します。

response は AuthenticatorAssertionResponse です。これは、RP でパスキーを使用して認証を試みるために必要なものを作成するようクライアントから指示されたパスキー プロバイダのレスポンスを表します。これには以下のものが含まれます。

  • response.authenticatorData と response.clientDataJSON(パスキー登録ステップなど)。
  • response.signature。これらの値に対する署名が含まれます。

PublicKeyCredential オブジェクトをサーバーに送信します。

サーバーで、次の操作を行います。

データベース スキーマ
推奨されるデータベース スキーマ。この設計の詳細については、サーバーサイドのパスキー登録をご覧ください。
  • アサーションを検証し、ユーザーを認証するために必要な情報を収集します。
    • 認証オプションを生成したときにセッションに保存した、想定されるチャレンジを取得します。
    • 想定されるオリジンと RP ID を取得します。
    • データベースでユーザーを特定します。検出可能な認証情報の場合、認証リクエストを行っているユーザーは特定できません。確認するには、次の 2 つの方法があります。
      • オプション 1: PublicKeyCredential オブジェクトの response.userHandle を使用します。[Users] テーブルで、userHandle と一致する passkey_user_id を探します。
      • オプション 2: PublicKeyCredential オブジェクトに存在する認証情報 id を使用します。[公開鍵認証情報] テーブルで、PublicKeyCredential オブジェクトに存在する認証情報 id と一致する認証情報 id を探します。次に、Users テーブルへの外部キー passkey_user_id を使用して、対応するユーザーを探します。
    • 受信した認証アサーションと一致する公開鍵認証情報をデータベースで検索します。これを行うには、[公開鍵認証情報] テーブルで、PublicKeyCredential オブジェクトに存在する認証情報 id と一致する認証情報 id を探します。
  • 認証アサーションを検証します。この検証ステップを FIDO サーバーサイド ライブラリに引き渡します。通常、このライブラリにはこの目的のためのユーティリティ関数が用意されています。SimpleWebAuthn には、たとえば verifyAuthenticationResponse が用意されています。内部の仕組みについては、付録: 認証レスポンスの検証をご覧ください。

  • リプレイ攻撃を防ぐため、検証が成功したかどうかにかかわらず、チャレンジを削除します。

  • ユーザーのログインを行う。検証に成功した場合は、セッション情報を更新して、ユーザーをログイン済みとしてマークします。また、フロントエンドが新しくログインしたユーザーに関連付けられた情報を使用できるように、user オブジェクトをクライアントに返すこともできます。

サンプルコード: ユーザーを確認してログインする

この例では、SimpleWebAuthn ライブラリを使用しています。ここでは、認証レスポンスの検証を verifyAuthenticationResponse 関数に渡します。

import {
  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.');
    }
    // Prepare credential information for verification
    const credential = {
      id: cred.id,
      publicKey: isoBase64URL.toBuffer(cred.publicKey),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      credential,
      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 のソースコードをご覧になるか、仕様の検証の完全なリストをご覧ください。