This guide demonstrates how your Quarkus application can use WebAuthn authentication instead of passwords.
WebAuthn is an authentication mechanism designed to replace passwords. In short, every time you write a service for registering new users, or logging them in, instead of asking for a password, you use WebAuthn, which will replace the password.
WebAuthn replaces the password with a proof of identity. In practice, users, instead of having to invent a password, store it or remember it, will use a hardware token that will generate a proof of identity specifically for your service or website. This can be done by asking users to press their thumb on their phone, or pressing a button on a YubiKey on a computer.
So, when you register your user, you use your browser to enter your user information (username, your name, etc…) and instead of typing a password to identify yourself, you click a button which will invoke the WebAuthn browser API which will ask you to do something (press a button, use your fingerprint). Then, your browser will generate a proof of identity which you can send to your service instead of a password.
This proof of identity, when you register, consists mostly in a public key. Actually there’s a lot of stuff in there but the most interesting is the public key. This public key is not stored on your device, or your browser. It is generated especially for the target service (tied to its URI), and derived from the hardware authenticator. So the association of the hardware authenticator and the target service will always derive the same private and public key pair, none of which are stored anywhere. You can for example, take your YubiKey to another computer and it will keep generating the same private/public keys for the same target service.
So, when you register, you send (mostly) a public key instead of a password, and the service stores that information as WebAuthn credentials for your new user account, and this is what will identify you later.
Then, when you need to log in to that service, instead of typing your password (which doesn’t exist, remember?), you press a button on the login form, and the browser will ask you to do something, and then it will send a signature to your service instead of a password. That signature requires the private key that is derived from your authenticator hardware and the target service, and so when your service receives it, it can verify that it corresponds to the signature of the public key you stored as credentials.
So, to recap: registration sends a generated public key instead of a password, and login sends a signature for that public key, allowing you to verify that the user is who they were when they registered.
In practice, it’s a little more complex, because there needs to be a handshake with the server before you can use the hardware authenticator (ask for a challenge and other things), so there are always two calls to your service: one before login or registration, before calling the hardware authenticator, and then the normal login or registration.
And also there are a lot more fields to store than just a public key, but we will help you with that.
In this example, we build a very simple microservice which offers four endpoints:
-
/api/public
-
/api/public/me
-
/api/users/me
-
/api/admin
The /api/public
endpoint can be accessed anonymously.
The /api/public/me
endpoint can be accessed anonymously and returns the current username if there is one, or <not logged in>
if not.
The /api/admin
endpoint is protected with RBAC (Role-Based Access Control) where only users granted with the admin
role can access. At this endpoint, we use the @RolesAllowed
annotation to declaratively enforce the access constraint.
The /api/users/me
endpoint is also protected with RBAC (Role-Based Access Control) where only users granted with the user
role can access. As a response, it returns a JSON document with details about the user.
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
Clone the Git repository: git clone {quickstarts-clone-url}
, or download an {quickstarts-archive-url}[archive].
The solution is located in the security-webauthn-quickstart
{quickstarts-tree-url}/security-webauthn-quickstart[directory].
First, we need a new project. Create a new project with the following command:
Note
|
Don’t forget to add the database connector library of choice. Here we are using PostgreSQL as identity store. |
This command generates a Maven project, importing the security-webauthn
extension
which allows you to use WebAuthn to authenticate users.
If you already have your Quarkus project configured, you can add the security-webauthn
extension
to your project by running the following command in your project base directory:
This will add the following to your build file:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-webauthn</artifactId>
</dependency>
implementation("io.quarkus:quarkus-security-webauthn")
Let’s start by implementing the /api/public
endpoint. As you can see from the source code below, it is just a regular Jakarta REST resource:
package org.acme.security.webauthn;
import java.security.Principal;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/public")
public class PublicResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String publicResource() {
return "public";
}
@GET
@Path("/me")
@Produces(MediaType.TEXT_PLAIN)
public String me(@Context SecurityContext securityContext) {
Principal user = securityContext.getUserPrincipal();
return user != null ? user.getName() : "<not logged in>";
}
}
The source code for the /api/admin
endpoint is also very simple. The main difference here is that we are using a @RolesAllowed
annotation to make sure that only users granted with the admin
role can access the endpoint:
package org.acme.security.webauthn;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/admin")
public class AdminResource {
@GET
@RolesAllowed("admin")
@Produces(MediaType.TEXT_PLAIN)
public String adminResource() {
return "admin";
}
}
Finally, let’s consider the /api/users/me
endpoint. As you can see from the source code below, we are trusting only users with the user
role.
We are using SecurityContext
to get access to the current authenticated Principal and we return the user’s name. This information is loaded from the database.
package org.acme.security.webauthn;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
@Path("/api/users")
public class UserResource {
@GET
@RolesAllowed("user")
@Path("/me")
public String me(@Context SecurityContext securityContext) {
return securityContext.getUserPrincipal().getName();
}
}
We can now describe how our WebAuthn credentials are stored in our database with three entities. Note that we’ve simplified the model in order to only store one credential per user (who could actually have more than one WebAuthn credential and other data such as roles):
package org.acme.security.webauthn;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import io.quarkus.hibernate.reactive.panache.PanacheEntity;
import io.smallrye.mutiny.Uni;
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 Uni<List<WebAuthnCredential>> findByUserName(String userName) {
return list("userName", userName);
}
public static Uni<List<WebAuthnCredential>> findByCredID(String credID) {
return list("credID", credID);
}
public <T> Uni<T> fetch(T association) {
return getSession().flatMap(session -> session.fetch(association));
}
}
We also need a second entity for the credentials:
package org.acme.security.webauthn;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import io.quarkus.hibernate.reactive.panache.PanacheEntity;
@Entity
public class WebAuthnCertificate extends PanacheEntity {
@ManyToOne
public WebAuthnCredential webAuthnCredential;
/**
* The list of X509 certificates encoded as base64url.
*/
public String x5c;
}
And last but not least, our user entity:
package org.acme.security.webauthn;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import io.quarkus.hibernate.reactive.panache.PanacheEntity;
import io.smallrye.mutiny.Uni;
@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 Uni<User> findByUserName(String userName) {
return find("userName", userName).firstResult();
}
}
WebAuthn relies on a combination of usernames (unique per user) and credential IDs (unique per authenticator device).
The reasons why there are two such identifiers, and why they are not unique keys for the credentials themselves are:
-
A single user can have more than one authenticator device, which means a single username can map to multiple credential IDs, all of which identify the same user.
-
An authenticator device may be shared by multiple users, because a single person may want multiple user accounts with different usernames, all of which having the same authenticator device. So a single credential ID may be used by multiple different users.
The combination of username and credential ID should be a unicity constraint for your credentials table, though.
You need to define a bean implementing the WebAuthnUserProvider
in order to allow the Quarkus WebAuthn
extension to load and store credentials. This is where you tell Quarkus how to turn your data model into the
WebAuthn security model:
package org.acme.security.webauthn;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional;
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;
@ApplicationScoped
public class MyWebAuthnSetup implements WebAuthnUserProvider {
@ReactiveTransactional
@Override
public Uni<List<Authenticator>> findWebAuthnCredentialsByUserName(String userName) {
return WebAuthnCredential.findByUserName(userName)
.flatMap(MyWebAuthnSetup::toAuthenticators);
}
@ReactiveTransactional
@Override
public Uni<List<Authenticator>> findWebAuthnCredentialsByCredID(String credID) {
return WebAuthnCredential.findByCredID(credID)
.flatMap(MyWebAuthnSetup::toAuthenticators);
}
@ReactiveTransactional
@Override
public Uni<Void> updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
return User.findByUserName(authenticator.getUserName())
.flatMap(user -> {
// new user
if(user == null) {
User newUser = new User();
newUser.userName = authenticator.getUserName();
WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser);
return credential.persist()
.flatMap(c -> newUser.persist())
.onItem().ignore().andContinueWithNull();
} else {
// existing user
user.webAuthnCredential.counter = authenticator.getCounter();
return Uni.createFrom().nullItem();
}
});
}
private static Uni<List<Authenticator>> toAuthenticators(List<WebAuthnCredential> dbs) {
// can't call combine/uni on empty list
if(dbs.isEmpty())
return Uni.createFrom().item(Collections.emptyList());
List<Uni<Authenticator>> ret = new ArrayList<>(dbs.size());
for (WebAuthnCredential db : dbs) {
ret.add(toAuthenticator(db));
}
return Uni.combine().all().unis(ret).combinedWith(f -> (List)f);
}
private static Uni<Authenticator> toAuthenticator(WebAuthnCredential credential) {
return credential.fetch(credential.x5c)
.map(x5c -> {
Authenticator ret = new Authenticator();
ret.setAaguid(credential.aaguid);
AttestationCertificates attestationCertificates = new AttestationCertificates();
attestationCertificates.setAlg(credential.alg);
List<String> x5cs = new ArrayList<>(x5c.size());
for (WebAuthnCertificate webAuthnCertificate : x5c) {
x5cs.add(webAuthnCertificate.x5c);
}
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")) {
Set<String> ret = new HashSet<>();
ret.add("user");
ret.add("admin");
return ret;
}
return Collections.singleton("user");
}
}
We now need to write a web page with links to all our APIs, as well as a way to register a new user, login, and logout,
in src/main/resources/META-INF/resources/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<script src="/q/webauthn/webauthn.js" type="text/javascript" charset="UTF-8"></script>
<style>
.container {
display: grid;
grid-template-columns: auto auto auto;
}
button, input {
margin: 5px 0;
}
.item {
padding: 20px;
}
nav > ul {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #333;
}
nav > ul > li {
float: left;
}
nav > ul > li > a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
nav > ul > li > a:hover {
background-color: #111;
}
</style>
</head>
<body>
<nav>
<ul>
<li><a href="/api/public">Public API</a></li>
<li><a href="/api/users/me">User API</a></li>
<li><a href="/api/admin">Admin API</a></li>
<li><a href="/q/webauthn/logout">Logout</a></li>
</nav>
<div class="container">
<div class="item">
<h1>Status</h1>
<div id="result"></div>
</div>
<div class="item">
<h1>Login</h1>
<p>
<input id="userNameLogin" placeholder="User name"/><br/>
<button id="login">Login</button>
</p>
</div>
<div class="item">
<h1>Register</h1>
<p>
<input id="userNameRegister" placeholder="User name"/><br/>
<input id="firstName" placeholder="First name"/><br/>
<input id="lastName" placeholder="Last name"/><br/>
<button id="register">Register</button>
</p>
</div>
</div>
<script type="text/javascript">
const webAuthn = new WebAuthn({
callbackPath: '/q/webauthn/callback',
registerPath: '/q/webauthn/register',
loginPath: '/q/webauthn/login'
});
const result = document.getElementById('result');
fetch('/api/public/me')
.then(response => response.text())
.then(name => result.append("User: "+name));
const loginButton = document.getElementById('login');
loginButton.onclick = () => {
var userName = document.getElementById('userNameLogin').value;
result.replaceChildren();
webAuthn.login({ name: userName })
.then(body => {
result.append("User: "+userName);
})
.catch(err => {
result.append("Login failed: "+err);
});
return false;
};
const registerButton = document.getElementById('register');
registerButton.onclick = () => {
var userName = document.getElementById('userNameRegister').value;
var firstName = document.getElementById('firstName').value;
var lastName = document.getElementById('lastName').value;
result.replaceChildren();
webAuthn.register({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
result.append("User: "+userName);
})
.catch(err => {
result.append("Registration failed: "+err);
});
return false;
};
</script>
</body>
</html>
The application is now protected and the identities are provided by our database.
Run your application in dev mode with:
which will start a PostgreSQL Dev Service container, and open http://localhost:8080 in your browser.
Initially, you will have no credentials registered, and no current user:
The current user is displayed on the left, and you can use the top menu to try accessing the public API, which should work, while the user and admin APIs will fail and redirect you to the current page.
Start by registering your WebAuthn credentials by entering a username, first and last names on the Register
form on the right, then pressing the Register
button:
Your browser will ask you to activate your WebAuthn authenticator:
You will then be logged in, and can check that the user API is now accessible:
At this stage you can Logout
and enter your username in the Login
form:
Then press the Login
button, and you will be logged in:
The admin API is only accessible if you register with the admin
user name.
The Quarkus WebAuthn extension comes out of the box with these REST endpoints pre-defined:
POST /q/webauthn/register
: Set up and obtain a registration challenge
{
"name": "userName",
"displayName": "Mr Nice Guy"
}
{
"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": ""
}
}
POST /q/webauthn/login
: Set up and obtain a login challenge
{
"name": "userName"
}
{
"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"
]
}
]
}
POST /q/webauthn/callback
: Trigger a registration
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"attestationObject": "<DATA>",
"clientDataJSON":"<DATA>"
},
"type": "public-key"
}
This returns a 204 with no body.
POST /q/webauthn/callback
: Trigger a login
{
"id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg",
"response": {
"clientDataJSON": "<DATA>",
"authenticatorData": "<DATA>",
"signature": "<DATA>",
"userHandle": ""
},
"type": "public-key"
}
This returns a 204 with no body.
Because there’s so much JavaScript needed to set WebAuthn up in the browser, the Quarkus WebAuthn extension ships with
a JavaScript library to help you talk to the WebAuthn endpoints, at /q/webauthn/webauthn.js
. You can set it up like this:
<script src="/q/webauthn/webauthn.js" type="text/javascript" charset="UTF-8"></script>
<script type="text/javascript">
// configure where our endpoints are
const webAuthn = new WebAuthn({
callbackPath: '/q/webauthn/callback',
registerPath: '/q/webauthn/register',
loginPath: '/q/webauthn/login'
});
// use the webAuthn APIs here
</script>
The webAuthn.register
method invokes the registration challenge endpoint, then calls the authenticator and invokes the callback endpoint
for that registration, and returns a Promise object:
webAuthn.register({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
// do something now that the user is registered
})
.catch(err => {
// registration failed
});
The webAuthn.login
method invokes the login challenge endpoint, then calls the authenticator and invokes the callback endpoint
for that login, and returns a Promise object:
webAuthn.login({ name: userName })
.then(body => {
// do something now that the user is logged in
})
.catch(err => {
// login failed
});
The webAuthn.registerOnly
method invokes the registration challenge endpoint, then calls the authenticator and returns
a Promise object containing a
JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials
in hidden form input
elements, for example, and send it as part of a regular HTML form:
webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName })
.then(body => {
// store the registration JSON in form elements
document.getElementById('webAuthnId').value = body.id;
document.getElementById('webAuthnRawId').value = body.rawId;
document.getElementById('webAuthnResponseAttestationObject').value = body.response.attestationObject;
document.getElementById('webAuthnResponseClientDataJSON').value = body.response.clientDataJSON;
document.getElementById('webAuthnType').value = body.type;
})
.catch(err => {
// registration failed
});
The webAuthn.loginOnly
method invokes the login challenge endpoint, then calls the authenticator and returns
a Promise object containing a
JSON object suitable for being sent to the callback endpoint. You can use that JSON object in order to store the credentials
in hidden form input
elements, for example, and send it as part of a regular HTML form:
webAuthn.loginOnly({ name: userName })
.then(body => {
// store the login JSON in form elements
document.getElementById('webAuthnId').value = body.id;
document.getElementById('webAuthnRawId').value = body.rawId;
document.getElementById('webAuthnResponseClientDataJSON').value = body.response.clientDataJSON;
document.getElementById('webAuthnResponseAuthenticatorData').value = body.response.authenticatorData;
document.getElementById('webAuthnResponseSignature').value = body.response.signature;
document.getElementById('webAuthnResponseUserHandle').value = body.response.userHandle;
document.getElementById('webAuthnType').value = body.type;
})
.catch(err => {
// login failed
});
Sometimes, you will want to ask for more data than just a username in order to register a user, or you want to deal with login and registration with custom validation, and so the WebAuthn callback endpoint is not enough.
In this case, you can use the WebAuthn.loginOnly
and WebAuthn.registerOnly
methods from the JavaScript
library, store the authenticator data in hidden form elements, and send them as part of your form payload
to the server to your custom login or registration endpoints.
If you are storing them in form input elements, you can then use the WebAuthnLoginResponse
and
WebAuthnRegistrationResponse
classes, mark them as @BeanParam
and then use the WebAuthnSecurity.login
and WebAuthnSecurity.register
methods. For example, here’s how you can handle a custom login and register:
package org.acme.security.webauthn;
import java.net.URI;
import jakarta.inject.Inject;
import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.jboss.resteasy.reactive.RestForm;
import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional;
import io.quarkus.security.webauthn.WebAuthnLoginResponse;
import io.quarkus.security.webauthn.WebAuthnRegisterResponse;
import io.quarkus.security.webauthn.WebAuthnSecurity;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.auth.webauthn.Authenticator;
import io.vertx.ext.web.RoutingContext;
@Path("")
public class LoginResource {
@Inject
WebAuthnSecurity webAuthnSecurity;
@Path("/login")
@POST
@ReactiveTransactional
public Uni<Response> login(@RestForm String userName,
@BeanParam WebAuthnLoginResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(userName == null || userName.isEmpty()
|| !webAuthnResponse.isSet()
|| !webAuthnResponse.isValid()) {
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
Uni<User> userUni = User.findByUserName(userName);
return userUni.flatMap(user -> {
if(user == null) {
// Invalid user
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
Uni<Authenticator> authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx);
return authenticator
// bump the auth counter
.invoke(auth -> user.webAuthnCredential.counter = auth.getCounter())
.map(auth -> {
// make a login JWT cookie
NewCookie cookie = null;
return Response.seeOther(URI.create("/")).cookie(cookie).build();
})
// handle login failure
.onFailure().recoverWithItem(x -> {
// make a proper error response
return Response.status(Status.BAD_REQUEST).build();
});
});
}
@Path("/register")
@POST
@ReactiveTransactional
public Uni<Response> register(@RestForm String userName,
@BeanParam WebAuthnRegisterResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(userName == null || userName.isEmpty()
|| !webAuthnResponse.isSet()
|| !webAuthnResponse.isValid()) {
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
Uni<User> userUni = User.findByUserName(userName);
return userUni.flatMap(user -> {
if(user != null) {
// Duplicate user
return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build());
}
Uni<Authenticator> authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx);
return authenticator
// store the user
.flatMap(auth -> {
User newUser = new User();
newUser.userName = auth.getUserName();
WebAuthnCredential credential = new WebAuthnCredential(auth, newUser);
return credential.persist()
.flatMap(c -> newUser.persist());
})
.map(newUser -> {
// make a login JWT cookie
NewCookie cookie = null;
return Response.seeOther(URI.create("/")).cookie(cookie).build();
})
// handle login failure
.onFailure().recoverWithItem(x -> {
// make a proper error response
return Response.status(Status.BAD_REQUEST).build();
});
});
}
}
Note
|
The WebAuthnSecurity methods do not set or read the user cookie, so you will have to take care
of it yourself, but it allows you to use other means of storing the user, such as JWT. You can use the
rememberUser(String userName, RoutingContext ctx) and logout(RoutingContext ctx) methods on the same
WebAuthnSecurity class if you want to manually set up login cookies.
|
If you’re using a blocking data access to the database, you can safely block on the WebAuthnSecurity
methods,
with .await().indefinitely()
, because nothing is async in the register
and login
methods, besides the
data access with your WebAuthnUserProvider
.
You will have to add the @Blocking
annotation on your WebAuthnUserProvider
class in order to tell the
Quarkus WebAuthn endpoints to defer those calls to the worker pool.
Testing WebAuthn can be complicated because normally you need a hardware token, which is why we’ve made the
quarkus-test-security-webauthn
helper library:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-webauthn</artifactId>
</dependency>
implementation("io.quarkus:quarkus-test-security-webauthn")
With this, you can use WebAuthnHardware
to emulate an authenticator token, as well as the
WebAuthnEndpointHelper
helper methods in order to invoke the WebAuthn endpoints, or even fill your form
data for custom endpoints:
package org.acme.security.webauthn.test;
import static io.restassured.RestAssured.given;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import io.quarkus.security.webauthn.WebAuthnController;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
import io.quarkus.test.security.webauthn.WebAuthnHardware;
import io.restassured.RestAssured;
import io.restassured.filter.Filter;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.vertx.core.json.JsonObject;
@QuarkusTest
public class WebAuthnResourceTest {
enum User {
USER, ADMIN;
}
enum Endpoint {
DEFAULT, MANUAL;
}
@Test
public void testWebAuthnUser() {
testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT);
testWebAuthn("scooby", User.USER, Endpoint.MANUAL);
}
@Test
public void testWebAuthnAdmin() {
testWebAuthn("admin", User.ADMIN, Endpoint.DEFAULT);
}
private void testWebAuthn(String userName, User user, Endpoint endpoint) {
Filter cookieFilter = new RenardeCookieFilter();
WebAuthnHardware token = new WebAuthnHardware();
verifyLoggedOut(cookieFilter);
// two-step registration
String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter);
JsonObject registrationJson = token.makeRegistrationJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter);
else {
invokeCustomEndpoint("/register", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson);
request.formParam("userName", userName);
});
}
// verify that we can access logged-in endpoints
verifyLoggedIn(cookieFilter, userName, user);
// logout
WebAuthnEndpointHelper.invokeLogout(cookieFilter);
verifyLoggedOut(cookieFilter);
// two-step login
challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter);
JsonObject loginJson = token.makeLoginJson(challenge);
if(endpoint == Endpoint.DEFAULT)
WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter);
else {
invokeCustomEndpoint("/login", cookieFilter, request -> {
WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson);
request.formParam("userName", userName);
});
}
// verify that we can access logged-in endpoints
verifyLoggedIn(cookieFilter, userName, user);
// logout
WebAuthnEndpointHelper.invokeLogout(cookieFilter);
verifyLoggedOut(cookieFilter);
}
private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer<RequestSpecification> requestCustomiser) {
RequestSpecification request = given()
.when();
requestCustomiser.accept(request);
request
.filter(cookieFilter)
.redirects().follow(false)
.log().ifValidationFails()
.post(uri)
.then()
.statusCode(200)
.log().ifValidationFails()
.cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is(""))
.cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is(""))
.cookie("quarkus-credential", Matchers.notNullValue());
}
private void verifyLoggedIn(Filter cookieFilter, String userName, User user) {
// public API still good
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public")
.then()
.statusCode(200)
.body(Matchers.is("public"));
// public API user name
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public/me")
.then()
.statusCode(200)
.body(Matchers.is(userName));
// user API accessible
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/users/me")
.then()
.statusCode(200)
.body(Matchers.is(userName));
// admin API?
if(user == User.ADMIN) {
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/admin")
.then()
.statusCode(200)
.body(Matchers.is("admin"));
} else {
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/admin")
.then()
.statusCode(403);
}
}
private void verifyLoggedOut(Filter cookieFilter) {
// public API still good
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public")
.then()
.statusCode(200)
.body(Matchers.is("public"));
// public API user name
RestAssured.given().filter(cookieFilter)
.when()
.get("/api/public/me")
.then()
.statusCode(200)
.body(Matchers.is("<not logged in>"));
// user API not accessible
RestAssured.given()
.filter(cookieFilter)
.redirects().follow(false)
.when()
.get("/api/users/me")
.then()
.statusCode(302)
.header("Location", Matchers.is("http://localhost:8081/"));
// admin API not accessible
RestAssured.given()
.filter(cookieFilter)
.redirects().follow(false)
.when()
.get("/api/admin")
.then()
.statusCode(302)
.header("Location", Matchers.is("http://localhost:8081/"));
}
}
For this test, since we’re testing both the provided callback endpoint, which updates users
in its WebAuthnUserProvider
and the manual LoginResource
endpoint, which deals with users
manually, we need to override the WebAuthnUserProvider
with one that doesn’t update the
scooby
user:
package org.acme.security.webauthn.test;
import jakarta.enterprise.context.ApplicationScoped;
import org.acme.security.webauthn.MyWebAuthnSetup;
import io.quarkus.test.Mock;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.auth.webauthn.Authenticator;
@Mock
@ApplicationScoped
public class TestUserProvider extends MyWebAuthnSetup {
@Override
public Uni<Void> updateOrStoreWebAuthnCredentials(Authenticator authenticator) {
// delegate the scooby user to the manual endpoint, because if we do it here it will be
// created/updated twice
if(authenticator.getUserName().equals("scooby"))
return Uni.createFrom().nullItem();
return super.updateOrStoreWebAuthnCredentials(authenticator);
}
}