Skip to content

Commit

Permalink
Add ApiErrorResponseAccessDeniedHandler so users can configure this t…
Browse files Browse the repository at this point in the history
…o have a consistent error response if a user does not have the correct role for a resource

Fixes #88
  • Loading branch information
wimdeblauwe committed Apr 24, 2024
1 parent 6f8cdab commit 146ff78
Show file tree
Hide file tree
Showing 3 changed files with 116 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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:
*
* <pre>
* public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {*
* &#64;Bean
* public AccessDeniedHandler accessDeniedHandler(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) {
* return new ApiErrorResponseAccessDeniedHandler(objectMapper, httpStatusMapper, errorCodeMapper, errorMessageMapper);
* }
*
* &#64;Bean
* public SecurityFilterChain securityFilterChain(HttpSecurity http,
* AccessDeniedHandler accessDeniedHandler) throws Exception {
* http.httpBasic().disable();
*
* http.authorizeHttpRequests().anyRequest().authenticated();
*
* http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
*
* return http.build();
* }
* }
* </pre>
*
* @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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
* }
* }
* </pre>
*
* @see ApiErrorResponseAccessDeniedHandler
*/
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -47,14 +49,24 @@ 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"))
.andExpect(jsonPath("code").value("ACCESS_DENIED"))
.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 {
Expand Down Expand Up @@ -94,6 +106,11 @@ public void throwAccountExpired() {
public void requiresAdminRole() {

}

@GetMapping("/admin-global")
public void requiresAdminRoleViaGlobalConfig() {

}
}

@TestConfiguration
Expand All @@ -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();
}

}

}

0 comments on commit 146ff78

Please sign in to comment.