LoginSignup
29
24

More than 3 years have passed since last update.

WebAuthn の動作デモをFirebaseで作ってみた

Posted at

パスワードレス認証の旗手である WebAuthn、早くサービスに普及して欲しいですね。
認証の手間が省けるうえセキュリティ強度も上がるし、ユーザーにとってメリットが多くて良いことづくめです。
Yahoo Japan や GitHub、Backlog などサービスでの導入事例も増えてきていますし、いずれ自分も実装をする機会になるかもしれません。
ただ、WebAuthn の仕組みや解説の情報はあるものの、実際に動作するサンプルとその実装コードの情報とかがあまりないように思えたので、いつ案件が来ても慌てず対応できるように、動作デモを作りながら実装するための仕組みや手順をまとめてみました。

WebAuthn 動作デモ

以下から実際に動いてるものを確認できます。

動作デモ: https://webauthn-demo.firebaseapp.com/
ソースコード: https://github.com/okamoai/webauthn-demo

注:

  • このデモではビルドイン認証器のある環境のみで動作検証しています。USB や NFC のいわゆる cross-platform の動作検証はしていません。
  • 動作検証は Surface Pro 4 の Windows Hello で ブラウザは Edge, Chrome, Firefox、Android は Pixel 3 の Chrome で行いました。
  • WebAuthn の制限として、SSL 通信環境下、もしくは localhost ドメインでのみ動作します。

クライアントサイド処理

WebAuthn の前提条件として、Web Authentication API に対応したブラウザが必要になります。

Web Authentication API - Can I use では

  • IE: 非対応
  • Edge:18 以上
  • Firefox: 60 以上
  • Chrome: 67 以上
  • Safari: 13 以上
  • Android Browser:5 以上
  • Android Chrome:対応
  • iOS Safari: 非対応

となっています。
IE11 とかはまあどうでもいいんですが、WebAuthn は認証機器が搭載済みのスマートフォンにおいて真価を発揮できるので、iPhone/iPad 非対応は日本のスマホシェア的には痛いところ。いちおう PC版 Safari の最新バージョン(13)では対応されているので、iOS の方にもいずれやって来ることを期待したいですね。

対応ブラウザでは JavaScript の navigator.credentials.create() で新規登録処理を、navigator.credentials.get() で認証処理を行います。

サーバサイド処理

WebAuthn を成立させるために 4つの API のエンドポイントが必要になり、実行される手順は以下のような形になります。

  1. 新規登録時、ユーザ ID に紐付いたチャレンジレスポンスを発行する API
  2. 新規登録時、ブラウザの認証器が発行した公開鍵を検証し、データベースに保存する API
  3. 認証チェック時、ユーザ ID に紐付いたチャレンジレスポンスを発行する API
  4. ブラウザの認証器が発行した署名をサーバに保存した公開鍵で検証してセッションを発行する API

クライアントサイドからやって来るデータはバッファデータも含まれているので、それらを処理する必要もあります。

新規登録時のフロー

新規登録時におけるクライアントサイドとサーバサイドの一連の流れをまとめてみました。
サーバサイド側の検証項目が多くて大変ですが、一つずつステップに分けて解説します。

navigator.credentials.create.png

Step 1. 非同期通信でチャレンジ発行APIにリクエスト

最初にクライアント側からチャレンジ発行のAPIに向けてリクエストを発行します。
チャレンジ発行はユーザー識別情報と紐づいている必要があります。(デモではメールアドレスとしています)
ここでは省略していますが、実際には登録したメールアドレス宛に届く新規登録用URLを経由するなどして、メールアドレスの所有者であることを事前に確認しておく必要があるかと思います。
非同期通信方法は問いませんが、デモでは axios を使ってます。

axios.post('/api/attestation/challenge', { userId: 'example@example.com' })

Step 2. チャレンジを発行し、ユーザ識別情報に紐づけてデータベースに保存

