【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 信息返回。

  1. 创建认证失败处理器,统一返回 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);
    }
}
  1. 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); // 配置登录失败处理器
    }
  1. 使用 PostMan 模拟前端登录,密码错误时返回了 Json 错误信息。

AccessDeniedException

简介

AccessDeniedException 从字面上看是拒绝访问的意思,当我们没有权限时访问某个接口时,会抛出这个异常。

只有两个子类,CsrfException、AuthorizationServiceException,分别为缺少 csrf 令牌、无法处理授权请求异常。

流程分析

接下来分析以下使用 @PreAuthorize 注解,而当前用户没有权限时,是怎么处理 AccessDeniedException 的。

  1. 抛出异常,通过之前的文章介绍,可知在进入授权投票器,选民投票后,当投票结果为不通过时会直接抛出 AccessDeniedException 异常。

  1. 这个异常会被 ExceptionTranslationFilter 捕获并处理

  1. 最后调用自己的 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);
        }
    }
  1. 处理器默认的是使用的 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);
    }
  1. 默认的没有权限时,浏览器返回的是下图页面信息

使用 PostMan 时,返回的是下图,这些都是由 Spring MVC 根据不同客户端返回的。

自定义 AccessDeniedHandler 处理器

接下来统一返回 Json 错误信息给前端。

  1. 实现 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);
    }
}
  1. 配置类添加 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处理器
    }
  1. 访问测试,当没有权限时,直接返回了 Json 错误信息。