ExceptionTranslationFilter原理分析

Spring Security 中的异常处理主要是在 ExceptionTranslationFilter 过滤器中完成的,该过滤器主要处理 AuthenticationException 和 AccessDeniedException 类型的异常,其他异常则会继续抛出,交给上一层容器去处理。

接下来我们来分析 ExceptionTranslationFilter 的工作原理。

在 WebSecurityConfigurerAdapter#getHttp 方法中进行 HttpSecurity 初始化的时候,就调用了 exceptionHandling() 方法去配置 ExceptionTranslationFilter 过滤器:

protected final HttpSecurity getHttp() throws Exception {

    // ...
    if (!disableDefaults) {
        // @formatter:off
        http
            .csrf().and()
            .addFilter(new WebAsyncManagerIntegrationFilter())
            .exceptionHandling().and()
            .headers().and()
            .sessionManagement().and()
            .securityContext().and()
            .requestCache().and()
            .anonymous().and()
            .servletApi().and()
            .apply(new DefaultLoginPageConfigurer<>()).and()
            .logout();
        // ...
    }
    configure(http);
    return http;
}

exceptionHandling() 方法就是调用 ExceptionHandlingConfigurer 去配置 ExceptionTranslationFilter。对 ExceptionHandlingConfigurer 配置类而言,最重要的当然就是它里边的 configure 方法了,我们来看一下该方法:

@Override
public void configure(H http) {
    AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(http);
    ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(
            entryPoint, getRequestCache(http));
    AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http);
    exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler);
    exceptionTranslationFilter = postProcess(exceptionTranslationFilter);
    http.addFilter(exceptionTranslationFilter);
}

可以看到,这里首先获取到一个 entryPoint 对象,这个就是认证失败时的处理器,然后创建 ExceptionTranslationFilter 过滤器并传入 entryPoint。接下来还会获取到一个 deniedHandler 对象设置给 ExceptionTranslationFilter 过滤器,这个 deniedHandler 就是权限异常处理器。最后调用 postProcess 方法将 ExceptionTranslationFilter 过滤器注册到 Spring 容器中,然后调用 addFilter 方法再将其添加到 Spring Security 过滤器链中。

AuthenticationEntryPoint

AuthenticationEntryPoint 实例是通过 getAuthenticationEntryPoint 方法获取到的,我们来具体看一下:

AuthenticationEntryPoint getAuthenticationEntryPoint(H http) {
    AuthenticationEntryPoint entryPoint = this.authenticationEntryPoint;
    if (entryPoint == null) {
        entryPoint = createDefaultEntryPoint(http);
    }
    return entryPoint;
}

private AuthenticationEntryPoint createDefaultEntryPoint(H http) {
    if (this.defaultEntryPointMappings.isEmpty()) {
        return new Http403ForbiddenEntryPoint();
    }
    if (this.defaultEntryPointMappings.size() == 1) {
        return this.defaultEntryPointMappings.values().iterator().next();
    }
    DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(
            this.defaultEntryPointMappings);
    entryPoint.setDefaultEntryPoint(this.defaultEntryPointMappings.values().iterator()
            .next());
    return entryPoint;
}

默认情况下,系统的 authenticationEntryPoint 属性值为 null,所以最终还是通过 createDefaultEntryPoint 方法来获取 AuthenticationEntryPoint 实例。在 createDefaultEntryPoint 方法中有一个 defaultEntryPointMappings 变量,它是一个 LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> 类型。

可以看到,这个 LinkedHashMap 的 key 是一个 RequestMatcher,即一个请求匹配器,而 value 则是一个 AuthenticationEntryPoint 认证失败处理器,即一个请求匹配器对应一个认证失败处理器。换句话说,针对不同的请求,可以给出不同的认证失败处理器。如果 defaultEntryPointMappings 变量为空,则返回一个 Http403ForbiddenEntryPoint 类型的处理器;如果 defaultEntryPointMappings 变量中只有一项,则将这一项取出来返回即可;如果 defaultEntryPointMappings 变量中有多项,则使用 DelegatingAuthenticationEntryPoint 代理类,在代理类中,会遍历 defaultEntryPointMappings 变量中的每一项,查看当前请求是否满足其 RequestMatcher,如果满足,则使用对应的认证失败处理器来处理。

