摘要

在将p模块迁移到Spring Boot框架下的过程中,发现了这样一个问题:在访问静态资源时,我们为SpringSecurity配置的AfterAuthenticatedProcessingFilter会错误地拦截请求,并导致抛出异常。经调研发现,这是Spring Boot自动装配javax.sevlet.Filter导致的问题。


 

问题

在将p迁移到Spring Boot架构下之后,正常启动系统,并访问静态资源(如http://localhost:8080/thread/js/fingerprint.json)时,发生如下异常:

17:20:07,806 INFO [cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter.doFilter] (http-nio-8080-exec-2) url:http://localhost:8080/thread/js/fingerprint.json,uri:{}/thread/js/fingerprint.json^|TraceId.-http-nio-8080-exec-2

17:20:07,813 ERROR [org.springframework.boot.web.support.ErrorPageFilter.forwardToErrorPage] (http-nio-8080-exec-2) Forwarding to error page from request [/js/fingerprint.json] due to exception [null]^|TraceId.-http-nio-8080-exec-2

java.lang.NullPointerException: null

at cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter.isFirstTimeLogin(AfterAuthenticatedProcessingFilter.java:108) ~[thread_common-2015.jar:?]

at cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter.doFilter(AfterAuthenticatedProcfessingFilter.java:84) ~[thread_common-2015.jar:?]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240) ~[catalina.jar:8.0.47]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207) ~[catalina.jar:8.0.47]

at org.springframework.orm.hibernate4.support.OpenSessionInViewFilter.doFilterInternal(OpenSessionInViewFilter.java:151) ~[spring-orm-4.3.10.RELEASE.jar:4.3.10.RELEASE]

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-4.3.10.RELEASE.jar:4.3.10.RELEASE]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240) ~[catalina.jar:8.0.47]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207) ~[catalina.jar:8.0.47]

at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:197) ~[spring-web-4.3.10.RELEASE.jar:4.3.10.RELEASE]

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) ~[spring-web-4.3.10.RELEASE.jar:4.3.10.RELEASE]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240) ~[catalina.jar:8.0.47]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207) ~[catalina.jar:8.0.47]

at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:207) ~[spring-security-web-4.0.3.RELEASE.jar:4.0.3.RELEASE]

at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:176) ~[spring-security-web-4.0.3.RELEASE.jar:4.0.3.RELEASE]

 

其中的AfterAuthenticatedProcessingFilter是在spring-security-common.xml中配置的,用于在BasicAuth认证通过之后,再做一些额外处理。其配置如下:

spring-security-common.xml

<http create-session="stateless" use-expressions="true" auto-config="false" realm="UCredit Inc. Thread"

    entry-point-ref="authenticationEntryPoint">

    <intercept-url pattern="/**" access="isAuthenticated()" />

    <http-basic authentication-details-source-ref="ipAwareWebAuthenticationDetailsSource" />

    <logout delete-cookies="JSESSIONID" invalidate-session="true" success-handler-ref="logoutSuccessHandler" />

    <custom-filter ref="preAuthenticatedProcessingFilter" before="BASIC_AUTH_FILTER" />

    <custom-filter ref="afterAuthenticatedProcessingFilter" after="BASIC_AUTH_FILTER" />

    <headers>

        <frame-options policy="SAMEORIGIN" />

        <cache-control />

        <content-type-options />

        <hsts include-subdomains="false" />

        <xss-protection />

    </headers>

    <csrf disabled="true" />

</http>

代码如下:

AfterAuthenticatedProcessingFilter

@Override

public void doFilter(ServletRequest request, ServletResponse response,

        FilterChain chain) throws IOException, ServletException {

    HttpServletRequest req = (HttpServletRequest) request;

    HttpServletResponse rep = (HttpServletResponse) response;

    //首次登陆校验

    if (AfterAuthenticatedProcessingFilter.isFirstTimeLogin(req, rep)) {

        return;

    }

    // 省略后续代码

}

/**

 * 首次登陆校验

 *

 * @param req

 * @param rep

 * @return

 * @throws IOException

 */

