使用 WebAuthn 进行安全
本指南演示了您的 Quarkus 应用程序如何使用 WebAuthn 身份验证而不是密码。
此技术被认为是预览版。 在预览中,不保证向后兼容性和生态系统中的存在。具体的改进可能需要更改配置或 API,并且正在制定成为稳定的计划。欢迎在我们的邮件列表或我们的GitHub 问题跟踪器中提供反馈。 有关可能的完整状态列表,请查看我们的常见问题解答条目。 |
先决条件
要完成本指南,您需要
-
大约 15 分钟
-
一个 IDE
-
已安装 JDK 17+ 并正确配置了
JAVA_HOME
-
Apache Maven 3.9.9
-
如果您想使用它,可以选择 Quarkus CLI
-
如果您想构建本机可执行文件(或者如果您使用本机容器构建,则为 Docker),可以选择安装 Mandrel 或 GraalVM 并进行适当的配置
-
支持 WebAuthn 或 PassKeys 的设备,或这些设备的模拟器。
WebAuthn 简介
WebAuthn 是一种旨在取代密码的身份验证机制。简而言之,每次您编写用于注册新用户或登录用户的服务时,您都可以使用 WebAuthn 来代替要求密码,WebAuthn 将取代密码。
WebAuthn 用身份证明代替密码。实际上,用户不必发明密码、存储或记住密码,而是使用硬件令牌来生成专门为您的服务或网站设计的身份证明。这可以通过要求用户将拇指放在手机上,或者按下电脑上的 YubiKey 上的按钮来完成。
因此,当您注册用户时,您可以使用浏览器输入您的用户信息(用户名、您的姓名等……),而不是输入密码来验证您的身份,您可以单击一个按钮,该按钮将调用 WebAuthn 浏览器 API,该 API 会要求您执行某些操作(按下按钮、使用您的指纹)。然后,您的浏览器将生成一个身份证明,您可以将其发送到您的服务,而不是密码。
注册时的身份证明主要由一个公钥组成。实际上,其中有很多东西,但最有趣的是公钥。此公钥不存储在您的设备或浏览器上。它是专门为目标服务(与其 URI 相关联)生成的,并且是从硬件验证器派生的。因此,硬件验证器和目标服务的关联将始终派生相同的私钥和公钥对,这些密钥对都不会存储在任何地方。例如,您可以将您的 YubiKey 带到另一台电脑,它将继续为同一目标服务生成相同的私钥/公钥。
因此,当您注册时,您(主要)发送一个公钥而不是密码,并且该服务将该信息存储为您的新用户帐户的 WebAuthn 凭据,这就是以后验证您的身份的方式。
然后,当您需要登录该服务时,您可以按下登录表单上的一个按钮,而不是输入您的密码(请记住,密码不存在?),浏览器会要求您执行某些操作,然后它会将签名发送到您的服务,而不是密码。该签名需要从您的验证器硬件和目标服务派生的私钥,因此当您的服务收到该签名时,它可以验证它是否与您存储为凭据的公钥的签名相对应。
因此,概括地说:注册会发送生成的公钥而不是密码,而登录会发送该公钥的签名,从而允许您验证用户在注册时是否是其本人。
实际上,这有点复杂,因为在使用硬件验证器之前需要与服务器进行握手(要求一个质询和其他内容),因此总是需要对您的服务进行两次调用:一次在登录或注册之前,在调用硬件验证器之前,然后是正常的登录或注册。
此外,要存储的字段也比公钥多得多,但我们会帮助您解决这个问题。
如果您想知道与 PassKeys 的关系以及我们是否支持它:当然,是的,PassKeys 是您的验证器设备可以共享和同步其凭据的一种方式,然后您可以将这些凭据与我们的 WebAuthn 身份验证一起使用。
WebAuthn 规范要求使用 HTTPS 与服务器通信,但某些浏览器允许 localhost 。如果您必须在开发模式下使用 HTTPS,您可以随时使用 quarkus-ngrok 扩展。 |
架构
在此示例中,我们构建了一个非常简单的微服务,该微服务提供四个端点
-
/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 文档,其中包含有关用户的详细信息。
解决方案
我们建议您按照以下章节中的说明,逐步创建应用程序。但是,您可以直接转到完整的示例。
克隆 Git 存储库:git clone https://github.com/quarkusio/quarkus-quickstarts.git
,或下载 archive。
该解决方案位于 security-webauthn-quickstart
directory 中。
创建 Maven 项目
首先,我们需要一个新项目。使用以下命令创建一个新项目
对于 Windows 用户
-
如果使用 cmd,(不要使用反斜杠
\
并将所有内容放在同一行上) -
如果使用 Powershell,请用双引号将
-D
参数括起来,例如"-DprojectArtifactId=security-webauthn-quickstart"
不要忘记添加选择的数据库连接器库。这里我们使用 PostgreSQL 作为身份存储。 |
此命令生成一个 Maven 项目,导入 security-webauthn
扩展,该扩展允许您使用 WebAuthn 对用户进行身份验证。
如果您已经配置了您的 Quarkus 项目,您可以通过在项目基本目录中运行以下命令将 security-webauthn
扩展添加到您的项目中
quarkus extension add security-webauthn
./mvnw quarkus:add-extension -Dextensions='security-webauthn'
./gradlew addExtension --extensions='security-webauthn'
这会将以下内容添加到您的构建文件中
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-webauthn</artifactId>
</dependency>
implementation("io.quarkus:quarkus-security-webauthn")
编写应用程序
让我们从实现 /api/public
端点开始。从下面的源代码中可以看到,它只是一个普通的 Jakarta REST 资源
package org.acme.security.webauthn;
import java.security.Principal;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/public")
public class PublicResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String publicResource() {
return "public";
}
@GET
@Path("/me")
@Produces(MediaType.TEXT_PLAIN)
public String me(@Context SecurityContext securityContext) {
Principal user = securityContext.getUserPrincipal();
return user != null ? user.getName() : "<not logged in>";
}
}
/api/admin
端点的源代码也非常简单。这里的主要区别在于我们使用 @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
来访问当前经过身份验证的 Principal,并返回用户的姓名。此信息是从数据库加载的。
package org.acme.security.webauthn;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/users")
public class UserResource {
@GET
@RolesAllowed("user")
@Path("/me")
public String me(@Context SecurityContext securityContext) {
return securityContext.getUserPrincipal().getName();
}
}
存储我们的 WebAuthn 凭据
我们现在可以使用两个实体来描述我们的 WebAuthn 凭据如何存储在我们的数据库中。请注意,我们简化了模型,以便每个用户只存储一个凭据(实际上,每个用户可以有多个 WebAuthn 凭据和其他数据,例如角色)
package org.acme.security.webauthn;
import java.util.List;
import java.util.UUID;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
@Entity
public class WebAuthnCredential extends PanacheEntityBase {
@Id
public String credentialId;
public byte[] publicKey;
public long publicKeyAlgorithm;
public long counter;
public UUID aaguid;
// this is the owning side
@OneToOne
public User user;
public WebAuthnCredential() {
}
public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) {
RequiredPersistedData requiredPersistedData =
credentialRecord.getRequiredPersistedData();
aaguid = requiredPersistedData.aaguid();
counter = requiredPersistedData.counter();
credentialId = requiredPersistedData.credentialId();
publicKey = requiredPersistedData.publicKey();
publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm();
this.user = user;
user.webAuthnCredential = this;
}
public WebAuthnCredentialRecord toWebAuthnCredentialRecord() {
return WebAuthnCredentialRecord
.fromRequiredPersistedData(
new RequiredPersistedData(user.username, credentialId,
aaguid, publicKey,
publicKeyAlgorithm, counter));
}
public static List<WebAuthnCredential> findByUsername(String username) {
return list("user.username", username);
}
public static WebAuthnCredential findByCredentialId(String credentialId) {
return findById(credentialId);
}
}
和我们的用户实体
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();
}
}
将您的实体公开给 Quarkus WebAuthn
您需要定义一个 bean 来实现 WebAuthnUserProvider
,以便允许 Quarkus WebAuthn 扩展加载和存储凭据。在这里,您可以告诉 Quarkus 如何将您的数据模型转换为 WebAuthn 安全模型
import java.util.Collections;
import java.util.List;
import java.util.Set;
import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@Blocking
@ApplicationScoped
public class MyWebAuthnSetup implements WebAuthnUserProvider {
@Transactional
@Override
public Uni<List<WebAuthnCredentialRecord>> findByUsername(String userId) {
return Uni.createFrom().item(
WebAuthnCredential.findByUsername(userId)
.stream()
.map(WebAuthnCredential::toWebAuthnCredentialRecord)
.toList());
}
@Transactional
@Override
public Uni<WebAuthnCredentialRecord> findByCredentialId(String credId) {
WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credId);
if(creds == null)
return Uni.createFrom()
.failure(new RuntimeException("No such credential ID"));
return Uni.createFrom().item(creds.toWebAuthnCredentialRecord());
}
@Transactional
@Override
public Uni<Void> store(WebAuthnCredentialRecord credentialRecord) {
User newUser = new User();
// We can only store one credential per username thanks to the unicity constraint
// which will cause this transaction to fail and throw if the username already exists
newUser.username = credentialRecord.getUsername();
WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser);
credential.persist();
newUser.persist();
return Uni.createFrom().voidItem();
}
@Transactional
@Override
public Uni<Void> update(String credentialId, long counter) {
WebAuthnCredential credential =
WebAuthnCredential.findByCredentialId(credentialId);
credential.counter = counter;
return Uni.createFrom().voidItem();
}
@Override
public Set<String> getRoles(String userId) {
if(userId.equals("admin")) {
return Set.of("user", "admin");
}
return Collections.singleton("user");
}
}
警告:在实现您自己的 WebAuthnUserProvider.store
方法时,请确保您永远不允许为已经存在的 username
创建新凭据。否则,您可能会允许第三方通过允许他们将自己的凭据添加到现有帐户来冒充现有用户。如果您想允许现有用户注册多个 WebAuthn 凭据,您必须确保在 WebAuthnUserProvider.store
中,用户当前以您想添加新凭据的同一 username
登录。在其他任何情况下,请确保从此方法返回一个失败的 Uni
。在此特定示例中,这是通过用户名上的唯一性约束来检查的,如果用户已经存在,这将导致事务失败。
配置
因为我们想将登录和注册委托给默认的 Quarkus WebAuthn 端点,我们需要在配置中启用它们(src/main/resources/application.properties
)
quarkus.webauthn.enable-login-endpoint=true
quarkus.webauthn.enable-registration-endpoint=true
编写 HTML 应用程序
我们现在需要编写一个网页,其中包含指向我们所有 API 的链接,以及一种在 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>
<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();
const result = document.getElementById('result');
fetch('/api/public/me')
.then(response => response.text())
.then(username => result.append("User: "+username));
const loginButton = document.getElementById('login');
loginButton.addEventListener("click", (e) => {
result.replaceChildren();
webAuthn.login()
.then(x => fetch('/api/public/me'))
.then(response => response.text())
.then(username => {
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({ username: username, displayName: firstName + " " + lastName })
.then(body => {
result.append("User: "+username);
})
.catch(err => {
result.append("Registration failed: "+err);
});
return false;
});
</script>
</body>
</html>
尝试应用程序
该应用程序现在受到保护,并且身份由我们的数据库提供。
使用以下命令在开发模式下运行您的应用程序
quarkus dev
./mvnw quarkus:dev
./gradlew --console=plain quarkusDev
这将启动一个 PostgreSQL 开发服务容器,并在您的浏览器中打开 https://:8080。
最初,您将没有注册任何凭据,也没有当前用户

当前用户显示在左侧,您可以使用顶部菜单尝试访问公共 API,这应该有效,而用户和管理员 API 将失败,并将您重定向到当前页面。
首先,通过在右侧的 Register
表单上输入用户名、名字和姓氏,然后按 Register
按钮来注册您的 WebAuthn 凭据

您的浏览器会要求您激活您的 WebAuthn 验证器(您需要一个支持 WebAuthn 的浏览器和可能的设备,或者您可以使用这些设备的模拟器)

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

在此阶段,您可以 Logout

然后按 Login
按钮,您将登录

只有您使用 admin
用户名注册时,管理员 API 才能访问。
WebAuthn 身份验证流程说明。
您想要处理用户和凭据创建的方式有两种操作模式
默认情况下,我们看到的解决方案会自动通过此扩展提供的开箱即用的 WebAuthn 端点处理用户和凭据创建以及登录。
在这种情况下,您只需提供一个 WebAuthnUserProvider
来告诉 Quarkus 如何查找、存储和更新您的凭据(请参阅将您的实体公开给 Quarkus WebAuthn)。
如果您想自定义注册和登录流程,您还可以通过您自己的逻辑自行处理用户和凭据创建以及登录(请参阅自行处理登录和注册端点)。
WebAuthn 端点
Quarkus WebAuthn 扩展提供了这些开箱即用的预定义 REST 端点
获取注册质询
GET /q/webauthn/register-options-challenge?username=<username>&displayName=<displayName>
:设置并获取注册质询
查询参数
-
username
是用户名。必需。 -
displayName
是用户帐户的人工可读名称。可选。
这会导致为质询设置一个 cookie,稍后注册步骤将使用它。
{
"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": true,
"residentKey": "required",
"userVerification": "required"
},
"timeout": 300000,
"attestation": "none",
"extensions": {
"txAuthSimple": ""
}
}
触发注册
POST /q/webauthn/register?username=<username>
:触发注册
查询参数
-
username
是用户名。必需。
这使用注册质询设置的质询 cookie 并清除它。它还使用您的 WebAuthnUserProvider
来存储新凭据,并设置 会话 cookie 以让您登录。
只有在 quarkus.webauthn.enable-registration-endpoint 配置设置为 true 时,才会启用此功能。 |
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"attestationObject": "<DATA>",
"clientDataJSON":"<DATA>"
},
"type": "public-key"
}
这会返回 204,没有正文。
获取登录质询
GET /q/webauthn/login-options-challenge?username=<username>
:设置并获取登录质询
查询参数
-
username
是用户名。在 可发现凭据(使用 PassKeys)的情况下,这是可选的。
这会导致为质询设置一个 cookie,稍后登录步骤将使用它。
{
"challenge": "RV4hqKHezkWSxpOICBkpx16yPJFGMZrkPlJP-Wp8w4rVl34VIzCT7AP0Q5Rv-3JCU3jwu-j3VlOgyNMDk2AqDg",
"timeout": 300000,
"userVerification": "required",
"extensions": {
"txAuthSimple": ""
},
"allowCredentials": [
{
"type": "public-key",
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"transports": [
"usb",
"nfc",
"ble",
"internal"
]
}
]
}
触发登录
这使用登录质询设置的质询 cookie 并清除它。它还使用您的 WebAuthnUserProvider
来查找和更新凭据,并设置 会话 cookie 以让您登录。
只有在 quarkus.webauthn.enable-login-endpoint 配置设置为 true 时,才会启用此功能。 |
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"clientDataJSON": "<DATA>",
"authenticatorData": "<DATA>",
"signature": "<DATA>",
"userHandle": ""
},
"type": "public-key"
}
这会返回 204,没有正文。
WebAuthn JavaScript 库
因为需要在浏览器中设置 WebAuthn 的 JavaScript 代码非常多,所以 Quarkus WebAuthn 扩展附带了一个 JavaScript 库来帮助您与 WebAuthn 端点进行通信,该库位于 /q/webauthn/webauthn.js
。您可以像这样设置它
<script src="/q/webauthn/webauthn.js" type="text/javascript" charset="UTF-8"></script>
<script type="text/javascript">
// keep the default /q/webauthn endpoints
const webAuthn = new WebAuthn();
// use the webAuthn APIs here
</script>
或者,如果您需要自定义端点
<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({
registerOptionsChallengePath: '/q/webauthn/register-options-challenge',
loginOptionsChallengePath: '/q/webauthn/login-options-challenge',
registerPath: '/q/webauthn/register',
loginPath: '/q/webauthn/login'
});
// use the webAuthn APIs here
</script>
CSRF 注意事项
如果您使用 Quarkus 提供的端点,它们将不受 xdoc:security-csrf-prevention.adoc[CSRF] 的保护,但是如果您定义自己的端点并使用此 JavaScript 库来访问它们,您将需要通过标头配置 CSRF
<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({
'csrf': {
'header': '{inject:csrf.headerName}',
'value': '{inject:csrf.token}'
}
});
// use the webAuthn APIs here
</script>
调用注册
webAuthn.register
方法调用注册质询端点,然后调用验证器并调用该注册的注册端点,并返回一个 Promise 对象
webAuthn.register({ username: username, displayName: firstName + " " + lastName })
.then(body => {
// do something now that the user is registered
})
.catch(err => {
// registration failed
});
调用登录
webAuthn.login
方法调用登录质询端点,然后调用验证器并调用该登录的登录端点,并返回一个 Promise 对象
webAuthn.login({ username: username }) (1)
.then(body => {
// do something now that the user is logged in
})
.catch(err => {
// login failed
});
1 | 在 可发现凭据(使用 PassKeys)的情况下,用户名是可选的 |
仅调用注册质询和验证器
webAuthn.registerClientSteps
方法调用注册质询端点,然后调用验证器并返回一个 Promise 对象,该对象包含一个适合发送到注册端点的 JSON 对象。例如,您可以使用该 JSON 对象将凭据存储在隐藏的表单 input
元素中,并将其作为常规 HTML 表单的一部分发送
webAuthn.registerClientSteps({ username: username, displayName: firstName + " " + lastName })
.then(body => {
// store the registration JSON in form elements
document.getElementById('webAuthnId').value = body.id;
document.getElementById('webAuthnRawId').value = body.rawId;
document.getElementById('webAuthnResponseAttestationObject').value = body.response.attestationObject;
document.getElementById('webAuthnResponseClientDataJSON').value = body.response.clientDataJSON;
document.getElementById('webAuthnType').value = body.type;
})
.catch(err => {
// registration failed
});
仅调用登录质询和验证器
webAuthn.loginClientSteps
方法调用登录质询端点,然后调用验证器并返回一个 Promise 对象,该对象包含一个适合发送到登录端点的 JSON 对象。例如,您可以使用该 JSON 对象将凭据存储在隐藏的表单 input
元素中,并将其作为常规 HTML 表单的一部分发送
webAuthn.loginClientSteps({ username: username }) (1)
.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
});
1 | 在 可发现凭据(使用 PassKeys)的情况下,用户名是可选的 |
自行处理登录和注册端点
有时,您可能希望请求比用户名更多的数据才能注册用户,或者您希望使用自定义验证来处理登录和注册,因此默认的 WebAuthn 登录和注册端点是不够的。
在这种情况下,您可以使用 JavaScript 库中的 WebAuthn.loginClientSteps
和 WebAuthn.registerClientSteps
方法,将验证器数据存储在隐藏的表单元素中,并将其作为表单有效负载的一部分发送到服务器到您的自定义登录或注册端点。
如果您将它们存储在表单输入元素中,那么您可以使用 WebAuthnLoginResponse
和 WebAuthnRegistrationResponse
类,将它们标记为 @BeanParam
,然后使用 WebAuthnSecurity.login
和 WebAuthnSecurity.register
方法来替换 /q/webauthn/login
和 /q/webauthn/register
端点。
在大多数情况下,您可以继续使用 /q/webauthn/login-options-challenge
和 /q/webauthn/register-options-challenge
质询启动端点,因为这不需要自定义逻辑。
在这种情况下,注册流程有点不同,因为您将编写自己的注册端点,该端点将处理凭据的存储和会话 cookie 的设置
同样,登录流程有点不同,因为您将编写自己的登录端点,该端点将处理凭据的更新和会话 cookie 的设置
如果您在端点中自行处理用户和凭据创建以及登录,您只需要在您的 WebAuthnUserProvider
中提供实体的只读视图,因此您可以跳过 store
和 update
方法
package org.acme.security.webauthn;
import java.util.List;
import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import model.WebAuthnCredential;
@Blocking
@ApplicationScoped
public class MyWebAuthnSetup implements WebAuthnUserProvider {
@Transactional
@Override
public Uni<List<WebAuthnCredentialRecord>> findByUsername(String username) {
return Uni.createFrom().item(
WebAuthnCredential.findByUsername(username)
.stream()
.map(WebAuthnCredential::toWebAuthnCredentialRecord)
.toList());
}
@Transactional
@Override
public Uni<WebAuthnCredentialRecord> findByCredentialId(String credentialId) {
WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credentialId);
if(creds == null)
return Uni.createFrom()
.failure(new RuntimeException("No such credential ID"));
return Uni.createFrom().item(creds.toWebAuthnCredentialRecord());
}
@Override
public Set<String> getRoles(String userId) {
if(userId.equals("admin")) {
return Set.of("user", "admin");
}
return Collections.singleton("user");
}
}
在设置您自己的登录和注册端点时,您不需要启用默认端点,因此您可以删除 quarkus.webauthn.enable-login-endpoint 和 quarkus.webauthn.enable-registration-endpoint 配置。 |
值得庆幸的是,您可以使用 WebAuthnSecurity
bean 来处理您的注册和登录端点的 WebAuthn 特定部分,并将精力集中在您自己的逻辑上
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.WebAuthnCredentialRecord;
import io.quarkus.security.webauthn.WebAuthnLoginResponse;
import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
import io.quarkus.security.webauthn.WebAuthnSecurity;
import io.vertx.ext.web.RoutingContext;
@Path("")
public class LoginResource {
@Inject
WebAuthnSecurity webAuthnSecurity;
// Provide an alternative implementation of the /q/webauthn/login endpoint
@Path("/login")
@POST
@Transactional
public Response login(@BeanParam WebAuthnLoginResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(!webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
return Response.status(Status.BAD_REQUEST).build();
}
try {
WebAuthnCredentialRecord credentialRecord = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely();
User user = User.findByUsername(credentialRecord.getUsername());
if(user == null) {
// Invalid user
return Response.status(Status.BAD_REQUEST).build();
}
// bump the auth counter
user.webAuthnCredential.counter = credentialRecord.getCounter();
// make a login cookie
this.webAuthnSecurity.rememberUser(credentialRecord.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/register endpoint
@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
WebAuthnCredentialRecord credentialRecord =
webAuthnSecurity.register(username, webAuthnResponse, ctx).await().indefinitely();
User newUser = new User();
newUser.username = credentialRecord.getUsername();
WebAuthnCredential credential =
new WebAuthnCredential(credentialRecord, 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 类上的 WebAuthnSecurity.rememberUser 和 WebAuthnSecurity.logout 方法。 |
阻塞版本
如果您使用阻塞数据访问数据库,您可以安全地阻塞 WebAuthnSecurity
方法,使用 .await().indefinitely()
,因为 register
和 login
方法中没有任何异步操作,除了通过您的 WebAuthnUserProvider
进行的数据访问。
您必须在您的 WebAuthnUserProvider
类上添加 @Blocking
注释,以便 Quarkus WebAuthn 端点将这些调用推迟到工作线程池。
虚拟线程版本
如果您使用阻塞数据访问数据库,您可以安全地阻塞 WebAuthnSecurity
方法,使用 .await().indefinitely()
,因为 register
和 login
方法中没有任何异步操作,除了通过您的 WebAuthnUserProvider
进行的数据访问。
您必须在您的 WebAuthnUserProvider
类上添加 @RunOnVirtualThread
注释,以便告诉 Quarkus WebAuthn 端点将这些调用推迟到工作线程池。
测试 WebAuthn
测试 WebAuthn 可能很复杂,因为通常您需要一个硬件令牌,这就是我们创建 quarkus-test-security-webauthn
帮助程序库的原因
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-webauthn</artifactId>
<scope>test</scope>
</dependency>
testImplementation("io.quarkus:quarkus-test-security-webauthn")
使用此库,您可以使用 WebAuthnHardware
来模拟验证器令牌,以及 WebAuthnEndpointHelper
帮助程序方法来调用 WebAuthn 端点,甚至填写自定义端点的表单数据
package org.acme.security.webauthn.test;
import static io.restassured.RestAssured.given;
import java.net.URL;
import java.util.function.Consumer;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import io.quarkus.test.common.http.TestHTTPResource;
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.filter.cookie.CookieFilter;
import io.restassured.specification.RequestSpecification;
import io.vertx.core.json.JsonObject;
@QuarkusTest
public class WebAuthnResourceTest {
enum User {
USER, ADMIN;
}
enum Endpoint {
DEFAULT, MANUAL;
}
@TestHTTPResource
URL url;
@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 CookieFilter();
WebAuthnHardware token = new WebAuthnHardware(url);
verifyLoggedOut(cookieFilter);
// two-step registration
String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(username, cookieFilter);
JsonObject registrationJson = token.makeRegistrationJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeRegistration(username, 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.obtainLoginChallenge(null, cookieFilter);
JsonObject loginJson = token.makeLoginJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter);
else {
invokeCustomEndpoint("/login", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson);
});
}
// 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.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("https://:8081/"));
// admin API not accessible
RestAssured.given()
.filter(cookieFilter)
.redirects().follow(false)
.when()
.get("/api/admin")
.then()
.statusCode(302)
.header("Location", Matchers.is("https://:8081/"));
}
}
对于此测试,由于我们正在测试提供的回调端点(它在其 WebAuthnUserProvider
中更新用户)和手动 LoginResource
端点(它手动处理用户),我们需要使用一个不更新 scooby
用户的 WebAuthnUserProvider
来覆盖 WebAuthnUserProvider
package org.acme.security.webauthn.test;
import org.acme.security.webauthn.MyWebAuthnSetup;
import org.acme.security.webauthn.WebAuthnCredential;
import io.quarkus.security.webauthn.WebAuthnCredentialRecord;
import io.quarkus.test.Mock;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@Mock
@ApplicationScoped
public class TestUserProvider extends MyWebAuthnSetup {
@Transactional
@Override
public Uni<Void> store(WebAuthnCredentialRecord credentialRecord) {
// this user is handled in the LoginResource endpoint manually
if (credentialRecord.getUsername().equals("scooby")) {
return Uni.createFrom().voidItem();
}
return super.store(credentialRecord);
}
@Transactional
@Override
public Uni<Void> update(String credentialId, long counter) {
WebAuthnCredential credential = WebAuthnCredential.findByCredentialId(credentialId);
// this user is handled in the LoginResource endpoint manually
if (credential.user.username.equals("scooby")) {
return Uni.createFrom().voidItem();
}
return super.update(credentialId, counter);
}
}
配置参考
可以使用 quarkus.http.auth.session.encryption-key
配置选项设置安全加密密钥,如安全指南中所述。
构建时固定的配置属性 - 所有其他配置属性都可以在运行时覆盖
配置属性 |
类型 |
默认 |
---|---|---|
布尔值 |
|
|
应用程序的来源。来源基本上是协议、主机和端口。如果您在您的应用程序位于 环境变量: 显示更多 |
字符串列表 |
|
应用程序允许的验证器传输。验证器可以通过多种传输与用户 Web 浏览器交互。应用程序可能出于额外的安全加固原因而限制传输协议。默认情况下,应允许所有传输。如果您的应用程序将被手机用户使用,您可能只想限制只允许
环境变量: 显示更多 |
|
|
id(或您的服务器的域名,从 环境变量: 显示更多 |
字符串 |
|
字符串 |
|
|
允许的验证器附件的类型。验证器可以通过两种形式连接到您的设备
出于安全原因,您的应用程序可以选择限制为特定的附件模式。如果省略,则允许任何模式。 环境变量: 显示更多 |
|
|
加载 FIDO 元数据以进行验证。请参阅 https://fidoalliance.org/metadata/。仅适用于与 环境变量: 显示更多 |
布尔值 |
|
所需的常驻密钥。常驻(私钥)密钥是无法离开您的验证器设备的密钥,这意味着您无法重用该验证器登录到第二台计算机。 环境变量: 显示更多 |
|
|
用户验证要求。Webauthn 应用程序可以选择
环境变量: 显示更多 |
|
|
布尔值 |
|
|
非负用户验证超时。身份验证必须在超时时间内发生,这将防止用户浏览器被需要用户验证的弹出窗口阻止,并且整个仪式必须在超时时间内完成。超时后,任何先前颁发的质询都会自动失效。 环境变量: 显示更多 |
|
|
设备证明首选项。在注册期间,应用程序可能想要证明设备。证明是对验证器硬件的加密验证。证明意味着用户的隐私可能会被暴露,并且浏览器可能会代表用户覆盖所需的配置。有效值为
环境变量: 显示更多 |
|
|
按首选项顺序排列的允许的公钥凭据算法。Webauthn 强制所有验证器必须至少支持以下 2 种算法: 环境变量: 显示更多 |
|
|
应用程序和浏览器之间交换的质询的长度。质询必须至少为 32 字节。 环境变量: 显示更多 |
整数 |
|
字符串 |
|
|
非活动(空闲)超时。当达到非活动超时时,cookie 不会续订,并且会强制执行新的登录。 环境变量: 显示更多 |
|
|
Cookie 在被具有更新超时时间的新 cookie 替换之前可以存在多久,也称为“renewal-timeout”。请注意,较小的值会导致服务器负载略微增加(因为会更频繁地生成新的加密 cookie);但是,较大的值会影响不活动超时,因为超时是在生成 cookie 时设置的。例如,如果此值设置为 10 分钟,并且不活动超时为 30 分钟,则如果用户上次请求的时间是 cookie 已经存在 9 分钟时,则实际超时将在上次请求后 21 分钟发生,因为超时仅在生成新 cookie 时刷新。也就是说,服务器端不跟踪超时;时间戳被编码和加密在 cookie 本身中,并且每次请求都会被解密和解析。 环境变量: 显示更多 |
|
|
字符串 |
|
|
用于在登录/注册期间存储 challenge 数据的 cookie 环境变量: 显示更多 |
字符串 |
|
会话 cookie 的 SameSite 属性。 环境变量: 显示更多 |
|
|
字符串 |
|
|
会话 cookie 的 Max-Age 属性。 这是浏览器将保留 cookie 的时间。 默认值为空,这意味着 cookie 将保留到浏览器关闭为止。 环境变量: 显示更多 |
||
如果您想启用 环境变量: 显示更多 |
布尔值 |
|
如果您想启用 环境变量: 显示更多 |
布尔值 |
|
关于 Duration 格式
要编写持续时间值,请使用标准 您还可以使用简化的格式,以数字开头
在其他情况下,简化格式将被转换为
|