Spring Security 7でPasskey認証を実装する:WebAuthn入門

Webアプリケーションの認証といえば、これまでは、

メールアドレス
+
パスワード

という方式が一般的でした。

しかし近年では、

  • Windows Hello
  • Touch ID
  • Face ID
  • Androidの画面ロック
  • パスワードマネージャー

などを使ってログインできる**Passkey(パスキー)**が広がっています。

PasskeyはWebAuthnという標準技術をベースにしており、Spring Securityでも利用できます。

Spring Security 7では、

implementation 'org.springframework.security:spring-security-webauthn'

を利用することで、Spring BootアプリケーションへWebAuthn認証を組み込めます。

この記事では、

Spring Boot
     │
     │ WebAuthn
     ▼
ブラウザ
     │
     ▼
Windows Hello
Touch ID
Face ID
Passkey Manager

という構成を実際に見ながら、

  • Passkeyとは何か
  • WebAuthnとは何か
  • 公開鍵認証の仕組み
  • Passkeyの登録処理
  • Passkeyによるログイン
  • Spring Security 7での設定
  • Credentialの保存
  • Reactから利用する場合の考え方

まで解説します。

Passkeyとは

Passkeyは、パスワードの代わりに公開鍵暗号方式を利用してユーザーを認証する仕組みです。

一般的なパスワード認証では、

ユーザー
   │
   │ password
   ▼
Server
   │
   ▼
Password Hash

という形になります。

サーバー側には通常、パスワードそのものではなくハッシュ値を保存します。

一方Passkeyでは、

Authenticator

Private Key
秘密鍵
    │
    │
    └─────────×

Server

Public Key
公開鍵

という形になります。

ポイントは、

秘密鍵をWebアプリケーションのサーバーへ送信しない

ことです。

サーバー側には認証に必要な公開鍵などの情報を保存します。

WebAuthnとは

PasskeyをWebアプリケーションから利用するための中心技術が、

WebAuthn(Web Authentication API)

です。

ブラウザには、

navigator.credentials.create()

と、

navigator.credentials.get()

というAPIがあります。

ざっくり分けると、

navigator.credentials.create()

→ Passkey登録
navigator.credentials.get()

→ Passkey認証

です。

Spring Securityは、このブラウザ側のWebAuthn処理と連携し、

Challenge生成
Credential保存
署名検証
ユーザー認証

などのサーバー側処理を担当します。

Passkey認証の全体像

まず大きな流れを理解しましょう。

Passkeyには、

1. Passkeyを登録する

2. Passkeyでログインする

という2つの処理があります。

登録時は、

Spring Boot
    │
    │ Challenge
    ▼
Browser
    │
    ▼
Authenticator
    │
    ├─ Private Key
    │
    └─ Public Key
            │
            ▼
       Spring Boot

となります。

秘密鍵はAuthenticator側に残り、Spring Boot側には公開鍵を含むCredential情報が保存されます。

認証時は、

Spring Boot
    │
    │ Challenge
    ▼
Browser
    │
    ▼
Authenticator
    │
    │ Private Keyで署名
    ▼
Browser
    │
    │ Signature
    ▼
Spring Boot
    │
    │ Public Keyで検証
    ▼
Authentication Success

となります。

つまり、パスワードそのものをサーバーへ送って照合する方式とはかなり違います。

Challengeとは

WebAuthnを理解するときに重要なのが、

Challenge

です。

例えばSpring Bootからブラウザへ、

challenge =
q7lCdd3SVQxdC-v8pnRAGEn1B2M-t7ZECWPwCAmhWvc

というランダムな値を渡します。

AuthenticatorはこのChallengeを含むデータに対して署名します。

Spring Boot側では、

送ったChallenge
+
返ってきた署名
+
登録済みPublic Key

を使って検証します。

そのため、過去に取得した認証レスポンスをそのまま再送するような攻撃にも強い構造になっています。

Relying Partyとは

WebAuthnでは、

Relying Party(RP)

という言葉がよく登場します。

簡単にいうと、

WebAuthn認証を利用するWebサービス

です。

例えば、

https://example.com

でPasskeyを利用するのであれば、

RP ID

example.com

のようになります。

Spring Securityでも後ほど、

.rpId("example.com")

を設定します。

RP IDとOriginの違い

ここは少し分かりづらいポイントです。

例えば、

https://app.example.com:443

でWebアプリケーションを公開しているとします。

Originは、

https://app.example.com

のように、

scheme
+
host
+
port

の組み合わせです。

一方、RP IDは、

example.com

や、

app.example.com

のようなドメインです。

RP IDには、

