使用 @ExceptionHandler 处理 Spring Security 异常

1、概览

本文将会带你了解如何使用 @ExceptionHandler@ControllerAdvice 全局处理 Spring Security 异常。

Controller Advice 是一种拦截器,常用于处理全局异常。

2、Spring Security 异常

Spring Security 核心异常(如 AuthenticationExceptionAccessDeniedException)属于运行时异常。由于这些异常是由 DispatcherServlet 后面的 Authentication Filter 在调用 Controller 方法之前抛出的,因此 @ControllerAdvice 无法捕获这些异常。

通过添加自定义 Filter 和构建响应体,可以直接处理 Spring Security 异常。要通过@ExceptionHandler@ControllerAdvice 在全局级别处理这些异常,需要自定义 AuthenticationEntryPoint 的实现。AuthenticationEntryPoint 用于发送 HTTP 响应,要求客户端提供凭证。虽然已经有多个内置实现,但是我们仍然需要自己实现,以发送自定义响应。

首先,让我们看看如何在不使用 @ExceptionHandler 的情况下全局处理 Security 异常。

3、不使用 @ExceptionHandler

Spring Security 异常是从 AuthenticationEntryPoint 开始的。让我们编写一个 AuthenticationEntryPoint 的实现,用于拦截 Security 异常。

3.1、配置 AuthenticationEntryPoint

实现 AuthenticationEntryPoint 并覆写 commence() 方法:

@Component("customAuthenticationEntryPoint")
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 
      throws IOException, ServletException {

        RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        OutputStream responseStream = response.getOutputStream();
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(responseStream, re);
        responseStream.flush();
    }
}

这里,我们使用 ObjectMapper 作为响应体的 Message Converter。

3.2、配置 SecurityConfig

接下来,配置 SecurityConfig 以拦截需要身份认证的路径。这里,配置 /login 作为上述实现的路径。此外,还为 admin 用户配置了 ADMIN 角色:

@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {

    @Autowired
    @Qualifier("customAuthenticationEntryPoint")
    AuthenticationEntryPoint authEntryPoint;

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails admin = User.withUsername("admin")
            .password("password")
            .roles("ADMIN")
            .build();
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(admin);
        return userDetailsManager;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.requestMatchers()
            .antMatchers("/login")
            .and()
            .authorizeRequests()
            .anyRequest()
            .hasRole("ADMIN")
            .and()
            .httpBasic()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authEntryPoint);
        return http.build();
    }
}

3.3、配置 Rest Controller

编写一个 Rest Controller,监听 /login 端点:

@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RestResponse> login() {
    return ResponseEntity.ok(new RestResponse("Success"));
}

3.4、测试

最后,用模拟测试来测试这个端点。

首先,编写一个认证成功的测试用例:

@Test
@WithMockUser(username = "admin", roles = { "ADMIN" })
public void whenUserAccessLogin_shouldSucceed() throws Exception {
    mvc.perform(formLogin("/login").user("username", "admin")
      .password("password", "password")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk());
}

接下来,再看看身份认证失败的情况:

@Test
public void whenUserAccessWithWrongCredentialsWithDelegatedEntryPoint_shouldFail() throws Exception {
    RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed");
    mvc.perform(formLogin("/login").user("username", "admin")
      .password("password", "wrong")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isUnauthorized())
      .andExpect(jsonPath("$.errorMessage", is(re.getErrorMessage())));
}

现在,让我们看看如何使用 @ControllerAdvice@ExceptionHandler 来实现同样的功能。

4、使用 @ExceptionHandler

这种方法允许我们使用完全相同的异常处理技术。

但在 Controller Advice 中使用 @ExceptionHandler 注解的方法时会更加简洁,效果也更好。

4.1、配置 AuthenticationEntryPoint

与上述方法类似,我们要实现 AuthenticationEntryPoint,然后将 Exception Handler 委托给 HandlerExceptionResolver

@Component("delegatedAuthenticationEntryPoint")
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Autowired
    @Qualifier("handlerExceptionResolver")
    private HandlerExceptionResolver resolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 
      throws IOException, ServletException {
        resolver.resolveException(request, response, null, authException);
    }
}

这里,我们注入了 DefaultHandlerExceptionResolver,并将 Handler 委托给该 Resolver(解析器)。现在,可以使用 Exception Handler 方法通过 Controller Advice 来处理此 Security 异常。

4.2、配置 ExceptionHandler

现在,继承 ResponseEntityExceptionHandler 并使用 @ControllerAdvice 注解对该类进行注解。这是 Exception Handler 的主要配置。

@ControllerAdvice
public class DefaultExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ AuthenticationException.class })
    @ResponseBody
    public ResponseEntity<RestError> handleAuthenticationException(Exception ex) {

        RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), 
          "Authentication failed at controller advice");
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(re);
    }
}

4.3、配置 SecurityConfig

现在,为这个 delegatedAuthenticationEntryPoint(委托的身份认证入口) 编写一个 Security 配置:

@Configuration
@EnableWebSecurity
public class DelegatedSecurityConfig {

    @Autowired
    @Qualifier("delegatedAuthenticationEntryPoint")
    AuthenticationEntryPoint authEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.requestMatchers()
            .antMatchers("/login-handler")
            .and()
            .authorizeRequests()
            .anyRequest()
            .hasRole("ADMIN")
            .and()
            .httpBasic()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authEntryPoint);
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails admin = User.withUsername("admin")
            .password("password")
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(admin);
    }
}

使用上述实现的 DelegatedAuthenticationEntryPoint/login-handler 端点配置了 Exception handler。

4.4、配置 Rest Controller

定义 /login-handler 端点的 Rest Controller。

@PostMapping(value = "/login-handler", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RestResponse> loginWithExceptionHandler() {
    return ResponseEntity.ok(new RestResponse("Success"));
}

4.5、测试

测试这个端点:

@Test
@WithMockUser(username = "admin", roles = { "ADMIN" })
public void whenUserAccessLogin_shouldSucceed() throws Exception {
    mvc.perform(formLogin("/login-handler").user("username", "admin")
      .password("password", "password")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk());
}

@Test
public void whenUserAccessWithWrongCredentialsWithDelegatedEntryPoint_shouldFail() throws Exception {
    RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed at controller advice");
    mvc.perform(formLogin("/login-handler").user("username", "admin")
      .password("password", "wrong")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isUnauthorized())
      .andExpect(jsonPath("$.errorMessage", is(re.getErrorMessage())));
}

houldSucceed 测试中,使用预先配置的用户名和密码测试了端点。

shouldFail 测试中,验证了响应的状态码和响应体中的错误消息。

5、总结

本文通过实际案例介绍了如何使用 @ExceptionHandler 全局处理 Spring Security 异常。


参考:https://www.baeldung.com/spring-security-exceptionhandler