Spring Boot 3 & Yubico java-webauthn-server でパスキー認証を実装する
パスキー実装のお勉強メモです
パスキーのサーバーライブラリの使い方をまとめました。
- Yubico java-webauthn-server ← 今回はこっち
- WebAuthn4J → こちらを参照
何のメモか
Spring Boot と Yubico の java-webauthn-server を使ってパスキー認証サンプルアプリを作成してみました。
- 主に java-webauthn-server の使い方をメモっています。
- Spring Securityのことは詳しく書いていません。
- フロントの実装のことも詳しく書いていません。
- ソースはGitHubにおいてあります
環境
- macOS
- ブラウザは Chrome
- IDE は IntelliJ
- Spring Boot 3.3.0
- Spring Security
- Kotlin
- Java17
- Yubico java-webauthn-server 2.5.2
- 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. java-webauthn-server を使う
1-1. build.gradle に 追記
1-2. RelyingPartyクラスとCredentialRepositoryインタフェースについて
java-webauthn-server では RelyingPartyクラス と CredentialRepositoryインタフェース がポイントになります。これらの使い方を理解すると実装は楽です。
- RelyingPartyクラス
- パスキーの登録や認証の処理を実行するメソッドを持っています
- startRegistration(): 登録オプションの取得
- finishRegistration(): 登録処理
- startAssertion(): 認証オプションの取得
- finishAssertion(): 認証処理
- CredentialRepositoryインタフェース
- パスキー登録データ(クレデンシャル)を検索するメソッドが定義されています
- RelyingPartyの中から利用されます
- アプリではこのインタフェースの処理を実装をする必要があります
1-3. CredentialRepository を作成する
まず最初に CredentialRepository の実装クラスを作成します。
以下のメソッドを Override する必要があります。これらのメソッドは RelyingParty の中から必要に応じて Call されます。
- getCredentialIdsForUsername
- User に紐づく Credential 情報を検索する処理を実装します。
- パスキー登録時に Call されます。
- getUserHandleForUsername
- ユーザー名を入力した後にパスキー認証するときに Call されます。
- 今回のサンプルの使い方(パスキーAutoFillで認証する方法)では Call されることはありません。
- getUsernameForUserHandle
- UserHandle から UserName を求める処理を実装します。
- パスキー認証時に Call されます。
- lookup
- Credential ID と UserHandle から登録済みのクレデンシャル情報を検索する処理を実装します。
- パスキー認証時に Call されます。
- lookupAll
- Credential ID をキーにして登録済みのクレデンシャル情報を検索します。
- パスキー登録時に Call されます。
2. パスキー登録
2-1. POST /register/option
画面で Register
ボタンをクリックするとこのエンドポイントに飛んできます。
ここでパスキー登録のための情報を生成してフロントに返します。
2-2. 登録オプション生成
RelyingParty#startRegistration を使って WebAuthn登録に必要なオプション情報を作成するんですが、オプションもいろいろあるんで、まあまあ手間がかかります。
ただ、java-webauthn-server のクラスは ステップビルダーパターン っていうんでしょうか、Builderを使って必要な情報を埋め込んで build してオブジェクトをGetするってやり方になってまして、そのお作法の通りにやればいつの間にか実装ができている感じです。
- RelyingPartyオブジェクトを作る
- RelyingPartyオブジェクトを作るためには RelyingPartyIdentity、CredentialRepository が必要です。
- CredentialRepository はさっき作ったのがあるので、それを使います。
- RelyingPartyIdentity は RelyingPartyIdentity#builder を使って作ります。
- UserIdentityオブジェクトを作る
- ユーザー識別情報を埋め込んだクラスです。
- UserIdentity.builder() を使って作ります。
- AuthenticatorSelectionCriteriaオブジェクトとを作る
- 登録オプションを埋め込んだクラスです。
- AuthenticatorSelectionCriteria.builder() を使って作ります。
- StartRegistrationOptionsオブジェクトを作る
- StartRegistrationOptions.builder() を使って作ります。
- UserIdentityオブジェクト, AuthenticatorSelectionCriteriaオブジェクト を埋め込みます。
RelyingPartyとStartRegistrationOptionsができたら登録オプションが作成できます。RelyingParty#startRegistration を使って 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
- PublicKeyCredentialオブジェクトを作る
- フロントから送られたパスキー登録結果をJSONのまま PublicKeyCredential#parseRegistrationResponseJson に食わせてPublicKeyCredentialオブジェクトを作成します
- FinishRegistrationOptionsオブジェクトを作る
- FinishRegistrationOptions.builder() を使います。
- PublicKeyCredentialCreationOptions と PublicKeyCredential を埋め込みます。
RelyingParty#finishRegistration の引数に FinishRegistrationOptions を渡してVerifyします。これが成功すればOKです。
Verifyを無事パスすると結果がもらえます。この中に入っている以下のものをDBに保存しときます。
- Credential ID: パスキーのIDみたいなものです、ログインするときもこのIDがキーになります。
- SignatureCounter: 認証カウンター。パスキーを使う度にカウントアップしていく値で、この値で不正にコピーされたものかどうかチェックするみたいなんですけど、GoogleやiCloudキーチェーンのパスキーは常に0なんですよねー。でも一応保存しておきます。
- Yubikeyを使って登録するとカウンターにはちゃんと値が入っています。
- Credential PublicKey: パスキーの公開鍵です。秘密鍵はパスキー作成元が持っています。
2-6. CredProps について
登録オプションと登録結果をみると extensions
に credProps
というのが付いてまして、これ何だ?って話です。
登録オプション
"credProps": true
ってのが付いてます。
{
"rp": {
"name": "yubico-webauthn-server-test",
"id": "localhost"
},
"user": {
"name": "user1",
"displayName": "ユーザー1",
"id": "X1VTRVIx"
},
"attestation": "none",
"authenticatorSelection": {
"authenticatorAttachment": null,
"requireResidentKey": true,
"residentKey": "required",
"userVerification": "required"
},
"challenge": "SrLCVR6zgX7AbT482-gokjwvwB08gXtnKMOzmWYyqyo",
"excludeCredentials": [],
"pubKeyCredParams": [
{ "alg": -7, "type": "public-key"},
{ "alg": -8, "type": "public-key"},
{ "alg": -35, "type": "public-key"},
{ "alg": -36, "type": "public-key"},
{ "alg": -257, "type": "public-key"},
{ "alg": -258, "type": "public-key"},
{ "alg": -259, "type": "public-key"}
],
"timeout": 60000,
"extensions": {
"appidExclude": null,
"credProps": true,
"largeBlob": null,
"uvm": null
},
}
登録結果
"credProps": { "rk": true }
ってのが付いてます
{
"id": "AB47zPqsRJJFcVvmYftjvlwkGEA",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2Vi...",
"attestationObject": "o2NmbXRkbm9uZW...",
"transports": [
"hybrid",
"internal"
]
},
"type": "public-key",
"clientExtensionResults": {
"credProps": {
"rk": true
}
}
}
サンプルアプリでは credProps オプションを指定してないのですが、java-webauthn-server(2.5.2) のコードをみると credProps はデフォルトで true が設定されるようです。
credPropsとは何なのか?
credProps
とは 生成されたクレデンシャルのプロパティをよこせ という設定です。
クレデンシャルのプロパティって何かというと、仕様では CredentialPropertiesOutput
って定義されています。
要約すると、プロパティには rk
っていうのがあって、これは discoverable credential のときは true, そうでないときは falseになる とのこと。
クレデンシャルにユーザー情報を含んでいるものを、discoverable credential といいます。パスキーがユーザー名もパスワードも入力しないでログインできるのはこの discoverable credential からユーザー情報を取り出しているからです。なので、discoverable credential として登録されてないとパスキーとして使えないということになります。
つまり、credProps とは ちゃんとパスキーとして使えるように登録されたかどうかのフラグ ってことになるかと思います。
VerifyではcredPropsがfalseだとエラーになるようになってる?
java-webauthn-server(2.5.2) のコードを見た感じだと、そういうチェックは見当たりませんでした。(見つけられなかっただけかもしれませんが)
ということはユーザーのコードでそういうチェックをするようにしたほうがいいのか?ってことになるんですが、そういうのはいらないんじゃなかと思います。
そもそも、登録オプションで
"residentKey": "required",
"userVerification": "required"
ってやってて、これにより「絶対にdiscoverable credentialとして登録しね」って指定をしています。
なので、credPropsについてはあまり気にしなくてもパスキーとしてちゃんと登録されるはずです。
credPropsの値をDBに保存しておけば、クレデンシャルがパスキーかどうかっていう判断に使えるかな、ってところかもしれません。
以上 CredPropsの話でした。
3. パスキー認証
3-1. POST /authenticate/option
パスキーのAutofillを使っているんで、フォームロード時にこのエンドポイントがCallされます。なのでユーザー情報は何もありません。
3-2. 認証オプション生成
- StartAssertionOptionsオブジェクトを作る
- StartAssertionOptions.builder() を使います。
- ユーザー情報は何もセットしないで、「ユーザー認証ちゃんとやれよ」っていう意味の
UserVerificationRequirement.REQUIRED
を埋め込みます
StartAssertionOptionsができたら登録オプションが作成できます。RelyingParty#startAssertion を使って AssertionRequest オブジェクトを作って、それをフロントに渡します。
重要なポイントとして AssertionRequestオブジェクト は 認証結果を検証するために後でまた必要になります。このサンプルではセッションに丸ごと保存しています。
3-3. navigator.credentials.get() 〜 指紋認証
サーバーで生成した認証オプションを navigator.credentials.get() に渡してやると、navigator.credentials.get()はUIスレッドをブロッキングせずにすぐに処理を終了します。で、ユーザーID入力欄にカーソルを当てるとパスキーのセレクタが表示されるようになります。ユーザーはこのセレクタから認証のUI(macの場合 Touch ID)を起動して認証します。Touch IDなどの認証をパスするとパスキー認証結果がゲットできるので、今度はそれをそのままサーバーに送ります。
3-4. POST /authenticate/verify
パスキー認証の結果データを受け取ります。先にセッションに保存しておいた AssertionRequest を使ってこの結果が正規のものであるか検証します。
3-5. Verify
- PublicKeyCredentialオブジェクトを作る
- フロントから送られたパスキー認証結果をJSONのまま PublicKeyCredential.parseAssertionResponseJson() に食わせてPublicKeyCredentialオブジェクトを作成します
- FinishAssertionOptionsオブジェクトを作る
- FinishAssertionOptions.builder() を使います。
- AssertionRequest と PublicKeyCredential を埋め込みます。
RelyingParty#finishAssertion の引数に FinishAssertionOptions を渡してVerifyします。これが成功すればOKです。
Verifyを無事パスすると結果がもらえます。この中にユーザー情報が入っているんで、そのユーザーでログインします。
おつかれさまでした
簡単は簡単ですけどなんだかよくわからない用語が多くてこれでいいんだろうかと思いつつ雰囲気で動いているからヨシとしているレベルです。
Discussion