https://

やポート番号を含めません。

本番環境ではWebAuthnは基本的にHTTPS環境で利用します。

ローカル開発では、

http://localhost:8080

のようなlocalhostが特別に利用できます。


開発環境

今回は次の環境を利用します。

Java 21
Spring Boot 4.1.1
Spring Security 7.x
Gradle

build.gradle

まず依存関係を追加します。

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.security:spring-security-webauthn'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

重要なのが、

implementation 'org.springframework.security:spring-security-webauthn'

です。

Spring Security 7ではWebAuthn関連機能が専用モジュールとして提供されています。

SecurityFilterChainを設定する

次にSpring Securityを設定します。

package com.example.passkey.config;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );

        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {

        UserDetails user = User
            .withUsername("user@example.com")
            .password("{noop}password")
            .roles("USER")
            .build();

        return new InMemoryUserDetailsManager(user);
    }
}

これだけで、

Form Login
+
WebAuthn

を利用できるようになります。

なお、

{noop}password

はローカル検証用です。

本番環境では適切なPasswordEncoderを利用してください。

なぜパスワード認証も設定しているのか

ここで疑問が出るかもしれません。

Passkeyの記事なのに、なぜformLoginを設定しているの?

理由は、最初のPasskeyをユーザーへ登録する必要があるからです。

Spring Securityの標準的なPasskey登録フローでは、

現在ログインしているユーザー

に対してCredentialを登録します。

そのため最初は、

Username / Password
        ↓
Login
        ↓
Passkey登録

という流れになります。

一度Passkeyを登録すれば、その後はPasskeyを使って認証できます。

つまり、

初回

Password Login
      ↓
Passkey登録


次回以降

Passkey
   ↓
Login

というイメージです。

Passkeyを登録してみる

今回の設定では、Spring SecurityがPasskey登録用のページを提供します。

まず通常のログインページから、

username:
user@example.com

password:
password

でログインします。

ログインした状態で、

/webauthn/register

へアクセスします。

Passkeyを登録すると、

Windows Hello
Touch ID
Face ID
Authenticator
Password Manager

など、利用している環境に応じた認証UIがブラウザから表示されます。

Spring Security内部では何が起きているのか

ここからが重要です。

Passkey登録は1回のHTTP通信だけで完了するわけではありません。

大きく、

1. Registration Options取得

2. Credential作成

3. Credential登録

という処理になります。

① Registration Optionsを取得する

ブラウザから、

POST /webauthn/register/options

へリクエストします。

Spring Securityは、

{
  "rp": {
    "name": "WebAuthn",
    "id": "localhost"
  },
  "user": {
    "name": "user@example.com",
    "id": "...",
    "displayName": "user@example.com"
  },
  "challenge": "...",
  "pubKeyCredParams": [
    {
      "type": "public-key",
      "alg": -7
    }
  ]
}

のような情報を返します。

重要なのが、

"challenge": "..."

です。

② navigator.credentials.create()を実行する

取得したOptionsをブラウザのWebAuthn APIへ渡します。

イメージとしては、

const credential =
    await navigator.credentials.create({
        publicKey: options
    });

です。

するとブラウザから、

Windows Hello

Touch ID

Face ID

Security Key

などが呼び出されます。

Authenticatorでは鍵ペアが生成されます。

Private Key
    │
    └─ Authenticator側

Public Key
    │
    └─ Spring Bootへ登録

となります。

③ CredentialをSpring Bootへ登録する

Authenticatorから返ってきたCredentialを、

POST /webauthn/register

へ送ります。

Spring SecurityがCredentialを検証し、問題なければユーザーへ紐付けて保存します。

Spring Security側の処理イメージは、

POST /webauthn/register

        ↓

WebAuthnRegistrationFilter

        ↓

Credential検証

        ↓

UserCredentialRepository

        ↓

Credential保存

です。

Spring Securityが用意しているFilterが登録処理を担当するため、

@RestController
class WebAuthnController {
}

のようなControllerを自分で一から実装する必要はありません。

Passkey認証をしてみる

Passkey登録が終わったら、一度ログアウトします。

次に再びログイン画面へアクセスします。

Passkey認証では、まずSpring Securityへ、

POST /webauthn/authenticate/options

を送ります。

するとSpring Securityから、

{
  "challenge": "...",
  "timeout": 300000,
  "rpId": "localhost",
  "allowCredentials": [],
  "userVerification": "preferred"
}

のような情報が返ります。

navigator.credentials.get()を実行する

ブラウザでは、

const credential =
    await navigator.credentials.get({
        publicKey: options
    });