当我们新建一个 Spring Security 项目,不做任何配置时,在 WebSecurityConfigurerAdapter#configure(HttpSecurity) 方法中默认会配置表单登录和 HTTP 基本认证,表单登录和 HTTP 基本认证在配置的过程中,会分别向 defaultEntryPointMappings 变量中添加认证失败处理器:

  • 表单登录在 AbstractAuthenticationFilterConfigurer#registerAuthenticationEntryPoint 方法中向 defaultEntryPointMappings 变量添加的处理器,对应的 AuthenticationEntryPoint 实例就是 LoginUrlAuthenticationEntryPoint,默认情况下访问需要认证才能访问的页面时,会自动跳转到登录页面,就是通过 LoginUrlAuthenticationEntryPoint 实现的。

  • HTTP 基本认证在 HttpBasicConfigurer#registerDefaultEntryPoint 方法中向 defaultEntryPointMappings 变量添加处理器,对应的 AuthenticationEntryPoint ‧实例则是 BasicAuthenticationEntryPoint(具体参考 10.1.3.1 小节)。

所以默认情况下,defaultEntryPointMappings 变量中将存在两个认证失败处理器。

AccessDeniedHandler

我们再来看 AccessDeniedHandler 实例的获取,AccessDeniedHandler 实例是通过 getAccessDeniedHandler 方法获取到的:

AccessDeniedHandler getAccessDeniedHandler(H http) {
    AccessDeniedHandler deniedHandler = this.accessDeniedHandler;
    if (deniedHandler == null) {
        deniedHandler = createDefaultDeniedHandler(http);
    }
    return deniedHandler;
}

private AccessDeniedHandler createDefaultDeniedHandler(H http) {
    if (this.defaultDeniedHandlerMappings.isEmpty()) {
        return new AccessDeniedHandlerImpl();
    }
    if (this.defaultDeniedHandlerMappings.size() == 1) {
        return this.defaultDeniedHandlerMappings.values().iterator().next();
    }
    return new RequestMatcherDelegatingAccessDeniedHandler(
            this.defaultDeniedHandlerMappings,
            new AccessDeniedHandlerImpl());
}

可以看到,AccessDeniedHandler 实例的获取流程和 AuthenticationEntryPoint 的获取流程基本上一模一样,这里也有一个类似的 defaultDeniedHandlerMappings 变量,也可以为不同的路径配置不同的鉴权失败处理器:如果存在多入鉴权失败处理器,则可以通过代理类统一处理。

不同的是,默认情况下这里的 defaultDeniedHandlerMappings 变量是空的,所以最终获取到的实例是 AccessDeniedHandlerImpl。在 AccessDeniedHandlerImpl#handle 方法中处理鉴权失败的情况,如果存在错误页面,就跳转到到错误页面,并设置响应码为 403;如果没有错误页面,则直接给出错误响应即可。

AuthenticationEntryPoint 和 AccessDeniedHandler 都有了之后,接下来就是 ExceptionTranslationFilter 中的处理逻辑了。

ExceptionTranslationFilter

默认情况下,ExceptionTranslationFilter 过滤器在整个 Spring Security 过滤器链中排名倒数第二,倒数第一是 FilterSecurityInterceptor。在 FilterSecurityInterceptor 中将会对用户的身份进行校验,如果用户身份不合法,就会抛出异常,抛出来的异常,刚好就在 ExceptionTranslationFilter 过滤器中进行处理了。我们一起来看一下 ExceptionTranslationFilter 中的 doFilter 方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    try {
        chain.doFilter(request, response);
    }
    catch (IOException ex) {
        throw ex;
    }
    catch (Exception ex) {
        // Try to extract a SpringSecurityException from the stacktrace
        Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
        RuntimeException ase = (AuthenticationException) throwableAnalyzer
                .getFirstThrowableOfType(AuthenticationException.class, causeChain);

        if (ase == null) {
            ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
                    AccessDeniedException.class, causeChain);
        }

        if (ase != null) {
            if (response.isCommitted()) {
                throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
            }
            handleSpringSecurityException(request, response, chain, ase);
        }
        else {
            // Rethrow ServletExceptions and RuntimeExceptions as-is
            if (ex instanceof ServletException) {
                throw (ServletException) ex;
            }
            else if (ex instanceof RuntimeException) {
                throw (RuntimeException) ex;
            }

            // Wrap other Exceptions. This shouldn't actually happen
            // as we've already covered all the possibilities for doFilter
            throw new RuntimeException(ex);
        }
    }
}

