The English version of quarkus.io is the official project site. Translated sites are community supported on a best-effort basis.

使用WebAuthn的安全性

本指南演示了你的Quarkus应用程序如何使用WebAuthn认证来代替密码。

这项技术被认为是preview。

preview(预览) 中,不保证向后兼容和在生态系统中的存在。具体的改进可能需要改变配置或API,并且正在计划变得 稳定 。欢迎在我们的 邮件列表 中提供反馈,或在我们的 GitHub问题列表 中提出问题。

For a full list of possible statuses, check our FAQ entry.

先决条件

完成这个指南,你需要:

  • 大概15分钟

  • 编辑器

  • JDK 17+ installed with JAVA_HOME configured appropriately

  • Apache Maven 3.9.6

  • 如果你愿意的话,还可以选择使用Quarkus CLI

  • 如果你想构建原生可执行程序,可以选择安装Mandrel或者GraalVM,并正确配置(或者使用Docker在容器中进行构建)

Introduction to WebAuthn

WebAuthn is an authentication mechanism designed to replace passwords. In short, every time you write a service for registering new users, or logging them in, instead of asking for a password, you use WebAuthn, which will replace the password.

WebAuthn replaces the password with a proof of identity. In practice, users, instead of having to invent a password, store it or remember it, will use a hardware token that will generate a proof of identity specifically for your service or website. This can be done by asking users to press their thumb on their phone, or pressing a button on a YubiKey on a computer.

So, when you register your user, you use your browser to enter your user information (username, your name, etc…) and instead of typing a password to identify yourself, you click a button which will invoke the WebAuthn browser API which will ask you to do something (press a button, use your fingerprint). Then, your browser will generate a proof of identity which you can send to your service instead of a password.

This proof of identity, when you register, consists mostly in a public key. Actually there’s a lot of stuff in there but the most interesting is the public key. This public key is not stored on your device, or your browser. It is generated especially for the target service (tied to its URI), and derived from the hardware authenticator. So the association of the hardware authenticator and the target service will always derive the same private and public key pair, none of which are stored anywhere. You can for example, take your YubiKey to another computer and it will keep generating the same private/public keys for the same target service.

So, when you register, you send (mostly) a public key instead of a password, and the service stores that information as WebAuthn credentials for your new user account, and this is what will identify you later.

Then, when you need to log in to that service, instead of typing your password (which doesn’t exist, remember?), you press a button on the login form, and the browser will ask you to do something, and then it will send a signature to your service instead of a password. That signature requires the private key that is derived from your authenticator hardware and the target service, and so when your service receives it, it can verify that it corresponds to the signature of the public key you stored as credentials.

So, to recap: registration sends a generated public key instead of a password, and login sends a signature for that public key, allowing you to verify that the user is who they were when they registered.

In practice, it’s a little more complex, because there needs to be a handshake with the server before you can use the hardware authenticator (ask for a challenge and other things), so there are always two calls to your service: one before login or registration, before calling the hardware authenticator, and then the normal login or registration.

And also there are a lot more fields to store than just a public key, but we will help you with that.

应用结构

In this example, we build a very simple microservice which offers four endpoints:

  • /api/public

  • /api/public/me

  • /api/users/me

  • /api/admin

The /api/public endpoint can be accessed anonymously. The /api/public/me endpoint can be accessed anonymously and returns the current username if there is one, or <not logged in> if not. The /api/admin endpoint is protected with RBAC (Role-Based Access Control) where only users granted with the admin role can access. At this endpoint, we use the @RolesAllowed annotation to declaratively enforce the access constraint. The /api/users/me endpoint is also protected with RBAC (Role-Based Access Control) where only users granted with the user role can access. As a response, it returns a JSON document with details about the user.

解决方案

我们建议您按照下一节的说明逐步创建应用程序。然而,您可以直接转到已完成的示例。

克隆 Git 仓库: git clone https://github.com/quarkusio/quarkus-quickstarts.git ,或下载一个 存档

The solution is located in the security-webauthn-quickstart directory.

创建Maven项目

首先,我们需要一个新的工程项目。用以下命令创建一个新项目:

CLI
quarkus create app org.acme:security-webauthn-quickstart \
    --extension='security-webauthn,jdbc-postgresql,resteasy-reactive,hibernate-orm-panache' \
    --no-code
cd security-webauthn-quickstart

创建Grade项目,请添加 --gradle 或者 --gradle-kotlin-dsl 参数。

For more information about how to install and use the Quarkus CLI, see the Quarkus CLI guide.

