Spring Boot 3 & WebAuthn4J でパスキー認証を実装する
パスキー実装のお勉強メモです
パスキーのサーバーライブラリの使い方をまとめました。
- WebAuthn4J ← 今回はこっち
- Yubico java-webauthn-server → こちらを参照
何のメモか
Spring Boot と webauthn4j を使ってパスキー認証サンプルアプリを作成してみました。
- 主に webauthn4j の使い方をメモっています。
- Spring Securityのことは詳しく書いていません。
- フロントの実装のことも詳しく書いていません。
- ソースはGitHubにおいてあります
環境
- macOS
- ブラウザは Chrome
- IDE は IntelliJ
- Spring Boot 3.3.0
- Spring Security
- Kotlin
- Java17
- WebAuthn4J 0.24.0.RELEASE
- Thymeleaf
- javascript
サンプルアプリ概要
Database
-
H2 Database を使ってます
-
http://localhost:8080/h2-console からコンソールに接続してください
-
接続情報
- Driver Class: org.h2.Driver - JDBC URL: jdbc:h2:./test - User Name: sa - Password: (empty)
-
テーブルは M_USER, M_FIDO_CREDENTIAL_FOR_YUBICO の2つです
create table M_USER ( INTERNAL_ID varchar(32) not null primary key, USER_ID varchar(32) not null unique, DISPLAY_NAME varchar(64) not null, PASSWORD varchar(128) not null ); create table M_FIDO_CREDENTIAL_FOR_YUBICO ( ID int default 0 not null auto_increment primary key, USER_INTERNAL_ID varchar(32) not null, CREDENTIAL_ID varbinary(1000) not null unique, SIGN_COUNT bigint default 0 not null, CREDENTIAL_PUBLIC_KEY varbinary(1000) not null ); INSERT INTO M_USER (INTERNAL_ID, USER_ID, DISPLAY_NAME, PASSWORD) VALUES ( '_USER1', 'user1', 'ユーザー1', '{bcrypt}$2a$10$xeYLBfOQILT1XKYhofosg.a3I1Vg8vF6Kd4NXjfigyy/.N.7AwYU.' ), ( '_USER2', 'user2', 'ユーザー2', '{bcrypt}$2a$10$142YrOgdho1EvrXhstuYMuD.6l5XrJt4yyJ6t6kcJLi7bHvDzpF3O' ), ( '_USER3', 'user3', 'ユーザー3', '{bcrypt}$2a$10$WlijXJStltiGmakfhoRBQuMy2Xlw6EOtnbrMQRg65tlF0aU5y2.7i' )
UserName/Passwordでログインする
以下のユーザーが登録済みです。ログインのためのIDとパスワードは以下のとおり
- user1 / password1
- user2 / password2
- user3 / password3
パスキーを登録する
Register
ボタンからパスキーを登録します
パスキーでログインする
ユーザー名の入力欄からパスキーをAutoFillして認証します
サンプルアプリ実装
1. webauthn4j を使う
1-1. build.gradle に 追記
2. パスキー登録
2-1. POST /register/option
画面で Register
ボタンをクリックするとこのエンドポイントに飛んできます。
ここでパスキー登録のための情報を生成してフロントに返します。
2-2. 登録オプション生成
登録オプションは PublicKeyCredentialCreationOptions
なんですが、これのコンストラクタに渡す引数がたくさんあります。順番に作成していきます。
- PublicKeyCredentialRpEntity
- PublicKeyCredentialUserEntity
- Challenge
- チャレンジは DefaultChallenge() を使って作成します
- List<PublicKeyCredentialParameters> pubKeyCredParams
- List<PublicKeyCredentialDescriptor> excludeCredentials
- AuthenticatorSelectionCriteria
- AttestationConveyancePreference
PublicKeyCredentialCreationOptions を使って登録オプションを作って、それをフロントに渡します。
重要なポイントとして PublicKeyCredentialCreationOptionsオブジェクト は 登録結果を検証するためにまた必要になります。このサンプルではセッションに丸ごと保存しています。
2-3. navigator.credentials.create() 〜 指紋認証
サーバーで生成した登録オプションを navigator.credentials.create() に渡してやると、WebAuthnで認証(macの場合 Touch ID) が起動します。Touch IDなどの認証をパスするとパスキー登録結果がゲットできるので、今度はそれをそのままサーバーに送ります。
2-4. POST /register/verify
パスキー登録の結果データを受け取ります。
まずはセッションに保存しておいた PublicKeyCredentialCreationOptionsを取り出します。これらのデータを使って登録の結果が正規のものであるか検証します。
2-5. Verify
Verifyは結構めんどくさいです。おおざっぱには
RegistrationData
を作る → RegistrationParameters
を作る → validate
する
です。
RegistrationDataを作る
フロントから送られたパスキー登録結果を元に RegistrationData
を作ります。
-
PublicKeyCredentialオブジェクトを作る
フロントから送られたパスキー登録結果をJSONから
PublicKeyCredentialCreateResult
に変換します
- RegistrationRequestを作る
- RegistrationDataを作る
RegistrationParametersを作る
- ServerProperty
- List<PublicKeyCredentialParameters>
- 登録結果の署名アルゴリズムがこれじゃなきゃダメって場合はこれで指定します。登録オプションでも指定しているのでここではnull(未指定)とします
- boolean userVerificationRequired
RegistrationParameters を作成します
validateする
RegistrationData
と RegistrationParameters
ができたらいよいよ検証です。これを無事パスしたら登録結果が正規のものであるということです。
Verifyを無事パスしたら、webauthn4j のマニュアルの手順に従って、 CredentialRecord
と Credential ID
を作成します。
- CredentialRecord: DBに保存するデータを作成するためのインタフェースです
- Credential ID: パスキーのIDみたいなものです、ログインするときもこのIDがキーになります
2-6. 登録情報を保存する
CredentialRecord
と Credential ID
をユーザーに紐づけてDBに保存します。バイナリデータなんでなんだかよくわからないですが、そのまま保存してしまいます。
2-7. Token Binding ID とは?
ServerPropertyのコンストラクタに指定する tokenBindingId
って何なの?って話です。
どうやら、この ID を使って中間者攻撃の検出ができるみたいです。
なのですが、WebAuthn 仕様書 5.8.1. Client Data Used in WebAuthn Signatures を見ると、「利用は想定されていません」ってことが書かれてあります。
NOTE: While Token Binding was present in Level 1 and Level 2 of WebAuthn, its use is not expected in Level 3. The tokenBinding field is reserved so that it will not be reused for a different purpose.
なんだかよくわからないのですが、こちらに事情が書かれています(Channel Binding のところ)
仕様は決めたけど、ブラウザが実装してない、ってことみたいです。何だそりゃって感じですが、仕様が Too much だったってことなんでしょうか?
2-8. 登録結果(Attestation) に署名を付けてないけどいいのか?
PublicKeyCredentialCreationOptionsのコンストラクタに指定するattestationに AttestationConveyancePreference.NONE
って指定していて、これだと登録結果が正規のものだかどうだか確認してないんじゃない?ってところなんですけど。
たぶん厳密にチェックしたい場合は INDIRECT
とか DIRECT
を指定して署名検証をしっかりやったほうがいいと思います。一方で Attestation にちゃんと対応していないパスキープロバイダもいたりなんかする場合があるかもしれません(?)
NIST SP 800-63B-3 Supplement には こんなことが書いてありました。この記載がここでいうAttestationのことかどうか今ひとつわからないのですが、要件に応じてAttestationを検証しましょうってことかなと思います。
For enterprise use cases, agencies SHOULD implement attestation capabilities
...
Attestations SHOULD NOT be used for broad public-facing applications.
3. パスキー認証
3-1. POST /authenticate/option
パスキーのAutofillを使っているんで、フォームロード時にこのエンドポイントがCallされます。なのでユーザー情報は何もありません。
3-2. 認証オプション生成
-
PublicKeyCredentialRequestOptions
を作る- 登録時と同様に
DefaultChallenge()
を使ってチャレンジを生成します - ユーザー情報は何もセットしないで、「ユーザー認証ちゃんとやれよ」っていう意味の
UserVerificationRequirement.REQUIRED
を埋め込みます
- 登録時と同様に
重要なポイントとして PublicKeyCredentialRequestOptionsオブジェクト は 認証結果を検証するためにまた必要になります。このサンプルではセッションに丸ごと保存しています。
3-3. navigator.credentials.get() 〜 指紋認証
サーバーで生成した認証オプションを navigator.credentials.get() に渡してやると、navigator.credentials.get()はUIスレッドをブロッキングせずにすぐに処理を終了します。で、ユーザーID入力欄にカーソルを当てるとパスキーのセレクタが表示されるようになります。ユーザーはこのセレクタから認証のUI(macの場合 Touch ID)を起動して認証します。Touch IDなどの認証をパスするとパスキー認証結果がゲットできるので、今度はそれをそのままサーバーに送ります。
3-4. POST /authenticate/verify
パスキー認証の結果データを受け取ります。先にセッションに保存しておいた PublicKeyCredentialRequestOptions を取り出します。これらを使ってこの結果が正規のものであるか検証します。
3-5. Verify
AuthenticationData
を作る → CredentialRecord
を作る → AuthenticationParameters
を作る → validate
する
です。
AuthenticationDataを作る
フロントから送られたパスキー認証結果のJSONから AuthenticationRequest
を作ります。
そのままの流れで AuthenticationData を作ります。
CredentialRecordを作る
作成した AuthenticationData
に入っている userHandle
と credentialId
を元に DBに保存してあるクレデンシャルを取り出して CredentialRecord
を作成します。このときに同時に対象のユーザーIDも求めます(認証のVerifyが成功したらこのユーザーでログインします)。
AuthenticationParametersを作る
validateする
Verifyを無事パスしたら先ほど求めたユーザーでログインします。
3-6. カウンタの扱い
そういえば、DBにカウンタも保存しているんですけど、使ってないです。(M_FIDO_CREDENTIAL_FOR_WEBAUTHN4JテーブルのSIGN_COUNT)
WebAuthn4J ではカウンタの検証はしない のでやるなら自分で実装する必要があります。
今回のサンプルではカウンタの検証は実装してません。
おつかれさまでした
WebAuthn4J は 日本語のリファレンスがあってわかりやすかったです。
Discussion