From 0d373d18fa7554d3c2456f47c9e3bc47d1b1e8a8 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Fri, 23 Jun 2023 08:08:06 +0200 Subject: [PATCH 1/7] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 2520ee9..a6cfc8b 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ io.github.wimdeblauwe error-handling-spring-boot-starter - 4.2.0 + 4.3.0-SNAPSHOT Error Handling Spring Boot Starter Spring Boot starter that configures error handling @@ -50,7 +50,7 @@ scm:git:git@github.com:wimdeblauwe/error-handling-spring-boot-starter.git git@github.com:wimdeblauwe/error-handling-spring-boot-starter.git - 4.2.0 + HEAD github From 6f8cdaba58a4d9101befd227c427952473d290f5 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Wed, 24 Apr 2024 15:10:36 +0200 Subject: [PATCH 2/7] Add test when user does not have the proper role for an endpoint --- ...SpringSecurityApiExceptionHandlerTest.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java index 3b964e5..565f374 100644 --- a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java +++ b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java @@ -12,7 +12,9 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.annotation.Secured; import org.springframework.security.authentication.AccountExpiredException; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.web.SecurityFilterChain; @@ -43,6 +45,16 @@ void testUnauthorized() throws Exception { .andExpect(jsonPath("message").value("Full authentication is required to access this resource")); } + @Test + @WithMockUser + void testForbidden() throws Exception { + mockMvc.perform(get("/test/spring-security/admin")) + .andExpect(status().isForbidden()) + .andExpect(header().string("Content-Type", "application/json")) + .andExpect(jsonPath("code").value("ACCESS_DENIED")) + .andExpect(jsonPath("message").value("Access Denied")); + } + @Test @WithMockUser void testAccessDenied() throws Exception { @@ -76,9 +88,16 @@ public void throwAccessDenied() { public void throwAccountExpired() { throw new AccountExpiredException("Fake account expired"); } + + @GetMapping("/admin") + @Secured("ADMIN") + public void requiresAdminRole() { + + } } @TestConfiguration + @EnableMethodSecurity(securedEnabled = true) static class TestConfig { @Bean public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) { @@ -92,7 +111,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, http.authorizeHttpRequests().anyRequest().authenticated(); - http.exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint); + http.exceptionHandling() + .authenticationEntryPoint(unauthorizedEntryPoint); return http.build(); } From f717389172199103fa363e95337ab59ba35eba90 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Wed, 24 Apr 2024 15:55:30 +0200 Subject: [PATCH 3/7] Handle exceptions from filter chain This commit adds a FilterChainExceptionHandlerFilter that allows to handle an exception thrown from a Filter in the same way as we do for exceptions thrown from controllers. It is not enabled by default for backwards compatibility. Use `error.handling.handle-filter-chain-exceptions=true` to enable it. Fixes #87 --- .../AbstractErrorHandlingConfiguration.java | 20 +++++ .../ErrorHandlingFacade.java | 45 +++++++++++ .../ErrorHandlingProperties.java | 10 +++ .../GlobalErrorWebExceptionHandler.java | 39 ++-------- .../ReactiveErrorHandlingConfiguration.java | 10 +-- .../ErrorHandlingControllerAdvice.java | 42 ++--------- .../FilterChainExceptionHandlerFilter.java | 37 +++++++++ .../ServletErrorHandlingConfiguration.java | 32 +++++--- ...FilterChainExceptionHandlerFilterTest.java | 75 +++++++++++++++++++ 9 files changed, 225 insertions(+), 85 deletions(-) create mode 100644 src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingFacade.java create mode 100644 src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilter.java create mode 100644 src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilterTest.java diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java index 7ba0cd0..04b4833 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java @@ -6,10 +6,30 @@ import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; + +import java.util.List; public abstract class AbstractErrorHandlingConfiguration { + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractErrorHandlingConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public ErrorHandlingFacade errorHandlingFacade(List handlers, + FallbackApiExceptionHandler fallbackHandler, + LoggingService loggingService, + List responseCustomizers) { + handlers.sort(AnnotationAwareOrderComparator.INSTANCE); + LOGGER.info("Error Handling Spring Boot Starter active with {} handlers", handlers.size()); + LOGGER.debug("Handlers: {}", handlers); + + return new ErrorHandlingFacade(handlers, fallbackHandler, loggingService, responseCustomizers); + } + @Bean @ConditionalOnMissingBean public LoggingService loggingService(ErrorHandlingProperties properties) { diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingFacade.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingFacade.java new file mode 100644 index 0000000..91b46d2 --- /dev/null +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingFacade.java @@ -0,0 +1,45 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ErrorHandlingFacade { + private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingFacade.class); + + private final List handlers; + private final FallbackApiExceptionHandler fallbackHandler; + private final LoggingService loggingService; + private final List responseCustomizers; + + public ErrorHandlingFacade(List handlers, FallbackApiExceptionHandler fallbackHandler, LoggingService loggingService, + List responseCustomizers) { + this.handlers = handlers; + this.fallbackHandler = fallbackHandler; + this.loggingService = loggingService; + this.responseCustomizers = responseCustomizers; + } + + public ApiErrorResponse handle(Throwable exception) { + ApiErrorResponse errorResponse = null; + for (ApiExceptionHandler handler : handlers) { + if (handler.canHandle(exception)) { + errorResponse = handler.handle(exception); + break; + } + } + + if (errorResponse == null) { + errorResponse = fallbackHandler.handle(exception); + } + + for (ApiErrorResponseCustomizer responseCustomizer : responseCustomizers) { + responseCustomizer.customize(errorResponse); + } + + loggingService.logException(errorResponse, exception); + + return errorResponse; + } +} diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java index e8ed532..9206aa1 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java @@ -39,6 +39,8 @@ public class ErrorHandlingProperties { private boolean searchSuperClassHierarchy = false; + private boolean handleFilterChainExceptions = false; + public boolean isEnabled() { return enabled; } @@ -143,6 +145,14 @@ public void setAddPathToError(boolean addPathToError) { this.addPathToError = addPathToError; } + public boolean isHandleFilterChainExceptions() { + return handleFilterChainExceptions; + } + + public void setHandleFilterChainExceptions(boolean handleFilterChainExceptions) { + this.handleFilterChainExceptions = handleFilterChainExceptions; + } + public enum ExceptionLogging { NO_LOGGING, MESSAGE_ONLY, diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java index 4db4ab9..e385e63 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java @@ -1,6 +1,9 @@ package io.github.wimdeblauwe.errorhandlingspringbootstarter.reactive; -import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseCustomizer; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiExceptionHandler; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.web.ErrorProperties; @@ -13,30 +16,20 @@ import org.springframework.web.reactive.function.server.*; import reactor.core.publisher.Mono; -import java.util.List; import java.util.Locale; public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler { private static final Logger LOGGER = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class); - private final List handlers; - private final FallbackApiExceptionHandler fallbackHandler; - private final LoggingService loggingService; - private final List responseCustomizers; + private final ErrorHandlingFacade errorHandlingFacade; public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes, WebProperties.Resources resources, ErrorProperties errorProperties, ApplicationContext applicationContext, - List handlers, - FallbackApiExceptionHandler fallbackHandler, - LoggingService loggingService, - List responseCustomizers) { + ErrorHandlingFacade errorHandlingFacade) { super(errorAttributes, resources, errorProperties, applicationContext); - this.handlers = handlers; - this.fallbackHandler = fallbackHandler; - this.loggingService = loggingService; - this.responseCustomizers = responseCustomizers; + this.errorHandlingFacade = errorHandlingFacade; } @Override @@ -55,23 +48,7 @@ public Mono handleException(ServerRequest request) { LOGGER.debug("webRequest: {}", request); LOGGER.debug("locale: {}", locale); - ApiErrorResponse errorResponse = null; - for (ApiExceptionHandler handler : handlers) { - if (handler.canHandle(exception)) { - errorResponse = handler.handle(exception); - break; - } - } - - if (errorResponse == null) { - errorResponse = fallbackHandler.handle(exception); - } - - for (ApiErrorResponseCustomizer responseCustomizer : responseCustomizers) { - responseCustomizer.customize(errorResponse); - } - - loggingService.logException(errorResponse, exception); + ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception); return ServerResponse.status(errorResponse.getHttpStatus()) .contentType(MediaType.APPLICATION_JSON) diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java index 66a56c5..0bee249 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java @@ -67,19 +67,13 @@ public GlobalErrorWebExceptionHandler globalErrorWebExceptionHandler(ErrorAttrib ObjectProvider viewResolvers, ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext, - LoggingService loggingService, - List handlers, - FallbackApiExceptionHandler fallbackApiExceptionHandler, - List responseCustomizers) { + ErrorHandlingFacade errorHandlingFacade) { GlobalErrorWebExceptionHandler exceptionHandler = new GlobalErrorWebExceptionHandler(errorAttributes, webProperties.getResources(), serverProperties.getError(), applicationContext, - handlers, - fallbackApiExceptionHandler, - loggingService, - responseCustomizers); + errorHandlingFacade); exceptionHandler.setViewResolvers(viewResolvers.orderedStream().collect(Collectors.toList())); exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters()); exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders()); diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java index dda8ffd..3059f17 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java @@ -1,17 +1,16 @@ package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; -import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; -import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.WebRequest; -import java.util.List; import java.util.Locale; @ControllerAdvice(annotations = RestController.class) @@ -19,23 +18,10 @@ public class ErrorHandlingControllerAdvice { private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingControllerAdvice.class); - private final List handlers; - private final FallbackApiExceptionHandler fallbackHandler; - private final LoggingService loggingService; - private final List responseCustomizers; + private final ErrorHandlingFacade errorHandlingFacade; - public ErrorHandlingControllerAdvice(List handlers, - FallbackApiExceptionHandler fallbackHandler, - LoggingService loggingService, - List responseCustomizers) { - this.handlers = handlers; - this.fallbackHandler = fallbackHandler; - this.loggingService = loggingService; - this.responseCustomizers = responseCustomizers; - this.handlers.sort(AnnotationAwareOrderComparator.INSTANCE); - - LOGGER.info("Error Handling Spring Boot Starter active with {} handlers", this.handlers.size()); - LOGGER.debug("Handlers: {}", this.handlers); + public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) { + this.errorHandlingFacade = errorHandlingFacade; } @ExceptionHandler @@ -43,23 +29,7 @@ public ResponseEntity handleException(Throwable exception, WebRequest webRequ LOGGER.debug("webRequest: {}", webRequest); LOGGER.debug("locale: {}", locale); - ApiErrorResponse errorResponse = null; - for (ApiExceptionHandler handler : handlers) { - if (handler.canHandle(exception)) { - errorResponse = handler.handle(exception); - break; - } - } - - if (errorResponse == null) { - errorResponse = fallbackHandler.handle(exception); - } - - for (ApiErrorResponseCustomizer responseCustomizer : responseCustomizers) { - responseCustomizer.customize(errorResponse); - } - - loggingService.logException(errorResponse, exception); + ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception); return ResponseEntity.status(errorResponse.getHttpStatus()) .body(errorResponse); diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilter.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilter.java new file mode 100644 index 0000000..78b52a6 --- /dev/null +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilter.java @@ -0,0 +1,37 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class FilterChainExceptionHandlerFilter extends OncePerRequestFilter { + + private final ErrorHandlingFacade errorHandlingFacade; + private final ObjectMapper objectMapper; + + public FilterChainExceptionHandlerFilter(ErrorHandlingFacade errorHandlingFacade, ObjectMapper objectMapper) { + this.errorHandlingFacade = errorHandlingFacade; + this.objectMapper = objectMapper; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (Exception ex) { + ApiErrorResponse errorResponse = errorHandlingFacade.handle(ex); + response.setStatus(errorResponse.getHttpStatus().value()); + var jsonResponseBody = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponseBody); + } + } +} diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java index 88b36a5..9edd78b 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java @@ -1,5 +1,6 @@ package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; +import com.fasterxml.jackson.databind.ObjectMapper; import io.github.wimdeblauwe.errorhandlingspringbootstarter.*; import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.MissingRequestValueExceptionHandler; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; @@ -10,11 +11,11 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.PropertySource; - -import java.util.List; +import org.springframework.core.Ordered; @AutoConfiguration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @@ -38,13 +39,24 @@ public MissingRequestValueExceptionHandler missingRequestValueExceptionHandler(H @Bean @ConditionalOnMissingBean - public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(List handlers, - FallbackApiExceptionHandler fallbackApiExceptionHandler, - LoggingService loggingService, - List responseCustomizers) { - return new ErrorHandlingControllerAdvice(handlers, - fallbackApiExceptionHandler, - loggingService, - responseCustomizers); + public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) { + return new ErrorHandlingControllerAdvice(errorHandlingFacade); + } + + @Bean + @ConditionalOnProperty("error.handling.handle-filter-chain-exceptions") + public FilterChainExceptionHandlerFilter filterChainExceptionHandlerFilter(ErrorHandlingFacade errorHandlingFacade, ObjectMapper objectMapper) { + return new FilterChainExceptionHandlerFilter(errorHandlingFacade, objectMapper); + } + + @Bean + @ConditionalOnProperty("error.handling.handle-filter-chain-exceptions") + public FilterRegistrationBean filterChainExceptionHandlerFilterFilterRegistrationBean(FilterChainExceptionHandlerFilter filterChainExceptionHandlerFilter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(filterChainExceptionHandlerFilter); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + + return registrationBean; } } diff --git a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilterTest.java b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilterTest.java new file mode 100644 index 0000000..9953f68 --- /dev/null +++ b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/FilterChainExceptionHandlerFilterTest.java @@ -0,0 +1,75 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.filter.OncePerRequestFilter; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest +@ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, + FilterChainExceptionHandlerFilterTest.TestController.class, + FilterChainExceptionHandlerFilterTest.TestConfig.class}) +@TestPropertySource(properties = "error.handling.handle-filter-chain-exceptions=true") +public class FilterChainExceptionHandlerFilterTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @WithMockUser + void test() throws Exception { + mockMvc.perform(get("/test/filter-chain")) + .andExpect(status().is5xxServerError()) + .andExpect(jsonPath("code").value("RUNTIME")) + .andExpect(jsonPath("message").value("Error in filter")); + + } + + @RestController + @RequestMapping("/test/filter-chain") + public static class TestController { + + @GetMapping + public void doSomething() { + } + } + + @TestConfiguration + static class TestConfig { + + @Bean + public FilterRegistrationBean filter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ThrowErrorFilter()); + registrationBean.addUrlPatterns("/test/filter-chain"); + registrationBean.setOrder(2); + + return registrationBean; + } + } + + static class ThrowErrorFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + throw new RuntimeException("Error in filter"); + } + } +} From 146ff7835c156519ed4dcef3cc106dadd8f5bdd4 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Wed, 24 Apr 2024 20:36:34 +0200 Subject: [PATCH 4/7] Add ApiErrorResponseAccessDeniedHandler so users can configure this to have a consistent error response if a user does not have the correct role for a resource Fixes #88 --- .../ApiErrorResponseAccessDeniedHandler.java | 82 +++++++++++++++++++ .../UnauthorizedEntryPoint.java | 2 + ...SpringSecurityApiExceptionHandlerTest.java | 36 +++++++- 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseAccessDeniedHandler.java diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseAccessDeniedHandler.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseAccessDeniedHandler.java new file mode 100644 index 0000000..6dec0ca --- /dev/null +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ApiErrorResponseAccessDeniedHandler.java @@ -0,0 +1,82 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Use this {@link AccessDeniedHandler} implementation if you want to have a consistent response + * with how this library works when the user is not allowed to access a resource. + *