Maven
mvn io.quarkus.platform:quarkus-maven-plugin:3.8.3:create \
    -DprojectGroupId=org.acme \
    -DprojectArtifactId=security-webauthn-quickstart \
    -Dextensions='security-webauthn,jdbc-postgresql,resteasy-reactive,hibernate-orm-panache' \
    -DnoCode
cd security-webauthn-quickstart

创建Grade项目,请添加 -DbuildTool=gradle 或者 -DbuildTool=gradle-kotlin-dsl 参数。

For Windows users:

  • If using cmd, (don’t use backward slash \ and put everything on the same line)

  • If using Powershell, wrap -D parameters in double quotes e.g. "-DprojectArtifactId=security-webauthn-quickstart"

Don’t forget to add the database connector library of choice. Here we are using PostgreSQL as identity store.

This command generates a Maven project, importing the security-webauthn extension which allows you to use WebAuthn to authenticate users.

If you already have your Quarkus project configured, you can add the security-webauthn extension to your project by running the following command in your project base directory:

CLI
quarkus extension add security-webauthn
Maven
./mvnw quarkus:add-extension -Dextensions='security-webauthn'
Gradle
./gradlew addExtension --extensions='security-webauthn'

这会将以下内容添加到你的构建文件中:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-security-webauthn</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-security-webauthn")

编写应用程序

Let’s start by implementing the /api/public endpoint. As you can see from the source code below, it is just a regular Jakarta REST resource:

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 的源代码也非常简单。这里的主要区别是,我们使用 @RolesAllowed 注解来确保只有被授予 admin 角色的用户才能访问该端点:

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";
    }
}

Finally, let’s consider the /api/users/me endpoint. As you can see from the source code below, we are trusting only users with the user role. We are using SecurityContext to get access to the current authenticated Principal and we return the user’s name. This information is loaded from the database.

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();
    }
}

Storing our WebAuthn credentials

We can now describe how our WebAuthn credentials are stored in our database with three entities. Note that we’ve simplified the model in order to only store one credential per user (who could actually have more than one WebAuthn credential and other data such as roles):

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);
    }
}

We also need a second entity for the credentials:

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;
}

And last but not least, our user entity:

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();
    }
}

A note about usernames and credential IDs

WebAuthn relies on a combination of usernames (unique per user) and credential IDs (unique per authenticator device).

The reasons why there are two such identifiers, and why they are not unique keys for the credentials themselves are:

  • A single user can have more than one authenticator device, which means a single username can map to multiple credential IDs, all of which identify the same user.

  • An authenticator device may be shared by multiple users, because a single person may want multiple user accounts with different usernames, all of which having the same authenticator device. So a single credential ID may be used by multiple different users.

The combination of username and credential ID should be a unicity constraint for your credentials table, though.

Exposing your entities to Quarkus WebAuthn

You need to define a bean implementing the WebAuthnUserProvider in order to allow the Quarkus WebAuthn extension to load and store credentials. This is where you tell Quarkus how to turn your data model into the WebAuthn security model:

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");
    }
}

Writing the HTML application

We now need to write a web page with links to all our APIs, as well as a way to register a new user, login, and logout, in 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>
    </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.onclick = () => {
        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.onclick = () => {
        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>

Testing the application

The application is now protected and the identities are provided by our database.

Run your application in dev mode with:

CLI
quarkus dev
Maven
./mvnw quarkus:dev
Gradle
./gradlew --console=plain quarkusDev

which will start a PostgreSQL Dev Service container, and open http://localhost:8080 in your browser.

Initially, you will have no credentials registered, and no current user:

webauthn 1

The current user is displayed on the left, and you can use the top menu to try accessing the public API, which should work, while the user and admin APIs will fail and redirect you to the current page.

Start by registering your WebAuthn credentials by entering a username, first and last names on the Register form on the right, then pressing the Register button:

webauthn 2

Your browser will ask you to activate your WebAuthn authenticator:

webauthn 3

You will then be logged in, and can check that the user API is now accessible:

webauthn 4

At this stage you can Logout and enter your username in the Login form:

webauthn 5

Then press the Login button, and you will be logged in:

webauthn 4

The admin API is only accessible if you register with the admin user name.

WebAuthn endpoints

The Quarkus WebAuthn extension comes out of the box with these REST endpoints pre-defined:

Obtain a registration challenge

POST /q/webauthn/register: Set up and obtain a registration challenge

Request
{
 "name": "userName",
 "displayName": "Mr Nice Guy"
}
Response
{
 "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": ""
  }
 }

Obtain a login challenge

POST /q/webauthn/login: Set up and obtain a login challenge

Request
{
 "name": "userName"
}
Response
{
 "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"
   ]
  }
 ]
}

Trigger a registration

