From 57bf2f86a03dbc5c5cdf91c2c7621131f088f0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Mon, 18 Nov 2024 16:20:48 +0100 Subject: [PATCH] feat(security,vertx-http): Support management interface without basic auth --- .../management-interface-reference.adoc | 32 ++++++ ...odeFlowManagementInterfaceDevModeTest.java | 85 ++++++++++++++ .../ManagementInterfaceSecurityProcessor.java | 2 +- .../ManagementInterfaceBasicAuthTest.java | 100 +++++++++++++++++ .../management/ManagementAuthConfig.java | 7 ++ .../ManagementInterfaceSecurityRecorder.java | 6 +- .../ManagementInterfaceCustomRoute.java | 25 +++++ .../BearerTokenManagementInterfaceTest.java | 106 ++++++++++++++++++ 8 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowManagementInterfaceDevModeTest.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/ManagementInterfaceBasicAuthTest.java create mode 100644 integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/ManagementInterfaceCustomRoute.java create mode 100644 integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenManagementInterfaceTest.java diff --git a/docs/src/main/asciidoc/management-interface-reference.adoc b/docs/src/main/asciidoc/management-interface-reference.adoc index 2d2badaa9e4da..ab964f1491cac 100644 --- a/docs/src/main/asciidoc/management-interface-reference.adoc +++ b/docs/src/main/asciidoc/management-interface-reference.adoc @@ -224,6 +224,38 @@ Until https://github.com/knative/serving/issues/8471[KNative#8471] is resolved, == Security +Security for the management endpoints exposed in the separate HTTP server needs to be enabled explicitly like in the example below: + +[source, properties] +---- +quarkus.management.enabled=true +quarkus.management.auth.enabled=true +---- + +Once enabled, you can use same authentication mechanism you have already configured for the main server, or use a different one. +All of these mechanisms are detailed in the xref:security-authentication-mechanisms.adoc[Authentication mechanisms in Quarkus] guide. + +=== Use HTTP Security Policy to enable path-based authentication + +The following configuration example demonstrates how you can enforce a single selectable authentication mechanism for a given request path: + +[source,properties] +---- +quarkus.management.auth.permission.metrics.paths=/q/metrics/* +quarkus.management.auth.permission.metrics.policy=authenticated +quarkus.management.auth.permission.metrics.auth-mechanism=basic <1> + +quarkus.management.auth.permission.health.paths=/q/health/* +quarkus.management.auth.permission.health.policy=authenticated +quarkus.management.auth.permission.health.auth-mechanism=bearer <2> +---- +<1> The metric endpoints will be only accessible with the <>. +<2> If the Quarkus OIDC extension is present, the health endpoints will be authenticated +by the xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication]. + +[[basic-auth]] +=== Basic authentication + You can enable _basic_ authentication using the following properties: [source, properties] diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowManagementInterfaceDevModeTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowManagementInterfaceDevModeTest.java new file mode 100644 index 0000000000000..4f35426735e04 --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowManagementInterfaceDevModeTest.java @@ -0,0 +1,85 @@ +package io.quarkus.oidc.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; + +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; + +import org.htmlunit.SilentCssErrorHandler; +import org.htmlunit.TextPage; +import org.htmlunit.WebClient; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.test.QuarkusDevModeTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; +import io.quarkus.vertx.http.ManagementInterface; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; + +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +public class CodeFlowManagementInterfaceDevModeTest { + + @RegisterExtension + static final QuarkusDevModeTest test = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(CodeFlowManagementRoute.class) + .addAsResource( + new StringAsset(""" + quarkus.management.enabled=true + quarkus.management.auth.enabled=true + quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus + quarkus.oidc.client-id=quarkus-web-app + quarkus.oidc.credentials.secret=secret + quarkus.oidc.application-type=web-app + quarkus.management.auth.permission.code-flow.paths=/code-flow + quarkus.management.auth.permission.code-flow.policy=authenticated + quarkus.management.auth.permission.code-flow.auth-mechanism=code + quarkus.log.category."org.htmlunit".level=ERROR + quarkus.log.file.enable=true + """), + "application.properties")); + + @Test + public void testAuthenticatedHttpPermission() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://0.0.0.0:9000/code-flow"); + + assertEquals("Sign in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + TextPage textPage = loginForm.getInputByName("login").click(); + + assertEquals("alice", textPage.getContent()); + + webClient.getCookieManager().clearCookies(); + } + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + + @Singleton + public static class CodeFlowManagementRoute { + void setupManagementRoutes(@Observes ManagementInterface managementInterface, IdentityProviderManager ipm) { + managementInterface.router().get("/code-flow").handler(rc -> QuarkusHttpUser + .getSecurityIdentity(rc, ipm) + .map(i -> i.getPrincipal().getName()) + .subscribe().with(rc::end, err -> rc.fail(500, err))); + } + } +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java index 00f5f12a2f340..cf32849fcc2d6 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java @@ -74,7 +74,7 @@ void setupAuthenticationMechanisms( void createManagementAuthMechHandler(ManagementInterfaceSecurityRecorder recorder, Capabilities capabilities, ManagementInterfaceBuildTimeConfig buildTimeConfig, BuildProducer managementAuthMechHandlerProducer) { - if (buildTimeConfig.auth.basic.orElse(false) && capabilities.isPresent(Capability.SECURITY)) { + if (buildTimeConfig.auth.enabled && capabilities.isPresent(Capability.SECURITY)) { managementAuthMechHandlerProducer.produce(new ManagementAuthenticationHandlerBuildItem( recorder.managementAuthenticationHandler(buildTimeConfig.auth.proactive))); } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/ManagementInterfaceBasicAuthTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/ManagementInterfaceBasicAuthTest.java new file mode 100644 index 0000000000000..c4250c4ec3595 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/ManagementInterfaceBasicAuthTest.java @@ -0,0 +1,100 @@ +package io.quarkus.vertx.http.security; + +import static org.hamcrest.Matchers.equalTo; + +import java.net.URL; +import java.util.function.Supplier; + +import jakarta.enterprise.event.Observes; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.vertx.http.ManagementInterface; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.restassured.RestAssured; + +/** + * Tests that basic authentication is enabled for the management interface when no other + * mechanism is available. + */ +public class ManagementInterfaceBasicAuthTest { + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(new Supplier<>() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClasses(TestIdentityProvider.class, TestTrustedIdentityProvider.class, TestIdentityController.class, + ManagementPathHandler.class) + .addAsResource(new StringAsset(""" + quarkus.management.enabled=true + quarkus.management.auth.enabled=true + quarkus.management.auth.policy.r1.roles-allowed=admin + quarkus.management.auth.permission.roles1.paths=/admin + quarkus.management.auth.permission.roles1.policy=r1 + """), "application.properties"); + } + }); + + @TestHTTPResource(value = "/metrics", management = true) + URL metrics; + + @BeforeAll + public static void setup() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin"); + } + + @Test + public void testBasicAuthSuccess() { + RestAssured + .given() + .auth().preemptive().basic("admin", "admin") + .redirects().follow(false) + .when() + .get(metrics) + .then() + .assertThat() + .statusCode(200) + .body(equalTo("admin:" + metrics.getPath())); + + } + + @Test + public void testBasicAuthFailure() { + RestAssured + .given() + .auth().preemptive().basic("admin", "wrongpassword") + .redirects().follow(false) + .get(metrics) + .then() + .assertThat() + .statusCode(401); + + } + + public static class ManagementPathHandler { + + void setup(@Observes ManagementInterface mi) { + mi.router().get("/q/metrics").handler(event -> { + QuarkusHttpUser user = (QuarkusHttpUser) event.user(); + StringBuilder ret = new StringBuilder(); + if (user != null) { + ret.append(user.getSecurityIdentity().getPrincipal().getName()); + } + ret.append(":"); + ret.append(event.normalizedPath()); + event.response().end(ret.toString()); + }); + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementAuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementAuthConfig.java index a22db7e139359..6aeda761ce7a0 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementAuthConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementAuthConfig.java @@ -10,6 +10,13 @@ */ @ConfigGroup public class ManagementAuthConfig { + + /** + * If authentication for the management interface should be enabled. + */ + @ConfigItem(defaultValue = "${quarkus.management.auth.basic:false}") + public boolean enabled; + /** * If basic auth should be enabled. * diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementInterfaceSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementInterfaceSecurityRecorder.java index c0d1f070e52cd..7ef7f637982ff 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementInterfaceSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementInterfaceSecurityRecorder.java @@ -33,14 +33,12 @@ public void initializeAuthenticationHandler(RuntimeValue public Handler permissionCheckHandler() { return new Handler() { - volatile ManagementInterfaceHttpAuthorizer authorizer; + private volatile ManagementInterfaceHttpAuthorizer authorizer; @Override public void handle(RoutingContext event) { if (authorizer == null) { - if (authorizer == null) { - authorizer = CDI.current().select(ManagementInterfaceHttpAuthorizer.class).get(); - } + authorizer = CDI.current().select(ManagementInterfaceHttpAuthorizer.class).get(); } authorizer.checkPermission(event); } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/ManagementInterfaceCustomRoute.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/ManagementInterfaceCustomRoute.java new file mode 100644 index 0000000000000..6bd24caebfdd9 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/ManagementInterfaceCustomRoute.java @@ -0,0 +1,25 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.event.Observes; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.vertx.http.ManagementInterface; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.ext.web.RoutingContext; + +public class ManagementInterfaceCustomRoute { + + void init(@Observes ManagementInterface mi, IdentityProviderManager ipm) { + mi.router().get("/q/management-secured").handler(rc -> QuarkusHttpUser + .getSecurityIdentity(rc, ipm) + .map(i -> i.isAnonymous() ? "anonymous" : i.getPrincipal().getName()) + .subscribe().with(rc::end, err -> fail(rc))); + mi.router().get("/q/management-public").handler(rc -> rc.end("this route is public")); + } + + private static void fail(RoutingContext rc) { + rc.fail(500, + new IllegalStateException("This route must only be accessible by authenticated user with 'management' role")); + } + +} diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenManagementInterfaceTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenManagementInterfaceTest.java new file mode 100644 index 0000000000000..4bb1d998450ca --- /dev/null +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenManagementInterfaceTest.java @@ -0,0 +1,106 @@ +package io.quarkus.it.keycloak; + +import java.net.URL; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import io.smallrye.jwt.algorithm.SignatureAlgorithm; +import io.smallrye.jwt.build.Jwt; + +@TestProfile(BearerTokenManagementInterfaceTest.ManagementInterfaceProfile.class) +@QuarkusTest +public class BearerTokenManagementInterfaceTest { + + @TestHTTPResource(value = "/management-secured", management = true) + URL managementSecured; + + @TestHTTPResource(value = "/management-public", management = true) + URL managementPublic; + + @Test + public void testPublicManagementRoute() { + // anonymous request to a public route -> success + RestAssured.given() + .when().get(managementPublic) + .then() + .statusCode(200) + .body(Matchers.is("this route is public")); + // route is public, but proactive auth is enabled, credentials are sent and RS256 is rejected + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.RS256, "admin")) + .when().get(managementPublic) + .then() + .statusCode(401); + // PS256 is OK, 'management' roles is missing but no roles are required -> success + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.PS256, "admin")) + .when().get(managementPublic) + .then() + .statusCode(200) + .body(Matchers.is("this route is public")); + } + + @Test + public void testManagementRouteSecuredWithHttpPerm() { + // RS256 is rejected + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.RS256, "admin")) + .when().get(managementSecured) + .then() + .statusCode(401); + // PS256 is OK but 'management' roles is missing + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.PS256, "admin")) + .when().get(managementSecured) + .then() + .statusCode(403); + // PS256 is OK but 'management' roles is missing + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.PS256, "management")) + .when().get(managementSecured) + .then() + .statusCode(200) + .body(Matchers.containsString("admin")); + } + + @Test + public void testMainRouterAuthenticationWorks() { + // RS256 is rejected + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.RS256, "admin")) + .when().get("/api/admin/bearer-required-algorithm") + .then() + .statusCode(401); + // PS256 is OK + RestAssured.given().auth().oauth2(getAccessToken("admin", SignatureAlgorithm.PS256, "admin")) + .when().get("/api/admin/bearer-required-algorithm") + .then() + .statusCode(200) + .body(Matchers.containsString("admin")); + } + + private static String getAccessToken(String userName, SignatureAlgorithm alg, String... roles) { + return Jwt.preferredUserName(userName) + .groups(Set.copyOf(Arrays.asList(roles))) + .issuer("https://server.example.com") + .audience("https://service.example.com") + .jws().algorithm(alg) + .sign(); + } + + public static class ManagementInterfaceProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.management.enabled", "true", + "quarkus.management.auth.enabled", "true", + "quarkus.oidc.bearer-required-algorithm.tenant-paths", "*", + "quarkus.management.auth.permission.custom.paths", "/q/management-secured", + "quarkus.management.auth.permission.custom.policy", "management-policy", + "quarkus.management.auth.policy.management-policy.roles-allowed", "management"); + } + } +}