API 側では受け取ったリクエストに対して推測不可能で一意でランダムな値を発行します。
サーバ側の UUID を発行する仕組みを使って発行すると良いかと思います。
この値をキーにしてリクエストに含まれている userId を紐づけてデータベースに保存します。

デモでは FireStore の Document Id をそのまま使っています。

const db = firebase.firestore()
const challenge = await db
  .collection('challenge')
  .add({
    userId: 'example@example.com',
    timestamp: new Date().getTime(),
  })
  .then(docRef => docRef.id)

また、デモでは省略していますがチャレンジ値の保存はAPIリクエストの度に追加されていくので、
ガベージコレクションをして定期的に古いデータ削除する必要があります。

Step 3. 発行したチャレンジをレスポンスで返す

API はチャレンジ値をレスポンスデータに含めて返却をします。
認証器への処理を行う際にこのチャレンジ値が必須になるためです。
このチャレンジ発行の一連の流れはリプレイ攻撃に対する対策のため必要な手順になります。

Step 4. navigator.credentials.create() でユーザーの認証処理を行う

navigator.credentials.create() を実行することでブラウザから OS の認証器に向けて新規認証登録処理を行うことができます。
実行に必要最低限のオプションを適用したコード例は以下のような形になります。

const credentials = await navigator.credentials.create({
  publicKey: {
    rp: {
      id: 'localhost',
      name: 'WebAuthn Demo'
    },
    user: {
      id: base64url.toBuffer('user_id').buffer,
      name: 'example@example.com',
      displayName: 'User Name'
    },
    pubKeyCredParams: [
      {
        type: 'public-key',
        alg: -7
      },
      {
        type: 'public-key',
        alg: -257
      }
    ],
    challenge: base64url.toBuffer('Pj5VVSB2wIBmTU3NIiKg').buffer,
  }
})

publicKey オプションはほかにもいろいろな項目があるんですが、ここでは必要最低限の説明に留めておきます。

プロパティ 説明
rp.id String サービス事業者の識別値。ドメイン名など。後の検証で使われる
rp.name String サービス事業者名称
user.id ArrayBuffer ユーザー識別値
user.name String ユーザー名を示す値(メールアドレスなど)
pubKeyCredParams.type String 'public-key' の固定文字列
pubKeyCredParams.alg Number 鍵作成時のアルゴリズム(COSE Algorithms準拠)を示す数値
challenge ArrayBuffer 発行されたチャレンジ値

publicKey オプションの構成

user.id challenge は ArrayBuffer 型に変換する必要があります。
デモでは base64url を使って文字列を ArrayBuffer に変換しています。

Step 5. 認証器が情報を返却する

認証器での認証が成功すると OS のセキュアな領域に秘密鍵を保存した後に、公開鍵や署名情報などを含むデータを返却します。

{
  "id": "JjBd6aeQ2h7MmJMW7sVqTpGQ5q6KpzhYlYYjABNKDZk",
  "rawId": ArrayBuffer,
  "type": "public-key",
  "response": {
    "attestationObject": ArrayBuffer,
    "clientDataJSON": ArrayBuffer
  }
}
プロパティ 説明
id String rawId を base64url 化したもの
rawId ArrayBuffer 認証情報の一意な識別情報。 navigator.credentials.get() 時に認証情報の検索に使用されます
type String 'public-key' の固定文字列
response.attestationObject ArrayBuffer 認証用の公開鍵や署名情報などを含むデータ。CBOR エンコードされた ArrayBuffer
response.clientDataJSON ArrayBuffer 認証用のクライアントデータ。JSON String を ArrayBuffer 化したもの

navigator.credentials.create() の返却値
navigator.credentials.create().response の返却値

Step 6. 非同期通信で認証登録APIにリクエスト

認証器からの返却値を認証登録用のAPIに非同期通信でリクエストを送ります。
返却値の clientDataJSONattestationObject は ArrayBuffer 型なので、そのままでは送信できません。
APIに送る前に base64 化してから送る必要があります。

