diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 7817418252ffd..1edac265a39ee 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -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 @@ -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: @@ -544,6 +553,7 @@ in `src/main/resources/META-INF/resources/index.html`:
  • User API
  • Admin API
  • Logout
  • +
    @@ -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 }) @@ -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; @@ -610,7 +620,7 @@ in `src/main/resources/META-INF/resources/index.html`: result.append("Registration failed: "+err); }); return false; - }; + }); @@ -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"] @@ -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 ---- @@ -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": "", + "clientDataJSON":"" + }, + "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 @@ -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 ---- @@ -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": "", - "clientDataJSON":"" - }, - "type": "public-key" -} ----- - -This returns a 204 with no body. - === Trigger a login `POST /q/webauthn/callback`: Trigger a login @@ -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] ---- @@ -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 @@ -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(); @@ -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 @@ -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) { @@ -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 diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java new file mode 100644 index 0000000000000..47489fae56e8d --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java @@ -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 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); + } +} diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java index b9c86917c9a4a..be602ec2aa4c6 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java @@ -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; @@ -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 @@ -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 diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java index 6ae86cc80ef8d..ef680306535cb 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java @@ -8,8 +8,10 @@ import jakarta.inject.Inject; import io.quarkus.runtime.BlockingOperationControl; +import io.quarkus.virtual.threads.VirtualThreadsRecorder; import io.smallrye.common.annotation.Blocking; import io.smallrye.common.annotation.NonBlocking; +import io.smallrye.common.annotation.RunOnVirtualThread; import io.smallrye.mutiny.Uni; import io.vertx.core.Future; import io.vertx.ext.auth.webauthn.Authenticator; @@ -39,18 +41,38 @@ else if (query.getCredID() != null) } @SuppressWarnings({ "rawtypes", "unchecked" }) - private Uni runPotentiallyBlocking(Supplier> supplier) { + private Uni runPotentiallyBlocking(Supplier> supplier) { if (BlockingOperationControl.isBlockingAllowed() - || !isBlocking(userProvider.getClass())) - return supplier.get(); + || isNonBlocking(userProvider.getClass())) { + return (Uni) supplier.get(); + } + if (isRunOnVirtualThread(userProvider.getClass())) { + return Uni.createFrom().deferred(supplier).runSubscriptionOn(VirtualThreadsRecorder.getCurrent()); + } // run it in a worker thread return vertx.executeBlocking(Uni.createFrom().deferred((Supplier) supplier)); } - private boolean isBlocking(Class klass) { + private boolean isNonBlocking(Class klass) { do { + if (klass.isAnnotationPresent(NonBlocking.class)) + return true; if (klass.isAnnotationPresent(Blocking.class)) + return false; + if (klass.isAnnotationPresent(RunOnVirtualThread.class)) + return false; + klass = klass.getSuperclass(); + } while (klass != null); + // no information, assumed non-blocking + return true; + } + + private boolean isRunOnVirtualThread(Class klass) { + do { + if (klass.isAnnotationPresent(RunOnVirtualThread.class)) return true; + if (klass.isAnnotationPresent(Blocking.class)) + return false; if (klass.isAnnotationPresent(NonBlocking.class)) return false; klass = klass.getSuperclass(); diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java index 23788400a772b..0c7894568bcba 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java @@ -24,9 +24,8 @@ public class WebAuthnController { private static final Logger log = Logger.getLogger(WebAuthnController.class); - public static final String USERNAME_COOKIE = "_quarkus_webauthn_username"; - - public static final String CHALLENGE_COOKIE = "_quarkus_webauthn_challenge"; + private String challengeUsernameCookie; + private String challengeCookie; private WebAuthnSecurity security; @@ -49,6 +48,8 @@ public WebAuthnController(WebAuthnSecurity security, WebAuthnRunTimeConfig confi this.security = security; this.identityProviderManager = identityProviderManager; this.authMech = authMech; + this.challengeCookie = config.challengeCookieName(); + this.challengeUsernameCookie = config.challengeUsernameCookieName(); } private static boolean containsRequiredString(JsonObject json, String key) { @@ -97,7 +98,7 @@ private static boolean containsRequiredObject(JsonObject json, String key) { } /** - * Endpoint for register + * Endpoint for getting a register challenge * * @param ctx the current request */ @@ -127,9 +128,9 @@ public void register(RoutingContext ctx) { final JsonObject credentialsOptions = createCredentialsOptions.result(); // save challenge to the session - authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, CHALLENGE_COOKIE, null, + authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, challengeCookie, null, ctx.request().isSSL()); - authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, USERNAME_COOKIE, null, + authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, challengeUsernameCookie, null, ctx.request().isSSL()); ok(ctx, credentialsOptions); @@ -143,7 +144,7 @@ public void register(RoutingContext ctx) { } /** - * Endpoint for login + * Endpoint for getting a login challenge * * @param ctx the current request */ @@ -174,9 +175,10 @@ public void login(RoutingContext ctx) { final JsonObject getAssertion = generateServerGetAssertion.result(); - authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, CHALLENGE_COOKIE, null, + authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, challengeCookie, null, + ctx.request().isSSL()); + authMech.getLoginManager().save(username, ctx, challengeUsernameCookie, null, ctx.request().isSSL()); - authMech.getLoginManager().save(username, ctx, USERNAME_COOKIE, null, ctx.request().isSSL()); ok(ctx, getAssertion); }); @@ -189,7 +191,7 @@ public void login(RoutingContext ctx) { } /** - * Endpoint for callback + * Endpoint for getting authenticated * * @param ctx the current request */ @@ -211,8 +213,8 @@ public void callback(RoutingContext ctx) { return; } - RestoreResult challenge = authMech.getLoginManager().restore(ctx, CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { ctx.fail(400, new IllegalArgumentException("Missing challenge or username")); @@ -238,8 +240,8 @@ public void callback(RoutingContext ctx) { public void accept(SecurityIdentity identity) { requestContext.destroy(contextState); // invalidate the challenge - WebAuthnSecurity.removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - WebAuthnSecurity.removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + WebAuthnSecurity.removeCookie(ctx, challengeCookie); + WebAuthnSecurity.removeCookie(ctx, challengeUsernameCookie); try { authMech.getLoginManager().save(identity, ctx, null, ctx.request().isSSL()); ok(ctx); diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java index 19f3ee77e1487..03b2d65e1f2b5 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java @@ -61,7 +61,9 @@ public WebAuthnAuthenticationMechanism get() { byte[] data = new byte[32]; new SecureRandom().nextBytes(data); key = encryptionKey = Base64.getEncoder().encodeToString(data); - log.warn("Encryption key was not specified for persistent WebAuthn auth, using temporary key " + key); + log.warn( + "Encryption key was not specified (using `quarkus.http.auth.session.encryption-key` configuration) for persistent WebAuthn auth, using temporary key " + + key); } } else { key = httpConfiguration.getValue().encryptionKey.get(); diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java index bba52cf08f4e8..9f118893ee2b8 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java @@ -104,7 +104,7 @@ enum CookieSameSite { *
  • {@code DISCOURAGED} - User should avoid interact with the browser
  • * */ - @ConfigDocDefault("REQUIRED") + @ConfigDocDefault("DISCOURAGED") Optional userVerification(); /** @@ -231,6 +231,18 @@ interface RelyingPartyConfig { @WithDefault("quarkus-credential") String cookieName(); + /** + * The cookie that is used to store the challenge data during login/registration + */ + @WithDefault("_quarkus_webauthn_challenge") + public String challengeCookieName(); + + /** + * The cookie that is used to store the username data during login/registration + */ + @WithDefault("_quarkus_webauthn_username") + public String challengeUsernameCookieName(); + /** * SameSite attribute for the session cookie. */ diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java index dfb5b7f67f0eb..d803512ea5a34 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java @@ -31,6 +31,8 @@ public class WebAuthnSecurity { @Inject WebAuthnAuthenticationMechanism authMech; + private String challengeCookie; + private String challengeUsernameCookie; public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthenticatorStorage database) { // create the webauthn security object @@ -75,6 +77,8 @@ public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthe Origin o = Origin.parse(origin); domain = o.host(); } + this.challengeCookie = config.challengeCookieName(); + this.challengeUsernameCookie = config.challengeUsernameCookieName(); } /** @@ -86,8 +90,8 @@ public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthe */ public Uni register(WebAuthnRegisterResponse response, RoutingContext ctx) { // validation of the response is done before - RestoreResult challenge = authMech.getLoginManager().restore(ctx, WebAuthnController.CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, WebAuthnController.USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); @@ -103,8 +107,8 @@ public Uni register(WebAuthnRegisterResponse response, RoutingCon .setUsername(username.getPrincipal()) .setWebauthn(response.toJsonObject()), authenticate -> { - removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + removeCookie(ctx, challengeCookie); + removeCookie(ctx, challengeUsernameCookie); if (authenticate.succeeded()) { // this is registration, so the caller will want to store the created Authenticator, // let's recreate it @@ -125,8 +129,8 @@ public Uni register(WebAuthnRegisterResponse response, RoutingCon */ public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { // validation of the response is done before - RestoreResult challenge = authMech.getLoginManager().restore(ctx, WebAuthnController.CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, WebAuthnController.USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); @@ -142,8 +146,8 @@ public Uni login(WebAuthnLoginResponse response, RoutingContext c .setUsername(username.getPrincipal()) .setWebauthn(response.toJsonObject()), authenticate -> { - removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + removeCookie(ctx, challengeCookie); + removeCookie(ctx, challengeUsernameCookie); if (authenticate.succeeded()) { // this is login, so the user will want to bump the counter // FIXME: do we need the auth here? likely the user will know it and will just ++ on the DB-stored counter, no? diff --git a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js index c2a2ef659e26f..e38b2982fc23e 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js +++ b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js @@ -123,7 +123,7 @@ if (res.status === 200) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }) .then(res => res.json()) .then(res => { @@ -167,7 +167,7 @@ if (res.status >= 200 && res.status < 300) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }); }; @@ -188,7 +188,7 @@ if (res.status >= 200 && res.status < 300) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }); }; @@ -209,7 +209,7 @@ if (res.status === 200) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }) .then(res => res.json()) .then(res => { diff --git a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java index 73fbd671b9abb..44171258e4675 100644 --- a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java +++ b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java @@ -7,7 +7,6 @@ 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; @@ -99,9 +98,9 @@ private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumerquartz-virtual-threads virtual-threads-disabled reactive-routes-virtual-threads + security-webauthn-virtual-threads diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml new file mode 100644 index 0000000000000..0431c2f4ed2d5 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + + quarkus-virtual-threads-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-virtual-threads-security-webauthn + Quarkus - Integration Tests - Virtual Threads - Security WebAuthn + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-security-webauthn + + + + io.quarkus + quarkus-test-vertx + + + io.quarkus + quarkus-junit5 + test + + + + io.quarkus + quarkus-test-security-webauthn + + + io.quarkus.junit5 + junit5-virtual-threads + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + + + io.quarkus + quarkus-security-webauthn-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java new file mode 100644 index 0000000000000..0789853a81a65 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java @@ -0,0 +1,49 @@ +package io.quarkus.virtual.security.webauthn; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.test.vertx.VirtualThreadsAssertions; +import io.smallrye.common.annotation.RunOnVirtualThread; + +@RunOnVirtualThread +@Path("/") +public class TestResource { + @Inject + SecurityIdentity identity; + + @Authenticated + @Path("secure") + @GET + public String getUserName() { + VirtualThreadsAssertions.assertEverything(); + return identity.getPrincipal().getName() + ": " + identity.getRoles(); + } + + @RolesAllowed("admin") + @Path("admin") + @GET + public String getAdmin() { + VirtualThreadsAssertions.assertEverything(); + return "OK"; + } + + @RolesAllowed("cheese") + @Path("cheese") + @GET + public String getCheese() { + VirtualThreadsAssertions.assertEverything(); + return "OK"; + } + + @Path("open") + @GET + public String hello() { + VirtualThreadsAssertions.assertEverything(); + return "Hello"; + } +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java new file mode 100644 index 0000000000000..7c7250eb12607 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java @@ -0,0 +1,52 @@ +package io.quarkus.virtual.security.webauthn; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.quarkus.test.vertx.VirtualThreadsAssertions; +import io.smallrye.common.annotation.RunOnVirtualThread; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.auth.webauthn.Authenticator; + +/** + * This UserProvider stores and updates the credentials in the callback endpoint, but is blocking + */ +@ApplicationScoped +@RunOnVirtualThread +public class WebAuthnVirtualThreadTestUserProvider extends WebAuthnTestUserProvider { + @Override + public Uni> findWebAuthnCredentialsByCredID(String credId) { + assertVirtualThread(); + return super.findWebAuthnCredentialsByCredID(credId); + } + + @Override + public Uni> findWebAuthnCredentialsByUserName(String userId) { + assertVirtualThread(); + return super.findWebAuthnCredentialsByUserName(userId); + } + + @Override + public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + assertVirtualThread(); + return super.updateOrStoreWebAuthnCredentials(authenticator); + } + + private void assertVirtualThread() { + // allow this being used in the tests + if (isTestThread()) + return; + VirtualThreadsAssertions.assertEverything(); + } + + static boolean isTestThread() { + for (StackTraceElement stackTraceElement : Thread.currentThread().getStackTrace()) { + if (stackTraceElement.getClassName().equals("io.quarkus.test.junit.QuarkusTestExtension")) + return true; + } + return false; + } + +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java new file mode 100644 index 0000000000000..c834a4ca97654 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java @@ -0,0 +1,8 @@ +package io.quarkus.virtual.security.webauthn; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +class RunOnVirtualThreadIT extends RunOnVirtualThreadTest { + +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java new file mode 100644 index 0000000000000..2cbd9f85afd5d --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java @@ -0,0 +1,103 @@ +package io.quarkus.virtual.security.webauthn; + +import static org.hamcrest.Matchers.is; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit5.virtual.ShouldNotPin; +import io.quarkus.test.junit5.virtual.VirtualThreadUnit; +import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; +import io.quarkus.test.security.webauthn.WebAuthnHardware; +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.webauthn.Authenticator; + +@QuarkusTest +@VirtualThreadUnit +@ShouldNotPin +class RunOnVirtualThreadTest { + + @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(); + WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + + // make sure we stored the user + List 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 + WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + + // 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); + } + +} diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java index 466515806a373..f99e0b00a4057 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.java @@ -1,9 +1,10 @@ package io.quarkus.test.security.webauthn; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; -import io.quarkus.security.webauthn.WebAuthnController; import io.restassured.RestAssured; import io.restassured.filter.Filter; import io.restassured.http.ContentType; @@ -24,8 +25,8 @@ public static String invokeRegistration(String userName, Filter cookieFilter) { .post("/q/webauthn/register") .then().statusCode(200) .log().ifValidationFails() - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.notNullValue()) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.notNullValue()) + .cookie(getChallengeCookie(), Matchers.notNullValue()) + .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -43,9 +44,9 @@ public static void invokeCallback(JsonObject registration, Filter cookieFilter) .post("/q/webauthn/callback") .then().statusCode(204) .log().ifValidationFails() - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is("")) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is("")) - .cookie("quarkus-credential", Matchers.notNullValue()); + .cookie(getChallengeCookie(), Matchers.is("")) + .cookie(getChallengeUsernameCookie(), Matchers.is("")) + .cookie(getMainCookie(), Matchers.notNullValue()); } public static String invokeLogin(String userName, Filter cookieFilter) { @@ -59,8 +60,8 @@ public static String invokeLogin(String userName, Filter cookieFilter) { .post("/q/webauthn/login") .then().statusCode(200) .log().ifValidationFails() - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.notNullValue()) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.notNullValue()) + .cookie(getChallengeCookie(), Matchers.notNullValue()) + .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -99,6 +100,23 @@ public static void invokeLogout(Filter cookieFilter) { .then() .log().ifValidationFails() .statusCode(302) - .cookie("quarkus-credential", Matchers.is("")); + .cookie(getMainCookie(), Matchers.is("")); + } + + public static String getMainCookie() { + Config config = ConfigProvider.getConfig(); + return config.getOptionalValue("quarkus.webauthn.cookie-name", String.class).orElse("quarkus-credential"); + } + + public static String getChallengeCookie() { + Config config = ConfigProvider.getConfig(); + return config.getOptionalValue("quarkus.webauthn.challenge-cookie-name", String.class) + .orElse("_quarkus_webauthn_challenge"); + } + + public static String getChallengeUsernameCookie() { + Config config = ConfigProvider.getConfig(); + return config.getOptionalValue("quarkus.webauthn.challenge-username-cookie-name", String.class) + .orElse("_quarkus_webauthn_username"); } }