From d99a1b750bdbdc65954fe6bed51ec5f70a8bea89 Mon Sep 17 00:00:00 2001 From: Jose Carranza Date: Thu, 19 Oct 2023 12:31:27 +0200 Subject: [PATCH] create security/webauthn module with reactive mysql client create quarkus webauthn code with reactive mysql client and compose yaml do some tweaks and add init.sql delete files not needed in the module for quarkus test suite delete docker files not needed in the module for quarkus test suite modify Readme fix some annotation and create src/test add security/webauthn module add LoginResource class remove init.sql and modify mysql.properties create WebAuthnCredential method to associate with the User add method to associate with WebAuthnCredential to the new User add logOut tests variable more descriptive use vertx HttpClient for testing purposes and rename of xc5 variables remove when from rest assured and add indentation delete uncecessary dependencies register a User with VirtualAuthenticator and selenium add dependencies needed for selenium and also jupiter junit add AbstractWebAuthnPlaywright example add WebDriverWait wait variable fix pom conflicts close http client and vertx to prevent leaks use MySqlService parameters withProperties create basic tests on AbstractWebAuthnTest remove classes not needed for now add failed testRegisterSameUserShouldNotAllowed change assertThat on testRegisterSameUserShouldNotAllowed add AdminResource add properties as static final and remove restAssured check from setUp use upstream mysql80 image call onSuccess after http.requests add MyWebAuthnHardware class tweak logic with all methods and parameters needed to register a user add methods invokeRegistration and invokeCallback needed in the user registration with mysql.80.image tests works add openshift tests add logoutUser method tweak to new user credential persist add security/webauthn to README refactor name method remove UTF8 propery drom mysql.properties add cbor library add admin checks use the getApp().getHost() instead of localhost improve security/webauthn description in README add some method order and another tweaks drop jackson-cbor version change deprecate getApp().getHost() method rename class to OpenShiftWebAuthnIT to be included according maven pattern configuration change expectedLog change localhost rpDomain to app.getUri() remove drop and create from mysql.properties add properties quarkus.build.skip fix regex header because failed on openshift add TODO on OpenShift scenario with issue https://github.com/quarkus-qe/quarkus-test-suite/issues/1500 --- README.md | 184 +++++++------- pom.xml | 1 + security/webauthn/pom.xml | 49 ++++ .../security/webauthn/api/AdminResource.java | 18 ++ .../security/webauthn/api/LoginResource.java | 110 +++++++++ .../security/webauthn/api/PublicResource.java | 27 +++ .../security/webauthn/api/UserResource.java | 17 ++ .../ts/security/webauthn/model/User.java | 24 ++ .../webauthn/model/WebAuthnCertificate.java | 17 ++ .../webauthn/model/WebAuthnCredential.java | 121 ++++++++++ .../webauthn/security/MyWebAuthnSetup.java | 102 ++++++++ .../resources/META-INF/resources/index.html | 120 +++++++++ .../src/main/resources/application.properties | 3 + .../src/test/java/AbstractWebAuthnTest.java | 228 ++++++++++++++++++ .../webauthn/src/test/java/CookieFilter.java | 101 ++++++++ .../src/test/java/MySqlWebAuthnIT.java | 26 ++ .../src/test/java/MyWebAuthnHardware.java | 121 ++++++++++ .../src/test/java/OpenShiftWebAuthnIT.java | 6 + .../src/test/resources/mysql.properties | 1 + 19 files changed, 1195 insertions(+), 81 deletions(-) create mode 100644 security/webauthn/pom.xml create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/AdminResource.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/PublicResource.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/UserResource.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/User.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java create mode 100644 security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java create mode 100644 security/webauthn/src/main/resources/META-INF/resources/index.html create mode 100644 security/webauthn/src/main/resources/application.properties create mode 100644 security/webauthn/src/test/java/AbstractWebAuthnTest.java create mode 100644 security/webauthn/src/test/java/CookieFilter.java create mode 100644 security/webauthn/src/test/java/MySqlWebAuthnIT.java create mode 100644 security/webauthn/src/test/java/MyWebAuthnHardware.java create mode 100644 security/webauthn/src/test/java/OpenShiftWebAuthnIT.java create mode 100644 security/webauthn/src/test/resources/mysql.properties diff --git a/README.md b/README.md index d12904a5ec..1afafef1e1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ By default, all your tests are running on bare metal (JVM / Dev mode), but you c All of these profiles are not mutual exclusive, indeed we encourage you to combine these profiles in order to run complex scenarios. -**Example:** +**Example:** To run in OpenShift a native version of root, security and SQL modules and also run knative scenarios of those modules @@ -99,19 +99,19 @@ By default [Quarkus-test-framework](https://github.com/quarkus-qe/quarkus-test-f **Example:** -User: `Run http-minimum module in OpenShift.` +User: `Run http-minimum module in OpenShift.` ```shell -mvn clean verify -Dall-modules -Dopenshift -pl http/http-minimum +mvn clean verify -Dall-modules -Dopenshift -pl http/http-minimum ``` **NOTE:** here we are combining two profiles, profile `openshift` in order to trigger OpenShift execution mode and property `all-modules` to enable `http-modules` profile, where `http/http-minimum` is located. -### OpenShift & Native +### OpenShift & Native Please read [OpenShift](#OpenShift) section first and login into OCP. -When we are running a [native compilation](https://quarkus.io/guides/building-native-image) the flow is the same as the regular way, the only difference is that we need to compile our application first with GraalVM/Mandrel in order to generate the binary application. To do that we will add the flag `-Dnative` to our maven command. +When we are running a [native compilation](https://quarkus.io/guides/building-native-image) the flow is the same as the regular way, the only difference is that we need to compile our application first with GraalVM/Mandrel in order to generate the binary application. To do that we will add the flag `-Dnative` to our maven command. You have a choice of using locally installed GraalVM or a Docker base image in order to generate native executable. #### OpenShift & Native via Docker @@ -121,7 +121,7 @@ You have a choice of using locally installed GraalVM or a Docker base image in o User: `Deploy in Openshift and run http-minimum module in native mode.` ```shell -mvn clean verify -Dall-modules -Dnative -Dopenshift -pl http/http-minimum +mvn clean verify -Dall-modules -Dnative -Dopenshift -pl http/http-minimum ``` Quarkus test framework will reuse the Native binary generated by Maven to run the test, except if the scenario provides a build property, then it will generate a new native executable. @@ -150,7 +150,7 @@ Be sure that GraalVM is installed by running the following command, otherwise yo User: `Deploy in OpenShift the module http-minimum compiled with my local GraalVM in order to build my application` ```shell -mvn clean verify -Popenshift -Dall-modules -Dquarkus.package.type=native -pl http/http-minimum +mvn clean verify -Popenshift -Dall-modules -Dquarkus.package.type=native -pl http/http-minimum ``` ### Bare metal @@ -166,7 +166,7 @@ docker run hello-world User: `Run http-minimum module.` ```shell -mvn clean verify -Dall-modules -pl http/http-minimum +mvn clean verify -Dall-modules -pl http/http-minimum ``` #### Bare metal & Native @@ -178,21 +178,21 @@ Same as [OpenShift & Native](#OpenShift--Native) scenarios, Quarkus test framewo User: `Run http-minimum module in native mode.` ```shell -mvn clean verify -Dall-modules -Dnative -pl http/http-minimum +mvn clean verify -Dall-modules -Dnative -pl http/http-minimum ``` All the above example for OpenShift are also valid for Bare metal, just remove the flag `-Dopenshift` and play with [native image generation properties](https://quarkus.io/guides/building-native-image#configuration-reference) ### Additional notes -Have a look at the main `pom.xml` file and pay attention to some useful areas as how the scenarios are categorized by topics/profiles, or some global properties as `quarkus.platform.version` that could be overwritten by a flag. +Have a look at the main `pom.xml` file and pay attention to some useful areas as how the scenarios are categorized by topics/profiles, or some global properties as `quarkus.platform.version` that could be overwritten by a flag. **Example:** As a user I would like to run all core modules of Quarkus `2.2.3.Final` ```shell -mvn clean verify -Droot-modules -Dquarkus.platform.version=2.2.3.Final +mvn clean verify -Droot-modules -Dquarkus.platform.version=2.2.3.Final ``` Since this is standard Quarkus configuration, it's possible to override using a system property. @@ -225,7 +225,7 @@ And also, the same user `qe` should have access to the `openshift-user-workload- oc adm policy add-role-to-user edit qe -n openshift-user-workload-monitoring ``` -These requirements are necessary to verify the `micrometer/prometheus` and `micrometer/prometheus-kafka` tests. +These requirements are necessary to verify the `micrometer/prometheus` and `micrometer/prometheus-kafka` tests. - the OpenShift user must have permission to create Operators: @@ -284,7 +284,7 @@ The `main` branch is always meant for latest upstream/downstream development. Fo We use a Quarkus QE Test Framework to verify this test suite. For further information about it, please go to [here](https://github.com/quarkus-qe/quarkus-test-framework). -## Name Convention +## Name Convention For bare metal testing, test classes must be named `*IT`, executed by Failsafe. OpenShift tests should be named `OpenShift*IT`. @@ -341,7 +341,7 @@ This module covers basic scenarios about HTTP servlets under `quarkus-undertow` - Undertow web.xml configuration ### `http/jakarta-rest` -Simple bootstrap project created by *quarkus-maven-plugin* +Simple bootstrap project created by *quarkus-maven-plugin* ### `http/jakarta-rest-reactive` RESTEasy Reactive equivalent of `http/jakarta-rest`. Tests simple and multipart endpoints. @@ -361,7 +361,7 @@ This module will setup a very minimal configuration (only `quarkus-resteasy`) an - Two endpoints to get the value of the previous endpoints using the rest client interface. ### `http/rest-client-reactive` -Reactive equivalent of the http/rest-client module. +Reactive equivalent of the http/rest-client module. Exclusions: XML test. Reason: https://quarkus.io/blog/resteasy-reactive/#what-jax-rs-features-are-missing ### `http/hibernate-validator` @@ -376,7 +376,7 @@ It also verifies multiple deployment strategies like: - Using OpenShift quarkus extension and Docker Build strategy ### `http/management` -Verifies, that management interface (micrometer metrics and health endpoints) can be hosted on a separate port +Verifies, that management interface (micrometer metrics and health endpoints) can be hosted on a separate port #### Additions * *@Deprecated* annotation has been added for test regression purposes to ensure `java.lang` annotations are allowed for resources @@ -425,7 +425,7 @@ Tests: - Test the health endpoints responses. - Test greeting resource endpoint response. - Reproducer for [QUARKUS-662](https://issues.redhat.com/browse/QUARKUS-662): "Injection of HttpSession throws UnsatisfiedResolutionException during the build phase" is covered by the test `InjectingScopedBeansResourceTest` and `NativeInjectingScopedBeansResourceIT`. -- Test to cover the functionality of the Fallback feature and ensure the associated metrics are properly updated. +- Test to cover the functionality of the Fallback feature and ensure the associated metrics are properly updated. ### `config` Checks that the application can read configuration from a ConfigMap and a Secret. @@ -452,7 +452,7 @@ Module that covers the logging functionality using JBoss Logging Manager. The fo - Usage of `quarkus-logging-json` extension - Inject the `Logger` instance in beans - Inject a `Logger` instance using a custom category -- Setting up the log level property for logger instances +- Setting up the log level property for logger instances - Check default `quarkus.log.min-level` value ### `sql-db/hibernate` @@ -460,10 +460,10 @@ Module that covers the logging functionality using JBoss Logging Manager. The fo This module contains Hibernate integration scenarios. The features covered: -* Reproducer for [14201](https://github.com/quarkusio/quarkus/issues/14201) and - [14881](https://github.com/quarkusio/quarkus/issues/14881): possible data loss bug in hibernate. This is covered under +* Reproducer for [14201](https://github.com/quarkusio/quarkus/issues/14201) and + [14881](https://github.com/quarkusio/quarkus/issues/14881): possible data loss bug in hibernate. This is covered under the Java package `io.quarkus.qe.hibernate.items`. -- Reproducer for [QUARKUS-661](https://issues.redhat.com/browse/QUARKUS-661): `@TransactionScoped` Context does not call +- Reproducer for [QUARKUS-661](https://issues.redhat.com/browse/QUARKUS-661): `@TransactionScoped` Context does not call `@Predestroy` on `TransactionScoped` beans. This is covered under the Java package `io.quarkus.qe.hibernate.transaction`. ### `sql-db/hibernate-fulltext-search` @@ -487,7 +487,7 @@ There are actually coverage scenarios `sql-app` directory: - `mysql`: same for MysQL - `mariadb`: same for MariaDB - `mssql`: same for MSSQL -- `oracle`: The same case as the others, but for Oracle, only JVM mode is supported. Native mode is not covered due to a bug in Quarkus, which causes it to fail when used in combination with other JDBC drivers (see `OracleDatabaseIT`). OpenShift scenario is also not supported due to another bug (see `OpenShiftOracleDatabaseIT`). +- `oracle`: The same case as the others, but for Oracle, only JVM mode is supported. Native mode is not covered due to a bug in Quarkus, which causes it to fail when used in combination with other JDBC drivers (see `OracleDatabaseIT`). OpenShift scenario is also not supported due to another bug (see `OpenShiftOracleDatabaseIT`). All the tests deploy an SQL database directly into OpenShift, alongside the application. This might not be recommended for production, but is good enough for test. @@ -547,13 +547,13 @@ Base application: - Define a REST resource `DataSourceResource` that provides info about the datasources. Additional tests: -- Rest Data with Panache test according to https://github.com/quarkus-qe/quarkus-test-plans/blob/main/QUARKUS-976.md +- Rest Data with Panache test according to https://github.com/quarkus-qe/quarkus-test-plans/blob/main/QUARKUS-976.md Additional UserEntity is a simple Jakarta Persistence entity that was created with aim to avoid inheritance of PanacheEntity methods and instead test the additional combination of Jakarta Persistence entity + PanacheRepository + PanacheRepositoryResource, where PanacheRepository is a facade class. Facade class can override certain methods to change the default behaviour of the PanacheRepositoryResource methods. -- AgroalPoolTest, will cover how the db pool is managed in terms of IDLE-timeout, max connections and concurrency. +- AgroalPoolTest, will cover how the db pool is managed in terms of IDLE-timeout, max connections and concurrency. ### `sql-db/reactive-rest-data-panache` @@ -572,10 +572,10 @@ invalid input, filtering, sorting, pagination. Verifies Quarkus transaction programmatic API, JDBC object store and transaction recovery. Base application contains REST resource `TransferResource` and three main services: `TransferTransactionService`, `TransferWithdrawalService` -and `TransferTopUpService` which implement various bank transactions. The main scenario is implemented in `TransactionGeneralUsageIT` +and `TransferTopUpService` which implement various bank transactions. The main scenario is implemented in `TransactionGeneralUsageIT` and checks whether transactions and rollbacks always done in full. -OpenTelemetry JDBC instrumentation test coverage is also placed here. JDBC tracing is tested for all supported +OpenTelemetry JDBC instrumentation test coverage is also placed here. JDBC tracing is tested for all supported databases in JVM mode, native mode and OpenShift. Smoke tests for DEV mode are using PostgreSQL. Smallrye Context Propagation cooperation with OpenTelemetry in DEV mode is also placed in this module. @@ -587,9 +587,9 @@ Authorization is based on roles, restrictions are defined using common annotatio ### `security/bouncycastle-fips` -Verify `bouncy castle FIPS` integration with Quarkus-security. +Verify `bouncy castle FIPS` integration with Quarkus-security. Bouncy castle providers: -- BCFIPS +- BCFIPS - BCFIPSJSSE ### `security/form-authn` @@ -622,7 +622,7 @@ Authorization is based on URL patterns, and Keycloak is used for defining and en A simple Keycloak realm with 1 client (protected application), 2 users, 2 roles and 2 protected resources is provided in `test-realm.json`. ### `security/keycloak-authz-reactive` -QUARKUS-1257 - Verifies authenticated endpoints with a generic body in parent class +QUARKUS-1257 - Verifies authenticated endpoints with a generic body in parent class Verifies token-based authn and URL-based authz. Authentication is OIDC, and Keycloak is used for issuing and verifying tokens. Authorization is based on URL patterns, and Keycloak is used for defining and enforcing restrictions. @@ -654,7 +654,7 @@ Restrictions are defined using common annotations (`@RolesAllowed` etc.). ### `security/keycloak-multitenant` -Verifies that we can use a multitenant configuration using JWT, web applications and code flow authorization in different tenants. +Verifies that we can use a multitenant configuration using JWT, web applications and code flow authorization in different tenants. Authentication is OIDC, and Keycloak is used. Authorization is based on roles, which are configured in Keycloak. @@ -662,7 +662,7 @@ A simple Keycloak realm with 1 client (protected application), 2 users and 2 rol ### `security/keycloak-oidc-client-basic` -Verifies authorization using `OIDC Client` extension as token generator. +Verifies authorization using `OIDC Client` extension as token generator. Keycloak is used for issuing and verifying tokens. Restrictions are defined using common annotations (`@RolesAllowed` etc.). @@ -680,7 +680,7 @@ Applications: - OIDC logout flow Test cases: -- When calling `/ping` or `/pong` endpoints without bearer token, then it should return 401 Unauthorized. +- When calling `/ping` or `/pong` endpoints without bearer token, then it should return 401 Unauthorized. - When calling `/ping` or `/pong` endpoints with incorrect bearer token, then it should return 401 Unauthorized. - When calling `/ping` endpoint with valid bearer token, then it should return 200 OK and "ping pong" as response. - When calling `/pong` endpoint with valid bearer token, then it should return 200 OK and "pong" as response. @@ -688,9 +688,9 @@ Test cases: Variants: - Using REST endpoints (quarkus-resteasy extension) - Using Reactive endpoints (quarkus-resteasy-mutiny extension) -- Using Lookup authorization via `@ClientHeaderParam` annotation +- Using Lookup authorization via `@ClientHeaderParam` annotation - Using `OIDC Client Filter` extension to automatically acquire the access token from Keycloak when calling to the RestClient. -- Using `OIDC Token Propagation` extension to propagate the tokens from the source REST call to the target RestClient. +- Using `OIDC Token Propagation` extension to propagate the tokens from the source REST call to the target RestClient. ### `security/keycloak-oidc-client-reactive` @@ -705,8 +705,8 @@ Reactive twin of the `security/keycloak-oidc-client-extended`, extends `security ### `securty/oidc-client-mutual-tls` -Verifies OIDC client can be authenticated as part of the `Mutual TLS` (`mTLS`) authentication process -when OpenID Connect Providers requires so. Keycloak is used as a primary OIDC server and Red Hat SSO +Verifies OIDC client can be authenticated as part of the `Mutual TLS` (`mTLS`) authentication process +when OpenID Connect Providers requires so. Keycloak is used as a primary OIDC server and Red Hat SSO is used for OpenShift scenarios. Test cases: @@ -732,21 +732,21 @@ This test doesn't run on OpenShift (yet). ### `security/vertx-jwt` In order to test Quarkus / Vertx extension security, we have set up an HTTP server with Vertx [Reactive Routes](https://quarkus.io/guides/reactive-routes#using-the-vert-x-web-router). -Basically Vertx it's an event loop that handler any kind of request as an event (Async and non-blocking). In this case the events are going to be generated by an HTTP-client, for example a browser. -This event is going to be managed by a Router (Application.class), that based on some criteria, will dispatch these events to an existing handler. +Basically Vertx it's an event loop that handler any kind of request as an event (Async and non-blocking). In this case the events are going to be generated by an HTTP-client, for example a browser. +This event is going to be managed by a Router (Application.class), that based on some criteria, will dispatch these events to an existing handler. -When a handler ends with a request, could reply a response or could propagate this request to the next handler (Handler chain approach). By this way you can segregate responsibilities between handlers. -In our case we are going to have several handlers. +When a handler ends with a request, could reply a response or could propagate this request to the next handler (Handler chain approach). By this way you can segregate responsibilities between handlers. +In our case we are going to have several handlers. Example: ``` this.router.get("/secured") - .handler(CorsHandler.create("*")) - .handler(LoggerHandler.create()) - .handler(JWTAuthHandler.create(authN)) - .handler(authZ::authorize) - .handler(rc -> secure.helloWorld(rc)); + .handler(CorsHandler.create("*")) + .handler(LoggerHandler.create()) + .handler(JWTAuthHandler.create(authN)) + .handler(authZ::authorize) + .handler(rc -> secure.helloWorld(rc)); ``` * CorsHandler: add cross origin headers to the HTTP response @@ -755,13 +755,35 @@ this.router.get("/secured") * authZ::authorize: custom AuthZ(authorization) provider. * secure.helloWorld(rc): actual http endpoint (Rest layer). +### `security/webauthn` +Verifies WebAuthn authentication mechanism. + +To test WebAuthn we have set up a reactive Mysql database container. + +However,testing WebAuthn can be challenging because it typically requires a hardware token. To address this, +we've created 'MyWebAuthnHardware' class to simulate that hardware token. + +Additionally, we utilize some webauthn enpoints such as "/q/webauthn/register" and "/q/webauthn/callback". + +Restrictions are defined using role-based access control (RBAC) annotations such as `@RolesAllowed`. + +Test cases + +* Check the quarkus application, accessing different endpoints without any registered user. +* Try to register a user with webauthn. +* Try to register the same user name. +* Try to login as the registered user. +* Try to simulate a register user without the specific webauthn data required (challenge, public key credentials, type, rawId, etc). +* Check for a failed login attemp with an improperly registered user. + + ### service-binding/postgresql-crunchy-classic and service-binding/postgresql-crunchy-reactive Modules verifying Quarkus `kubernetes-service-binding` extension is able to inject application projection service -binding from a PostgreSQL cluster created by Crunchy Postgres operator. +binding from a PostgreSQL cluster created by Crunchy Postgres operator. Binding is verified for both classic and reactive SQL clients (`quarkus-jdbc-postgresql` and `quarkus-reactive-pg-client`). -The module requires a cluster with Kubernetes API >=1.21 to work with Red Hat Service Binding Operator and Crunchy +The module requires a cluster with Kubernetes API >=1.21 to work with Red Hat Service Binding Operator and Crunchy Postgres v5 (this means OCP 4.7 and upwards.) This module requires an installed Crunchy Postgres Operator v5 and Red Hat Service Binding Operator. @@ -777,8 +799,8 @@ Verifies Stork integration in order to provide service discovering and round-rob * Pung: is a simple endpoint that returns "pung" as a string * Pong: is a simple endpoint that returns "pong" as a string * PongReplica: is a "Pong service" replica, that is deployed in another physical service -* Ping: is the main client microservice that will use `pung` and `pong` (Pong and PongReplica) services. The service -discovery will be done by Stork, and the request dispatching between "pong" services is going to be done by Stork load balancer. +* Ping: is the main client microservice that will use `pung` and `pong` (Pong and PongReplica) services. The service +discovery will be done by Stork, and the request dispatching between "pong" services is going to be done by Stork load balancer. ### Service-discovery/stork-custom @@ -805,7 +827,7 @@ Jaeger is deployed in an "all-in-one" configuration, and the OpenShift test veri Testing OpenTelemetry with Jaeger components - Extension `quarkus-opentelemetry` - responsible for traces generation in OpenTelemetry format and export into OpenTelemetry components (opentelemetry-agent, opentelemetry-collector) - + Scenarios that test proper traces export to Jaeger components, context propagation, OpenTelemetry SDK Autoconfiguration and CDI injection of OpenTelemetry beans. See also `monitoring/opentelemetry/README.md` @@ -819,9 +841,9 @@ There is a PrimeNumberResource that checks whether an integer is prime or not. T Where `{uniqueId}` is an unique identifier that is calculated at startup time to uniquely identify the metrics of the application. -This module also covers the usage of `MeterRegistry` and `MicroProfile API`: - -- The `MeterRegistry` approach includes three scenarios: +This module also covers the usage of `MeterRegistry` and `MicroProfile API`: + +- The `MeterRegistry` approach includes three scenarios: `simple`: single call will increment the counter. `forloop`: will increment the counter a number of times. `forloop parallel`: will increment the counter a number of times using a parallel flow. @@ -876,11 +898,11 @@ Verifies KafkaSSL integration. This module cover a simple Kafka producer/consume ### `messaging/kafka-streams-reactive-messaging` -Verifies that `Quarkus Kafka Stream` and `Quarkus SmallRye Reactive Messaging` extensions works as expected. +Verifies that `Quarkus Kafka Stream` and `Quarkus SmallRye Reactive Messaging` extensions works as expected. -There is an EventsProducer that generate login status events every 100ms. -A Kafka stream called `WindowedLoginDeniedStream` will aggregate these events in fixed time windows of 3 seconds. -So if the number of wrong access excess a threshold, then a new alert event is thrown. All aggregated events(not only unauthorized) are persisted. +There is an EventsProducer that generate login status events every 100ms. +A Kafka stream called `WindowedLoginDeniedStream` will aggregate these events in fixed time windows of 3 seconds. +So if the number of wrong access excess a threshold, then a new alert event is thrown. All aggregated events(not only unauthorized) are persisted. - Quarkus Grateful Shutdown for Kafka connectors @@ -890,22 +912,22 @@ The test will confirm that no messages are lost when the `grateful-shutdown` is - Reactive Kafka and Kafka Streams SSL - Auto-detect serializers and deserializers for the Reactive Messaging Kafka Connector -All current tests are running under a secured Kafka by SSL. -Kafka streams pipeline is configured by `quarkus.kafka-streams.ssl` prefix property, but reactive Kafka producer/consumer is configured by `kafka` prefix as you can see on `SslStrimziKafkaTestResource` +All current tests are running under a secured Kafka by SSL. +Kafka streams pipeline is configured by `quarkus.kafka-streams.ssl` prefix property, but reactive Kafka producer/consumer is configured by `kafka` prefix as you can see on `SslStrimziKafkaTestResource` ### `messaging/kafka-confluent-avro-reactive-messaging` -- Verifies that `Quarkus Kafka` + `Apicurio Kakfa Registry`(AVRO) and `Quarkus SmallRye Reactive Messaging` extensions work as expected. +- Verifies that `Quarkus Kafka` + `Apicurio Kakfa Registry`(AVRO) and `Quarkus SmallRye Reactive Messaging` extensions work as expected. -There is an EventsProducer that generate stock prices events every 1s. The events are typed by an AVRO schema. -A Kafka consumer will read these events serialized by AVRO and change an `status` property to `COMPLETED`. -The streams of completed events will be exposed through an SSE endpoint. +There is an EventsProducer that generate stock prices events every 1s. The events are typed by an AVRO schema. +A Kafka consumer will read these events serialized by AVRO and change an `status` property to `COMPLETED`. +The streams of completed events will be exposed through an SSE endpoint. ### `messaging/kafka-strimzi-avro-reactive-messaging` - Verifies that `Quarkus Kafka` + `Apicurio Kakfa Registry`(AVRO) and `Quarkus SmallRye Reactive Messaging` extensions work as expected. -There is an EventsProducer that generate stock prices events every 1s. The events are typed by an AVRO schema. +There is an EventsProducer that generate stock prices events every 1s. The events are typed by an AVRO schema. A Kafka consumer will read these events serialized by AVRO and change an `status` property to `COMPLETED`. The streams of completed events will be exposed through an SSE endpoint. @@ -914,7 +936,7 @@ The streams of completed events will be exposed through an SSE endpoint. ### `messaging/kafka-producer` -This scenario is focus on issues related only to Kafka producer. +This scenario is focus on issues related only to Kafka producer. Verifies that Kafka producer doesn't block the main thread and also doesn't takes more time than `mp.messaging.outgoing..max.block.ms`, and also doesn't retry more times than `mp.messaging.outgoing..retries` @@ -946,14 +968,14 @@ It contains three applications: #### `todo-demo-app` -This test produces an S2I source deployment config for OpenShift with [todo-demo-app](https://github.com/quarkusio/todo-demo-app) +This test produces an S2I source deployment config for OpenShift with [todo-demo-app](https://github.com/quarkusio/todo-demo-app) serving a simple todo checklist. The code for this application lives outside of the test suite's codebase. The test verifies that the application with a sample of libraries is buildable and deployable via supported means. #### `quarkus-workshop-super-heroes` -This test produces an S2I source deployment config for OpenShift with +This test produces an S2I source deployment config for OpenShift with [Quarkus Super heroes workshop](https://github.com/quarkusio/quarkus-workshops) application. The code for this application lives outside of the test suite's codebase. @@ -1006,7 +1028,7 @@ Covers two areas related to Spring Web: - CRUD endpoints. - Custom error handlers. - Cooperation with Qute templating engine. - + ### `spring/spring-web-reactive` Covers two areas related to Spring Web Reactive: - Proper behavior of SmallRye OpenAPI with Mutiny method signatures - correct content types in OpenAPI endpoint output (`/q/openapi`). @@ -1016,7 +1038,7 @@ Covers two areas related to Spring Web: - Cooperation with Qute templating engine. - Verify functionality of methods with transactional annotation @ReactiveTransactional - Verify functionality of methods with transactional method (.withTransactional) - + ### `spring/spring-cloud-config` Verifies that we can use an external Spring Cloud Server to inject configuration in our Quarkus applications. @@ -1031,45 +1053,45 @@ Current limitations: ### `infinispan-client` -Verifies the way of the sharing cache by Datagrid operator and Infinispan cluster and data consistency after failures. +Verifies the way of the sharing cache by Datagrid operator and Infinispan cluster and data consistency after failures. Verifies cache entries serialization, querying and cache eviction. #### Prerequisites - Datagrid operator installed in `datagrid-operator` namespace. This needs cluster-admin rights to install. -- The operator supports only single-namespace so it has to watch another well-known namespace `datagrid-cluster`. +- The operator supports only single-namespace so it has to watch another well-known namespace `datagrid-cluster`. This namespace must be created by "qe" user or this user must have access to it because tests are connecting to it. - These namespaces should be prepared after the Openshift installation - See [Installing Data Grid Operator](https://access.redhat.com/documentation/en-us/red_hat_data_grid/8.1/html/running_data_grid_on_openshift/installation) -Tests create an Infinispan cluster in the `datagrid-cluster` namespace. Cluster is created before tests by `infinispan_cluster_config.yaml`. -To allow parallel runs of tests this cluster is renamed for every test run - along with configmap `infinispan-config`. The configmap contains -configuration property `quarkus.infinispan-client.hosts`. Value of this property is a path to the infinispan cluster from test namespace, -its structure is `infinispan-cluster-name.datagrid-cluster-namespace.svc.cluster.local:11222`. It is because the testsuite uses dynamically generated +Tests create an Infinispan cluster in the `datagrid-cluster` namespace. Cluster is created before tests by `infinispan_cluster_config.yaml`. +To allow parallel runs of tests this cluster is renamed for every test run - along with configmap `infinispan-config`. The configmap contains +configuration property `quarkus.infinispan-client.hosts`. Value of this property is a path to the infinispan cluster from test namespace, +its structure is `infinispan-cluster-name.datagrid-cluster-namespace.svc.cluster.local:11222`. It is because the testsuite uses dynamically generated namespaces for tests. So this path is needed for the tests to find Infinispan cluster in another namespace. The Infinispan cluster needs 2 special secrets - tls-secret with TLS certificate and connect-secret with the credentials. -TLS certificate is a substitution of `secrets/signing-key` in openshift-service-ca namespace, which "qe" user cannot use (doesn't have rights on it). +TLS certificate is a substitution of `secrets/signing-key` in openshift-service-ca namespace, which "qe" user cannot use (doesn't have rights on it). Clientcert secret is generated for "qe" from the tls-secret mentioned above. -Infinispan client tests use the cache directly with `@Inject` and `@RemoteCache`. Through the Jakarta REST endpoint, we send data into the cache and retrieve it through another Jakarta REST endpoint. +Infinispan client tests use the cache directly with `@Inject` and `@RemoteCache`. Through the Jakarta REST endpoint, we send data into the cache and retrieve it through another Jakarta REST endpoint. The next tests are checking a simple fail-over - first client (application) fail, then Infinispan cluster (cache) fail. Tests kill first the Quarkus pod then Infinispan cluster pod and then check data. For the Quarkus application, pod killing is used the same approach as in configmap tests. For the Infinispan cluster, pod killing is updated its YAML snipped and uploaded with zero replicas. By default, when the Infinispan server is down and the application can't open a connection, it tries to connect again, up to 10 times (max_retries) and gives up after 60s (connect_timeout). Because of that we are using the `hotrod-client.properties` file where are the max_retries and connect_timeout reduced. Without this the application will be still trying to connect to the Infinispan server next 10 minutes and the incremented number can appear later. -The last three tests are for testing of the multiple client access to the cache. We simulate the second client by deploying the second deployment config, Service, and Route for these tests. These are copied from the `openshift.yml` file. +The last three tests are for testing of the multiple client access to the cache. We simulate the second client by deploying the second deployment config, Service, and Route for these tests. These are copied from the `openshift.yml` file. ### `cache/caffeine` Verifies the `quarkus-cache` extension using `@CacheResult`, `@CacheInvalidate`, `@CacheInvalidateAll` and `@CacheKey`. -It covers different usages: +It covers different usages: 1. from an application scoped service 2. from a request scoped service 3. from a blocking endpoint -4. from a reactive endpoint +4. from a reactive endpoint ### `cache/spring` Verifies the `quarkus-spring-cache` extension using `@Cacheable`, `@CacheEvict` and `@CachePut`. -It covers different usages: +It covers different usages: 1. from an application scoped service 2. from a request scoped service 3. from a REST controller endpoint (using `@RestController) diff --git a/pom.xml b/pom.xml index f03e456348..fef11aabd9 100644 --- a/pom.xml +++ b/pom.xml @@ -501,6 +501,7 @@ security/keycloak-oidc-client-reactive-extended security/vertx-jwt security/oidc-client-mutual-tls + security/webauthn diff --git a/security/webauthn/pom.xml b/security/webauthn/pom.xml new file mode 100644 index 0000000000..01f33fc353 --- /dev/null +++ b/security/webauthn/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + io.quarkus.ts.qe + parent + 1.0.0-SNAPSHOT + ../.. + + security-webauthn + 1.0.0-SNAPSHOT + Quarkus QE TS: Security: WebAuth + + true + + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-reactive-mysql-client + + + io.quarkus + quarkus-security-webauthn + + + io.quarkus + quarkus-hibernate-reactive-panache + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus.qe + quarkus-test-service-database + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-cbor + test + + + diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/AdminResource.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/AdminResource.java new file mode 100644 index 0000000000..2211b4cb7b --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/AdminResource.java @@ -0,0 +1,18 @@ +package io.quarkus.ts.security.webauthn.api; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/admin") +public class AdminResource { + + @GET + @RolesAllowed("admin") + @Produces(MediaType.TEXT_PLAIN) + public String adminResource() { + return "admin"; + } +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java new file mode 100644 index 0000000000..40fdf3363b --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/LoginResource.java @@ -0,0 +1,110 @@ +package io.quarkus.ts.security.webauthn.api; + +import jakarta.inject.Inject; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestForm; + +import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.security.webauthn.WebAuthnLoginResponse; +import io.quarkus.security.webauthn.WebAuthnRegisterResponse; +import io.quarkus.security.webauthn.WebAuthnSecurity; +import io.quarkus.ts.security.webauthn.model.User; +import io.quarkus.ts.security.webauthn.model.WebAuthnCredential; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.auth.webauthn.Authenticator; +import io.vertx.ext.web.RoutingContext; + +@Path("") +public class LoginResource { + + @Inject + WebAuthnSecurity webAuthnSecurity; + + @Path("/login") + @POST + @ReactiveTransactional + public Uni login(@RestForm String userName, + @BeanParam WebAuthnLoginResponse webAuthnResponse, + RoutingContext ctx) { + // Input validation + if (userName == null || userName.isEmpty() + || !webAuthnResponse.isSet() + || !webAuthnResponse.isValid()) { + return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); + } + + Uni userUni = User.findByUserName(userName); + return userUni.flatMap(user -> { + if (user == null) { + // Invalid user + return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); + } + Uni authenticator = this.webAuthnSecurity.login(webAuthnResponse, ctx); + + return authenticator + // bump the auth counter + .invoke(auth -> user.webAuthnCredential.counter = auth.getCounter()) + .map(auth -> { + // make a login cookie + this.webAuthnSecurity.rememberUser(auth.getUserName(), ctx); + return Response.ok().build(); + }) + // handle login failure + .onFailure().recoverWithItem(x -> { + // make a proper error response + return Response.status(Response.Status.BAD_REQUEST).build(); + }); + + }); + } + + @Path("/register") + @POST + @ReactiveTransactional + public Uni register(@RestForm String userName, + @BeanParam WebAuthnRegisterResponse webAuthnResponse, + RoutingContext ctx) { + // Input validation + if (userName == null || userName.isEmpty() + || !webAuthnResponse.isSet() + || !webAuthnResponse.isValid()) { + return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); + } + + Uni userUni = User.findByUserName(userName); + return userUni.flatMap(user -> { + if (user != null) { + // Duplicate user + return Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()); + } + Uni authenticator = this.webAuthnSecurity.register(webAuthnResponse, ctx); + + return authenticator + // store the user + .flatMap(auth -> { + User newUser = new User(); + newUser.userName = auth.getUserName(); + WebAuthnCredential credential = new WebAuthnCredential(auth, newUser); + return credential.persist() + .flatMap(c -> newUser. persist()); + + }) + .map(newUser -> { + // make a login cookie + this.webAuthnSecurity.rememberUser(newUser.userName, ctx); + return Response.ok().build(); + }) + // handle login failure + .onFailure().recoverWithItem(x -> { + // make a proper error response + return Response.status(Response.Status.BAD_REQUEST).build(); + }); + + }); + } + +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/PublicResource.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/PublicResource.java new file mode 100644 index 0000000000..42fa056e0b --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/PublicResource.java @@ -0,0 +1,27 @@ +package io.quarkus.ts.security.webauthn.api; + +import java.security.Principal; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.SecurityContext; + +@Path("/api/public") +public class PublicResource { + @GET + @Produces(MediaType.TEXT_PLAIN) + public String publicResource() { + return "public"; + } + + @GET + @Path("/me") + @Produces(MediaType.TEXT_PLAIN) + public String me(@Context SecurityContext securityContext) { + Principal user = securityContext.getUserPrincipal(); + return user != null ? user.getName() : ""; + } +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/UserResource.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/UserResource.java new file mode 100644 index 0000000000..efb21ead1a --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/api/UserResource.java @@ -0,0 +1,17 @@ +package io.quarkus.ts.security.webauthn.api; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +@Path("/api/users") +public class UserResource { + @GET + @RolesAllowed("user") + @Path("/me") + public String me(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/User.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/User.java new file mode 100644 index 0000000000..84445e0eea --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/User.java @@ -0,0 +1,24 @@ +package io.quarkus.ts.security.webauthn.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +import io.quarkus.hibernate.reactive.panache.PanacheEntity; +import io.smallrye.mutiny.Uni; + +@Table(name = "user_table") +@Entity +public class User extends PanacheEntity { + + @Column(unique = true) + public String userName; + + @OneToOne(mappedBy = "user") + public WebAuthnCredential webAuthnCredential; + + public static Uni findByUserName(String userName) { + return find("userName", userName).firstResult(); + } +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java new file mode 100644 index 0000000000..2d5ae828ce --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCertificate.java @@ -0,0 +1,17 @@ +package io.quarkus.ts.security.webauthn.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; + +import io.quarkus.hibernate.reactive.panache.PanacheEntity; + +@Entity +public class WebAuthnCertificate extends PanacheEntity { + @ManyToOne + public WebAuthnCredential webAuthnCredential; + + /** + * The list of X509 certificates encoded as base64url. + */ + public String base64X509Certificate; +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java new file mode 100644 index 0000000000..28ac6369a8 --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/model/WebAuthnCredential.java @@ -0,0 +1,121 @@ +package io.quarkus.ts.security.webauthn.model; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import io.quarkus.hibernate.reactive.panache.PanacheEntity; +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"; + + /** + * 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 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 webAuthnx509Certificates = new ArrayList<>(); + + public String fmt; + + // 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 x509VCertificate : authenticator.getAttestationCertificates().getX5c()) { + WebAuthnCertificate cert = new WebAuthnCertificate(); + cert.base64X509Certificate = x509VCertificate; + cert.webAuthnCredential = this; + this.webAuthnx509Certificates.add(cert); + } + } + this.user = user; + user.webAuthnCredential = this; + } + + public static Uni createWebAuthnCredential(Authenticator authenticator, User user) { + WebAuthnCredential credential = new WebAuthnCredential(authenticator, user); + credential.persistAndFlush(); + user.webAuthnCredential = credential; + user.persistAndFlush(); + return Uni.createFrom().item(credential); + } + + public static Uni> findByUserName(String userName) { + return list("userName", userName); + } + + public static Uni> findByCredID(String credID) { + return list("credID", credID); + } + + public Uni fetch(T association) { + return getSession().flatMap(session -> session.fetch(association)); + } +} diff --git a/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java new file mode 100644 index 0000000000..e737c2e25b --- /dev/null +++ b/security/webauthn/src/main/java/io/quarkus/ts/security/webauthn/security/MyWebAuthnSetup.java @@ -0,0 +1,102 @@ +package io.quarkus.ts.security.webauthn.security; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.ts.security.webauthn.model.User; +import io.quarkus.ts.security.webauthn.model.WebAuthnCertificate; +import io.quarkus.ts.security.webauthn.model.WebAuthnCredential; +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 + @Override + public Uni> findWebAuthnCredentialsByUserName(String userName) { + return WebAuthnCredential.findByUserName(userName) + .flatMap(MyWebAuthnSetup::toAuthenticators); + } + + @ReactiveTransactional + @Override + public Uni> findWebAuthnCredentialsByCredID(String credID) { + return WebAuthnCredential.findByCredID(credID) + .flatMap(MyWebAuthnSetup::toAuthenticators); + } + + @ReactiveTransactional + @Override + public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + 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)); + } + return Uni.combine().all().unis(ret).combinedWith(f -> (List) f); + } + + private static Uni toAuthenticator(WebAuthnCredential credential) { + return credential.fetch(credential.webAuthnx509Certificates) + .map(x5c -> { + Authenticator ret = new Authenticator(); + ret.setAaguid(credential.aaguid); + AttestationCertificates attestationCertificates = new AttestationCertificates(); + attestationCertificates.setAlg(credential.alg); + List x509CertificatesList = new ArrayList<>(x5c.size()); + for (WebAuthnCertificate webAuthnCertificate : x5c) { + x509CertificatesList.add(webAuthnCertificate.base64X509Certificate); + } + 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; + }); + } + + @Override + public Set getRoles(String userId) { + if (userId.equals("admin")) { + Set ret = new HashSet<>(); + ret.add("user"); + ret.add("admin"); + return ret; + } + return Collections.singleton("user"); + } +} diff --git a/security/webauthn/src/main/resources/META-INF/resources/index.html b/security/webauthn/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000000..85b991b0f6 --- /dev/null +++ b/security/webauthn/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,120 @@ + + + + + Login + + + + + + +
+
+

