简体   繁体   中英

Handle Security exceptions in Spring Boot Resource Server

How can I get my custom ResponseEntityExceptionHandler or OAuth2ExceptionRenderer to handle Exceptions raised by Spring security on a pure resource server?

We implemented a

@ControllerAdvice
@RestController
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

so whenever there is an error on the resource server we want it to answer with

{
  "message": "...",
  "type": "...",
  "status": 400
}

The resource server uses the application.properties setting:

security.oauth2.resource.userInfoUri: http://localhost:9999/auth/user

to authenticate and authorize a request against our auth server.

However any spring security error will always bypass our exception handler at

    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<Map<String, Object>> handleInvalidTokenException(InvalidTokenException e) {
        return createErrorResponseAndLog(e, 401);
    }

and produce either

{
  "timestamp": "2016-12-14T10:40:34.122Z",
  "status": 403,
  "error": "Forbidden",
  "message": "Access Denied",
  "path": "/api/templates/585004226f793042a094d3a9/schema"
}

or

{
  "error": "invalid_token",
  "error_description": "5d7e4ab5-4a88-4571-b4a4-042bce0a076b"
}

So how do I configure the security exception handling for a resource server? All I ever find are examples on how to customize the Auth Server by implementing a custom OAuth2ExceptionRenderer . But I can't find where to wire this to the resource server's security chain.

Our only configuration/setup is this:

@SpringBootApplication
@Configuration
@ComponentScan(basePackages = {"our.packages"})
@EnableAutoConfiguration
@EnableResourceServer

As noted in previous comments the request is rejected by the security framework before it reaches the MVC layer so @ControllerAdvice is not an option here.

There are 3 interfaces in the Spring Security framework that may be of interest here:

  • org.springframework.security.web.authentication.AuthenticationSuccessHandler
  • org.springframework.security.web.authentication.AuthenticationFailureHandler
  • org.springframework.security.web.access.AccessDeniedHandler

You can create implementations of each of these Interfaces in order to customize the response sent for various events: successful login, failed login, attempt to access protected resource with insufficient permissions.

The following would return a JSON response on unsuccessful login attempt:

@Component
public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler
{
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException ex) throws IOException, ServletException
  {
    response.setStatus(HttpStatus.FORBIDDEN.value());
    
    Map<String, Object> data = new HashMap<>();
    data.put("timestamp", new Date());
    data.put("status",HttpStatus.FORBIDDEN.value());
    data.put("message", "Access Denied");
    data.put("path", request.getRequestURL().toString());
    
    OutputStream out = response.getOutputStream();
    com.fasterxml.jackson.databind.ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(out, data);
    out.flush();
  }
}

You also need to register your implementation(s) with the Security framework. In Java config this looks like the below:

@Configuration
@EnableWebSecurity
@ComponentScan("...")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter
{
  @Override
  public void configure(HttpSecurity http) throws Exception
  {
    http
       .addFilterBefore(corsFilter(), ChannelProcessingFilter.class)
       .logout()
       .deleteCookies("JESSIONID")
       .logoutUrl("/api/logout")
       .logoutSuccessHandler(logoutSuccessHandler())
       .and()
       .formLogin()
       .loginPage("/login")
       .loginProcessingUrl("/api/login")
       .failureHandler(authenticationFailureHandler())
       .successHandler(authenticationSuccessHandler())
       .and()
       .csrf()
       .disable()
       .exceptionHandling()
       .authenticationEntryPoint(authenticationEntryPoint())
       .accessDeniedHandler(accessDeniedHandler());
  }

  /**
   * @return Custom {@link AuthenticationFailureHandler} to send suitable response to REST clients in the event of a
   *         failed authentication attempt.
   */
  @Bean
  public AuthenticationFailureHandler authenticationFailureHandler()
  {
    return new RestAuthenticationFailureHandler();
  }

  /**
   * @return Custom {@link AuthenticationSuccessHandler} to send suitable response to REST clients in the event of a
   *         successful authentication attempt.
   */
  @Bean
  public AuthenticationSuccessHandler authenticationSuccessHandler()
  {
    return new RestAuthenticationSuccessHandler();
  }

  /**
   * @return Custom {@link AccessDeniedHandler} to send suitable response to REST clients in the event of an attempt to
   *         access resources to which the user has insufficient privileges.
   */
  @Bean
  public AccessDeniedHandler accessDeniedHandler()
  {
    return new RestAccessDeniedHandler();
  }
}

In case if you're using @EnableResourceServer , you may also find convenient to extend ResourceServerConfigurerAdapter instead of WebSecurityConfigurerAdapter in your @Configuration class. By doing this, you may simply register a custom AuthenticationEntryPoint by overriding configure(ResourceServerSecurityConfigurer resources) and using resources.authenticationEntryPoint(customAuthEntryPoint()) inside the method.

Something like this:

@Configuration
@EnableResourceServer
public class CommonSecurityConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.authenticationEntryPoint(customAuthEntryPoint());
    }

    @Bean
    public AuthenticationEntryPoint customAuthEntryPoint(){
        return new AuthFailureHandler();
    }
}

There's also a nice OAuth2AuthenticationEntryPoint that can be extended (since it's not final) and partially re-used while implementing a custom AuthenticationEntryPoint . In particular, it adds "WWW-Authenticate" headers with error-related details.

