松哥手把手帶你捋一遍 Spring Security 登錄流程
- 2020 年 3 月 11 日
- 筆記
今日乾貨

為什麼想和大家捋一捋 Spring Security 登錄流程呢?這是因為之前小夥伴們的一個提問:如何在 Spring Security 中動態修改用戶資訊?
如果你搞清楚了 Spring Security 登錄流程,這其實不是問題。但是在很多新手小夥伴多次詢問之後,松哥還是決定來和大家仔細捋一捋這個問題(文末有配套影片講解)。
我們先來大致描述一下問題場景:
❝你在服務端的安全管理使用了 Spring Security,用戶登錄成功之後,Spring Security 幫你把用戶資訊保存在 Session 里,但是具體保存在哪裡,要是不深究你可能就不知道, 這帶來了一個問題,如果用戶在前端操作修改了當前用戶資訊,在不重新登錄的情況下,如何獲取到最新的用戶資訊?這就是松哥今天要和搭建介紹的問題。 ❞
1.無處不在的 Authentication
玩過 Spring Security 的小夥伴都知道,在 Spring Security 中有一個非常重要的對象叫做 Authentication,我們可以在任何地方注入 Authentication 進而獲取到當前登錄用戶資訊,Authentication 本身是一個介面,它有很多實現類:

