SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

木鯨魚發表於2019-04-14

使用者認證流程

UsernamePasswordAuthenticationFilter

我們直接來看UsernamePasswordAuthenticationFilter類,

public class UsernamePasswordAuthenticationFilter extends
		AbstractAuthenticationProcessingFilter {
    
    public Authentication attemptAuthentication(HttpServletRequest request,
                HttpServletResponse response) throws AuthenticationException {
        // 判斷是否是 POST 請求
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                "Authentication method not supported: " + request.getMethod());
        }
        
        // 獲取請求中的使用者,密碼。
        // 就是最簡單的:request.getParameter(xxx)
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        // 生成 authRequest,本質就是個 usernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);

        // 把 request 請求也一同塞進 token 裡
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        // 將 authRequest 塞進 AuthenticationManager並返回
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}
複製程式碼

attemptAuthentication()方法中:主要是先進行請求判斷並獲取usernamepassword的值,然後再生成一個UsernamePasswordAuthenticationToken物件,將這個物件塞進AuthenticationManager物件並返回,注意:此時的authRequest的許可權是沒有任何值的

UsernamePasswordAuthenticationToken

不過我們可以先看看UsernamePasswordAuthenticationToken的構造方法:

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
    super(null);
    this.principal = principal;
    this.credentials = credentials;
    setAuthenticated(false);
}
複製程式碼

其實UsernamePasswordAuthenticationToken是繼承於Authentication,該物件在學習筆記一中的中"自定義處理登入成功/失敗"章節裡的自定義登入成功裡有提到過,它是處理登入成功回撥方法中的一個引數,裡面包含了使用者資訊、請求資訊等引數。

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

來一張繼承關係圖,對其有個大概的認識,注意到Authentication繼承了Principal

AuthenticationManager

AuthenticationManager是一個介面,它的所有實現類如圖:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

其中一個十分核心的類就是:ProviderManager,在attemptAuthentication()方法最後返回的就是這個類

this.getAuthenticationManager().authenticate(authRequest);
複製程式碼

進入authenticate()方法檢視具體做了什麼:

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()) {
        // 1.判斷是否有provider支援該Authentication
        if (!provider.supports(toTest)) {
            continue;
        }

        if (debug) {
            logger.debug("Authentication attempt using "
                         + provider.getClass().getName());
        }

        try {
            // 2. 真正的邏輯判斷
            result = provider.authenticate(authentication);

            if (result != null) {
                copyDetails(authentication, result);
                break;
            }
        }
        catch (AccountStatusException e) {
           ……
        }
    }

    ……
}
複製程式碼

這裡首先通過 provider 判斷是否支援當前傳入進來的Authentication,目前我們使用的是UsernamePasswordAuthenticationToken,因為除了帳號密碼登入的方式,還會有其他的方式,比如JwtAuthenticationToken

從整體來看Authentication 的實現類如圖:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

官方 API 文件列出了所有的子類

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

從整體來看AuthenticationProvider的實現類如圖:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

官方 API 文件列出了所有的子類

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

根據我們目前所使用的UsernamePasswordAuthenticationToken,provider 對應的是AbstractUserDetailsAuthenticationProvider抽象類的子類DaoAuthenticationProvider,其authenticate()屬於抽象類本身的方法。

public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.onlySupports",
                            "Only UsernamePasswordAuthenticationToken is supported"));

    // Determine username
    String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
        : authentication.getName();

    boolean cacheWasUsed = true;
    // 1.從快取中獲取 UserDetails
    UserDetails user = this.userCache.getUserFromCache(username);

    if (user == null) {
        cacheWasUsed = false;

        try {
            // 2.快取獲取不到,就去介面實現類中獲取
            user = retrieveUser(username,
                                (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (UsernameNotFoundException notFound) {
            ……
        }

        Assert.notNull(user,
                       "retrieveUser returned null - a violation of the interface contract");
    }

    try {
        // 3.使用者資訊預檢查(使用者是否密碼過期,使用者資訊被刪除等)
        preAuthenticationChecks.check(user);
        // 4.附加的檢查(密碼檢查:匹配使用者的密碼和伺服器中的使用者密碼是否一致)
        additionalAuthenticationChecks(user,
                                       (UsernamePasswordAuthenticationToken) authentication);
    }
    catch (AuthenticationException exception) {
        if (cacheWasUsed) {
            // There was a problem, so try again after checking
            // we're using latest data (i.e. not from the cache)
            cacheWasUsed = false;
            user = retrieveUser(username,
                                (UsernamePasswordAuthenticationToken) authentication);
            preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user,
                                           (UsernamePasswordAuthenticationToken) authentication);
        }
        else {
            throw exception;
        }
    }

    // 5.最後的檢查
    postAuthenticationChecks.check(user);

    ……

    // 6.返回真正的經過認證的Authentication 
    return createSuccessAuthentication(principalToReturn, authentication, user);
}
複製程式碼

注意: retrieveUser()的具體方法實現是由DaoAuthenticationProvider類完成的:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {	
	
    protected final UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
            // 獲取使用者資訊
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException(
                        "UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
        }
        catch (UsernameNotFoundException ex) {
            ……
        }
    }
}
複製程式碼

