Quarkus SecurityでWebAuthnを使用
このガイドでは、Quarkus アプリケーションで、パスワードの代わりに WebAuthn 認証を使用する方法を説明します。
この技術は、previewと考えられています。 preview では、下位互換性やエコシステムでの存在は保証されていません。具体的な改善には設定や API の変更が必要になるかもしれませんが、 stable になるための計画は現在進行中です。フィードバックは メーリングリスト や GitHub の課題管理 で受け付けています。 とりうるステータスの完全なリストについては、 FAQの項目 を参照してください。 |
前提条件
このガイドを完成させるには、以下が必要です:
-
約15分
-
IDE
-
JDK 17+がインストールされ、
JAVA_HOME
が適切に設定されていること -
Apache Maven 3.9.8
-
使用したい場合は、 Quarkus CLI
-
ネイティブ実行可能ファイルをビルドしたい場合、MandrelまたはGraalVM(あるいはネイティブなコンテナビルドを使用する場合はDocker)をインストールし、 適切に設定していること
-
A WebAuthn or PassKeys-capable device, or an emulator of those.
WebAuthnの紹介
WebAuthn は、パスワードを置き換えるために設計された認証メカニズムです。つまり、新しいユーザーを登録したり、ログインしたりするサービスを書くたびに、パスワードを要求する代わりに、WebAuthn を使って、パスワードを置き換えるのです。
WebAuthnは、パスワードをID証明に置き換えます。実際には、ユーザーは、パスワードを考案したり、保存したり、記憶したりする代わりに、あなたのサービスやウェブサイトのために特別にID証明を生成するハードウェアトークンを使用することになります。これは、ユーザーに携帯電話で親指を押してもらったり、パソコンで YubiKey のボタンを押したりすることで実現可能です。
そこで、ユーザーを登録する際に、ブラウザーでユーザー情報(ユーザー名、自分の名前など)を入力しパスワードを入力して本人確認をする代わりに、ボタンをクリックすると、WebAuthn ブラウザー API が実行され、何か(ボタンを押す、指紋を使う)をするように要求されます。すると、ブラウザーはパスワードの代わりに、サービスに送信できるID証明を生成します。
この登録時のID証明は、ほとんどが公開鍵で構成されています。実際にはいろいろなものがありますが、最も興味深いのは公開鍵です。この公開鍵は、あなたのデバイスやブラウザに保存されているわけではありません。この公開鍵は、対象となるサービスのために特別に生成され(そのURIに関連付けられます)、ハードウェア認証デバイスから導出されたものです。したがって、ハードウェア認証デバイスとターゲットサービスの関連付けは、常に同じ秘密鍵と公開鍵のペアを導き出すことになります。例えば、YubiKeyを別のコンピュータに持ち出しても、同じターゲットサービスに対して同じ秘密鍵と公開鍵を生成し続けることができます。
そのため、登録時にはパスワードの代わりに(主に)公開鍵を送信し、サービスはその情報を新しいユーザーアカウントのWebAuthnクレデンシャルとして保存し、これが後にあなたを識別することになります。
そして、そのサービスにログインする必要があるとき、パスワードを入力する代わりに(これは存在しませんよ)、ログインフォームのボタンを押すと、ブラウザーが何かを尋ねてきて、パスワードの代わりに署名をサービスに送信してくれます。その署名にはあなたの認証ハードウェアと対象サービスから得られる秘密鍵が必要なので、あなたのサービスがそれを受け取ると、それがあなたがクレデンシャルとして保存した公開鍵の署名と一致するかどうかを検証することができるのです。
つまり、まとめると、登録時にはパスワードの代わりに生成した公開鍵を送信し、ログイン時にはその公開鍵に対する署名を送信することで、ユーザーが登録時の人物であることを確認することができるのです。
実際にはもう少し複雑で、ハードウェア認証機能を使う前にサーバーとハンドシェイクする必要があるため(チャレンジを求めるなど)、サービスには常に2つの呼び出しがあります。1つはログインや登録の一つ前のハードウェア認証デバイスを呼び出す前、そしてもう1つは通常のログインや登録の時です。
また、公開鍵以外にも保存するフィールドはたくさんありますが、それについては私たちがお手伝いします。
Just in case you get there wondering what’s the relation with PassKeys and whether we support it: sure, yes, PassKeys is a way that your authenticator devices can share and sync their credentials, which you can then use with our WebAuthn authentication.
The WebAuthn specification requires HTTPS to be used for communication with the server, though
some browsers allow localhost . If you must use HTTPS in dev mode, you can always use the
quarkus-ngrok extension.
|
アーキテクチャ
この例では、4つのエンドポイントを提供する、非常にシンプルなマイクロサービスを構築します:
-
/api/public
-
/api/public/me
-
/api/users/me
-
/api/admin
/api/public
エンドポイントは匿名でアクセスすることができます。 /api/public/me
エンドポイントは匿名でアクセスでき、現在のユーザー名がある場合はそのユーザー名を、ない場合は <not logged in>
を返します。 /api/admin
エンドポイントは RBAC (Role-Based Access Control) で保護されており、 admin
のロールを与えられたユーザーのみがアクセスすることができます。このエンドポイントでは、 @RolesAllowed
アノテーションを使用して、アクセス制約を宣言的に適用しています。 /api/users/me
のエンドポイントも RBAC (Role-Based Access Control) で保護されており, user
のロールが付与されたユーザのみがアクセスできるようになっています.レスポンスとして、ユーザーに関する詳細情報を含むJSONドキュメントが返されます。
ソリューション
次の章で紹介する手順に沿って、ステップを踏んでアプリを作成することをお勧めします。ただし、完成した例にそのまま進んでも構いません。
Gitレポジトリをクローンするか git clone https://github.com/quarkusio/quarkus-quickstarts.git
、 アーカイブ をダウンロードします。
ソリューションは security-webauthn-quickstart
ディレクトリ にあります。
Mavenプロジェクトの作成
まず、新しいプロジェクトが必要です。以下のコマンドで新規プロジェクトを作成します。
Windowsユーザーの場合:
-
cmdを使用する場合、(バックスラッシュ
\
を使用せず、すべてを同じ行に書かないでください)。 -
Powershellを使用する場合は、
-D
パラメータを二重引用符で囲んでください。例:"-DprojectArtifactId=security-webauthn-quickstart"
選択したデータベースコネクターライブラリを追加することを忘れないでください。ここでは、PostgreSQLをIDストアとして使用しています。 |
このコマンドは Maven プロジェクトを生成し、WebAuthn を使ってユーザー認証を行うための security-webauthn
エクステンションをインポートします。
Quarkusプロジェクトをすでに設定している場合は、プロジェクトのベースディレクトリで次のコマンドを実行することで、 security-webauthn
エクステンションをプロジェクトに追加できます。
quarkus extension add security-webauthn
./mvnw quarkus:add-extension -Dextensions='security-webauthn'
./gradlew addExtension --extensions='security-webauthn'
これにより、 pom.xml
に以下が追加されます:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-webauthn</artifactId>
</dependency>
implementation("io.quarkus:quarkus-security-webauthn")
アプリケーションの記述
まず、 /api/public
のエンドポイントを実装することから始めましょう。以下のソースコードからわかるように、これは通常のJakarta RESTリソースに過ぎません:
package org.acme.security.webauthn;
import java.security.Principal;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/public")
public class PublicResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String publicResource() {
return "public";
}
@GET
@Path("/me")
@Produces(MediaType.TEXT_PLAIN)
public String me(@Context SecurityContext securityContext) {
Principal user = securityContext.getUserPrincipal();
return user != null ? user.getName() : "<not logged in>";
}
}
/api/admin
エンドポイントのソースコードも非常にシンプルです。ここでの主な違いは、 admin
ロールで付与されたユーザーだけがエンドポイントにアクセスできるように @RolesAllowed
アノテーションを使用していることです:
package org.acme.security.webauthn;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/admin")
public class AdminResource {
@GET
@RolesAllowed("admin")
@Produces(MediaType.TEXT_PLAIN)
public String adminResource() {
return "admin";
}
}
最後に、 /api/users/me
エンドポイントを考えてみましょう。下のソースコードを見ればわかるように、 user
の役割を持つユーザのみを信頼しています。現在認証されているプリンシパルへのアクセスを得るために SecurityContext
を使用しており、ユーザーの名前を返します。この情報はデータベースから読み込まれます。
package org.acme.security.webauthn;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/users")
public class UserResource {
@GET
@RolesAllowed("user")
@Path("/me")
public String me(@Context SecurityContext securityContext) {
return securityContext.getUserPrincipal().getName();
}
}
WebAuthnのクレデンシャルの保存
これで、WebAuthn クレデンシャルがデータベースにどのように保存されるかを、3 つのエンティティで説明できるようになりました。ユーザー (実際には複数の WebAuthn クレデンシャルとロールなどの他のデータを持つことができます) あたり 1 つのクレデンシャルしか保存しないように、モデルを簡略化したことに注意してください。
package org.acme.security.webauthn;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.vertx.ext.auth.webauthn.Authenticator;
import io.vertx.ext.auth.webauthn.PublicKeyCredential;
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"userName", "credID"}))
@Entity
public class WebAuthnCredential extends PanacheEntity {
/**
* The username linked to this authenticator
*/
public String userName;
/**
* The type of key (must be "public-key")
*/
public String type = "public-key";
/**
* The non user identifiable id for the authenticator
*/
public String credID;
/**
* The public key associated with this authenticator
*/
public String publicKey;
/**
* The signature counter of the authenticator to prevent replay attacks
*/
public long counter;
public String aaguid;
/**
* The Authenticator attestation certificates object, a JSON like:
* <pre>{@code
* {
* "alg": "string",
* "x5c": [
* "base64"
* ]
* }
* }</pre>
*/
/**
* The algorithm used for the public credential
*/
public PublicKeyCredential alg;
/**
* The list of X509 certificates encoded as base64url.
*/
@OneToMany(mappedBy = "webAuthnCredential")
public List<WebAuthnCertificate> x5c = new ArrayList<>();
public String fmt;
// owning side
@OneToOne
public User user;
public WebAuthnCredential() {
}
public WebAuthnCredential(Authenticator authenticator, User user) {
aaguid = authenticator.getAaguid();
if(authenticator.getAttestationCertificates() != null)
alg = authenticator.getAttestationCertificates().getAlg();
counter = authenticator.getCounter();
credID = authenticator.getCredID();
fmt = authenticator.getFmt();
publicKey = authenticator.getPublicKey();
type = authenticator.getType();
userName = authenticator.getUserName();
if(authenticator.getAttestationCertificates() != null
&& authenticator.getAttestationCertificates().getX5c() != null) {
for (String x5c : authenticator.getAttestationCertificates().getX5c()) {
WebAuthnCertificate cert = new WebAuthnCertificate();
cert.x5c = x5c;
cert.webAuthnCredential = this;
this.x5c.add(cert);
}
}
this.user = user;
user.webAuthnCredential = this;
}
public static List<WebAuthnCredential> findByUserName(String userName) {
return list("userName", userName);
}
public static List<WebAuthnCredential> findByCredID(String credID) {
return list("credID", credID);
}
}
また、クレデンシャルのための第二のエンティティが必要です:
package org.acme.security.webauthn;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
@Entity
public class WebAuthnCertificate extends PanacheEntity {
@ManyToOne
public WebAuthnCredential webAuthnCredential;
/**
* The list of X509 certificates encoded as base64url.
*/
public String x5c;
}
そして最後に、私たちのユーザー・エンティティーです:
package org.acme.security.webauthn;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
@Table(name = "user_table")
@Entity
public class User extends PanacheEntity {
@Column(unique = true)
public String userName;
// non-owning side, so we can add more credentials later
@OneToOne(mappedBy = "user")
public WebAuthnCredential webAuthnCredential;
public static User findByUserName(String userName) {
return User.find("userName", userName).firstResult();
}
}
ユーザー名とクレデンシャルIDに関する注意点
WebAuthnは、ユーザー名(ユーザーごとにユニーク)とクレデンシャルID(認証デバイスごとにユニーク)の組み合わせに依存します。
このように識別子が2つ存在する理由、およびそれらがクレデンシャル自体のユニークキーでない理由は次のとおりです:
-
1人のユーザーが複数の認証デバイスを持つことができるため、1つのユーザー名が複数のクレデンシャルIDに対応し、そのすべてが同じユーザーを識別することができます。
-
一人の人が異なるユーザー名で複数のユーザーアカウントを持ち、その全員が同じ認証デバイスを持つことができるため、認証デバイスは複数のユーザーで共有されることがあります。そのため、1つのクレデンシャルIDを複数の異なるユーザーが使用することがあります。
ただし、ユーザー名とクレデンシャルIDの組み合わせは、クレデンシャルテーブルのユニーク制約である必要があります。
Quarkus WebAuthnにエンティティを公開する
Quarkus WebAuthn エクステンションでクレデンシャルをロードおよび保存できるようにするには、 WebAuthnUserProvider
を実装する bean を定義する必要があります。ここで、データモデルを WebAuthn セキュリティモデルに変換する方法を Quarkus に指示します。
package org.acme.security.webauthn;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.auth.webauthn.AttestationCertificates;
import io.vertx.ext.auth.webauthn.Authenticator;
import jakarta.transaction.Transactional;
import static org.acme.security.webauthn.WebAuthnCredential.findByCredID;
import static org.acme.security.webauthn.WebAuthnCredential.findByUserName;
@Blocking
@ApplicationScoped
public class MyWebAuthnSetup implements WebAuthnUserProvider {
@Transactional
@Override
public Uni<List<Authenticator>> findWebAuthnCredentialsByUserName(String userName) {
return Uni.createFrom().item(toAuthenticators(findByUserName(userName)));
}
@Transactional
@Override
public Uni<List<Authenticator>> findWebAuthnCredentialsByCredID(String credID) {
return Uni.createFrom().item(toAuthenticators(findByCredID(credID)));
}
@Transactional
@Override
public Uni<Void> updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
// leave the scooby user to the manual endpoint, because if we do it here it will be created/updated twice
if(!authenticator.getUserName().equals("scooby")) {
User user = User.findByUserName(authenticator.getUserName());
if(user == null) {
// new user
User newUser = new User();
newUser.userName = authenticator.getUserName();
WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser);
credential.persist();
newUser.persist();
} else {
// existing user
user.webAuthnCredential.counter = authenticator.getCounter();
}
}
return Uni.createFrom().nullItem();
}
private static List<Authenticator> toAuthenticators(List<WebAuthnCredential> dbs) {
return dbs.stream().map(MyWebAuthnSetup::toAuthenticator).collect(Collectors.toList());
}
private static Authenticator toAuthenticator(WebAuthnCredential credential) {
Authenticator ret = new Authenticator();
ret.setAaguid(credential.aaguid);
AttestationCertificates attestationCertificates = new AttestationCertificates();
attestationCertificates.setAlg(credential.alg);
ret.setAttestationCertificates(attestationCertificates);
ret.setCounter(credential.counter);
ret.setCredID(credential.credID);
ret.setFmt(credential.fmt);
ret.setPublicKey(credential.publicKey);
ret.setType(credential.type);
ret.setUserName(credential.userName);
return ret;
}
@Override
public Set<String> getRoles(String userId) {
if(userId.equals("admin")) {
return Set.of("user", "admin");
}
return Collections.singleton("user");
}
}
HTMLアプリケーションの作成
次に、すべてのAPIへのリンクと、新規ユーザー登録、ログイン、ログアウトを行うためのWebページを src/main/resources/META-INF/resources/index.html
に記述する必要があります。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<script src="/q/webauthn/webauthn.js" type="text/javascript" charset="UTF-8"></script>
<style>
.container {
display: grid;
grid-template-columns: auto auto auto;
}
button, input {
margin: 5px 0;
}
.item {
padding: 20px;
}
nav > ul {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #333;
}
nav > ul > li {
float: left;
}
nav > ul > li > a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
nav > ul > li > a:hover {
background-color: #111;
}
</style>
</head>
<body>
<nav>
<ul>
<li><a href="/api/public">Public API</a></li>
<li><a href="/api/users/me">User API</a></li>
<li><a href="/api/admin">Admin API</a></li>
<li><a href="/q/webauthn/logout">Logout</a></li>
</ul>
</nav>
<div class="container">
<div class="item">
<h1>Status</h1>
<div id="result"></div>
</div>
<div class="item">
<h1>Login</h1>
<p>
<input id="userNameLogin" placeholder="User name"/><br/>
<button id="login">Login</button>
</p>
</div>
<div class="item">
<h1>Register</h1>
<p>
<input id="userNameRegister" placeholder="User name"/><br/>
<input id="firstName" placeholder="First name"/><br/>
<input id="lastName" placeholder="Last name"/><br/>
<button id="register">Register</button>
</p>
</div>
</div>
<script type="text/javascript">
const webAuthn = new WebAuthn({
callbackPath: '/q/webauthn/callback',
registerPath: '/q/webauthn/register',
loginPath: '/q/webauthn/login'
});
const result = document.getElementById('result');
fetch('/api/public/me')
.then(response => response.text())
.then(name => result.append("User: "+name));
const loginButton = document.getElementById('login');
loginButton.addEventListener("click", (e) => {
var userName = document.getElementById('userNameLogin').value;
result.replaceChildren();
webAuthn.login({ name: userName })
.then(body => {
result.append("User: "+userName);
})
.catch(err => {
result.append("Login failed: "+err);
});
return false;
});
const registerButton = document.getElementById('register');
registerButton.addEventListener("click", (e) => {
var userName = document.getElementById('userNameRegister').value;
var firstName = document.getElementById('firstName').value;
var lastName = document.getElementById('lastName').value;
result.replaceChildren();
webAuthn.register({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
result.append("User: "+userName);
})
.catch(err => {
result.append("Registration failed: "+err);
});
return false;
});
</script>
</body>
</html>
アプリケーションのテスト
アプリケーションは保護され、IDは私たちのデータベースから提供されるようになりました。
次のようにdevモードでアプリケーションを実行します:
quarkus dev
./mvnw quarkus:dev
./gradlew --console=plain quarkusDev
これで、PostgreSQL Dev Service コンテナが起動します。ブラウザで http://localhost:8080 を開いてください。
初期状態では、クレデンシャルが登録されておらず、現在のユーザーもいない状態です。
左側に現在のユーザーが表示され、上部メニューで公開APIにアクセスしてみると、うまくいくはずですが、ユーザーAPIとadmin APIは失敗して現在のページにリダイレクトされます。
まず、右側の Register
フォームにユーザー名、氏名を入力し、 Register
ボタンを押して、WebAuthn のクレデンシャルを登録します:
Your browser will ask you to activate your WebAuthn authenticator (you will need a WebAuthn-capable browser and possibly device, or you can use an emulator of those):
その後、ログインし、ユーザーAPIにアクセスできるようになったことを確認できます。
この段階で、 Logout
し、 Login
のフォームにユーザー名を入力することができます:
その後、 Login
ボタンを押せば、ログイン状態になります:
admin APIは、 admin
のユーザー名で登録した場合のみアクセス可能です。
WebAuthnのエンドポイント
Quarkus WebAuthnエクステンションには、これらのRESTエンドポイントがあらかじめ定義されています:
登録チャレンジの取得
POST /q/webauthn/register
:登録チャレンジの設定と取得
{
"name": "userName", (1)
"displayName": "Mr Nice Guy" (2)
}
1 | 必須 |
2 | オプション |
{
"rp": {
"name": "Quarkus server"
},
"user": {
"id": "ryPi43NJSx6LFYNitrOvHg",
"name": "FroMage",
"displayName": "Mr Nice Guy"
},
"challenge": "6tkVLgYzp5yJz_MtnzCy6VRMkHuN4f4C-_hukRmsuQ_MQl7uxJweiqH8gaFkm_mEbKzlUbOabJM3nLbi08i1Uw",
"pubKeyCredParams": [
{
"alg": -7,
"type":"public-key"
},
{
"alg": -257,
"type": "public-key"
}
],
"authenticatorSelection": {
"requireResidentKey": false,
"userVerification": "discouraged"
},
"timeout": 60000,
"attestation": "none",
"extensions": {
"txAuthSimple": ""
}
}
登録をトリガーする
POST /q/webauthn/callback
:登録をトリガーします
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"attestationObject": "<DATA>",
"clientDataJSON":"<DATA>"
},
"type": "public-key"
}
これは、ボディを持たない204を返却します
ログインチャレンジの取得
POST /q/webauthn/login
:ログインチャレンジのセットアップと取得
{
"name": "userName" (1)
}
1 | 必須 |
{
"challenge": "RV4hqKHezkWSxpOICBkpx16yPJFGMZrkPlJP-Wp8w4rVl34VIzCT7AP0Q5Rv-3JCU3jwu-j3VlOgyNMDk2AqDg",
"timeout": 60000,
"userVerification": "discouraged",
"extensions": {
"txAuthSimple": ""
},
"allowCredentials": [
{
"type": "public-key",
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"transports": [
"usb",
"nfc",
"ble",
"internal"
]
}
]
}
ログインをトリガーする
POST /q/webauthn/callback
:ログインをトリガーします
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"clientDataJSON": "<DATA>",
"authenticatorData": "<DATA>",
"signature": "<DATA>",
"userHandle": ""
},
"type": "public-key"
}
これは、ボディを持たない204を返却します
WebAuthn JavaScript ライブラリ
ブラウザでWebAuthnを設定するには多くのJavaScriptが必要なため、Quarkus WebAuthnエクステンションには、WebAuthnのエンドポイントとの対話を支援するJavaScriptライブラリが同梱されています( /q/webauthn/webauthn.js
)。このように設定することができます。
<script src="/q/webauthn/webauthn.js" type="text/javascript" charset="UTF-8"></script>
<script type="text/javascript">
// configure where our endpoints are
const webAuthn = new WebAuthn({
callbackPath: '/q/webauthn/callback',
registerPath: '/q/webauthn/register',
loginPath: '/q/webauthn/login'
});
// use the webAuthn APIs here
</script>
登録の呼出
webAuthn.register
メソッドは、登録チャレンジエンドポイントを呼び出し、次に認証デバイスを呼び出してその登録のためのコールバックエンドポイントを呼び出し、 Promise オブジェクト を返します。
webAuthn.register({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
// do something now that the user is registered
})
.catch(err => {
// registration failed
});
ログインの呼出
webAuthn.login
メソッドは、ログインチャレンジのエンドポイントを呼び出し、次に認証デバイスを呼び出してそのログインのコールバックエンドポイントを呼び出し、 Promiseオブジェクト を返します。
webAuthn.login({ name: userName })
.then(body => {
// do something now that the user is logged in
})
.catch(err => {
// login failed
});
登録チャレンジと認証デバイスのみを呼出
webAuthn.registerOnly
メソッドは登録チャレンジのエンドポイントを呼び出し、次に認証ツールを呼び出して、コールバックエンドポイントに送信するのに適した JSON オブジェクトを含む Promise オブジェクト を返します。その JSON オブジェクトを使用して、たとえば隠しフォーム input
要素にクレデンシャルを格納し、通常の HTML フォームの一部として送信することができます。
webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
// store the registration JSON in form elements
document.getElementById('webAuthnId').value = body.id;
document.getElementById('webAuthnRawId').value = body.rawId;
document.getElementById('webAuthnResponseAttestationObject').value = body.response.attestationObject;
document.getElementById('webAuthnResponseClientDataJSON').value = body.response.clientDataJSON;
document.getElementById('webAuthnType').value = body.type;
})
.catch(err => {
// registration failed
});
ログインチャレンジと認証デバイスのみを呼出
webAuthn.loginOnly
メソッドはログインチャレンジのエンドポイントを呼び出し、次にオーセンティケータを呼び出して、コールバックエンドポイントに送信するのに適した JSON オブジェクトを含む Promise オブジェクト を返します。その JSON オブジェクトを使用して、たとえば隠しフォーム input
要素にクレデンシャルを格納したり、通常の HTML フォームの一部として送信したりすることができます。
webAuthn.loginOnly({ name: userName })
.then(body => {
// store the login JSON in form elements
document.getElementById('webAuthnId').value = body.id;
document.getElementById('webAuthnRawId').value = body.rawId;
document.getElementById('webAuthnResponseClientDataJSON').value = body.response.clientDataJSON;
document.getElementById('webAuthnResponseAuthenticatorData').value = body.response.authenticatorData;
document.getElementById('webAuthnResponseSignature').value = body.response.signature;
document.getElementById('webAuthnResponseUserHandle').value = body.response.userHandle;
document.getElementById('webAuthnType').value = body.type;
})
.catch(err => {
// login failed
});
ログインと登録のエンドポイントを自分でハンドリングする
時には、ユーザーを登録するためにユーザー名以外のデータを要求したり、カスタムバリデーションでログインや登録を処理したいので、WebAuthnコールバックエンドポイントでは十分でないことがあります。
この場合、JavaScript ライブラリの WebAuthn.loginOnly
と WebAuthn.registerOnly
メソッドを使用し、認証データを hidden フォーム要素に格納し、フォームペイロードの一部としてサーバーに送信し、カスタムログインまたは登録エンドポイントに送信することができます。
If you are storing them in form input elements, you can then use the WebAuthnLoginResponse
and
WebAuthnRegistrationResponse
classes, mark them as @BeanParam
and then use the WebAuthnSecurity.login
and WebAuthnSecurity.register
methods to replace the /q/webauthn/callback
endpoint. This even
allows you to create two separate endpoints for handling login and registration at different endpoints.
In most cases you can keep using the /q/webauthn/login
and /q/webauthn/register
challenge-initiating
endpoints, because this is not where custom logic is required.
For example, here’s how you can handle a custom login and register action:
package org.acme.security.webauthn;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.jboss.resteasy.reactive.RestForm;
import io.quarkus.security.webauthn.WebAuthnLoginResponse;
import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
import io.quarkus.security.webauthn.WebAuthnSecurity;
import io.vertx.ext.auth.webauthn.Authenticator;
import io.vertx.ext.web.RoutingContext;
@Path("")
public class LoginResource {
@Inject
WebAuthnSecurity webAuthnSecurity;
// Provide an alternative implementation of the /q/webauthn/callback endpoint, only for login
@Path("/login")
@POST
@Transactional
public Response login(@RestForm String userName,
@BeanParam WebAuthnLoginResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
return Response.status(Status.BAD_REQUEST).build();
}
User user = User.findByUserName(userName);
if(user == null) {
// Invalid user
return Response.status(Status.BAD_REQUEST).build();
}
try {
Authenticator authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely();
// bump the auth counter
user.webAuthnCredential.counter = authenticator.getCounter();
// make a login cookie
this.webAuthnSecurity.rememberUser(authenticator.getUserName(), ctx);
return Response.ok().build();
} catch (Exception exception) {
// handle login failure - make a proper error response
return Response.status(Status.BAD_REQUEST).build();
}
}
// Provide an alternative implementation of the /q/webauthn/callback endpoint, only for registration
@Path("/register")
@POST
@Transactional
public Response register(@RestForm String userName,
@BeanParam WebAuthnRegisterResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
return Response.status(Status.BAD_REQUEST).build();
}
User user = User.findByUserName(userName);
if(user != null) {
// Duplicate user
return Response.status(Status.BAD_REQUEST).build();
}
try {
// store the user
Authenticator authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx).await().indefinitely();
User newUser = new User();
newUser.userName = authenticator.getUserName();
WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser);
credential.persist();
newUser.persist();
// make a login cookie
this.webAuthnSecurity.rememberUser(newUser.userName, ctx);
return Response.ok().build();
} catch (Exception ignored) {
// handle login failure
// make a proper error response
return Response.status(Status.BAD_REQUEST).build();
}
}
}
WebAuthnSecurity のメソッドはユーザークッキーを設定したり読み込んだりしないので、自分で対処する必要がありますが、JWT などのユーザーを保存する他の手段を使用することができます。手動でログインクッキーを設定したい場合は、同じ WebAuthnSecurity クラスの rememberUser(String userName, RoutingContext ctx) と logout(RoutingContext ctx) のメソッドを使用することができます。
|
ブロッキング版
WebAuthnSecurity
データベースへのデータアクセスにブロックを使っている場合、 register
と login
のメソッドでは、 WebAuthnUserProvider
のデータアクセス以外に非同期なものはないので、 .await().indefinitely()
のメソッドで、安全にブロックをすることができます。
Quarkus WebAuthnエンドポイントに、これらの呼び出しをワーカープールに委ねるように指示するために、 @Blocking
アノテーションを WebAuthnUserProvider
クラスに追加する必要があります。
Virtual-Threads version
WebAuthnSecurity
データベースへのデータアクセスにブロックを使っている場合、 register
と login
のメソッドでは、 WebAuthnUserProvider
のデータアクセス以外に非同期なものはないので、 .await().indefinitely()
のメソッドで、安全にブロックをすることができます。
You will have to add the @RunOnVirtualThread
annotation on your WebAuthnUserProvider
class in order to tell the
Quarkus WebAuthn endpoints to defer those calls to virtual threads.
WebAuthnのテスト
通常、ハードウェアトークンが必要なため、WebAuthn のテストは複雑です。そのため、私たちは quarkus-test-security-webauthn
というヘルパーライブラリを作りました。
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-webauthn</artifactId>
<scope>test</scope>
</dependency>
testImplementation("io.quarkus:quarkus-test-security-webauthn")
これを使うと、 WebAuthnHardware
を使って認証トークンをエミュレートしたり、WebAuthn のエンドポイントを呼び出すために WebAuthnEndpointHelper
ヘルパーメソッドを使ったり、カスタムエンドポイントのためにフォームデータを埋めたりすることができるようになります。
package org.acme.security.webauthn.test;
import static io.restassured.RestAssured.given;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import io.quarkus.security.webauthn.WebAuthnController;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
import io.quarkus.test.security.webauthn.WebAuthnHardware;
import io.restassured.RestAssured;
import io.restassured.filter.Filter;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.vertx.core.json.JsonObject;
@QuarkusTest
public class WebAuthnResourceTest {
enum User {
USER, ADMIN;
}
enum Endpoint {
DEFAULT, MANUAL;
}
@Test
public void testWebAuthnUser() {
testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT);
testWebAuthn("scooby", User.USER, Endpoint.MANUAL);
}
@Test
public void testWebAuthnAdmin() {
testWebAuthn("admin", User.ADMIN, Endpoint.DEFAULT);
}
private void testWebAuthn(String userName, User user, Endpoint endpoint) {
Filter cookieFilter = new RenardeCookieFilter();
WebAuthnHardware token = new WebAuthnHardware();
verifyLoggedOut(cookieFilter);
// two-step registration
String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter);
JsonObject registrationJson = token.makeRegistrationJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter);
else {
invokeCustomEndpoint("/register", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson);
request.formParam("userName", userName);
});
}
// verify that we can access logged-in endpoints
verifyLoggedIn(cookieFilter, userName, user);
// logout
WebAuthnEndpointHelper.invokeLogout(cookieFilter);
verifyLoggedOut(cookieFilter);
// two-step login
challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter);
JsonObject loginJson = token.makeLoginJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter);
else {
invokeCustomEndpoint("/login", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson);
request.formParam("userName", userName);
});
}
// verify that we can access logged-in endpoints
verifyLoggedIn(cookieFilter, userName, user);
// logout
WebAuthnEndpointHelper.invokeLogout(cookieFilter);
verifyLoggedOut(cookieFilter);
}
private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer<RequestSpecification> requestCustomiser) {
RequestSpecification request = given()
.when();
requestCustomiser.accept(request);
request
.filter(cookieFilter)
.redirects().follow(false)
.log().ifValidationFails()
.post(uri)
.then()
.statusCode(200)
.log().ifValidationFails()
.cookie(WebAuthnEndpointHelper.getChallengeCookie(), Matchers.is(""))
.cookie(WebAuthnEndpointHelper.getChallengeUsernameCookie(), Matchers.is(""))
.cookie(WebAuthnEndpointHelper.getMainCookie(), Matchers.notNullValue());
}
private void verifyLoggedIn(Filter cookieFilter, String userName, User user) {
// public API still good
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public")
.then()
.statusCode(200)
.body(Matchers.is("public"));
// public API user name
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public/me")
.then()
.statusCode(200)
.body(Matchers.is(userName));
// user API accessible
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/users/me")
.then()
.statusCode(200)
.body(Matchers.is(userName));
// admin API?
if(user == User.ADMIN) {
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/admin")
.then()
.statusCode(200)
.body(Matchers.is("admin"));
} else {
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/admin")
.then()
.statusCode(403);
}
}
private void verifyLoggedOut(Filter cookieFilter) {
// public API still good
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public")
.then()
.statusCode(200)
.body(Matchers.is("public"));
// public API user name
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public/me")
.then()
.statusCode(200)
.body(Matchers.is("<not logged in>"));
// user API not accessible
RestAssured.given()
.filter(cookieFilter)
.redirects().follow(false)
.when()
.get("/api/users/me")
.then()
.statusCode(302)
.header("Location", Matchers.is("http://localhost:8081/"));
// admin API not accessible
RestAssured.given()
.filter(cookieFilter)
.redirects().follow(false)
.when()
.get("/api/admin")
.then()
.statusCode(302)
.header("Location", Matchers.is("http://localhost:8081/"));
}
}
このテストでは、その WebAuthnUserProvider
のユーザーを更新する提供されたコールバックエンドポイントと、ユーザーを処理する自作した LoginResource
エンドポイントの両方をテストしているので、 WebAuthnUserProvider
を scooby
ユーザーを更新しないものに上書きする必要があります。
package org.acme.security.webauthn.test;
import jakarta.enterprise.context.ApplicationScoped;
import org.acme.security.webauthn.MyWebAuthnSetup;
import io.quarkus.test.Mock;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.auth.webauthn.Authenticator;
@Mock
@ApplicationScoped
public class TestUserProvider extends MyWebAuthnSetup {
@Override
public Uni<Void> updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
// delegate the scooby user to the manual endpoint, because if we do it here it will be
// created/updated twice
if(authenticator.getUserName().equals("scooby"))
return Uni.createFrom().nullItem();
return super.updateOrStoreWebAuthnCredentials(authenticator);
}
}
設定リファレンス
The security encryption key can be set with the
quarkus.http.auth.session.encryption-key
configuration option, as described in the security guide.
ビルド時に固定される構成プロパティ - 他のすべての構成プロパティは実行時にオーバーライド可能
Configuration property |
型 |
デフォルト |
---|---|---|
If the WebAuthn extension is enabled. Environment variable: Show more |
boolean |
|
The origin of the application. The origin is basically protocol, host and port. If you are calling WebAuthn API while your application is located at Environment variable: Show more |
string |
|
Authenticator Transports allowed by the application. Authenticators can interact with the user web browser through several transports. Applications may want to restrict the transport protocols for extra security hardening reasons. By default, all transports should be allowed. If your application is to be used by mobile phone users, you may want to restrict only the
Environment variable: Show more |
list of |
|
The id (or domain name of your server) Environment variable: Show more |
string |
|
A user friendly name for your server Environment variable: Show more |
string |
|
Kind of Authenticator Attachment allowed. Authenticators can connect to your device in two forms:
Environment variable: Show more |
|
|
Resident key required. A resident (private) key, is a key that cannot leave your authenticator device, this means that you cannot reuse the authenticator to log into a second computer. Environment variable: Show more |
boolean |
|
User Verification requirements. Webauthn applications may choose
Environment variable: Show more |
|
|
Non-negative User Verification timeout. Authentication must occur within the timeout, this will prevent the user browser from being blocked with a pop-up required user verification, and the whole ceremony must be completed within the timeout period. After the timeout, any previously issued challenge is automatically invalidated. Environment variable: Show more |
|
|
Device Attestation Preference. During registration, applications may want to attest the device. Attestation is a cryptographic verification of the authenticator hardware. Attestation implies that the privacy of the users may be exposed and browsers might override the desired configuration on the user’s behalf. Valid values are:
Environment variable: Show more |
|
|
Allowed Public Key Credential algorithms by preference order. Webauthn mandates that all authenticators must support at least the following 2 algorithms: Environment variable: Show more |
list of |
|
Length of the challenges exchanged between the application and the browser. Challenges must be at least 32 bytes. Environment variable: Show more |
int |
|
The login page Environment variable: Show more |
string |
|
The inactivity (idle) timeout When inactivity timeout is reached, cookie is not renewed and a new login is enforced. Environment variable: Show more |
|
|
How old a cookie can get before it will be replaced with a new cookie with an updated timeout, also referred to as "renewal-timeout". Note that smaller values will result in slightly more server load (as new encrypted cookies will be generated more often); however, larger values affect the inactivity timeout because the timeout is set when a cookie is generated. For example if this is set to 10 minutes, and the inactivity timeout is 30m, if a user’s last request is when the cookie is 9m old then the actual timeout will happen 21m after the last request because the timeout is only refreshed when a new cookie is generated. That is, no timeout is tracked on the server side; the timestamp is encoded and encrypted in the cookie itself, and it is decrypted and parsed with each request. Environment variable: Show more |
|
|
The cookie that is used to store the persistent session Environment variable: Show more |
string |
|
The cookie that is used to store the challenge data during login/registration Environment variable: Show more |
string |
|
The cookie that is used to store the username data during login/registration Environment variable: Show more |
string |
|
SameSite attribute for the session cookie. Environment variable: Show more |
|
|
The cookie path for the session cookies. Environment variable: Show more |
string |
|
期間フォーマットについて
To write duration values, use the standard 数字で始まる簡略化した書式を使うこともできます:
その他の場合は、簡略化されたフォーマットが解析のために
|