From 6f8cdaba58a4d9101befd227c427952473d290f5 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Wed, 24 Apr 2024 15:10:36 +0200 Subject: [PATCH 1/3] 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 146ff7835c156519ed4dcef3cc106dadd8f5bdd4 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Wed, 24 Apr 2024 20:36:34 +0200 Subject: [PATCH 2/3] 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 3/3] 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.