同時createSuccessAuthentication()的方法也是由DaoAuthenticationProvider類來完成的:

// 子類拿 user 物件
@Override
protected Authentication createSuccessAuthentication(Object principal,
                                                     Authentication authentication, UserDetails user) {
    boolean upgradeEncoding = this.userDetailsPasswordService != null
        && this.passwordEncoder.upgradeEncoding(user.getPassword());
    if (upgradeEncoding) {
        String presentedPassword = authentication.getCredentials().toString();
        String newPassword = this.passwordEncoder.encode(presentedPassword);
        user = this.userDetailsPasswordService.updatePassword(user, newPassword);
    }
    // 呼叫父類的方法完成 Authentication 的建立
    return super.createSuccessAuthentication(principal, authentication, user);
}

// 建立已認證的 Authentication
protected Authentication createSuccessAuthentication(Object principal,
			Authentication authentication, UserDetails user) {
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
        principal, authentication.getCredentials(),
        authoritiesMapper.mapAuthorities(user.getAuthorities()));
    result.setDetails(authentication.getDetails());

    return result;
}
複製程式碼

小結:authenticate()的認證邏輯

  1. 去呼叫自己實現的UserDetailsService,返回UserDetails
  2. 對 UserDetails 的資訊進行校驗,主要是帳號是否被凍結,是否過期等
  3. 對密碼進行檢查,這裡呼叫了PasswordEncoder,檢查 UserDetails 是否可用。
  4. 返回經過認證的Authentication

編碼技巧提示:這裡在認證之前使用了Assert.isInstanceOf()進行斷言校驗,方法內部也不斷用了Assert.notNull(),這種編碼非常的靈巧,省去了後續的型別判斷。

這裡的兩次對UserDetails的檢查,主要就是通過它的四個返回 boolean 型別的方法(isAccountNonExpired()isAccountNonLocked()isCredentialsNonExpired()isEnabled())。

經過資訊的校驗之後,通過UsernamePasswordAuthenticationToken的全參構造方法,返回了一個已經過認證的Authentication

拿到經過認證的Authentication之後,至此UsernamePasswordAuthenticationFilter的過濾步驟就完全結束了,之後就會進入BasicAuthenticationFilter,具體來說就是去呼叫successHandler。或者未通過認證,去呼叫failureHandler

已認證資料共享

完成了使用者認證處理流程之後,我們思考一下是如何在多個請求之間共享這個認證結果的呢?因為沒有做關於這方面的配置,所以可以聯想到預設的方式應該是在session中存入了認證結果。思考:那麼是什麼時候存放入session中的呢?

認證流程完畢之後,再看是誰呼叫的它,發現是AbstractAuthenticationProcessingFilterdoFilter()進行呼叫的,這是AbstractAuthenticationProcessingFilter繼承關係結構圖:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

當認證成功之後會呼叫successfulAuthentication(request, response, chain, authResult),該方法中,不僅呼叫了successHandler,還有一行比較重要的程式碼:

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
		implements ApplicationEventPublisherAware, MessageSourceAware {
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // 呼叫了 UsernamePasswordAuthenticationFilter
        authResult = attemptAuthentication(request, response);
        
        ……
        // 呼叫方法,目的是儲存到session   
        successfulAuthentication(request, response, chain, authResult);
    }

    // 將成功認證的使用者資訊儲存到session
    protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
                         + authResult);
        }

        // 儲存到 SecurityContextHolder 的靜態屬性 SecurityContextHolderStrategy 裡, 非常重要的程式碼
        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類中存著 靜態屬性:SecurityContextHolderStrategy