POST /q/webauthn/callback: Trigger a registration

Request
{
 "id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
 "rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
 "response": {
  "attestationObject": "<DATA>",
  "clientDataJSON":"<DATA>"
 },
 "type": "public-key"
}

This returns a 204 with no body.

Trigger a login

POST /q/webauthn/callback: Trigger a login

Request
{
 "id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
 "rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
 "response": {
  "clientDataJSON": "<DATA>",
  "authenticatorData": "<DATA>",
  "signature": "<DATA>",
  "userHandle": ""
 },
 "type": "public-key"
}

This returns a 204 with no body.

Logout

GET /q/webauthn/logout: Logs you out

This returns a 302 redirect to the root URI of your application.

WebAuthn JavaScript library

Because there’s so much JavaScript needed to set WebAuthn up in the browser, the Quarkus WebAuthn extension ships with a JavaScript library to help you talk to the WebAuthn endpoints, at /q/webauthn/webauthn.js. You can set it up like this:

<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>

Invoke registration

The webAuthn.register method invokes the registration challenge endpoint, then calls the authenticator and invokes the callback endpoint for that registration, and returns a Promise object:

webAuthn.register({ name: userName, displayName: firstName + " " + lastName })
  .then(body => {
    // do something now that the user is registered
  })
  .catch(err => {
    // registration failed
  });

Invoke login

The webAuthn.login method invokes the login challenge endpoint, then calls the authenticator and invokes the callback endpoint for that login, and returns a Promise object:

webAuthn.login({ name: userName })
  .then(body => {
    // do something now that the user is logged in
  })
  .catch(err => {
    // login failed
  });

Only invoke the registration challenge and authenticator

The webAuthn.registerOnly method invokes the registration challenge endpoint, then calls the authenticator and returns a Promise object containing a JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials in hidden form input elements, for example, and send it as part of a regular HTML form:

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
  });

Only invoke the login challenge and authenticator

The webAuthn.loginOnly method invokes the login challenge endpoint, then calls the authenticator and returns a Promise object containing a JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials in hidden form input elements, for example, and send it as part of a regular HTML form:

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
  });

Handling login and registration endpoints yourself

Sometimes, you will want to ask for more data than just a username in order to register a user, or you want to deal with login and registration with custom validation, and so the WebAuthn callback endpoint is not enough.

In this case, you can use the WebAuthn.loginOnly and WebAuthn.registerOnly methods from the JavaScript library, store the authenticator data in hidden form elements, and send them as part of your form payload to the server to your custom login or registration endpoints.

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. For example, here’s how you can handle a custom login and register:

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;

    @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();
        }
    }

    @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();
        }
    }
}
The WebAuthnSecurity methods do not set or read the user cookie, so you will have to take care of it yourself, but it allows you to use other means of storing the user, such as JWT. You can use the rememberUser(String userName, RoutingContext ctx) and logout(RoutingContext ctx) methods on the same WebAuthnSecurity class if you want to manually set up login cookies.

Blocking version

If you’re using a blocking data access to the database, you can safely block on the WebAuthnSecurity methods, with .await().indefinitely(), because nothing is async in the register and login methods, besides the data access with your WebAuthnUserProvider.

You will have to add the @Blocking annotation on your WebAuthnUserProvider class in order to tell the Quarkus WebAuthn endpoints to defer those calls to the worker pool.

Testing WebAuthn

Testing WebAuthn can be complicated because normally you need a hardware token, which is why we’ve made the quarkus-test-security-webauthn helper library:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-security-webauthn</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-security-webauthn")

With this, you can use WebAuthnHardware to emulate an authenticator token, as well as the WebAuthnEndpointHelper helper methods in order to invoke the WebAuthn endpoints, or even fill your form data for custom endpoints:

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(WebAuthnController.CHALLENGE_COOKIE, Matchers.is(""))
        .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is(""))
        .cookie("quarkus-credential", 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/"));
    }
}

For this test, since we’re testing both the provided callback endpoint, which updates users in its WebAuthnUserProvider and the manual LoginResource endpoint, which deals with users manually, we need to override the WebAuthnUserProvider with one that doesn’t update the scooby user:

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);
    }
}

配置参考

Configuration property fixed at build time - All other configuration properties are overridable at runtime

Configuration property

类型

默认

If the WebAuthn extension is enabled.

Environment variable: QUARKUS_WEBAUTHN_ENABLED

Show more

boolean

true

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 https://example.com/login, then origin will be https://example.com. If you are calling from http://localhost:2823/test, then the origin will be http://localhost:2823. Please note that WebAuthn API will not work on pages loaded over HTTP, unless it is localhost, which is considered secure context.