デモでは以下のように base64url で ArrayBuffer を String に変換して送信しています。

await axios.post('/api/attestation/register', {
  id: credentials.id,
  attestationObject: base64url(credentials.response.attestationObject),
  clientDataJSON: base64url(credentials.response.clientDataJSON),
})

Step 7. clientDataJSON, attestationObject を base64 デコード

API にリクエストされたデータは base64 エンコードされているので、受け取ったデータを先ずは base64 デコードしてデータとして扱えるようにします。
デモではクライアントサイドで使っていた base64url を使ってデコード処理をしています。 clientDataJSON は JSON データを文字列化したものなので、 JSON.pase() でオブジェクトとして扱えるようにします。

const clientDataJSON = JSON.parse(base64url.decode(req.body.clientDataJSON))
const attestationObjectBuffer = base64url.toBuffer(req.body.attestationObject)

clientDataJSON は以下のような内容で構成されています。

const clientDataJSON = {
  challenge: 'lTbl5U06Hra0SEowP2SH',
  origin: 'http://localhost:8080',
  type: 'webauthn.create',
}
プロパティ 説明
challenge String navigator.credentials.create() の challenge 値
origin String navigator.credentials.create() を実行したときのプロトコルを含めたFQDN
type String 発行形式。 navigator.credentials.create() の場合 'webauthn.create' 固定

clientDataJSON の構成

Step 8. チャレンジをデータベースに照会してユーザーの正当性を検証

clientDataJSON.challenge の値をデータベースに照会して、正規のユーザーかどうかを確認します。
デモでは FireStore の Document ID を検索してユーザーID の値を見る形でチェックしています。

const userId = await db
  .collection('challenge')
  .doc(clientDataJSON.challenge)
  .get()
  .then(doc => (doc.exists ? doc.get('userId') : null))

Step 9. origin が認証処理を行った FQDN と一致しているか検証

clientDataJSON.origin の値がサービスを提供している FQDN と一致をしているか確認します。

if (clientDataJSON.origin !== 'http://localhost:8080') {
  // エラー処理
}

Step 10. attestationObject を CBOR デコード

attestationObjectCBOR という形式のバッファデータです。
これをデータとして扱える形にデコードする必要があります。
デモでは cbor-js というパッケージを使ってデコード処理をしています。

const attestationObject = cbor.decode(attestationObjectBuffer)

attestationObject は以下のような構成になっています。

const attestationObject = {
  fmt: 'packed',
  authData: ArrayBuffer,
  attStmt: {
    alg: -257,
    sig: ArrayBuffer,
  }
}
プロパティ 説明
fmt String attStmt の形式を示す文字列。WebAuthn が扱える形式は現時点では "packed", "tpm", "android-key", "android-safetynet", "fido-u2f", "none" です
authData ArrayBuffer 公開鍵などの認証用のデータ群が格納された ArrayBuffer
attStmt Object 署名情報など。 fmt の種類よって返却値が変わります。 詳しい仕様は Defined Attestation Statement Formats を参照

attestationObject の構成

attestationObject の全体像は以下の図を見ると分かりやすいです。
fido-attestation-structures.png
Attestation object layout illustrating the included authenticator data - W3C

Step 11. attestationObject.authData をパース

attestationObject の全体像を見ての通り、 attestationObject.authData は取り決めされたルールによってバイトごとに情報が区切られたバッファデータです。

