有好幾位小伙伴小伙伴曾向松哥求助過這個問題锭硼。
一開始我覺得這可能是一個小概率 BUG,但是當(dāng)問的人多了暑始,我覺得這個問題對于新手來說還有一定的普遍性婴削,有必要來寫篇文章跟大家仔細(xì)聊一聊這個問題嗤朴,防止小伙伴們掉坑。
1.問題復(fù)現(xiàn)
如果使用了 Spring Security虫溜,當(dāng)我們登錄成功后雹姊,可以通過如下方式獲取到當(dāng)前登錄用戶信息:
SecurityContextHolder.getContext().getAuthentication()
- 在 Controller 的方法中,加入 Authentication 參數(shù)
這兩種辦法吼渡,都可以獲取到當(dāng)前登錄用戶信息容为。具體的操作辦法,大家可以看看松哥之前發(fā)布的教程:Spring Security 如何動態(tài)更新已登錄用戶信息寺酪?坎背。
正常情況下,我們通過如上兩種方式的任意一種就可以獲取到已經(jīng)登錄的用戶信息寄雀。
異常情況得滤,就是這兩種方式中的任意一種,都返回 null盒犹。
都返回 null,意味著系統(tǒng)收到當(dāng)前請求時并不知道你已經(jīng)登錄了(因為你沒有在系統(tǒng)中留下任何有效信息),這會帶來兩個問題:
- 無法獲取到當(dāng)前登錄用戶信息奸腺。
- 當(dāng)你發(fā)送任何請求,系統(tǒng)都會給你返回 401副砍。
2.順藤摸瓜
要弄明白這個問題,我們就得明白 Spring Security 中的用戶信息到底是在哪里存的?
前面說了兩種數(shù)據(jù)獲取方式,但是這兩種數(shù)據(jù)獲取方式,獲取到的數(shù)據(jù)又是從哪里來的?
首先松哥之前和大家聊過,SecurityContextHolder 中的數(shù)據(jù),本質(zhì)上是保存在 ThreadLocal
中,ThreadLocal
的特點是存在它里邊的數(shù)據(jù),哪個線程存的呛梆,哪個線程才能訪問到霎终。
這樣就帶來一個問題,當(dāng)不同的請求進(jìn)入到服務(wù)端之后阅茶,由不同的 thread 去處理撞蜂,按理說后面的請求就可能無法獲取到登錄請求的線程存入的數(shù)據(jù)送漠,例如登錄請求在線程 A 中將登錄用戶信息存入 ThreadLocal
爷狈,后面的請求來了羡微,在線程 B 中處理毅哗,那此時就無法獲取到用戶的登錄信息翅睛。
但實際上爬骤,正常情況下坷剧,我們每次都能夠獲取到登錄用戶信息,這又是怎么回事呢胖替?
這我們就要引入 Spring Security 中的 SecurityContextPersistenceFilter
了冲呢。
小伙伴們都知道瓢颅,無論是 Spring Security 還是 Shiro,它的一系列功能其實都是由過濾器來完成的渔嚷,在 Spring Security 中量瓜,松哥前面跟大家聊了 UsernamePasswordAuthenticationFilter
過濾器,在這個過濾器之前,還有一個過濾器就是 SecurityContextPersistenceFilter
,請求在到達(dá) UsernamePasswordAuthenticationFilter
之前都會先經(jīng)過 SecurityContextPersistenceFilter
。
我們來看下它的源碼(部分):
public class SecurityContextPersistenceFilter extends GenericFilterBean {
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);
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
SecurityContextHolder.clearContext();
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
}
}
}
原本的方法很長赠尾,我這里列出來了比較關(guān)鍵的幾個部分:
- SecurityContextPersistenceFilter 繼承自 GenericFilterBean甲棍,而 GenericFilterBean 則是 Filter 的實現(xiàn)唱遭,所以 SecurityContextPersistenceFilter 作為一個過濾器,它里邊最重要的方法就是 doFilter 了呈驶。
- 在 doFilter 方法中拷泽,它首先會從 repo 中讀取一個 SecurityContext 出來,這里的 repo 實際上就是 HttpSessionSecurityContextRepository袖瞻,讀取 SecurityContext 的操作會進(jìn)入到 readSecurityContextFromSession 方法中司致,在這里我們看到了讀取的核心方法
Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
,這里的 springSecurityContextKey 對象的值就是 SPRING_SECURITY_CONTEXT聋迎,讀取出來的對象最終會被轉(zhuǎn)為一個 SecurityContext 對象脂矫。 - SecurityContext 是一個接口,它有一個唯一的實現(xiàn)類 SecurityContextImpl霉晕,這個實現(xiàn)類其實就是用戶信息在 session 中保存的 value庭再。
- 在拿到 SecurityContext 之后捞奕,通過 SecurityContextHolder.setContext 方法將這個 SecurityContext 設(shè)置到 ThreadLocal 中去,這樣拄轻,在當(dāng)前請求中颅围,Spring Security 的后續(xù)操作,我們都可以直接從 SecurityContextHolder 中獲取到用戶信息了恨搓。
- 接下來院促,通過 chain.doFilter 讓請求繼續(xù)向下走(這個時候就會進(jìn)入到
UsernamePasswordAuthenticationFilter
過濾器中了)。 - 在過濾器鏈走完之后斧抱,數(shù)據(jù)響應(yīng)給前端之后常拓,finally 中還有一步收尾操作,這一步很關(guān)鍵辉浦。這里從 SecurityContextHolder 中獲取到 SecurityContext墩邀,獲取到之后,會把 SecurityContextHolder 清空盏浙,然后調(diào)用 repo.saveContext 方法將獲取到的 SecurityContext 存入 session 中。
至此荔茬,整個流程就很明了了废膘。
每一個請求到達(dá)服務(wù)端的時候,首先從 session 中找出來 SecurityContext 慕蔚,然后設(shè)置到 SecurityContextHolder 中去丐黄,方便后續(xù)使用,當(dāng)這個請求離開的時候孔飒,SecurityContextHolder 會被清空灌闺,SecurityContext 會被放回 session 中,方便下一個請求來的時候獲取坏瞄。
搞明白這一點之后桂对,再去解決 Spring Security 登錄后無法獲取到當(dāng)前登錄用戶這個問題,就非常 easy 了鸠匀。
3.問題解決
經(jīng)過上面的分析之后蕉斜,我們再來回顧一下為什么會發(fā)生登錄之后無法獲取到當(dāng)前用戶信息這樣的事情?
最簡單情況的就是你在一個新的線程中去執(zhí)行 SecurityContextHolder.getContext().getAuthentication()
缀棍,這肯定獲取不到用戶信息宅此,無需多說。例如下面這樣:
@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();
}
這種簡單的問題相信大家都能夠很容易排查到爬范。
還有一種隱藏比較深的就是在 SecurityContextPersistenceFilter 的 doFilter 方法中沒能從 session 中加載到用戶信息父腕,進(jìn)而導(dǎo)致 SecurityContextHolder 里邊空空如也。
在 SecurityContextPersistenceFilter 中沒能加載到用戶信息青瀑,原因可能就比較多了璧亮,例如:
- 上一個請求臨走的時候萧诫,沒有將數(shù)據(jù)存儲到 session 中去。
- 當(dāng)前請求自己沒走過濾器鏈杜顺。
什么時候會發(fā)生這個問題呢财搁?有的小伙伴可能在配置 SecurityConfig#configure(WebSecurity) 方法時,會忽略掉一個重要的點躬络。
當(dāng)我們想讓 Spring Security 中的資源可以匿名訪問時尖奔,我們有兩種辦法:
- 不走 Spring Security 過濾器鏈。
- 繼續(xù)走 Spring Security 過濾器鏈穷当,但是可以匿名訪問提茁。
這兩種辦法對應(yīng)了兩種不同的配置方式。其中第一種配置可能會影響到我們獲取登錄用戶信息馁菜,第二種則不影響茴扁,所以這里我們來重點看看第一種。
不想走 Spring Security 過濾器鏈汪疮,我們一般可以通過如下方式配置:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**","/js/**","/index.html","/img/**","/fonts/**","/favicon.ico","/verifyCode");
}
正常這樣配置是沒有問題的峭火。
如果你很不巧,把登錄請求地址放進(jìn)來了智嚷,那就 gg 了卖丸。雖然登錄請求可以被所有人訪問,但是不能放在這里(而應(yīng)該通過允許匿名訪問的方式來給請求放行)盏道。如果放在這里稍浆,登錄請求將不走 SecurityContextPersistenceFilter
過濾器,也就意味著不會將登錄用戶信息存入 session猜嘱,進(jìn)而導(dǎo)致后續(xù)請求無法獲取到登錄用戶信息衅枫。
這也就是一開始小伙伴遇到的問題。
好了朗伶,小伙伴們?nèi)绻谑褂?Spring Security 時遇到類似問題弦撩,不妨按照本文提供的思路來解決一下。如果覺得有收獲论皆,記得點一下右下角在看哦