From ed83d4c47d41bd2b48b85e56d9dc943a903d8248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Sat, 13 Apr 2024 18:55:05 +0200 Subject: [PATCH] Support path-based auth with @TestSecurity annotation --- docs/src/main/asciidoc/security-testing.adoc | 32 ++++++ .../security/HttpCredentialTransport.java | 8 +- .../it/keycloak/MultipleAuthMechResource.java | 39 +++++++ .../src/main/resources/application.properties | 7 ++ .../TestSecurityCombiningAuthMechTest.java | 105 ++++++++++++++++++ .../QuarkusSecurityTestExtension.java | 13 ++- .../TestHttpAuthenticationMechanism.java | 16 ++- .../security/TestIdentityAssociation.java | 13 ++- .../quarkus/test/security/TestSecurity.java | 17 +++ 9 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechResource.java create mode 100644 integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java diff --git a/docs/src/main/asciidoc/security-testing.adoc b/docs/src/main/asciidoc/security-testing.adoc index 382087d19aa468..c83031ab514770 100644 --- a/docs/src/main/asciidoc/security-testing.adoc +++ b/docs/src/main/asciidoc/security-testing.adoc @@ -121,6 +121,38 @@ If it becomes necessary to test security features using both `@TestSecurity` and mechanism when none is defined), then Basic Auth needs to be enabled explicitly, for example by setting `quarkus.http.auth.basic=true` or `%test.quarkus.http.auth.basic=true`. +=== Path-based authentication + +`@TestSecurity` can also be used when xref:security-authentication-mechanisms.adoc#combining-authentication-mechanisms[authentication mechanisms must be combined]. +Example below shows how to select authentication mechanism when path-based authentication is enabled by HTTP Security +Policies or by annotations: + +[source,java] +---- +@Test +@TestSecurity(user = "testUser", roles = {"admin", "user"}, authMechanism = "basic") <1> +void basicTestMethod() { + ... +} + +@Test +@TestSecurity(user = "testUser", roles = {"admin", "user"}, authMechanism = "form") <2> +void formTestMethod() { + ... +} +---- +<1> The 'authMechanism' attribute selects Basic authentication. +<2> The 'authMechanism' attribute selects Form-based authentication. + +[source,properties] +---- +quarkus.http.auth.permission.basic.paths=/basic-only +quarkus.http.auth.permission.basic.policy=authenticated +quarkus.http.auth.permission.basic.auth-mechanism=basic <1> <2> +---- +<1> All HTTP requests to the `/basic-only` path from the `basicTestMethod` test are authenticated successfully. +<2> Same HTTP requests will fail when invoked from the `formTestMethod` test as Basic authentication is required. + == Use Wiremock for Integration Testing You can also use Wiremock to mock the authorization OAuth2 and OIDC services: diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpCredentialTransport.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpCredentialTransport.java index 2c26532da5e1ee..393a6d1ad600df 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpCredentialTransport.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpCredentialTransport.java @@ -9,7 +9,7 @@ * Authorization header * POST * - * Not that using multiple HTTP authentication mechanisms to use the same credential + * Note that using multiple HTTP authentication mechanisms to use the same credential * transport type can lead to unexpected authentication failures as they will not be able to figure out which mechanisms should * process which * request. @@ -54,7 +54,11 @@ public enum Type { /** * Authorization code, type target is the query 'code' parameter */ - AUTHORIZATION_CODE + AUTHORIZATION_CODE, + /** + * HTTP credential transport is mocked during security testing. + */ + TEST_SECURITY } @Override diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechResource.java new file mode 100644 index 00000000000000..6d45ec3b15284d --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/MultipleAuthMechResource.java @@ -0,0 +1,39 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.SecurityContext; + +import io.quarkus.oidc.BearerTokenAuthentication; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; + +@Path("multiple-auth-mech") +public class MultipleAuthMechResource { + + @GET + @Path("bearer/policy") + public String bearerPolicy(@Context SecurityContext sec) { + return sec.getUserPrincipal().getName(); + } + + @GET + @Path("basic/policy") + public String basicPolicy(@Context SecurityContext sec) { + return sec.getUserPrincipal().getName(); + } + + @BearerTokenAuthentication + @GET + @Path("bearer/annotation") + public String bearerAnnotation(@Context SecurityContext sec) { + return sec.getUserPrincipal().getName(); + } + + @BasicAuthentication + @GET + @Path("basic/annotation") + public String basicAnnotation(@Context SecurityContext sec) { + return sec.getUserPrincipal().getName(); + } +} diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 9385f1b34a6506..88bf88c41c0e16 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -182,3 +182,10 @@ quarkus.oidc.tenant-f.auth-server-url=${keycloak.url}/realms/quarkus-f quarkus.oidc.tenant-f.client-id=quarkus-app-f quarkus.oidc.tenant-f.credentials.secret=secret quarkus.oidc.tenant-f.application-type=service + +quarkus.http.auth.permission.basic-policy.paths=/multiple-auth-mech/basic/policy +quarkus.http.auth.permission.basic-policy.policy=authenticated +quarkus.http.auth.permission.basic-policy.auth-mechanism=basic +quarkus.http.auth.permission.bearer-policy.paths=/multiple-auth-mech/bearer/policy +quarkus.http.auth.permission.bearer-policy.policy=authenticated +quarkus.http.auth.permission.bearer-policy.auth-mechanism=Bearer diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java new file mode 100644 index 00000000000000..8c1f6bf5a65793 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/TestSecurityCombiningAuthMechTest.java @@ -0,0 +1,105 @@ +package io.quarkus.it.keycloak; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@TestHTTPEndpoint(MultipleAuthMechResource.class) +@QuarkusTest +public class TestSecurityCombiningAuthMechTest { + + @TestSecurity(user = "testUser", authMechanism = "basic") + @Test + public void testBasicAuthentication() { + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/policy") + .then() + .statusCode(200); + RestAssured + .given() + .contentType(ContentType.TEXT) + .redirects().follow(false) + .get("bearer/policy") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/annotation") + .then() + .statusCode(200); + RestAssured + .given() + .contentType(ContentType.TEXT) + .redirects().follow(false) + .get("bearer/annotation") + .then() + .statusCode(401); + } + + @TestSecurity(user = "testUser", authMechanism = "Bearer") + @Test + public void testBearerBasedAuthentication() { + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/policy") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("bearer/policy") + .then() + .statusCode(200); + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/annotation") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("bearer/annotation") + .then() + .statusCode(200); + } + + @TestSecurity(user = "testUser", authMechanism = "custom") + @Test + public void testCustomAuthentication() { + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/policy") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .redirects().follow(false) + .get("bearer/policy") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .get("basic/annotation") + .then() + .statusCode(401); + RestAssured + .given() + .contentType(ContentType.TEXT) + .redirects().follow(false) + .get("bearer/annotation") + .then() + .statusCode(401); + } +} diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java index a4fcd2b5a68f78..0780c0d9584b8a 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java @@ -26,10 +26,14 @@ public void afterEach(QuarkusTestMethodContext context) { try { if (getAnnotationContainer(context).isPresent()) { CDI.current().select(TestAuthController.class).get().setEnabled(true); - CDI.current().select(TestIdentityAssociation.class).get().setTestIdentity(null); + CDI.current().select(TestHttpAuthenticationMechanism.class).get().setAuthMechanism(null); + var testIdentity = CDI.current().select(TestIdentityAssociation.class).get(); + testIdentity.setTestIdentity(null); + testIdentity.setPathBasedIdentity(false); } } catch (Exception e) { - throw new RuntimeException("Unable to reset TestAuthController and TestIdentityAssociation", e); + throw new RuntimeException( + "Unable to reset TestAuthController, TestIdentityAssociation and TestHttpAuthenticationMechanism", e); } } @@ -61,6 +65,11 @@ public void beforeEach(QuarkusTestMethodContext context) { SecurityIdentity userIdentity = augment(user.build(), allAnnotations); CDI.current().select(TestIdentityAssociation.class).get().setTestIdentity(userIdentity); + if (!testSecurity.authMechanism().isEmpty()) { + CDI.current().select(TestHttpAuthenticationMechanism.class).get() + .setAuthMechanism(testSecurity.authMechanism()); + CDI.current().select(TestIdentityAssociation.class).get().setPathBasedIdentity(true); + } } } catch (Exception e) { throw new RuntimeException("Unable to setup @TestSecurity", e); diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java b/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java index a716c933e9b671..1bf6a853d50d62 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/TestHttpAuthenticationMechanism.java @@ -23,6 +23,8 @@ public class TestHttpAuthenticationMechanism implements HttpAuthenticationMechan @Inject TestIdentityAssociation testIdentityAssociation; + volatile String authMechanism = null; + @PostConstruct public void check() { if (LaunchMode.current() != LaunchMode.TEST) { @@ -47,7 +49,17 @@ public Set> getCredentialTypes() { } @Override - public HttpCredentialTransport getCredentialTransport() { - return null; + public Uni getCredentialTransport(RoutingContext context) { + return authMechanism == null ? Uni.createFrom().nullItem() + : Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.TEST_SECURITY, authMechanism)); + } + + @Override + public int getPriority() { + return 3000; + } + + void setAuthMechanism(String authMechanism) { + this.authMechanism = authMechanism; } } diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestIdentityAssociation.java b/test-framework/security/src/main/java/io/quarkus/test/security/TestIdentityAssociation.java index 85dfe76543e3be..569a9f266e8803 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestIdentityAssociation.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/TestIdentityAssociation.java @@ -28,6 +28,11 @@ public void check() { volatile SecurityIdentity testIdentity; + /** + * Whether authentication is successful only if right mechanism was used to authenticate. + */ + volatile boolean isPathBasedIdentity = false; + /** * A request scoped delegate that allows the system to function as normal when * the user has not been explicitly overridden @@ -60,7 +65,7 @@ public Uni getDeferredIdentity() { return delegate.getDeferredIdentity(); } return delegate.getDeferredIdentity().onItem() - .transform(underlying -> underlying.isAnonymous() ? testIdentity : underlying); + .transform(underlying -> underlying.isAnonymous() && !isPathBasedIdentity ? testIdentity : underlying); } @Override @@ -71,12 +76,16 @@ public SecurityIdentity getIdentity() { //the identity ends up in the routing context SecurityIdentity underlying = delegate.getIdentity(); if (underlying.isAnonymous()) { - if (testIdentity != null) { + if (testIdentity != null && !isPathBasedIdentity) { return testIdentity; } } return delegate.getIdentity(); } + + void setPathBasedIdentity(boolean pathBasedIdentity) { + isPathBasedIdentity = pathBasedIdentity; + } } @RequestScoped diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java index 102b51cf4fdbf3..40d9c2c086d8de 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java @@ -6,6 +6,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import io.quarkus.security.identity.SecurityIdentity; + @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) @Inherited @@ -26,5 +28,20 @@ */ String[] roles() default {}; + /** + * Adds attributes to a {@link SecurityIdentity} configured by this annotation. + * The attributes can be retrieved by the {@link SecurityIdentity#getAttributes()} method. + */ SecurityAttribute[] attributes() default {}; + + /** + * Selects authentication mechanism used in a path-based authentication. + * If an HTTP Security Policy is used to enable path-based authentication, + * then a {@link SecurityIdentity} will only be provided by this annotation if this attribute + * matches the 'quarkus.http.auth.permission."permissions".auth-mechanism' configuration property. + * Situation is similar when annotations are used to enable path-based authentication for Jakarta REST endpoints. + * Set this attribute to 'basic' if an HTTP request to Jakarta REST endpoint annotated with the + * {@link io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication} should be successfully authenticated. + */ + String authMechanism() default ""; }