プロパティ byte 説明
rpIdHash 32 navigator.credentials.create() で設定した publicKey.rp.id の値を SHA256ハッシュ化したもの
flags 1 認証時の状態を示す真偽値。さらにbit単位に分割して検証する必要があります
signCount 4 認証器の検証回数。32ビットの符号なしビッグエンディアン整数で格納されてます
attestedCredentialData 可変 公開鍵情報などが含まれたデータ。この中もAttested Credential Data のルールに従ってデータを分割する必要があります
attestedCredentialData.aaguid 16 認証器ごとの識別情報
attestedCredentialData.credentialIdLength 2 attestedCredentialData.credentialId のバイト数。 16ビットの符号なしビッグエンディアン整数で格納されてます
attestedCredentialData.credentialId credentialIdLength の値 公開鍵に割り振られたユニークID
attestedCredentialData.credentialPublicKey 可変 公開鍵情報。COSE 形式で格納されています

Authenticator Data の構成

デモでは ArrayBuffer.slice() を使ってバイト毎にデータを分割して必要な情報を取り出しています。

const { authData } = attestationObject
const rpIdHash = authData.slice(0, 32)
const flags = authData[32]
const signCount = (authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]
const aaguid = authData.slice(37, 53)
const credentialIdLength = (authData[53] << 8) + authData[54]
const credentialId = authData.slice(55, 55 + credentialIdLength)
const credentialPublicKey = authData.slice(55 + credentialIdLength)

Step 12. attestationObject.authData.credentialPublicKey の公開鍵情報を JWK に変換

credentialPublicKey はCOSE という形式で格納されています。
これをテキストベースの情報であるJWK(JSON Web Key) 形式に変換しておくことでデータベースへの保存や署名の検証で取り扱いやすくなります。

デモでは cbor-js を使ってデータをデコードしつつ、JWK形式に変換しています。

const coseToJwk = cose => {
  try {
    let publicKeyJwk = {}
    const publicKeyCbor = cbor.decode(cose)
    if (publicKeyCbor[3] === -7) {
      publicKeyJwk = {
        kty: 'EC',
        crv: 'P-256',
        x: base64url(publicKeyCbor[-2]),
        y: base64url(publicKeyCbor[-3]),
      }
    } else if (publicKeyCbor[3] === -257) {
      publicKeyJwk = {
        kty: 'RSA',
        n: base64url(publicKeyCbor[-1]),
        e: base64url(publicKeyCbor[-2]),
      }
    } else {
      throw new Error('Unknown public key algorithm')
    }
    return publicKeyJwk
  } catch (e) {
    throw new Error('Could not decode COSE Key')
  }
}
const publicKeyJwk = coseToJwk(credentialPublicKey.buffer)

Step 13. attestationObject.attStmt の署名を検証

attestationObject.attStmt に認証器の署名情報があり、認証器が信頼のできるものかを検証する必要があります。
attestationObject.fmt で指定された形式ごとに署名の形式が違うため、形式に合わせて方法で検証する必要があります。

形式 説明
packed WebAuthnに最適化された認証形式
tpm 主にWindowsで使われる認証方式
android-key Android で使われる認証形式
android-safetynet Android の SafetyNet API に対応した認証形式
fido-u2f FIDO U2F に対応した認証形式
none 認証形式を提供しないことを明示

デモでは packed のみ、x5c の形式をカバーしてます(デモで全ての形式を網羅するの大変なので手を抜かせてもらいましたが、ホントは形式毎に検証処理を書くのが正しいです)
また、JWK 形式のデータを PEM に変換する際に jwk-to-pem というパッケージを使用しています。

const sha256 = data => {
  const hash = crypto.createHash('sha256')
  hash.update(data)
  return hash.digest()
}

const invalidSignature = ({
  authData,
  clientDataJSON,
  signature,
  pem,
  cryptType = 'sha256',
}) => {
  const clientDataHash = sha256(clientDataJSON)
  const verify = crypto.createVerify(cryptType)
  verify.update(authData)
  verify.update(clientDataHash)
  return !verify.verify(pem, signature)
}

const { fmt, attStmt } = attestationObject
const clientDataJSON = base64url.decode(req.body.clientDataJSON)

