Using Security with WebAuthn
本指南演示了 Quarkus 应用程序如何使用 WebAuthn 认证代替密码。 :iokays-category: quarkus :iokays-path: modules/ROOT/pages/_includes/extension-status.adoc :keywords: Quarkus, 中文文档, 编程技术
该技术被认为是 {extension-status}。 有关可能状态的完整列表,请查看我们的 FAQ entry. |
- Prerequisites
- Introduction to WebAuthn
- Architecture
- Solution
- Creating the Maven Project
- Writing the application
- Writing the HTML application
- Testing the application
- WebAuthn endpoints
- WebAuthn JavaScript library
- Handling login and registration endpoints yourself
- Blocking version
- Virtual-Threads version
- Testing WebAuthn
- Configuration Reference
- References
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 与服务器进行通信,不过某些浏览器允许 |
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
首先,我们需要一个新项目。使用以下命令创建一个新项目:
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 指南。
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
扩展添加到项目:
quarkus extension add {add-extension-extensions}
./mvnw quarkus:add-extension -Dextensions='{add-extension-extensions}'
./gradlew addExtension --extensions='{add-extension-extensions}'
这会将以下内容添加到构建文件中:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-webauthn</artifactId>
</dependency>
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();
}
}
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
现在,该应用程序受到保护,并且身份由我们的数据库提供。
使用以下命令在开发模式下运行您的应用程序:
quarkus dev
./mvnw quarkus:dev
./gradlew --console=plain quarkusDev
这将启动一个 PostgreSQL 开发服务容器,并在您的浏览器中打开 [role="bare"][role="bare"]http://localhost:8080 。
最初,你没有任何注册的凭证,也没有当前用户:
当前用户显示在左侧,你可以使用顶部菜单尝试访问公共 API,而用户和管理员 API 将失败并重定向你到当前页面。
通过在右侧的 Register
表单中输入用户名、名和姓来开始注册你的 WebAuthn 凭据,然后按下 Register
按钮:
你的浏览器将要求你激活你的 WebAuthn 认证器(你需要一个兼容 WebAuthn 的浏览器,可能需要设备,或者你可以使用 an emulator of those):
然后你会登录,并且可以检查用户 API 现在是否可以访问:
在此阶段,你可以 Logout
并在 Login
中输入你的用户名:
然后按下 Login
按钮,你将登录:
只有当你使用 admin
用户名注册时才能访问管理员 API。
WebAuthn endpoints
Quarkus WebAuthn 扩展开箱即用,预定义了这些 REST 端点:
Obtain a registration challenge
POST /q/webauthn/register
:设置并获取注册 challenge
{
"name": "userName", 1
"displayName": "Mr Nice Guy" 2
}
1 | Required |
2 | Optional |
{
"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
:触发注册
{
"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
{
"name": "userName" 1
}
1 | Required |
{
"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
:触发登录
{
"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 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.loginOnly
和 WebAuthn.registerOnly
方法,将验证器数据存储在隐藏表单元素中,并将它们作为表单有效负载的一部分发送到服务器以自定义登录或注册端点。
如果你将它们存储在表单输入元素中,则可以使用 WebAuthnLoginResponse
和 WebAuthnRegistrationResponse
类,将它们标记为 @BeanParam
,然后使用 WebAuthnSecurity.login
和 WebAuthnSecurity.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();
}
}
}
|
Blocking version
如果你使用阻塞数据访问数据库,则可以安全地阻止 WebAuthnSecurity
方法(使用 .await().indefinitely()
),因为除了与 WebAuthnUserProvider
的数据访问之外, register
和 login
方法中没有任何内容是异步的。
你必须在 WebAuthnUserProvider
类中添加 @Blocking
注释,以告知 Quarkus WebAuthn 端点将这些调用推迟到工作程序池。
Virtual-Threads version
如果你使用阻塞数据访问数据库,则可以安全地阻止 WebAuthnSecurity
方法(使用 .await().indefinitely()
),因为除了与 WebAuthnUserProvider
的数据访问之外, register
和 login
方法中没有任何内容是异步的。
你必须在 WebAuthnUserProvider
类中添加 @RunOnVirtualThread
注释,以告知 Quarkus WebAuthn 端点将这些调用推迟到虚拟线程。
Testing 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.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[]