Using Security with WebAuthn

Prerequisites

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

Introduction to WebAuthn

WebAuthn 是一种旨在取代密码的认证机制。简而言之,每当您编写用于注册新用户或让他们登录的服务时,您不再需要密码,而是使用将替代密码的 WebAuthn。

WebAuthn 用身份证明替换密码。实际上,用户不再需要发明、存储或记住一个密码,而是使用专门为您的服务或网站生成身份证明的硬件令牌。这可以通过让用户在他们的手机上按下拇指或在计算机上的 YubiKey 上按下按钮来完成。

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

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

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

然后,当您需要登录到该服务时,您不用输入您的密码(它不存在,记得吗?),您可以按登录表单上的一个按钮,浏览器将要求您进行操作,然后它将发送签名给您的服务,而不是发送密码。该签名需要从您的验证器硬件和目标服务派生的私钥,因此当您的服务收到它时,它可以验证它是否与您存储为凭证的公钥的签名相对应。

因此,总结一下:注册发送生成的公钥而不是密码,而登录发送该公钥的签名,让您可以验证用户是否在注册时是他们自称的人。

实际上,它稍微复杂一些,因为在您使用硬件验证器之前需要与服务器握手(要求挑战和其他内容),因此总是需要对您的服务进行两次调用:在登录或注册之前,在调用硬件验证器之前,然后是正常的登录或注册。

此外,要存储的不仅仅是一个公钥,还有很多字段,但我们会帮助您实现。

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

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

Architecture

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

  • /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 文档。

Solution

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

克隆 Git 存储库: git clone $${quickstarts-base-url}.git,或下载 $${quickstarts-base-url}/archive/main.zip[存档]。

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

Creating the Maven Project

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

CLI
quarkus create app {create-app-group-id}:{create-app-artifact-id} \
    --no-code
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 --gradle--gradle-kotlin-dsl 选项。 有关如何安装和使用 Quarkus CLI 的详细信息,请参见 Quarkus CLI 指南。

Maven
mvn {quarkus-platform-groupid}:quarkus-maven-plugin:{quarkus-version}:create \
    -DprojectGroupId={create-app-group-id} \
    -DprojectArtifactId={create-app-artifact-id} \
    -DnoCode
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 -DbuildTool=gradle-DbuildTool=gradle-kotlin-dsl 选项。

适用于 Windows 用户:

  • 如果使用 cmd,(不要使用反斜杠 \ ,并将所有内容放在同一行上)

  • 如果使用 Powershell,将 -D 参数用双引号引起来,例如 "-DprojectArtifactId={create-app-artifact-id}"

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

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

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

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

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

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 资源:

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

最后,考虑一下 /api/users/me 端点。正如您从下面的源代码中所见,我们仅信任具有 user 角色的用户。我们使用 SecurityContext 来获取当前经过身份验证的主体,并返回用户姓名。此信息从数据库中加载。

package org.acme.security.webauthn;

import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;

@Path("/api/users")
public class UserResource {

    @GET
    @RolesAllowed("user")
    @Path("/me")
    public String me(@Context SecurityContext securityContext) {
        return securityContext.getUserPrincipal().getName();
    }
}

Storing our WebAuthn credentials

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

package org.acme.security.webauthn;

import java.util.ArrayList;
import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.vertx.ext.auth.webauthn.Authenticator;
import io.vertx.ext.auth.webauthn.PublicKeyCredential;

@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"userName", "credID"}))
@Entity
public class WebAuthnCredential extends PanacheEntity {

    /**
     * The username linked to this authenticator
     */
    public String userName;

    /**
     * The type of key (must be "public-key")
     */
    public String type = "public-key";

    /**
     * The non user identifiable id for the authenticator
     */
    public String credID;

    /**
     * The public key associated with this authenticator
     */
    public String publicKey;

    /**
     * The signature counter of the authenticator to prevent replay attacks
     */
    public long counter;

    public String aaguid;

    /**
     * The Authenticator attestation certificates object, a JSON like:
     * <pre>{@code
     *   {
     *     "alg": "string",
     *     "x5c": [
     *       "base64"
     *     ]
     *   }
     * }</pre>
     */
    /**
     * The algorithm used for the public credential
     */
    public PublicKeyCredential alg;