を実行します。

すると、

Windows Hello

Touch ID

Face ID

Passkey Manager

などによるユーザー確認が行われます。

Authenticatorは秘密鍵を使ってChallengeへ署名します。

Challenge

   ↓

Private Key

   ↓

Signature

重要なのは、ここでも秘密鍵そのものがSpring Bootへ送信されるわけではないことです。

認証情報をSpring Securityへ送る

ブラウザ側で取得したCredentialを、

POST /login/webauthn

へ送信します。

処理はおおよそ、

POST /login/webauthn
        │
        ▼
WebAuthnAuthenticationFilter
        │
        ▼
WebAuthnAuthenticationProvider
        │
        ▼
WebAuthn検証
        │
        ▼
UserDetailsService
        │
        ▼
Authentication
        │
        ▼
認証成功

となります。

つまり、

Form Login

UsernamePasswordAuthenticationFilter

に相当する入口として、

WebAuthn

WebAuthnAuthenticationFilter

が存在すると考えるとSpring Security経験者には分かりやすいでしょう。

なぜPasskeyなら本人だと分かるのか

例えばユーザーがPasskey登録時に、

Private Key A
Public Key A

という鍵ペアを作ったとします。

Spring Bootには、

Public Key A

が保存されています。

ログイン時にはSpring Bootが、

Challenge X

を送ります。

Authenticatorは、

Challenge X
+
Private Key A

を使って署名します。

Spring Bootは、

Signature
+
Public Key A

から署名が正しいか確認します。

つまり、

Public Key Aに対応するPrivate Key Aを本当に持っている

ことを確認できるわけです。

パスワード認証との大きな違い

パスワード認証では、

秘密情報

Password

をユーザーとサーバー側の認証システムの両方が扱います。

Passkeyでは、

Client

Private Key


Server

Public Key

と役割が分かれます。

そのためサーバーからCredential情報が漏えいした場合でも、保存された公開鍵だけから秘密鍵を復元してログインすることはできません。

Credentialはどこに保存されるのか

Spring SecurityのWebAuthnでは主に、

PublicKeyCredentialUserEntityRepository

UserCredentialRepository

というRepositoryが利用されます。

役割を簡単にすると、

PublicKeyCredentialUserEntityRepository

ユーザー情報
UserCredentialRepository

Passkey Credential情報

です。

デフォルトではメモリ上へ保存されます。

つまり、

アプリ停止
↓
Passkey情報消失

となるため、本番環境ではそのまま利用できません。

JDBCでCredentialを保存する

Spring SecurityにはJDBC用のRepositoryも用意されています。

@Bean
JdbcPublicKeyCredentialUserEntityRepository
        publicKeyCredentialUserEntityRepository(
                JdbcOperations jdbc
        ) {

    return new JdbcPublicKeyCredentialUserEntityRepository(
        jdbc
    );
}

@Bean
JdbcUserCredentialRepository
        userCredentialRepository(
                JdbcOperations jdbc
        ) {

    return new JdbcUserCredentialRepository(
        jdbc
    );
}

これによって、

Spring Security
      │
      ▼
JDBC
      │
      ▼
Database

へCredentialを永続化できます。

Spring SecurityにはそれぞれのRepositoryが利用するスキーマ定義も用意されています。

実務では、

Userテーブル

WebAuthn User Entity

Credential

の関係を整理して設計する必要があります。

CSRFを無効化しないように注意する

API開発では、

csrf(csrf -> csrf.disable())

としているプロジェクトもあるかもしれません。

しかしWebAuthnでは、

POST /webauthn/register/options

POST /webauthn/register

POST /webauthn/authenticate/options

POST /login/webauthn

などのPOSTリクエストを利用します。

Spring Security標準のWebAuthnフローではChallenge情報もサーバー側に保持されるため、CSRFについてきちんと設計する必要があります。

特にReactなどのSPAからSpring Bootを呼び出す場合、

React
http://localhost:3000

        ↓

Spring Boot
http://localhost:8080

のようにOriginが分かれるケースでは、

CORS
Cookie
SameSite
CSRF
allowedOrigins

をセットで考える必要があります。

Reactから利用する場合

Spring Securityが生成するデフォルト画面を使わず、

React
+
Spring Boot API

で自前画面を作ることもできます。

登録処理は、

React

POST /webauthn/register/options

        ↓

Spring Boot

        ↓

React

navigator.credentials.create()

        ↓

Authenticator

        ↓

React

POST /webauthn/register

        ↓

Spring Boot

となります。

認証処理は、

React

POST /webauthn/authenticate/options

        ↓

