セキュリティ キー(WebAuthn)による 2 要素認証でサイトを保護する

1. 達成目標

まず、パスワード ベースのログインをサポートする基本的なウェブ アプリケーションを作成します。

その後、WebAuthn をベースにしたセキュリティ キーによる 2 要素認証のサポートを追加します。そのためには、以下を実装します。

  • ユーザーが WebAuthn 認証情報を登録する方法。
  • ユーザーが 2 つ目の認証要素(WebAuthn 認証情報)を登録している場合に、それを求める 2 要素認証フロー。
  • 認証情報管理インターフェース: ユーザーが認証情報の名前を変更したり削除したりできるようにする認証情報のリスト。

16ce77744061c5f7.png

完成したウェブアプリを確認してください。

2. WebAuthn について

WebAuthn の基本

WebAuthn を使う理由

フィッシングはウェブ上の重大なセキュリティ問題です。アカウントへの不正アクセスの多くは、複数のサイトで再利用されている脆弱なパスワードや盗まれたパスワードを使って行われます。この問題に対する業界全体の対策として、多要素認証が使われていますが、実装は統一されておらず、その多くがフィッシングに十分に対応しきれていません。

Web Authentication API(WebAuthn)はフィッシング対策に有効な標準化されたプロトコルで、あらゆるウェブ アプリケーションで使用できます。

仕組み

出典: webauthn.guide

WebAuthn を使用すると、サーバーはパスワードの代わりに公開鍵暗号を使用してユーザーの登録と認証を行えるようになります。ウェブサイトは、秘密鍵と公開鍵のペアから構成される認証情報を作成できます。

  • 秘密鍵はユーザーのデバイスに安全に保存されます。
  • 公開鍵とランダムに生成された認証情報 ID がサーバーに送信されて格納されます。

サーバーはユーザーが本人であることを証明するために公開鍵を使用します。公開鍵は、対応する秘密鍵なしでは役に立たないため、秘密にする必要はありません。

利点

WebAuthn には主に 2 つの利点があります。

  • 共有シークレットを使用しない: サーバーにはシークレットは格納されません。ハッカーにとって公開鍵は役に立たないため、データベースを攻撃するメリットが減ります。
  • 認証情報のスコープが限定されている: site.example に登録された認証情報は evil-site.example では使用できません。そのため、WebAuthn はフィッシング対策として有効です。

ユースケース

WebAuthn のユースケースの 1 つに、セキュリティ キーによる 2 要素認証があります。これは特にエンタープライズ ウェブ アプリケーションに適しています。

ブラウザ サポート

WebAuthn は W3C と FIDO によって策定された仕様で、Google、Mozilla、Microsoft、Yubico などが参画しています。

用語集

  • 認証システム: ユーザーを登録し、その後登録された認証情報の所有者であることを主張できるソフトウェアまたはハードウェア エンティティ。認証システムには次の 2 種類があります。
  • ローミング認証システム: ユーザーがログインに使用しようとしているデバイスで使用できる認証システム(USB セキュリティ キー、スマートフォンなど)。
  • プラットフォーム認証システム: ユーザーのデバイスに組み込まれている認証システム(Apple の Touch ID など)。
  • 認証情報: 秘密鍵と公開鍵のペア
  • 証明書利用者: ユーザーを認証しようとしているウェブサイト(ウェブサイト用のサーバー)
  • FIDO サーバー: 認証に使用されるサーバー。FIDO は FIDO Alliance によって開発されたプロトコル ファミリーで、WebAuthn もその一つです。

このワークショップでは、ローミング認証システムを使用します。

3. 始める前に

必要なもの

この Codelab を完了するには、以下が必要です。

  • WebAuthn に関する基礎知識。
  • JavaScript と HTML に関する基礎知識。
  • WebAuthn をサポートしている最新のブラウザ
  • U2F 準拠セキュリティ キー

次のいずれかをセキュリティ キーとして使用できます。

  • Android 7(Nougat)以上を搭載し、Chrome を実行しているスマートフォン。この場合、Bluetooth が動作する Windows、macOS、または ChromeOS パソコンも必要です。
  • USB キー(YubiKey など)。

6539dc7ffec2538c.png

出典: https://www.yubico.com/products/security-key/

dd56e2cfe0f7ced2.png

演習内容

学習すること ✅

  • WebAuthn 認証の 2 つ目の認証要素としてセキュリティ キーを登録して使用する方法。
  • このプロセスをユーザー フレンドリーにする方法。

