Skip to content

Commit

Permalink
Merge pull request #92 from wimdeblauwe/feature/gh-88
Browse files Browse the repository at this point in the history
Add AccessDeniedHandler implementation
  • Loading branch information
wimdeblauwe authored Apr 30, 2024
2 parents 0d373d1 + f9aa52a commit 7331216
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 4 deletions.
73 changes: 72 additions & 1 deletion src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1239,6 +1241,75 @@ public class WebSecurityConfiguration {
<.> Define the UnauthorizedEntryPoint as a bean.
<.> Use the bean in the security configuration.

==== AccessDeniedHandler

Similar to the <<AuthenticationEntryPoint>>, 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.
Expand Down
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 @@ -12,10 +13,13 @@
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;
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 @@ -43,6 +47,26 @@ void testUnauthorized() throws Exception {
.andExpect(jsonPath("message").value("Full authentication is required to access this resource"));
}

@Test
@WithMockUser
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 @@ -76,25 +100,49 @@ public void throwAccessDenied() {
public void throwAccountExpired() {
throw new AccountExpiredException("Fake account expired");
}

@GetMapping("/admin")
@Secured("ADMIN")
public void requiresAdminRole() {

}

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

}
}

@TestConfiguration
@EnableMethodSecurity(securedEnabled = true)
static class TestConfig {
@Bean
public UnauthorizedEntryPoint unauthorizedEntryPoint(HttpStatusMapper httpStatusMapper, ErrorCodeMapper errorCodeMapper, ErrorMessageMapper errorMessageMapper, ObjectMapper objectMapper) {
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);
http.exceptionHandling()
.authenticationEntryPoint(unauthorizedEntryPoint)
.accessDeniedHandler(accessDeniedHandler);

return http.build();
}

}

}

0 comments on commit 7331216

Please sign in to comment.