使用 @ExceptionHandler 处理 Spring Security 异常
1、概览
本文将会带你了解如何使用 @ExceptionHandler
和 @ControllerAdvice
全局处理 Spring Security 异常。
Controller Advice 是一种拦截器,常用于处理全局异常。
2、Spring Security 异常
Spring Security 核心异常(如 AuthenticationException
和 AccessDeniedException
)属于运行时异常。由于这些异常是由 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