🦥

Spring Boot 3 & Yubico java-webauthn-server でパスキー認証を実装する

2024/05/24に公開

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

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

何のメモか

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

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/9d97846dceb4efddb39ec46bc42112d257b6e82c/build.gradle.kts#L31

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 されます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerCredentialRepository.kt#L20-L30

  • getUserHandleForUsername
    • ユーザー名を入力した後にパスキー認証するときに Call されます。
    • 今回のサンプルの使い方(パスキーAutoFillで認証する方法)では Call されることはありません。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerCredentialRepository.kt#L32-L34

  • getUsernameForUserHandle
    • UserHandle から UserName を求める処理を実装します。
    • パスキー認証時に Call されます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerCredentialRepository.kt#L36-L40

  • lookup
    • Credential ID と UserHandle から登録済みのクレデンシャル情報を検索する処理を実装します。
    • パスキー認証時に Call されます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerCredentialRepository.kt#L43-L56

  • lookupAll
    • Credential ID をキーにして登録済みのクレデンシャル情報を検索します。
    • パスキー登録時に Call されます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerCredentialRepository.kt#L58-L67

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 を使って作ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L31-L54

  • UserIdentityオブジェクトを作る
    • ユーザー識別情報を埋め込んだクラスです。
    • UserIdentity.builder() を使って作ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L59-L63

  • AuthenticatorSelectionCriteriaオブジェクトとを作る
    • 登録オプションを埋め込んだクラスです。
    • AuthenticatorSelectionCriteria.builder() を使って作ります。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L65-L70

  • StartRegistrationOptionsオブジェクトを作る
    • StartRegistrationOptions.builder() を使って作ります。
    • UserIdentityオブジェクト, AuthenticatorSelectionCriteriaオブジェクト を埋め込みます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L72-L76

RelyingPartyとStartRegistrationOptionsができたら登録オプションが作成できます。RelyingParty#startRegistration を使って PublicKeyCredentialCreationOptionsオブジェクトを作って、それをフロントに渡します。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L78

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

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/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-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/resources/static/js/index.js#L164-L187

2-4. POST /register/verify

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

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/controller/Fido2RestController.kt#L43

2-5. Verify

  • PublicKeyCredentialオブジェクトを作る
    • フロントから送られたパスキー登録結果をJSONのまま PublicKeyCredential#parseRegistrationResponseJson に食わせてPublicKeyCredentialオブジェクトを作成します

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L85

  • FinishRegistrationOptionsオブジェクトを作る
    • FinishRegistrationOptions.builder() を使います。
    • PublicKeyCredentialCreationOptions と PublicKeyCredential を埋め込みます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L87-L90

RelyingParty#finishRegistration の引数に FinishRegistrationOptions を渡してVerifyします。これが成功すればOKです。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L92

Verifyを無事パスすると結果がもらえます。この中に入っている以下のものをDBに保存しときます。

  • Credential ID: パスキーのIDみたいなものです、ログインするときもこのIDがキーになります。
  • SignatureCounter: 認証カウンター。パスキーを使う度にカウントアップしていく値で、この値で不正にコピーされたものかどうかチェックするみたいなんですけど、GoogleやiCloudキーチェーンのパスキーは常に0なんですよねー。でも一応保存しておきます。
    • Yubikeyを使って登録するとカウンターにはちゃんと値が入っています。
  • Credential PublicKey: パスキーの公開鍵です。秘密鍵はパスキー作成元が持っています。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L94-L98

2-6. CredProps について

登録オプションと登録結果をみると extensionscredProps というのが付いてまして、これ何だ?って話です。

登録オプション

"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 が設定されるようです。

https://github.com/Yubico/java-webauthn-server/blob/63366e1ac9310d4a0bfdd35e0ba9f3794780ad26/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java#L491-L495

credPropsとは何なのか?

credProps とは 生成されたクレデンシャルのプロパティをよこせ という設定です。

https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension

クレデンシャルのプロパティって何かというと、仕様では CredentialPropertiesOutput って定義されています。

https://www.w3.org/TR/webauthn-3/#ref-for-dictdef-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として登録しね」って指定をしています。

https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey

https://www.w3.org/TR/webauthn-3/#enum-residentKeyRequirement

なので、credPropsについてはあまり気にしなくてもパスキーとしてちゃんと登録されるはずです。

credPropsの値をDBに保存しておけば、クレデンシャルがパスキーかどうかっていう判断に使えるかな、ってところかもしれません。

以上 CredPropsの話でした。

3. パスキー認証

3-1. POST /authenticate/option

パスキーのAutofillを使っているんで、フォームロード時にこのエンドポイントがCallされます。なのでユーザー情報は何もありません。

3-2. 認証オプション生成

  • StartAssertionOptionsオブジェクトを作る
    • StartAssertionOptions.builder() を使います。
    • ユーザー情報は何もセットしないで、「ユーザー認証ちゃんとやれよ」っていう意味の UserVerificationRequirement.REQUIRED を埋め込みます

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L102-L106

StartAssertionOptionsができたら登録オプションが作成できます。RelyingParty#startAssertion を使って AssertionRequest オブジェクトを作って、それをフロントに渡します。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L108

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

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/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-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/resources/static/js/index.js#L235-L261

3-4. POST /authenticate/verify

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

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/Fido2AuthenticationProvider.kt#L24

3-5. Verify

  • PublicKeyCredentialオブジェクトを作る
    • フロントから送られたパスキー認証結果をJSONのまま PublicKeyCredential.parseAssertionResponseJson() に食わせてPublicKeyCredentialオブジェクトを作成します

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L116

  • FinishAssertionOptionsオブジェクトを作る
    • FinishAssertionOptions.builder() を使います。
    • AssertionRequest と PublicKeyCredential を埋め込みます。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L118-L121

RelyingParty#finishAssertion の引数に FinishAssertionOptions を渡してVerifyします。これが成功すればOKです。

Verifyを無事パスすると結果がもらえます。この中にユーザー情報が入っているんで、そのユーザーでログインします。

https://github.com/gebogebogebo/spring-boot-3-passkey-yubico/blob/31291df0b841011da33364b29709adc0b75ab828/src/main/kotlin/com/example/springyubico/service/yubico/YubicoWebauthnServerServiceImpl.kt#L123-L125

おつかれさまでした

簡単は簡単ですけどなんだかよくわからない用語が多くてこれでいいんだろうかと思いつつ雰囲気で動いているからヨシとしているレベルです。

Discussion