switch (fmt) {
  case 'packed':
    if ('x5c' in attStmt) {
      const [attestnCert] = attStmt.x5c
      const pem = '-----BEGIN CERTIFICATE-----\n'
        + base64url(attestnCert)
        + '\n-----END CERTIFICATE-----' 
      const signature = attStmt.sig
      if (
        invalidSignature({
          authData,
          clientDataJSON,
          pem,
          signature,
        })
      ) {
        // エラー処理
      }
    } else {
      const cryptType = publicKeyJwk.kty === 'RSA' ? 'RSA-SHA256' : 'sha256'
      const pem = jwkToPem(publicKeyJwk)
      const signature = attStmt.sig
      if (
        invalidSignature({
          authData,
          clientDataJSON,
          pem,
          signature,
          cryptType,
        })
      ) {
        // エラー処理
      }
    }
    break
}

署名情報の形式は以下の図のように、 attestationObject.authData と SHA-256ハッシュ化した clientDataJSON を結合したものです。

fido-signature-formats-figure2.png
Generating an assertion signature - W3C

注意点として、 clientDataJSON を SHA-256ハッシュ化する際には、リクエストデータを base64 デコードをして JSON.parse() を実行する前のバッファデータに対して行う必要があります。
JSON.parse() 実行後の clientDataJSON をハッシュ化すると、署名情報と差異がでてきてしまい検証に失敗してしまいます。

Step 14. attestationObject.authData.rpIdHash を検証

attestationObject.authData.rpIdHash の値が navigator.credentials.create() のオプションで指定した publicKey.rp.id の値と一致しているか確認します。
rpIdHasharraybuffer-to-buffer というパッケージを使って ArrayBuffer を Buffer に変換しつつハッシュ化された値と突き合わせます。

if (sha256('localhost').equals(arrayBufferToBuffer(rpIdHash)) === false) {
  // エラー処理
}

Step 15. attestationObject.authData.flags を検証

attestationObject.authData.flags は 1byte のバッファデータですが、
これを bit に分割して真偽値として検証します。
各 bit の仕様の詳細は flags を参照のこと。

今回は
- Bit 0: User Present (UP) ※ユーザーの存在を示す
- Bit 2: User Verified (UV) ※ユーザーの検証が正常に完了したことを示す
の値が それぞれ true であることを検証します。

// Flags UP の一致を確認
if (Boolean(flags & 0x01) === false) {
  // エラー処理
}

// Flags UV の一致を確認
if (Boolean(flags & 0x04) === false) {
  // エラー処理
}

Step 16. 認証情報をデータベースに保存

Step 8~15 までの検証を全てクリアしたら、

  • credentials.idnavigator.credentials.create() が発行したID)
  • Step 12 で作成した JWK
  • attestationObject.authData.signCount

をユーザ識別情報に紐づけてデータベースに保存します。

await db
  .collection('credential')
  .doc(userId)
  .set({
    id: req.body.id,
    jwk: credentialPublicKey,
    signCount,
    timestamp: new Date().getTime(),
  })

Step 17. クライアントにレスポンスを返す

Step 16 で検証に成功してデータベース保存したなら成功ステータスを、
途中で検証に失敗したらエラーレスポンスを返して登録フローは終了です。

基本的に登録フローは最初の一回のみで良く、次回以降のアクセスは認証フローとなります。

認証時のフロー

認証時におけるクライアントサイドとサーバサイドの一連の流れをまとめてみました。
新規登録時と共通する部分は多いので、同じところは省略します。

navigator.credentials.get.png

Step 1. 非同期通信でチャレンジ発行APIにリクエスト

新規登録時と手順は一緒です。APIのリクエスト先のみが違います。

axios.post('/api/assertion/challenge', { userId: 'example@example.com' })

Step 2. チャレンジを発行し、ユーザ識別情報に紐づけてデータベースに保存し認証IDを取得

こちらも新規登録と手順は同じです。相違点としてはデータベース保存の後、ユーザー識別情報から保存されている認証IDを取得することです。
認証IDは後の navigator.credentials.get() 実行時のオプションで利用します。

