Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Webauthn improvements : docs, customisable cookies, virtual thread support #38373

Merged
merged 10 commits into from
Mar 26, 2024
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 @@
== 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 @@ -60,11 +61,19 @@
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.

Check warning on line 64 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'many' or 'much' rather than 'a lot' unless updating existing content that uses the term. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'many' or 'much' rather than 'a lot' unless updating existing content that uses the term.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 64, "column": 20}}}, "severity": "WARNING"}

Just in case you get there wondering what's the relation with https://fidoalliance.org/passkeys/[PassKeys]

Check warning on line 66 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer. Raw Output: {"message": "[Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 66, "column": 1}}}, "severity": "INFO"}
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:

Check warning on line 76 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using ', which (non restrictive clause preceded by a comma)' or 'that (restrictive clause without a comma)' rather than 'which'.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 76, "column": 53}}}, "severity": "INFO"}

* `/api/public`
* `/api/public/me`
Expand Down Expand Up @@ -544,6 +553,7 @@
<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 @@

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 @@
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 @@
result.append("Registration failed: "+err);
});
return false;
};
});
</script>
</body>
</html>
Expand Down Expand Up @@ -639,7 +649,8 @@

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]):

Check warning on line 653 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'webauthn'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'webauthn'?", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 653, "column": 69}}}, "severity": "WARNING"}

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

Expand Down Expand Up @@ -669,11 +680,14 @@
.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 @@
}
----

=== 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 @@
.Request
----
{
"name": "userName"
"name": "userName" <1>
}
----

<1> Required

[source,json]
.Response
----
Expand All @@ -746,26 +782,6 @@
}
----

=== 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 @@

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

Check warning on line 927 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 927, "column": 16}}}, "severity": "INFO"}
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 @@
@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 @@
}
}

// 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 @@ -1009,9 +1033,18 @@
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

Check warning on line 1036 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'to' rather than 'in order to' unless updating existing content that uses the term. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'to' rather than 'in order to' unless updating existing content that uses the term.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 1036, "column": 75}}}, "severity": "WARNING"}

Check warning on line 1036 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Fluff] Depending on the context, consider using 'Be concise: use 'to' rather than' rather than 'in order to'. Raw Output: {"message": "[Quarkus.Fluff] Depending on the context, consider using 'Be concise: use 'to' rather than' rather than 'in order to'.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 1036, "column": 75}}}, "severity": "INFO"}
Quarkus WebAuthn endpoints to defer those calls to the worker pool.

Check warning on line 1037 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'Virtual-Threads version'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Virtual-Threads version'.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 1037, "column": 62}}}, "severity": "INFO"}

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

Check warning on line 1045 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Fluff] Depending on the context, consider using 'Be concise: use 'to' rather than' rather than 'in order to'. Raw Output: {"message": "[Quarkus.Fluff] Depending on the context, consider using 'Be concise: use 'to' rather than' rather than 'in order to'.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 1045, "column": 85}}}, "severity": "INFO"}

Check warning on line 1045 in docs/src/main/asciidoc/security-webauthn.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'to' rather than 'in order to' unless updating existing content that uses the term. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'to' rather than 'in order to' unless updating existing content that uses the term.", "location": {"path": "docs/src/main/asciidoc/security-webauthn.adoc", "range": {"start": {"line": 1045, "column": 85}}}, "severity": "WARNING"}
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 @@
.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 @@
[[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
Loading