Skip to content

Commit

Permalink
Merge pull request quarkusio#38373 from FroMage/webauthn-docs
Browse files Browse the repository at this point in the history
Webauthn improvements : docs, customisable cookies, virtual thread support
  • Loading branch information
FroMage authored Mar 26, 2024
2 parents a71e127 + 82efe40 commit a5fb156
Show file tree
Hide file tree
Showing 18 changed files with 613 additions and 84 deletions.
105 changes: 71 additions & 34 deletions docs/src/main/asciidoc/security-webauthn.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ include::{includes}/extension-status.adoc[]
== Prerequisites

include::{includes}/prerequisites.adoc[]
* A WebAuthn or PassKeys-capable device, or https://developer.chrome.com/docs/devtools/webauthn/[an emulator of those].

== Introduction to WebAuthn

Expand Down Expand Up @@ -62,6 +63,14 @@ 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.

Just in case you get there wondering what's the relation with https://fidoalliance.org/passkeys/[PassKeys]
and whether we support it: sure, yes, PassKeys is a way that your authenticator devices can share and sync
their credentials, which you can then use with our WebAuthn authentication.

NOTE: The WebAuthn specification requires `https` to be used for communication with the server, though
some browsers allow `localhost`. If you must use `https` in `DEV` mode, you can always use the
https://docs.quarkiverse.io/quarkus-ngrok/dev/index.html[quarkus-ngrok] extension.

== Architecture

In this example, we build a very simple microservice which offers four endpoints:
Expand Down Expand Up @@ -544,6 +553,7 @@ in `src/main/resources/META-INF/resources/index.html`:
<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">
Expand Down Expand Up @@ -582,7 +592,7 @@ in `src/main/resources/META-INF/resources/index.html`:
const loginButton = document.getElementById('login');
loginButton.onclick = () => {
loginButton.addEventListener("click", (e) => {
var userName = document.getElementById('userNameLogin').value;
result.replaceChildren();
webAuthn.login({ name: userName })
Expand All @@ -593,11 +603,11 @@ in `src/main/resources/META-INF/resources/index.html`:
result.append("Login failed: "+err);
});
return false;
};
});
const registerButton = document.getElementById('register');
registerButton.onclick = () => {
registerButton.addEventListener("click", (e) => {
var userName = document.getElementById('userNameRegister').value;
var firstName = document.getElementById('firstName').value;
var lastName = document.getElementById('lastName').value;
Expand All @@ -610,7 +620,7 @@ in `src/main/resources/META-INF/resources/index.html`:
result.append("Registration failed: "+err);
});
return false;
};
});
</script>
</body>
</html>
Expand Down Expand Up @@ -639,7 +649,8 @@ form on the right, then pressing the `Register` button:

image::webauthn-2.png[role="thumb"]

Your browser will ask you to activate your WebAuthn authenticator:
Your browser will ask you to activate your WebAuthn authenticator (you will need a WebAuthn-capable browser
and possibly device, or you can use https://developer.chrome.com/docs/devtools/webauthn/[an emulator of those]):

image::webauthn-3.png[role="thumb"]

Expand Down Expand Up @@ -669,11 +680,14 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr
.Request
----
{
"name": "userName",
"displayName": "Mr Nice Guy"
"name": "userName", <1>
"displayName": "Mr Nice Guy" <2>
}
----

<1> Required
<2> Optional

[source,json]
.Response
----
Expand Down Expand Up @@ -709,6 +723,26 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr
}
----

=== Trigger a registration

`POST /q/webauthn/callback`: Trigger a registration

[source,json]
.Request
----
{
"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.

=== Obtain a login challenge

`POST /q/webauthn/login`: Set up and obtain a login challenge
Expand All @@ -717,10 +751,12 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr
.Request
----
{
"name": "userName"
"name": "userName" <1>
}
----

<1> Required

[source,json]
.Response
----
Expand All @@ -746,26 +782,6 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr
}
----

=== Trigger a registration

`POST /q/webauthn/callback`: Trigger a registration