const credentialId = await db
  .collection('credential')
  .doc(userId)
  .get()
  .then(doc => (doc.exists ? doc.get('id') : null))

Step 3. 発行したチャレンジと認証IDをレスポンスで返す

API はチャレンジ値と取得した認証IDをレスポンスデータに含めて返却をします。
認証器への処理を行う際にこのチャレンジ値と認証IDが必要になるためです。

Step 4. navigator.credentials.get() でユーザーの認証処理を行う

navigator.credentials.get() を実行することでブラウザから OS の認証器に向けて認証処理を行うことができます。
実行に必要最低限のオプションを適用したコード例は以下のような形になります。

const credentials = await navigator.credentials.get({
  publicKey: {
    allowCredentials: [
      {
        transports: ['internal'],
        type: 'public-key',
        id: base64url.toBuffer('iPe3vi-z090G1Cal-eBNSHjfL_oylWphYOB6JhWboaA').buffer,
      }
    ],
    challenge: base64url.toBuffer('Pj5VVSB2wIBmTU3NIiKg').buffer,
  },
})

navigator.credentials.create() 同様、publicKey オプションはほかにもいろいろな項目があるんですが、ここでは必要最低限の説明に留めておきます。

プロパティ 説明
allowCredentials[].transports[] String 認証方法の指定。"usb", "nfc", "ble", "internal" を指定できる。 internal は指定必須
allowCredentials[].type String 'public-key' の固定文字列
allowCredentials[].id ArrayBuffer 新規登録時に保存した認証ID(navigator.credentials.create() が発行したID)
challenge ArrayBuffer 発行されたチャレンジ値

publicKey オプションの構成

challenge allowCredentials[].id は ArrayBuffer 型に変換する必要があります。
デモでは base64url を使って文字列を ArrayBuffer に変換しています。

Step 5. 認証器が情報を返却する

認証器での認証が成功すると 、認証時の状態や署名情報などを含むデータを返却します。

{
  "id": "iPe3vi-z090G1Cal-eBNSHjfL_oylWphYOB6JhWboaA",
  "rawId": ArrayBuffer,
  "type": "public-key",
  "response": {
    "authenticatorData": ArrayBuffer,
    "clientDataJSON": ArrayBuffer,
    "signature": ArrayBuffer,
    "userHandle": ArrayBuffer
  }
}

id, rawId, type, response.clientDataJSONnavigator.credentials.create() の返却値と同義のものになるので、説明は省略します。

プロパティ 説明
response.authenticatorData ArrayBuffer rpIdHashsignCount などの認証情報を含んだ ArrayBuffer
response.signature ArrayBuffer 認証の署名を ArrayBuffer にしたもの
response.userHandle ArrayBuffer ユーザーの識別情報を ArrayBuffer にしたもの

navigator.credentials.get() の返却値
navigator.credentials.get().response の返却値

Step 6. 非同期通信で認証APIにリクエスト

認証器からの返却値を認証検証用のAPIに非同期通信でリクエストを送ります。
検証には clientDataJSONauthenticatorData signature が必要になり、それぞれ ArrayBuffer 型なので、navigator.credentials.create() のとき同様、API に送る前に base64 化してから送ります。

デモでは以下のように base64url で ArrayBuffer を String に変換して送信しています。

await axios.post('/api/assertion/verify', {
  "authenticatorData": base64url(credentials.response.authenticatorData),
  "clientDataJSON": base64url(credentials.response.clientDataJSON),
  "signature": base64url(credentials.response.signature),
})

Step 7. clientDataJSON, authenticatorData, signature を base64 デコード

navigator.credentials.create() のときと同じ手順で、受け取ったデータを base64 デコードします。

const clientDataJSON = JSON.parse(base64url.decode(req.body.clientDataJSON))
const authenticatorData= base64url.toBuffer(req.body.authenticatorData)
const signature = base64url.toBuffer(req.body.signature)

