From 370c50829579f7409a9e91bb0a3cb6a94cc7110c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Tue, 1 Oct 2024 17:26:12 +0200 Subject: [PATCH] Reimplement security-webauthn on top of webauthn4j --- bom/application/pom.xml | 13 + .../asciidoc/images/webauthn-custom-login.svg | 1 + .../images/webauthn-custom-register.svg | 1 + .../main/asciidoc/images/webauthn-login.svg | 1 + .../asciidoc/images/webauthn-register.svg | 1 + docs/src/main/asciidoc/security-webauthn.adoc | 540 +++++++------- .../security-webauthn/deployment/pom.xml | 4 +- .../QuarkusSecurityWebAuthnProcessor.java | 59 +- .../webauthn/test/ManualResource.java | 6 +- .../test/WebAuthnAndBasicAuthnTest.java | 15 +- .../webauthn/test/WebAuthnAutomaticTest.java | 23 +- .../WebAuthnBlockingTestUserProvider.java | 20 +- .../test/WebAuthnManualCustomCookiesTest.java | 19 +- .../webauthn/test/WebAuthnManualTest.java | 23 +- .../test/WebAuthnManualTestUserProvider.java | 18 +- .../WebAuthnNonBlockingTestUserProvider.java | 20 +- .../webauthn/test/WebAuthnOriginsTest.java | 48 ++ .../security/webauthn/test/WebAuthnTest.java | 32 + extensions/security-webauthn/runtime/pom.xml | 8 +- .../WebAuthnAuthenticationMechanism.java | 2 +- .../WebAuthnAuthenticationRequest.java | 18 - .../WebAuthnAuthenticatorStorage.java | 32 +- .../security/webauthn/WebAuthnController.java | 270 ++----- .../webauthn/WebAuthnCredentialRecord.java | 192 +++++ .../webauthn/WebAuthnIdentityProvider.java | 56 -- .../security/webauthn/WebAuthnRecorder.java | 9 +- .../webauthn/WebAuthnRunTimeConfig.java | 212 +++++- .../security/webauthn/WebAuthnSecurity.java | 662 +++++++++++++++--- .../webauthn/WebAuthnUserProvider.java | 39 +- .../webauthn/impl/VertxHttpAsyncClient.java | 41 ++ .../runtime/src/main/resources/webauthn.js | 43 +- .../it/security/webauthn/LoginResource.java | 14 +- .../it/security/webauthn/MyWebAuthnSetup.java | 93 +-- .../security/webauthn/WebAuthnCredential.java | 97 +-- .../webauthn/test/WebAuthnResourceTest.java | 17 +- ...WebAuthnVirtualThreadTestUserProvider.java | 20 +- .../webauthn/RunOnVirtualThreadIT.java | 16 +- .../webauthn/RunOnVirtualThreadTest.java | 25 +- test-framework/security-webauthn/pom.xml | 6 +- .../webauthn/WebAuthnEndpointHelper.java | 36 +- .../security/webauthn/WebAuthnHardware.java | 88 +-- .../security/webauthn/WebAuthnHelper.java | 265 +++++++ .../webauthn/WebAuthnTestUserProvider.java | 85 +-- 43 files changed, 2167 insertions(+), 1023 deletions(-) create mode 100644 docs/src/main/asciidoc/images/webauthn-custom-login.svg create mode 100644 docs/src/main/asciidoc/images/webauthn-custom-register.svg create mode 100644 docs/src/main/asciidoc/images/webauthn-login.svg create mode 100644 docs/src/main/asciidoc/images/webauthn-register.svg create mode 100644 extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnOriginsTest.java delete mode 100644 extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticationRequest.java create mode 100644 extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnCredentialRecord.java delete mode 100644 extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnIdentityProvider.java create mode 100644 extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/impl/VertxHttpAsyncClient.java create mode 100644 test-framework/security-webauthn/src/main/java/io/quarkus/test/security/webauthn/WebAuthnHelper.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 63ac806017154..7452714f7107e 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -220,6 +220,7 @@ 0.16.0 1.0.11 + 0.27.0.RELEASE @@ -6538,6 +6539,18 @@ + + + com.webauthn4j + webauthn4j-core-async + ${webauthn4j.version} + + + com.webauthn4j + webauthn4j-metadata-async + ${webauthn4j.version} + + 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..292789c5494b2 --- /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..dbf3ebd0c43c1 --- /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..8114d0c658d52 --- /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..37693a75611ef --- /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..730ddfb40ffff 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"; - - /** - * The non user identifiable id for the authenticator - */ - public String credID; - - /** - * The public key associated with this authenticator - */ - public String publicKey; +public class WebAuthnCredential extends PanacheEntityBase { + + @Id + public String credentialId; - /** - * 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,72 @@ 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(); - } - - private static List toAuthenticators(List dbs) { - return dbs.stream().map(MyWebAuthnSetup::toAuthenticator).collect(Collectors.toList()); + public Uni store(WebAuthnCredentialRecord credentialRecord) { + User newUser = new User(); + newUser.userName = credentialRecord.getUserName(); + WebAuthnCredential credential = new WebAuthnCredential(credentialRecord, newUser); + credential.persist(); + newUser.persist(); + return Uni.createFrom().voidItem(); } - 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 @@ -578,11 +480,7 @@ in `src/main/resources/META-INF/resources/index.html`: + +---- + +Or, if you need to customise the endpoints: + [source,javascript] ----