【Spring Security】 异常处理机制
【Spring Security】 异常处理机制
Metadata
title: 【Spring Security】 异常处理机制
date: 2023-02-02 22:41
tags:
- 行动阶段/完成
- 主题场景/组件
- 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
- 细化主题/Module/SpringSecurity
categories:
- SpringSecurity
keywords:
- SpringSecurity
description: 【Spring Security】 异常处理机制
ExceptionTranslationFilter
功能
之前介绍过,Security 基于过滤器链实现认证授权,而 ExceptionTranslationFilter 是其中一个捕获 AccessDeniedException 和 AuthenticationException 异常,并进行处理的一个过滤器。
成员属性
ExceptionTranslationFilter 成员属性,主要用于处理异常,大部分都有其默认值。
// AccessDeniedException异常处理器
private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl();
// 未认证用户访问异常处理
private AuthenticationEntryPoint authenticationEntryPoint;
// 对Authentication进行解析处理
private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl();
// 异常分析器
private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();
// 保存请求
private RequestCache requestCache = new HttpSessionRequestCache();
// MessageSource访问对象
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
构造方法
其构造方法主要是为成员属性赋值。
public ExceptionTranslationFilter(AuthenticationEntryPoint authenticationEntryPoint, RequestCache requestCache) {
Assert.notNull(authenticationEntryPoint, "authenticationEntryPoint cannot be null");
Assert.notNull(requestCache, "requestCache cannot be null");
this.authenticationEntryPoint = authenticationEntryPoint;
this.requestCache = requestCache;
}
doFilter
doFilter 方法就是这个过滤器的逻辑处理过程了。
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
// 1. 请求直接放行
chain.doFilter(request, response);
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
// 2. 捕获后续出现的异常
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
// 3. 处理发生的异常
handleSpringSecurityException(request, response, chain, securityException);
}
}
Security 中的异常分类
从 ExceptionTranslationFilter 中,可以看到 Security 中的异常主要分为 AccessDeniedException 和 AuthenticationException 异常。
AuthenticationException
AuthenticationException 继承了 RuntimeException,主要负责认证时发生的异常。
异常分类
从图中可以看出,Security 定义了很多和认证失败有关的异常,主要有:
异常 | 描述 |
---|---|
UsernameNotFoundException | 未找到用户名 |
OAuth2AuthenticationException | 所有与 OAuth 2.0 相关的错误都会引发此异常,例如授权请求或令牌请求缺少必需参数、重定向 URI 无效或不匹配等 |
AccountStatusException | 账号状态异常,比如已冻结、锁定、过期等 |
RememberMeAuthenticationException | 记住我异常 |
Saml2AuthenticationException | 所有与 SAML 2.0 相关的 {@link Authentication} 错误都会引发此异常 |
BadCredentialsException | 认证凭据异常,比如密码错误 |
异常处理流程
在输错的用户或密码后,发现 AuthenticationException 并没有被拦截器捕获处理。
那么肯定是在自己的过滤器中处理了 AuthenticationException 并响应给了客户端。
在 AbstractAuthenticationProcessingFilter 中,可以看到 AuthenticationException 已经被 catch 掉了。
认证失败后,AbstractAuthenticationProcessingFilter 会调用自己的 unsuccessfulAuthentication 方法。
最终调用默认的 SimpleUrlAuthenticationFailureHandler 失败处理器,重定向到配置的认证失败页面,并返回 401 状态吗,直接 response 了,所以 ExceptionTranslationFilter 也就捕获不到了。。。使用 @ControllerAdvice 也是捕获不到的。
/**
* defaultFailureUrl 如果设置登陆失败URL,则重定向到配置URL,否则重定向到默认login?error
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
// 1. 判断defaultFailureUrl,默认为login?error
if (this.defaultFailureUrl == null) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sending 401 Unauthorized error since no failure URL is set");
}
else {
this.logger.debug("Sending 401 Unauthorized error");
}
// 2. 没有判断defaultFailureUrl,直接响应错误信息
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
return;
}
// 3. 缓存Exception到request和session中,便于错误页面获取错误信息
saveException(request, exception);
// 4. 判断 是否配置了转发到目的地,默认false
if (this.forwardToDestination) {
this.logger.debug("Forwarding to " + this.defaultFailureUrl);
// 5. 配置了,则请求转发到defaultFailureUrl
request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
}
else {
// 6. 没有配置,调用重定向策略,默认是DefaultRedirectStrategy
// 会直接跳转到defaultFailureUrl
this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
}
}
自定义 AuthenticationFailureHandler 认证失败处理器
前面介绍了认证异常的处理流程,那么怎么自定义异常处理呢?
在前后端分离这样的开发架构下,前后端的交互都是通过 JSON 来进行,不需要跳转页面,只需要返回信息即可,怎么跳转由前端自己决定。所以我们需要将 AuthenticationException 包装为 Json 信息返回。
- 创建认证失败处理器,统一返回 Json 认证失败信息
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private ObjectMapper objectMapper = new ObjectMapper();
/**
* 认证失败返回Json
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException {
// 封装结果信息,应该使用统一结果集封装,这里使用Map
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("msg", exception.getMessage());
objectMapper.writeValue(response.getOutputStream(), result);
}
}
- Security 配置类添加失败处理器
@Autowired
CustomAuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors(); // 关闭csrf,开启跨域支持
http.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.failureHandler(authenticationFailureHandler); // 配置登录失败处理器
}
- 使用 PostMan 模拟前端登录,密码错误时返回了 Json 错误信息。
AccessDeniedException
简介
AccessDeniedException 从字面上看是拒绝访问的意思,当我们没有权限时访问某个接口时,会抛出这个异常。
只有两个子类,CsrfException、AuthorizationServiceException,分别为缺少 csrf 令牌、无法处理授权请求异常。
流程分析
接下来分析以下使用 @PreAuthorize 注解,而当前用户没有权限时,是怎么处理 AccessDeniedException 的。
- 抛出异常,通过之前的文章介绍,可知在进入授权投票器,选民投票后,当投票结果为不通过时会直接抛出 AccessDeniedException 异常。
- 这个异常会被 ExceptionTranslationFilter 捕获并处理
- 最后调用自己的 handleAccessDeniedException 方法处理异常,最终也是使用了一个处理器。
/**
* 处理AccessDeniedException
*/
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
// 1. 获取认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 2. 是否是匿名用户
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
// 3. 如果是匿名用户,并且开启了记住我功能,则发送认证请求
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
} else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
// 4. 调用AccessDeniedException异常处理器
this.accessDeniedHandler.handle(request, response, exception);
}
}
- 处理器默认的是使用的 AccessDeniedHandlerImpl,最终进入他的 handle 方法处理。
/**
* 处理AccessDeniedException
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 1. 检查看服务端是否已将数据输出到客户端,如果已返回,则不处理
if (response.isCommitted()) {
logger.trace("Did not write to response since already committed");
return;
}
// 2. 判断是否配置了错误页面,没配置直接sendError返回错误
if (this.errorPage == null) {
logger.debug("Responding with 403 status code");
response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
return;
}
// 3. request设置属性,SPRING_SECURITY_403_EXCEPTION: 异常
// Put exception into request scope (perhaps of use to a view)
request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
// Set the 403 status code.
// 4. 设置状态码为403
response.setStatus(HttpStatus.FORBIDDEN.value());
// forward to error page.
if (logger.isDebugEnabled()) {
logger.debug(LogMessage.format("Forwarding to %s with status code 403", this.errorPage));
}
// 5. 配置了错误页面,直接跳转到错误页面
request.getRequestDispatcher(this.errorPage).forward(request, response);
}
- 默认的没有权限时,浏览器返回的是下图页面信息
使用 PostMan 时,返回的是下图,这些都是由 Spring MVC 根据不同客户端返回的。
自定义 AccessDeniedHandler 处理器
接下来统一返回 Json 错误信息给前端。
- 实现 AccessDeniedHandler 接口,封装错误信息返回给前端
@Slf4j
@Configuration
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private ObjectMapper objectMapper = new ObjectMapper();
/**
* 处理AccessDeniedException
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 1. 检查看服务端是否已将数据输出到客户端,如果已返回,则不处理
if (response.isCommitted()) {
return;
}
// 2. 封装结果信息,应该使用统一结果集封装,这里使用Map
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("msg", accessDeniedException.getMessage());
objectMapper.writeValue(response.getOutputStream(), result);
}
}
- 配置类添加 AccessDeniedHandler 处理器。
@Autowired
CustomAccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 关闭csrf,开启跨域支持
http.csrf().disable().cors();
http.authorizeRequests()
.antMatchers("/login").permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.failureHandler(authenticationFailureHandler); // 配置登录失败处理器
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler); // 配置DeniedHandler处理器
}
- 访问测试,当没有权限时,直接返回了 Json 错误信息。