摘要
在将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(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
方法中。这个断点的具体位置其实没什么关系,只要足够“靠前”,就可以了。因为后来发现问题时,代码已经运行到非常“靠后”的位置上了。
第一层原因
中间真的是不知道执行了多少行代码了,突然跳到这样一个代码位置上:
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向下看,它们依次是:
- ApplicationFilterConfig[name=log4jServletFilter, filterClass=org.apache.logging.log4j.web.Log4jServletFilter]
- ApplicationFilterConfig[name=errorPageFilter, filterClass=org.springframework.boot.web.support.ErrorPageFilter]
- ApplicationFilterConfig[name=characterEncodingFilter, filterClass=org.springframework.boot.web.filter.OrderedCharacterEncodingFilter]
- ApplicationFilterConfig[name=hiddenHttpMethodFilter, filterClass=org.springframework.boot.web.filter.OrderedHiddenHttpMethodFilter]
- ApplicationFilterConfig[name=httpPutFormContentFilter, filterClass=org.springframework.boot.web.filter.OrderedHttpPutFormContentFilter]
- ApplicationFilterConfig[name=requestContextFilter, filterClass=org.springframework.boot.web.filter.OrderedRequestContextFilter]
- ApplicationFilterConfig[name=springSecurityFilterChain, filterClass=org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean$1]
- ApplicationFilterConfig[name=afterAuthenticatedProcessingFilter, filterClass=cn.xxx.thread.common.security.AfterAuthenticatedProcessingFilter]
- ApplicationFilterConfig[name=preAuthenticatedProcessingFilter, filterClass=cn.xxx.thread.common.security.PreAuthenticatedProcessingFilter]
- ApplicationFilterConfig[name=org.springframework.security.filterChainProxy, filterClass=org.springframework.security.web.FilterChainProxy]
- ApplicationFilterConfig[name=org.springframework.security.web.access.intercept.FilterSecurityInterceptor#0, filterClass=org.springframework.security.web.access.intercept.FilterSecurityInterceptor]
- 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?
很遗憾,都不是。
第二层原因
第二层原因要靠谷歌了。我搜到了这几个网页:
这是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