使用@ExceptionHandler处理Spring Security异常

2023/05/17

1. 概述

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

2. Spring Security异常

AuthenticationException和AccessDeniedException等Spring Security核心异常是运行时异常。由于这些异常是由DispatcherServlet后面的身份验证过滤器在调用控制器方法之前引发的,因此@ControllerAdvice将无法捕获这些异常。

Spring Security异常可以通过添加自定义过滤器和构造响应体来直接处理。若要通过@ExceptionHandler和@ControllerAdvice在全局级别处理这些异常,我们需要AuthenticationEntryPoint的自定义实现。AuthenticationEntryPoint用于发送从客户端请求凭据的HTTP响应。尽管安全入口点有多个内置实现,但我们需要编写一个自定义实现来发送自定义响应消息。

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

3. 不使用@ExceptionHandler

Spring Security异常从AuthenticationEntryPoint开始。让我们为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.requestMatchers()
              .antMatchers("/login")
              .and()
              .authorizeRequests()
              .anyRequest()
              .hasRole("ADMIN")
              .and()
              .httpBasic()
              .and()
              .exceptionHandling()
              .authenticationEntryPoint(authEntryPoint);
        return http.build();
    }
}

3.3 配置RestController

现在,让我们编写一个处理此端点“/login”的RestController:

@RestController
@RequestMapping
public class LoginController {

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

3.4 测试

最后,让我们使用MockMvc来测试这个端点。

首先,让我们编写一个身份验证成功的测试用例:

@ExtendWith(SpringExtension.class)
@WebMvcTest(CustomSecurityConfig.class)
@Import({LoginController.class, CustomAuthenticationEntryPoint.class, DelegatedAuthenticationEntryPoint.class})
class SecurityConfigUnitTest {

    @Autowired
    private MockMvc mvc;

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

接下来我们来看一个身份验证失败的场景:

@Test
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

这种方法允许我们使用完全相同的异常处理技术,但在ControllerAdvice中以一种更清晰、更好的方式使用带有@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,并将处理程序委托给这个解析器。现在可以使用带有异常处理程序方法的ControllerAdvice来处理此安全异常。

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
@Order(101)
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 配置RestController

让我们为“/login-handler”端点配置RestController:

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

4.5 测试

现在让我们测试这个端点:

@ExtendWith(SpringExtension.class)
@WebMvcTest(DelegatedSecurityConfig.class)
@Import({LoginController.class, CustomAuthenticationEntryPoint.class, DelegatedAuthenticationEntryPoint.class})
class DelegatedSecurityConfigUnitTest {

    @Autowired
    private MockMvc mvc;

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

    @Test
    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上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章