    /**
     * The list of X509 certificates encoded as base64url.
     */
    @OneToMany(mappedBy = "webAuthnCredential")
    public List<WebAuthnCertificate> x5c = new ArrayList<>();

    public String fmt;

    // owning side
    @OneToOne
    public User user;

    public WebAuthnCredential() {
    }

    public WebAuthnCredential(Authenticator authenticator, User user) {
        aaguid = authenticator.getAaguid();
        if(authenticator.getAttestationCertificates() != null)
            alg = authenticator.getAttestationCertificates().getAlg();
        counter = authenticator.getCounter();
        credID = authenticator.getCredID();
        fmt = authenticator.getFmt();
        publicKey = authenticator.getPublicKey();
        type = authenticator.getType();
        userName = authenticator.getUserName();
        if(authenticator.getAttestationCertificates() != null
                && authenticator.getAttestationCertificates().getX5c() != null) {
            for (String x5c : authenticator.getAttestationCertificates().getX5c()) {
                WebAuthnCertificate cert = new WebAuthnCertificate();
                cert.x5c = x5c;
                cert.webAuthnCredential = this;
                this.x5c.add(cert);
            }
        }
        this.user = user;
        user.webAuthnCredential = this;
    }

    public static List<WebAuthnCredential> findByUserName(String userName) {
        return list("userName", userName);
    }

    public static List<WebAuthnCredential> findByCredID(String credID) {
        return list("credID", credID);
    }
}

我们还需要一个用于凭证的第二个实体:

package org.acme.security.webauthn;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;


@Entity
public class WebAuthnCertificate extends PanacheEntity {

    @ManyToOne
    public WebAuthnCredential webAuthnCredential;

    /**
     * The list of X509 certificates encoded as base64url.
     */
    public String x5c;
}

最后但并非最不重要的,是我们的用户实体:

package org.acme.security.webauthn;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;

@Table(name = "user_table")
@Entity
public class User extends PanacheEntity {

    @Column(unique = true)
    public String userName;

    // non-owning side, so we can add more credentials later
    @OneToOne(mappedBy = "user")
    public WebAuthnCredential webAuthnCredential;

    public static User findByUserName(String userName) {
        return User.find("userName", userName).firstResult();
    }
}

A note about usernames and credential IDs

WebAuthn 依赖于用户名(对每个用户来说是唯一的)和凭证 ID(对每个验证器设备来说是唯一的)的组合。

出现这两个标识符且它们不是凭证本身的唯一密钥的原因是:

  • 单个用户可以拥有多个验证器设备,这意味着单个用户名可以映射到多个凭证 ID,所有这些 ID 都标识同一个用户。

  • 单个验证器设备可能由多个用户共享,因为一个人可能希望拥有多个具有不同用户名的用户帐户,所有这些帐户都具有相同的验证器设备。因此,单个凭证 ID 可能被多个不同的用户使用。

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

Exposing your entities to Quarkus WebAuthn

您需要定义一个实现 WebAuthnUserProvider 的 bean,以允许 Quarkus WebAuthn 扩展加载并存储凭证。这就是您告诉 Quarkus 如何将您的数据模型转换为 WebAuthn 安全模型的地方:

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 的链接的网页,以及注册新用户、登录和注销的方法:

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

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

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

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

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

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

webauthn 1

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

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

webauthn 2

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

webauthn 3

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

webauthn 4

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

webauthn 5

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

webauthn 4

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

WebAuthn endpoints

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

Obtain a registration challenge

POST /q/webauthn/register:设置并获取注册 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:触发注册

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

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

Obtain a login challenge

POST /q/webauthn/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:触发登录

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。

Logout

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

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

WebAuthn JavaScript library

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

<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

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

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 表单的一部分发送:

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 表单的一部分发送:

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 回调端点还不够。

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

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

在大多数情况下,你可以继续使用 /q/webauthn/login/q/webauthn/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;

    // 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) 方法。

Blocking version

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

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

Virtual-Threads version

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

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

Testing WebAuthn

测试 WebAuthn 可能会很复杂,因为通常需要硬件令牌,这就是我们创建 quarkus-test-security-webauthn 帮助器库的原因:

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 端点,甚至可以填充自定义端点的表单数据:

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

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 中所述。

Unresolved include directive in modules/ROOT/pages/security-webauthn.adoc - include::../../../target/quarkus-generated-doc/config/quarkus-security-webauthn.adoc[]