diff --git a/bom/application/pom.xml b/bom/application/pom.xml index f20011fbaa72c..b4cdc64ed4321 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -219,6 +219,7 @@ 0.16.0 1.0.11 + 0.28.0.RELEASE @@ -6509,6 +6510,18 @@ ${project.version} + + + com.webauthn4j + webauthn4j-core-async + ${webauthn4j.version} + + + com.webauthn4j + webauthn4j-metadata-async + ${webauthn4j.version} + + io.quarkus @@ -6532,6 +6545,7 @@ ${project.version} + diff --git a/docs/src/main/asciidoc/images/webauthn-1.png b/docs/src/main/asciidoc/images/webauthn-1.png index 70b1764e343ed..515e71df96b04 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-1.png and b/docs/src/main/asciidoc/images/webauthn-1.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-2.png b/docs/src/main/asciidoc/images/webauthn-2.png index 760faf4a61506..e9522798b0152 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-2.png and b/docs/src/main/asciidoc/images/webauthn-2.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-4.png b/docs/src/main/asciidoc/images/webauthn-4.png index 2da3b1d5a176e..934a175c7bdcd 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-4.png and b/docs/src/main/asciidoc/images/webauthn-4.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-5.png b/docs/src/main/asciidoc/images/webauthn-5.png index 042d943a3fb71..19860f16d5be2 100644 Binary files a/docs/src/main/asciidoc/images/webauthn-5.png and b/docs/src/main/asciidoc/images/webauthn-5.png differ diff --git a/docs/src/main/asciidoc/images/webauthn-custom-login.svg b/docs/src/main/asciidoc/images/webauthn-custom-login.svg new file mode 100644 index 0000000000000..e08ba0cd89ee0 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-custom-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-custom-register.svg b/docs/src/main/asciidoc/images/webauthn-custom-register.svg new file mode 100644 index 0000000000000..75b98727ac93b --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-custom-register.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-login.svg b/docs/src/main/asciidoc/images/webauthn-login.svg new file mode 100644 index 0000000000000..0055a442c28e9 --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/images/webauthn-register.svg b/docs/src/main/asciidoc/images/webauthn-register.svg new file mode 100644 index 0000000000000..5c60cdb486b8e --- /dev/null +++ b/docs/src/main/asciidoc/images/webauthn-register.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 996691c7a8183..09ee4ce6b24e2 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -10,6 +10,8 @@ include::_attributes.adoc[] :categories: security :topics: security,webauthn,authorization :extensions: io.quarkus:quarkus-security-webauthn +:webauthn-api: https://javadoc.io/doc/io.quarkus/quarkus-security-webauthn/{quarkus-version} +:webauthn-test-api: https://javadoc.io/doc/io.quarkus/quarkus-test-security-webauthn/{quarkus-version} This guide demonstrates how your Quarkus application can use WebAuthn authentication instead of passwords. @@ -221,7 +223,7 @@ public class UserResource { === Storing our WebAuthn credentials -We can now describe how our WebAuthn credentials are stored in our database with three entities. Note that we've +We can now describe how our WebAuthn credentials are stored in our database with two 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): @@ -229,139 +231,65 @@ and other data such as roles): ---- package org.acme.security.webauthn; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; - -import io.quarkus.hibernate.orm.panache.PanacheEntity; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"userName", "credID"})) @Entity -public class WebAuthnCredential extends PanacheEntity { - - /** - * The username linked to this authenticator - */ - public String userName; - - /** - * The type of key (must be "public-key") - */ - public String type = "public-key"; +public class WebAuthnCredential extends PanacheEntityBase { + + @Id + public String credentialId; - /** - * 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 byte[] publicKey; + public long publicKeyAlgorithm; public long counter; - - public String aaguid; - - /** - * The Authenticator attestation certificates object, a JSON like: - *
{@code
-     *   {
-     *     "alg": "string",
-     *     "x5c": [
-     *       "base64"
-     *     ]
-     *   }
-     * }
- */ - /** - * The algorithm used for the public credential - */ - public PublicKeyCredential alg; - - /** - * The list of X509 certificates encoded as base64url. - */ - @OneToMany(mappedBy = "webAuthnCredential") - public List x5c = new ArrayList<>(); - - public String fmt; - - // owning side + public UUID aaguid; + + // this is the 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); - } - } + + public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) { + RequiredPersistedData requiredPersistedData = + credentialRecord.getRequiredPersistedData(); + aaguid = requiredPersistedData.aaguid(); + counter = requiredPersistedData.counter(); + credentialId = requiredPersistedData.credentialId(); + publicKey = requiredPersistedData.publicKey(); + publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm(); this.user = user; user.webAuthnCredential = this; } - public static List findByUserName(String userName) { - return list("userName", userName); + public WebAuthnCredentialRecord toWebAuthnCredentialRecord() { + return WebAuthnCredentialRecord + .fromRequiredPersistedData( + new RequiredPersistedData(user.userName, credentialId, + aaguid, publicKey, + publicKeyAlgorithm, counter)); } - public static List findByCredID(String credID) { - return list("credID", credID); + public static List findByUserName(String userName) { + return list("user.userName", userName); + } + + public static WebAuthnCredential findByCredentialId(String credentialId) { + return findById(credentialId); } } ---- -We also need a second entity for the credentials: - -[source,java] ----- -package org.acme.security.webauthn; - -import io.quarkus.hibernate.orm.panache.PanacheEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.ManyToOne; - - -@Entity -public class WebAuthnCertificate extends PanacheEntity { - - @ManyToOne - public WebAuthnCredential webAuthnCredential; - - /** - * The list of X509 certificates encoded as base64url. - */ - public String x5c; -} ----- - -And last but not least, our user entity: +And our user entity: [source,java] ---- @@ -392,98 +320,74 @@ public class User extends PanacheEntity { ==== A note about usernames and credential IDs -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. +Usernames are unique and to your users. Every created WebAuthn credential record has a unique ID. -The combination of username and credential ID should be a unicity constraint for your credentials table, though. +You can allow (if you want, but you don't have to) your users to have more than one authenticator device, +which means a single username can map to multiple credential IDs, all of which identify the same user. === Exposing your entities to Quarkus WebAuthn -You need to define a bean implementing the `WebAuthnUserProvider` in order to allow the Quarkus WebAuthn +You need to define a bean implementing the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`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: [source,java] ---- -package org.acme.security.webauthn; - import java.util.Collections; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; - -import io.smallrye.common.annotation.Blocking; -import jakarta.enterprise.context.ApplicationScoped; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.smallrye.common.annotation.Blocking; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.AttestationCertificates; -import io.vertx.ext.auth.webauthn.Authenticator; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; -import static org.acme.security.webauthn.WebAuthnCredential.findByCredID; -import static org.acme.security.webauthn.WebAuthnCredential.findByUserName; - @Blocking @ApplicationScoped public class MyWebAuthnSetup implements WebAuthnUserProvider { @Transactional @Override - public Uni> findWebAuthnCredentialsByUserName(String userName) { - return Uni.createFrom().item(toAuthenticators(findByUserName(userName))); + public Uni> findByUserName(String userId) { + return Uni.createFrom().item( + WebAuthnCredential.findByUserName(userId) + .stream() + .map(WebAuthnCredential::toWebAuthnCredentialRecord) + .toList()); } @Transactional @Override - public Uni> findWebAuthnCredentialsByCredID(String credID) { - return Uni.createFrom().item(toAuthenticators(findByCredID(credID))); + public Uni findByCredentialId(String credId) { + WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credId); + if(creds == null) + return Uni.createFrom() + .failure(new RuntimeException("No such credential ID")); + return Uni.createFrom().item(creds.toWebAuthnCredentialRecord()); } @Transactional @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - // leave the scooby user to the manual endpoint, because if we do it here it will be created/updated twice - if(!authenticator.getUserName().equals("scooby")) { - User user = User.findByUserName(authenticator.getUserName()); - if(user == null) { - // new user - User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); - credential.persist(); - newUser.persist(); - } else { - // existing user - user.webAuthnCredential.counter = authenticator.getCounter(); - } - } - return Uni.createFrom().nullItem(); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + User newUser = new User(); + // We can only store one credential per userName thanks to the unicity constraint + // which will cause this transaction to fail and throw if the userName already exists + newUser.userName = credentialRecord.getUserName(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + credential.persist(); + newUser.persist(); + return Uni.createFrom().voidItem(); } - private static List toAuthenticators(List dbs) { - return dbs.stream().map(MyWebAuthnSetup::toAuthenticator).collect(Collectors.toList()); - } - - private static Authenticator toAuthenticator(WebAuthnCredential credential) { - Authenticator ret = new Authenticator(); - ret.setAaguid(credential.aaguid); - AttestationCertificates attestationCertificates = new AttestationCertificates(); - attestationCertificates.setAlg(credential.alg); - ret.setAttestationCertificates(attestationCertificates); - ret.setCounter(credential.counter); - ret.setCredID(credential.credID); - ret.setFmt(credential.fmt); - ret.setPublicKey(credential.publicKey); - ret.setType(credential.type); - ret.setUserName(credential.userName); - return ret; + @Transactional + @Override + public Uni update(String credentialId, long counter) { + WebAuthnCredential credential = + WebAuthnCredential.findByCredentialId(credentialId); + credential.counter = counter; + return Uni.createFrom().voidItem(); } @Override @@ -496,6 +400,25 @@ public class MyWebAuthnSetup implements WebAuthnUserProvider { } ---- +Warning: When implementing your own `WebAuthnUserProvider.store` method, make sure that you never allow creating +new credentials for a `userName` that already exists. Otherwise you risk allowing third-parties to impersonate existing +users by letting them add their own credentials to existing accounts. If you want to allow existing users to register +more than one WebAuthn credential, you must make sure in `WebAuthnUserProvider.store` that the user is currently logged +in under the same `userName` to which you want to add new credentials. In every other case, make sure to return a failed +`Uni` from this method. In this particular example, this is checked using a unicity constraint on the user name, which +will cause the transaction to fail if the user already exists. + +== Configuration + +Because we want to delegate login and registration to the default Quarkus WebAuthn endpoints, we need to enable them +in configuration (`src/main/resources/application.properties`): + +[source,properties] +---- +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true +---- + == Writing the HTML application 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, @@ -563,7 +486,6 @@ in `src/main/resources/META-INF/resources/index.html`:

Login

-

