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認証を比較
簡単に整理すると次のようになります。
| 項目 | Password | Passkey |
|---|---|---|
| 認証情報 | Password | 公開鍵・秘密鍵 |
| サーバー保存 | Password Hash | Public Keyなど |
| Client | Password入力 | 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への理解がかなり深まるでしょう。
是非フォローしてください
最新の情報をお伝えします
