diff --git a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java index a78f85e87933d..a63334d0cbdf1 100644 --- a/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java +++ b/extensions/resteasy-classic/resteasy/deployment/src/main/java/io/quarkus/resteasy/deployment/ResteasyStandaloneBuildStep.java @@ -5,6 +5,12 @@ import java.util.Optional; +import javax.ws.rs.ext.ExceptionMapper; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.Type; + import io.quarkus.builder.item.SimpleBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; @@ -14,19 +20,27 @@ import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.ExecutorBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.resteasy.common.deployment.ResteasyInjectionReadyBuildItem; +import io.quarkus.resteasy.runtime.AuthenticationCompletionExceptionMapper; +import io.quarkus.resteasy.runtime.AuthenticationFailedExceptionMapper; +import io.quarkus.resteasy.runtime.AuthenticationRedirectExceptionMapper; import io.quarkus.resteasy.runtime.ResteasyVertxConfig; import io.quarkus.resteasy.runtime.standalone.ResteasyStandaloneRecorder; import io.quarkus.resteasy.server.common.deployment.ResteasyDeploymentBuildItem; +import io.quarkus.security.AuthenticationCompletionException; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; import io.quarkus.vertx.http.deployment.DefaultRouteBuildItem; import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.quarkus.vertx.http.deployment.RequireVirtualHttpBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.VertxHttpRecorder; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -34,6 +48,7 @@ public class ResteasyStandaloneBuildStep { private static final int REST_ROUTE_ORDER_OFFSET = 500; + private static final DotName EXCEPTION_MAPPER = DotName.createSimple(ExceptionMapper.class.getName()); public static final class ResteasyStandaloneBuildItem extends SimpleBuildItem { @@ -75,6 +90,8 @@ public void boot(ShutdownContextBuildItem shutdown, BuildProducer routes, BuildProducer filterBuildItemBuildProducer, CoreVertxBuildItem vertx, + CombinedIndexBuildItem combinedIndexBuildItem, + HttpBuildTimeConfig vertxConfig, ResteasyStandaloneBuildItem standalone, Optional requireVirtual, ExecutorBuildItem executorBuildItem, @@ -90,11 +107,28 @@ public void boot(ShutdownContextBuildItem shutdown, Handler handler = recorder.vertxRequestHandler(vertx.getVertx(), executorBuildItem.getExecutorProxy(), resteasyVertxConfig); + final boolean noCustomAuthCompletionExMapper; + final boolean noCustomAuthFailureExMapper; + final boolean noCustomAuthRedirectExMapper; + if (vertxConfig.auth.proactive) { + noCustomAuthCompletionExMapper = notFoundCustomExMapper(AuthenticationCompletionException.class.getName(), + AuthenticationCompletionExceptionMapper.class.getName(), combinedIndexBuildItem.getIndex()); + noCustomAuthFailureExMapper = notFoundCustomExMapper(AuthenticationFailedException.class.getName(), + AuthenticationFailedExceptionMapper.class.getName(), combinedIndexBuildItem.getIndex()); + noCustomAuthRedirectExMapper = notFoundCustomExMapper(AuthenticationRedirectException.class.getName(), + AuthenticationRedirectExceptionMapper.class.getName(), combinedIndexBuildItem.getIndex()); + } else { + // with disabled proactive auth we need to handle exceptions anyway as default auth failure handler did not + noCustomAuthCompletionExMapper = false; + noCustomAuthFailureExMapper = false; + noCustomAuthRedirectExMapper = false; + } // failure handler for auth failures that occurred before the handler defined right above started processing the request // we add the failure handler right before QuarkusErrorHandler // so that user can define failure handlers that precede exception mappers final Handler failureHandler = recorder.vertxFailureHandler(vertx.getVertx(), - executorBuildItem.getExecutorProxy(), resteasyVertxConfig); + executorBuildItem.getExecutorProxy(), resteasyVertxConfig, noCustomAuthCompletionExMapper, + noCustomAuthFailureExMapper, noCustomAuthRedirectExMapper, vertxConfig.auth.proactive); filterBuildItemBuildProducer.produce(FilterBuildItem.ofAuthenticationFailureHandler(failureHandler)); // Exact match for resources matched to the root path @@ -117,6 +151,24 @@ public void boot(ShutdownContextBuildItem shutdown, recorder.start(shutdown, requireVirtual.isPresent()); } + private static boolean notFoundCustomExMapper(String exSignatureStr, String exMapperSignatureStr, IndexView index) { + for (var implementor : index.getAllKnownImplementors(EXCEPTION_MAPPER)) { + if (exMapperSignatureStr.equals(implementor.name().toString())) { + continue; + } + for (Type interfaceType : implementor.interfaceTypes()) { + if (EXCEPTION_MAPPER.equals(interfaceType.name())) { + final String mapperExSignature = interfaceType.asParameterizedType().arguments().get(0).name().toString(); + if (exSignatureStr.equals(mapperExSignature)) { + return false; + } + break; + } + } + } + return true; + } + @BuildStep @Record(value = ExecutionTime.STATIC_INIT) public FilterBuildItem addDefaultAuthFailureHandler(ResteasyStandaloneRecorder recorder) { diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationFailedExceptionHeaderTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationFailedExceptionHeaderTest.java new file mode 100644 index 0000000000000..740494074d9d6 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationFailedExceptionHeaderTest.java @@ -0,0 +1,100 @@ +package io.quarkus.resteasy.test.security; + +import static io.vertx.core.http.HttpHeaders.LOCATION; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.restassured.RestAssured; +import io.restassured.http.Header; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AuthenticationFailedExceptionHeaderTest { + + private static final String APP_PROPS = "" + + "quarkus.http.auth.permission.default.paths=/*\n" + + "quarkus.http.auth.permission.default.policy=authenticated"; + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(APP_PROPS), "application.properties")); + + @Test + public void testHeaders() { + // case-insensitive test that there is only one location header + // there has been duplicate location when both default auth failure handler and auth ex mapper send challenge + var response = RestAssured + .given() + .redirects() + .follow(false) + .when() + .get("/secured-route"); + response.then().statusCode(302); + assertEquals(1, response.headers().asList().stream().map(Header::getName).map(String::toLowerCase) + .filter(LOCATION.toString()::equals).count()); + } + + @Path("/hello") + public static class HelloResource { + @GET + public String hello() { + return "hello"; + } + } + + @ApplicationScoped + public static class FailingAuthenticator implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(BaseAuthenticationRequest.class); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(302, LOCATION, "http://localhost:8080/")); + } + + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return BaseAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + BaseAuthenticationRequest simpleAuthenticationRequest, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().nothing(); + } + } +} diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationRedirectExceptionHeaderTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationRedirectExceptionHeaderTest.java new file mode 100644 index 0000000000000..b46efe9d16aef --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/AuthenticationRedirectExceptionHeaderTest.java @@ -0,0 +1,109 @@ +package io.quarkus.resteasy.test.security; + +import static io.vertx.core.http.HttpHeaders.CACHE_CONTROL; +import static io.vertx.core.http.HttpHeaders.LOCATION; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.AuthenticationRedirectException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.restassured.RestAssured; +import io.restassured.http.Header; +import io.restassured.response.Response; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AuthenticationRedirectExceptionHeaderTest { + + private static final String APP_PROPS = "" + + "quarkus.http.auth.permission.default.paths=/*\n" + + "quarkus.http.auth.permission.default.policy=authenticated"; + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(APP_PROPS), "application.properties")); + + @Test + public void testHeaders() { + // case-insensitive test that Pragma, cache-control and location headers are only present once + // there were duplicate headers when both default auth failure handler and auth ex mapper set headers + var response = RestAssured + .given() + .redirects() + .follow(false) + .when() + .get("/secured-route"); + response.then().statusCode(302); + assertEquals(1, getHeaderCount(response, LOCATION.toString())); + assertEquals(1, getHeaderCount(response, CACHE_CONTROL.toString())); + assertEquals(1, getHeaderCount(response, "Pragma")); + } + + private static int getHeaderCount(Response response, String headerName) { + headerName = headerName.toLowerCase(); + return (int) response.headers().asList().stream().map(Header::getName).map(String::toLowerCase) + .filter(headerName::equals).count(); + } + + @Path("/hello") + public static class HelloResource { + @GET + public String hello() { + return "hello"; + } + } + + @ApplicationScoped + public static class RedirectingAuthenticator implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return Uni.createFrom().failure(new AuthenticationRedirectException(302, "https://quarkus.io/")); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(BaseAuthenticationRequest.class); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(302, "header-name", "header-value")); + } + + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return BaseAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + BaseAuthenticationRequest simpleAuthenticationRequest, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().nothing(); + } + } +} diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java index 253be4d73b8ab..9a290ac4a2c11 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java @@ -78,7 +78,9 @@ public Handler vertxRequestHandler(Supplier vertx, Execut return null; } - public Handler vertxFailureHandler(Supplier vertx, Executor executor, ResteasyVertxConfig config) { + public Handler vertxFailureHandler(Supplier vertx, Executor executor, ResteasyVertxConfig config, + boolean noCustomAuthCompletionExMapper, boolean noCustomAuthFailureExMapper, boolean noCustomAuthRedirectExMapper, + boolean proactive) { if (deployment == null) { return null; } else { @@ -90,6 +92,40 @@ public Handler vertxFailureHandler(Supplier vertx, Execut @Override public void handle(RoutingContext request) { + + // special handling when proactive auth is enabled as then we know default auth failure handler already run + if (proactive && request.get(QuarkusHttpUser.AUTH_FAILURE_HANDLER) instanceof DefaultAuthFailureHandler) { + // we want to prevent repeated handling of exceptions if user don't want to handle exception himself + // we do not pass exception to abort handlers if proactive auth is enabled and user did not + // provide custom ex. mapper; we replace default auth failure handler as soon as we can, so that + // we can handle Quarkus Security Exceptions ourselves + if (request.failure() instanceof AuthenticationFailedException) { + if (noCustomAuthFailureExMapper) { + request.next(); + } else { + // allow response customization + super.handle(request); + } + return; + } else if (request.failure() instanceof AuthenticationCompletionException) { + if (noCustomAuthCompletionExMapper) { + request.next(); + } else { + // allow response customization + super.handle(request); + } + return; + } else if (request.failure() instanceof AuthenticationRedirectException) { + if (noCustomAuthRedirectExMapper) { + request.next(); + } else { + // allow response customization + super.handle(request); + } + return; + } + } + if (request.failure() instanceof AuthenticationFailedException || request.failure() instanceof AuthenticationCompletionException || request.failure() instanceof AuthenticationRedirectException diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 92dfa45304f3e..688b05dc843a9 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -188,6 +188,7 @@ import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.RuntimeValue; import io.quarkus.security.AuthenticationCompletionException; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.security.ForbiddenException; import io.quarkus.vertx.http.deployment.FilterBuildItem; @@ -1203,7 +1204,26 @@ public void setupDeployment(BeanContainerBuildItem beanContainerBuildItem, if (!requestContextFactoryBuildItem.isPresent()) { RuntimeValue restInitialHandler = recorder.restInitialHandler(deployment); Handler handler = recorder.handler(restInitialHandler); - Handler failureHandler = recorder.failureHandler(restInitialHandler); + + final boolean noCustomAuthCompletionExMapper; + final boolean noCustomAuthFailureExMapper; + final boolean noCustomAuthRedirectExMapper; + if (vertxConfig.auth.proactive) { + noCustomAuthCompletionExMapper = notFoundCustomExMapper(AuthenticationCompletionException.class.getName(), + AuthenticationCompletionExceptionMapper.class.getName(), exceptionMapping); + noCustomAuthFailureExMapper = notFoundCustomExMapper(AuthenticationFailedException.class.getName(), + AuthenticationFailedExceptionMapper.class.getName(), exceptionMapping); + noCustomAuthRedirectExMapper = notFoundCustomExMapper(AuthenticationRedirectException.class.getName(), + AuthenticationRedirectExceptionMapper.class.getName(), exceptionMapping); + } else { + // with disabled proactive auth we need to handle exceptions anyway as default auth failure handler did not + noCustomAuthCompletionExMapper = false; + noCustomAuthFailureExMapper = false; + noCustomAuthRedirectExMapper = false; + } + + Handler failureHandler = recorder.failureHandler(restInitialHandler, noCustomAuthCompletionExMapper, + noCustomAuthFailureExMapper, noCustomAuthRedirectExMapper, vertxConfig.auth.proactive); // we add failure handler right before QuarkusErrorHandler // so that user can define failure handlers that precede exception mappers @@ -1225,6 +1245,26 @@ public void setupDeployment(BeanContainerBuildItem beanContainerBuildItem, } } + private static boolean notFoundCustomExMapper(String builtInExSignature, String builtInMapperSignature, + ExceptionMapping exceptionMapping) { + for (var entry : exceptionMapping.getMappers().entrySet()) { + if (builtInExSignature.equals(entry.getKey()) + && !entry.getValue().getClassName().startsWith(builtInMapperSignature)) { + return false; + } + } + for (var entry : exceptionMapping.getRuntimeCheckMappers().entrySet()) { + if (builtInExSignature.equals(entry.getKey())) { + for (var resourceExceptionMapper : entry.getValue()) { + if (!resourceExceptionMapper.getClassName().startsWith(builtInMapperSignature)) { + return false; + } + } + } + } + return true; + } + @BuildStep @Record(value = ExecutionTime.STATIC_INIT) public FilterBuildItem addDefaultAuthFailureHandler(ResteasyReactiveRecorder recorder) { diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationFailedExceptionHeaderTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationFailedExceptionHeaderTest.java new file mode 100644 index 0000000000000..4b4c3d67efe79 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationFailedExceptionHeaderTest.java @@ -0,0 +1,91 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import static io.vertx.core.http.HttpHeaders.LOCATION; +import static org.jboss.resteasy.reactive.RestResponse.StatusCode.FOUND; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.restassured.RestAssured; +import io.restassured.http.Header; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AuthenticationFailedExceptionHeaderTest { + + private static final String APP_PROPS = "" + + "quarkus.http.auth.permission.default.paths=/*\n" + + "quarkus.http.auth.permission.default.policy=authenticated"; + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(APP_PROPS), "application.properties")); + + @Test + public void testHeaders() { + // case-insensitive test that there is only one location header + // there has been duplicate location when both default auth failure handler and auth ex mapper send challenge + var response = RestAssured + .given() + .redirects() + .follow(false) + .when() + .get("/secured-route"); + response.then().statusCode(FOUND); + assertEquals(1, response.headers().asList().stream().map(Header::getName).map(String::toLowerCase) + .filter(LOCATION.toString()::equals).count()); + } + + @ApplicationScoped + public static class FailingAuthenticator implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(BaseAuthenticationRequest.class); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(FOUND, LOCATION, "http://localhost:8080/")); + } + + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return BaseAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + BaseAuthenticationRequest simpleAuthenticationRequest, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().nothing(); + } + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationRedirectExceptionHeaderTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationRedirectExceptionHeaderTest.java new file mode 100644 index 0000000000000..35157e762ad77 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/AuthenticationRedirectExceptionHeaderTest.java @@ -0,0 +1,100 @@ +package io.quarkus.resteasy.reactive.server.test.security; + +import static io.vertx.core.http.HttpHeaders.CACHE_CONTROL; +import static io.vertx.core.http.HttpHeaders.LOCATION; +import static org.jboss.resteasy.reactive.RestResponse.StatusCode.FOUND; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.AuthenticationRedirectException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.BaseAuthenticationRequest; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.restassured.RestAssured; +import io.restassured.http.Header; +import io.restassured.response.Response; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +public class AuthenticationRedirectExceptionHeaderTest { + + private static final String APP_PROPS = "" + + "quarkus.http.auth.permission.default.paths=/*\n" + + "quarkus.http.auth.permission.default.policy=authenticated"; + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset(APP_PROPS), "application.properties")); + + @Test + public void testHeaders() { + // case-insensitive test that Pragma, cache-control and location headers are only present once + // there were duplicate headers when both default auth failure handler and auth ex mapper set headers + var response = RestAssured + .given() + .redirects() + .follow(false) + .when() + .get("/secured-route"); + response.then().statusCode(FOUND); + assertEquals(1, getHeaderCount(response, LOCATION.toString())); + assertEquals(1, getHeaderCount(response, CACHE_CONTROL.toString())); + assertEquals(1, getHeaderCount(response, "Pragma")); + } + + private static int getHeaderCount(Response response, String headerName) { + headerName = headerName.toLowerCase(); + return (int) response.headers().asList().stream().map(Header::getName).map(String::toLowerCase) + .filter(headerName::equals).count(); + } + + @ApplicationScoped + public static class RedirectingAuthenticator implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return Uni.createFrom().failure(new AuthenticationRedirectException(FOUND, "https://quarkus.io/")); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(BaseAuthenticationRequest.class); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item(new ChallengeData(FOUND, "header-name", "header-value")); + } + + } + + @ApplicationScoped + public static class BasicIdentityProvider implements IdentityProvider { + + @Override + public Class getRequestType() { + return BaseAuthenticationRequest.class; + } + + @Override + public Uni authenticate( + BaseAuthenticationRequest simpleAuthenticationRequest, + AuthenticationRequestContext authenticationRequestContext) { + return Uni.createFrom().nothing(); + } + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java index 6cc91a1899e50..49eaf3c7159ee 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java @@ -212,13 +212,48 @@ public void accept(RoutingContext routingContext) { return new ResteasyReactiveVertxHandler(eventCustomizer, initialHandler); } - public Handler failureHandler(RuntimeValue restInitialHandlerRuntimeValue) { + public Handler failureHandler(RuntimeValue restInitialHandlerRuntimeValue, + boolean noCustomAuthCompletionExMapper, boolean noCustomAuthFailureExMapper, boolean noCustomAuthRedirectExMapper, + boolean proactive) { final RestInitialHandler restInitialHandler = restInitialHandlerRuntimeValue.getValue(); // process auth failures with abort handlers return new Handler() { @Override public void handle(RoutingContext event) { + // special handling when proactive auth is enabled as then we know default auth failure handler already run + if (proactive && event.get(QuarkusHttpUser.AUTH_FAILURE_HANDLER) instanceof DefaultAuthFailureHandler) { + // we want to prevent repeated handling of exceptions if user don't want to handle exception himself + // we do not pass exception to abort handlers if proactive auth is enabled and user did not + // provide custom ex. mapper; we replace default auth failure handler as soon as we can, so that + // we can handle Quarkus Security Exceptions ourselves + if (event.failure() instanceof AuthenticationFailedException) { + if (noCustomAuthFailureExMapper) { + event.next(); + } else { + // allow response customization + restInitialHandler.beginProcessing(event, event.failure()); + } + return; + } else if (event.failure() instanceof AuthenticationCompletionException) { + if (noCustomAuthCompletionExMapper) { + event.next(); + } else { + // allow response customization + restInitialHandler.beginProcessing(event, event.failure()); + } + return; + } else if (event.failure() instanceof AuthenticationRedirectException) { + if (noCustomAuthRedirectExMapper) { + event.next(); + } else { + // allow response customization + restInitialHandler.beginProcessing(event, event.failure()); + } + return; + } + } + if (event.failure() instanceof AuthenticationFailedException || event.failure() instanceof AuthenticationCompletionException || event.failure() instanceof AuthenticationRedirectException diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index ffc0fff14ceee..5505c4eb0c133 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -310,6 +310,9 @@ protected DefaultAuthFailureHandler() { @Override public void accept(RoutingContext event, Throwable throwable) { + if (event.response().ended()) { + return; + } throwable = extractRootCause(throwable); //auth failed if (throwable instanceof AuthenticationFailedException) {