可以看到,在该过滤器中,直接执行了 chain.doFilter 方法,让当前请求继续执行剩下的 过滤器(即 FilterSecurityInterceptor),然后用一个 try{…​}catch(){…​} 代码块将 chain.doFilter 包裹起来,如果后面有异常抛出,就直接在这里捕获到了。

throwableAnalyzer 对象是一个异常分析器,由于异常在抛出的过程中可能被“层层转包”,我们需要还原最初的异常,通过 throwableAnalyzer.determineCauseChain 方法可以获得整个异常链。有的读者可能对此不太理解,这里举一个简单例子,例如如下一段代码:

NullPointException aaa = new NullPointerException("aaa");
ServletException bbb = new ServletException(aaa);
IOException ccc = new IOException(bbb);
ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ccc);
for (int i = 0; i < causeChain.length; i++) {
    System.out.println("causeChain[i].getclass() = "
                        +causechaini.getclass());

打印信息如下:

causeChain[i].getClass() = class java.io.IOException
causeChain[i].getClass() = class javax.servlet.ServletException
causeChain[i].getClass() = class java.lang.NullPointerException

throwableAnalyzer.determineCauseChain 方法的功能就很清楚了:把 “层层转包” 的异常再解析出来形成一个数组。

所以在 catch 块中捕获到异常之后,首先获取异常链,然后调用 getFirstThrowableOfType 方法查看异常链中是否有认证失败类型的异常 AuthenticationException,如果不存在,再去查看是否有鉴权失败类型的异常 AccessDeniedException。注意这个查找顺序,先找认证异常,再找鉴权异常。如果存在这两种类型的异常,则调用 handleSpringSecurityException 方法进行异常处理,否则将异常抛出交给上层容器去处理。

我们来看 handleSpringSecurityException 方法:

private void handleSpringSecurityException(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain, RuntimeException exception)
        throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
        logger.debug(
                "Authentication exception occurred; redirecting to authentication entry point",
                exception);

        sendStartAuthentication(request, response, chain,
                (AuthenticationException) exception);
    }
    else if (exception instanceof AccessDeniedException) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
            logger.debug(
                    "Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point",
                    exception);

            sendStartAuthentication(
                    request,
                    response,
                    chain,
                    new InsufficientAuthenticationException(
                        messages.getMessage(
                            "ExceptionTranslationFilter.insufficientAuthentication",
                            "Full authentication is required to access this resource")));
        }
        else {
            logger.debug(
                    "Access is denied (user is not anonymous); delegating to AccessDeniedHandler",
                    exception);

            accessDeniedHandler.handle(request, response,
                    (AccessDeniedException) exception);
        }
    }
}

在 handleSpringSecurityException 方法中,首先判断异常类型是不是 AuthenticationException ,如果是,则进入 sendStartAuthentication 方法中处理认证失败;如果异常类型是 AccessDeniedException,那么先从 SecurityContextHolder 中取出当前认证主体;如果当前认证主体是一个匿名用户,或者当前认证是通过 RememberMe 完成的,那么也认为是认证异常,需要重新创建一个 InsufficientAuthenticationException 类型的异常对象,然后进入 sendStartAuthentication 方法进行处理,否则就认为是鉴权异常,调用 accessDeniedHandler.handle 方法进行处理。

最后我们再来看看 sendStartAuthentication 方法:

protected void sendStartAuthentication(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain,
        AuthenticationException reason) throws ServletException, IOException {
    // SEC-112: Clear the SecurityContextHolder's Authentication, as the
    // existing Authentication is no longer considered valid
    SecurityContextHolder.getContext().setAuthentication(null);
    requestCache.saveRequest(request, response);
    logger.debug("Calling Authentication entry point.");
    authenticationEntryPoint.commence(request, response, reason);
}

这里做了三件事:

  1. 清除 SecurityContextHolder 中保存的认证主体。

  2. 保存当前请求。

  3. 调用 authenticationEntryPoint.commence 方法完成认证失败处理。

至此,我们前面所说的 AuthenticationEntryPoint 和 AccessDeniedHandler 在这里就派上用场了。