private static boolean isFirstTimeLogin(HttpServletRequest req,

        HttpServletResponse rep) throws IOException {

    User user = SecurityUtils.getUserFromPrincipal(SecurityContextHolder

        .getContext().getAuthentication());

    // 下一行抛出一行,因为这里获取到的user是null

    if (user.getUserType() == UserType.SYSTEM_USER) {

        return false;

    }

    // 省略后续代码


 

然而,我们在工程下的spring-thread.xml中已经做了如下配置,确保SpringSecurity不拦截、处理静态资源。相关配置如下:

spring-security.xml

<http pattern="/js/**" security="none" create-session="stateless" />

<http pattern="/html/**" security="none" create-session="stateless" />

<http pattern="/resources/**" security="none" create-session="stateless" />

 

<beans:import resource="classpath:spring-security-common.xml" />

 

那么,为什么会出现这个异常呢?


 

分析

这个问题最大的疑点在于,为什么我们为静态资源做了security="none"的配置,可是SpringSecurity仍然拦截到了这个请求?其次,为什么SpringSecurity的三个Filter(preAuthenticatedProcessingFilter、BasicAuthenticationFilter、afterAuthenticatedProcessingFilter)中,只有afterAuthenticatedProcessingFilter拦截并处理了静态资源的请求?如果preAuthenticatedProcessingFilter处理了请求,应该会打印相关日志,但始终没有打印出来。如果BasicAuthenticationFilter处理了请求,那么afterAuthenticatedProcessingFilter中获取的user就不会是null了。

 

大家可以来“我猜我猜我猜猜猜”一下,猜猜看是哪儿的问题。我提供几个我猜过的选项:

  • application.properties文件中,context-path配置错了。

  • spring-security.xml中,<http pattern="xxx" ... /> 配置错了。

  • SpringSecurity被加载了两次(SpringSecurity一次、Spring Boot下xxxAutoConfiguration一次)。

  • Spring的web容器被加载了两次。

  • Spring Boot引发版本冲突,导致security="none"对preAuthenticatedProcessingFilter、BasicAuthenticationFilter生效、而对afterAuthenticatedProcessingFilter未生效。

 

 

各种错误的猜想我就不赘述了,直接切入正确轨道上来。切入方式么,还是打断点。

断点位置

一般来说,断点会打在异常堆栈中的某个类/方法上,从而在合适的位置切入到发生异常时的上下文环境中去。但是这次,我把异常堆栈看了又看,始终不能确定断点放在什么地方比较合适。

虽然异常确实发生在at cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter.isFirstTimeLogin(AfterAuthenticatedProcessingFilter.java:108) ~[thread_common-2015.jar:?]这个位置上,但是很显然:代码执行到这里时,一切都已经晚了。我们需要把断点往前移。

但是异常堆栈的前面几行,是其它的Filter的doFilter方法。这些Filter只负责自己的一部分任务,与登录认证无关。因此,这些类也不是合适的断点位置。

再往前呢?再往前是org.apache.catalina包下的类;这些类离“犯罪现场”有点太远了,可能需要经过不知道多少行代码,才能运行到发生问题的位置上去。

 

可是没办法,再往前就是java.lang.Thread.run了。就这样吧。我把断点打在了StandardWrapperValve.invoke方法中。这个断点的具体位置其实没什么关系,只要足够“靠前”,就可以了。因为后来发现问题时,代码已经运行到非常“靠后”的位置上了。

第一层原因

中间真的是不知道执行了多少行代码了,突然跳到这样一个代码位置上:

VirtualFilterChain

private static class VirtualFilterChain implements FilterChain {

    private final FilterChain originalChain;

    private final List<Filter> additionalFilters;

    private final FirewalledRequest firewalledRequest;

    private final int size;

    private int currentPosition = 0;

    private VirtualFilterChain(FirewalledRequest firewalledRequest,

            FilterChain chain, List<Filter> additionalFilters) {

        this.originalChain = chain;

        this.additionalFilters = additionalFilters;

        this.size = additionalFilters.size();

        this.firewalledRequest = firewalledRequest;

    }

    // 省略后面代码

}

 

这段代码很不起眼;可贵的是其中有一个字段“originalChain”:在这个字段中,存放了当前上下文中加载的所有Filter。如下图:

 

图中可见,系统一共加载了12个Filter来拦截、处理当前请求。我们逐个Filter向下看,它们依次是:

  1. ApplicationFilterConfig[name=log4jServletFilter, filterClass=org.apache.logging.log4j.web.Log4jServletFilter]

  2. ApplicationFilterConfig[name=errorPageFilter, filterClass=org.springframework.boot.web.support.ErrorPageFilter]

  3. ApplicationFilterConfig[name=characterEncodingFilter, filterClass=org.springframework.boot.web.filter.OrderedCharacterEncodingFilter]

  4. ApplicationFilterConfig[name=hiddenHttpMethodFilter, filterClass=org.springframework.boot.web.filter.OrderedHiddenHttpMethodFilter]

  5. ApplicationFilterConfig[name=httpPutFormContentFilter, filterClass=org.springframework.boot.web.filter.OrderedHttpPutFormContentFilter]

  6. ApplicationFilterConfig[name=requestContextFilter, filterClass=org.springframework.boot.web.filter.OrderedRequestContextFilter]

  7. ApplicationFilterConfig[name=springSecurityFilterChain, filterClass=org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1]

  8. ApplicationFilterConfig[name=afterAuthenticatedProcessingFilter, filterClass=cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter]

  9. ApplicationFilterConfig[name=preAuthenticatedProcessingFilter, filterClass=cn.xxx.thread.common.security.PreAuthenticatedProcessingFilter]

  10. ApplicationFilterConfig[name=org.springframework.security.filterChainProxy, filterClass=org.springframework.security.web.FilterChainProxy]

  11. ApplicationFilterConfig[name=org.springframework.security.web.access.intercept.FilterSecurityInterceptor#0, filterClass=org.springframework.security.web.access.intercept.FilterSecurityInterceptor]

  12. ApplicationFilterConfig[name=Tomcat WebSocket (JSR356) Filter, filterClass=org.apache.tomcat.websocket.server.WsFilter]

 

发现问题了么?在这些Filter中,除了SpringSecurity的入口springSecurityFilterChain之外,afterAuthenticatedProcessingFilter和preAuthenticatedProcessingFilter也被加载了进来。换句话说,同一个请求,在被springSecurityFilterChain处理过一次之后,还会被afterAuthenticatedProcessingFilter和preAuthenticatedProcessingFilter再处理一遍。

不仅如此,第10个、11个Filter,也是在springSecurityFilterChain中就已经加载过的Filter;它们同样不应该出现在这个Filter列表中。

这样,我们就找到第一层原因:SpringSecurity的Filter被加载了两次。所以“我猜我猜我猜猜猜”的答案,应该是“SpringSecurity被加载了两次(SpringSecurity一次、Spring Boot下xxxAutoConfiguration一次)”。

 

那么,我们只要找到对应的xxxAutoConfiguration,并将它Exclude掉就可以了吧。是哪个AutoConfiguration在这里捣乱呢?SecurityAutoConfiguration?还是SecurityFilterAutoConfiguration?

很遗憾,都不是。

 

第二层原因

 第二层原因要靠谷歌了。我搜到了这几个网页:

Prevent Spring Boot from registering a servlet filter

这是Stack Overflow上的一个问题,问的是怎样防止Spring Boot把SpringSecurity的filterChainProxy注册为一个filter。回头看看上面的12个Filter,filterChainProxy就躺在其中。虽然问题表现上有点不一致,但原因都是一样的。正如这个问题中所说的:

“By default Spring Boot creates a FilterRegistrationBean for every Filter in the application context for which a FilterRegistrationBean doesn't already exist. ”

 

Introduce a mechanism to disable existing filters/servlets beans #2173 

这是GitHub上Spring Boot项目中的一个讨论。可以看到,有不少人都遇到了类似问题。

而关于“bean class that implements javax.servlet.Filter interface is registered to filter automatically”,帖子最后表示,“That's by design”,Spring Boot就是这样设计的。这一点不会变。

 

Disable registration of a Servlet or Filter 

这是Spring Boot官方文档中给出的一个“不加载/注册servlet或filter”的方法。实际上,上面两篇文章中,也都使用了这个方法。

 

Spring Security FilterChainProxy is registered automatically as a Filter #2171

这里提供了问题的另一种解决方案。不过正如dsyer指出的:“That doesn't seem like a great resolution.


 

方案

综合上面分析的原因,我采用了Disable registration of a Servlet or Filter 中提供的方案,把重复加载的SpringSecurity四个Filter都“disable”掉了。代码如下:

@Bean

public FilterRegistrationBean registration(

        AfterAuthenticatedProcessingFilter filter) {

    FilterRegistrationBean registration = new FilterRegistrationBean(

        filter);

    registration.setEnabled(false);

    return registration;

}

@Bean

public FilterRegistrationBean registration1(

        PreAuthenticatedProcessingFilter filter) {

    FilterRegistrationBean registration = new FilterRegistrationBean(

        filter);

    registration.setEnabled(false);

    return registration;

}

@Bean

public FilterRegistrationBean registration2(FilterChainProxy proxy) {

    FilterRegistrationBean registration = new FilterRegistrationBean(proxy);

    registration.setEnabled(false);

    return registration;

}

@Bean

public FilterRegistrationBean registration3(

        FilterSecurityInterceptor proxy) {

    FilterRegistrationBean registration = new FilterRegistrationBean(proxy);

    registration.setEnabled(false);

    return registration;

}

 

配置完成之后,页面测试、断点监控的结果都恢复正常。


 

小结

多啰嗦几句。

从使用xml配置Spring IoC开始,就有“配置优先”还是“约定优先”的争论。Spring Boot的“自动装配”,可以理解为“约定优先”的一种升级版。你看,实现了javax.servlet.Filter接口的bean,就会被注册到web应用的Filter链中去;这其实就是Spring Boot和开发者、或者说和系统之间的“约定”。

从“约定优先”到“自动装配”,主打的都是简化开发工作、提高开发效率。有些情况——也许是80%的情况下,它确实达到了这一目标。但是在另外那20%的情况下,它会带来问题;并且,由于一切都是框架实现、没有人工干预,开发者甚至很难发现问题出在哪儿。因而,这20%的情况,有时要占去开发者80%的时间。

就如这次THREAD系统迁移到Spring Boot下的改造工作:f模块由于Validation和Batch的自动装配引发问题,花费了我一天时间;p模块由于这里记录的这个问题,花费了我近两天的时间。而其他四个模块,总共也就两天半时间,这还包括了a和c这两个“探路”模块。

而且,f和p这两个模块遇到的问题还有些不同。f模块遇到的,是典型的“从传统Spring项目迁移到Spring Boot框架下”时会发生的问题,如果项目一开始就使用Spring Boot,确实可以避免这类情况。但p模块遇到的,是“即使一开始就是Spring Boot项目也照样会遇到会蒙圈会花费两天时间去分析解决”的问题——看看Stack Overflow和GitHub上的讨论吧。

这是我不喜欢“约定优先”,因而也不太喜欢“自动装配”的一点:它们会帮你做很多事情;但有时候做得太多,过犹不及了。

类似的还有hibernate的session管理机制和关联查询机制。session管理机制使得JVM内存和数据库变得透明、统一起来了,开发者只需要操作一下内存对象——调用一下setXxx()方法,hibernate就会在session flush时自动将这个改动写入数据库。关联查询则将复杂的库表关联关系转变成了更简单的Java对象关系,无论多少个join都由hibernate完成。不必再费心费力去写SQL、HQL,开发起来真爽利。

但是,如果我们确实只要修改JVM中的数据、而不想把它持久化呢?如果我们只需要查询某个实体中的一小部分数据、而不想把所有关联表都join一遍呢?我们需要做一些特殊处理来绕开hibernate的自动处理,否则就会出现功能或性能上的问题。这时,原本用来提供便利的框架,反而变成了拦路石。

然而我们还是得使用这些框架,尽管它们不能“按照自己的名分,一分不多、一分不少”地去完成自己的任务。毕竟,在80%的情况下,它们确实给了我们很大的帮助。

不过,绝对不要满足于这80%的便利,而忘记那20%的风险。尽可能的弄清楚它,预防它,在风险转化为问题时尽快地解决它。对系统、对个人,这都是莫大的提高。


 

参考

springboot对静态资源做了afterAuthFilter和preAuthFilter的问题

Prevent Spring Boot from registering a servlet filter 

Introduce a mechanism to disable existing filters/servlets beans #2173 

Disable registration of a Servlet or Filter 

Spring Security FilterChainProxy is registered automatically as a Filter #2171

Spring Security custom authentication filter using Java Config