奇怪,Spring Security 登錄成功後總是獲取不到登錄用戶信息?

有好幾位小夥伴小夥伴曾向松哥求助過這個問題。

一開始我覺得這可能是一個小概率 BUG,但是當問的人多了,我覺得這個問題對於新手來說還有一定的普遍性,有必要來寫篇文章跟大家仔細聊一聊這個問題,防止小夥伴們掉坑。

1.問題復現

如果使用了 Spring Security,當我們登錄成功後,可以通過如下方式獲取到當前登錄用戶信息:

  1. SecurityContextHolder.getContext().getAuthentication()
  2. 在 Controller 的方法中,加入 Authentication 參數

這兩種辦法,都可以獲取到當前登錄用戶信息。具體的操作辦法,大家可以看看松哥之前發佈的教程:Spring Security 如何動態更新已登錄用戶信息?。

正常情況下,我們通過如上兩種方式的任意一種就可以獲取到已經登錄的用戶信息。

異常情況,就是這兩種方式中的任意一種,都返回 null。

都返回 null,意味著系統收到當前請求時並不知道你已經登錄了(因為你沒有在系統中留下任何有效信息),這會帶來兩個問題:

  1. 無法獲取到當前登錄用戶信息。
  2. 當你發送任何請求,系統都會給你返回 401。

2.順藤摸瓜

要弄明白這個問題,我們就得明白 Spring Security 中的用戶信息到底是在哪裡存的?

前面說了兩種數據獲取方式,但是這兩種數據獲取方式,獲取到的數據又是從哪裡來的?

首先松哥之前和大家聊過,SecurityContextHolder 中的數據,本質上是保存在 ThreadLocal 中,ThreadLocal 的特點是存在它裡邊的數據,哪個線程存的,哪個線程才能訪問到。

這樣就帶來一個問題,當不同的請求進入到服務端之後,由不同的 thread 去處理,按理說後面的請求就可能無法獲取到登錄請求的線程存入的數據,例如登錄請求在線程 A 中將登錄用戶信息存入 ThreadLocal,後面的請求來了,在線程 B 中處理,那此時就無法獲取到用戶的登錄信息。

但實際上,正常情況下,我們每次都能夠獲取到登錄用戶信息,這又是怎麼回事呢?

這我們就要引入 Spring Security 中的 SecurityContextPersistenceFilter 了。

小夥伴們都知道,無論是 Spring Security 還是 Shiro,它的一系列功能其實都是由過濾器來完成的,在 Spring Security 中,松哥前面跟大家聊了 UsernamePasswordAuthenticationFilter 過濾器,在這個過濾器之前,還有一個過濾器就是 SecurityContextPersistenceFilter,請求在到達 UsernamePasswordAuthenticationFilter 之前都會先經過 SecurityContextPersistenceFilter。

我們來看下它的源碼(部分):

<code>public class SecurityContextPersistenceFilter extends GenericFilterBean {
\tpublic void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
\t\t\tthrows IOException, ServletException {
\t\tHttpServletRequest request = (HttpServletRequest) req;
\t\tHttpServletResponse response = (HttpServletResponse) res;
\t\tHttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
\t\t\t\tresponse);
\t\tSecurityContext contextBeforeChainExecution = repo.loadContext(holder);
\t\ttry {
\t\t\tSecurityContextHolder.setContext(contextBeforeChainExecution);
\t\t\tchain.doFilter(holder.getRequest(), holder.getResponse());
\t\t}
\t\tfinally {
\t\t\tSecurityContext contextAfterChainExecution = SecurityContextHolder
\t\t\t\t\t.getContext();
\t\t\tSecurityContextHolder.clearContext();
\t\t\trepo.saveContext(contextAfterChainExecution, holder.getRequest(),
\t\t\t\t\tholder.getResponse());
\t\t}
\t}
}/<code>