学習しないこと ❌

  • FIDO サーバー(認証に使用されるサーバー)の構築方法。通常、ウェブ アプリケーションやサイトのデベロッパーは既存の FIDO サーバーの実装を使用するため、問題ありません。使用するサーバー実装の機能と品質を必ず確認してください。この Codelab では、FIDO サーバーに SimpleWebAuthn を使用します。他の方法については、FIDO Alliance の公式ページをご覧ください。オープンソース ライブラリについては、webauthn.io または AwesomeWebAuthn をご覧ください。

免責条項

ユーザーはパスワードを入力してログインする必要があります。ただし、この Codelab ではわかりやすくするため、パスワードの保存や確認は行いません。実際のアプリケーションでは、パスワードが正しいことをサーバー側で確認します。

この Codelab では、CSRF チェック、セッション検証、入力値のサニタイズなどの基本的なセキュリティ チェックを実装しますが、多くのセキュリティ対策(ブルート フォース攻撃を防止するためのパスワードの入力制限など)は実装しません。ここではパスワードを保存しないため、この実装で問題ありませんが、このコードを本番環境でそのまま使用しないでください。

4. 認証システムを設定する

Android スマートフォンを認証システムとして使用する場合

  • パソコンとスマートフォンの両方で Chrome が最新であることを確認します。
  • パソコンとスマートフォンの両方で Chrome を開き、同じプロファイル(このワークショップで使用するプロファイル)でログインします。
  • パソコンスマートフォンで、このプロファイルの同期をオンにします。これには chrome://settings/syncSetup を使用します。
  • パソコンとスマートフォンの両方で Bluetooth をオンにします。
  • 同じプロファイルでログインしている Chrome パソコンで、webauthn.io を開きます。
  • シンプルなユーザー名を入力します。[Attestation Type] と [Authenticator Type] は [None] と [Unspecified] の値(デフォルト)のままにします。[Register] をクリックします。

6b49ff0298f5a0af.png

  • ブラウザ ウィンドウが開き、本人確認を行うよう求められます。リストからスマートフォンを選択します。

ffebe58ac826eaf2.png 852de328fcd4eb42.png

  • スマートフォンに「Verify your identity」という通知が表示されたら、これをタップします。
  • スマートフォンで、スマートフォンの PIN コードの入力(または指紋認証センサーのタッチ)を求められます。PIN コードを入力します。
  • パソコンの webauthn.io に「Success」というインジケーターが表示されます。

fc0acf00a4d412fa.png

  • パソコンの webauthn.io で [Login] ボタンをクリックします。
  • 再度ブラウザ ウィンドウが開きます。リストからスマートフォンを選択します。
  • スマートフォンでポップアップ通知をタップし、PIN を入力します(または指紋認証センサーをタッチします)。
  • ログインが完了したことを示すメッセージが webauthn.io に表示されます。スマートフォンがセキュリティ キーとして正常に機能していることを確認できたため、以上でワークショップの準備は完了です。

USB セキュリティ キーを認証システムとして使用する場合

  • Chrome パソコンで webauthn.io を開きます。
  • シンプルなユーザー名を入力します。[Attestation Type] と [Authenticator Type] は [None] と [Unspecified] の値(デフォルト)のままにします。[Register] をクリックします。
  • ブラウザ ウィンドウが開き、本人確認を行うよう求められます。リストから [USB security key] を選択します。

ffebe58ac826eaf2.png 9fe75f04e43da035.png

  • セキュリティ キーをパソコンに挿入し、タッチします。

923d5adb8aa8286c.png

  • パソコンの webauthn.io に「Success」というインジケーターが表示されます。

fc0acf00a4d412fa.png

  • パソコンの webauthn.io で [Login] ボタンをクリックします。
  • 再度ブラウザ ウィンドウが開きます。リストから [USB security key] を選択します。
  • キーをタッチします。
  • ログインが完了したことを示すメッセージが webauthn.io に表示されます。USB セキュリティ キーが正常に機能していることを確認できたため、以上でワークショップの準備は完了です。

7e1c0bb19c9f3043.png

5. 準備

この Codelab では、コードを自動で簡単にデプロイできるオンライン コードエディタである Glitch を使用します。

スターター コードをフォークする

スターター プロジェクトを開きます。

[Remix] ボタンをクリックします。

これでスターター コードのコピーが作成されるので、このコードを編集していきます。この Codelab のすべての作業は、フォーク(Glitch では「remix」と呼ばれます)で行います。

cf2b9f552c9809b6.png

スターター コードを確認する

フォークしたスターター コードを確認します。

libs で、auth.js というライブラリがすでに提供されていることがわかります。これはサーバー側の認証ロジックを処理するカスタム ライブラリで、fido ライブラリを依存関係として使用します。

