天天看點

SpringBoot+SpringSecurity誤攔截靜态資源問題調研

摘要

在将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.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:197) ~[spring-web-4.3.10.RELEASE.jar:4.3.10.RELEASE]

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向下看,它們依次是:

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?

很遺憾,都不是。

第二層原因

 第二層原因要靠谷歌了。我搜到了這幾個網頁:

<a href="https://stackoverflow.com/questions/28421966/prevent-spring-boot-from-registering-a-servlet-filter">Prevent Spring Boot from registering a servlet filter</a>

這是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就是這樣設計的。這一點不會變。

這是Spring Boot官方文檔中給出的一個“不加載/注冊servlet或filter”的方法。實際上,上面兩篇文章中,也都使用了這個方法。

<a href="https://github.com/spring-projects/spring-boot/issues/2171">Spring Security FilterChainProxy is registered automatically as a Filter #2171</a>

這裡提供了問題的另一種解決方案。不過正如dsyer指出的:“That doesn't seem like a great resolution.”

方案

@Bean

public FilterRegistrationBean registration(

        AfterAuthenticatedProcessingFilter filter) {

    FilterRegistrationBean registration = new FilterRegistrationBean(

        filter);

    registration.setEnabled(false);

    return registration;

public FilterRegistrationBean registration1(

        PreAuthenticatedProcessingFilter filter) {

public FilterRegistrationBean registration2(FilterChainProxy proxy) {

    FilterRegistrationBean registration = new FilterRegistrationBean(proxy);

public FilterRegistrationBean registration3(

        FilterSecurityInterceptor proxy) {

配置完成之後,頁面測試、斷點監控的結果都恢複正常。

小結

多啰嗦幾句。

從使用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%的風險。盡可能的弄清楚它,預防它,在風險轉化為問題時盡快地解決它。對系統、對個人,這都是莫大的提高。

本文轉自 斯然在天邊 51CTO部落格,原文連結:http://blog.51cto.com/winters1224/2052034,如需轉載請自行聯系原作者