diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 78334a851e1d3..8abab01676220 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -100,6 +100,11 @@ quarkus-resteasy-jackson-deployment test + + io.quarkus + quarkus-elytron-security-properties-file-deployment + test + diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndBearerAuthCombinationTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndBearerAuthCombinationTest.java new file mode 100644 index 0000000000000..857130a08ef65 --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndBearerAuthCombinationTest.java @@ -0,0 +1,77 @@ +package io.quarkus.oidc.test; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.oidc.BearerTokenAuthentication; +import io.quarkus.test.QuarkusDevModeTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; +import io.restassured.RestAssured; + +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +public class ImplicitBasicAuthAndBearerAuthCombinationTest { + + @RegisterExtension + static final QuarkusDevModeTest test = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(BasicBearerResource.class) + .addAsResource( + new StringAsset(""" + quarkus.security.users.embedded.enabled=true + quarkus.security.users.embedded.plain-text=true + quarkus.security.users.embedded.users.alice=alice + quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus + quarkus.oidc.client-id=quarkus-service-app + quarkus.oidc.credentials.secret=secret + quarkus.http.auth.proactive=false + """), + "application.properties")); + + @Test + public void testBasicEnabledAsSelectedWithAnnotation() { + // endpoint is annotated with 'BasicAuthentication', so basic auth must be enabled + RestAssured.given().auth().oauth2(getAccessToken()).get("/basic-bearer/bearer") + .then().statusCode(200).body(Matchers.is("alice")); + RestAssured.given().auth().basic("alice", "alice").get("/basic-bearer/basic") + .then().statusCode(204); + RestAssured.given().auth().basic("alice", "alice").get("/basic-bearer/bearer") + .then().statusCode(401); + RestAssured.given().auth().oauth2(getAccessToken()).get("/basic-bearer/basic") + .then().statusCode(401); + } + + private static String getAccessToken() { + return KeycloakTestResourceLifecycleManager.getAccessToken("alice"); + } + + @BearerTokenAuthentication + @Path("basic-bearer") + public static class BasicBearerResource { + + @Inject + JsonWebToken accessToken; + + @GET + @BasicAuthentication + @Path("basic") + public String basic() { + return accessToken.getName(); + } + + @GET + @Path("bearer") + public String bearer() { + return accessToken.getName(); + } + } + +} diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndCodeFlowAuthCombinationTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndCodeFlowAuthCombinationTest.java new file mode 100644 index 0000000000000..917cfe5b57dc1 --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ImplicitBasicAuthAndCodeFlowAuthCombinationTest.java @@ -0,0 +1,118 @@ +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.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import io.quarkus.oidc.IdToken; +import io.quarkus.test.QuarkusDevModeTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; +import io.restassured.RestAssured; + +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +public class ImplicitBasicAuthAndCodeFlowAuthCombinationTest { + + @RegisterExtension + static final QuarkusDevModeTest test = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(BasicCodeFlowResource.class) + .addAsResource( + new StringAsset(""" + quarkus.security.users.embedded.enabled=true + quarkus.security.users.embedded.plain-text=true + quarkus.security.users.embedded.users.alice=alice + 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.http.auth.permission.code-flow.paths=/basic-code-flow/code-flow + quarkus.http.auth.permission.code-flow.policy=authenticated + quarkus.http.auth.permission.code-flow.auth-mechanism=code + quarkus.http.auth.permission.basic.paths=/basic-code-flow/basic + quarkus.http.auth.permission.basic.policy=authenticated + quarkus.http.auth.permission.basic.auth-mechanism=basic + """), + "application.properties")); + + @Test + public void testBasicEnabledAsSelectedWithHttpPerm() throws IOException, InterruptedException { + // endpoint is annotated with 'BasicAuthentication', so basic auth must be enabled + RestAssured.given().auth().basic("alice", "alice").get("/basic-code-flow/basic") + .then().statusCode(204); + RestAssured.given().auth().basic("alice", "alice").redirects().follow(false) + .get("/basic-code-flow/code-flow").then().statusCode(302); + + try (final WebClient webClient = createWebClient()) { + + try { + webClient.getPage("http://localhost:8080/basic-code-flow/basic"); + fail("Exception is expected because by the basic auth is required"); + } catch (FailingHttpStatusCodeException ex) { + // Reported by Quarkus + assertEquals(401, ex.getStatusCode()); + } + HtmlPage page = webClient.getPage("http://localhost:8080/basic-code-flow/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"); + + page = loginForm.getInputByName("login").click(); + + assertEquals("alice", page.getBody().asNormalizedText()); + + webClient.getCookieManager().clearCookies(); + } + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + + private static String getAccessToken() { + return KeycloakTestResourceLifecycleManager.getAccessToken("alice"); + } + + @Path("basic-code-flow") + public static class BasicCodeFlowResource { + + @Inject + @IdToken + JsonWebToken idToken; + + @GET + @Path("basic") + public String basic() { + return idToken.getName(); + } + + @GET + @Path("code-flow") + public String codeFlow() { + return idToken.getName(); + } + } + +} diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index a79eb04049dbd..6e86f73d93de0 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -3,6 +3,8 @@ import static io.quarkus.arc.processor.DotNames.APPLICATION_SCOPED; import static io.quarkus.arc.processor.DotNames.DEFAULT_BEAN; import static io.quarkus.arc.processor.DotNames.SINGLETON; +import static io.quarkus.vertx.http.runtime.security.HttpAuthenticator.BASIC_AUTH_ANNOTATION_DETECTED; +import static io.quarkus.vertx.http.runtime.security.HttpAuthenticator.TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED; import static java.util.stream.Collectors.toMap; import java.lang.reflect.Modifier; @@ -32,15 +34,19 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; +import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.arc.processor.BeanInfo; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ApplicationIndexBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem; @@ -71,6 +77,7 @@ public class HttpSecurityProcessor { private static final DotName AUTH_MECHANISM_NAME = DotName.createSimple(HttpAuthenticationMechanism.class); private static final DotName BASIC_AUTH_MECH_NAME = DotName.createSimple(BasicAuthenticationMechanism.class); + private static final DotName BASIC_AUTH_ANNOTATION_NAME = DotName.createSimple(BasicAuthentication.class); @Record(ExecutionTime.STATIC_INIT) @BuildStep @@ -127,13 +134,46 @@ void setMtlsCertificateRoleProperties( } } + @BuildStep(onlyIf = IsApplicationBasicAuthRequired.class) + void detectBasicAuthImplicitlyRequired(HttpBuildTimeConfig buildTimeConfig, + BeanRegistrationPhaseBuildItem beanRegistrationPhaseBuildItem, ApplicationIndexBuildItem applicationIndexBuildItem, + BuildProducer systemPropertyProducer, + List eagerSecurityInterceptorBindings) { + if (makeBasicAuthMechDefaultBean(buildTimeConfig)) { + var appIndex = applicationIndexBuildItem.getIndex(); + boolean noCustomAuthMechanismsDetected = beanRegistrationPhaseBuildItem + .getContext() + .beans() + .filter(b -> b.hasType(AUTH_MECHANISM_NAME)) + .filter(BeanInfo::isClassBean) + .filter(b -> appIndex.getClassByName(b.getBeanClass()) != null) + .isEmpty(); + // we can't decide whether custom mechanisms support basic auth or not + if (noCustomAuthMechanismsDetected) { + systemPropertyProducer + .produce(new SystemPropertyBuildItem(TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED, Boolean.TRUE.toString())); + if (!eagerSecurityInterceptorBindings.isEmpty()) { + boolean basicAuthAnnotationUsed = eagerSecurityInterceptorBindings + .stream() + .map(EagerSecurityInterceptorBindingBuildItem::getAnnotationBindings) + .flatMap(Arrays::stream) + .anyMatch(BASIC_AUTH_ANNOTATION_NAME::equals); + // @BasicAuthentication is used, hence the basic authentication is required + if (basicAuthAnnotationUsed) { + systemPropertyProducer + .produce(new SystemPropertyBuildItem(BASIC_AUTH_ANNOTATION_DETECTED, Boolean.TRUE.toString())); + } + } + } + } + } + @BuildStep(onlyIf = IsApplicationBasicAuthRequired.class) AdditionalBeanBuildItem initBasicAuth(HttpBuildTimeConfig buildTimeConfig, BuildProducer annotationsTransformerProducer, BuildProducer securityInformationProducer) { - if (!buildTimeConfig.auth.form.enabled && !isMtlsClientAuthenticationEnabled(buildTimeConfig) - && !buildTimeConfig.auth.basic.orElse(false)) { + if (makeBasicAuthMechDefaultBean(buildTimeConfig)) { //if not explicitly enabled we make this a default bean, so it is the fallback if nothing else is defined annotationsTransformerProducer.produce(new AnnotationsTransformerBuildItem(AnnotationsTransformer .appliedToClass() @@ -148,7 +188,12 @@ AdditionalBeanBuildItem initBasicAuth(HttpBuildTimeConfig buildTimeConfig, return AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(BasicAuthenticationMechanism.class).build(); } - public static boolean applicationBasicAuthRequired(HttpBuildTimeConfig buildTimeConfig, + private static boolean makeBasicAuthMechDefaultBean(HttpBuildTimeConfig buildTimeConfig) { + return !buildTimeConfig.auth.form.enabled && !isMtlsClientAuthenticationEnabled(buildTimeConfig) + && !buildTimeConfig.auth.basic.orElse(false); + } + + private static boolean applicationBasicAuthRequired(HttpBuildTimeConfig buildTimeConfig, ManagementInterfaceBuildTimeConfig managementInterfaceBuildTimeConfig) { //basic auth explicitly disabled if (buildTimeConfig.auth.basic.isPresent() && !buildTimeConfig.auth.basic.get()) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java index e4ff3dec29715..79bccaec08a5c 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java @@ -14,7 +14,8 @@ public class AuthConfig { /** * If basic auth should be enabled. If both basic and form auth is enabled then basic auth will be enabled in silent mode. * - * If no authentication mechanisms are configured basic auth is the default. + * The basic auth is enabled by default if no authentication mechanisms are configured or Quarkus can safely + * determine that basic authentication is required. */ @ConfigItem public Optional basic; diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java index 84eb2f49368a4..87b39a7cdcd3b 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java @@ -23,16 +23,20 @@ import org.jboss.logging.Logger; import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.arc.Arc; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.AnonymousAuthenticationRequest; import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; import io.quarkus.security.spi.runtime.AuthenticationFailureEvent; import io.quarkus.security.spi.runtime.AuthenticationSuccessEvent; import io.quarkus.security.spi.runtime.SecurityEventHelper; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -41,6 +45,24 @@ */ @Singleton public class HttpAuthenticator { + /** + * Special handling for the basic authentication mechanism, for user convenience, we add the mechanism when: + * - not explicitly disabled or enabled + * - is default bean and not programmatically looked up because there are other authentication mechanisms + * - no custom auth mechanism is defined because then, we can't tell if user didn't provide custom impl. + * - there is a provider that supports it (if not, we inform user via the log) + *

+ * Presence of this system property means that we need to test whether: + * - there are HTTP Permissions using explicitly this mechanism + * - or {@link io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication} + */ + public static final String TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED = "io.quarkus.security.http.test-if-basic-auth-implicitly-required"; + /** + * Whether {@link io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication} has been detected, + * which means that user needs to use basic authentication. + * Only set when detected and {@link HttpAuthenticator#TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED} is true. + */ + public static final String BASIC_AUTH_ANNOTATION_DETECTED = "io.quarkus.security.http.basic-authentication-annotation-detected"; private static final Logger log = Logger.getLogger(HttpAuthenticator.class); /** * Added to a {@link RoutingContext} as selected authentication mechanism. @@ -106,6 +128,7 @@ public HttpAuthenticator(IdentityProviderManager identityProviderManager, """.formatted(mechanism.getClass().getName(), mechanism.getCredentialTypes())); } } + addBasicAuthMechanismIfImplicitlyRequired(httpAuthenticationMechanism, mechanisms, providers); if (mechanisms.isEmpty()) { this.mechanisms = new HttpAuthenticationMechanism[] { new NoAuthenticationMechanism() }; } else { @@ -377,6 +400,42 @@ public void accept(HttpCredentialTransport t) { }); } + private static void addBasicAuthMechanismIfImplicitlyRequired( + Instance httpAuthenticationMechanism, + List mechanisms, Instance> providers) { + if (!Boolean.getBoolean(TEST_IF_BASIC_AUTH_IMPLICITLY_REQUIRED) || isBasicAuthNotRequired()) { + return; + } + + var basicAuthMechInstance = httpAuthenticationMechanism.select(BasicAuthenticationMechanism.class); + if (basicAuthMechInstance.isResolvable() && !mechanisms.contains(basicAuthMechInstance.get())) { + for (IdentityProvider i : providers) { + if (UsernamePasswordAuthenticationRequest.class.equals(i.getRequestType())) { + mechanisms.add(basicAuthMechInstance.get()); + return; + } + } + log.debug(""" + BasicAuthenticationMechanism has been enabled because no custom authentication mechanism has been detected + and basic authentication is required either by the HTTP Security Policy or '@BasicAuthentication', but + there is no IdentityProvider based on username and password. Please use one of supported extensions. + For more information, go to the https://quarkus.io/guides/security-basic-authentication-howto. + """); + } + } + + private static boolean isBasicAuthNotRequired() { + if (Boolean.getBoolean(BASIC_AUTH_ANNOTATION_DETECTED)) { + return false; + } + for (var policy : Arc.container().instance(HttpConfiguration.class).get().auth.permissions.values()) { + if (BasicAuthentication.AUTH_MECHANISM_SCHEME.equals(policy.authMechanism.orElse(null))) { + return false; + } + } + return true; + } + static class NoAuthenticationMechanism implements HttpAuthenticationMechanism { @Override @@ -397,8 +456,8 @@ public Set> getCredentialTypes() { } @Override - public HttpCredentialTransport getCredentialTransport() { - return null; + public Uni getCredentialTransport(RoutingContext context) { + return Uni.createFrom().nullItem(); } }