public class SecurityContextHolder {
    ……
    private static SecurityContextHolderStrategy strategy;
    ……

    public static void setContext(SecurityContext context) {
        strategy.setContext(context);
    }
}
複製程式碼

SecurityContextHolderStrategy介面的所有實現類:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

非常顯眼的看出:ThreadLocalSecurityContextHolderStrategy類:

final class ThreadLocalSecurityContextHolderStrategy implements
		SecurityContextHolderStrategy {

    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

    ……

    public void setContext(SecurityContext context) {
        Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
        // 將已認證的使用者物件儲存到 ThreadLocal<SecurityContext> 中
        contextHolder.set(context);
    }
    ……
}
複製程式碼

注意:SecurityContext類的equals()hashCode()方法已經重寫了,用來保證了authentication的唯一性。

身份認證成功後,最後在UsernamePasswordAuthenticationFilter返回後會進入一個AbstractAuthenticationProcessingFilter類中呼叫successfulAuthentication()方法,這個方法最後會返回我們自己定義的登入成功處理器handler

在返回之前,它會呼叫SecurityContext,最後將認證的結果放入SecurityContextHolder中,SecurityContext 類很簡單,重寫了equals() 方法和hashCode()方法,保證了authentication的唯一性。

從程式碼可以看出:SecurityContextHolder類實際上是對ThreadLocal的一個封裝,可以在不同方法之間進行通訊,可以簡單理解為執行緒級別的一個全域性變數。

因此,可以在同一個執行緒中的不同方法中獲取到認證資訊。最後會被SecurityContextPersistenceFilter過濾器使用,這個過濾器的作用是:

當一個請求來的時候,它會將 session 中的值傳入到該執行緒中,當請求返回的時候,它會判斷該請求執行緒是否有 SecurityContext,如果有它會將其放入到 session 中,因此保證了請求結果可以在不同的請求之間共享。

使用者認證流程總結

引用徐靖峰在個人部落格Spring Security(一)--Architecture Overview中的概括性總結,非常的到位:

  1. 使用者名稱和密碼被過濾器獲取到,封裝成Authentication,通常情況下是UsernamePasswordAuthenticationToken這個實現類。
  2. AuthenticationManager 身份管理器負責驗證這個Authentication
  3. 認證成功後,AuthenticationManager身份管理器返回一個被填充滿了資訊的(包括上面提到的許可權資訊,身份資訊,細節資訊,但密碼通常會被移除)Authentication例項。
  4. SecurityContextHolder安全上下文容器將第3步填充了資訊的Authentication,通過SecurityContextHolder.getContext().setAuthentication(…)方法,設定到其中。

高度概括起來本章節所有用的核心認證相關介面:SecurityContextHolder

身份資訊的存放容器,Authentication是身份資訊的抽象,AuthenticationManager是身份認證器,一般常用的是使用者名稱+密碼的身份認證器,還有其它認證器,如郵箱+密碼、手機號碼+密碼等。

再引用一張十分流行的流程圖來表示使用者的認證過程:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

架構概覽圖

為了更加形象的理解,在徐靖峰大佬的經典架構圖之上,根據自己的理解,做了更多的細化和調整:

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

獲取認證使用者資訊

如果我們需要獲取用的校驗過的所有資訊,該如何獲取呢?上面我們知道了會將校驗結果放入 session 中,因此,我們可以通過 session 獲取:

@GetMapping("/me1")
@ResponseBody
public Object getMeDetail() {
    return SecurityContextHolder.getContext().getAuthentication();
}

@GetMapping("/me2")
@ResponseBody
public Object getMeDetail(Authentication authentication){
    return authentication;
}
複製程式碼

在登入成功之後,上面有兩種方式來獲取,訪問上面的請求,就會獲取使用者全部的校驗資訊,包括ip地址等資訊。

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

如果我們只想獲取使用者名稱和密碼以及它的許可權,不需要ip地址等太多的資訊可以使用下面的方式來獲取資訊:

@GetMapping("/me3")
@ResponseBody
public Object getMeDetail(@AuthenticationPrincipal UserDetails userDetails){
    return userDetails;
}
複製程式碼

SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解

參考資料:

www.cnkirito.moe/spring-secu…

blog.csdn.net/u013435893/…

blog.csdn.net/qq_37142346…

個人部落格:woodwhale's blog

部落格園:木鯨魚的部落格

相關文章