Environment variable: QUARKUS_WEBAUTHN_ORIGIN

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 INTERNAL authenticator to be allowed. Permitted values are:

  • USB - USB connected authenticators (e.g.: Yubikey’s)

  • NFC - NFC connected authenticators (e.g.: Yubikey’s)

  • BLE - Bluetooth LE connected authenticators

  • INTERNAL - Hardware security chips (e.g.: Intel TPM2.0)

Environment variable: QUARKUS_WEBAUTHN_TRANSPORTS

Show more

list of AuthenticatorTransport

USB,NFC,BLE,INTERNAL

The id (or domain name of your server)

Environment variable: QUARKUS_WEBAUTHN_RELYING_PARTY_ID

Show more

string

A user friendly name for your server

Environment variable: QUARKUS_WEBAUTHN_RELYING_PARTY_NAME

Show more

string

Quarkus server

Kind of Authenticator Attachment allowed. Authenticators can connect to your device in two forms:

  • PLATFORM - The Authenticator is built-in to your device (e.g.: Security chip)

  • CROSS_PLATFORM - The Authenticator can roam across devices (e.g.: USB Authenticator) For security reasons your application may choose to restrict to a specific attachment mode. If omitted, then any mode is permitted.

Environment variable: QUARKUS_WEBAUTHN_AUTHENTICATOR_ATTACHMENT

Show more

platform, cross-platform

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: QUARKUS_WEBAUTHN_REQUIRE_RESIDENT_KEY

Show more

boolean

false

User Verification requirements. Webauthn applications may choose REQUIRED verification to assert that the user is present during the authentication ceremonies, but in some cases, applications may want to reduce the interactions with the user, i.e.: prevent the use of pop-ups. Valid values are:

  • REQUIRED - User must always interact with the browser

  • PREFERRED - User should always interact with the browser

  • DISCOURAGED - User should avoid interact with the browser

Environment variable: QUARKUS_WEBAUTHN_USER_VERIFICATION

Show more

required, preferred, discouraged

REQUIRED

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: QUARKUS_WEBAUTHN_TIMEOUT

Show more

Duration

60s

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:

  • NONE - no attestation data is sent with registration

  • INDIRECT - attestation data is sent with registration, yielding anonymized data by a trusted CA

  • DIRECT - attestation data is sent with registration

  • ENTERPRISE - no attestation data is sent with registration. The device AAGUID is returned unaltered.

Environment variable: QUARKUS_WEBAUTHN_ATTESTATION

Show more

none, indirect, direct, enterprise

NONE

Allowed Public Key Credential algorithms by preference order. Webauthn mandates that all authenticators must support at least the following 2 algorithms: ES256 and RS256. Applications may require stronger keys and algorithms, for example: ES512 or EdDSA. Note that the use of stronger algorithms, e.g.: EdDSA may require Java 15 or a cryptographic JCE provider that implements the algorithms.

Environment variable: QUARKUS_WEBAUTHN_PUB_KEY_CRED_PARAMS

Show more

list of PublicKeyCredential

ES256,RS256

Length of the challenges exchanged between the application and the browser. Challenges must be at least 32 bytes.

Environment variable: QUARKUS_WEBAUTHN_CHALLENGE_LENGTH

Show more

int

64

The login page

Environment variable: QUARKUS_WEBAUTHN_LOGIN_PAGE

Show more

string

/login.html

The inactivity (idle) timeout When inactivity timeout is reached, cookie is not renewed and a new login is enforced.

Environment variable: QUARKUS_WEBAUTHN_SESSION_TIMEOUT

Show more

Duration

PT30M

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: QUARKUS_WEBAUTHN_NEW_COOKIE_INTERVAL

Show more

Duration

PT1M

The cookie that is used to store the persistent session

Environment variable: QUARKUS_WEBAUTHN_COOKIE_NAME

Show more

string

quarkus-credential

SameSite attribute for the session cookie.

Environment variable: QUARKUS_WEBAUTHN_COOKIE_SAME_SITE

Show more

strict, lax, none

strict

The cookie path for the session cookies.

Environment variable: QUARKUS_WEBAUTHN_COOKIE_PATH

Show more

string

/

About the Duration format

To write duration values, use the standard java.time.Duration format. See the Duration#parse() Java API documentation for more information.

You can also use a simplified format, starting with a number:

  • If the value is only a number, it represents time in seconds.

  • If the value is a number followed by ms, it represents time in milliseconds.

In other cases, the simplified format is translated to the java.time.Duration format for parsing:

  • If the value is a number followed by h, m, or s, it is prefixed with PT.

  • If the value is a number followed by d, it is prefixed with P.

Related content