Status

+
+
+
+

Login

+

+
+ +

+
+
+

Register

+

+
+
+
+ +

+
+
+ + + diff --git a/security/webauthn/src/main/resources/application.properties b/security/webauthn/src/main/resources/application.properties new file mode 100644 index 0000000000..b89856e157 --- /dev/null +++ b/security/webauthn/src/main/resources/application.properties @@ -0,0 +1,3 @@ +quarkus.hibernate-orm.database.generation=drop-and-create + + diff --git a/security/webauthn/src/test/java/AbstractWebAuthnTest.java b/security/webauthn/src/test/java/AbstractWebAuthnTest.java new file mode 100644 index 0000000000..b603a6becf --- /dev/null +++ b/security/webauthn/src/test/java/AbstractWebAuthnTest.java @@ -0,0 +1,228 @@ +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import io.quarkus.test.bootstrap.RestService; +import io.restassured.RestAssured; +import io.restassured.filter.Filter; +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import io.vertx.core.json.JsonObject; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public abstract class AbstractWebAuthnTest { + + protected abstract RestService getApp(); + + private static final String REGISTER_URL = "/q/webauthn/register"; + private static final String REGISTER_CALLBACK_URL = "/q/webauthn/callback"; + private static final String LOGIN_URL = "/q/webauthn/login"; + private static final String LOGOUT_URL = "/q/webauthn/logout"; + + private static final String PUBLIC_API_URL = "/api/public"; + private static final String PUBLIC_ME_API_URL = "/api/public/me"; + private static final String USER_API_URL = "/api/users/me"; + private static final String ADMIN_API_URL = "/api/admin"; + + private static final String USERNAME = "Roosvelt"; + + private static Filter cookieFilter; + + enum User { + USER, + ADMIN; + } + + @BeforeAll + public static void setup() { + cookieFilter = new CookieFilter(); + + } + + @Test + @Order(1) + public void checkLogoutInitial() { + verifyLoggedOut(cookieFilter); + } + + @Test + @Order(2) + public void checkAdminAPIWithoutUser() { + given() + .redirects().follow(false) + .get(ADMIN_API_URL) + .then() + .statusCode(302); + } + + @Test + @Order(3) + public void checkMeAPIWithoutUser() { + given() + .redirects().follow(false) + .get(USER_API_URL) + .then() + .statusCode(302); + } + + @Test + @Order(4) + public void checkPublicAPI() { + given() + .get(PUBLIC_API_URL) + .then() + .statusCode(200) + .body(Matchers.is("public")); + } + + @Test + @Order(5) + public void testRegisterWebAuthnUser() { + MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(); + String challenge = getChallenge(USERNAME, cookieFilter); + JsonObject registrationJson = myWebAuthnHardware.makeRegistrationJson(challenge); + invokeCallback(registrationJson, cookieFilter); + verifyLoggedIn(cookieFilter, USERNAME, User.USER); + invokeUserLogout(); + + } + + @Test + @Order(6) + public void testRegisterSameUserName() { + MyWebAuthnHardware myWebAuthnHardware = new MyWebAuthnHardware(); + String challenge = getChallenge(USERNAME, cookieFilter); + JsonObject registrationJson = myWebAuthnHardware.makeRegistrationJson(challenge); + invokeCallback(registrationJson, cookieFilter); + verifyLoggedIn(cookieFilter, USERNAME, User.USER); + + } + + @Test + @Order(7) + public void testFailLoginWithFakeRegisterUser() { + invokeUserLogout(); + String newUserName = "Kipchoge"; + ExtractableResponse response = given().filter(cookieFilter) + .contentType(ContentType.JSON) + .body("{\"name\": \"" + newUserName + "\"}") + .post(REGISTER_URL) + .then() + .statusCode(is(200)).extract(); + given().filter(cookieFilter) + .get(PUBLIC_ME_API_URL) + .then() + .statusCode(200) + .body(Matchers.is("")); + } + + public static void invokeCallback(JsonObject registration, Filter cookieFilter) { + RestAssured + .given().body(registration.encode()).filter(cookieFilter).contentType(ContentType.JSON).log() + .ifValidationFails().post(REGISTER_CALLBACK_URL, new Object[0]).then().statusCode(204).log() + .ifValidationFails().cookie("_quarkus_webauthn_challenge", Matchers.is("")) + .cookie("_quarkus_webauthn_username", Matchers.is("")).cookie("quarkus-credential", Matchers.notNullValue()); + + } + + public static String getChallenge(String userName, Filter cookieFilter) { + JsonObject registerJson = new JsonObject().put("name", userName); + ExtractableResponse response = given() + .body(registerJson.encode()) + .contentType(ContentType.JSON) + .filter(cookieFilter) + .post(REGISTER_URL) + .then() + .statusCode(200) + .cookie("_quarkus_webauthn_challenge", Matchers.notNullValue()) + .cookie("_quarkus_webauthn_username", Matchers.notNullValue()).extract(); + JsonObject responseJson = new JsonObject(response.asString()); + String challenge = responseJson.getString("challenge"); + Assertions.assertNotNull(challenge); + return challenge; + } + + private void verifyLoggedIn(Filter cookieFilter, String userName, User user) { + + // public API still good + given().filter(cookieFilter) + .get(PUBLIC_API_URL) + .then() + .statusCode(200) + .body(Matchers.is("public")); + // public API user name + given().filter(cookieFilter) + .get(PUBLIC_ME_API_URL) + .then() + .statusCode(200) + .body(Matchers.is(userName)); + + // user API accessible + given().filter(cookieFilter) + .get(USER_API_URL) + .then() + .statusCode(200) + .body(Matchers.is(userName)); + + //admin API + if (user == User.ADMIN) { + RestAssured.given().filter(cookieFilter) + .when() + .get("/api/admin") + .then() + .statusCode(200) + .body(Matchers.is("admin")); + } else { + RestAssured.given().filter(cookieFilter) + .when() + .get("/api/admin") + .then() + .statusCode(403); + } + + } + + private void verifyLoggedOut(Filter cookieFilter) { + // public API still good + given().filter(cookieFilter) + .get(PUBLIC_API_URL) + .then() + .statusCode(200) + .body(Matchers.is("public")); + // public API user name + given().filter(cookieFilter) + .get(PUBLIC_ME_API_URL) + .then() + .statusCode(200) + .body(Matchers.is("")); + + // user API not accessible + given() + .filter(cookieFilter) + .redirects().follow(false) + .get(USER_API_URL) + .then() + .statusCode(302) + .header("Location", Matchers.matchesRegex(getApp().getHost() + "(:\\d+)?/login.html")); + + } + + public void invokeUserLogout() { + given() + .filter(cookieFilter) + .redirects() + .follow(false) + .get(LOGOUT_URL) + .then() + .statusCode(302) + .cookie("quarkus-credential", Matchers.is("")); + } +} diff --git a/security/webauthn/src/test/java/CookieFilter.java b/security/webauthn/src/test/java/CookieFilter.java new file mode 100644 index 0000000000..184a843c92 --- /dev/null +++ b/security/webauthn/src/test/java/CookieFilter.java @@ -0,0 +1,101 @@ +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import org.apache.http.Header; +import org.apache.http.client.CookieStore; +import org.apache.http.cookie.Cookie; +import org.apache.http.cookie.CookieOrigin; +import org.apache.http.cookie.CookieSpec; +import org.apache.http.cookie.MalformedCookieException; +import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.impl.cookie.RFC6265StrictSpec; +import org.apache.http.message.BasicHeader; + +import io.restassured.filter.Filter; +import io.restassured.filter.FilterContext; +import io.restassured.response.Response; +import io.restassured.specification.FilterableRequestSpecification; +import io.restassured.specification.FilterableResponseSpecification; + +public class CookieFilter implements Filter { + private final boolean allowMultipleCookiesWithTheSameName; + private final CookieSpec cookieSpec; + private final BasicCookieStore cookieStore; + + /** + * Create an instance of {@link CookieFilter} that will prevent cookies with the same name to be sent twice. + * + * @see CookieFilter#CookieFilter(boolean) + */ + public CookieFilter() { + this(false); + } + + /** + * Create an instance of {@link CookieFilter} that allows specifying whether or not it should accept (and thus send) + * multiple cookies with the same name. + * Default is false. + * + * @param allowMultipleCookiesWithTheSameName Specify whether or not to allow found two cookies with same name eg. + * JSESSIONID with different paths. + */ + public CookieFilter(boolean allowMultipleCookiesWithTheSameName) { + this.allowMultipleCookiesWithTheSameName = allowMultipleCookiesWithTheSameName; + this.cookieSpec = new RFC6265StrictSpec(); + this.cookieStore = new BasicCookieStore(); + } + + public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, + FilterContext ctx) { + + final CookieOrigin cookieOrigin = cookieOriginFromUri(requestSpec.getURI()); + for (Cookie cookie : cookieStore.getCookies()) { + if (cookieSpec.match(cookie, cookieOrigin) + && allowMultipleCookiesWithTheSameNameOrCookieNotPreviouslyDefined(requestSpec, cookie)) { + requestSpec.cookie(cookie.getName(), cookie.getValue()); + } + } + + final Response response = ctx.next(requestSpec, responseSpec); + + List responseCookies = extractResponseCookies(response, cookieOrigin); + cookieStore.addCookies(responseCookies.toArray(new Cookie[0])); + return response; + } + + private boolean allowMultipleCookiesWithTheSameNameOrCookieNotPreviouslyDefined(FilterableRequestSpecification requestSpec, + Cookie cookie) { + return allowMultipleCookiesWithTheSameName || !requestSpec.getCookies().hasCookieWithName(cookie.getName()); + } + + private List extractResponseCookies(Response response, CookieOrigin cookieOrigin) { + + List cookies = new ArrayList(); + for (String cookieValue : response.getHeaders().getValues("Set-Cookie")) { + Header setCookieHeader = new BasicHeader("Set-Cookie", cookieValue); + try { + cookies.addAll(cookieSpec.parse(setCookieHeader, cookieOrigin)); + } catch (MalformedCookieException ignored) { + } + } + return cookies; + } + + private CookieOrigin cookieOriginFromUri(String uri) { + + try { + URL parsedUrl = new URL(uri); + int port = parsedUrl.getPort() != -1 ? parsedUrl.getPort() : 80; + return new CookieOrigin( + parsedUrl.getHost(), port, parsedUrl.getPath(), "https".equals(parsedUrl.getProtocol())); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } + + public CookieStore getCookieStore() { + return cookieStore; + } +} diff --git a/security/webauthn/src/test/java/MySqlWebAuthnIT.java b/security/webauthn/src/test/java/MySqlWebAuthnIT.java new file mode 100644 index 0000000000..378af80ff0 --- /dev/null +++ b/security/webauthn/src/test/java/MySqlWebAuthnIT.java @@ -0,0 +1,26 @@ +import io.quarkus.test.bootstrap.MySqlService; +import io.quarkus.test.bootstrap.RestService; +import io.quarkus.test.scenarios.QuarkusScenario; +import io.quarkus.test.services.Container; +import io.quarkus.test.services.QuarkusApplication; + +@QuarkusScenario +public class MySqlWebAuthnIT extends AbstractWebAuthnTest { + + private static final int MYSQL_PORT = 3306; + + @Container(image = "${mysql.80.image}", port = MYSQL_PORT, expectedLog = "Only MySQL server logs after this point") + static MySqlService database = new MySqlService(); + + @QuarkusApplication + static RestService app = new RestService().withProperties("mysql.properties") + .withProperty("quarkus.datasource.username", database::getUser) + .withProperty("quarkus.datasource.password", database::getPassword) + .withProperty("quarkus.datasource.reactive.url", database::getReactiveUrl); + + @Override + protected RestService getApp() { + return app; + } + +} diff --git a/security/webauthn/src/test/java/MyWebAuthnHardware.java b/security/webauthn/src/test/java/MyWebAuthnHardware.java new file mode 100644 index 0000000000..5152ffedf7 --- /dev/null +++ b/security/webauthn/src/test/java/MyWebAuthnHardware.java @@ -0,0 +1,121 @@ +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Base64; +import java.util.Random; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; + +import io.quarkus.test.bootstrap.Protocol; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.impl.Codec; + +public class MyWebAuthnHardware { + private KeyPair keyPair; + private String id; + private byte[] credID; + + public MyWebAuthnHardware() { + generateKeyPair(); + } + + private void generateKeyPair() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + this.keyPair = generator.generateKeyPair(); + } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + + this.credID = new byte[32]; + new Random().nextBytes(this.credID); + this.id = Base64.getUrlEncoder().withoutPadding().encodeToString(this.credID); + } + + public JsonObject makeRegistrationJson(String challenge) { + JsonObject clientData = (new JsonObject()).put("type", "webauthn.create").put("challenge", challenge) + .put("origin", MySqlWebAuthnIT.app.getURI(Protocol.HTTP).getHost()).put("crossOrigin", false); + String clientDataEncoded = Base64.getUrlEncoder().encodeToString(clientData.encode().getBytes(StandardCharsets.UTF_8)); + byte[] authBytes = this.makeAuthBytes(); + CBORFactory cborFactory = new CBORFactory(); + ByteArrayOutputStream byteWriter = new ByteArrayOutputStream(); + + try { + JsonGenerator generator = cborFactory.createGenerator(byteWriter); + generator.writeStartObject(); + generator.writeStringField("fmt", "none"); + generator.writeObjectFieldStart("attStmt"); + generator.writeEndObject(); + generator.writeBinaryField("authData", authBytes); + generator.writeEndObject(); + generator.close(); + } catch (IOException var8) { + throw new RuntimeException(var8); + } + + String attestationObjectEncoded = Base64.getUrlEncoder().encodeToString(byteWriter.toByteArray()); + return (new JsonObject()) + .put("id", this.id).put("rawId", this.id).put("response", (new JsonObject()) + .put("attestationObject", attestationObjectEncoded).put("clientDataJSON", clientDataEncoded)) + .put("type", "public-key"); + } + + private byte[] makeAuthBytes() { + int counter = 1; + Buffer buffer = Buffer.buffer(); + String rpDomain = MySqlWebAuthnIT.app.getURI(Protocol.HTTP).getHost(); + + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException var19) { + throw new RuntimeException(var19); + } + + byte[] rpIdHash = md.digest(rpDomain.getBytes(StandardCharsets.UTF_8)); + buffer.appendBytes(rpIdHash); + byte flags = 65; + buffer.appendByte(flags); + long signCounter = counter++; + buffer.appendUnsignedInt(signCounter); + String aaguidString = "00000000-0000-0000-0000-000000000000"; + String aaguidStringShort = aaguidString.replace("-", ""); + byte[] aaguid = Codec.base16Decode(aaguidStringShort); + buffer.appendBytes(aaguid); + buffer.appendUnsignedShort(this.credID.length); + buffer.appendBytes(this.credID); + ECPublicKey publicKey = (ECPublicKey) this.keyPair.getPublic(); + Base64.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(); + generator.writeNumberField("1", 2); + generator.writeNumberField("3", -7); + generator.writeNumberField("-1", 1); + generator.writeStringField("-2", x); + generator.writeStringField("-3", y); + generator.writeEndObject(); + generator.close(); + } catch (IOException var18) { + throw new RuntimeException(var18); + } + buffer.appendBytes(byteWriter.toByteArray()); + return buffer.getBytes(); + } + +} diff --git a/security/webauthn/src/test/java/OpenShiftWebAuthnIT.java b/security/webauthn/src/test/java/OpenShiftWebAuthnIT.java new file mode 100644 index 0000000000..fb2ad75f71 --- /dev/null +++ b/security/webauthn/src/test/java/OpenShiftWebAuthnIT.java @@ -0,0 +1,6 @@ +import io.quarkus.test.scenarios.OpenShiftScenario; + +@OpenShiftScenario +public class OpenShiftWebAuthnIT extends MySqlWebAuthnIT { +// TODO Review the OpenshiftScenario after this issue will be solved: https://github.com/quarkus-qe/quarkus-test-suite/issues/1500 +} diff --git a/security/webauthn/src/test/resources/mysql.properties b/security/webauthn/src/test/resources/mysql.properties new file mode 100644 index 0000000000..e72de735ab --- /dev/null +++ b/security/webauthn/src/test/resources/mysql.properties @@ -0,0 +1 @@ +quarkus.datasource.db-kind=mysql