原本的方法很長,我這裡列出來了比較關鍵的幾個部分:

  1. SecurityContextPersistenceFilter 繼承自 GenericFilterBean,而 GenericFilterBean 則是 Filter 的實現,所以 SecurityContextPersistenceFilter 作為一個過濾器,它裡邊最重要的方法就是 doFilter 了。
  2. 在 doFilter 方法中,它首先會從 repo 中讀取一個 SecurityContext 出來,這裡的 repo 實際上就是 HttpSessionSecurityContextRepository,讀取 SecurityContext 的操作會進入到 readSecurityContextFromSession 方法中,在這裡我們看到了讀取的核心方法 Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);,這裡的 springSecurityContextKey 對象的值就是 SPRING_SECURITY_CONTEXT,讀取出來的對象最終會被轉為一個 SecurityContext 對象。
  3. SecurityContext 是一個接口,它有一個唯一的實現類 SecurityContextImpl,這個實現類其實就是用戶信息在 session 中保存的 value。
  4. 在拿到 SecurityContext 之後,通過 SecurityContextHolder.setContext 方法將這個 SecurityContext 設置到 ThreadLocal 中去,這樣,在當前請求中,Spring Security 的後續操作,我們都可以直接從 SecurityContextHolder 中獲取到用戶信息了。
  5. 接下來,通過 chain.doFilter 讓請求繼續向下走(這個時候就會進入到 UsernamePasswordAuthenticationFilter 過濾器中了)。
  6. 在過濾器鏈走完之後,數據響應給前端之後,finally 中還有一步收尾操作,這一步很關鍵。這裡從 SecurityContextHolder 中獲取到 SecurityContext,獲取到之後,會把 SecurityContextHolder 清空,然後調用 repo.saveContext 方法將獲取到的 SecurityContext 存入 session 中。

至此,整個流程就很明瞭了。

每一個請求到達服務端的時候,首先從 session 中找出來 SecurityContext ,然後設置到 SecurityContextHolder 中去,方便後續使用,當這個請求離開的時候,SecurityContextHolder 會被清空,SecurityContext 會被放回 session 中,方便下一個請求來的時候獲取。

搞明白這一點之後,再去解決 Spring Security 登錄後無法獲取到當前登錄用戶這個問題,就非常 easy 了。

3.問題解決

經過上面的分析之後,我們再來回顧一下為什麼會發生登錄之後無法獲取到當前用戶信息這樣的事情?

最簡單情況的就是你在一個新的線程中去執行 SecurityContextHolder.getContext().getAuthentication(),這肯定獲取不到用戶信息,無需多說。例如下面這樣:

<code>@GetMapping("/menu")
public List<menu> getMenusByHrId() {
new Thread(new Runnable() {
@Override
public void run() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
System.out.println(authentication);
}
}).start();
return menuService.getMenusByHrId();
}/<menu>/<code>

這種簡單的問題相信大家都能夠很容易排查到。

還有一種隱藏比較深的就是在 SecurityContextPersistenceFilter 的 doFilter 方法中沒能從 session 中加載到用戶信息,進而導致 SecurityContextHolder 裡邊空空如也。

在 SecurityContextPersistenceFilter 中沒能加載到用戶信息,原因可能就比較多了,例如:

  • 「上一個請求臨走的時候,沒有將數據存儲到 session 中去。」
  • 「當前請求自己沒走過濾器鏈。」

什麼時候會發生這個問題呢?有的小夥伴可能在配置 SecurityConfig#configure(WebSecurity) 方法時,會忽略掉一個重要的點。

當我們想讓 Spring Security 中的資源可以匿名訪問時,我們有兩種辦法:

  1. 不走 Spring Security 過濾器鏈。
  2. 繼續走 Spring Security 過濾器鏈,但是可以匿名訪問。

這兩種辦法對應了兩種不同的配置方式。其中第一種配置可能會影響到我們獲取登錄用戶信息,第二種則不影響,所以這裡我們來重點看看第一種。

不想走 Spring Security 過濾器鏈,我們一般可以通過如下方式配置:

<code>@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode");
}/<code>

正常這樣配置是沒有問題的。

如果你很不巧,把登錄請求地址放進來了,那就 gg 了。雖然登錄請求可以被所有人訪問,但是不能放在這裡(而應該通過允許匿名訪問的方式來給請求放行)。「如果放在這裡,登錄請求將不走 SecurityContextPersistenceFilter 過濾器,也就意味著不會將登錄用戶信息存入 session,進而導致後續請求無法獲取到登錄用戶信息。」

這也就是一開始小夥伴遇到的問題。

好了,小夥伴們如果在使用 Spring Security 時遇到類似問題,不妨按照本文提供的思路來解決一下。「如果覺得有收穫,記得點一下右下角在看哦」


分享到:


相關文章: