Using Security with WebAuthn

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

This guide demonstrates how your Quarkus application can use WebAuthn authentication instead of passwords. Unresolved directive in security-webauthn.adoc - include::{includes}/extension-status.adoc[]

Prerequisites

include::{includes}/prerequisites.adoc[]* 支持 WebAuthn 或通行密钥的设备或 an emulator of those

Unresolved directive in security-webauthn.adoc - include::{includes}/prerequisites.adoc[] * A WebAuthn or PassKeys-capable device, or an emulator of those.

Introduction to WebAuthn

WebAuthn 是一种旨在取代密码的认证机制。简而言之,每当您编写用于注册新用户或让他们登录的服务时,您不再需要密码,而是使用将替代密码的 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 用身份证明替换密码。实际上,用户不再需要发明、存储或记住一个密码,而是使用专门为您的服务或网站生成身份证明的硬件令牌。这可以通过让用户在他们的手机上按下拇指或在计算机上的 YubiKey 上按下按钮来完成。

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.

因此,当您注册用户时,您可以使用您的浏览器输入您的用户信息(用户名、您的姓名等),而不用输入密码来识别自己,您可以单击一个按钮,该按钮将调用 WebAuthn 浏览器 API,该 API 将要求您进行操作(按下按钮,使用您的指纹)。然后,您的浏览器将生成身份证明,您可以将其发送给您的服务,而不是发送密码。

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.

当您注册时,此身份证明主要由一个公钥组成。实际上,那里有很多东西,但最重要的是公钥。此公钥不会存储在您的设备或浏览器上。它是专门为目标服务(绑定到其 URI)生成的,并从硬件验证器派生的。因此,硬件验证器和目标服务的关联将始终派生出相同的私钥和公钥对,其中任何一个都不会存储在任何地方。例如,您可以将 YubiKey 拿到另一台计算机上,它将继续为相同的目标服务生成相同的私钥/公钥。

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.

因此,当您注册时,您发送的主要是公钥(而不是密码),并且该服务将该信息存储为新用户帐户的 WebAuthn 凭证,这将是稍后识别您的信息。

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.

万一您到那里想知道与 PassKeys 的关系以及我们是否支持它:当然,是的,通行密钥是您的验证器设备可以共享和同步其凭证的一种方式,然后您可以将其与我们的 WebAuthn 认证一起使用。

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.

WebAuthn 规范要求使用 HTTPS 与服务器进行通信,不过某些浏览器允许 localhost。如果您必须在开发模式下使用 HTTPS,您可以随时使用 quarkus-ngrok 扩展。

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.

Architecture

在此示例中,我们构建了一个非常简单的微服务,它提供四个端点:

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

  • /api/public

  • /api/public/me

  • /api/users/me

  • /api/admin

/api/public 端点可以匿名访问。/api/public/me 端点可以匿名访问,如果没有则返回当前用户名,否则返回 <not logged in>/api/admin 端点受基于角色的访问控制 (RBAC) 保护,只有被授予 admin 角色的用户才能访问。在此端点,我们使用 @RolesAllowed 注解来以声明的方式强制执行访问约束。/api/users/me 端点也受基于角色的访问控制 (RBAC) 保护,只有被授予 user 角色的用户才能访问。作为响应,它返回一个包含有关用户的详细信息的 JSON 文档。

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.

Solution

我们建议您遵循接下来的部分中的说明,按部就班地创建应用程序。然而,您可以直接跳到完成的示例。

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

克隆 Git 存储库: git clone {quickstarts-clone-url},或下载 {quickstarts-archive-url}[存档]。

Clone the Git repository: git clone {quickstarts-clone-url}, or download an {quickstarts-archive-url}[archive].

解决方案位于 security-webauthn-quickstart directory

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

Creating the Maven Project

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

First, we need a new project. Create a new project with the following command:

Unresolved directive in security-webauthn.adoc - include::{includes}/devtools/create-app.adoc[]

别忘了添加数据库连接器库。我们在此使用 PostgreSQL 作为身份存储。

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

此命令会生成一个 Maven 项目,导入 security-webauthn 扩展,该扩展允许您使用 WebAuthn 对用户进行身份验证。

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

如果您已配置了自己的 Quarkus 项目,可以通过在项目基本目录中运行以下命令将 security-webauthn 扩展添加到项目:

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:

Unresolved directive in security-webauthn.adoc - include::{includes}/devtools/extension-add.adoc[]

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

This will add the following to your build file:

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

Writing the application

首先,实现 /api/public 端点。正如您从下面的源代码中所见,它仅仅是一个常规的 Jakarta REST 资源:

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 角色的用户才可以访问此端点:

The source code for the /api/admin endpoint is also very simple. The main difference here is that we are using a @RolesAllowed annotation to make sure that only users granted with the admin role can access the endpoint:

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 来获取当前经过身份验证的主体,并返回用户姓名。此信息从数据库中加载。

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

我们现在可以用三个实体来描述我们的 WebAuthn 凭证如何存储在我们的数据库中。请注意,我们已经对模型进行了简化,以便仅为每个用户存储一个凭证(该用户实际上可能拥有多个 WebAuthn 凭证和其他数据,例如角色):

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 依赖于用户名(对每个用户来说是唯一的)和凭证 ID(对每个验证器设备来说是唯一的)的组合。

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.

但是,用户名和凭证 ID 的组合应该是凭证表的唯一性约束。

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

Exposing your entities to Quarkus WebAuthn

您需要定义一个实现 WebAuthnUserProvider 的 bean,以允许 Quarkus WebAuthn 扩展加载并存储凭证。这就是您告诉 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

现在我们需要在 src/main/resources/META-INF/resources/index.html 中编写一个带有指向我们所有 API 的链接的网页,以及注册新用户、登录和注销的方法:

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

Testing the application

现在,该应用程序受到保护,并且身份由我们的数据库提供。

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

使用以下命令在开发模式下运行您的应用程序:

Run your application in dev mode with:

Unresolved directive in security-webauthn.adoc - include::{includes}/devtools/dev.adoc[]

这将启动一个 PostgreSQL 开发服务容器,并在您的浏览器中打开 [role="bare"][role="bare"]http://localhost:8080

which will start a PostgreSQL Dev Service container, and open [role="bare"]http://localhost:8080 in your browser.

最初,你没有任何注册的凭证,也没有当前用户:

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

webauthn 1

当前用户显示在左侧,你可以使用顶部菜单尝试访问公共 API,而用户和管理员 API 将失败并重定向你到当前页面。

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.

通过在右侧的 Register 表单中输入用户名、名和姓来开始注册你的 WebAuthn 凭据,然后按下 Register 按钮:

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

你的浏览器将要求你激活你的 WebAuthn 认证器(你需要一个兼容 WebAuthn 的浏览器,可能需要设备,或者你可以使用 an emulator of those):

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

webauthn 3

然后你会登录,并且可以检查用户 API 现在是否可以访问:

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

webauthn 4

在此阶段,你可以 Logout 并在 Login 中输入你的用户名:

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

webauthn 5

然后按下 Login 按钮,你将登录:

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

webauthn 4

只有当你使用 admin 用户名注册时才能访问管理员 API。

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

WebAuthn endpoints

Quarkus WebAuthn 扩展开箱即用,预定义了这些 REST 端点:

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

Obtain a registration challenge

POST /q/webauthn/register:设置并获取注册 challenge

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

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

Trigger a registration

POST /q/webauthn/callback:触发注册

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

这会返回一个不带正文的 204。

This returns a 204 with no body.

Obtain a login challenge

POST /q/webauthn/login:设置并获取登录 challenge

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

Request
{
 "name": "userName" 1
}
1 Required
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 login

POST /q/webauthn/callback:触发登录

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

这会返回一个不带正文的 204。

This returns a 204 with no body.

Logout

GET /q/webauthn/logout:让你退出登录

GET /q/webauthn/logout: Logs you out

这会返回一个 302 重定向到应用程序的根 URI。

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

WebAuthn JavaScript library

