SpringSecurity中的Authentication資訊與登入流程

天喬巴夏丶發表於2020-09-08

本篇文章參考於【江南一點雨】的公眾號。

Authentication

使用SpringSecurity可以在任何地方注入Authentication進而獲取到當前登入的使用者資訊,可謂十分強大。

在Authenticaiton的繼承體系中,實現類UsernamePasswordAuthenticationToken 算是比較常見的一個了,在這個類中存在兩個屬性:principal和credentials,其實分別代表著使用者和密碼。【當然其他的屬性存在於其父類中,如authoritiesdetails。】

我們需要對這個物件有一個基本地認識,它儲存了使用者的基本資訊。使用者在登入的時候,進行了一系列的操作,將資訊存與這個物件中,後續我們使用的時候,就可以輕鬆地獲取這些資訊了。

那麼,使用者資訊如何存,又是如何取的呢?繼續往下看吧。

登入流程

一、與認證相關的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;
    }
}
  1. 獲取Class,判斷當前provider是否支援該authentication。

  2. 如果支援,則呼叫provider的authenticate方法開始校驗,校驗完成之後,返回一個新的Authentication。

  3. 將舊的token的details屬性拷貝到新的token中。

  4. 如果上一步的結果為null,呼叫provider的parent的authenticate方法繼續校驗。

  5. 呼叫eraseCredentials方法擦除憑證資訊,也就是密碼,具體來說就是讓credentials為空。

  6. 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);
}
  1. 從Authenticaiton中提取登入的使用者名稱。
  2. retrieveUser方法將會呼叫loadUserByUsername方法,這裡將會返回登入物件。
  3. preAuthenticationChecks.check(user);校驗user中的各個賬戶狀態屬性是否正常,如賬號是否被禁用,賬戶是否被鎖定,賬戶是否過期等。
  4. additionalAuthenticationChecks用於做密碼比對,密碼加密解密校驗就在這裡進行。
  5. postAuthenticationChecks.check(user);用於密碼比對。
  6. forcePrincipalAsString表示是否強制將Authentication中的principal屬性設定為字串,預設為false,也就是說預設登入之後獲取的使用者是物件,而不是username。
  7. 構建新的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());
		}
	}
}
  1. SecurityContextPersistenceFilter 繼承自 GenericFilterBean,而 GenericFilterBean 則是 Filter 的實現,所以 SecurityContextPersistenceFilter 作為一個過濾器,它裡邊最重要的方法就是 doFilter 了。
  2. doFilter 方法中,它首先會從 repo 中讀取一個 SecurityContext 出來,這裡的 repo 實際上就是 HttpSessionSecurityContextRepository,讀取 SecurityContext 的操作會進入到 readSecurityContextFromSession(httpSession) 方法中。
  3. 在這裡我們看到了讀取的核心方法 Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);,這裡的 springSecurityContextKey 物件的值就是 SPRING_SECURITY_CONTEXT,讀取出來的物件最終會被轉為一個 SecurityContext 物件。
  4. SecurityContext 是一個介面,它有一個唯一的實現類 SecurityContextImpl,這個實現類其實就是使用者資訊在 session 中儲存的 value。
  5. 在拿到 SecurityContext 之後,通過 SecurityContextHolder.setContext 方法將這個 SecurityContext 設定到 ThreadLocal 中去,這樣,在當前請求中,Spring Security 的後續操作,我們都可以直接從 SecurityContextHolder 中獲取到使用者資訊了。
  6. 接下來,通過 chain.doFilter 讓請求繼續向下走(這個時候就會進入到 UsernamePasswordAuthenticationFilter 過濾器中了)。
  7. 在過濾器鏈走完之後,資料響應給前端之後,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");
}

相關文章