您无法使用 Spring MVC 异常处理程序注释(例如@ControllerAdvice因为 Spring 安全过滤器在 Spring MVC 之前就开始使用了。

If you're using token validation URL with config similar to Configuring resource server with RemoteTokenServices in Spring Security Oauth2 which returns HTTP status 401 in case of unauthorized:

@Primary
@Bean
public RemoteTokenServices tokenService() {
    RemoteTokenServices tokenService = new RemoteTokenServices();
    tokenService.setCheckTokenEndpointUrl("https://token-validation-url.com");
    tokenService.setTokenName("token");
    return tokenService;
}

Implementing custom authenticationEntryPoint as described in other answers ( https://stackoverflow.com/a/44372313/5962766 ) won't work because RemoteTokenService use 400 status and throws unhandled exceptions for other statuses like 401:

public RemoteTokenServices() {
        restTemplate = new RestTemplate();
        ((RestTemplate) restTemplate).setErrorHandler(new DefaultResponseErrorHandler() {
            @Override
            // Ignore 400
            public void handleError(ClientHttpResponse response) throws IOException {
                if (response.getRawStatusCode() != 400) {
                    super.handleError(response);
                }
            }
        });
}

So you need to set custom RestTemplate in RemoteTokenServices config which would handle 401 without throwing exception:

@Primary
@Bean
public RemoteTokenServices tokenService() {
    RemoteTokenServices tokenService = new RemoteTokenServices();
    tokenService.setCheckTokenEndpointUrl("https://token-validation-url.com");
    tokenService.setTokenName("token");
    RestOperations restTemplate = new RestTemplate();
    restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
    ((RestTemplate) restTemplate).setErrorHandler(new DefaultResponseErrorHandler() {
            @Override
            // Ignore 400 and 401
            public void handleError(ClientHttpResponse response) throws IOException {
                if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
                    super.handleError(response);
                }
            }
        });
    }
    tokenService.setRestTemplate(restTemplate);
    return tokenService;
}

And add dependency for HttpComponentsClientHttpRequestFactory :

<dependency>
  <groupId>org.apache.httpcomponents</groupId>
  <artifactId>httpclient</artifactId>
</dependency>

OAuth2ExceptionRenderer is for an Authorization Server. The correct answer is likely to handle it like detailed in this post (that is, ignore that it's oauth and treat it like any other spring security authentication mechanism): https://stackoverflow.com/a/26502321/5639571

Of course, this will catch oauth related exceptions (which are thrown before you reach your resource endpoint), but any exceptions happening within your resource endpoint will still require an @ExceptionHandler method.

We can use this security handler to pass the handler to spring mvc @ControllerAdvice

@Component
public class AuthExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {

    private static final Logger LOG = LoggerFactory.getLogger(AuthExceptionHandler.class);

    private final HandlerExceptionResolver resolver;

    @Autowired
    public AuthExceptionHandler(@Qualifier("handlerExceptionResolver") final HandlerExceptionResolver resolver) {
        this.resolver = resolver;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        LOG.error("Responding with unauthorized error. Message - {}", authException.getMessage());
        resolver.resolveException(request, response, null, authException);
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        LOG.error("Responding with access denied error. Message - {}", accessDeniedException.getMessage());
        resolver.resolveException(request, response, null, accessDeniedException);
    }
}

Then define the exception by using @ControllerAdvice so that we can manage the global exception handler in one place..

This is possible. Since the original question is for a REST controller that needs to return a custom JSON response, I will write up a complete answer step by step which worked for me. First and foremost, it seems you cannot handle this with a @ControllerAdvice that extends ControllResponseEntityExceptionHandler . You need a separate handler that extends AccessDeniedHandler . Follow the below steps.

Step 1: Create a custom handler class that extends AccessDeniedHandler

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {

    private static final String JSON_TYPE = "application/json";
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        MyErrorList errors = new MyErrorList();
        errors.addError(new MyError("", "You do not have permission to access this resource."));

        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(JSON_TYPE);
        OutputStream output = response.getOutputStream();
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(output, errors);
        output.flush();
    }
}

'MyError' above is a simple POJO to represent an error json structure and MyErrorList is another POJO that holds a list of 'MyError's.

Step 2: Inject the Handler created above into the Security configuration

@Autowired
private VOMSAccessDeniedHandler accessDeniedHandler;  

Step 3: Register the accessDeniedHandler in your configure method

.and().exceptionHandling().accessDeniedHandler(accessDeniedHandler)

With Step 2 and Step 3 , Your SecurityConfiguration should look something like this (Note that I am omitting code that is not relevant to this problem to shorten the length of this answer):

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyAccessDeniedHandler accessDeniedHandler;

    // Other stuff

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/register").permitAll()
                .antMatchers("/authenticate").permitAll()
                .antMatchers("/public").permitAll()
                .anyRequest().authenticated()
                .and().exceptionHandling().accessDeniedHandler(accessDeniedHandler)
                .and().sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
  @Override
  public void commence(HttpServletRequest request, HttpServletResponse res,
        AuthenticationException authException) throws IOException, ServletException {
      ApiException ex = new ApiException(HttpStatus.FORBIDDEN, "Invalid Token", authException);

      ObjectMapper mapper = new ObjectMapper();
      res.setContentType("application/json;charset=UTF-8");
      res.setStatus(403);
      res.getWriter().write(mapper.writeValueAsString(ex));
  }
}

Spring 3.0 Onwards,You can use @ControllerAdvice (At Class Level) and extends org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler class from CustomGlobalExceptionHandler

@ExceptionHandler({com.test.CustomException1.class,com.test.CustomException2.class})
public final ResponseEntity<CustomErrorMessage> customExceptionHandler(RuntimeException ex){
     return new ResponseEntity<CustomErrorMessage>(new CustomErrorMessage(false,ex.getMessage(),404),HttpStatus.BAD_REQUEST);
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM