SpringSecurity中的Authentication資訊與登錄流程
- 2020 年 9 月 8 日
- 筆記
- spring, SpringSecurity
本篇文章參考於【江南一點雨】的公眾號。
Authentication
使用SpringSecurity可以在任何地方注入Authentication進而獲取到當前登錄的用戶資訊,可謂十分強大。
在Authenticaiton的繼承體系中,實現類UsernamePasswordAuthenticationToken 算是比較常見的一個了,在這個類中存在兩個屬性:principal和credentials,其實分別代表著用戶和密碼。【當然其他的屬性存在於其父類中,如authorities
和details
。】
我們需要對這個對象有一個基本地認識,它保存了用戶的基本資訊。用戶在登錄的時候,進行了一系列的操作,將資訊存與這個對象中,後續我們使用的時候,就可以輕鬆地獲取這些資訊了。
那麼,用戶資訊如何存,又是如何取的呢?繼續往下看吧。
登錄流程
一、與認證相關的UsernamePasswordAuthenticationFilter
通過Servlet中的Filter技術進行實現,通過一系列內置的或自定義的安全Filter,實現介面的認證與授權。
比如:UsernamePasswordAuthenticationFilter
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
//獲取用戶名和密碼
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
//構造UsernamePasswordAuthenticationToken對象
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// 為details屬性賦值
setDetails(request, authRequest);
// 調用authenticate方法進行校驗
return this.getAuthenticationManager().authenticate(authRequest);
}
獲取用戶名和密碼
從request中提取參數,這也是SpringSecurity默認的表單登錄需要通過key/value形式傳遞參數的原因。
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
構造UsernamePasswordAuthenticationToken對象
傳入獲取到的用戶名和密碼,而用戶名對應UPAT對象中的principal屬性,而密碼對應credentials屬性。
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
//UsernamePasswordAuthenticationToken 的構造器
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
為details屬性賦值
// Allow subclasses to set the "details" property 允許子類去設置這個屬性
setDetails(request, authRequest);
protected void setDetails(HttpServletRequest request,
UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
//AbstractAuthenticationToken 是UsernamePasswordAuthenticationToken的父類
public void setDetails(Object details) {
this.details = details;
}
details屬性存在於父類之中,主要描述兩個資訊,一個是remoteAddress 和sessionId。
public WebAuthenticationDetails(HttpServletRequest request) {
this.remoteAddress = request.getRemoteAddr();
HttpSession session = request.getSession(false);
this.sessionId = (session != null) ? session.getId() : null;
}
調用authenticate方法進行校驗
this.getAuthenticationManager().authenticate(authRequest)
二、ProviderManager的校驗邏輯
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
//獲取Class,判斷當前provider是否支援該authentication
if (!provider.supports(toTest)) {
continue;
}
//如果支援,則調用provider的authenticate方法開始校驗
result = provider.authenticate(authentication);
//將舊的token的details屬性拷貝到新的token中。
if (result != null) {
copyDetails(authentication, result);
break;
}
}
//如果上一步的結果為null,調用provider的parent的authenticate方法繼續校驗。
if (result == null && parent != null) {
result = parentResult = parent.authenticate(authentication);
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
//調用eraseCredentials方法擦除憑證資訊
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
//publishAuthenticationSuccess將登錄成功的事件進行廣播。
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
}
-
獲取Class,判斷當前provider是否支援該authentication。
-
如果支援,則調用provider的authenticate方法開始校驗,校驗完成之後,返回一個新的Authentication。
-
將舊的token的details屬性拷貝到新的token中。
-
如果上一步的結果為null,調用provider的parent的authenticate方法繼續校驗。
-
調用eraseCredentials方法擦除憑證資訊,也就是密碼,具體來說就是讓credentials為空。
-
publishAuthenticationSuccess將登錄成功的事件進行廣播。
三、AuthenticationProvider的authenticate
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
//從Authenticaiton中提取登錄的用戶名。
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
//返回登錄對象
user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
//校驗user中的各個賬戶狀態屬性是否正常
preAuthenticationChecks.check(user);
//密碼比對
additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
//密碼比對
postAuthenticationChecks.check(user);
Object principalToReturn = user;
//表示是否強制將Authentication中的principal屬性設置為字元串
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
//構建新的UsernamePasswordAuthenticationToken
return createSuccessAuthentication(principalToReturn, authentication, user);
}
- 從Authenticaiton中提取登錄的用戶名。
retrieveUser
方法將會調用loadUserByUsername
方法,這裡將會返回登錄對象。preAuthenticationChecks.check(user);
校驗user中的各個賬戶狀態屬性是否正常,如帳號是否被禁用,賬戶是否被鎖定,賬戶是否過期等。additionalAuthenticationChecks
用於做密碼比對,密碼加密解密校驗就在這裡進行。postAuthenticationChecks.check(user);
用於密碼比對。forcePrincipalAsString
表示是否強制將Authentication中的principal屬性設置為字元串,默認為false,也就是說默認登錄之後獲取的用戶是對象,而不是username。- 構建新的
UsernamePasswordAuthenticationToken
。
用戶資訊保存
我們來到UsernamePasswordAuthenticationFilter 的父類AbstractAuthenticationProcessingFilter 中,
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
Authentication authResult;
try {
//實際觸發了上面提到的attemptAuthentication方法
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);
}
關於登錄成功調用的方法:
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
//將登陸成功的用戶資訊存儲在SecurityContextHolder.getContext()中
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().getAuthentication()
即可。 - 如果我們想要更新用戶資訊,我們只需要調用
SecurityContextHolder.getContext().setAuthentication(authResult);
即可。
用戶資訊的獲取
前面說到,我們可以利用Authenticaiton輕鬆得到用戶資訊,主要有下面幾種方法:
- 通過上下文獲取。
SecurityContextHolder.getContext().getAuthentication();
- 直接在Controller注入Authentication。
@GetMapping("/hr/info")
public Hr getCurrentHr(Authentication authentication) {
return ((Hr) authentication.getPrincipal());
}
為什麼多次請求可以獲取同樣的資訊
前面已經談到,SpringSecurity將登錄用戶資訊存入SecurityContextHolder 中,本質上,其實是存在ThreadLocal中,為什麼這麼說呢?
原因在於,SpringSecurity採用了策略模式,在SecurityContextHolder 中定義了三種不同的策略,而如果我們不配置,默認就是MODE_THREADLOCAL
模式。
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
private static void initialize() {
if (!StringUtils.hasText(strategyName)) {
// Set default
strategyName = MODE_THREADLOCAL;
}
if (strategyName.equals(MODE_THREADLOCAL)) {
strategy = new ThreadLocalSecurityContextHolderStrategy();
}
}
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();
了解這個之後,又有一個問題拋出:ThreadLocal能夠保證同一執行緒的數據是一份,那進進出出之後,執行緒更改,又如何保證登錄的資訊是正確的呢。
這裡就要說到一個比較重要的過濾器:SecurityContextPersistenceFilter
,它的優先順序很高,僅次於WebAsyncManagerIntegrationFilter
。也就是說,在進入後面的過濾器之前,將會先來到這個類的doFilter方法。
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;
if (request.getAttribute(FILTER_APPLIED) != null) {
// 確保這個過濾器只應對一個請求
chain.doFilter(request, response);
return;
}
//分岔路口之後,表示應對多個請求
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
response);
//用戶資訊在 session 中保存的 value。
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
//將當前用戶資訊存入上下文
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
//收尾工作,獲取SecurityContext
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
//清空SecurityContext
SecurityContextHolder.clearContext();
//重新存進session中
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
}
}
}
SecurityContextPersistenceFilter
繼承自GenericFilterBean
,而GenericFilterBean
則是 Filter 的實現,所以SecurityContextPersistenceFilter
作為一個過濾器,它裡邊最重要的方法就是doFilter
了。- 在
doFilter
方法中,它首先會從 repo 中讀取一個SecurityContext
出來,這裡的 repo 實際上就是HttpSessionSecurityContextRepository
,讀取SecurityContext
的操作會進入到readSecurityContextFromSession(httpSession)
方法中。 - 在這裡我們看到了讀取的核心方法
Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
,這裡的springSecurityContextKey
對象的值就是SPRING_SECURITY_CONTEXT
,讀取出來的對象最終會被轉為一個SecurityContext
對象。 SecurityContext
是一個介面,它有一個唯一的實現類SecurityContextImpl
,這個實現類其實就是用戶資訊在 session 中保存的 value。- 在拿到
SecurityContext
之後,通過SecurityContextHolder.setContext
方法將這個SecurityContext
設置到ThreadLocal
中去,這樣,在當前請求中,Spring Security 的後續操作,我們都可以直接從SecurityContextHolder
中獲取到用戶資訊了。 - 接下來,通過
chain.doFilter
讓請求繼續向下走(這個時候就會進入到UsernamePasswordAuthenticationFilter
過濾器中了)。 - 在過濾器鏈走完之後,數據響應給前端之後,finally 中還有一步收尾操作,這一步很關鍵。這裡從
SecurityContextHolder
中獲取到SecurityContext
,獲取到之後,會把SecurityContextHolder
清空,然後調用repo.saveContext
方法將獲取到的SecurityContext
存入 session 中。
總結:
每個請求到達服務端的時候,首先從session中找出SecurityContext ,為了本次請求之後都能夠使用,設置到SecurityContextHolder 中。
當請求離開的時候,SecurityContextHolder 會被清空,且SecurityContext 會被放回session中,方便下一個請求來獲取。
資源放行的兩種方式
用戶登錄的流程只有走過濾器鏈,才能夠將資訊存入session中,因此我們配置登錄請求的時候需要使用configure(HttpSecurity http),因為這個配置會走過濾器鏈。
http.authorizeRequests()
.antMatchers("/hello").permitAll()
.anyRequest().authenticated()
而 configure(WebSecurity web)不會走過濾器鏈,適用於靜態資源的放行。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/index.html","/img/**","/fonts/**","/favicon.ico");
}