在這眾多的實現類中,我們最常用的就是 UsernamePasswordAuthenticationToken 了,但是當我們打開這個類的源碼後,卻發現這個類平平無奇,他只有兩個屬性、兩個構造方法以及若干個 get/set 方法;當然,他還有更多屬性在它的父類上。
但是從它僅有的這兩個屬性中,我們也能大致看出,這個類就保存了我們登錄用戶的基本資訊。那麼我們的登錄資訊是如何存到這兩個對象中的?這就要來梳理一下登錄流程了。
2.登錄流程
在 Spring Security 中,認證與授權的相關校驗都是在一系列的過濾器鏈中完成的,在這一系列的過濾器鏈中,和認證相關的過濾器就是 UsernamePasswordAuthenticationFilter,篇幅問題,我這裡列出來該類中幾個重要方法:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public UsernamePasswordAuthenticationFilter() { super(new AntPathRequestMatcher("/login", "POST")); } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String username = obtainUsername(request); String password = obtainPassword(request); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } protected String obtainPassword(HttpServletRequest request) { return request.getParameter(passwordParameter); } protected String obtainUsername(HttpServletRequest request) { return request.getParameter(usernameParameter); } protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } } 根據這段源碼我們可以看出:
- 首先通過 obtainUsername 和 obtainPassword 方法提取出請求裡邊的用戶名/密碼出來,提取方式就是 request.getParameter ,這也是為什麼 Spring Security 中默認的表單登錄要通過 key/value 的形式傳遞參數,而不能傳遞 JSON 參數,如果像傳遞 JSON 參數,修改這裡的邏輯即可。
- 獲取到請求里傳遞來的用戶名/密碼之後,接下來就構造一個 UsernamePasswordAuthenticationToken 對象,傳入 username 和 password,username 對應了 UsernamePasswordAuthenticationToken 中的 principal 屬性,而 password 則對應了它的 credentials 屬性。
- 接下來 setDetails 方法給 details 屬性賦值,UsernamePasswordAuthenticationToken 本身是沒有 details 屬性的,這個屬性在它的父類 AbstractAuthenticationToken 中。details 是一個對象,這個對象裡邊放的是 WebAuthenticationDetails 實例,該實例主要描述了兩個資訊,請求的 remoteAddress 以及請求的 sessionId。
- 最後一步,就是調用 authenticate 方法去做校驗了。
好了,從這段源碼中,大家可以看出來請求的各種資訊基本上都找到了自己的位置,找到了位置,這就方便我們未來去獲取了。
接下來我們再來看請求的具體校驗操作。
在前面的 attemptAuthentication 方法中,該方法的最後一步開始做校驗,校驗操作首先要獲取到一個 AuthenticationManager,這裡拿到的是 ProviderManager ,所以接下來我們就進入到 ProviderManager 的 authenticate 方法中,當然這個方法也比較長,我這裡僅僅摘列出來幾個重要的地方:
public Authentication authenticate(Authentication authentication) throws AuthenticationException { Class<? extends Authentication> toTest = authentication.getClass(); for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; } result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } } if (result == null && parent != null) { result = parentResult = parent.authenticate(authentication); } if (result != null) { if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { ((CredentialsContainer) result).eraseCredentials(); } if (parentResult == null) { eventPublisher.publishAuthenticationSuccess(result); } return result; } throw lastException; } 這個方法就比較魔幻了,因為幾乎關於認證的重要邏輯都將在這裡完成:
- 首先獲取 authentication 的 Class,判斷當前 provider 是否支援該 authentication。
- 如果支援,則調用 provider 的 authenticate 方法開始做校驗,校驗完成後,會返回一個新的 Authentication。一會來和大家捋這個方法的具體邏輯。
- 這裡的 provider 可能有多個,如果 provider 的 authenticate 方法沒能正常返回一個 Authentication,則調用 provider 的 parent 的 authenticate 方法繼續校驗。
- copyDetails 方法則用來把舊的 Token 的 details 屬性拷貝到新的 Token 中來。
- 接下來會調用 eraseCredentials 方法擦除憑證資訊,也就是你的密碼,這個擦除方法比較簡單,就是將 Token 中的 credentials 屬性置空。
- 最後通過 publishAuthenticationSuccess 方法將登錄成功的事件廣播出去。
大致的流程,就是上面這樣,在 for 循環中,第一次拿到的 provider 是一個 AnonymousAuthenticationProvider,這個 provider 壓根就不支援 UsernamePasswordAuthenticationToken,也就是會直接在 provider.supports 方法中返回 false,結束 for 循環,然後會進入到下一個 if 中,直接調用 parent 的 authenticate 方法進行校驗。
而 parent 就是 ProviderManager,所以會再次回到這個 authenticate 方法中。再次回到 authenticate 方法中,provider 也變成了 DaoAuthenticationProvider,這個 provider 是支援 UsernamePasswordAuthenticationToken 的,所以會順利進入到該類的 authenticate 方法去執行,而 DaoAuthenticationProvider 繼承自 AbstractUserDetailsAuthenticationProvider 並且沒有重寫 authenticate 方法,所以 我們最終來到 AbstractUserDetailsAuthenticationProvider#authenticate 方法中:
public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication); postAuthenticationChecks.check(user); Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); } 這裡的邏輯就比較簡單了:
- 首先從 Authentication 提取出登錄用戶名。
- 然後通過拿著 username 去調用 retrieveUser 方法去獲取當前用戶對象,這一步會調用我們自己在登錄時候的寫的 loadUserByUsername 方法,所以這裡返回的 user 其實就是你的登錄對象,可以參考微人事的 org/javaboy/vhr/service/HrService.java#L34。
- 接下來調用 preAuthenticationChecks.check 方法去檢驗 user 中的各個賬戶狀態屬性是否正常,例如賬戶是否被禁用、賬戶是否被鎖定、賬戶是否過期等等。
- additionalAuthenticationChecks 方法則是做密碼比對的,好多小夥伴好奇 Spring Security 的密碼加密之後,是如何進行比較的,看這裡就懂了,因為比較的邏輯很簡單,我這裡就不貼程式碼出來了。
- 最後在 postAuthenticationChecks.check 方法中檢查密碼是否過期。
- 接下來有一個 forcePrincipalAsString 屬性,這個是是否強制將 Authentication 中的 principal 屬性設置為字元串,這個屬性我們一開始在 UsernamePasswordAuthenticationFilter 類中其實就是設置為字元串的(即 username),但是默認情況下,當用戶登錄成功之後, 這個屬性的值就變成當前用戶這個對象了。之所以會這樣,就是因為 forcePrincipalAsString 默認為 false,不過這塊其實不用改,就用 false,這樣在後期獲取當前用戶資訊的時候反而方便很多。
- 最後,通過 createSuccessAuthentication 方法構建一個新的 UsernamePasswordAuthenticationToken。
好了,那麼登錄的校驗流程現在就基本和大家捋了一遍了。那麼接下來還有一個問題,登錄的用戶資訊我們去哪裡查找?
3.用戶資訊保存
要去找登錄的用戶資訊,我們得先來解決一個問題,就是上面我們說了這麼多,這一切是從哪裡開始被觸發的?
我們來到 UsernamePasswordAuthenticationFilter 的父類 AbstractAuthenticationProcessingFilter 中,這個類我們經常會見到,因為很多時候當我們想要在 Spring Security 自定義一個登錄驗證碼或者將登錄參數改為 JSON 的時候,我們都需自定義過濾器繼承自 AbstractAuthenticationProcessingFilter ,毫無疑問,UsernamePasswordAuthenticationFilter#attemptAuthentication 方法就是在 AbstractAuthenticationProcessingFilter 類的 doFilter 方法中被觸發的:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; Authentication authResult; try { authResult = attemptAuthentication(request, response); if (authResult == null) { return; } sessionStrategy.onAuthentication(authResult, request, response); } catch (InternalAuthenticationServiceException failed) { unsuccessfulAuthentication(request, response, failed); return; } catch (AuthenticationException failed) { unsuccessfulAuthentication(request, response, failed); return; } if (continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } successfulAuthentication(request, response, chain, authResult); } 從上面的程式碼中,我們可以看到,當 attemptAuthentication 方法被調用時,實際上就是觸發了 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法,當登錄拋出異常的時候,unsuccessfulAuthentication 方法會被調用,而當登錄成功的時候,successfulAuthentication 方法則會被調用,那我們就來看一看 successfulAuthentication 方法:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { SecurityContextHolder.getContext().setAuthentication(authResult); rememberMeServices.loginSuccess(request, response, authResult); // Fire event if (this.eventPublisher != null) { eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( authResult, this.getClass())); } successHandler.onAuthenticationSuccess(request, response, authResult); } 在這裡有一段很重要的程式碼,就是 SecurityContextHolder.getContext().setAuthentication(authResult); ,登錄成功的用戶資訊被保存在這裡,也就是說,在任何地方,如果我們想獲取用戶登錄資訊,都可以從 SecurityContextHolder.getContext() 中獲取到,想修改,也可以在這裡修改。
最後大家還看到有一個 successHandler.onAuthenticationSuccess,這就是我們在 SecurityConfig 中配置登錄成功回調方法,就是在這裡被觸發的,這塊大家也可以參考微人事裡邊的配置 org/javaboy/vhr/config/SecurityConfig。
真相大白!
4.影片講解
最後,松哥也錄了一個簡單的影片教程,來和大家捋一捋:http://mpvideo.qpic.cn/0b78feaaqaaadeagkbtglfpfakodbauqacaa.f10002.mp4?dis_k=3a565bd6bf879efcbf32dbc20313dfed&dis_t=1583898260
並且基於這個知識點,我給微人事添加了一個個人中心的功能,一起來看下:
好了,不知道小夥伴們有沒有學會呢?

