概述

在这个教程中,我们将学习如何使用@ExceptionHandler@ControllerAdvice全局处理Spring Security异常。控制器建议是一种拦截器,它允许我们在整个应用中使用相同的异常处理机制。

2. Spring Security异常

Spring Security的核心异常,如AuthenticationExceptionAccessDeniedException,是运行时异常。由于这些异常在DispatcherServlet后面的认证过滤器抛出,并在调用控制器方法之前,@ControllerAdvice无法捕获这些异常。

关于Spring Security异常的详细信息,请参阅这里。要通过@ExceptionHandler@ControllerAdvice在全球范围内处理这些异常,我们需要自定义AuthenticationEntryPoint的实现。AuthenticationEntryPoint用于发送一个HTTP响应,要求客户端提供凭证。尽管存在多个内置的安全入口点实现,但我们需要编写一个自定义实现,以发送自定义的响应消息。

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

3. 不使用@ExceptionHandler

Spring Security异常始于AuthenticationEntryPoint。让我们实现一个AuthenticationEntryPoint,并重写commence()方法:

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    // 实现代码...
}

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作为响应体的消息转换器。

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.authorizeHttpRequests(auth -> auth
            .requestMatchers("/login")
            .authenticated()
            .anyRequest()
            .hasRole("ADMIN"))
            .httpBasic(basic -> basic.authenticationEntryPoint(authEntryPoint))
            .exceptionHandling(Customizer.withDefaults());
      return http.build();
 }
}

3.3. 配置REST控制器

现在,编写一个监听此端点/login的REST控制器:

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

现在,我们将了解如何使用@ExceptionHandler@ControllerAdvice实现相同的功能。

4. 使用@ExceptionHandler

这种方法使我们能够在@ExceptionHandler注解的方法上使用相同的技术,以更清洁、更高效的方式在控制器建议中进行处理。

4.1. 配置AuthenticationEntryPoint

与上述方法类似,我们将实现AuthenticationEntryPoint,然后将异常处理器委托给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并将处理器委托给此解析器。现在,这个安全异常可以在带有异常处理方法的控制器建议中处理。

4.2. 配置ExceptionHandler

对于主要的异常处理配置,我们将扩展ResponseEntityExceptionHandler并使用@ControllerAdvice注解这个类:

@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

现在,为委托的认证入口点编写安全配置:

@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);
    }
}

对于/login-handler端点,我们已配置了上述实现的DelegatedAuthenticationEntryPoint

4.4. 配置REST控制器

/login-handler端点配置REST控制器:

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

在成功测试中,我们使用预配置的用户名和密码测试了端点。在失败测试中,我们验证了响应状态码和响应体中的错误消息。

5. 总结

在这篇文章中,我们学习了如何使用@ExceptionHandler全局处理Spring Security异常。此外,我们创建了一个完整的示例,帮助我们理解所解释的概念。

文章的完整源代码可在GitHub上找到:点击此处