Spring Boot

        ↓

React

navigator.credentials.get()

        ↓

Authenticator

        ↓

React

POST /login/webauthn

        ↓

Spring Boot

です。

ArrayBufferの変換に注意する

ReactやJavaScriptでWebAuthnを実装するときに少し面倒なのが、

Base64URL
⇔
ArrayBuffer

の変換です。

Spring Bootから取得した、

{
  "challenge": "..."
}

をそのまま、

navigator.credentials.get()

へ渡すわけではありません。

WebAuthn APIではChallengeなどのバイナリデータをArrayBufferとして扱います。

そのため、

Spring Boot

Base64URL

    ↓ decode

ArrayBuffer

    ↓

navigator.credentials.get()

とします。

逆にAuthenticatorから返ってきた、

ArrayBuffer

は、

ArrayBuffer

    ↓ Base64URL encode

JSON

    ↓

Spring Boot

へ変換します。

Spring Securityのデフォルト画面を利用すると、この辺りを理解しなくても動作確認できます。

そのため最初は、

Spring Securityのデフォルトページで仕組みを確認する

↓

Reactで独自画面を作る

という順番がおすすめです。

SessionレスAPIではどうする?

バックエンドAPIを開発している場合、

Spring Security
+
JWT
+
Session STATELESS

という構成も多いでしょう。

ここは注意が必要です。

Spring Security標準のWebAuthn実装では、Credential登録・認証で利用するOptionsをリクエスト間で保持します。

例えば登録では、

POST /webauthn/register/options
        ↓
Challenge生成
        ↓
一時保存

POST /webauthn/register
        ↓
Challenge検証

となります。

デフォルトの、

PublicKeyCredentialCreationOptionsRepository

ではHttpSessionが利用されます。

つまり、

SessionCreationPolicy.STATELESS

の完全なステートレスAPIへそのまま追加すればよい、という話ではありません。

JWT認証とPasskeyを組み合わせるなら

例えばSPAの場合、

React
     │
     │ WebAuthn
     ▼
Spring Security
     │
     │ Authentication Success
     ▼
JWT発行
     │
     ▼
React

という構成も考えられます。

ただし、

Challengeをどこに保持するのか

Authentication成功後にどうJWTを発行するのか

Cookieを利用するのか

アクセストークンとして返すのか

などを別途設計する必要があります。

Passkeyを導入したからといって、

JWT不要

Session不要

になるわけではありません。

Passkeyはあくまで、

ユーザー本人をどう認証するか

の仕組みです。

認証後の、

Session

JWT

OAuth 2.0 Access Token

とは別のレイヤーとして考えると分かりやすいでしょう。

PasskeyとOAuth 2.0 / OIDCは競合する?

これも混乱しやすいところです。

例えば、

Spring Boot
     ↓
OIDC
     ↓
Keycloak

という構成があるとします。

この場合、ユーザー認証自体をKeycloakなどのIdPが担当しています。

Passkeyを、

Spring Boot

側へ実装するのではなく、

Identity Provider

側で提供する設計もあります。

つまり、

Web Application

      ↓ OIDC

Identity Provider

      ↓ Passkey

User

という構成です。

外部IdPを利用している場合には、

Passkeyをどのシステムの責務にするのか

を先に整理することが重要です。

RP IDの設定には注意する

本番環境では、

.webAuthn(webAuthn -> webAuthn
    .rpId("example.com")
    .allowedOrigins("https://example.com")
)

のように設定します。

例えばフロントエンドが、

https://app.example.com

であれば、

.allowedOrigins(
    "https://app.example.com"
)

など、実際のOriginに合わせる必要があります。

WebAuthnではCredentialがRP IDに紐付くため、

example.com

で作ったPasskeyを、

example-other.com

から自由に利用することはできません。

これがフィッシング耐性にも関わる重要な仕組みです。

本番ではHTTPSを利用する

WebAuthnではセキュアなOriginが前提になります。

基本的には、

https://example.com

で利用します。

ただしローカル開発用の、

http://localhost:8080

は例外として利用できます。

そのため、

ローカル

http://localhost:8080
本番

https://example.com

という構成で開発できます。

Passkeyを削除する機能も必要

実際のサービスでは、

Passkey登録

だけでは不十分です。

例えば、

MacBook
iPhone
Windows PC
Security Key

のように複数Credentialを登録する可能性があります。

そのため設定画面として、

登録済みPasskey

・Windows Hello
・iPhone
・1Password

[削除]

のようなCredential管理機能も考える必要があります。

Spring SecurityのUserCredentialRepositoryではCredential IDを利用した削除処理も用意されています。

