パスワードレス認証の旗手である 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 のエンドポイントが必要になり、実行される手順は以下のような形になります。
- 新規登録時、ユーザ ID に紐付いたチャレンジレスポンスを発行する API
- 新規登録時、ブラウザの認証器が発行した公開鍵を検証し、データベースに保存する API
- 認証チェック時、ユーザ ID に紐付いたチャレンジレスポンスを発行する API
- ブラウザの認証器が発行した署名をサーバに保存した公開鍵で検証してセッションを発行する API
クライアントサイドからやって来るデータはバッファデータも含まれているので、それらを処理する必要もあります。
新規登録時のフロー
新規登録時におけるクライアントサイドとサーバサイドの一連の流れをまとめてみました。
サーバサイド側の検証項目が多くて大変ですが、一つずつステップに分けて解説します。
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 | 発行されたチャレンジ値 |
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に非同期通信でリクエストを送ります。
返却値の clientDataJSON
と attestationObject
は 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' 固定 |
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 デコード
attestationObject
は CBOR という形式のバッファデータです。
これをデータとして扱える形にデコードする必要があります。
デモでは 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
の全体像は以下の図を見ると分かりやすいです。
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 形式で格納されています |
デモでは 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
を結合したものです。
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
の値と一致しているか確認します。
rpIdHash
を arraybuffer-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.id
(navigator.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 で検証に成功してデータベース保存したなら成功ステータスを、
途中で検証に失敗したらエラーレスポンスを返して登録フローは終了です。
基本的に登録フローは最初の一回のみで良く、次回以降のアクセスは認証フローとなります。
認証時のフロー
認証時におけるクライアントサイドとサーバサイドの一連の流れをまとめてみました。
新規登録時と共通する部分は多いので、同じところは省略します。
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 | 発行されたチャレンジ値 |
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.clientDataJSON
は navigator.credentials.create()
の返却値と同義のものになるので、説明は省略します。
プロパティ | 型 | 説明 |
---|---|---|
response.authenticatorData | ArrayBuffer |
rpIdHash や signCount などの認証情報を含んだ ArrayBuffer |
response.signature | ArrayBuffer | 認証の署名を ArrayBuffer にしたもの |
response.userHandle | ArrayBuffer | ユーザーの識別情報を ArrayBuffer にしたもの |
navigator.credentials.get() の返却値
navigator.credentials.get().response の返却値
Step 6. 非同期通信で認証APIにリクエスト
認証器からの返却値を認証検証用のAPIに非同期通信でリクエストを送ります。
検証には clientDataJSON
と authenticatorData
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',
}
type
が webauthn.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 は従来の認証に併用する形でユーザーの利便性を高めるためのオプション的な位置づけで提供される形にすると良いでしょう。