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 0ecd40854a4c4..291774d72a214 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 @@ -4,6 +4,7 @@ import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; import java.util.Optional; +import java.util.function.Consumer; import io.quarkus.builder.item.SimpleBuildItem; import io.quarkus.deployment.Capabilities; @@ -11,6 +12,7 @@ import io.quarkus.deployment.Feature; 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.ApplicationArchivesBuildItem; import io.quarkus.deployment.builditem.ExecutorBuildItem; @@ -22,11 +24,13 @@ import io.quarkus.resteasy.server.common.deployment.ResteasyDeploymentBuildItem; 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.VertxHttpRecorder; import io.vertx.core.Handler; +import io.vertx.ext.web.Route; import io.vertx.ext.web.RoutingContext; public class ResteasyStandaloneBuildStep { @@ -86,11 +90,17 @@ public void boot(ShutdownContextBuildItem shutdown, // Routes use the order VertxHttpRecorder.DEFAULT_ROUTE_ORDER + 1 to ensure the default route is called before the resteasy one Handler handler = recorder.vertxRequestHandler(vertx.getVertx(), executorBuildItem.getExecutorProxy(), resteasyVertxConfig); + + // failure handler for auth failures that occurred before the handler defined right above started processing the request + final Consumer addFailureHandler = recorder.addVertxFailureHandler(vertx.getVertx(), + executorBuildItem.getExecutorProxy(), resteasyVertxConfig); + // Exact match for resources matched to the root path routes.produce( RouteBuildItem.builder() .orderedRoute(standalone.deploymentRootPath, - VertxHttpRecorder.AFTER_DEFAULT_ROUTE_ORDER_MARK + REST_ROUTE_ORDER_OFFSET) + VertxHttpRecorder.AFTER_DEFAULT_ROUTE_ORDER_MARK + REST_ROUTE_ORDER_OFFSET, + addFailureHandler) .handler(handler).build()); String matchPath = standalone.deploymentRootPath; if (matchPath.endsWith("/")) { @@ -106,4 +116,10 @@ public void boot(ShutdownContextBuildItem shutdown, recorder.start(shutdown, requireVirtual.isPresent()); } + @BuildStep + @Record(value = ExecutionTime.STATIC_INIT) + public FilterBuildItem addDefaultAuthFailureHandler(ResteasyStandaloneRecorder recorder) { + // replace default auth failure handler added by vertx-http so that our exception mappers can customize response + return new FilterBuildItem(recorder.defaultAuthFailureHandler(), FilterBuildItem.AUTHENTICATION - 1); + } } diff --git a/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/HttpPolicyAuthFailureExceptionMapperTest.java b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/HttpPolicyAuthFailureExceptionMapperTest.java new file mode 100644 index 0000000000000..2c1011cd57117 --- /dev/null +++ b/extensions/resteasy-classic/resteasy/deployment/src/test/java/io/quarkus/resteasy/test/security/HttpPolicyAuthFailureExceptionMapperTest.java @@ -0,0 +1,70 @@ +package io.quarkus.resteasy.test.security; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class HttpPolicyAuthFailureExceptionMapperTest { + + private static final String EXPECTED_RESPONSE = "expect response"; + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TestIdentityProvider.class, TestIdentityController.class) + .addAsResource( + new StringAsset("quarkus.http.auth.proactive=false\n" + + "quarkus.http.auth.permission.basic.paths=/*\n" + + "quarkus.http.auth.permission.basic.policy=authenticated\n"), + "application.properties")); + + @BeforeAll + public static void setupUsers() { + TestIdentityController.resetRoles() + .add("user", "user", "user"); + } + + @Test + public void testAuthFailedExceptionMapper() { + RestAssured + .given() + .auth().basic("user", "unknown-pwd") + .get("/") + .then() + .statusCode(401) + .body(Matchers.equalTo(EXPECTED_RESPONSE)); + } + + @Provider + public static class AuthFailedExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(AuthenticationFailedException exception) { + return Response.status(401).entity(EXPECTED_RESPONSE).build(); + } + } + + @Path("hello") + public static final class HelloResource { + + @GET + public String hello() { + return "hello world"; + } + } + +} diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/RequestDispatcher.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/RequestDispatcher.java index 85dcb5cf6ea01..9af166908ef7f 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/RequestDispatcher.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/RequestDispatcher.java @@ -1,6 +1,7 @@ package io.quarkus.resteasy.runtime.standalone; import java.io.IOException; +import java.util.function.Consumer; import org.jboss.logging.Logger; import org.jboss.resteasy.core.ResteasyContext; @@ -54,7 +55,7 @@ public ResteasyProviderFactory getProviderFactory() { public void service(Context context, HttpServerRequest req, HttpServerResponse resp, - HttpRequest vertxReq, HttpResponse vertxResp, boolean handleNotFound) throws IOException { + HttpRequest vertxReq, HttpResponse vertxResp, boolean handleNotFound, Throwable throwable) throws IOException { ClassLoader old = Thread.currentThread().getContextClassLoader(); try { @@ -69,7 +70,15 @@ public void service(Context context, ResteasyContext.pushContext(HttpServerRequest.class, req); ResteasyContext.pushContext(HttpServerResponse.class, resp); ResteasyContext.pushContext(Vertx.class, context.owner()); - if (handleNotFound) { + if (throwable != null) { + dispatcher.pushContextObjects(vertxReq, vertxResp); + dispatcher.writeException(vertxReq, vertxResp, throwable, new Consumer() { + @Override + public void accept(Throwable throwable) { + + } + }); + } else if (handleNotFound) { dispatcher.invoke(vertxReq, vertxResp); } else { dispatcher.invokePropagateNotFound(vertxReq, vertxResp); 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 2ec27b26bb614..3c94837db867f 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 @@ -1,8 +1,11 @@ package io.quarkus.resteasy.runtime.standalone; import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Supplier; +import org.jboss.resteasy.specimpl.ResteasyUriInfo; import org.jboss.resteasy.spi.ResteasyConfiguration; import org.jboss.resteasy.spi.ResteasyDeployment; @@ -12,9 +15,15 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.security.AuthenticationCompletionException; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.AuthenticationRedirectException; import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.DefaultAuthFailureHandler; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.vertx.core.Handler; import io.vertx.core.Vertx; +import io.vertx.ext.web.Route; import io.vertx.ext.web.RoutingContext; /** @@ -23,7 +32,7 @@ @Recorder public class ResteasyStandaloneRecorder { - public static final String META_INF_RESOURCES = "META-INF/resources"; + static final String RESTEASY_URI_INFO = ResteasyUriInfo.class.getName(); private static boolean useDirect = true; @@ -68,6 +77,80 @@ public Handler vertxRequestHandler(Supplier vertx, Execut return null; } + public Consumer addVertxFailureHandler(Supplier vertx, Executor executor, ResteasyVertxConfig config) { + if (deployment == null) { + return null; + } else { + return new Consumer() { + @Override + public void accept(Route route) { + // allow customization of auth failures with exception mappers; this failure handler is only + // used when auth failed before RESTEasy Classic began processing the request + route.failureHandler(new VertxRequestHandler(vertx.get(), deployment, contextPath, + new ResteasyVertxAllocator(config.responseBufferSize), executor, + readTimeout.getValue().readTimeout.toMillis()) { + + @Override + public void handle(RoutingContext request) { + if (request.failure() instanceof AuthenticationFailedException + || request.failure() instanceof AuthenticationCompletionException + || request.failure() instanceof AuthenticationRedirectException) { + super.handle(request); + } else { + request.next(); + } + } + + @Override + protected void setCurrentIdentityAssociation(RoutingContext routingContext) { + // security identity is not available as authentication failed + } + }); + } + }; + } + } + + public Handler defaultAuthFailureHandler() { + return new Handler() { + @Override + public void handle(RoutingContext event) { + if (event.get(QuarkusHttpUser.AUTH_FAILURE_HANDLER) instanceof DefaultAuthFailureHandler) { + + // only replace default auth failure handler if we can extract URI info + // as org.jboss.resteasy.plugins.server.BaseHttpRequest requires it; + // we need to extract URI info here as if auth failure will happen further upstream + // we want to return 401 and correct headers rather than 400 (malformed input) and so on + try { + event.put(RESTEASY_URI_INFO, VertxUtil.extractUriInfo(event.request(), contextPath)); + } catch (Exception e) { + // URI could be malformed or there has been internal error when extracting URI info + // keep default behavior (don't fail event, let default auth failure handler to handle this) + event.next(); + return; + } + + // fail event rather than end it, so that exception mappers can customize response + event.put(QuarkusHttpUser.AUTH_FAILURE_HANDLER, new BiConsumer() { + + @Override + public void accept(RoutingContext event, Throwable throwable) { + if (event.failed()) { + //auth failure handler should never get called from route failure handlers + //but if we get to this point bad things have happened, + //so it is better to send a response than to hang + event.end(); + } else { + event.fail(throwable); + } + } + }); + } + event.next(); + } + }; + } + private static class ResteasyVertxAllocator implements BufferAllocator { private final int bufferSize; diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/VertxRequestHandler.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/VertxRequestHandler.java index e8a4b6c2cc73b..6ba7df57dcb29 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/VertxRequestHandler.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/VertxRequestHandler.java @@ -1,5 +1,7 @@ package io.quarkus.resteasy.runtime.standalone; +import static io.quarkus.resteasy.runtime.standalone.ResteasyStandaloneRecorder.RESTEASY_URI_INFO; + import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -99,33 +101,27 @@ public void run() { } private void dispatch(RoutingContext routingContext, InputStream is, VertxOutput output) { - ResteasyUriInfo uriInfo; - try { - uriInfo = VertxUtil.extractUriInfo(routingContext.request(), rootPath); - } catch (Exception e) { - if (e.getCause() instanceof MalformedInputException) { - log.debug(e.getCause()); - routingContext.response().setStatusCode(400); - } else { - log.debug(e); - routingContext.response().setStatusCode(500); + ResteasyUriInfo uriInfo = routingContext.get(RESTEASY_URI_INFO); + if (uriInfo == null) { + try { + uriInfo = VertxUtil.extractUriInfo(routingContext.request(), rootPath); + } catch (Exception e) { + if (e.getCause() instanceof MalformedInputException) { + log.debug(e.getCause()); + routingContext.response().setStatusCode(400); + } else { + log.debug(e); + routingContext.response().setStatusCode(500); + } + routingContext.response().end(); + return; } - routingContext.response().end(); - return; } ManagedContext requestContext = Arc.container().requestContext(); requestContext.activate(); routingContext.remove(QuarkusHttpUser.AUTH_FAILURE_HANDLER); - if (association != null) { - QuarkusHttpUser existing = (QuarkusHttpUser) routingContext.user(); - if (existing != null) { - SecurityIdentity identity = existing.getSecurityIdentity(); - association.setIdentity(identity); - } else { - association.setIdentity(QuarkusHttpUser.getSecurityIdentity(routingContext, null)); - } - } + setCurrentIdentityAssociation(routingContext); currentVertxRequest.setCurrent(routingContext); try { Context ctx = vertx.getOrCreateContext(); @@ -148,7 +144,7 @@ private void dispatch(RoutingContext routingContext, InputStream is, VertxOutput map.put(RoutingContext.class, routingContext); try (ResteasyContext.CloseableContext restCtx = ResteasyContext.addCloseableContextDataLevel(map)) { ContextUtil.pushContext(routingContext); - dispatcher.service(ctx, request, response, vertxRequest, vertxResponse, true); + dispatcher.service(ctx, request, response, vertxRequest, vertxResponse, true, routingContext.failure()); } catch (Failure e1) { vertxResponse.setStatus(e1.getErrorCode()); if (e1.isLoggable()) { @@ -187,4 +183,16 @@ private void dispatch(RoutingContext routingContext, InputStream is, VertxOutput } } + protected void setCurrentIdentityAssociation(RoutingContext routingContext) { + if (association != null) { + QuarkusHttpUser existing = (QuarkusHttpUser) routingContext.user(); + if (existing != null) { + SecurityIdentity identity = existing.getSecurityIdentity(); + association.setIdentity(identity); + } else { + association.setIdentity(QuarkusHttpUser.getSecurityIdentity(routingContext, null)); + } + } + } + } diff --git a/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/AuthFailedExceptionMapper.java b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/AuthFailedExceptionMapper.java new file mode 100644 index 0000000000000..3cb80fe37991a --- /dev/null +++ b/integration-tests/elytron-resteasy/src/main/java/io/quarkus/it/resteasy/elytron/AuthFailedExceptionMapper.java @@ -0,0 +1,18 @@ +package io.quarkus.it.resteasy.elytron; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import io.quarkus.security.AuthenticationFailedException; + +@Provider +public class AuthFailedExceptionMapper implements ExceptionMapper { + + static final String EXPECTED_RESPONSE = "expected response"; + + @Override + public Response toResponse(AuthenticationFailedException exception) { + return Response.status(401).entity(EXPECTED_RESPONSE).build(); + } +} diff --git a/integration-tests/elytron-resteasy/src/main/resources/application.properties b/integration-tests/elytron-resteasy/src/main/resources/application.properties index d56f07473671d..cf88f8a9ec564 100644 --- a/integration-tests/elytron-resteasy/src/main/resources/application.properties +++ b/integration-tests/elytron-resteasy/src/main/resources/application.properties @@ -6,4 +6,8 @@ quarkus.security.users.embedded.roles.mary=managers quarkus.security.users.embedded.users.poul=poul quarkus.security.users.embedded.roles.poul=interns quarkus.security.users.embedded.plain-text=true -quarkus.http.auth.basic=true \ No newline at end of file +quarkus.http.auth.basic=true + +%auth-failed-ex-mapper.quarkus.http.auth.permission.basic.paths=/* +%auth-failed-ex-mapper.quarkus.http.auth.permission.basic.policy=authenticated +%auth-failed-ex-mapper.quarkus.http.auth.proactive=false \ No newline at end of file diff --git a/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/HttpPolicyAuthFailedExMapperTest.java b/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/HttpPolicyAuthFailedExMapperTest.java new file mode 100644 index 0000000000000..368601c4a3d57 --- /dev/null +++ b/integration-tests/elytron-resteasy/src/test/java/io/quarkus/it/resteasy/elytron/HttpPolicyAuthFailedExMapperTest.java @@ -0,0 +1,38 @@ +package io.quarkus.it.resteasy.elytron; + +import static io.quarkus.it.resteasy.elytron.AuthFailedExceptionMapper.EXPECTED_RESPONSE; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@QuarkusTest +@TestProfile(HttpPolicyAuthFailedExMapperTest.CustomTestProfile.class) +public class HttpPolicyAuthFailedExMapperTest { + + @Test + public void testAuthFailedExceptionMapper() { + RestAssured + .given() + .auth().basic("unknown-user", "unknown-pwd") + .contentType(ContentType.TEXT) + .get("/") + .then() + .statusCode(401) + .body(Matchers.equalTo(EXPECTED_RESPONSE)); + } + + public static class CustomTestProfile implements QuarkusTestProfile { + + @Override + public String getConfigProfile() { + return "auth-failed-ex-mapper"; + } + + } +}