[source,json]
.Request
----
{
"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.

=== Trigger a login

`POST /q/webauthn/callback`: Trigger a login
Expand Down Expand Up @@ -905,7 +921,13 @@ 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:
and `WebAuthnSecurity.register` methods to replace the `/q/webauthn/callback` endpoint. This even
allows you to create two separate endpoints for handling login and registration at different endpoints.

In most cases you can keep using the `/q/webauthn/login` and `/q/webauthn/register` challenge-initiating
endpoints, because this is not where custom logic is required.

For example, here's how you can handle a custom login and register action:

[source,java]
----
Expand Down Expand Up @@ -933,6 +955,7 @@ public class LoginResource {
@Inject
WebAuthnSecurity webAuthnSecurity;
// Provide an alternative implementation of the /q/webauthn/callback endpoint, only for login
@Path("/login")
@POST
@Transactional
Expand Down Expand Up @@ -962,12 +985,13 @@ public class LoginResource {
}
}
// 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) {
@BeanParam WebAuthnRegisterResponse webAuthnResponse,
RoutingContext ctx) {
// Input validation
if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) {
return Response.status(Status.BAD_REQUEST).build();
Expand Down Expand Up @@ -1012,6 +1036,15 @@ 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.

== Virtual-Threads version

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 `@RunOnVirtualThread` annotation on your `WebAuthnUserProvider` class in order to tell the
Quarkus WebAuthn endpoints to defer those calls to virtual threads.

== Testing WebAuthn

Testing WebAuthn can be complicated because normally you need a hardware token, which is why we've made the
Expand Down Expand Up @@ -1139,9 +1172,9 @@ public class WebAuthnResourceTest {
.then()
.statusCode(200)
.log().ifValidationFails()
.cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is(""))
.cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is(""))
.cookie("quarkus-credential", Matchers.notNullValue());
.cookie(WebAuthnEndpointHelper.getChallengeCookie(), Matchers.is(""))
.cookie(WebAuthnEndpointHelper.getChallengeUsernameCookie(), Matchers.is(""))
.cookie(WebAuthnEndpointHelper.getMainCookie(), Matchers.notNullValue());
}
private void verifyLoggedIn(Filter cookieFilter, String userName, User user) {
Expand Down Expand Up @@ -1258,6 +1291,10 @@ public class TestUserProvider extends MyWebAuthnSetup {
[[configuration-reference]]
== Configuration Reference

The security encryption key can be set with the
link:all-config#quarkus-vertx-http_quarkus.http.auth.session.encryption-key[`quarkus.http.auth.session.encryption-key`]
configuration option, as described in the link:security-authentication-mechanisms#form-auth[security guide].

include::{generated-dir}/config/quarkus-security-webauthn.adoc[opts=optional, leveloffset=+1]

== References
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package io.quarkus.security.webauthn.test;

import java.util.List;

import jakarta.inject.Inject;

import org.hamcrest.Matchers;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
import io.quarkus.test.security.webauthn.WebAuthnHardware;
import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider;
import io.restassured.RestAssured;
import io.restassured.filter.cookie.CookieFilter;
import io.restassured.specification.RequestSpecification;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.webauthn.Authenticator;

/**
* Same test as WebAuthnManualTest but with custom cookies configured
*/
public class WebAuthnManualCustomCookiesTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.add(new StringAsset("quarkus.webauthn.cookie-name=main-cookie\n"
+ "quarkus.webauthn.challenge-cookie-name=challenge-cookie\n"
+ "quarkus.webauthn.challenge-username-cookie-name=username-cookie\n"), "application.properties")
.addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class,
TestResource.class, ManualResource.class, TestUtil.class));

@Inject
WebAuthnUserProvider userProvider;

@Test
public void test() throws Exception {

RestAssured.get("/open").then().statusCode(200).body(Matchers.is("Hello"));
RestAssured
.given().redirects().follow(false)
.get("/secure").then().statusCode(302);
RestAssured
.given().redirects().follow(false)
.get("/admin").then().statusCode(302);
RestAssured
.given().redirects().follow(false)
.get("/cheese").then().statusCode(302);

Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty());
CookieFilter cookieFilter = new CookieFilter();
String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter);
WebAuthnHardware hardwareKey = new WebAuthnHardware();
JsonObject registration = hardwareKey.makeRegistrationJson(challenge);

// now finalise
RequestSpecification request = RestAssured
.given()
.filter(cookieFilter);
WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration);
request
.post("/register")
.then().statusCode(200)
.body(Matchers.is("OK"))
.cookie("challenge-cookie", Matchers.is(""))
.cookie("username-cookie", Matchers.is(""))
.cookie("main-cookie", Matchers.notNullValue());

// make sure we stored the user
List<Authenticator> users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely();
Assertions.assertEquals(1, users.size());
Assertions.assertTrue(users.get(0).getUserName().equals("stef"));
Assertions.assertEquals(1, users.get(0).getCounter());

// make sure our login cookie works
checkLoggedIn(cookieFilter);

// reset cookies for the login phase
cookieFilter = new CookieFilter();
// now try to log in
challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter);
JsonObject login = hardwareKey.makeLoginJson(challenge);

// now finalise
request = RestAssured
.given()
.filter(cookieFilter);
WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, login);
request
.post("/login")
.then().statusCode(200)
.body(Matchers.is("OK"))
.cookie("challenge-cookie", Matchers.is(""))
.cookie("username-cookie", Matchers.is(""))
.cookie("main-cookie", Matchers.notNullValue());

// make sure we bumped the user
users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely();
Assertions.assertEquals(1, users.size());
Assertions.assertTrue(users.get(0).getUserName().equals("stef"));
Assertions.assertEquals(2, users.get(0).getCounter());

// make sure our login cookie still works
checkLoggedIn(cookieFilter);
}

private void checkLoggedIn(CookieFilter cookieFilter) {
RestAssured
.given()
.filter(cookieFilter)
.get("/secure")
.then()
.statusCode(200)
.body(Matchers.is("stef: [admin]"));
RestAssured
.given()
.filter(cookieFilter)
.redirects().follow(false)
.get("/admin").then().statusCode(200).body(Matchers.is("OK"));
RestAssured
.given()
.filter(cookieFilter)
.redirects().follow(false)
.get("/cheese").then().statusCode(403);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.security.webauthn.WebAuthnController;
import io.quarkus.security.webauthn.WebAuthnUserProvider;
import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper;
Expand Down Expand Up @@ -61,8 +60,8 @@ public void test() throws Exception {
.post("/register")
.then().statusCode(200)
.body(Matchers.is("OK"))
.cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is(""))
.cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is(""))
.cookie("_quarkus_webauthn_challenge", Matchers.is(""))
.cookie("_quarkus_webauthn_username", Matchers.is(""))
.cookie("quarkus-credential", Matchers.notNullValue());

// make sure we stored the user
Expand All @@ -89,8 +88,8 @@ public void test() throws Exception {
.post("/login")
.then().statusCode(200)
.body(Matchers.is("OK"))
.cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is(""))
.cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is(""))
.cookie("_quarkus_webauthn_challenge", Matchers.is(""))
.cookie("_quarkus_webauthn_username", Matchers.is(""))
.cookie("quarkus-credential", Matchers.notNullValue());

// make sure we bumped the user
Expand Down
Loading

0 comments on commit a5fb156

Please sign in to comment.