spring security如何做的user的同步

FonLin發表於2018-01-27

前言

在使用spring security開發的過程中,我們常常會用到這樣的寫法:

UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication();
複製程式碼

來獲取UserDetails,即使是在多執行緒環境下,我們也總是能拿到想要的結果。很奇怪spring security是如何做到的,因此開了這篇文章來分析。

注:本篇文章採用spring security4.2.3版本

spring security的過濾器鏈

首先我們得對spring security的過濾器鏈有一個整體上的認識。

在使用配置web.xml這種開發方式時,我們如果要使用spring security就必須得在web.xml中寫入這樣一段:

<filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>
複製程式碼

這個意味著向tomcat容器註冊一個Filter,它會過濾/*所有的請求。看DelegatingFilterProxy,顧名思義,這是一個代理類,真的過濾操作是由FilterChainProxy來中的內部類VirtualFilterChain來完成的。其中註冊了一定數量的Filter(一般是12個),來達到對請求的許可權管理操作。具體程式碼:

public void doFilter(ServletRequest request, ServletResponse response)
				throws IOException, ServletException {
	//如果每個過濾器都已通過,會轉回tomcat中ApplicationFilterChain
	if (currentPosition == size) {
		if (logger.isDebugEnabled()) {
			logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
					+ " reached end of additional filter chain; proceeding with original chain");
		}

		// Deactivate path stripping as we exit the security filter chain
		this.firewalledRequest.reset();

		originalChain.doFilter(request, response);
	}
	else {
		//按順序取出過濾器   
		currentPosition++;

		Filter nextFilter = additionalFilters.get(currentPosition - 1);

		if (logger.isDebugEnabled()) {
			logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
					+ " at position " + currentPosition + " of " + size
					+ " in additional filter chain; firing Filter: '"
					+ nextFilter.getClass().getSimpleName() + "'");
		}

		nextFilter.doFilter(request, response, this);
	}
}
複製程式碼

在一般情況下,spring security有12個Filter

  • WebAsyncManagerIntegrationFilter:提供了對securityContext和WebAsyncManager的整合,其會把SecurityContext設定到非同步執行緒中,使其也能獲取到使用者上下文認證資訊
  • SecurityContextPersistenceFilter:這個是本篇文章的重點,它會根據策略獲取一個SecurityContext放到SecurityContextHolder中,並且在請求結束後清空
  • HeaderWriterFilter:其會往該請求的Header中新增相應的資訊,在http標籤內部使用security:headers來控制
  • LogoutFilter:匹配URL,預設為/logout,匹配成功後則使用者退出,清除認證資訊.如果有自己的退出邏輯,那麼這個過濾器可以disable
  • UsernamePasswordAuthenticationFilter:登入認證過濾器,根據使用者名稱密碼進行認證
  • ConcurrentSessionFilter:session同步過濾器,主要有兩個功能,一是會重新整理當前session的最後訪問時間,二是判斷當前session是否失效,失效了的話會做退出操作並觸發相應事件。
  • RequestCacheAwareFilter:重新恢復被打斷的請求
  • SecurityContextHolderAwareRequestFilter:將request包裝成HttpServletRequest
  • AnonymousAuthenticationFilter:判斷SecurityContext中是否有一個Authentication物件,如果沒有建立一個新的(AnonymousAuthenticationToken)
  • SessionManagementFilter:檢查session在spring security中是否是失效了(注意不是在web容器中),比如說配置設定了最大session數量為1,那麼之前的session會被設定expired = true
  • ExceptionTranslationFilter:處理AccessDeniedException和AuthenticationException,為java exceptions和HTTP responses提供了橋樑
  • FilterSecurityInterceptor:對http資源做許可權攔截,我們平時設定的不同角色不同許可權訪問就是藉此Filter過濾

ThreadLocal

在詳細介紹SecurityContextPersistenceFilter之前,必須瞭解ThreadLocal這個類,spring中也許多地方用到了ThreadLocal。

先看一下它的百科:

JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal為解決多執行緒程式的併發問題提供了一種新的思路。使用這個工具類可以很簡潔地編寫出優美的多執行緒程式,ThreadLocal並不是一個Thread,而是Thread的區域性變數。

其中說的很清楚,ThreadLocal並不是一個Thread,而是Thread的區域性變數,我想這也是大部分人將其翻譯為“本地執行緒變數”的原因。

然後介紹一下其中的資料結構。

每個Thread會維護一個本地變數ThreadLocalMap,它是HashMap的另一種實現,key是ThreadLocal變數本身,value才是要儲存的值。ThreadLocal本事並不儲存任何值,它只是充當了key的作用。如下圖:

spring security如何做的user的同步
ThreadLocalMap具體實現在這裡我們不做分析,大可以將其想象為一個普通的Map,其中key是ThreadLocal本身,value是要儲存的值。

SecurityContextPersistenceFilter

最關鍵的步驟就是SecurityContextPersistenceFilter這個過濾器了。先介紹下其中關鍵的兩個類:

SecurityContextRepository

顧名思義,是儲存SecurityContext的倉庫,預設實現是HttpSessionSecurityContextRepository,基於session,將SecurityContext用key="SPRING_SECURITY_CONTEXT"存入session中。

Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
複製程式碼

SecurityContextHolder

在請求之間儲存SecurityContext,提供了一系列的靜態方法。使用了策略設計模式,預設使用的策略是ThreadLocalSecurityContextHolderStrategy,這其中便是使用ThreadLocal進行儲存的。

我們再說下該過濾器的大概流程:首先會從SecurityContextRepository中獲取SecurityContext,然後將其設定到SecurityContextHolder中,之後會轉到下一個過濾器。在請求結束之後,清空SecurityContextHolder,並將請求後的SecurityContext在儲存到SecurityContextRepository中。 貼上原始碼:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;
    
        //省略若干語句...

	HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
			response);
	//從session中根據key取出SecurityContext,如果沒有會建立一個新的
	SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

	try {
	        //設定到ThreadLoacal中
		SecurityContextHolder.setContext(contextBeforeChainExecution);

		chain.doFilter(holder.getRequest(), holder.getResponse());

	}
	finally {
		SecurityContext contextAfterChainExecution = SecurityContextHolder
				.getContext();
		// Crucial removal of SecurityContextHolder contents - do this before anything
		// else.
		SecurityContextHolder.clearContext();
		repo.saveContext(contextAfterChainExecution, holder.getRequest(),
				holder.getResponse());
		request.removeAttribute(FILTER_APPLIED);

		if (debug) {
			logger.debug("SecurityContextHolder now cleared, as request processing completed");
		}
	}
	}
複製程式碼

注意:這個finally語句塊是在request經過Filter,到達DispatcherServlet完成業務處理之後才會執行的。所以我們可以在controller中直接使用文章開頭的方式獲取到當前登入使用者。

總結

最後再來梳理一遍流程:

瀏覽器發起一個Http請求到達Tomcat,Tomcat將其封裝成一個Request,先經過Filter,其中經過spring security的SecurityContextPersistenceFilter,從session中取出SecurityContext(如果沒有就建立新的)存入當前執行緒的ThreadLocalMap中,因為是當前執行緒,所以不同的執行緒之間根本互不影響。之後完成Servlet呼叫,執行finally語句塊,清除當前執行緒中ThreadLocalMap對應的SecurityContext,再將其覆蓋session中之前的部分。

這裡多說一句,為什麼在每次請求之後要清空當前執行緒呢?看一下spring的官方api說明:

In an application which receives concurrent requests in a single session, the same SecurityContext instance will be shared between threads. Even though a ThreadLocal is being used, it is the same instance that is retrieved from the HttpSession for each thread. This has implications if you wish to temporarily change the context under which a thread is running. If you just use SecurityContextHolder.getContext(), and call setAuthentication(anAuthentication) on the returned context object, then the Authentication object will change in all concurrent threads which share the same SecurityContext instance. You can customize the behaviour of SecurityContextPersistenceFilter to create a completely new SecurityContext for each request, preventing changes in one thread from affecting another. Alternatively you can create a new instance just at the point where you temporarily change the context. The method SecurityContextHolder.createEmptyContext() always returns a new context instance.

簡而言之,因為SecurityContext,是放在session中的,所有一個session下的request的都是共享一個SecurityContext,也就是會有多個Thread共享一個SecurityContext。如果我們在某一個執行緒中只是想臨時對SecurityContext做點更改,那麼其他執行緒中SecurityContext也會受到影響,這是不被允許的。

參考文獻

Spring Security(二) -- Spring Security的Filter

【java併發】詳解ThreadLocal

Spring Security Reference

相關文章