@@ -578,11 +500,7 @@ in `src/main/resources/META-INF/resources/index.html`: + +---- + +Or, if you need to customise the endpoints: + [source,javascript] ---- ---- +=== CSRF considerations + +If you use the endpoints provided by Quarkus, they will not be protected by xdoc:security-csrf-prevention.adoc[CSRF], but +if you define your own endpoints and use this JavaScript library to access them you will need to configure CSRF via headers: + +[source,javascript] +---- + + +---- + === Invoke registration -The `webAuthn.register` method invokes the registration challenge endpoint, then calls the authenticator and invokes the callback endpoint +The `webAuthn.register` method invokes the registration challenge endpoint, then calls the authenticator and invokes the registration endpoint for that registration, and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object]: [source,javascript] @@ -847,12 +866,12 @@ webAuthn.register({ name: userName, displayName: firstName + " " + lastName }) === Invoke login -The `webAuthn.login` method invokes the login challenge endpoint, then calls the authenticator and invokes the callback endpoint +The `webAuthn.login` method invokes the login challenge endpoint, then calls the authenticator and invokes the login endpoint for that login, and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[Promise object]: [source,javascript] ---- -webAuthn.login({ name: userName }) +webAuthn.login({ name: userName }) <1> .then(body => { // do something now that the user is logged in }) @@ -861,16 +880,18 @@ webAuthn.login({ name: userName }) }); ---- +<1> The name is optional, in the case of https://www.w3.org/TR/webauthn-3/#discoverable-credential[Discoverable Credentials] (with PassKeys) + === Only invoke the registration challenge and authenticator -The `webAuthn.registerOnly` method invokes the registration challenge endpoint, then calls the authenticator and returns +The `webAuthn.registerClientSteps` method invokes the registration challenge endpoint, then calls the authenticator and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[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 +JSON object suitable for being sent to the registration 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: [source,javascript] ---- -webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName }) +webAuthn.registerClientSteps({ name: userName, displayName: firstName + " " + lastName }) .then(body => { // store the registration JSON in form elements document.getElementById('webAuthnId').value = body.id; @@ -886,14 +907,14 @@ webAuthn.registerOnly({ name: userName, displayName: firstName + " " + lastName === Only invoke the login challenge and authenticator -The `webAuthn.loginOnly` method invokes the login challenge endpoint, then calls the authenticator and returns +The `webAuthn.loginClientSteps` method invokes the login challenge endpoint, then calls the authenticator and returns a https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise[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 +JSON object suitable for being sent to the login 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: [source,javascript] ---- -webAuthn.loginOnly({ name: userName }) +webAuthn.loginClientSteps({ name: userName }) <1> .then(body => { // store the login JSON in form elements document.getElementById('webAuthnId').value = body.id; @@ -909,25 +930,95 @@ webAuthn.loginOnly({ name: userName }) }); ---- +<1> The name is optional, in the case of https://www.w3.org/TR/webauthn-3/#discoverable-credential[Discoverable Credentials] (with PassKeys) + == Handling login and registration endpoints yourself 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. +or you want to deal with login and registration with custom validation, and so the default WebAuthn login +and registration endpoints are not enough. -In this case, you can use the `WebAuthn.loginOnly` and `WebAuthn.registerOnly` methods from the JavaScript +In this case, you can use the `WebAuthn.loginClientSteps` and `WebAuthn.registerClientSteps` 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 to replace the `/q/webauthn/callback` endpoint. This even -allows you to create two separate endpoints for handling login and registration at different endpoints. +If you are storing them in form input elements, you can then use the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnLoginResponse.html[`WebAuthnLoginResponse`] and +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnRegistrationResponse.html[`WebAuthnRegistrationResponse`] classes, +mark them as `@BeanParam` and then use the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#login(io.quarkus.security.webauthn.WebAuthnLoginResponse,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.login`] +and link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#register(io.quarkus.security.webauthn.WebAuthnRegisterResponse,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.register`] +methods to replace the `/q/webauthn/login` and `/q/webauthn/register` endpoints. -In most cases you can keep using the `/q/webauthn/login` and `/q/webauthn/register` challenge-initiating +In most cases you can keep using the `/q/webauthn/login-options-challenge` and `/q/webauthn/register-options-challenge` 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: +In this case, the registration flow is a little different because you will write your own registration endpoint +which will handle storing of the credentials and setting up the session cookie: + +image::webauthn-custom-register.svg[role="thumb"] + +Similarly, the login flow is a little different because you will write your own login endpoint +which will handle updating the credentials and setting up the session cookie: + +image::webauthn-custom-login.svg[role="thumb"] + +If you handle user and credential creation and logins yourself in your endpoints, you only need +to provide a read-only view of your entities in your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`], so you can skip +the `store` and `update` methods: + +[source,java] +---- +package org.acme.security.webauthn; + +import java.util.List; + +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.smallrye.common.annotation.Blocking; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import model.WebAuthnCredential; + +@Blocking +@ApplicationScoped +public class MyWebAuthnSetup implements WebAuthnUserProvider { + + @Transactional + @Override + public Uni> findByUserName(String userName) { + return Uni.createFrom().item( + WebAuthnCredential.findByUserName(userName) + .stream() + .map(WebAuthnCredential::toWebAuthnCredentialRecord) + .toList()); + } + + @Transactional + @Override + public Uni findByCredentialId(String credentialId) { + WebAuthnCredential creds = WebAuthnCredential.findByCredentialId(credentialId); + if(creds == null) + return Uni.createFrom() + .failure(new RuntimeException("No such credential ID")); + return Uni.createFrom().item(creds.toWebAuthnCredentialRecord()); + } + + @Override + public Set getRoles(String userId) { + if(userId.equals("admin")) { + return Set.of("user", "admin"); + } + return Collections.singleton("user"); + } +} +---- + +NOTE: When setting up your own login and registration endpoints, you don't need to enable the default endpoints, so you can +remove the `quarkus.webauthn.enable-login-endpoint` and `quarkus.webauthn.enable-registration-endpoint` configuration. + +Thankfully, you can use the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] bean to handle the WebAuthn-specific part of +your registration and login endpoints, and focus on your own logic: [source,java] ---- @@ -943,10 +1034,10 @@ import jakarta.ws.rs.core.Response.Status; import org.jboss.resteasy.reactive.RestForm; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnLoginResponse; import io.quarkus.security.webauthn.WebAuthnRegisterResponse; import io.quarkus.security.webauthn.WebAuthnSecurity; -import io.vertx.ext.auth.webauthn.Authenticator; import io.vertx.ext.web.RoutingContext; @Path("") @@ -955,29 +1046,28 @@ public class LoginResource { @Inject WebAuthnSecurity webAuthnSecurity; - // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for login + // Provide an alternative implementation of the /q/webauthn/login endpoint @Path("/login") @POST @Transactional - public Response login(@RestForm String userName, - @BeanParam WebAuthnLoginResponse webAuthnResponse, + public Response login(@BeanParam WebAuthnLoginResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { + if(!webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Response.status(Status.BAD_REQUEST).build(); } - User user = User.findByUserName(userName); - if(user == null) { - // Invalid user - return Response.status(Status.BAD_REQUEST).build(); - } try { - Authenticator authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely(); + WebAuthnCredentialRecord credentialRecord = this.webAuthnSecurity.login(webAuthnResponse, ctx).await().indefinitely(); + User user = User.findByUserName(credentialRecord.getUserName()); + if(user == null) { + // Invalid user + return Response.status(Status.BAD_REQUEST).build(); + } // bump the auth counter - user.webAuthnCredential.counter = authenticator.getCounter(); + user.webAuthnCredential.counter = credentialRecord.getCounter(); // make a login cookie - this.webAuthnSecurity.rememberUser(authenticator.getUserName(), ctx); + this.webAuthnSecurity.rememberUser(credentialRecord.getUserName(), ctx); return Response.ok().build(); } catch (Exception exception) { // handle login failure - make a proper error response @@ -985,7 +1075,7 @@ public class LoginResource { } } - // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for registration + // Provide an alternative implementation of the /q/webauthn/register endpoint @Path("/register") @POST @Transactional @@ -993,7 +1083,8 @@ public class LoginResource { @BeanParam WebAuthnRegisterResponse webAuthnResponse, RoutingContext ctx) { // Input validation - if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { + if(userName == null || userName.isEmpty() + || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Response.status(Status.BAD_REQUEST).build(); } @@ -1004,10 +1095,12 @@ public class LoginResource { } try { // store the user - Authenticator authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx).await().indefinitely(); + WebAuthnCredentialRecord credentialRecord = + webAuthnSecurity.register(userName, webAuthnResponse, ctx).await().indefinitely(); User newUser = new User(); - newUser.userName = authenticator.getUserName(); - WebAuthnCredential credential = new WebAuthnCredential(authenticator, newUser); + newUser.userName = credentialRecord.getUserName(); + WebAuthnCredential credential = + new WebAuthnCredential(credentialRecord, newUser); credential.persist(); newUser.persist(); // make a login cookie @@ -1022,28 +1115,32 @@ public class LoginResource { } ---- -NOTE: The `WebAuthnSecurity` methods do not set or read the user cookie, so you will have to take care +NOTE: The link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] +methods do not set or read the <>, 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. +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#rememberUser(java.lang.String,io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.rememberUser`] + and link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html#logout(io.vertx.ext.web.RoutingContext)[`WebAuthnSecurity.logout`] + methods on the same link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] class if you want to manually set up login cookies. == Blocking version -If you're using a blocking data access to the database, you can safely block on the `WebAuthnSecurity` methods, +If you're using a blocking data access to the database, you can safely block on the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] methods, with `.await().indefinitely()`, because nothing is async in the `register` and `login` methods, besides the -data access with your `WebAuthnUserProvider`. +data access with your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`]. -You will have to add the `@Blocking` annotation on your `WebAuthnUserProvider` class in order to tell the +You will have to add the `@Blocking` annotation on your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] class in order for 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, +If you're using a blocking data access to the database, you can safely block on the +link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnSecurity.html[`WebAuthnSecurity`] methods, with `.await().indefinitely()`, because nothing is async in the `register` and `login` methods, besides the -data access with your `WebAuthnUserProvider`. +data access with your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`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. +You will have to add the `@RunOnVirtualThread` annotation on your link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] class in order to tell the +Quarkus WebAuthn endpoints to defer those calls to the worker pool. == Testing WebAuthn @@ -1066,8 +1163,10 @@ Testing WebAuthn can be complicated because normally you need a hardware token, testImplementation("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 +With this, you can use link:{webauthn-test-api}/io/quarkus/test/security/webauthn/WebAuthnHardware.html[`WebAuthnHardware`] +to emulate an authenticator token, as well as the +link:{webauthn-test-api}/io/quarkus/test/security/webauthn/WebAuthnEndpointHelper.html[`WebAuthnEndpointHelper`] +helper methods in order to invoke the WebAuthn endpoints, or even fill your form data for custom endpoints: [source,java] @@ -1076,25 +1175,24 @@ package org.acme.security.webauthn.test; import static io.restassured.RestAssured.given; +import java.net.URL; 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.common.http.TestHTTPResource; 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; } @@ -1102,6 +1200,9 @@ public class WebAuthnResourceTest { DEFAULT, MANUAL; } + @TestHTTPResource + URL url; + @Test public void testWebAuthnUser() { testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT); @@ -1112,42 +1213,41 @@ public class WebAuthnResourceTest { 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(); + WebAuthnHardware token = new WebAuthnHardware(url); verifyLoggedOut(cookieFilter); // two-step registration - String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(userName, cookieFilter); JsonObject registrationJson = token.makeRegistrationJson(challenge); if(endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration(userName, 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); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(null, cookieFilter); JsonObject loginJson = token.makeLoginJson(challenge); if(endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter); else { invokeCustomEndpoint("/login", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson); - request.formParam("userName", userName); }); } @@ -1156,7 +1256,7 @@ public class WebAuthnResourceTest { // logout WebAuthnEndpointHelper.invokeLogout(cookieFilter); - + verifyLoggedOut(cookieFilter); } @@ -1173,7 +1273,6 @@ public class WebAuthnResourceTest { .statusCode(200) .log().ifValidationFails() .cookie(WebAuthnEndpointHelper.getChallengeCookie(), Matchers.is("")) - .cookie(WebAuthnEndpointHelper.getChallengeUsernameCookie(), Matchers.is("")) .cookie(WebAuthnEndpointHelper.getMainCookie(), Matchers.notNullValue()); } @@ -1200,7 +1299,7 @@ public class WebAuthnResourceTest { .then() .statusCode(200) .body(Matchers.is(userName)); - + // admin API? if(user == User.ADMIN) { RestAssured.given().filter(cookieFilter) @@ -1243,7 +1342,7 @@ public class WebAuthnResourceTest { .then() .statusCode(302) .header("Location", Matchers.is("http://localhost:8081/")); - + // admin API not accessible RestAssured.given() .filter(cookieFilter) @@ -1258,32 +1357,45 @@ public class WebAuthnResourceTest { ---- 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 +in its link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] and the manual `LoginResource` endpoint, which deals with users +manually, we need to override the link:{webauthn-api}/io/quarkus/security/webauthn/WebAuthnUserProvider.html[`WebAuthnUserProvider`] with one that doesn't update the `scooby` user: [source,java] ---- package org.acme.security.webauthn.test; -import jakarta.enterprise.context.ApplicationScoped; - import org.acme.security.webauthn.MyWebAuthnSetup; +import org.acme.security.webauthn.WebAuthnCredential; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.Mock; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; @Mock @ApplicationScoped public class TestUserProvider extends MyWebAuthnSetup { + @Transactional + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + // this user is handled in the LoginResource endpoint manually + if (credentialRecord.getUserName().equals("scooby")) { + return Uni.createFrom().voidItem(); + } + return super.store(credentialRecord); + } + + @Transactional @Override - public Uni 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); + public Uni update(String credentialId, long counter) { + WebAuthnCredential credential = WebAuthnCredential.findByCredentialId(credentialId); + // this user is handled in the LoginResource endpoint manually + if (credential.user.userName.equals("scooby")) { + return Uni.createFrom().voidItem(); + } + return super.update(credentialId, counter); } } ---- diff --git a/extensions/security-webauthn/deployment/pom.xml b/extensions/security-webauthn/deployment/pom.xml index bf0f0d74fe732..dd286d827b5ce 100644 --- a/extensions/security-webauthn/deployment/pom.xml +++ b/extensions/security-webauthn/deployment/pom.xml @@ -1,6 +1,6 @@ - + 4.0.0 io.quarkus diff --git a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java index 9a12d8e4e9f55..a624fdf872752 100644 --- a/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java +++ b/extensions/security-webauthn/deployment/src/main/java/io/quarkus/security/webauthn/deployment/QuarkusSecurityWebAuthnProcessor.java @@ -7,6 +7,20 @@ import org.jboss.jandex.DotName; +import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.AuthenticatorAssertionResponse; +import com.webauthn4j.data.AuthenticatorAttestationResponse; +import com.webauthn4j.data.PublicKeyCredential; +import com.webauthn4j.data.PublicKeyCredentialCreationOptions; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.PublicKeyCredentialRequestOptions; +import com.webauthn4j.data.PublicKeyCredentialRpEntity; +import com.webauthn4j.data.PublicKeyCredentialType; +import com.webauthn4j.data.PublicKeyCredentialUserEntity; +import com.webauthn4j.data.RegistrationRequest; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.client.CollectedClientData; + import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; @@ -15,12 +29,12 @@ import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; -import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.security.webauthn.WebAuthn; import io.quarkus.security.webauthn.WebAuthnAuthenticationMechanism; import io.quarkus.security.webauthn.WebAuthnAuthenticatorStorage; import io.quarkus.security.webauthn.WebAuthnBuildTimeConfig; -import io.quarkus.security.webauthn.WebAuthnIdentityProvider; import io.quarkus.security.webauthn.WebAuthnRecorder; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.quarkus.security.webauthn.WebAuthnTrustedIdentityProvider; @@ -28,18 +42,50 @@ import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.VertxWebRouterBuildItem; import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; -import io.vertx.ext.auth.webauthn.impl.attestation.Attestation; @BuildSteps(onlyIf = QuarkusSecurityWebAuthnProcessor.IsEnabled.class) class QuarkusSecurityWebAuthnProcessor { + @BuildStep + public IndexDependencyBuildItem addTypesToJandex() { + // needed by registerJacksonTypes() + return new IndexDependencyBuildItem("com.webauthn4j", "webauthn4j-core"); + } + + @BuildStep + public void registerJacksonTypes(BuildProducer reflection) { + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AuthenticatorAssertionResponse.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AuthenticatorAttestationResponse.class).build()); + reflection.produce(ReflectiveHierarchyBuildItem.builder(AuthenticationRequest.class).build()); + reflection.produce(ReflectiveHierarchyBuildItem.builder(RegistrationRequest.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialCreationOptions.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialRequestOptions.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialRpEntity.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialUserEntity.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialParameters.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredentialType.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(PublicKeyCredential.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(AttestationObject.class).build()); + reflection.produce( + ReflectiveHierarchyBuildItem.builder(CollectedClientData.class).build()); + } + @BuildStep public void myBeans(BuildProducer additionalBeans) { AdditionalBeanBuildItem.Builder builder = AdditionalBeanBuildItem.builder().setUnremovable(); builder.addBeanClass(WebAuthnSecurity.class) .addBeanClass(WebAuthnAuthenticatorStorage.class) - .addBeanClass(WebAuthnIdentityProvider.class) .addBeanClass(WebAuthnTrustedIdentityProvider.class); additionalBeans.produce(builder.build()); } @@ -55,11 +101,6 @@ public void setup( nonApplicationRootPathBuildItem.getNonApplicationRootPath()); } - @BuildStep - public ServiceProviderBuildItem serviceLoader() { - return ServiceProviderBuildItem.allProvidersFromClassPath(Attestation.class.getName()); - } - @BuildStep @Record(ExecutionTime.RUNTIME_INIT) SyntheticBeanBuildItem initWebAuthnAuth( diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java index 8d2f628d426b4..42fd2c508e285 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/ManualResource.java @@ -4,6 +4,7 @@ import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; import io.quarkus.security.webauthn.WebAuthnLoginResponse; import io.quarkus.security.webauthn.WebAuthnRegisterResponse; @@ -23,10 +24,11 @@ public class ManualResource { @Path("register") @POST - public Uni register(@BeanParam WebAuthnRegisterResponse register, RoutingContext ctx) { - return security.register(register, ctx).map(authenticator -> { + public Uni register(@QueryParam("username") String username, @BeanParam WebAuthnRegisterResponse register, + RoutingContext ctx) { + return security.register(username, register, ctx).map(authenticator -> { // need to attach the authenticator to the user - userProvider.store(authenticator); + userProvider.reallyStore(authenticator); security.rememberUser(authenticator.getUserName(), ctx); return "OK"; }); @@ -34,10 +36,10 @@ public Uni register(@BeanParam WebAuthnRegisterResponse register, Routin @Path("login") @POST - public Uni register(@BeanParam WebAuthnLoginResponse login, RoutingContext ctx) { + public Uni login(@BeanParam WebAuthnLoginResponse login, RoutingContext ctx) { return security.login(login, ctx).map(authenticator -> { // need to update the user's authenticator - userProvider.update(authenticator.getUserName(), authenticator.getCredID(), authenticator.getCounter()); + userProvider.reallyUpdate(authenticator.getCredentialID(), authenticator.getCounter()); security.rememberUser(authenticator.getUserName(), ctx); return "OK"; }); diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java index ced7d44860ff1..efa77f85bb7cf 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAndBasicAuthnTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -13,9 +14,11 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnRunTimeConfig; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -24,7 +27,6 @@ import io.restassured.specification.RequestSpecification; import io.smallrye.config.SmallRyeConfigBuilder; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; public class WebAuthnAndBasicAuthnTest { @@ -40,6 +42,9 @@ public class WebAuthnAndBasicAuthnTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @BeforeAll public static void setupUsers() { TestIdentityController.resetRoles() @@ -50,10 +55,10 @@ public static void setupUsers() { @Test public void test() throws Exception { - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUserName("stev").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stev", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stev", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -66,15 +71,15 @@ public void test() throws Exception { .build() .getConfigMapping(WebAuthnRunTimeConfig.class); request + .queryParam("username", "stev") .post("/register") .then().statusCode(200) .body(Matchers.is("OK")) .cookie(config.challengeCookieName(), Matchers.is("")) - .cookie(config.challengeUsernameCookieName(), Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stev").await().indefinitely(); + List users = userProvider.findByUserName("stev").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stev")); Assertions.assertEquals(1, users.get(0).getCounter()); diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java index 1a48817c00263..2381effb59ecf 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticBlockingTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; @@ -11,6 +12,10 @@ public class WebAuthnAutomaticBlockingTest extends WebAuthnAutomaticTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(""" + quarkus.webauthn.enable-login-endpoint=true + quarkus.webauthn.enable-registration-endpoint=true + """), "application.properties") .addClasses(WebAuthnBlockingTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, TestUtil.class)); } diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java index 8c56262608a26..485144730881c 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticNonBlockingTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; @@ -11,6 +12,10 @@ public class WebAuthnAutomaticNonBlockingTest extends WebAuthnAutomaticTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(""" + quarkus.webauthn.enable-login-endpoint=true + quarkus.webauthn.enable-registration-endpoint=true + """), "application.properties") .addClasses(WebAuthnNonBlockingTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, TestUtil.class)); diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java index 696b1bb5481a7..ce1074c868e2b 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnAutomaticTest.java @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -8,19 +9,23 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.common.http.TestHTTPResource; 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; public abstract class WebAuthnAutomaticTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -35,17 +40,17 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); @@ -56,20 +61,38 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUserName("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); + + // reset cookies for a new login + cookieFilter = new CookieFilter(); + // now try to log in without a username + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(null, cookieFilter); + login = hardwareKey.makeLoginJson(challenge); + + // now finalise + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); + + // make sure we bumped the user + users = userProvider.findByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(3, users.get(0).getCounter()); + + // make sure our login cookie still works + checkLoggedIn(cookieFilter); } private void checkLoggedIn(CookieFilter cookieFilter) { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java index de755e7cd41be..9fd20f321a561 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnBlockingTestUserProvider.java @@ -6,10 +6,10 @@ import org.jboss.resteasy.reactive.server.core.BlockingOperationSupport; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.common.annotation.Blocking; 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 @@ -18,21 +18,27 @@ @Blocking public class WebAuthnBlockingTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertBlockingAllowed(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUserName(String userId) { assertBlockingAllowed(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUserName(userId); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni update(String credentialId, long counter) { assertBlockingAllowed(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.update(credentialId, counter); + } + + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + assertBlockingAllowed(); + return super.store(credentialRecord); } private void assertBlockingAllowed() { 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 index 47489fae56e8d..c9c15baf3c6c8 100644 --- 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 @@ -1,5 +1,6 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -10,8 +11,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -19,7 +22,6 @@ 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 @@ -38,6 +40,9 @@ public class WebAuthnManualCustomCookiesTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -52,10 +57,10 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -64,15 +69,15 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); request + .queryParam("username", "stef") .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(); + List users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); @@ -83,7 +88,7 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise @@ -96,11 +101,10 @@ public void test() throws Exception { .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(); + users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); 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 be602ec2aa4c6..ef2a4c69886ca 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 @@ -1,16 +1,19 @@ package io.quarkus.security.webauthn.test; +import java.net.URL; import java.util.List; import jakarta.inject.Inject; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; @@ -18,18 +21,26 @@ import io.restassured.filter.cookie.CookieFilter; import io.restassured.specification.RequestSpecification; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.Authenticator; public class WebAuthnManualTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withApplicationRoot((jar) -> jar - .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, + WebAuthnTestUserProvider.class, WebAuthnHardware.class, TestResource.class, ManualResource.class, TestUtil.class)); @Inject - WebAuthnUserProvider userProvider; + WebAuthnManualTestUserProvider userProvider; + + @TestHTTPResource + URL url; + + @BeforeEach + public void before() { + userProvider.clear(); + } @Test public void test() throws Exception { @@ -45,10 +56,10 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise @@ -57,15 +68,17 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); request + .log().ifValidationFails() + .queryParam("username", "stef") .post("/register") .then().statusCode(200) + .log().ifValidationFails() .body(Matchers.is("OK")) .cookie("_quarkus_webauthn_challenge", Matchers.is("")) - .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); @@ -76,7 +89,7 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise @@ -85,21 +98,55 @@ public void test() throws Exception { .filter(cookieFilter); WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, login); request + .log().ifValidationFails() .post("/login") .then().statusCode(200) + .log().ifValidationFails() .body(Matchers.is("OK")) .cookie("_quarkus_webauthn_challenge", Matchers.is("")) - .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUserName("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); + + // make sure we can't log in via the default endpoint + // reset cookies for the login phase + CookieFilter finalCookieFilter = new CookieFilter(); + // now try to log in + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", finalCookieFilter); + JsonObject defaultLogin = hardwareKey.makeLoginJson(challenge); + + // now finalise + Assertions.assertThrows(AssertionError.class, + () -> WebAuthnEndpointHelper.invokeLogin(defaultLogin, finalCookieFilter)); + + // make sure we did not bump the user + users = userProvider.findByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(2, users.get(0).getCounter()); + } + + @Test + public void checkDefaultRegistrationDisabled() { + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); + CookieFilter cookieFilter = new CookieFilter(); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + Assertions.assertThrows(AssertionError.class, + () -> WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter)); + + // make sure we did not create any user + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); } private void checkLoggedIn(CookieFilter cookieFilter) { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java index 65ae0801fdd95..be5779656c498 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTestUserProvider.java @@ -5,10 +5,10 @@ import jakarta.enterprise.context.ApplicationScoped; import io.quarkus.arc.Arc; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnSecurity; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider does not update or store credentials in the callback endpoint: you do it manually after calls to @@ -19,21 +19,15 @@ public class WebAuthnManualTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertRequestContext(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUserName(String userId) { assertRequestContext(); - return super.findWebAuthnCredentialsByUserName(userId); - } - - @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - assertRequestContext(); - return Uni.createFrom().nullItem(); + return super.findByUserName(userId); } private void assertRequestContext() { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java index 1ce44c088ed52..4cab358e2a838 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnNonBlockingTestUserProvider.java @@ -6,9 +6,9 @@ import org.jboss.resteasy.reactive.server.core.BlockingOperationSupport; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * This UserProvider stores and updates the credentials in the callback endpoint, and checks that it's non-blocking @@ -16,21 +16,27 @@ @ApplicationScoped public class WebAuthnNonBlockingTestUserProvider extends WebAuthnTestUserProvider { @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertBlockingNotAllowed(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUserName(String userId) { assertBlockingNotAllowed(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUserName(userId); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni update(String credentialId, long counter) { assertBlockingNotAllowed(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.update(credentialId, counter); + } + + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + assertBlockingNotAllowed(); + return super.store(credentialRecord); } private void assertBlockingNotAllowed() { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java new file mode 100644 index 0000000000000..4acb80be4f140 --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java @@ -0,0 +1,48 @@ +package io.quarkus.security.webauthn.test; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.vertx.core.json.JsonObject; + +public class WebAuthnOriginsTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, TestUtil.class) + .addAsResource(new StringAsset("quarkus.webauthn.origins=http://foo,https://bar:42"), + "application.properties")); + + @Test + public void testLoginRpFromFirstOrigin() { + RestAssured + .given() + .body(new JsonObject() + .put("name", "foo").encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/register-options-challenge") + .then() + .log().all() + .statusCode(200) + .contentType(ContentType.JSON) + .body("rp.id", Matchers.equalTo("foo")); + } + + @Test + public void testWellKnownConfigured() { + RestAssured.get("/.well-known/webauthn") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("origins.size()", Matchers.equalTo(2)) + .body("origins[0]", Matchers.equalTo("http://foo")) + .body("origins[1]", Matchers.equalTo("https://bar:42")); + } +} diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java index 1752a5ecac77a..f671a33f0cd1a 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnTest.java @@ -5,8 +5,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.http.ContentType; +import io.vertx.core.json.JsonObject; public class WebAuthnTest { @@ -15,8 +19,87 @@ public class WebAuthnTest { .withApplicationRoot((jar) -> jar .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, TestUtil.class)); + @TestHTTPResource + public String url; + @Test public void testJavaScriptFile() { RestAssured.get("/q/webauthn/webauthn.js").then().statusCode(200).body(Matchers.startsWith("\"use strict\";")); } + + @Test + public void testLoginRpFromFirstOrigin() { + RestAssured + .given() + .body(new JsonObject() + .put("name", "foo").encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/register-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("rp.id", Matchers.equalTo("localhost")); + } + + @Test + public void testRegisterChallengeIsEqualAcrossCalls() { + CookieFilter cookieFilter = new CookieFilter(); + + String challenge = RestAssured + .given() + .filter(cookieFilter) + .body(new JsonObject() + .put("name", "foo").encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/register-options-challenge") + .jsonPath().get("challenge"); + + RestAssured + .given() + .filter(cookieFilter) + .body(new JsonObject() + .put("name", "foo").encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/register-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("challenge", Matchers.equalTo(challenge)); + } + + @Test + public void testLoginChallengeIsEqualAcrossCalls() { + CookieFilter cookieFilter = new CookieFilter(); + + String challenge = RestAssured + .given() + .filter(cookieFilter) + .body(new JsonObject().encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/login-options-challenge") + .jsonPath().get("challenge"); + + RestAssured + .given() + .filter(cookieFilter) + .body(new JsonObject().encode()) + .contentType(ContentType.JSON) + .post("/q/webauthn/login-options-challenge") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("challenge", Matchers.equalTo(challenge)); + } + + @Test + public void testWellKnownDefault() { + String origin = url; + if (origin.endsWith("/")) { + origin = origin.substring(0, origin.length() - 1); + } + RestAssured.get("/.well-known/webauthn").then().statusCode(200) + .contentType(ContentType.JSON) + .body("origins.size()", Matchers.equalTo(1)) + .body("origins[0]", Matchers.equalTo(origin)); + } } diff --git a/extensions/security-webauthn/runtime/pom.xml b/extensions/security-webauthn/runtime/pom.xml index cc609bd087d12..0aebc6cd0ba03 100644 --- a/extensions/security-webauthn/runtime/pom.xml +++ b/extensions/security-webauthn/runtime/pom.xml @@ -35,8 +35,12 @@ quarkus-vertx-http - io.vertx - vertx-auth-webauthn + com.webauthn4j + webauthn4j-core-async + + + com.webauthn4j + webauthn4j-metadata-async diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java index 6ed0ef49744c3..ce07656549710 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationMechanism.java @@ -69,7 +69,7 @@ static Uni getRedirect(final RoutingContext exchange, final Strin @Override public Set> getCredentialTypes() { - return new HashSet<>(Arrays.asList(WebAuthnAuthenticationRequest.class, TrustedAuthenticationRequest.class)); + return new HashSet<>(Arrays.asList(TrustedAuthenticationRequest.class)); } @Override diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java deleted file mode 100644 index f24ac245ad060..0000000000000 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.quarkus.security.webauthn; - -import io.quarkus.security.identity.request.BaseAuthenticationRequest; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; - -public class WebAuthnAuthenticationRequest extends BaseAuthenticationRequest { - - private WebAuthnCredentials credentials; - - public WebAuthnAuthenticationRequest(WebAuthnCredentials credentials) { - this.credentials = credentials; - } - - public WebAuthnCredentials getCredentials() { - return credentials; - } - -} 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 ef680306535cb..bc67b739be83b 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 @@ -1,6 +1,5 @@ package io.quarkus.security.webauthn; -import java.util.Collections; import java.util.List; import java.util.function.Supplier; @@ -13,8 +12,6 @@ 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; import io.vertx.mutiny.core.Vertx; /** @@ -29,15 +26,20 @@ public class WebAuthnAuthenticatorStorage { @Inject Vertx vertx; - public Future> fetcher(Authenticator query) { - Uni> res; - if (query.getUserName() != null) - res = runPotentiallyBlocking(() -> userProvider.findWebAuthnCredentialsByUserName(query.getUserName())); - else if (query.getCredID() != null) - res = runPotentiallyBlocking(() -> userProvider.findWebAuthnCredentialsByCredID(query.getCredID())); - else - return Future.succeededFuture(Collections.emptyList()); - return Future.fromCompletionStage(res.subscribeAsCompletionStage()); + public Uni> findByUserName(String userName) { + return runPotentiallyBlocking(() -> userProvider.findByUserName(userName)); + } + + public Uni findByCredID(String credID) { + return runPotentiallyBlocking(() -> userProvider.findByCredentialId(credID)); + } + + public Uni create(WebAuthnCredentialRecord credentialRecord) { + return runPotentiallyBlocking(() -> userProvider.store(credentialRecord)); + } + + public Uni update(String credID, long counter) { + return runPotentiallyBlocking(() -> userProvider.update(credID, counter)); } @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -80,10 +82,4 @@ private boolean isRunOnVirtualThread(Class klass) { // no information, assumed non-blocking return false; } - - public Future updater(Authenticator authenticator) { - return Future - .fromCompletionStage(runPotentiallyBlocking(() -> userProvider.updateOrStoreWebAuthnCredentials(authenticator)) - .subscribeAsCompletionStage()); - } } 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 0c7894568bcba..bbe16c5d3282c 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 @@ -1,187 +1,114 @@ package io.quarkus.security.webauthn; -import java.util.function.Consumer; - -import org.jboss.logging.Logger; +import java.util.function.Supplier; import io.quarkus.arc.Arc; import io.quarkus.arc.InjectableContext.ContextState; import io.quarkus.arc.ManagedContext; -import io.quarkus.security.identity.IdentityProviderManager; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; -import io.quarkus.vertx.http.runtime.security.PersistentLoginManager.RestoreResult; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonObject; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; -import io.vertx.ext.auth.webauthn.impl.attestation.AttestationException; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.impl.Origin; /** * Endpoints for login/register/callback */ public class WebAuthnController { - private static final Logger log = Logger.getLogger(WebAuthnController.class); - - private String challengeUsernameCookie; - private String challengeCookie; - private WebAuthnSecurity security; - private String origin; - - private String domain; - - private IdentityProviderManager identityProviderManager; - - private WebAuthnAuthenticationMechanism authMech; - - public WebAuthnController(WebAuthnSecurity security, WebAuthnRunTimeConfig config, - IdentityProviderManager identityProviderManager, - WebAuthnAuthenticationMechanism authMech) { - origin = config.origin().orElse(null); - if (origin != null) { - Origin o = Origin.parse(origin); - domain = o.host(); - } + public WebAuthnController(WebAuthnSecurity security) { 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) { + /** + * Endpoint for getting a list of allowed origins + * + * @param ctx the current request + */ + public void wellKnown(RoutingContext ctx) { try { - if (json == null) { - return false; - } - if (!json.containsKey(key)) { - return false; - } - Object s = json.getValue(key); - return (s instanceof String) && !"".equals(s); - } catch (ClassCastException e) { - return false; + ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(new JsonObject() + .put("origins", security.getAllowedOrigins(ctx)) + .encode()); + } catch (IllegalArgumentException e) { + ctx.fail(400, e); + } catch (RuntimeException e) { + ctx.fail(e); } } - private static boolean containsOptionalString(JsonObject json, String key) { + /** + * Endpoint for getting a register challenge and options + * + * @param ctx the current request + */ + public void registerOptionsChallenge(RoutingContext ctx) { try { - if (json == null) { - return true; - } - if (!json.containsKey(key)) { - return true; - } - Object s = json.getValue(key); - return (s instanceof String); - } catch (ClassCastException e) { - return false; + // might throw runtime exception if there's no json or is bad formed + final JsonObject webauthnRegister = ctx.getBodyAsJson(); + + String name = webauthnRegister.getString("name"); + String displayName = webauthnRegister.getString("displayName"); + withContext(() -> security.getRegisterChallenge(name, displayName, ctx)) + .map(challenge -> security.toJsonString(challenge)) + .subscribe().with(challenge -> ok(ctx, challenge), ctx::fail); + + } catch (IllegalArgumentException e) { + ctx.fail(400, e); + } catch (RuntimeException e) { + ctx.fail(e); } } - private static boolean containsRequiredObject(JsonObject json, String key) { - try { - if (json == null) { - return false; - } - if (!json.containsKey(key)) { - return false; - } - JsonObject s = json.getJsonObject(key); - return s != null; - } catch (ClassCastException e) { - return false; - } + private Uni withContext(Supplier> uni) { + ManagedContext requestContext = Arc.container().requestContext(); + requestContext.activate(); + ContextState contextState = requestContext.getState(); + return uni.get().eventually(() -> requestContext.destroy(contextState)); } /** - * Endpoint for getting a register challenge + * Endpoint for getting a login challenge and options * * @param ctx the current request */ - public void register(RoutingContext ctx) { + public void loginOptionsChallenge(RoutingContext ctx) { try { // might throw runtime exception if there's no json or is bad formed - final JsonObject webauthnRegister = ctx.getBodyAsJson(); - - // the register object should match a Webauthn user. - // A user has only a required field: name - // And optional fields: displayName and icon - if (webauthnRegister == null || !containsRequiredString(webauthnRegister, "name")) { - ctx.fail(400, new IllegalArgumentException("missing 'name' field from request json")); - } else { - // input basic validation is OK - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - security.getWebAuthn().createCredentialsOptions(webauthnRegister, createCredentialsOptions -> { - requestContext.destroy(contextState); - if (createCredentialsOptions.failed()) { - ctx.fail(createCredentialsOptions.cause()); - return; - } - - final JsonObject credentialsOptions = createCredentialsOptions.result(); + final JsonObject webauthnLogin = ctx.getBodyAsJson(); - // save challenge to the session - authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, challengeCookie, null, - ctx.request().isSSL()); - authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, challengeUsernameCookie, null, - ctx.request().isSSL()); + String name = webauthnLogin.getString("name"); + withContext(() -> security.getLoginChallenge(name, ctx)) + .map(challenge -> security.toJsonString(challenge)) + .subscribe().with(challenge -> ok(ctx, challenge), ctx::fail); - ok(ctx, credentialsOptions); - }); - } } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { ctx.fail(e); } + } /** - * Endpoint for getting a login challenge + * Endpoint for login. This will call {@link} * * @param ctx the current request */ public void login(RoutingContext ctx) { try { // might throw runtime exception if there's no json or is bad formed - final JsonObject webauthnLogin = ctx.getBodyAsJson(); - - if (webauthnLogin == null || !containsRequiredString(webauthnLogin, "name")) { - ctx.fail(400, new IllegalArgumentException("Request missing 'name' field")); - return; - } - - // input basic validation is OK - - final String username = webauthnLogin.getString("name"); - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - // STEP 18 Generate assertion - security.getWebAuthn().getCredentialsOptions(username, generateServerGetAssertion -> { - requestContext.destroy(contextState); - if (generateServerGetAssertion.failed()) { - ctx.fail(generateServerGetAssertion.cause()); - return; - } - - final JsonObject getAssertion = generateServerGetAssertion.result(); - - authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, challengeCookie, null, - ctx.request().isSSL()); - authMech.getLoginManager().save(username, ctx, challengeUsernameCookie, null, - ctx.request().isSSL()); + final JsonObject webauthnResp = ctx.getBodyAsJson(); - ok(ctx, getAssertion); - }); + withContext(() -> security.login(webauthnResp, ctx)) + .onItem().call(record -> security.storage().update(record.getCredentialID(), record.getCounter())) + .subscribe().with(record -> { + security.rememberUser(record.getUserName(), ctx); + ok(ctx); + }, x -> ctx.fail(400, x)); } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { @@ -191,76 +118,22 @@ public void login(RoutingContext ctx) { } /** - * Endpoint for getting authenticated + * Endpoint for registration * * @param ctx the current request */ - public void callback(RoutingContext ctx) { + public void register(RoutingContext ctx) { try { + final String username = ctx.queryParams().get("username"); // might throw runtime exception if there's no json or is bad formed final JsonObject webauthnResp = ctx.getBodyAsJson(); - // input validation - if (webauthnResp == null || - !containsRequiredString(webauthnResp, "id") || - !containsRequiredString(webauthnResp, "rawId") || - !containsRequiredObject(webauthnResp, "response") || - !containsOptionalString(webauthnResp.getJsonObject("response"), "userHandle") || - !containsRequiredString(webauthnResp, "type") || - !"public-key".equals(webauthnResp.getString("type"))) { - - ctx.fail(400, new IllegalArgumentException( - "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); - return; - } - 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")); - return; - } - - ManagedContext requestContext = Arc.container().requestContext(); - requestContext.activate(); - ContextState contextState = requestContext.getState(); - // input basic validation is OK - // authInfo - WebAuthnCredentials credentials = new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(webauthnResp); - identityProviderManager - .authenticate(HttpSecurityUtils - .setRoutingContextAttribute(new WebAuthnAuthenticationRequest(credentials), ctx)) - .subscribe().with(new Consumer() { - @Override - public void accept(SecurityIdentity identity) { - requestContext.destroy(contextState); - // invalidate the challenge - WebAuthnSecurity.removeCookie(ctx, challengeCookie); - WebAuthnSecurity.removeCookie(ctx, challengeUsernameCookie); - try { - authMech.getLoginManager().save(identity, ctx, null, ctx.request().isSSL()); - ok(ctx); - } catch (Throwable t) { - log.error("Unable to complete post authentication", t); - ctx.fail(t); - } - } - }, new Consumer() { - @Override - public void accept(Throwable throwable) { - requestContext.terminate(); - if (throwable instanceof AttestationException) { - ctx.fail(400, throwable); - } else { - ctx.fail(throwable); - } - } - }); + withContext(() -> security.register(username, webauthnResp, ctx)) + .onItem().call(record -> security.storage().create(record)) + .subscribe().with(record -> { + security.rememberUser(record.getUserName(), ctx); + ok(ctx); + }, x -> ctx.fail(400, x)); } catch (IllegalArgumentException e) { ctx.fail(400, e); } catch (RuntimeException e) { @@ -275,20 +148,22 @@ public void accept(Throwable throwable) { * @param ctx the current request */ public void logout(RoutingContext ctx) { - authMech.getLoginManager().clear(ctx); + security.logout(ctx); ctx.redirect("/"); } + private static void ok(RoutingContext ctx, String json) { + ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(json); + } + private static void ok(RoutingContext ctx) { ctx.response() .setStatusCode(204) .end(); } - private static void ok(RoutingContext ctx, JsonObject result) { - ctx.json(result); - } - public void javascript(RoutingContext ctx) { ctx.response().sendFile("webauthn.js"); } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java new file mode 100644 index 0000000000000..9deedc2ffa5cf --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java @@ -0,0 +1,192 @@ +package io.quarkus.security.webauthn; + +import static io.vertx.ext.auth.impl.Codec.base64UrlDecode; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.EdECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Set; +import java.util.UUID; + +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.attestation.authenticator.AAGUID; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; +import com.webauthn4j.data.attestation.authenticator.COSEKey; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import com.webauthn4j.data.attestation.authenticator.EdDSACOSEKey; +import com.webauthn4j.data.attestation.authenticator.RSACOSEKey; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.CollectedClientData; +import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientOutputs; +import com.webauthn4j.data.extension.client.RegistrationExtensionClientOutput; +import com.webauthn4j.util.Base64UrlUtil; + +/** + * This is the internal WebAuthn4J representation for a credential record, augmented with + * a user name. One user name can be shared among multiple credential records, but each + * credential record has a unique credential ID. + */ +public class WebAuthnCredentialRecord extends CredentialRecordImpl { + + private String userName; + + /* + * This is used for registering + */ + public WebAuthnCredentialRecord(String userName, + AttestationObject attestationObject, + CollectedClientData clientData, + AuthenticationExtensionsClientOutputs clientExtensions, + Set transports) { + super(attestationObject, clientData, clientExtensions, transports); + this.userName = userName; + } + + /* + * This is used for login + */ + private WebAuthnCredentialRecord(String userName, + long counter, + AttestedCredentialData attestedCredentialData) { + super(null, null, null, null, counter, attestedCredentialData, null, null, null, null); + this.userName = userName; + } + + /** + * The increasing signature counter for usage of this credential record. See + * https://w3c.github.io/webauthn/#signature-counter + * + * @return The increasing signature counter. + */ + @Override + public long getCounter() { + // this method is just to get rid of deprecation warnings for users. + return super.getCounter(); + } + + /** + * The username for this credential record + * + * @return the username for this credential record + */ + public String getUserName() { + return userName; + } + + /** + * The unique credential ID for this record. This is a convenience method returning a Base64Url-encoded + * version of getAttestedCredentialData().getCredentialId() + * + * @return The unique credential ID for this record + */ + public String getCredentialID() { + return Base64UrlUtil.encodeToString(getAttestedCredentialData().getCredentialId()); + } + + /** + * Returns the fields of this credential record that are necessary to persist for your users + * to be able to log back in using WebAuthn. + * + * @return the fields required to be persisted. + */ + public RequiredPersistedData getRequiredPersistedData() { + return new RequiredPersistedData(getUserName(), + getCredentialID(), + getAttestedCredentialData().getAaguid().getValue(), + getAttestedCredentialData().getCOSEKey().getPublicKey().getEncoded(), + getAttestedCredentialData().getCOSEKey().getAlgorithm().getValue(), + getCounter()); + } + + /** + * Reassembles a credential record from the given required persisted fields. + * + * @param persistedData the required fields to be able to log back in with WebAuthn. + * @return the internal representation of a WebAuthn credential record. + */ + public static WebAuthnCredentialRecord fromRequiredPersistedData(RequiredPersistedData persistedData) { + // important + long counter = persistedData.counter(); + X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(persistedData.publicKey); + COSEAlgorithmIdentifier coseAlgorithm = COSEAlgorithmIdentifier.create(persistedData.publicKeyAlgorithm); + COSEKey coseKey; + try { + switch (coseAlgorithm.getKeyType()) { + case EC2: + coseKey = EC2COSEKey.create((ECPublicKey) KeyFactory.getInstance("EC").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + case OKP: + coseKey = EdDSACOSEKey + .create((EdECPublicKey) KeyFactory.getInstance("EdDSA").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + case RSA: + coseKey = RSACOSEKey + .create((RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec), + coseAlgorithm); + break; + default: + throw new IllegalArgumentException("Invalid cose algorithm: " + coseAlgorithm); + } + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new IllegalArgumentException("Invalid public key", e); + } + byte[] credentialId = base64UrlDecode(persistedData.credentialId()); + AAGUID aaguid = new AAGUID(persistedData.aaguid()); + AttestedCredentialData attestedCredentialData = new AttestedCredentialData(aaguid, credentialId, coseKey); + + return new WebAuthnCredentialRecord(persistedData.userName(), counter, attestedCredentialData); + } + + /** + * Record holding all the required persistent fields for logging back someone over WebAuthn. + */ + public record RequiredPersistedData( + /** + * The user name. A single user name may be associated with multiple WebAuthn credentials. + */ + String userName, + /** + * The credential ID. This must be unique. See https://w3c.github.io/webauthn/#credential-id + */ + String credentialId, + /** + * See https://w3c.github.io/webauthn/#aaguid + */ + UUID aaguid, + /** + * A X.509 encoding of the public key. See https://w3c.github.io/webauthn/#credential-public-key + */ + byte[] publicKey, + /** + * The COSE algorithm used for signing with the public key. See + * https://w3c.github.io/webauthn/#typedefdef-cosealgorithmidentifier + */ + long publicKeyAlgorithm, + /** + * The increasing signature counter for usage of this credential record. See + * https://w3c.github.io/webauthn/#signature-counter + */ + long counter) { + /** + * Returns a PEM-encoded representation of the public key. This is a utility method you can use as an alternate for + * storing the + * binary public key if you do not want to store a byte[] and prefer strings. + * + * @return a PEM-encoded representation of the public key + */ + public String getPublicKeyPEM() { + return "-----BEGIN PUBLIC KEY-----\n" + + Base64.getEncoder().encodeToString(publicKey) + + "\n-----END PUBLIC KEY-----\n"; + } + } +} diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java deleted file mode 100644 index 8e1e62fffdd9a..0000000000000 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.quarkus.security.webauthn; - -import java.util.function.Consumer; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -import io.quarkus.security.identity.AuthenticationRequestContext; -import io.quarkus.security.identity.IdentityProvider; -import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.runtime.QuarkusPrincipal; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; -import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.subscription.UniEmitter; -import io.vertx.core.AsyncResult; -import io.vertx.core.Handler; -import io.vertx.ext.auth.User; - -/** - * WebAuthn IdentityProvider - */ -@ApplicationScoped -public class WebAuthnIdentityProvider implements IdentityProvider { - - @Inject - WebAuthnSecurity security; - - @Override - public Class getRequestType() { - return WebAuthnAuthenticationRequest.class; - } - - @Override - public Uni authenticate(WebAuthnAuthenticationRequest request, AuthenticationRequestContext context) { - return Uni.createFrom().emitter(new Consumer>() { - @Override - public void accept(UniEmitter emitter) { - security.getWebAuthn().authenticate(request.getCredentials(), new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.failed()) { - emitter.fail(event.cause()); - } else { - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - // only the username matters, because when we auth we create a session cookie with it - // and we reply instantly so the roles are never used - builder.setPrincipal(new QuarkusPrincipal(request.getCredentials().getUsername())); - emitter.complete(builder.build()); - } - } - }); - } - }); - } - -} 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 b21affad39408..f23f509f74dd0 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 @@ -10,7 +10,6 @@ import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.security.PersistentLoginManager; import io.vertx.ext.web.Router; @@ -34,18 +33,24 @@ public WebAuthnRecorder(RuntimeValue httpConfiguration, Runti public void setupRoutes(BeanContainer beanContainer, RuntimeValue routerValue, String prefix) { WebAuthnSecurity security = beanContainer.beanInstance(WebAuthnSecurity.class); - WebAuthnAuthenticationMechanism authMech = beanContainer.beanInstance(WebAuthnAuthenticationMechanism.class); - IdentityProviderManager identityProviderManager = beanContainer.beanInstance(IdentityProviderManager.class); - WebAuthnController controller = new WebAuthnController(security, config.getValue(), identityProviderManager, authMech); + WebAuthnController controller = new WebAuthnController(security); Router router = routerValue.getValue(); BodyHandler bodyHandler = BodyHandler.create(); // FIXME: paths configurable // prefix is the non-application root path, ends with a slash: defaults to /q/ - router.post(prefix + "webauthn/login").handler(bodyHandler).handler(controller::login); - router.post(prefix + "webauthn/register").handler(bodyHandler).handler(controller::register); - router.post(prefix + "webauthn/callback").handler(bodyHandler).handler(controller::callback); + router.post(prefix + "webauthn/login-options-challenge").handler(bodyHandler) + .handler(controller::loginOptionsChallenge); + router.post(prefix + "webauthn/register-options-challenge").handler(bodyHandler) + .handler(controller::registerOptionsChallenge); + if (config.getValue().enableLoginEndpoint().orElse(false)) { + router.post(prefix + "webauthn/login").handler(bodyHandler).handler(controller::login); + } + if (config.getValue().enableRegistrationEndpoint().orElse(false)) { + router.post(prefix + "webauthn/register").handler(bodyHandler).handler(controller::register); + } router.get(prefix + "webauthn/webauthn.js").handler(controller::javascript); router.get(prefix + "webauthn/logout").handler(controller::logout); + router.get("/.well-known/webauthn").handler(controller::wellKnown); } public Supplier setupWebAuthnAuthenticationMechanism() { 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 ab7ef3ea30dcd..1daac0b41b7c0 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 @@ -5,17 +5,16 @@ import java.util.Optional; import java.util.OptionalInt; +import com.webauthn4j.data.AttestationConveyancePreference; +import com.webauthn4j.data.ResidentKeyRequirement; +import com.webauthn4j.data.UserVerificationRequirement; + import io.quarkus.runtime.annotations.ConfigDocDefault; import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; -import io.vertx.ext.auth.webauthn.Attestation; -import io.vertx.ext.auth.webauthn.AuthenticatorAttachment; -import io.vertx.ext.auth.webauthn.AuthenticatorTransport; -import io.vertx.ext.auth.webauthn.PublicKeyCredential; -import io.vertx.ext.auth.webauthn.UserVerification; /** * Webauthn runtime configuration object. @@ -24,6 +23,172 @@ @ConfigRoot(phase = ConfigPhase.RUN_TIME) public interface WebAuthnRunTimeConfig { + /** + * COSEAlgorithm + * https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ + public enum COSEAlgorithm { + ES256(-7), + ES384(-35), + ES512(-36), + PS256(-37), + PS384(-38), + PS512(-39), + ES256K(-47), + RS256(-257), + RS384(-258), + RS512(-259), + RS1(-65535), + EdDSA(-8); + + private final int coseId; + + COSEAlgorithm(int coseId) { + this.coseId = coseId; + } + + public static COSEAlgorithm valueOf(int coseId) { + switch (coseId) { + case -7: + return ES256; + case -35: + return ES384; + case -36: + return ES512; + case -37: + return PS256; + case -38: + return PS384; + case -39: + return PS512; + case -47: + return ES256K; + case -257: + return RS256; + case -258: + return RS384; + case -259: + return RS512; + case -65535: + return RS1; + case -8: + return EdDSA; + default: + throw new IllegalArgumentException("Unknown cose-id: " + coseId); + } + } + + public int coseId() { + return coseId; + } + } + + /** + * AttestationConveyancePreference + * https://www.w3.org/TR/webauthn/#attestation-convey + */ + public enum Attestation { + NONE, + INDIRECT, + DIRECT, + ENTERPRISE; + + AttestationConveyancePreference toWebAuthn4J() { + switch (this) { + case DIRECT: + return AttestationConveyancePreference.DIRECT; + case ENTERPRISE: + return AttestationConveyancePreference.ENTERPRISE; + case INDIRECT: + return AttestationConveyancePreference.INDIRECT; + case NONE: + return AttestationConveyancePreference.NONE; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * UserVerificationRequirement + * https://www.w3.org/TR/webauthn/#enumdef-userverificationrequirement + */ + public enum UserVerification { + REQUIRED, + PREFERRED, + DISCOURAGED; + + UserVerificationRequirement toWebAuthn4J() { + switch (this) { + case DISCOURAGED: + return UserVerificationRequirement.DISCOURAGED; + case PREFERRED: + return UserVerificationRequirement.PREFERRED; + case REQUIRED: + return UserVerificationRequirement.REQUIRED; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * AuthenticatorAttachment + * https://www.w3.org/TR/webauthn/#enumdef-authenticatorattachment + */ + public enum AuthenticatorAttachment { + PLATFORM, + CROSS_PLATFORM; + + com.webauthn4j.data.AuthenticatorAttachment toWebAuthn4J() { + switch (this) { + case CROSS_PLATFORM: + return com.webauthn4j.data.AuthenticatorAttachment.CROSS_PLATFORM; + case PLATFORM: + return com.webauthn4j.data.AuthenticatorAttachment.PLATFORM; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + + /** + * AuthenticatorTransport + * https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport + */ + public enum AuthenticatorTransport { + USB, + NFC, + BLE, + HYBRID, + INTERNAL; + } + + /** + * ResidentKey + * https://www.w3.org/TR/webauthn-2/#dictdef-authenticatorselectioncriteria + * + * This enum is used to specify the desired behaviour for resident keys with the authenticator. + */ + public enum ResidentKey { + DISCOURAGED, + PREFERRED, + REQUIRED; + + ResidentKeyRequirement toWebAuthn4J() { + switch (this) { + case DISCOURAGED: + return ResidentKeyRequirement.DISCOURAGED; + case PREFERRED: + return ResidentKeyRequirement.PREFERRED; + case REQUIRED: + return ResidentKeyRequirement.REQUIRED; + default: + throw new IllegalStateException("Illegal enum value: " + this); + } + } + } + /** * SameSite attribute values for the session cookie. */ @@ -34,7 +199,7 @@ enum CookieSameSite { } /** - * The origin of the application. The origin is basically protocol, host and port. + * The origins of the application. The origin is basically protocol, host and port. * * If you are calling WebAuthn API while your application is located at {@code https://example.com/login}, * then origin will be {@code https://example.com}. @@ -44,8 +209,14 @@ enum CookieSameSite { * * Please note that WebAuthn API will not work on pages loaded over HTTP, unless it is localhost, * which is considered secure context. + * + * If unspecified, this defaults to whatever URI this application is deployed on. + * + * This allows more than one value if you want to allow multiple origins. See + * https://w3c.github.io/webauthn/#sctn-related-origins */ - Optional origin(); + @ConfigDocDefault("The URI this application is deployed on") + Optional> origins(); /** * Authenticator Transports allowed by the application. Authenticators can interact with the user web browser @@ -86,12 +257,19 @@ enum CookieSameSite { */ Optional authenticatorAttachment(); + /** + * Load the FIDO metadata for verification. See https://fidoalliance.org/metadata/. Only useful for attestations + * different from {@code Attestation.NONE}. + */ + @ConfigDocDefault("false") + Optional loadMetadata(); + /** * Resident key required. A resident (private) key, is a key that cannot leave your authenticator device, this * means that you cannot reuse the authenticator to log into a second computer. */ - @ConfigDocDefault("false") - Optional requireResidentKey(); + @ConfigDocDefault("REQUIRED") + Optional residentKey(); /** * User Verification requirements. Webauthn applications may choose {@code REQUIRED} verification to assert that @@ -104,15 +282,21 @@ enum CookieSameSite { *
  • {@code DISCOURAGED} - User should avoid interact with the browser
  • * */ - @ConfigDocDefault("DISCOURAGED") + @ConfigDocDefault("REQUIRED") Optional userVerification(); + /** + * User presence requirements. + */ + @ConfigDocDefault("true") + Optional userPresenceRequired(); + /** * Non-negative User Verification timeout. Authentication must occur within the timeout, this will prevent the user * browser from being blocked with a pop-up required user verification, and the whole ceremony must be completed * within the timeout period. After the timeout, any previously issued challenge is automatically invalidated. */ - @ConfigDocDefault("60s") + @ConfigDocDefault("5m") Optional timeout(); /** @@ -144,9 +328,11 @@ enum CookieSameSite { * * Note that the use of stronger algorithms, e.g.: {@code EdDSA} may require Java 15 or a cryptographic {@code JCE} * provider that implements the algorithms. + * + * See https://www.w3.org/TR/webauthn-1/#dictdef-publickeycredentialparameters */ @ConfigDocDefault("ES256,RS256") - Optional> pubKeyCredParams(); + Optional> publicKeyCredentialParameters(); /** * Length of the challenges exchanged between the application and the browser. @@ -180,8 +366,10 @@ enum CookieSameSite { @ConfigGroup interface RelyingPartyConfig { /** - * The id (or domain name of your server) + * The id (or domain name of your server, as obtained from the first entry of origins or looking + * at where this request is being served from) */ + @ConfigDocDefault("The host name of the first allowed origin, or the host where this application is deployed") Optional id(); /** @@ -237,12 +425,6 @@ interface RelyingPartyConfig { @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. */ @@ -261,4 +443,20 @@ interface RelyingPartyConfig { * The default value is empty, which means the cookie will be kept until the browser is closed. */ Optional cookieMaxAge(); + + /** + * Set to true if you want to enable the default registration endpoint at /q/webauthn/register, in + * which case + * you should also implement the WebAuthnUserProvider.store method. + */ + @WithDefault("false") + Optional enableRegistrationEndpoint(); + + /** + * Set to true if you want to enable the default login endpoint at /q/webauthn/login, in which + * case + * you should also implement the WebAuthnUserProvider.update method. + */ + @WithDefault("false") + Optional enableLoginEndpoint(); } 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 d803512ea5a34..a489f5b892f92 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 @@ -1,23 +1,80 @@ package io.quarkus.security.webauthn; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.security.cert.CertificateException; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import com.webauthn4j.async.WebAuthnAsyncManager; +import com.webauthn4j.async.anchor.KeyStoreTrustAnchorAsyncRepository; +import com.webauthn4j.async.anchor.TrustAnchorAsyncRepository; +import com.webauthn4j.async.metadata.FidoMDS3MetadataBLOBAsyncProvider; +import com.webauthn4j.async.metadata.HttpAsyncClient; +import com.webauthn4j.async.metadata.anchor.MetadataBLOBBasedTrustAnchorAsyncRepository; +import com.webauthn4j.async.verifier.attestation.statement.androidkey.AndroidKeyAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.androidsafetynet.AndroidSafetyNetAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.apple.AppleAnonymousAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.packed.PackedAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.tpm.TPMAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.statement.u2f.FIDOU2FAttestationStatementAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessAsyncVerifier; +import com.webauthn4j.async.verifier.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessAsyncVerifier; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticationParameters; +import com.webauthn4j.data.AuthenticatorSelectionCriteria; +import com.webauthn4j.data.PublicKeyCredentialCreationOptions; +import com.webauthn4j.data.PublicKeyCredentialDescriptor; +import com.webauthn4j.data.PublicKeyCredentialParameters; +import com.webauthn4j.data.PublicKeyCredentialRequestOptions; +import com.webauthn4j.data.PublicKeyCredentialRpEntity; +import com.webauthn4j.data.PublicKeyCredentialType; +import com.webauthn4j.data.PublicKeyCredentialUserEntity; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientInputs; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.util.Base64UrlUtil; + import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.Attestation; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.AuthenticatorAttachment; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.COSEAlgorithm; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.ResidentKey; +import io.quarkus.security.webauthn.WebAuthnRunTimeConfig.UserVerification; +import io.quarkus.security.webauthn.impl.VertxHttpAsyncClient; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; import io.quarkus.vertx.http.runtime.security.PersistentLoginManager.RestoreResult; import io.smallrye.mutiny.Uni; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.Cookie; -import io.vertx.ext.auth.webauthn.Authenticator; -import io.vertx.ext.auth.webauthn.RelyingParty; -import io.vertx.ext.auth.webauthn.WebAuthn; -import io.vertx.ext.auth.webauthn.WebAuthnCredentials; -import io.vertx.ext.auth.webauthn.WebAuthnOptions; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.impl.CertificateHelper; +import io.vertx.ext.auth.impl.CertificateHelper.CertInfo; +import io.vertx.ext.auth.impl.jose.JWS; +import io.vertx.ext.auth.prng.VertxContextPRNG; import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.impl.Origin; /** * Utility class that allows users to manually login or register users using WebAuthn @@ -25,138 +82,530 @@ @ApplicationScoped public class WebAuthnSecurity { - private WebAuthn webAuthn; - private String origin; - private String domain; + /* + * Android Keystore Root is not published anywhere. + * This certificate was extracted from one of the attestations + * The last certificate in x5c must match this certificate + * This needs to be checked to ensure that malicious party won't generate fake attestations + */ + private static final String ANDROID_KEYSTORE_ROOT = "MIICizCCAjKgAwIBAgIJAKIFntEOQ1tXMAoGCCqGSM49BAMCMIGYMQswCQYDVQQG" + + "EwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmll" + + "dzEVMBMGA1UECgwMR29vZ2xlLCBJbmMuMRAwDgYDVQQLDAdBbmRyb2lkMTMwMQYD" + + "VQQDDCpBbmRyb2lkIEtleXN0b3JlIFNvZnR3YXJlIEF0dGVzdGF0aW9uIFJvb3Qw" + + "HhcNMTYwMTExMDA0MzUwWhcNMzYwMTA2MDA0MzUwWjCBmDELMAkGA1UEBhMCVVMx" + + "EzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxFTAT" + + "BgNVBAoMDEdvb2dsZSwgSW5jLjEQMA4GA1UECwwHQW5kcm9pZDEzMDEGA1UEAwwq" + + "QW5kcm9pZCBLZXlzdG9yZSBTb2Z0d2FyZSBBdHRlc3RhdGlvbiBSb290MFkwEwYH" + + "KoZIzj0CAQYIKoZIzj0DAQcDQgAE7l1ex+HA220Dpn7mthvsTWpdamguD/9/SQ59" + + "dx9EIm29sa/6FsvHrcV30lacqrewLVQBXT5DKyqO107sSHVBpKNjMGEwHQYDVR0O" + + "BBYEFMit6XdMRcOjzw0WEOR5QzohWjDPMB8GA1UdIwQYMBaAFMit6XdMRcOjzw0W" + + "EOR5QzohWjDPMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgKEMAoGCCqG" + + "SM49BAMCA0cAMEQCIDUho++LNEYenNVg8x1YiSBq3KNlQfYNns6KGYxmSGB7AiBN" + + "C/NR2TB8fVvaNTQdqEcbY6WFZTytTySn502vQX3xvw=="; + + // https://aboutssl.org/globalsign-root-certificates-licensing-and-use/ + // Name gsr1 + // Thumbprint: b1:bc:96:8b:d4:f4:9d:62:2a:a8:9a:81:f2:15:01:52:a4:1d:82:9c + // Valid Until 28 January 2028 + private static final String GSR1 = "MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n" + + "A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv\n" + + "b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw\n" + + "MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i\n" + + "YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT\n" + + "aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ\n" + + "jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp\n" + + "xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp\n" + + "1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG\n" + + "snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ\n" + + "U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8\n" + + "9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E\n" + + "BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B\n" + + "AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz\n" + + "yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE\n" + + "38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP\n" + + "AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad\n" + + "DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME\n" + + "HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A=="; + + /** + * Apple WebAuthn Root CA PEM + *

    + * Downloaded from https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem + *

    + * Valid until 03/14/2045 @ 5:00 PM PST + */ + private static final String APPLE_WEBAUTHN_ROOT_CA = "MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w" + + "HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ" + + "bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx" + + "NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG" + + "A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49" + + "AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k" + + "xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/" + + "pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk" + + "2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA" + + "MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3" + + "jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B" + + "1bWeT0vT"; + + /** + * Default FIDO2 MDS3 ROOT Certificate + *

    + * Downloaded from https://valid.r3.roots.globalsign.com/ + *

    + * Valid until 18 March 2029 + */ + private static final String FIDO_MDS3_ROOT_CERTIFICATE = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G" + + + "A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp" + + "Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4" + + "MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG" + + "A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI" + + "hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8" + + "RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT" + + "gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm" + + "KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd" + + "QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ" + + "XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw" + + "DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o" + + "LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU" + + "RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp" + + "jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK" + + "6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX" + + "mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs" + + "Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH" + + "WD9f"; + + @Inject + TlsConfigurationRegistry certificates; @Inject WebAuthnAuthenticationMechanism authMech; + + @Inject + WebAuthnAuthenticatorStorage storage; + + private ObjectConverter objectConverter = new ObjectConverter(); + private WebAuthnAsyncManager webAuthn; + private VertxContextPRNG random; + private String challengeCookie; - private String challengeUsernameCookie; + + private List origins; + private String rpId; + private String rpName; + + private UserVerification userVerification; + private Boolean userPresenceRequired; + private List pubKeyCredParams; + private ResidentKey residentKey; + + private Duration timeout; + private int challengeLength; + private AuthenticatorAttachment authenticatorAttachment; + + private Attestation attestation; public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthenticatorStorage database) { - // create the webauthn security object - WebAuthnOptions options = new WebAuthnOptions(); - RelyingParty relyingParty = new RelyingParty(); - if (config.relyingParty().id().isPresent()) { - relyingParty.setId(config.relyingParty().id().get()); - } - // this is required - relyingParty.setName(config.relyingParty().name()); - options.setRelyingParty(relyingParty); - if (config.attestation().isPresent()) { - options.setAttestation(config.attestation().get()); - } - if (config.authenticatorAttachment().isPresent()) { - options.setAuthenticatorAttachment(config.authenticatorAttachment().get()); - } - if (config.challengeLength().isPresent()) { - options.setChallengeLength(config.challengeLength().getAsInt()); + // apply config defaults + this.rpId = config.relyingParty().id().orElse(null); + this.rpName = config.relyingParty().name(); + this.origins = config.origins().orElse(Collections.emptyList()); + this.challengeCookie = config.challengeCookieName(); + this.challengeLength = config.challengeLength().orElse(64); + this.userPresenceRequired = config.userPresenceRequired().orElse(true); + this.timeout = config.timeout().orElse(Duration.ofMinutes(5)); + if (config.publicKeyCredentialParameters().isPresent()) { + this.pubKeyCredParams = new ArrayList<>(config.publicKeyCredentialParameters().get().size()); + for (COSEAlgorithm publicKeyCredential : config.publicKeyCredentialParameters().get()) { + this.pubKeyCredParams.add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, + COSEAlgorithmIdentifier.create(publicKeyCredential.coseId()))); + } + } else { + this.pubKeyCredParams = new ArrayList<>(2); + this.pubKeyCredParams + .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256)); + this.pubKeyCredParams + .add(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256)); } - if (config.pubKeyCredParams().isPresent()) { - options.setPubKeyCredParams(config.pubKeyCredParams().get()); + this.authenticatorAttachment = config.authenticatorAttachment().orElse(null); + this.userVerification = config.userVerification().orElse(UserVerification.REQUIRED); + this.residentKey = config.residentKey().orElse(ResidentKey.REQUIRED); + this.attestation = config.attestation().orElse(Attestation.NONE); + // create the webauthn4j manager + this.webAuthn = makeWebAuthn(vertx, config); + this.random = VertxContextPRNG.current(vertx); + } + + private String randomBase64URLBuffer() { + final byte[] buff = new byte[challengeLength]; + random.nextBytes(buff); + return Base64UrlUtil.encodeToString(buff); + } + + private WebAuthnAsyncManager makeWebAuthn(Vertx vertx, WebAuthnRunTimeConfig config) { + if (config.attestation().isPresent() + && config.attestation().get() != WebAuthnRunTimeConfig.Attestation.NONE) { + TrustAnchorAsyncRepository something; + // FIXME: make config name configurable? + Optional webauthnTlsConfiguration = certificates.get("webauthn"); + KeyStore trustStore; + if (webauthnTlsConfiguration.isPresent()) { + trustStore = webauthnTlsConfiguration.get().getTrustStore(); + } else { + try { + trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + addCert(trustStore, ANDROID_KEYSTORE_ROOT); + addCert(trustStore, APPLE_WEBAUTHN_ROOT_CA); + addCert(trustStore, FIDO_MDS3_ROOT_CERTIFICATE); + addCert(trustStore, GSR1); + } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | IOException e) { + throw new RuntimeException("Failed to configure default WebAuthn certificates", e); + } + } + Set trustAnchors = new HashSet<>(); + try { + Enumeration aliases = trustStore.aliases(); + while (aliases.hasMoreElements()) { + trustAnchors.add(new TrustAnchor((X509Certificate) trustStore.getCertificate(aliases.nextElement()), null)); + } + } catch (KeyStoreException e) { + throw new RuntimeException("Failed to configure WebAuthn trust store", e); + } + // FIXME CLRs are not supported yet + something = new KeyStoreTrustAnchorAsyncRepository(trustStore); + if (config.loadMetadata().orElse(false)) { + HttpAsyncClient httpClient = new VertxHttpAsyncClient(vertx); + FidoMDS3MetadataBLOBAsyncProvider blobAsyncProvider = new FidoMDS3MetadataBLOBAsyncProvider(objectConverter, + FidoMDS3MetadataBLOBAsyncProvider.DEFAULT_BLOB_ENDPOINT, httpClient, trustAnchors); + something = new MetadataBLOBBasedTrustAnchorAsyncRepository(blobAsyncProvider); + } + + return new WebAuthnAsyncManager( + Arrays.asList( + new FIDOU2FAttestationStatementAsyncVerifier(), + new PackedAttestationStatementAsyncVerifier(), + new TPMAttestationStatementAsyncVerifier(), + new AndroidKeyAttestationStatementAsyncVerifier(), + new AndroidSafetyNetAttestationStatementAsyncVerifier(), + new AppleAnonymousAttestationStatementAsyncVerifier()), + new DefaultCertPathTrustworthinessAsyncVerifier(something), + new DefaultSelfAttestationTrustworthinessAsyncVerifier(), + objectConverter); + + } else { + return WebAuthnAsyncManager.createNonStrictWebAuthnAsyncManager(objectConverter); } - if (config.requireResidentKey().isPresent()) { - options.setRequireResidentKey(config.requireResidentKey().get()); + } + + private void addCert(KeyStore keyStore, String pemCertificate) throws CertificateException, KeyStoreException { + X509Certificate cert = JWS.parseX5c(pemCertificate); + CertInfo info = CertificateHelper.getCertInfo(cert); + keyStore.setCertificateEntry(info.subject("CN"), cert); + } + + private static byte[] uUIDBytes(UUID uuid) { + Buffer buffer = Buffer.buffer(16); + buffer.setLong(0, uuid.getMostSignificantBits()); + buffer.setLong(8, uuid.getLeastSignificantBits()); + return buffer.getBytes(); + } + + /** + * Obtains a registration challenge for the given required userName and displayName. This will also + * create and save a challenge in a session cookie. + * + * @param userName the userName for the registration + * @param displayName the displayName for the registration + * @param ctx the Vert.x context + * @return the registration challenge. + */ + @SuppressWarnings("unused") + public Uni getRegisterChallenge(String userName, String displayName, + RoutingContext ctx) { + if (userName == null || userName.isEmpty()) { + return Uni.createFrom().failure(new IllegalArgumentException("Username is required")); } - if (config.timeout().isPresent()) { - options.setTimeoutInMilliseconds(config.timeout().get().toMillis()); + // default displayName to userName, but it's required really + if (displayName == null || displayName.isEmpty()) { + displayName = userName; } - if (config.transports().isPresent()) { - options.setTransports(config.transports().get()); + String finalDisplayName = displayName; + String challenge = getOrCreateChallenge(ctx); + Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI()); + String rpId = this.rpId != null ? this.rpId : origin.getHost(); + + return storage.findByUserName(userName) + .map(credentials -> { + List excluded; + // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled + if (false) { + excluded = new ArrayList<>(credentials.size()); + for (WebAuthnCredentialRecord credential : credentials) { + excluded.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY, + credential.getAttestedCredentialData().getCredentialId(), + credential.getTransports())); + } + } else { + excluded = Collections.emptyList(); + } + PublicKeyCredentialCreationOptions publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions( + new PublicKeyCredentialRpEntity( + rpId, + rpName), + new PublicKeyCredentialUserEntity( + uUIDBytes(UUID.randomUUID()), + userName, + finalDisplayName), + new DefaultChallenge(challenge), + pubKeyCredParams, + timeout.getSeconds() * 1000, + excluded, + new AuthenticatorSelectionCriteria( + authenticatorAttachment != null ? authenticatorAttachment.toWebAuthn4J() : null, + residentKey == ResidentKey.REQUIRED, + residentKey.toWebAuthn4J(), + userVerification.toWebAuthn4J()), + attestation.toWebAuthn4J(), + new AuthenticationExtensionsClientInputs<>()); + + // save challenge to the session + authMech.getLoginManager().save(challenge, ctx, challengeCookie, null, + ctx.request().isSSL()); + + return publicKeyCredentialCreationOptions; + }); + + } + + /** + * Obtains a login challenge for the given optional userName. This will also + * create and save a challenge in a session cookie. + * + * @param userName the optional userName for the login + * @param ctx the Vert.x context + * @return the login challenge. + */ + @SuppressWarnings("unused") + public Uni getLoginChallenge(String userName, RoutingContext ctx) { + // Username is not required with passkeys + if (userName == null) { + userName = ""; } - if (config.userVerification().isPresent()) { - options.setUserVerification(config.userVerification().get()); + String finalUserName = userName; + String challenge = getOrCreateChallenge(ctx); + Origin origin = Origin.create(!this.origins.isEmpty() ? this.origins.get(0) : ctx.request().absoluteURI()); + String rpId = this.rpId != null ? this.rpId : origin.getHost(); + + // do not attempt to look users up if there's no user name + Uni> credentialsUni; + if (userName.isEmpty()) { + credentialsUni = Uni.createFrom().item(Collections.emptyList()); + } else { + credentialsUni = storage.findByUserName(userName); } - webAuthn = WebAuthn.create(vertx, options) - // where to load/update authenticators data - .authenticatorFetcher(database::fetcher) - .authenticatorUpdater(database::updater); - origin = config.origin().orElse(null); - if (origin != null) { - Origin o = Origin.parse(origin); - domain = o.host(); + return credentialsUni + .map(credentials -> { + List allowedCredentials; + // See https://github.com/quarkusio/quarkus/issues/44292 for why this is currently disabled + if (false) { + + if (credentials.isEmpty()) { + throw new RuntimeException("No credentials found for " + finalUserName); + } + allowedCredentials = new ArrayList<>(credentials.size()); + for (WebAuthnCredentialRecord credential : credentials) { + allowedCredentials.add(new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PUBLIC_KEY, + credential.getAttestedCredentialData().getCredentialId(), + credential.getTransports())); + } + } else { + allowedCredentials = Collections.emptyList(); + } + PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions = new PublicKeyCredentialRequestOptions( + new DefaultChallenge(challenge), + timeout.getSeconds() * 1000, + rpId, + allowedCredentials, + userVerification.toWebAuthn4J(), + null); + + // save challenge to the session + authMech.getLoginManager().save(challenge, ctx, challengeCookie, null, + ctx.request().isSSL()); + + return publicKeyCredentialRequestOptions; + }); + } + + private String getOrCreateChallenge(RoutingContext ctx) { + RestoreResult challengeRestoreResult = authMech.getLoginManager().restore(ctx, challengeCookie); + String challenge; + if (challengeRestoreResult == null || challengeRestoreResult.getPrincipal() == null + || challengeRestoreResult.getPrincipal().isEmpty()) { + challenge = randomBase64URLBuffer(); + } else { + challenge = challengeRestoreResult.getPrincipal(); } - this.challengeCookie = config.challengeCookieName(); - this.challengeUsernameCookie = config.challengeUsernameCookieName(); + return challenge; } /** - * Registers a new WebAuthn credentials + * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of + * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. * + * @param the username to register credentials for * @param response the Webauthn registration info * @param ctx the current request * @return the newly created credentials */ - public Uni register(WebAuthnRegisterResponse response, RoutingContext ctx) { - // validation of the response is done before + public Uni register(String username, WebAuthnRegisterResponse response, RoutingContext ctx) { + return register(username, response.toJsonObject(), ctx); + } + + /** + * Registers a new WebAuthn credentials. This will check it, clear the challenge cookie and return it in case of + * success, but not invoke {@link WebAuthnUserProvider#store(WebAuthnCredentialRecord)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. + * + * @param the username to register credentials for + * @param response the Webauthn registration info + * @param ctx the current request + * @return the newly created credentials + */ + public Uni register(String username, JsonObject response, RoutingContext ctx) { 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")); - } - - return Uni.createFrom().emitter(emitter -> { - webAuthn.authenticate( - // authInfo - new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(response.toJsonObject()), - authenticate -> { - 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 - emitter.complete(new Authenticator(authenticate.result().principal())); - } else { - emitter.fail(authenticate.cause()); - } - }); - }); + if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty()) { + return Uni.createFrom().failure(new RuntimeException("Missing challenge")); + } + if (username == null || username.isEmpty()) { + return Uni.createFrom().failure(new RuntimeException("Missing username")); + } + + // input validation + if (response == null || + !containsRequiredString(response, "id") || + !containsRequiredString(response, "rawId") || + !containsRequiredObject(response, "response") || + !containsOptionalString(response.getJsonObject("response"), "userHandle") || + !containsRequiredString(response, "type") || + !"public-key".equals(response.getString("type"))) { + + return Uni.createFrom().failure(new IllegalArgumentException( + "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); + } + String registrationResponseJSON = response.encode(); + + ServerProperty serverProperty = makeServerProperty(challenge, ctx); + RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, pubKeyCredParams, + userVerification == UserVerification.REQUIRED, userPresenceRequired); + + return Uni.createFrom() + .completionStage(webAuthn.verifyRegistrationResponseJSON(registrationResponseJSON, registrationParameters)) + .eventually(() -> { + removeCookie(ctx, challengeCookie); + }).map(registrationData -> new WebAuthnCredentialRecord( + username, + registrationData.getAttestationObject(), + registrationData.getCollectedClientData(), + registrationData.getClientExtensions(), + registrationData.getTransports())); + } + + private ServerProperty makeServerProperty(RestoreResult challenge, RoutingContext ctx) { + Set origins = new HashSet<>(); + Origin firstOrigin = null; + if (this.origins.isEmpty()) { + firstOrigin = Origin.create(ctx.request().absoluteURI()); + origins.add(firstOrigin); + } else { + for (String origin : this.origins) { + Origin newOrigin = Origin.create(origin); + if (firstOrigin == null) { + firstOrigin = newOrigin; + origins.add(newOrigin); + } + } + } + String rpId = this.rpId != null ? this.rpId : firstOrigin.getHost(); + DefaultChallenge challengeObject = new DefaultChallenge(challenge.getPrincipal()); + return new ServerProperty(origins, rpId, challengeObject, /* this is deprecated in Level 3, so ignore it */ null); + } + + /** + * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in + * case of + * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. + * + * @param response the Webauthn login info + * @param ctx the current request + * @return the updated credentials + */ + public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { + return login(response.toJsonObject(), ctx); } /** - * Logs an existing WebAuthn user in + * Logs an existing WebAuthn user in. This will check it, clear the challenge cookie and return the updated credentials in + * case of + * success, but not invoke {@link WebAuthnUserProvider#update(String, long)}, you have to do + * it manually in case of success. This will also not set a login cookie, you have to do it manually using + * {@link #rememberUser(String, RoutingContext)} + * or using any other way. * * @param response the Webauthn login info * @param ctx the current request * @return the updated credentials */ - public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { - // validation of the response is done before + public Uni login(JsonObject response, RoutingContext ctx) { 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")); - } - - return Uni.createFrom().emitter(emitter -> { - webAuthn.authenticate( - // authInfo - new WebAuthnCredentials() - .setOrigin(origin) - .setDomain(domain) - .setChallenge(challenge.getPrincipal()) - .setUsername(username.getPrincipal()) - .setWebauthn(response.toJsonObject()), - authenticate -> { - 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? - emitter.complete(new Authenticator(authenticate.result().principal())); - } else { - emitter.fail(authenticate.cause()); - } - }); - }); + // although login can be empty, we should still have a cookie for it + ) { + return Uni.createFrom().failure(new RuntimeException("Missing challenge")); + } + + // input validation + if (response == null || + !containsRequiredString(response, "id") || + !containsRequiredString(response, "rawId") || + !containsRequiredObject(response, "response") || + !containsOptionalString(response.getJsonObject("response"), "userHandle") || + !containsRequiredString(response, "type") || + !"public-key".equals(response.getString("type"))) { + + return Uni.createFrom().failure(new IllegalArgumentException( + "Response missing one or more of id/rawId/response[.userHandle]/type fields, or type is not public-key")); + } + + String authenticationResponseJSON = response.encode(); + // validated + String rawId = response.getString("rawId"); + + ServerProperty serverProperty = makeServerProperty(challenge, ctx); + + return storage.findByCredID(rawId) + .chain(credentialRecord -> { + List allowCredentials = List.of(Base64UrlUtil.decode(rawId)); + AuthenticationParameters authenticationParameters = new AuthenticationParameters(serverProperty, + credentialRecord, allowCredentials, + userVerification == UserVerification.REQUIRED, userPresenceRequired); + + return Uni.createFrom() + .completionStage(webAuthn.verifyAuthenticationResponseJSON(authenticationResponseJSON, + authenticationParameters)) + .eventually(() -> { + removeCookie(ctx, challengeCookie); + }).map(authenticationData -> credentialRecord); + }); } static void removeCookie(RoutingContext ctx, String name) { @@ -170,11 +619,11 @@ static void removeCookie(RoutingContext ctx, String name) { } /** - * Returns the underlying Vert.x WebAuthn authenticator + * Returns the underlying WebAuthn4J authenticator * - * @return the underlying Vert.x WebAuthn authenticator + * @return the underlying WebAuthn4J authenticator */ - public WebAuthn getWebAuthn() { + public WebAuthnAsyncManager getWebAuthn4J() { return webAuthn; } @@ -198,4 +647,72 @@ public void rememberUser(String userID, RoutingContext ctx) { public void logout(RoutingContext ctx) { authMech.getLoginManager().clear(ctx); } + + static boolean containsRequiredString(JsonObject json, String key) { + try { + if (json == null) { + return false; + } + if (!json.containsKey(key)) { + return false; + } + Object s = json.getValue(key); + return (s instanceof String) && !"".equals(s); + } catch (ClassCastException e) { + return false; + } + } + + private static boolean containsOptionalString(JsonObject json, String key) { + try { + if (json == null) { + return true; + } + if (!json.containsKey(key)) { + return true; + } + Object s = json.getValue(key); + return (s instanceof String); + } catch (ClassCastException e) { + return false; + } + } + + private static boolean containsRequiredObject(JsonObject json, String key) { + try { + if (json == null) { + return false; + } + if (!json.containsKey(key)) { + return false; + } + JsonObject s = json.getJsonObject(key); + return s != null; + } catch (ClassCastException e) { + return false; + } + } + + public String toJsonString(PublicKeyCredentialCreationOptions challenge) { + return objectConverter.getJsonConverter().writeValueAsString(challenge); + } + + public String toJsonString(PublicKeyCredentialRequestOptions challenge) { + return objectConverter.getJsonConverter().writeValueAsString(challenge); + } + + /** + * Returns the list of allowed origins, or defaults to the current request's origin if unconfigured. + */ + public List getAllowedOrigins(RoutingContext ctx) { + if (this.origins.isEmpty()) { + return List.of(Origin.create(ctx.request().absoluteURI()).toString()); + } else { + return this.origins; + } + } + + WebAuthnAuthenticatorStorage storage() { + return storage; + } } diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java index b74c45363eb50..03b58ce4924b3 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnUserProvider.java @@ -5,7 +5,6 @@ import java.util.Set; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** * Implement this interface in order to tell Quarkus WebAuthn how to look up @@ -14,29 +13,66 @@ */ public interface WebAuthnUserProvider { /** - * Look up a WebAuthn credential by username + * Look up a WebAuthn credential by username. This should return an empty list Uni if the user name is not found. * * @param userName the username - * @return a list of credentials for this username + * @return a list of credentials for this username, or an empty list if there are no credentials or if the user name is + * not found. */ - public Uni> findWebAuthnCredentialsByUserName(String userName); + public Uni> findByUserName(String userName); /** - * Look up a WebAuthn credential by credential ID + * Look up a WebAuthn credential by credential ID, this should return an exception Uni rather than return a null-item Uni + * in case the credential is not found. * * @param credentialId the credential ID - * @returna list of credentials for this credential ID. + * @return a credentials for this credential ID. + * @throws an exception Uni if the credential ID is unknown */ - public Uni> findWebAuthnCredentialsByCredID(String credentialId); + public Uni findByCredentialId(String credentialId); /** - * If this credential's combination of user and credential ID does not exist, - * then store the new credential. If it already exists, then only update its counter + * Update an existing WebAuthn credential's counter. This is only used by the default login endpoint, which + * is disabled by default and can be enabled via the quarkus.webauthn.enable-login-endpoint. + * You don't have to implement this method + * if you handle logins manually via {@link WebAuthnSecurity#login(WebAuthnLoginResponse, io.vertx.ext.web.RoutingContext)}. * - * @param authenticator the new credential if it does not exist, or the credential to update + * The default behaviour is to not do anything. + * + * @param credentialId the credential ID * @return a uni completion object */ - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator); + public default Uni update(String credentialId, long counter) { + return Uni.createFrom().voidItem(); + } + + /** + * Store a new WebAuthn credential. This is only used by the default registration endpoint, which + * is disabled by default and can be enabled via the quarkus.webauthn.enable-registration-endpoint. + * You don't have to implement this method if you handle registration manually via + * {@link WebAuthnSecurity#register(WebAuthnRegisterResponse, io.vertx.ext.web.RoutingContext)} + * + * Make sure that you never allow creating + * new credentials for a `userName` that already exists. Otherwise you risk allowing third-parties to impersonate existing + * users by letting them add their own credentials to existing accounts. If you want to allow existing users to register + * more than one WebAuthn credential, you must make sure that the user is currently logged + * in under the same userName to which you want to add new credentials. In every other case, make sure to + * return a failed + * {@link Uni} from this method. + * + * The default behaviour is to not do anything. + * + * @param userName the userName's credentials + * @param credentialRecord the new credentials to store + * @return a uni completion object + * @throws Exception a failed {@link Uni} if the credentialId already exists, or the userName + * already + * has a credential and you disallow having more, or if trying to add credentials to other users than the current + * user. + */ + public default Uni store(WebAuthnCredentialRecord credentialRecord) { + return Uni.createFrom().voidItem(); + } /** * Returns the set of roles for the given username diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java new file mode 100644 index 0000000000000..755b9810b216b --- /dev/null +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java @@ -0,0 +1,41 @@ +package io.quarkus.security.webauthn.impl; + +import java.io.ByteArrayInputStream; +import java.util.concurrent.CompletionStage; + +import com.webauthn4j.async.metadata.HttpAsyncClient; +import com.webauthn4j.metadata.HttpClient.Response; +import com.webauthn4j.metadata.exception.MDSException; + +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.auth.impl.http.SimpleHttpClient; + +public class VertxHttpAsyncClient implements HttpAsyncClient { + + private static final byte[] NO_BYTES = new byte[0]; + private SimpleHttpClient httpClient; + + public VertxHttpAsyncClient(Vertx vertx) { + this.httpClient = new SimpleHttpClient(vertx, "vertx-auth", new HttpClientOptions()); + } + + @Override + public CompletionStage fetch(String uri) throws MDSException { + return httpClient + .fetch(HttpMethod.GET, uri, null, null) + .map(res -> { + Buffer body = res.body(); + byte[] bytes; + if (body != null) { + bytes = body.getBytes(); + } else { + bytes = NO_BYTES; + } + return new Response(res.statusCode(), new ByteArrayInputStream(bytes)); + }).toCompletionStage(); + } + +} diff --git a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml index b13ed8dc74128..8e56c89d705c1 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/security-webauthn/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -8,6 +8,6 @@ metadata: guide: "https://quarkus.io/guides/security-webauthn" categories: - "security" - status: "preview" + status: "experimental" config: - "quarkus.webauthn." diff --git a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js index e38b2982fc23e..cc91864350831 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js +++ b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js @@ -94,24 +94,33 @@ * Licensed under the Apache 2 license. */ - function WebAuthn(options) { - this.registerPath = options.registerPath; - this.loginPath = options.loginPath; - this.callbackPath = options.callbackPath; - // validation - if (!this.callbackPath) { - throw new Error('Callback path is missing!'); - } + function WebAuthn(options = {}) { + this.registerOptionsChallengePath = options.registerOptionsChallengePath || "/q/webauthn/register-options-challenge"; + this.loginOptionsChallengePath = options.loginOptionsChallengePath || "/q/webauthn/login-options-challenge"; + this.registerPath = options.registerPath || "/q/webauthn/register"; + this.loginPath = options.loginPath || "/q/webauthn/login"; + this.csrf = options.csrf; } WebAuthn.constructor = WebAuthn; - WebAuthn.prototype.registerOnly = function (user) { + WebAuthn.prototype.fetchWithCsrf = function (path, options) { const self = this; - if (!self.registerPath) { - return Promise.reject('Register path missing form the initial configuration!'); + if(self.csrf) { + if(!options.headers) { + options.headers = {}; + } + options.headers[self.csrf.header] = self.csrf.value; + } + return fetch(path, options); + } + + WebAuthn.prototype.registerClientSteps = function (user) { + const self = this; + if (!self.registerOptionsChallengePath) { + return Promise.reject('Register challenge path missing form the initial configuration!'); } - return fetch(self.registerPath, { + return self.fetchWithCsrf(self.registerOptionsChallengePath, { method: 'POST', headers: { 'Accept': 'application/json', @@ -152,9 +161,15 @@ WebAuthn.prototype.register = function (user) { const self = this; - return self.registerOnly(user) + if (!self.registerPath) { + throw new Error('Register path is missing!'); + } + if (!user || !user.name) { + return Promise.reject('User name (user.name) required'); + } + return self.registerClientSteps(user) .then(body => { - return fetch(self.callbackPath, { + return self.fetchWithCsrf(self.registerPath + "?" + new URLSearchParams({username: user.name}).toString(), { method: 'POST', headers: { 'Accept': 'application/json', @@ -173,9 +188,12 @@ WebAuthn.prototype.login = function (user) { const self = this; - return self.loginOnly(user) + if (!self.loginPath) { + throw new Error('Login path is missing!'); + } + return self.loginClientSteps(user) .then(body => { - return fetch(self.callbackPath, { + return self.fetchWithCsrf(self.loginPath, { method: 'POST', headers: { 'Accept': 'application/json', @@ -192,18 +210,18 @@ }); }; - WebAuthn.prototype.loginOnly = function (user) { + WebAuthn.prototype.loginClientSteps = function (user) { const self = this; - if (!self.loginPath) { - return Promise.reject('Login path missing from the initial configuration!'); + if (!self.loginOptionsChallengePath) { + return Promise.reject('Login challenge path missing from the initial configuration!'); } - return fetch(self.loginPath, { + return self.fetchWithCsrf(self.loginOptionsChallengePath, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify(user) + body: JSON.stringify(user || {}) }) .then(res => { if (res.status === 200) { diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java index e14bbfbc04f73..a34e51eb8e9b0 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/LoginResource.java @@ -9,12 +9,12 @@ import org.jboss.resteasy.reactive.RestForm; -import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; 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("") @@ -25,7 +25,7 @@ public class LoginResource { @Path("/login") @POST - @ReactiveTransactional + @WithTransaction public Uni login(@RestForm String userName, @BeanParam WebAuthnLoginResponse webAuthnResponse, RoutingContext ctx) { @@ -42,7 +42,7 @@ public Uni login(@RestForm String userName, // Invalid user return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); + Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); return authenticator // bump the auth counter @@ -54,6 +54,7 @@ public Uni login(@RestForm String userName, }) // handle login failure .onFailure().recoverWithItem(x -> { + x.printStackTrace(); // make a proper error response return Response.status(Status.BAD_REQUEST).build(); }); @@ -63,7 +64,7 @@ public Uni login(@RestForm String userName, @Path("/register") @POST - @ReactiveTransactional + @WithTransaction public Uni register(@RestForm String userName, @BeanParam WebAuthnRegisterResponse webAuthnResponse, RoutingContext ctx) { @@ -80,7 +81,7 @@ public Uni register(@RestForm String userName, // Duplicate user return Uni.createFrom().item(Response.status(Status.BAD_REQUEST).build()); } - Uni authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx); + Uni authenticator = this.webAuthnSecurity.register(userName, webAuthnResponse, ctx); return authenticator // store the user @@ -100,6 +101,7 @@ public Uni register(@RestForm String userName, // handle login failure .onFailure().recoverWithItem(x -> { // make a proper error response + x.printStackTrace(); return Response.status(Status.BAD_REQUEST).build(); }); diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java index 26a5891c715d5..15423104b0f17 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/MyWebAuthnSetup.java @@ -1,6 +1,5 @@ package io.quarkus.it.security.webauthn; -import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -8,85 +7,55 @@ import jakarta.enterprise.context.ApplicationScoped; -import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; 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 + @WithTransaction @Override - public Uni> findWebAuthnCredentialsByUserName(String userName) { + public Uni> findByUserName(String userName) { return WebAuthnCredential.findByUserName(userName) - .flatMap(MyWebAuthnSetup::toAuthenticators); + .map(list -> list.stream().map(WebAuthnCredential::toWebAuthnCredentialRecord).toList()); } - @ReactiveTransactional + @WithTransaction @Override - public Uni> findWebAuthnCredentialsByCredID(String credID) { - return WebAuthnCredential.findByCredID(credID) - .flatMap(MyWebAuthnSetup::toAuthenticators); + public Uni findByCredentialId(String credentialId) { + return WebAuthnCredential.findByCredentialId(credentialId) + .onItem().ifNull().failWith(() -> new RuntimeException("No such credentials")) + .map(WebAuthnCredential::toWebAuthnCredentialRecord); } - @ReactiveTransactional + @WithTransaction @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - // leave the scooby user to the manual endpoint, because if we do it here it will be - // created/updated twice - if (authenticator.getUserName().equals("scooby")) - return Uni.createFrom().nullItem(); - 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> toAuthenticators(List dbs) { - // can't call combine/uni on empty list - if (dbs.isEmpty()) - return Uni.createFrom().item(Collections.emptyList()); - List> ret = new ArrayList<>(dbs.size()); - for (WebAuthnCredential db : dbs) { - ret.add(toAuthenticator(db)); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + // this user is handled in the LoginResource endpoint manually + if (credentialRecord.getUserName().equals("scooby")) { + return Uni.createFrom().voidItem(); } - return Uni.combine().all().unis(ret).combinedWith(f -> (List) f); + User newUser = new User(); + newUser.userName = credentialRecord.getUserName(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + return credential.persist() + .flatMap(c -> newUser.persist()) + .onItem().ignore().andContinueWithNull(); } - private static Uni 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 x5cs = new ArrayList<>(x5c.size()); - for (WebAuthnCertificate webAuthnCertificate : x5c) { - x5cs.add(webAuthnCertificate.x5c); + @WithTransaction + @Override + public Uni update(String credentialId, long counter) { + return WebAuthnCredential.findByCredentialId(credentialId) + .invoke(credential -> { + // this user is handled in the LoginResource endpoint manually + if (!credential.user.userName.equals("scooby")) { + credential.counter = counter; } - 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; - }); + }) + .onItem().ignore().andContinueWithNull(); } @Override diff --git a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java index b6d6fd0f9396b..ec0e526b9d9f0 100644 --- a/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java +++ b/integration-tests/security-webauthn/src/main/java/io/quarkus/it/security/webauthn/WebAuthnCredential.java @@ -1,76 +1,39 @@ package io.quarkus.it.security.webauthn; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; +import jakarta.persistence.Id; import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import io.quarkus.hibernate.reactive.panache.PanacheEntity; +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord.RequiredPersistedData; 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"; +public class WebAuthnCredential extends PanacheEntityBase { /** * The non user identifiable id for the authenticator */ + @Id public String credID; /** * The public key associated with this authenticator */ - public String publicKey; + public byte[] publicKey; + + public long publicKeyAlgorithm; /** * The signature counter of the authenticator to prevent replay attacks */ public long counter; - public String aaguid; - - /** - * The Authenticator attestation certificates object, a JSON like: - * - *

    -     * {@code
    -     *   {
    -     *     "alg": "string",
    -     *     "x5c": [
    -     *       "base64"
    -     *     ]
    -     *   }
    -     * }
    -     * 
    - */ - /** - * The algorithm used for the public credential - */ - public PublicKeyCredential alg; - - /** - * The list of X509 certificates encoded as base64url. - */ - @OneToMany(mappedBy = "webAuthnCredential") - public List x5c = new ArrayList<>(); - - public String fmt; + public UUID aaguid; // owning side @OneToOne @@ -79,35 +42,29 @@ public class WebAuthnCredential extends PanacheEntity { 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); - } - } + public WebAuthnCredential(WebAuthnCredentialRecord credentialRecord, User user) { + RequiredPersistedData requiredPersistedData = credentialRecord.getRequiredPersistedData(); + aaguid = requiredPersistedData.aaguid(); + counter = requiredPersistedData.counter(); + credID = requiredPersistedData.credentialId(); + publicKey = requiredPersistedData.publicKey(); + publicKeyAlgorithm = requiredPersistedData.publicKeyAlgorithm(); this.user = user; user.webAuthnCredential = this; } + public WebAuthnCredentialRecord toWebAuthnCredentialRecord() { + return WebAuthnCredentialRecord + .fromRequiredPersistedData( + new RequiredPersistedData(user.userName, credID, aaguid, publicKey, publicKeyAlgorithm, counter)); + } + public static Uni> findByUserName(String userName) { - return list("userName", userName); + return list("user.userName", userName); } - public static Uni> findByCredID(String credID) { - return list("credID", credID); + public static Uni findByCredentialId(String credID) { + return findById(credID); } public Uni fetch(T association) { diff --git a/integration-tests/security-webauthn/src/main/resources/application.properties b/integration-tests/security-webauthn/src/main/resources/application.properties index 06af79d80af21..8c351a0f75197 100644 --- a/integration-tests/security-webauthn/src/main/resources/application.properties +++ b/integration-tests/security-webauthn/src/main/resources/application.properties @@ -6,3 +6,5 @@ quarkus.hibernate-orm.database.generation=drop-and-create quarkus.webauthn.login-page=/ +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true 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 44171258e4675..cefcdd3f22202 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 @@ -2,11 +2,13 @@ import static io.restassured.RestAssured.given; +import java.net.URL; import java.util.function.Consumer; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; @@ -28,6 +30,9 @@ enum Endpoint { MANUAL; } + @TestHTTPResource + URL url; + @Test public void testWebAuthnUser() { testWebAuthn("FroMage", User.USER, Endpoint.DEFAULT); @@ -41,15 +46,15 @@ public void testWebAuthnAdmin() { private void testWebAuthn(String userName, User user, Endpoint endpoint) { Filter cookieFilter = new RenardeCookieFilter(); - WebAuthnHardware token = new WebAuthnHardware(); + WebAuthnHardware token = new WebAuthnHardware(url); verifyLoggedOut(cookieFilter); // two-step registration - String challenge = WebAuthnEndpointHelper.invokeRegistration(userName, cookieFilter); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge(userName, cookieFilter); JsonObject registrationJson = token.makeRegistrationJson(challenge); if (endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(registrationJson, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration(userName, registrationJson, cookieFilter); else { invokeCustomEndpoint("/register", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registrationJson); @@ -66,10 +71,10 @@ private void testWebAuthn(String userName, User user, Endpoint endpoint) { verifyLoggedOut(cookieFilter); // two-step login - challenge = WebAuthnEndpointHelper.invokeLogin(userName, cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge(userName, cookieFilter); JsonObject loginJson = token.makeLoginJson(challenge); if (endpoint == Endpoint.DEFAULT) - WebAuthnEndpointHelper.invokeCallback(loginJson, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(loginJson, cookieFilter); else { invokeCustomEndpoint("/login", cookieFilter, request -> { WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, loginJson); @@ -96,10 +101,9 @@ private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer> findWebAuthnCredentialsByCredID(String credId) { + public Uni findByCredentialId(String credId) { assertVirtualThread(); - return super.findWebAuthnCredentialsByCredID(credId); + return super.findByCredentialId(credId); } @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { + public Uni> findByUserName(String userId) { assertVirtualThread(); - return super.findWebAuthnCredentialsByUserName(userId); + return super.findByUserName(userId); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + public Uni store(WebAuthnCredentialRecord credentialRecord) { assertVirtualThread(); - return super.updateOrStoreWebAuthnCredentials(authenticator); + return super.store(credentialRecord); + } + + @Override + public Uni update(String credentialId, long counter) { + assertVirtualThread(); + return super.update(credentialId, counter); } private void assertVirtualThread() { 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 index e69de29bb2d1d..6ef5d8c9ccb2e 100644 --- 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 @@ -0,0 +1,2 @@ +quarkus.webauthn.enable-login-endpoint=true +quarkus.webauthn.enable-registration-endpoint=true 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 index 6ff6f8303ec7c..711f4e5140444 100644 --- 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 @@ -2,9 +2,12 @@ import static io.quarkus.virtual.security.webauthn.RunOnVirtualThreadTest.checkLoggedIn; +import java.net.URL; + import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusIntegrationTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; @@ -15,6 +18,9 @@ @QuarkusIntegrationTest class RunOnVirtualThreadIT { + @TestHTTPResource + URL url; + @Test public void test() { @@ -30,12 +36,12 @@ public void test() { .get("/cheese").then().statusCode(302); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure our login cookie works checkLoggedIn(cookieFilter); @@ -43,11 +49,11 @@ public void test() { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure our login cookie still works checkLoggedIn(cookieFilter); 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 index 4d73fc4210d59..a8f7b84031a7d 100644 --- 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 @@ -1,7 +1,6 @@ package io.quarkus.virtual.security.webauthn; -import static org.hamcrest.Matchers.is; - +import java.net.URL; import java.util.List; import jakarta.inject.Inject; @@ -10,7 +9,9 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit5.virtual.ShouldNotPin; import io.quarkus.test.junit5.virtual.VirtualThreadUnit; @@ -19,7 +20,6 @@ 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 @@ -29,6 +29,9 @@ class RunOnVirtualThreadTest { @Inject WebAuthnUserProvider userProvider; + @TestHTTPResource + URL url; + @Test public void test() throws Exception { @@ -43,17 +46,17 @@ public void test() throws Exception { .given().redirects().follow(false) .get("/cheese").then().statusCode(302); - Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + Assertions.assertTrue(userProvider.findByUserName("stef").await().indefinitely().isEmpty()); CookieFilter cookieFilter = new CookieFilter(); - WebAuthnHardware hardwareKey = new WebAuthnHardware(); - String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(url); + String challenge = WebAuthnEndpointHelper.obtainRegistrationChallenge("stef", cookieFilter); JsonObject registration = hardwareKey.makeRegistrationJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + WebAuthnEndpointHelper.invokeRegistration("stef", registration, cookieFilter); // make sure we stored the user - List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + List users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(1, users.get(0).getCounter()); @@ -64,14 +67,14 @@ public void test() throws Exception { // reset cookies for the login phase cookieFilter = new CookieFilter(); // now try to log in - challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + challenge = WebAuthnEndpointHelper.obtainLoginChallenge("stef", cookieFilter); JsonObject login = hardwareKey.makeLoginJson(challenge); // now finalise - WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + WebAuthnEndpointHelper.invokeLogin(login, cookieFilter); // make sure we bumped the user - users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + users = userProvider.findByUserName("stef").await().indefinitely(); Assertions.assertEquals(1, users.size()); Assertions.assertTrue(users.get(0).getUserName().equals("stef")); Assertions.assertEquals(2, users.get(0).getCounter()); diff --git a/test-framework/security-webauthn/pom.xml b/test-framework/security-webauthn/pom.xml index 38923a799df21..3699d95ab3cf9 100644 --- a/test-framework/security-webauthn/pom.xml +++ b/test-framework/security-webauthn/pom.xml @@ -1,7 +1,7 @@ - + io.quarkus quarkus-test-framework 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 f99e0b00a4057..b33d01d876ec9 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 @@ -14,7 +14,7 @@ import io.vertx.core.json.JsonObject; public class WebAuthnEndpointHelper { - public static String invokeRegistration(String userName, Filter cookieFilter) { + public static String obtainRegistrationChallenge(String userName, Filter cookieFilter) { JsonObject registerJson = new JsonObject() .put("name", userName); ExtractableResponse response = RestAssured @@ -22,11 +22,11 @@ public static String invokeRegistration(String userName, Filter cookieFilter) { .contentType(ContentType.JSON) .filter(cookieFilter) .log().ifValidationFails() - .post("/q/webauthn/register") - .then().statusCode(200) + .post("/q/webauthn/register-options-challenge") + .then() .log().ifValidationFails() + .statusCode(200) .cookie(getChallengeCookie(), Matchers.notNullValue()) - .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -35,21 +35,36 @@ public static String invokeRegistration(String userName, Filter cookieFilter) { return challenge; } - public static void invokeCallback(JsonObject registration, Filter cookieFilter) { + public static void invokeLogin(JsonObject login, Filter cookieFilter) { + RestAssured + .given().body(login.encode()) + .filter(cookieFilter) + .contentType(ContentType.JSON) + .log().ifValidationFails() + .post("/q/webauthn/login") + .then() + .log().ifValidationFails() + .statusCode(204) + .cookie(getChallengeCookie(), Matchers.is("")) + .cookie(getMainCookie(), Matchers.notNullValue()); + } + + public static void invokeRegistration(String username, JsonObject registration, Filter cookieFilter) { RestAssured .given().body(registration.encode()) .filter(cookieFilter) .contentType(ContentType.JSON) .log().ifValidationFails() - .post("/q/webauthn/callback") - .then().statusCode(204) + .queryParam("username", username) + .post("/q/webauthn/register") + .then() .log().ifValidationFails() + .statusCode(204) .cookie(getChallengeCookie(), Matchers.is("")) - .cookie(getChallengeUsernameCookie(), Matchers.is("")) .cookie(getMainCookie(), Matchers.notNullValue()); } - public static String invokeLogin(String userName, Filter cookieFilter) { + public static String obtainLoginChallenge(String userName, Filter cookieFilter) { JsonObject loginJson = new JsonObject() .put("name", userName); ExtractableResponse response = RestAssured @@ -57,11 +72,11 @@ public static String invokeLogin(String userName, Filter cookieFilter) { .contentType(ContentType.JSON) .filter(cookieFilter) .log().ifValidationFails() - .post("/q/webauthn/login") - .then().statusCode(200) + .post("/q/webauthn/login-options-challenge") + .then() .log().ifValidationFails() + .statusCode(200) .cookie(getChallengeCookie(), Matchers.notNullValue()) - .cookie(getChallengeUsernameCookie(), Matchers.notNullValue()) .extract(); // assert stuff JsonObject responseJson = new JsonObject(response.asString()); @@ -113,10 +128,4 @@ public static String getChallengeCookie() { 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"); - } } diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java index 3dabdb619f5f9..c8f7197d9eb3a 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHardware.java @@ -2,6 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; @@ -19,18 +20,17 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.data.attestation.authenticator.AuthenticatorData; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.impl.Codec; -import io.vertx.ext.auth.webauthn.impl.AuthData; /** * Provides an emulation of a WebAuthn hardware token, suitable for generating registration * and login JSON objects that you can send to the Quarkus WebAuthn Security extension. * - * The public/private key and id/credID are randomly generated and different for every instance, - * and the origin is always for http://localhost + * The public/private key and id/credID are randomly generated and different for every instance. */ public class WebAuthnHardware { @@ -38,8 +38,9 @@ public class WebAuthnHardware { private String id; private byte[] credID; private int counter = 1; + private URL origin; - public WebAuthnHardware() { + public WebAuthnHardware(URL origin) { KeyPairGenerator generator; try { generator = KeyPairGenerator.getInstance("EC"); @@ -53,6 +54,7 @@ public WebAuthnHardware() { credID = new byte[32]; random.nextBytes(credID); id = Base64.getUrlEncoder().withoutPadding().encodeToString(credID); + this.origin = origin; } /** @@ -65,11 +67,11 @@ public JsonObject makeRegistrationJson(String challenge) { JsonObject clientData = new JsonObject() .put("type", "webauthn.create") .put("challenge", challenge) - .put("origin", "http://localhost") + .put("origin", origin.toString()) .put("crossOrigin", false); String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientData.encode().getBytes(StandardCharsets.UTF_8)); - byte[] authBytes = makeAuthBytes(); + byte[] authBytes = makeAuthBytes(true); /* * {"fmt": "none", "attStmt": {}, "authData": h'DATAAAAA'} */ @@ -108,12 +110,12 @@ public JsonObject makeLoginJson(String challenge) { JsonObject clientData = new JsonObject() .put("type", "webauthn.get") .put("challenge", challenge) - .put("origin", "http://localhost") + .put("origin", origin.toString()) .put("crossOrigin", false); byte[] clientDataBytes = clientData.encode().getBytes(StandardCharsets.UTF_8); String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientDataBytes); - byte[] authBytes = makeAuthBytes(); + byte[] authBytes = makeAuthBytes(false); String authenticatorData = Base64.getUrlEncoder().encodeToString(authBytes); // sign the authbytes + hash(client data json) @@ -148,7 +150,7 @@ public JsonObject makeLoginJson(String challenge) { .put("type", "public-key"); } - private byte[] makeAuthBytes() { + private byte[] makeAuthBytes(boolean attest) { Buffer buffer = Buffer.buffer(); String rpDomain = "localhost"; @@ -161,45 +163,47 @@ private byte[] makeAuthBytes() { byte[] rpIdHash = md.digest(rpDomain.getBytes(StandardCharsets.UTF_8)); buffer.appendBytes(rpIdHash); - byte flags = AuthData.ATTESTATION_DATA | AuthData.USER_PRESENT; + byte flags = AuthenticatorData.BIT_AT | AuthenticatorData.BIT_UP | AuthenticatorData.BIT_UV; buffer.appendByte(flags); long signCounter = counter++; buffer.appendUnsignedInt(signCounter); - // Attested Data is present - String aaguidString = "00000000-0000-0000-0000-000000000000"; - String aaguidStringShort = aaguidString.replace("-", ""); - byte[] aaguid = Codec.base16Decode(aaguidStringShort); - buffer.appendBytes(aaguid); - - buffer.appendUnsignedShort(credID.length); - buffer.appendBytes(credID); - - ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); - Encoder urlEncoder = Base64.getUrlEncoder(); - String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); - String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); - - CBORFactory cborFactory = new CBORFactory(); - ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); - try { - JsonGenerator generator = cborFactory.createGenerator(byteWriter); - generator.writeStartObject(); - // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1 - generator.writeNumberField("1", 2); // kty: "EC" - generator.writeNumberField("3", -7); // alg: "ES256" - generator.writeNumberField("-1", 1); // crv: "P-256" - // https://tools.ietf.org/html/rfc8152#section-13.1.1 - generator.writeStringField("-2", x); // x, base64url - generator.writeStringField("-3", y); // y, base64url - generator.writeEndObject(); - generator.close(); - } catch (IOException t) { - throw new RuntimeException(t); + if (attest) { + // Attested Data is present + String aaguidString = "00000000-0000-0000-0000-000000000000"; + String aaguidStringShort = aaguidString.replace("-", ""); + byte[] aaguid = Codec.base16Decode(aaguidStringShort); + buffer.appendBytes(aaguid); + + buffer.appendUnsignedShort(credID.length); + buffer.appendBytes(credID); + + ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); + // NOTE: this used to be Base64 URL, but webauthn4j refuses it and wants Base64. I can't find in the spec where it's specified. + Encoder urlEncoder = Base64.getEncoder(); + String x = urlEncoder.encodeToString(publicKey.getW().getAffineX().toByteArray()); + String y = urlEncoder.encodeToString(publicKey.getW().getAffineY().toByteArray()); + + CBORFactory cborFactory = new CBORFactory(); + ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); + try { + JsonGenerator generator = cborFactory.createGenerator(byteWriter); + generator.writeStartObject(); + // see CWK and https://tools.ietf.org/html/rfc8152#section-7.1 + generator.writeNumberField("1", 2); // kty: "EC" + generator.writeNumberField("3", -7); // alg: "ES256" + generator.writeNumberField("-1", 1); // crv: "P-256" + // https://tools.ietf.org/html/rfc8152#section-13.1.1 + generator.writeStringField("-2", x); // x, base64url + generator.writeStringField("-3", y); // y, base64url + generator.writeEndObject(); + generator.close(); + } catch (IOException t) { + throw new RuntimeException(t); + } + buffer.appendBytes(byteWriter.toByteArray()); } - buffer.appendBytes(byteWriter.toByteArray()); - return buffer.getBytes(); } diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java new file mode 100644 index 0000000000000..19011267cb182 --- /dev/null +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java @@ -0,0 +1,265 @@ +package io.quarkus.test.security.webauthn; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.fasterxml.jackson.dataformat.cbor.CBORParser; +import com.webauthn4j.util.Base64UrlUtil; + +import io.quarkus.security.webauthn.WebAuthnLoginResponse; +import io.quarkus.security.webauthn.WebAuthnRegisterResponse; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; + +public class WebAuthnHelper { + public static class PrettyPrinter { + private int indent; + + private void indent() { + for (int i = 0; i < indent; i++) { + System.err.print(" "); + } + } + + public void handleToken(CBORParser parser, JsonToken t) throws IOException { + switch (t) { + case END_ARRAY: + endArray(); + break; + case END_OBJECT: + endObject(); + break; + case FIELD_NAME: + fieldName(parser.currentName()); + break; + case NOT_AVAILABLE: + break; + case START_ARRAY: + startArray(); + break; + case START_OBJECT: + startObject(); + break; + case VALUE_EMBEDDED_OBJECT: + Object embeddedObject = parser.getEmbeddedObject(); + if (parser.currentName().equals("authData")) { + dumpAuthData((byte[]) embeddedObject); + } else { + System.err.println(embeddedObject); + } + break; + case VALUE_FALSE: + falseConstant(); + break; + case VALUE_NULL: + nullConstant(); + break; + case VALUE_NUMBER_FLOAT: + floatValue(parser.getFloatValue()); + break; + case VALUE_NUMBER_INT: + intValue(parser.getNumberValue()); + break; + case VALUE_STRING: + stringValue(parser.getValueAsString()); + break; + case VALUE_TRUE: + trueConstant(); + break; + default: + break; + + } + + } + + private void floatValue(float floatValue) { + System.err.println(floatValue); + } + + private void intValue(Number numberValue) { + System.err.println(numberValue); + } + + private void stringValue(String value) { + System.err.println("\"" + value + "\""); + } + + private void nullConstant() { + System.err.println("null"); + } + + private void trueConstant() { + System.err.println("true"); + } + + private void falseConstant() { + System.err.println("false"); + } + + private void startObject() { + indent(); + System.err.println("{"); + indent++; + } + + private void startArray() { + indent(); + System.err.println("["); + indent++; + } + + public void fieldName(String name) { + indent(); + System.err.print("\""); + System.err.print(name); + System.err.print("\": "); + } + + public void endObject() { + indent(); + System.err.println("}"); + indent--; + } + + public void endArray() { + indent(); + System.err.println("]"); + indent--; + } + + private void dumpAuthData(byte[] embeddedObject) throws IOException { + Buffer buf = Buffer.buffer(embeddedObject); + startObject(); + int current = 0; + byte[] rpIdHash = buf.getBytes(0, 32); + current += 32; + fieldName("rpIdHash"); + stringValue(""); // TODO + byte flags = buf.getByte(current); + current += 1; + fieldName("flags"); + intValue(flags); // TODO in binary + long counter = buf.getUnsignedInt(current); + current += 4; + fieldName("counter"); + intValue(counter); + if (embeddedObject.length > current) { + fieldName("attestedCredentialData"); + startObject(); + byte[] aaguid = buf.getBytes(current, current + 16); + current += 16; + fieldName("aaguid"); + stringValue(Base64UrlUtil.encodeToString(aaguid)); + + int credentialIdLength = buf.getUnsignedShort(current); + current += 2; + fieldName("credentialIdLength"); + intValue(credentialIdLength); + + byte[] credentialId = buf.getBytes(current, current + credentialIdLength); + current += credentialIdLength; + fieldName("credentialId"); + stringValue(Base64UrlUtil.encodeToString(credentialId)); + + fieldName("credentialPublicKey"); + current += readCBOR(embeddedObject, current); + + endObject(); + } + // TODO: there's more + endObject(); + } + + private int readCBOR(byte[] bytes, int offset) throws IOException { + CBORFactory factory = CBORFactory.builder().build(); + long lastReadByte = offset; + try (CBORParser parser = factory.createParser(bytes, offset, bytes.length - offset)) { + JsonToken t; + while ((t = parser.nextToken()) != null) { + // System.err.println("Token: "+t); + handleToken(parser, t); + } + lastReadByte = parser.currentLocation().getByteOffset(); + } + return (int) (lastReadByte - offset); + } + } + + public static void dumpWebAuthnRequest(JsonObject json) throws IOException { + System.err.println(json.encodePrettily()); + JsonObject response = json.getJsonObject("response"); + if (response != null) { + String attestationObject = response.getString("attestationObject"); + if (attestationObject != null) { + System.err.println("Attestation object:"); + dumpAttestationObject(attestationObject); + } + String authenticatorData = response.getString("authenticatorData"); + if (authenticatorData != null) { + System.err.println("Authenticator Data:"); + dumpAuthenticatorData(authenticatorData); + } + String clientDataJSON = response.getString("clientDataJSON"); + if (clientDataJSON != null) { + System.err.println("Client Data JSON:"); + String encoded = new String(Base64UrlUtil.decode(clientDataJSON), StandardCharsets.UTF_8); + System.err.println(new JsonObject(encoded).encodePrettily()); + } + } + } + + private static void dumpAttestationObject(String attestationObject) throws IOException { + CBORFactory factory = CBORFactory.builder().build(); + PrettyPrinter printer = new PrettyPrinter(); + try (CBORParser parser = factory.createParser(Base64UrlUtil.decode(attestationObject))) { + JsonToken t; + while ((t = parser.nextToken()) != null) { + // System.err.println("Token: "+t); + printer.handleToken(parser, t); + } + } + } + + private static void dumpAuthenticatorData(String authenticatorData) throws IOException { + PrettyPrinter printer = new PrettyPrinter(); + byte[] bytes = Base64UrlUtil.decode(authenticatorData); + printer.dumpAuthData(bytes); + } + + public static void dumpWebAuthnRequest(WebAuthnRegisterResponse response) { + try { + dumpWebAuthnRequest(response.toJsonObject()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void dumpWebAuthnRequest(WebAuthnLoginResponse response) { + try { + dumpWebAuthnRequest(response.toJsonObject()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void main(String[] args) { + // WebAuthnRegisterResponse response = new WebAuthnRegisterResponse(); + // response.webAuthnId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk"; + // response.webAuthnRawId = "N3P8WalYEtlUPMcD8q7C8hfY9tZ-DZBl7oPZNGMBxjk"; + // response.webAuthnResponseAttestationObject = "v2NmbXRkbm9uZWdhdHRTdG10v_9oYXV0aERhdGFYxUmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjQQAAAAEAAAAAAAAAAAAAAAAAAAAAACA3c_xZqVgS2VQ8xwPyrsLyF9j21n4NkGXug9k0YwHGOb9hMQJhMyZiLTEBYi0yeCxGR0hxMHlCTWJ5X1RuOGpmWlU4XzZSTDlFNFg4ZnhJSkVOY05NN3UtSEFRPWItM3gsQUtNVEtFRG5DSzhnVVNxamRtdU45bnVzbTRRRXJNY0pBNjV6OWhJOW5TWlP__w=="; + // response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibk9uVHRac1diSE5rRWNhOEZYY29NVUdIanJOY1c4S1BybWg0REFPQXFxaUVvRDNYdHhVT09TcXFiVXFndHlEbkEzU1VCM25YS21PRUp2WGNFZTBfVnciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0IiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ=="; + // response.webAuthnType = "public-key"; + // dumpWebAuthnRequest(response); + WebAuthnLoginResponse response = new WebAuthnLoginResponse(); + response.webAuthnId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw"; + response.webAuthnRawId = "cmokxFnWpNiqBDgI8qL41usvkUCeZC_J8EVS_jD0Brw"; + response.webAuthnResponseAuthenticatorData = "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAgAAAAAAAAAAAAAAAAAAAAAAIHJqJMRZ1qTYqgQ4CPKi-NbrL5FAnmQvyfBFUv4w9Aa8v2ExAmEzJmItMQFiLTJ4LFhCbHRrY25LZ2xjTU94bmZYSnAydE1xc2RESFBhNVB5YnIvaFJUY2tSU3c9Yi0zeCxjZWQvdHRvZGdaQjhmUGdHZ0NIM3lIUUU5NjUzVk5GdTNET2JqNFNtZ2dRPf8="; + response.webAuthnResponseSignature = "MEUCIQDlT0NRyeElINrF59m54fsAhjVh09ykApfKzUsFw1qCVQIgHHkrPCQedlVo_fWcb7p8ch7tAT8mt3h3-GihBUP8s4o="; + response.webAuthnResponseClientDataJSON = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiQTdYTDFvOTlINEdRZXBESDI5MnAzSUdwU0NNRHU3cUVlRVEwY3dWMU5BYm82ZzFjbC1yc2pGRHZuaVdwbE5hdk1rX1Z4YkJVcG8wdE00c2V2bXpaQUEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwODEvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ=="; + response.webAuthnType = "public-key"; + dumpWebAuthnRequest(response); + } +} diff --git a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java index b18ad798661b8..e6a5ca5509e71 100644 --- a/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java +++ b/test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnTestUserProvider.java @@ -1,29 +1,29 @@ package io.quarkus.test.security.webauthn; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Set; +import com.webauthn4j.util.Base64UrlUtil; + +import io.quarkus.security.webauthn.WebAuthnCredentialRecord; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.smallrye.mutiny.Uni; -import io.vertx.ext.auth.webauthn.Authenticator; /** - * UserProvider suitable for tests, which stores and updates credentials in a list, + * UserProvider suitable for tests, which fetches and updates credentials from a list, * so you can use it in your tests. - * - * @see WebAuthnStoringTestUserProvider - * @see WebAuthnManualTestUserProvider */ public class WebAuthnTestUserProvider implements WebAuthnUserProvider { - private List auths = new ArrayList<>(); + private List auths = new ArrayList<>(); @Override - public Uni> findWebAuthnCredentialsByUserName(String userId) { - List ret = new ArrayList<>(); - for (Authenticator authenticator : auths) { + public Uni> findByUserName(String userId) { + List ret = new ArrayList<>(); + for (WebAuthnCredentialRecord authenticator : auths) { if (authenticator.getUserName().equals(userId)) { ret.add(authenticator); } @@ -32,38 +32,26 @@ public Uni> findWebAuthnCredentialsByUserName(String userId) } @Override - public Uni> findWebAuthnCredentialsByCredID(String credId) { - List ret = new ArrayList<>(); - for (Authenticator authenticator : auths) { - if (authenticator.getCredID().equals(credId)) { - ret.add(authenticator); + public Uni findByCredentialId(String credId) { + byte[] bytes = Base64UrlUtil.decode(credId); + for (WebAuthnCredentialRecord authenticator : auths) { + if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) { + return Uni.createFrom().item(authenticator); } } - return Uni.createFrom().item(ret); + return Uni.createFrom().failure(new RuntimeException("Credentials not found for credential ID " + credId)); } @Override - public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { - Authenticator existing = find(authenticator.getUserName(), authenticator.getCredID()); - if (existing != null) { - // update - existing.setCounter(authenticator.getCounter()); - } else { - // add - store(authenticator); - } - return Uni.createFrom().nullItem(); + public Uni update(String credentialId, long counter) { + reallyUpdate(credentialId, counter); + return Uni.createFrom().voidItem(); } - private Authenticator find(String userName, String credID) { - for (Authenticator auth : auths) { - if (userName.equals(auth.getUserName()) - && credID.equals(auth.getCredID())) { - // update - return auth; - } - } - return null; + @Override + public Uni store(WebAuthnCredentialRecord credentialRecord) { + reallyStore(credentialRecord); + return Uni.createFrom().voidItem(); } @Override @@ -71,24 +59,23 @@ public Set getRoles(String userId) { return Collections.singleton("admin"); } - /** - * Stores a new credential - * - * @param authenticator the new credential to store - */ - public void store(Authenticator authenticator) { - auths.add(authenticator); + // For tests + + public void clear() { + auths.clear(); + } + + public void reallyUpdate(String credentialId, long counter) { + byte[] bytes = Base64UrlUtil.decode(credentialId); + for (WebAuthnCredentialRecord authenticator : auths) { + if (Arrays.equals(authenticator.getAttestedCredentialData().getCredentialId(), bytes)) { + authenticator.setCounter(counter); + break; + } + } } - /** - * Updates an existing credential - * - * @param userName the user name - * @param credID the credential ID - * @param counter the new counter value - */ - public void update(String userName, String credID, long counter) { - Authenticator authenticator = find(userName, credID); - authenticator.setCounter(counter); + public void reallyStore(WebAuthnCredentialRecord credentialRecord) { + auths.add(credentialRecord); } } \ No newline at end of file