6 認証情報登録を実装する

認証情報登録を実装する

セキュリティ キーを使用した 2 要素認証を設定するには、最初にユーザーが認証情報を作成できるようにする必要があります。

まず、これを行う関数をクライアント側のコードに追加します。

public/auth.client.js には、まだコードが記述されていない registerCredential() という関数があります。この関数に、次のコードを追加します。

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    "/auth/credential-options",
    "POST"
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
    }
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Send the encoded credential to the backend for storage
  return await _fetch("/auth/credential", "POST", encodedCredential);
}

なお、この関数はすでにエクスポート済みです。

registerCredential の機能は次のとおりです。

  • サーバーから認証情報作成オプション(/auth/credential-options)を取得します。
  • 取得したサーバー オプションはエンコードされているため、ユーティリティ関数 decodeServerOptions を使用してデコードします。
  • ウェブ API navigator.credential.create を呼び出して認証情報を作成します。navigator.credential.create が呼び出されると、ブラウザが引き継いでユーザーにセキュリティ キーの選択を求めます。
  • 新しく作成された認証情報をデコードします。
  • エンコードされた認証情報が格納されている /auth/credential にリクエストを送信して、新しい認証情報をサーバー側で登録します。

補足: サーバーコードを確認する

registerCredential() はサーバーに対して 2 つの呼び出しを行います。そのときにバックエンドで何が起きているのか見てみましょう。

認証情報作成オプション

クライアントが(/auth/credential-options)にリクエストを送信すると、サーバーはオプション オブジェクトを生成し、それをクライアントに返します。

このオブジェクトは、クライアントの実際の認証情報作成呼び出しで次のように使用されます。

