摘要

在将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认证通过之后,再做一些额外处理。其配置如下:

<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>

代码如下:

@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不拦截、处理静态资源。相关配置如下:

<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(preAuthenticatedProcessingFilterBasicAuthenticationFilterafterAuthenticatedProcessingFilter)中,只有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方法中。这个断点的具体位置其实没什么关系,只要足够“靠前”,就可以了。因为后来发现问题时,代码已经运行到非常“靠后”的位置上了。

第一层原因

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

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。如下图: SpringSecurity的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之外,afterAuthenticatedProcessingFilterpreAuthenticatedProcessingFilter也被加载了进来。换句话说,同一个请求,在被springSecurityFilterChain处理过一次之后,还会被afterAuthenticatedProcessingFilterpreAuthenticatedProcessingFilter再处理一遍。

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

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

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

很遗憾,都不是。

 

第二层原因

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

这是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. ”

 

这是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