+ * It is impossible for the library to provide auto-configuration for this. So you need to manually add + * this to your security configuration. For example: + * + *

+ *     public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {*
+ *         @Bean
+ *         public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) {
+ *             return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper);
+ *         }
+ *
+ *         @Bean
+ *         public SecurityFilterChain securityFilterChain(HttpSecurity http,
+ *                                                        AccessDeniedHandler accessDeniedHandler) throws Exception {
+ *             http.httpBasic().disable();
+ *
+ *             http.authorizeHttpRequests().anyRequest().authenticated();
+ *
+ *             http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
+ *
+ *             return http.build();
+ *         }
+ *     }
+ * 
+ * + * @see UnauthorizedEntryPoint + */ +public class ApiErrorResponseAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + private final HttpStatusMapper httpStatusMapper; + private final ErrorCodeMapper errorCodeMapper; + private final ErrorMessageMapper errorMessageMapper; + + public ApiErrorResponseAccessDeniedHandler(ObjectMapper objectMapper, HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, + ErrorMessageMapper errorMessageMapper) { + this.objectMapper = objectMapper; + this.httpStatusMapper = httpStatusMapper; + this.errorCodeMapper = errorCodeMapper; + this.errorMessageMapper = errorMessageMapper; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException, ServletException { + ApiErrorResponse errorResponse = createResponse(accessDeniedException); + + response.setStatus(errorResponse.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.toString()); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + public ApiErrorResponse createResponse(AccessDeniedException exception) { + HttpStatusCode httpStatus = httpStatusMapper.getHttpStatus(exception, HttpStatus.FORBIDDEN); + String code = errorCodeMapper.getErrorCode(exception); + String message = errorMessageMapper.getErrorMessage(exception); + + return new ApiErrorResponse(httpStatus, code, message); + } + +} diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/UnauthorizedEntryPoint.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/UnauthorizedEntryPoint.java index 110808c..b9907f4 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/UnauthorizedEntryPoint.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/UnauthorizedEntryPoint.java @@ -43,6 +43,8 @@ * } * } * + * + * @see ApiErrorResponseAccessDeniedHandler */ public class UnauthorizedEntryPoint implements AuthenticationEntryPoint { diff --git a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java index 565f374..28644e4 100644 --- a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java +++ b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/SpringSecurityApiExceptionHandlerTest.java @@ -1,6 +1,7 @@ package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseAccessDeniedHandler; import io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; @@ -18,6 +19,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.annotation.GetMapping; @@ -47,7 +49,7 @@ void testUnauthorized() throws Exception { @Test @WithMockUser - void testForbidden() throws Exception { + void testForbiddenViaSecuredAnnotation() throws Exception { mockMvc.perform(get("/test/spring-security/admin")) .andExpect(status().isForbidden()) .andExpect(header().string("Content-Type", "application/json")) @@ -55,6 +57,16 @@ void testForbidden() throws Exception { .andExpect(jsonPath("message").value("Access Denied")); } + @Test + @WithMockUser + void testForbiddenViaGlobalSecurityConfig() throws Exception { + mockMvc.perform(get("/test/spring-security/admin-global")) + .andExpect(status().isForbidden()) + .andExpect(header().string("Content-Type", "application/json;charset=UTF-8")) + .andExpect(jsonPath("code").value("ACCESS_DENIED")) + .andExpect(jsonPath("message").value("Access Denied")); + } + @Test @WithMockUser void testAccessDenied() throws Exception { @@ -94,6 +106,11 @@ public void throwAccountExpired() { public void requiresAdminRole() { } + + @GetMapping("/admin-global") + public void requiresAdminRoleViaGlobalConfig() { + + } } @TestConfiguration @@ -104,17 +121,28 @@ public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatus return new UnauthorizedEntryPoint(httpStatusMapper, errorCodeMapper, errorMessageMapper, objectMapper); } + @Bean + public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) { + return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - UnauthorizedEntryPoint unauthorizedEntryPoint) throws Exception { + UnauthorizedEntryPoint unauthorizedEntryPoint, + AccessDeniedHandler accessDeniedHandler) throws Exception { http.httpBasic().disable(); - http.authorizeHttpRequests().anyRequest().authenticated(); + http.authorizeHttpRequests() + .requestMatchers("/test/spring-security/admin-global").hasRole("ADMIN") + .anyRequest().authenticated(); http.exceptionHandling() - .authenticationEntryPoint(unauthorizedEntryPoint); + .authenticationEntryPoint(unauthorizedEntryPoint) + .accessDeniedHandler(accessDeniedHandler); return http.build(); } + } + } From f9aa52a8d20cedd579fab6bd9e9d9e68252324a3 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Sat, 27 Apr 2024 11:21:14 +0200 Subject: [PATCH 5/7] Add documentation on ApiErrorResponseAccessDeniedHandler Fixes #88 --- src/docs/asciidoc/index.adoc | 73 +++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index de9be4c..dbd846a 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -1189,7 +1189,9 @@ With this configuration, 400 Bad Request will be printed on DEBUG level. 401 Unauthorized will be printed on INFO. Finally, all status code in the 5xx range will be printed on ERROR. -=== Spring Security AuthenticationEntryPoint +=== Spring Security + +==== AuthenticationEntryPoint By default, the library will not provide a response when there is an unauthorized exception. It is impossible for this library to provide auto-configuration for this. @@ -1239,6 +1241,75 @@ public class WebSecurityConfiguration { <.> Define the UnauthorizedEntryPoint as a bean. <.> Use the bean in the security configuration. +==== AccessDeniedHandler + +Similar to the <>, there is also an `AccessDeniedHandler` implementation available at `io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponseAccessDeniedHandler`. + +Example configuration: + +[source,java] +---- +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.UnauthorizedEntryPoint; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; + +import org.springframework.context.annotation.Bean; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; + +public class WebSecurityConfiguration { + + @Bean + public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, + ErrorCodeMapper errorCodeMapper, + ErrorMessageMapper errorMessageMapper, + ObjectMapper objectMapper) { //<.> + return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + AccessDeniedHandler accessDeniedHandler) throws Exception { + http.httpBasic().disable(); + + http.authorizeHttpRequests().anyRequest().authenticated(); + + http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);//<.> + + return http.build(); + } +} +---- + +<.> Define the AccessDeniedHandler as a bean. +<.> Use the bean in the security configuration. + +[NOTE] +==== +You can perfectly combine the `AccessDeniedHandler` with the `UnauthorizedEntryPoint`: + +[source,java] +---- +@Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + UnauthorizedEntryPoint unauthorizedEntryPoint, + AccessDeniedHandler accessDeniedHandler) throws Exception { + http.httpBasic().disable(); + + http.authorizeHttpRequests().anyRequest().authenticated(); + + http.exceptionHandling() + .authenticationEntryPoint(unauthorizedEntryPoint) + .accessDeniedHandler(accessDeniedHandler); + + return http.build(); + } +---- + +==== + === Handle non-rest controller exceptions The library is setup in such a way that only exceptions coming from `@RestController` classes are handled. From 1ce5817a902ebfc78a0bdeb65bbeea5f27effcee Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Sat, 27 Apr 2024 11:26:39 +0200 Subject: [PATCH 6/7] Add documentation on error.handling.handle-filter-chain-exceptions Fixes #87 --- src/docs/asciidoc/index.adoc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index de9be4c..c10ad96 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -1272,6 +1272,13 @@ public class FallbackExceptionHandler extends ErrorHandlingControllerAdvice { } ---- +=== Handle filter exceptions + +By default, the library will not handle exceptions from custom filters. +Those are implementations of `jakarta.servlet.Filter`, usually subclasses of `org.springframework.web.filter.OncePerRequestFilter` in a Spring Boot application. + +By setting the property `error.handling.handle-filter-chain-exceptions` to `true`, the library will handle those exceptions and return error responses just like is done for exceptions coming from controller methods. + == Custom exception handler If the <> are not enough, you can write your own `ApiExceptionHandler` implementation. @@ -1395,6 +1402,10 @@ When this is set to `true`, you can use any superclass from your `Exception` typ |error.handling.add-path-to-error |This property allows to remove the `path` property in the error response when set to `false`. |`true` + +|error.handling.handle-filter-chain-exceptions +|Set this to `true` to have the library intercept any exception thrown from custom filters and also have the same error responses as exceptions thrown from controller methods. +|`false`. |=== == Support From a9e82db758017f00ef8b9d220aa35fb658cad7a3 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Tue, 30 Apr 2024 08:48:27 +0200 Subject: [PATCH 7/7] [maven-release-plugin] prepare release 4.3.0 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a6cfc8b..5ca89cb 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ io.github.wimdeblauwe error-handling-spring-boot-starter - 4.3.0-SNAPSHOT + 4.3.0 Error Handling Spring Boot Starter Spring Boot starter that configures error handling @@ -50,7 +50,7 @@ scm:git:git@github.com:wimdeblauwe/error-handling-spring-boot-starter.git git@github.com:wimdeblauwe/error-handling-spring-boot-starter.git - HEAD + 4.3.0 github