navigator.credentials.create({
    publicKey: {
    // Options generated server-side
    ...credentialCreationOptions
// ...
}

前のステップで実装したクライアント側の registerCredential で最終的に使用されている、この credentialCreationOptions には何が含まれているでしょうか。

router.post("/credential-options", ... のサーバーコードを見てみましょう。

ただし、すべてのプロパティを確認する必要はありません。サーバーコードのオプション オブジェクトに興味深いプロパティがありますが、これらは fido2 ライブラリを使用して生成され、最終的にクライアントに返されます。

  • rpNamerpId には、ユーザーを登録して認証する組織を記述します。WebAuthn では認証情報のスコープが特定のドメインに限定されるため、セキュリティ上のメリットがあります。ここでは、rpNamerpId が認証情報のスコープを指定するために使用されます。有効な rpId は、サイトのホスト名などです。これらのプロパティは、スターター プロジェクトをフォークすると自動的に更新されます 🧘🏻‍♀️
  • excludeCredentials は、認証情報のリストです。excludeCredentials にリストされている認証情報のいずれかを含む認証システムでは、新しい認証情報を作成できません。この Codelab では、excludeCredentials はそのユーザーの既存の認証情報のリストです。このリストと user.id を使用して、ユーザーが作成した認証情報がそれぞれ異なる認証システム(セキュリティ キー)に保存されるようにします。ユーザーが複数の認証情報を登録している場合、それらの認証情報はそれぞれ別の認証システム(セキュリティ キー)に保存されるため、1 つのセキュリティ キーを紛失してもユーザーはアカウントにアクセスできなくなることはありません。そのため、この方法をおすすめします。
  • authenticatorSelection では、ウェブ アプリケーションで許可する認証システムのタイプを定義します。authenticatorSelection を詳しく見てみましょう。
    • residentKey: preferred は、このアプリケーションがクライアント側で検出可能な認証情報を適用しないことを意味します。クライアント側で検出可能な認証情報は、ユーザーを最初に特定しなくても認証できる特別な認証情報です。この Codelab では基本的な実装に焦点を当てているため、ここでは preferred を設定しています。検出可能な認証情報は、より高度なフローで使用するものです。
    • requireResidentKey は、WebAuthn v1 との下位互換性を維持するためにのみ存在します。
    • userVerification: preferred は、認証システムがユーザー確認をサポートしている場合(たとえば、生体認証セキュリティ キーの場合や、PIN 機能が組み込まれたキーの場合)に、証明書利用者が認証情報の作成時にユーザー確認をリクエストすることを意味します。認証システムがユーザー確認をサポートしていない場合(基本的なセキュリティ キーの場合)、サーバーはユーザー確認をリクエストしません。
  • ​​pubKeyCredParam には、認証情報の望ましい暗号プロパティが優先順で記述されています。

これらのオプションはすべて、ウェブ アプリケーションでセキュリティ モデルを実現するために決定する必要があるものです。サーバーで、これらのオプションが単一の authSettings オブジェクトで定義されていることを確認します。

チャレンジ

もう一つの興味深い部分は req.session.challenge = options.challenge; です。

WebAuthn は暗号プロトコルであるため、リプレイ攻撃を回避するためにランダム化されたチャレンジを使用します。リプレイ攻撃とは、攻撃者が認証を可能にする秘密鍵の所有者ではない場合に、ペイロードを盗んで認証をリプレイする攻撃のことです。

これを軽減するため、チャレンジはサーバー上で生成され、すばやく署名されます。その後、署名が想定される結果と比較されます。これにより、ユーザーが認証情報を生成したときに秘密鍵を保持していたことが確認されます。

認証情報登録コード

router.post("/credential", ... のサーバーコードを見てみましょう。

ここでは、認証情報をサーバー側で登録します。

具体的にどのような処理が行われているでしょうか。

このコードで特に注目すべき部分の一つは、fido2.verifyAttestationResponse を介した確認の呼び出しです。

  • 署名済みチャレンジがチェックされ、作成時に秘密鍵を実際に保持している人物によって認証情報が作成されたことが確認されます。
  • 送信元にバインドされている、証明書利用者の ID も確認されます。これにより、認証情報がこのウェブ アプリケーションにのみバインドされるようになります。

この機能を UI に追加する

認証情報を作成する関数 registerCredential(), の準備が整ったので、次にユーザーがそれを使用できるようします。

これは認証管理を行う一般的な場所である [Account] ページから行います。

account.html のマークアップのユーザー名の下に、レイアウト クラス class="flex-h-between" を持つ空の div があります。この div を、2 要素認証機能に関連する UI 要素に使用します。

この div に以下を追加します。

  • 「Two-factor authentication」というタイトル
  • 認証情報を作成するボタン
 <div class="flex-h-between">
    <h3>
        Two-factor authentication
    </h3>
    <button class="create" id="registerButton" raised>
        ➕ Add a credential
    </button>
</div>

この div の下に、後で必要になる認証情報の div を追加します。

<div class="flex-h-between">
(HTML you've just added)
</div>
<div id="credentials"></div>

account.html のインライン スクリプトで、作成した関数をインポートするとともに、それを呼び出す関数 register と、作成したボタンにアタッチされたイベント ハンドラを追加します。

// Set up the handler for the button that registers credentials
const registerButton = document.querySelector('#registerButton');
registerButton.addEventListener('click', register);

// Register a credential
async function register() {
  let user = {};
  try {
    const user = await registerCredential();
  } catch (e) {
    // Alert the user that something went wrong
    if (Array.isArray(e)) {
      alert(
        // `msg` not `message`, this is the key's name as per the express validator API
        `Registration failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
      );
    } else {
      alert(`Registration failed. ${e}`);
    }
  }
}

ユーザーに認証情報を表示する

認証情報を作成する機能を追加したので、次にユーザーが追加済みの認証情報を確認するための方法が必要になります。

これには [Account] ページが最適です。

account.html で、updateCredentialList() という関数を見つけます。

次のコードを追加します。このコードは、現在ログインしているユーザーの登録済み認証情報をすべて取得するバックエンド呼び出しを行い、返された認証情報を表示します。

// Update the list that displays credentials
async function updateCredentialList() {
  // Fetch the latest credential list from the backend
  const response = await _fetch('/auth/credentials', 'GET');
  const credentials = response.credentials || [];
  // Generate the credential list as HTML and pass remove/rename functions as args
  const credentialListHtml = getCredentialListHtml(
    credentials,
    removeEl,
    renameEl
  );
  // Display the list of credentials in the DOM
  const list = document.querySelector('#credentials');
  render(credentialListHtml, list);
}

removeElrenameEl については、この Codelab で後ほど説明します。

account.html 内で、インライン スクリプトの先頭に updateCredentialList の呼び出しを 1 つ追加します。この呼び出しでは、ユーザーがアカウント ページにアクセスすると、利用可能な認証情報が取得されます。

<script type="module">
    // ... (imports)
    // Initialize the credential list by updating it once on page load
    updateCredentialList();

registerCredential が正常に完了したら、updateCredentialList を呼び出して、新しく作成された認証情報がリストに表示されるようにします。

async function register() {
  let user = {};
  try {
    // ...
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

試してみる 👩🏻‍💻

これで、認証情報の登録が完了しました。ユーザーがセキュリティ キーベースの認証情報を作成し、[Account] ページで確認できるようになりました。

以下を試してみましょう。

  • ログアウトします。
  • 任意のユーザー名とパスワードでログインします。前述のように、この Codelab ではわかりやすくため、実際にはパスワードのチェックを行っていません。空白以外の任意のパスワードを入力してください。
  • [Account] ページにログインしたら、[Add a credential] をクリックします。
  • セキュリティ キーを挿入してタッチするようメッセージが表示されたら、それに従います。
  • 認証情報が正常に作成されると、アカウント ページに認証情報が表示されます。
  • [Account] ページを再読み込みします。認証情報が表示されます。
  • 利用できる鍵が 2 つある場合は、2 つのセキュリティ キーを認証情報として追加してみてください。両方の認証情報が表示されるはずです。
  • 同じ認証システム(鍵)を使用して 2 つの認証情報を作成してみると、サポートされないことがわかります。これはバックエンドで excludeCredentials を使用しているために起こる、意図的な動作です。

7. 2 要素認証を有効にする

ユーザーは認証情報の登録と登録解除を行えますが、認証情報は表示されているだけで、実際にはまだ使用されていません。

認証情報を使用して、実際に 2 要素認証を設定しましょう。

このセクションでは、ウェブ アプリケーションの認証フローを変更します。具体的には、次のような基本的なフローから、

6ff49a7e520836d0.png

以下のように 2 要素を使用するフローに変更します。

e7409946cd88efc7.png

2 要素認証を実装する

まず、必要な機能を追加し、バックエンドとの通信を実装しましょう。次のステップで、これをフロントエンドに追加します。

ここで実装する必要があるのは、認証情報を使用してユーザーを認証する関数です。

public/auth.client.js で空の関数 authenticateTwoFactor を探して、次のコードを追加します。

async function authenticateTwoFactor() {
  // Fetch the 2F options from the backend
  const optionsFromServer = await _fetch("/auth/two-factor-options", "POST");
  // Decode them
  const decodedOptions = decodeServerOptions(optionsFromServer);
  // Get a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.get({
    publicKey: decodedOptions
  });
  // Encode the credential
  const encodedCredential = encodeCredential(credential);
  // Send it to the backend for verification
  return await _fetch("/auth/authenticate-two-factor", "POST", {
    credential: encodedCredential
  });
}

なお、この関数は次のステップで必要になるため、すでにエクスポート済みです。

authenticateTwoFactor の機能は次のとおりです。

  • サーバーに 2 要素認証オプションをリクエストします。前述の認証情報作成オプションと同様に、これらはサーバー上で定義されており、ウェブ アプリケーションのセキュリティ モデルによって異なります。詳しくは、router.post("/two-factors-options", ... の下にあるサーバーコードをご覧ください。
  • navigator.credentials.get を呼び出すことで、ブラウザで、登録済みのキーを挿入してタッチするようユーザーに求めることができます。これにより、この特定の 2 要素認証オペレーション用の認証情報が選択されます。
  • 次に、選択した認証情報が、"/auth/authentication-two-factor" をフェッチするバックエンド リクエストに渡されます。認証情報が有効である場合、ユーザーは認証されます。

補足: サーバーコードを確認する

なお、server.js はすでに一部のナビゲーションとアクセスを処理しています。具体的には、[Account] ページに認証済みのユーザーのみがアクセスできるようにするとともに、必要なリダイレクトを行っています。

次に、router.post("/initialize-authentication", ... の下にあるサーバーコードを見てみましょう。

ここで注目すべき点が 2 つあります。

  • この段階でパスワードと認証情報の両方が同時にチェックされますが、これはセキュリティ対策です。つまり、2 要素認証を設定済みのユーザーの場合に、パスワードが正しいかどうかによって UI フローが変わらないようにする必要があります。そのため、このステップではパスワードと認証情報の両方を同時にチェックします。
  • パスワードと認証情報の両方が有効な場合は、completeAuthentication(req, res); を呼び出して認証を完了します。つまり、ユーザーが認証されていない一時的な auth セッションから、ユーザーが認証されているメイン セッション main へとセッションを切り替えます。

ユーザーフローに 2 要素認証ページを追加する

views フォルダ内に、新しいページ second-factor.html があります。

[Use security key] というボタンがありますが、今のところクリックしても何も起こりません。

このボタンをクリックすると authenticateTwoFactor() が呼び出されるようにします。

  • authenticateTwoFactor() が成功した場合は、ユーザーを [Account] ページにリダイレクトします。
  • 失敗した場合は、エラーが発生したことをユーザーに通知します。実際のアプリケーションでは、より実用的なエラー メッセージを実装してください。このデモではわかりやすくするために、ウィンドウ アラートのみを使用しています。
    <main>
...
    </main>
    <script type="module">
      import { authenticateTwoFactor, authStatuses } from "/auth.client.js";

      const button = document.querySelector("#authenticateButton");
      button.addEventListener("click", async e => {
        try {
          // Ask the user to authenticate with the second factor; this will trigger a browser prompt
          const response = await authenticateTwoFactor();
          const { authStatus } = response;
          if (authStatus === authStatuses.COMPLETE) {
            // The user is properly authenticated => Navigate to the Account page
            location.href = "/account";
          } else {
            throw new Error("Two-factor authentication failed");
          }
        } catch (e) {
          // Alert the user that something went wrong
          alert(`Two-factor authentication failed. ${e}`);
        }
      });
    </script>
  </body>
</html>

2 要素認証を使用する

これで、2 要素認証のステップを追加する準備が整いました。

次に、2 要素認証を設定しているユーザー用に、index.html にこのステップを追加する必要があります。

322a5c49d865a0d8.png

index.htmllocation.href = "/account"; の下に、ユーザーが 2 要素認証を設定している場合に条件付きでユーザーを 2 要素認証ページに移動させるコードを追加します。

この Codelab では、ユーザーが認証情報を作成すると、自動的に 2 要素認証が有効になります。

なお、server.js にはサーバー側のセッション チェックも実装されているため、認証されたユーザーのみが account.html にアクセスできるようになっています。

const { authStatus } = response;
if (authStatus === authStatuses.COMPLETE) {
  // The user is properly authenticated => navigate to account
  location.href = '/account';
} else if (authStatus === authStatuses.NEED_SECOND_FACTOR) {
  // Navigate to the two-factor-auth page because two-factor-auth is set up for this user
  location.href = '/second-factor';
}

試してみる 👩🏻‍💻

  • 新しいユーザー johndoe でログインします。
  • ログアウトします。
  • アカウントに johndoe としてログインします。パスワードのみが要求されることを確認します。
  • 認証情報を作成します。これにより、johndoe の 2 要素認証を有効にしたことになります。
  • ログアウトします。
  • ユーザー名 johndoe とパスワードを入力します。
  • 自動的に 2 要素認証ページに移動することを確認します。
  • /account の [Account] ページにアクセスしてみてください。2 つ目の要素を入力しておらず認証が完了していないため、インデックス ページにリダイレクトされます)
  • 2 要素認証ページに戻り、[Use security key] をクリックして 2 要素認証を有効にします。
  • ログインが完了し、[Account] ページが表示されます。

8. 認証情報を使いやすくする

これで、セキュリティ キーによる 2 要素認証の基本的な機能の設定が完了しました 🚀

しかし、一部改善すべき点が残っています。

認証情報リストに登録されている認証情報 ID と公開鍵は長い文字列であるため、このままでは認証情報を管理するのに不便です。人間は長い文字列や数字を扱うのが得意ではありません 🤖

そこで、人間が読み取れる文字列を使用して名前を付けたり、名前を変更したりできる機能を認証情報に追加しましょう。

renameCredential を確認する

この関数は特に目新しい処理をするわけではありません。そのため、実装にかかる時間を節約できるよう、認証情報の名前を変更するための関数をスターター コードの auth.client.js に追加済みです。

async function renameCredential(credId, newName) {
  const params = new URLSearchParams({
    credId,
    name: newName
  });
  return _fetch(
    `/auth/credential?${params}`,
    "PUT"
  );
}

これは定期的なデータベース更新の呼び出しです。クライアントは、認証情報 ID とその認証情報の新しい名前を使用して PUT リクエストをバックエンドに送信します。

認証情報のカスタム名を実装する

account.html に、空の関数 rename があります。

これに次のコードを追加します。

// Rename a credential
async function rename(credentialId) {
  // Let the user input a new name
  const newName = window.prompt(`Name this credential:`);
  // Rename only if the user didn't cancel AND didn't enter an empty name
  if (newName && newName.trim()) {
    try {
      // Make the backend call to rename the credential (the name is sanitized) server-side
      await renameCredential(credentialId, newName);
    } catch (e) {
      // Alert the user that something went wrong
      if (Array.isArray(e)) {
        alert(
          // `msg` not `message`, this is the key's name as per the express validator API
          `Renaming failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
        );
      } else {
        alert(`Renaming failed. ${e}`);
      }
    }
    // Refresh the credential list to display the new name
    await updateCredentialList();
  }
}

認証情報の命名は、認証情報の作成が正常に完了した場合にのみ行うほうが、理にかなっているといえます。そのため、名前を付けずに認証情報を作成し、正常に作成できたら認証情報の名前を変更しましょう。ただし、この場合はバックエンド呼び出しが 2 回行われます。

register()rename 関数を使用して、登録時にユーザーが認証情報に名前を付けられるようにします。

async function register() {
  let user = {};
  try {
    const user = await registerCredential();
    // Get the latest credential's ID (newly created credential)
    const allUserCredentials = user.credentials;
    const newCredential = allUserCredentials[allUserCredentials.length - 1];
    // Rename it
    await rename(newCredential.credId);
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

ユーザー入力は、バックエンドで検証およびサニタイズされます。

  check("name")
    .trim()
    .escape()

次に、認証情報の名前を表示します。

templates.jsgetCredentialHtml に移動します。

認証情報カードの上部に認証情報の名前を表示するコードがすでに存在します。

// Register credential
const getCredentialHtml = (credential, removeEl, renameEl) => {
 const { name, credId, publicKey } = credential;
 return html`
    <div class="credential-card">
      <div class="credential-name">
        ${name
          ? html`
              ${name}
            `
          : html`
              <span class="unnamed">(Unnamed)</span>
            `}
      </div>
     // ...
    </div>
  `;
};

試してみる 👩🏻‍💻

  • 認証情報を作成します。
  • 名前を付けるよう求められます。
  • 新しい名前を入力し、[OK] をクリックします。
  • 認証情報の名前が変更されます。
  • 名前欄を空欄にのままにしても問題なく動作することを確認します。

認証情報名の変更を有効にする

ユーザーは認証情報の名前を変更したい場合があります。たとえば、2 つ目の鍵を追加し、区別しやすいように 1 つ目の鍵の名前を変更したい場合などです。

account.html で、これまで空のままだった関数 renameEl を探して、次のコードを追加します。

// Rename a credential via HTML element
async function renameEl(el) {
  // Define the ID of the credential to update
  const credentialId = el.srcElement.dataset.credentialId;
  // Rename the credential
  await rename(credentialId);
  // Refresh the credential list to display the new name
  await updateCredentialList();
}

templates.jsgetCredentialHtml で、class="flex-end" div 内に次のコードを追加します。このコードにより、[Rename] ボタンが認証情報カード テンプレートに追加されます。このボタンをクリックすると、作成した renameEl 関数が呼び出されます。

const getCredentialHtml = (credential, removeEl, renameEl) => {
// ...
 <div class="flex-end">
  <button
    data-credential-id="${credId}"
    @click="${renameEl}"
    class="secondary right"
  >
   Rename
  </button>
 </div>
 // ...
  `;
};

試してみる 👩🏻‍💻

  • [Rename] をクリックします。
  • プロンプトが表示されたら、新しい名前を入力します。
  • [OK] をクリックします。
  • 認証情報の名前が正常に変更され、リストが自動的に更新されます。
  • ページを再度読み込むと、新しい名前が引き続き表示されます(これは、新しい名前がサーバー側で保持されていることを意味します)。

認証情報の作成日を表示する

navigator.credential.create() で作成された認証情報には、作成日が存在しません。

この情報はユーザーが認証情報を区別するのに便利なので、スターター コードのサーバー側ライブラリを調整し、creationDate フィールドを追加しました。これは新しい認証情報の格納時に Date.now() を実行することに相当します。

templates.jsclass="creation-date" div 内に次の行を追加して、ユーザーに作成日の情報を表示します。

<div class="creation-date">
  <label>Created:</label>
  <div class="info">
    ${new Date(creationDate).toLocaleDateString()}
    ${new Date(creationDate).toLocaleTimeString()}
  </div>
</div>

9. コードの対応範囲を広げる

これまでは、ログイン時に 2 つ目の要素として使用するシンプルなローミング認証システムのみを登録するようユーザーに求めていました。

さらに高度な方法の 1 つとして、より強力なタイプの認証システムであるユーザー確認ローミング認証システム(UVRA)を使用する方法があります。UVRA は、1 段階のログインフローで 2 つの認証要素とフィッシング耐性を提供できます。

上記の両方をサポートすることが理想的です。そのためには、ユーザー エクスペリエンスをカスタマイズする必要があります。

  • ユーザーがシンプルな(ユーザー確認を行わない)ローミング認証システムのみを使用している場合は、フィッシング耐性のあるアカウント ブートストラップを実現するため、その認証システムを使用できるようにします。ただし、ユーザー名とパスワードの入力も必須とします。これはこの Codelab ですでに実施済みです。
  • より高度なユーザー確認ローミング認証システムを使用しているユーザーは、アカウントのブートストラップ中にパスワードのステップを(可能な場合はユーザー名のステップも)スキップできます。

詳しくは、「オプションのパスワードレス ログインを使用したフィッシング対策に有効なアカウントのブートストラップ」をご覧ください。

この Codelab では、実際にはユーザー エクスペリエンスをカスタマイズしませんが、そのために必要なデータを利用できるよう、コードベースを設定します。

次の 2 つを行う必要があります。

  • バックエンドの設定で residentKey: preferred を設定します。これはすでに完了しています。
  • 検出可能な認証情報(レジデントキー)が作成されたかどうかを確認する方法を設定します。

検出可能な認証情報が作成されたかどうかを確認するには:

  • 認証情報の作成時に credProps の値をクエリします(credProps: true)。
  • 認証情報の作成時に transports の値をクエリします。これにより、基盤となるプラットフォームが UVRA 機能をサポートしているか(たとえば、本当にスマートフォンなのか)を判断できます。
  • credPropstransports の値をバックエンドに格納します。これはスターター コードですでに行われています。興味がある方は、auth.js をご覧ください。

credPropstransports の値を取得し、バックエンドに送信しましょう。auth.client.js で、registerCredential を次のように変更します。

  • navigator.credentials.create の呼び出し時に extensions フィールドを追加します。
  • 認証情報バックエンドに送信して格納する前に、encodedCredential.transportsencodedCredential.credProps を設定します。

registerCredential は次のようになります。

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    '/auth/credential-options',
    'POST'
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
      extensions: {
        credProps: true,
      },
    },
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Set transports and credProps for more advanced user flows
  encodedCredential.transports = credential.response.getTransports();
  encodedCredential.credProps =
    credential.getClientExtensionResults().credProps;
  // Send the encoded credential to the backend for storage
  return await _fetch('/auth/credential', 'POST', encodedCredential);
}

10. クロスブラウザをサポートする

Chromium 以外のブラウザをサポートする

public/auth.client.jsregisterCredential 関数で、新しく作成された認証情報に対して credential.response.getTransports() を呼び出して、最終的にこの情報をサーバーへのヒントとしてバックエンドに保存します。

ただし、現在のところ getTransports() はすべてのブラウザで実装されているわけではありません(各ブラウザでサポートされている getClientExtensionResults とは異なります)。Firefox と Safari では getTransports() 呼び出しがエラーとなるため、これらのブラウザでは認証情報を作成できません。

すべての主要なブラウザでコードが実行されるようにするには、encodedCredential.transports 呼び出しを次の条件でラップします。

if (credential.response.getTransports) {
  encodedCredential.transports = credential.response.getTransports();
}

サーバーでは、transportstransports || [] に設定されています。Firefox と Safari では、transports リストは undefined ではなく、空のリストである [] となるため、エラーを回避できます。

WebAuthn をサポートしていないブラウザを使用しているユーザーに警告を表示する

1e9c1be837d66ce8.png

WebAuthn はすべての主要なブラウザでサポートされていますが、WebAuthn をサポートしていないブラウザでは警告を表示することをおすすめします。

index.html で、次の div が存在することを確認します。

<div id="warningbanner" class="invisible">
⚠️ Your browser doesn't support WebAuthn. Open this demo in Chrome, Edge, Firefox or Safari.
</div>

index.html のインライン スクリプトに次のコードを追加して、WebAuthn をサポートしていないブラウザでバナーを表示します。

// Display a banner in browsers that don't support WebAuthn
if (!window.PublicKeyCredential) {
  document.querySelector('#warningbanner').classList.remove('invisible');
}

実際のウェブ アプリケーションでは、より複雑な作業を行ってこれらのブラウザ用に適切な代替メカニズムを用意します。ここでは、WebAuthn のサポートを確認する方法を紹介しました。

11. よくできました!

✨これで完了です。

セキュリティ キーによる 2 要素認証を実装しました。

この Codelab では、基本事項について説明しました。2 要素認証での WebAuthn の使用についてさらに確認したい場合は、以下をお試しください。

  • 認証情報カードに「最終使用日」の情報を追加する。この情報は、特にユーザーが複数のキーを登録している場合に、特定のセキュリティ キーがアクティブに使用されているかどうかを判断するのに役立ちます。
  • より堅牢なエラー処理と、より正確なエラー メッセージを実装する。
  • 特にユーザー確認をサポートするキーを使用している場合に、auth.jsauthSettings の一部を変更するとどうなるか確認する。