clientDataJSON は以下のような内容で構成されています。

const clientDataJSON = {
  challenge: 'lTbl5U06Hra0SEowP2SH',
  origin: 'http://localhost:8080',
  type: 'webauthn.get',
}

typewebauthn.get となっている以外は navigator.credentials.create() と同じです。

Step 8. チャレンジをデータベースに照会してユーザーの正当性を検証

navigator.credentials.create() の Step 8と同じです。

Step 9. origin が認証処理を行った FQDN と一致しているか検証

navigator.credentials.create() の Step 9と同じです。

Step 10. authenticatorData をパース

navigator.credentials.create() の Step 11 と手順は一緒で、バッファデータを指定のバイトで分割して処理をします。
navigator.credentials.create() と違い、値が rpIdHash, flags, signCount の3つのみです。

Step 11. authenticatorData.rpIdHash を検証

navigator.credentials.create() の Step 14と同じです。

Step 12. authenticatorData.flags を検証

navigator.credentials.create() の Step 15と同じです。

Step 13. データベースに保存されている認証情報一式を取得する

navigator.credentials.create() の Step 16で保存した情報をロードします。

const credential = await db
  .collection('credential')
  .doc(userId)
  .get()
  .then(doc => (doc.exists ? doc.data() : null))

Step 14. signature を公開鍵で検証

navigator.credentials.create() の Step 13で行った署名検証と同じ流れで、保存された公開鍵を使って送信されてきた署名情報を検証をします。

const clientDataJSON = base64url.decode(req.body.clientDataJSON)
const cryptType = credential.jwk.kty === 'RSA' ? 'RSA-SHA256' : 'sha256'
const pem = jwkToPem(credential.jwk)
if (
  invalidSignature({
    authData,
    clientDataJSON,
    pem,
    signature,
    cryptType,
  })
) {
  // エラー処理
}

Step 15. authenticatorData.signCount の値を比較検証

データベースに保存されていた signCount と APIに送られてきた authenticatorData.signCount の値を比較して、データベースの値よりもAPIから送信された値の方が大きいことを確認します。
正規ユーザーであればこの値が逆転することはなくユーザー本人の環境からの認証であることの一つの理由になるためです。

  if (signCount !== 0 && signCount < credential.signCount) {
    // エラー処理
  }

Step 16. authenticatorData.signCount の更新情報をデータベースに保存

signCount の比較が正しければ、データベースの signCount を更新しておきます。

await db
  .collection('credential')
  .doc(userId)
  .update({
    signCount,
    timestamp: new Date().getTime(),
  })

Step 17. 従来の認証処理を行う

Step 8~16までの処理でエラーが一度も発生せずに検証が完了したら、従来の認証と同じログインセッションを発行する処理を行います。

Step 18. クライアントにレスポンスを返す

検証にすべて成功したなら成功のレスポンスを、
途中で検証に失敗したらエラーレスポンスを返して認証フローは終了です。
お疲れさまでした。

まとめ

ユーザーの認証を簡単にする WebAuthn、実装には上記で紹介した手順を踏む必要がありました。

フロントエンド側の処理は Web Authentication API を使って生成したデータを非同期で API に投げるだけなのでとても簡単ですが、バンクエンド側の処理は各種デコードやバッファのパース、形式別の署名検証など、やることが多くてなかなかに大変でした。
検証処理自体は定型的なものなので、いったん型に嵌めてしまえば次回からはラクできそうな感じではあります。

また、WebAuthn は秘密鍵をデバイスに保存するものなので、アカウントの認証機構を WebAuthn のみとすると、デバイスが物理的に利用不能になった時点でアカウントへのアクセスができずに色々と詰んでしまいます。
ですので、WebAuthn は従来の認証に併用する形でユーザーの利便性を高めるためのオプション的な位置づけで提供される形にすると良いでしょう。

29
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
24