松哥手把手帶你捋一遍 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));  	}  }  

根據這段源碼我們可以看出:

  1. 首先通過 obtainUsername 和 obtainPassword 方法提取出請求裡邊的用戶名/密碼出來,提取方式就是 request.getParameter ,這也是為什麼 Spring Security 中默認的表單登錄要通過 key/value 的形式傳遞參數,而不能傳遞 JSON 參數,如果像傳遞 JSON 參數,修改這裡的邏輯即可。
  2. 獲取到請求里傳遞來的用戶名/密碼之後,接下來就構造一個 UsernamePasswordAuthenticationToken 對象,傳入 username 和 password,username 對應了 UsernamePasswordAuthenticationToken 中的 principal 屬性,而 password 則對應了它的 credentials 屬性。
  3. 接下來 setDetails 方法給 details 屬性賦值,UsernamePasswordAuthenticationToken 本身是沒有 details 屬性的,這個屬性在它的父類 AbstractAuthenticationToken 中。details 是一個對象,這個對象裡邊放的是 WebAuthenticationDetails 實例,該實例主要描述了兩個資訊,請求的 remoteAddress 以及請求的 sessionId。
  4. 最後一步,就是調用 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;  }  

這個方法就比較魔幻了,因為幾乎關於認證的重要邏輯都將在這裡完成:

  1. 首先獲取 authentication 的 Class,判斷當前 provider 是否支援該 authentication。
  2. 如果支援,則調用 provider 的 authenticate 方法開始做校驗,校驗完成後,會返回一個新的 Authentication。一會來和大家捋這個方法的具體邏輯。
  3. 這裡的 provider 可能有多個,如果 provider 的 authenticate 方法沒能正常返回一個 Authentication,則調用 provider 的 parent 的 authenticate 方法繼續校驗。
  4. copyDetails 方法則用來把舊的 Token 的 details 屬性拷貝到新的 Token 中來。
  5. 接下來會調用 eraseCredentials 方法擦除憑證資訊,也就是你的密碼,這個擦除方法比較簡單,就是將 Token 中的 credentials 屬性置空。
  6. 最後通過 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);  }  

這裡的邏輯就比較簡單了:

  1. 首先從 Authentication 提取出登錄用戶名。
  2. 然後通過拿著 username 去調用 retrieveUser 方法去獲取當前用戶對象,這一步會調用我們自己在登錄時候的寫的 loadUserByUsername 方法,所以這裡返回的 user 其實就是你的登錄對象,可以參考微人事的 org/javaboy/vhr/service/HrService.java#L34。
  3. 接下來調用 preAuthenticationChecks.check 方法去檢驗 user 中的各個賬戶狀態屬性是否正常,例如賬戶是否被禁用、賬戶是否被鎖定、賬戶是否過期等等。
  4. additionalAuthenticationChecks 方法則是做密碼比對的,好多小夥伴好奇 Spring Security 的密碼加密之後,是如何進行比較的,看這裡就懂了,因為比較的邏輯很簡單,我這裡就不貼程式碼出來了。
  5. 最後在 postAuthenticationChecks.check 方法中檢查密碼是否過期。
  6. 接下來有一個 forcePrincipalAsString 屬性,這個是是否強制將 Authentication 中的 principal 屬性設置為字元串,這個屬性我們一開始在 UsernamePasswordAuthenticationFilter 類中其實就是設置為字元串的(即 username),但是默認情況下,當用戶登錄成功之後, 這個屬性的值就變成當前用戶這個對象了。之所以會這樣,就是因為 forcePrincipalAsString 默認為 false,不過這塊其實不用改,就用 false,這樣在後期獲取當前用戶資訊的時候反而方便很多。
  7. 最後,通過 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

並且基於這個知識點,我給微人事添加了一個個人中心的功能,一起來看下:

http://mpvideo.qpic.cn/0bf2qiaaoaaamyaejo27lrpfbawda6baabya.f10002.mp4?dis_k=606701ec8460917c7378d7e6edcc1ab9&dis_t=1583898260

好了,不知道小夥伴們有沒有學會呢?

Tags: