🦥

Spring Boot 3 & WebAuthn4J でパスキー認証を実装する

2024/06/02に公開

パスキー実装のお勉強メモです

パスキーのサーバーライブラリの使い方をまとめました。

何のメモか

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 に 追記

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/build.gradle.kts#L31

2. パスキー登録

2-1. POST /register/option

画面で Register ボタンをクリックするとこのエンドポイントに飛んできます。
ここでパスキー登録のための情報を生成してフロントに返します。

2-2. 登録オプション生成

登録オプションは PublicKeyCredentialCreationOptions なんですが、これのコンストラクタに渡す引数がたくさんあります。順番に作成していきます。

  • PublicKeyCredentialRpEntity

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L52-L57

  • PublicKeyCredentialUserEntity

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L67-L71

  • Challenge
    • チャレンジは DefaultChallenge() を使って作成します

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L65

  • List<PublicKeyCredentialParameters> pubKeyCredParams

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L73-L85

  • List<PublicKeyCredentialDescriptor> excludeCredentials

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L87-L94

  • AuthenticatorSelectionCriteria

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L96-L103

  • AttestationConveyancePreference

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/83621191c2546e7e6a04bc0ee2490ec41d25d9a7/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L105-L109

PublicKeyCredentialCreationOptions を使って登録オプションを作って、それをフロントに渡します。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L111-L122

重要なポイントとして PublicKeyCredentialCreationOptionsオブジェクト は 登録結果を検証するためにまた必要になります。このサンプルではセッションに丸ごと保存しています。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/controller/Fido2RestController.kt#L30

2-3. navigator.credentials.create() 〜 指紋認証

サーバーで生成した登録オプションを navigator.credentials.create() に渡してやると、WebAuthnで認証(macの場合 Touch ID) が起動します。Touch IDなどの認証をパスするとパスキー登録結果がゲットできるので、今度はそれをそのままサーバーに送ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/resources/static/js/index.js#L164-L187

2-4. POST /register/verify

パスキー登録の結果データを受け取ります。

まずはセッションに保存しておいた PublicKeyCredentialCreationOptionsを取り出します。これらのデータを使って登録の結果が正規のものであるか検証します。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/controller/Fido2RestController.kt#L43

2-5. Verify

Verifyは結構めんどくさいです。おおざっぱには

RegistrationData を作る → RegistrationParameters を作る → validate する

です。

RegistrationDataを作る

フロントから送られたパスキー登録結果を元に RegistrationData を作ります。

  1. PublicKeyCredentialオブジェクトを作る

    フロントから送られたパスキー登録結果をJSONから PublicKeyCredentialCreateResult に変換します

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L157

  1. RegistrationRequestを作る

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L162-L172

  1. RegistrationDataを作る

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L174-L179

RegistrationParametersを作る

  • ServerProperty

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L185-L194

  • List<PublicKeyCredentialParameters>
    • 登録結果の署名アルゴリズムがこれじゃなきゃダメって場合はこれで指定します。登録オプションでも指定しているのでここではnull(未指定)とします

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L196-L199

  • boolean userVerificationRequired

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L201-L203

RegistrationParameters を作成します

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L205-L209

validateする

RegistrationDataRegistrationParameters ができたらいよいよ検証です。これを無事パスしたら登録結果が正規のものであるということです。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L136-L141

Verifyを無事パスしたら、webauthn4j のマニュアルの手順に従って、 CredentialRecordCredential ID を作成します。

  • CredentialRecord: DBに保存するデータを作成するためのインタフェースです

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L144-L149

  • Credential ID: パスキーのIDみたいなものです、ログインするときもこのIDがキーになります

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L151

2-6. 登録情報を保存する

CredentialRecordCredential ID をユーザーに紐づけてDBに保存します。バイナリデータなんでなんだかよくわからないですが、そのまま保存してしまいます。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JCredentialServiceImpl.kt#L22-L40

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 のところ)

https://cloud.flect.co.jp/entry/2023/12/11/133526

仕様は決めたけど、ブラウザが実装してない、ってことみたいです。何だそりゃって感じですが、仕様が 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 を埋め込みます

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L214-L231

重要なポイントとして PublicKeyCredentialRequestOptionsオブジェクト は 認証結果を検証するためにまた必要になります。このサンプルではセッションに丸ごと保存しています。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/controller/Fido2RestController.kt#L71

3-3. navigator.credentials.get() 〜 指紋認証

サーバーで生成した認証オプションを navigator.credentials.get() に渡してやると、navigator.credentials.get()はUIスレッドをブロッキングせずにすぐに処理を終了します。で、ユーザーID入力欄にカーソルを当てるとパスキーのセレクタが表示されるようになります。ユーザーはこのセレクタから認証のUI(macの場合 Touch ID)を起動して認証します。Touch IDなどの認証をパスするとパスキー認証結果がゲットできるので、今度はそれをそのままサーバーに送ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/resources/static/js/index.js#L241-L261

3-4. POST /authenticate/verify

パスキー認証の結果データを受け取ります。先にセッションに保存しておいた PublicKeyCredentialRequestOptions を取り出します。これらを使ってこの結果が正規のものであるか検証します。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/Fido2AuthenticationProvider.kt#L24

3-5. Verify

AuthenticationData を作る → CredentialRecord を作る → AuthenticationParameters を作る → validate する

です。

AuthenticationDataを作る

フロントから送られたパスキー認証結果のJSONから AuthenticationRequest を作ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L260-L277

そのままの流れで AuthenticationData を作ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L279-L284

CredentialRecordを作る

作成した AuthenticationData に入っている userHandlecredentialId を元に DBに保存してあるクレデンシャルを取り出して CredentialRecord を作成します。このときに同時に対象のユーザーIDも求めます(認証のVerifyが成功したらこのユーザーでログインします)。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L240-L245

AuthenticationParametersを作る

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L293-L313

validateする

Verifyを無事パスしたら先ほど求めたユーザーでログインします。

https://github.com/gebogebogebo/spring-boot-3-passkey-webauthn4j/blob/63a167305fee6ac5936ac90134561348a6fb1673/src/main/kotlin/com/example/springwebauthn4j/service/webauthn4j/WebAuthn4JServerServiceImpl.kt#L249-L254

3-6. カウンタの扱い

そういえば、DBにカウンタも保存しているんですけど、使ってないです。(M_FIDO_CREDENTIAL_FOR_WEBAUTHN4JテーブルのSIGN_COUNT)

WebAuthn4J ではカウンタの検証はしない のでやるなら自分で実装する必要があります。

今回のサンプルではカウンタの検証は実装してません。

おつかれさまでした

WebAuthn4J は 日本語のリファレンスがあってわかりやすかったです。

https://webauthn4j.github.io/webauthn4j/ja/

Discussion