由于在浏览器中设置 WebAuthn 需要大量的 JavaScript,Quarkus WebAuthn 扩展附带了一个 JavaScript 库,帮助你与 /q/webauthn/webauthn.js 处的 WebAuthn 端点通信。你可以这样设置它:

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

webAuthn.register 方法调用注册 challenge 端点,然后调用认证器并调用用于该注册的回调端点,并返回一个 Promise object

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

webAuthn.login 方法调用登录 challenge 端点,然后调用认证器并调用用于该登录的回调端点,并返回一个 Promise object

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

webAuthn.registerOnly 方法调用注册挑战端点,然后调用验证器并返回一个 Promise object ,其中包含适合发送到回调端点的 JSON 对象。你可以使用该 JSON 对象将凭据存储在隐藏表单 input 元素中(例如),并将其作为常规 HTML 表单的一部分发送:

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

webAuthn.loginOnly 方法调用登录挑战端点,然后调用验证器并返回一个 Promise object ,其中包含适合发送到回调端点的 JSON 对象。你可以使用该 JSON 对象将凭据存储在隐藏表单 input 元素中(例如),并将其作为常规 HTML 表单的一部分发送:

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

有时,你想请求的数据不仅仅是用于注册用户的用户名,或者你想使用自定义验证来处理登录和注册,因此 WebAuthn 回调端点还不够。

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.

在这种情况下,你可以使用 JavaScript 库中的 WebAuthn.loginOnlyWebAuthn.registerOnly 方法,将验证器数据存储在隐藏表单元素中,并将它们作为表单有效负载的一部分发送到服务器以自定义登录或注册端点。

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.

如果你将它们存储在表单输入元素中,则可以使用 WebAuthnLoginResponseWebAuthnRegistrationResponse 类,将它们标记为 @BeanParam ,然后使用 WebAuthnSecurity.loginWebAuthnSecurity.register 方法替换 /q/webauthn/callback 端点。这甚至允许你创建两个单独的端点,以在不同端点处理登录和注册。

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.

在大多数情况下,你可以继续使用 /q/webauthn/login/q/webauthn/register 挑战发起端点,因为这不是自定义逻辑要求的。

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 方法不设置或读取用户 cookie,因此你必须自己处理它,但它允许你使用其他手段来存储用户,例如 JWT。如果你想手动设置登录 cookie,可以在同一 WebAuthnSecurity 类上使用 rememberUser(String userName, RoutingContext ctx)logout(RoutingContext ctx) 方法。

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

如果你使用阻塞数据访问数据库,则可以安全地阻止 WebAuthnSecurity 方法(使用 .await().indefinitely() ),因为除了与 WebAuthnUserProvider 的数据访问之外, registerlogin 方法中没有任何内容是异步的。

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.

你必须在 WebAuthnUserProvider 类中添加 @Blocking 注释,以告知 Quarkus WebAuthn 端点将这些调用推迟到工作程序池。

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.

Virtual-Threads version

如果你使用阻塞数据访问数据库,则可以安全地阻止 WebAuthnSecurity 方法(使用 .await().indefinitely() ),因为除了与 WebAuthnUserProvider 的数据访问之外, registerlogin 方法中没有任何内容是异步的。

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.

你必须在 WebAuthnUserProvider 类中添加 @RunOnVirtualThread 注释,以告知 Quarkus WebAuthn 端点将这些调用推迟到虚拟线程。

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.

Testing WebAuthn

测试 WebAuthn 可能会很复杂,因为通常需要硬件令牌,这就是我们创建 quarkus-test-security-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")

利用此库,你可以使用 WebAuthnHardware 模仿验证器令牌,以及 WebAuthnEndpointHelper 帮助器方法来调用 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(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 端点,我们需要用一个不更新 scooby 用户的端点替代 WebAuthnUserProvider

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 Reference

安全加密密钥可以使用 quarkus.http.auth.session.encryption-key 配置选项设置,如 security guide 中所述。

The security encryption key can be set with the quarkus.http.auth.session.encryption-key configuration option, as described in the security guide.

Unresolved directive in security-webauthn.adoc - include::{generated-dir}/config/quarkus-security-webauthn.adoc[]