アカウント復旧方法も設計する

Passkeyだけでログインするサービスを作る場合、

Passkeyをすべて失ったらどうするのか?

も重要です。

例えば、

スマートフォン紛失

PC故障

Passkey Managerへアクセスできない

といったケースです。

そのため実務では、

複数Passkey登録

Recovery Code

本人確認による復旧

メール認証

外部IdP

など、アカウント復旧手段まで含めて認証設計を考える必要があります。

PasskeyをMFAとして使う方法もある

Passkeyは必ずしも、

パスワードを完全に廃止する

ためだけに使うものではありません。

例えば、

Password
+
WebAuthn

という多要素認証に利用することもできます。

Spring Security 7.1ではWebAuthn Credentialが登録されているユーザーに対して、多要素認証を条件付きで要求する仕組みも強化されています。

つまり、

従来認証
    +
Passkey

という段階的な導入も可能です。

Spring Securityが担当する範囲

ここまでを見ると、

WebAuthnってかなり複雑では?

と思うかもしれません。

実際、プロトコル自体は複雑です。

しかしSpring Securityを使うことで、

Registration Options生成

Challenge管理

Credential登録

Credential保存

Authentication Options生成

署名検証

Authentication生成

といった部分をSpring Security側へ任せられます。

アプリケーション開発者は、

SecurityFilterChain設定

UserDetailsService

Credential永続化

フロントエンド

アカウント管理

を中心に実装できます。

Password認証とPasskey認証を比較

簡単に整理すると次のようになります。

項目PasswordPasskey
認証情報Password公開鍵・秘密鍵
サーバー保存Password HashPublic Keyなど
ClientPassword入力Authenticator
フィッシング耐性利用者依存RP IDに紐付く
パスワード管理必要不要にできる
ブラウザAPI不要WebAuthn
実装難易度比較的低いやや高い

Passkeyには大きなメリットがありますが、

RP ID
Origin
Credential
Challenge
Authenticator
Recovery

など、パスワード認証にはなかった概念も理解する必要があります。

実際のアプリケーション構成

Spring BootでPasskeyを導入する場合、最終的には次のような構成になります。

               Browser / React
                     │
                     │ WebAuthn API
                     ▼
               Authenticator
             Windows Hello
               Touch ID
                Face ID
                     │
                     ▼
               Spring Boot
                     │
              Spring Security
                     │
            WebAuthn Filters
                     │
          ┌──────────┴──────────┐
          │                     │
     UserDetailsService   CredentialRepository
                                │
                                ▼
                            Database

Passkeyはブラウザだけ、Spring Bootだけで完結する技術ではありません。

Browser
+
Authenticator
+
Spring Security
+
Database

が協調して認証処理を行います。

まとめ

Spring Security 7では、

implementation 'org.springframework.security:spring-security-webauthn'

を利用することでWebAuthn・Passkey認証を実装できます。

基本設定は、

http
    .formLogin(withDefaults())
    .webAuthn(webAuthn -> webAuthn
        .rpId("example.com")
        .allowedOrigins("https://example.com")
    );

です。

Passkey登録では、

Registration Options取得
        ↓
navigator.credentials.create()
        ↓
Credential登録

という処理が行われます。

Passkey認証では、

Authentication Options取得
        ↓
navigator.credentials.get()
        ↓
Signature検証
        ↓
Authentication成功

という流れになります。

特に覚えておきたいのは、

秘密鍵
↓
Authenticator側

公開鍵
↓
Server側

という構造です。

秘密鍵そのものをSpring Bootへ送信して認証するわけではありません。

そしてSpring Bootエンジニアとして実務で注意したいのが、

Credentialの永続化

CSRF

Session

ReactとのCORS

RP ID / Origin

JWTとの組み合わせ

Passkey紛失時のRecovery

です。

特に普段、

SessionCreationPolicy.STATELESS

でREST APIを開発している場合、

Passkeyも普通のJWTログインAPIと同じように実装できる

と考えてしまうとハマりやすいでしょう。

WebAuthnではChallengeを発行し、そのChallengeに対する署名を後続リクエストで検証するため、登録・認証という複数ステップの認証Ceremonyとして考える必要があります。

Spring SecurityがWebAuthnの複雑な部分をかなり吸収してくれるため、まずはデフォルトのログイン画面と登録画面で動かしてみるのがおすすめです。

その後、

Spring Boot
+
React
+
Passkey
+
JWT

まで拡張すると、Spring SecurityとWebAuthnへの理解がかなり深まるでしょう。

是非フォローしてください

最新の情報をお伝えします

類似投稿