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

概要

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

パスキー認証フロー

  • パスキーによる認証に必要な本人確認とその他のオプションを定義します。パスキー認証の呼び出し(ウェブの場合は 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 には、パスキーの認証に必要なすべての情報が含まれている必要があります。この情報を、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 {
  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.getPublicKeyCredential を返します。

responseAuthenticatorAssertionResponse です。RP でパスキーによる認証を試行するために必要なものを作成するというクライアントの指示に対する、パスキー プロバイダのレスポンスを表します。これには以下のものが含まれます。

  • response.authenticatorDataresponse.clientDataJSONパスキー登録の手順など)。
  • response.signature。これらの値に対するシグネチャが含まれます。

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

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

データベース スキーマ
推奨されるデータベース スキーマ。この設計について詳しくは、サーバーサイド パスキーの登録をご覧ください。
  • アサーションの検証とユーザーの認証に必要な情報を収集します。
    • 認証オプションを生成したときにセッションに保存した想定チャレンジを取得します。
    • 想定される origin と 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 {
  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 として指定された要件を満たしていることを確認します。ユーザー確認が必要な場合は、authenticatorDatauv(ユーザー確認)フラグが true であることを確認してください。パスキーではユーザーの存在が常に必須であるため、authenticatorDataup(ユーザー表示)フラグが true であることを確認します。
  • 署名を検証します。署名を検証するには、次のものが必要です。
    • 署名(署名付きチャレンジ): response.signature
    • 署名の検証に使用する公開鍵。
    • 元の署名付きデータ。これは、署名を検証するデータです。
    • 署名の作成に使用された暗号アルゴリズム。

これらの手順について詳しくは、SimpleWebAuthn の verifyAuthenticationResponse のソースコードを確認するか、仕様にある検証の一覧をご覧ください。