最簡單易懂的 Spring Security 身份認證流程講解

曾俊傑發表於2019-03-30

最簡單易懂的Spring Security 身份認證流程講解

導言

相信大夥對Spring Security這個框架又愛又恨,愛它的強大,恨它的繁瑣,其實這是一個誤區,Spring Security確實非常繁瑣,繁瑣到讓人生厭。討厭也木有辦法呀,作為JavaEE的工程師們還是要面對的,在開始之前,先打一下比方(比方好可憐):

Spring Security 就像一個行政服務中心,如果我們去裡面辦事,可以辦啥事呢?可以小到諮詢簡單問題、查詢社保資訊,也可以戶籍登記、補辦身份證,同樣也可以大到企業事項、各種複雜的資質辦理。但是我們並不需要跑一次行政服務中心,就挨個把業務全部辦理一遍,現實中沒有這樣的人吧。

啥意思呢,就是說選擇您需要的服務(功能),無視那些不需要的,等有需要的時候再瞭解不遲。這也是給眾多工程師們的一個建議,特別是體系異常龐大的Java系,別動不動就精通,擼遍原始碼之類的,真沒啥意義,我大腦的儲存比較小,人生苦短,沒必要。

回到正題!本文會以一種比較輕鬆的方式展開,不會是堆程式碼。

關於身份認證

Web 身份認證是一個後端工程師永遠無法避開的領域,身份認證Authentication,和授權Authorization是不同的,Authentication指的是使用者身份的認證,並不介入這個使用者能夠做什麼,不能夠做什麼,僅僅是確認存在這個使用者而已。而Authorization授權是建立的認證的基礎上的,存在這個使用者了,再來約定這個使用者能補能夠做一件事,這點大家要區分開。本文講的是Authentication的故事,並不會關注許可權。

熱熱身,讓我們來溫習一下身份認證的方式演變:

  • 先是最著名的入門留言板程式,相信很多做後端的工程師都做過留言板,那是一個基本沒有框架的階段,回想一下是怎麼認證的。表單輸入使用者名稱密碼Submit,然後後端取到資料資料庫查詢,查不到的話無情地丟擲一個異常,哦,密碼錯了;查到了,愉快的將使用者ID和相關資訊加密寫入到Session標識中存起來,響應寫入Cookie,後續的請求都解密後驗證就行了,對吧。是的,身認證真可以簡單到僅僅是匹配Session標識而已。令人沮喪的是現代網際網路的發展早已經過了 Web2.0 的時代,客戶端的出現讓身份認證更加複雜。我們繼續

  • 隨著移動端的崛起,Android和ios佔據主導,同樣是使用者登入認證,取到使用者資訊,正準備按圖索驥寫入Session回寫Cookie的時候,等等!啥?Android不支援Cookie?這聽起來不科學是吧,有點反人類是吧,有點手足無措是吧。

    嘿嘿,聰明的人兒也許想到了辦法,嗯,Android客戶端不是有本地儲存嗎?把回傳的資料存起來不就行了嗎?又要抱歉了,Android本地儲存並沒有瀏覽器Cookie那麼人性化,不會自動過期。沒事,再註明過期時間,每次讀取的時候判斷就行啦,貌似可以了。

    等等。客戶端的Api介面要求輕量級,某一天一個隊友想實現個性化的事情,竟然往Cookie了回傳了一串字串,貌似很方便,嗯。於是其他隊友也效仿,然後Cookie變得更加複雜。此時Android隊友一聲吼,你們夠了!STOP!我只要一個認證標識而已,夠簡單你們知道嗎?還有Cookie過期了就要重新登陸,使用者體驗極差,產品經理都找我談了幾十次了,使用者都快跑光了,你們還在往Cookie里加一些奇怪的東西。

  • Oauth 2.0來了

有問題總要想辦法解決是吧。客戶端不是瀏覽器,有自己特有的互動約定,Cookie還是放棄掉了。這裡就要解決五個問題:

  • 只需要簡單的一個字串標識,不需要遵守Cookie的規則
  • 伺服器端需要能夠輕鬆認證這個標識,最好是做成標準化
  • 不要讓使用者反覆輸入密碼登入,能夠自動重新整理
  • 這段祕鑰要安全,從網路傳輸鏈路層到客戶端本地層都要是安全的,就算被中途捕獲,也可以讓其失效
  • 多個子系統的客戶端需要獨立的認證標識,讓他們能夠獨立存在(例如淘寶的認證狀態不會影響到阿里旺旺的登入認證狀態)

需求一旦確定,方案呼之欲出,讓我們來簡單構思一下。

  • 首先是標識,這個最簡單了,將使用者標識資料進行可逆加密,OK,這個搞定。
  • 然後是標識認證的標準化,最好輕量級,並且讓她不干擾請求的表現方式,例如Get和Post資料,聰明的你想到了吧,沒錯,就是Header,我們暫且就統一成 Userkey 為Header名,值就是那個加密過的標識,夠簡潔粗暴吧,後端對每一個請求都攔截處理,如果能夠解密成功並且表示有效,就告訴後邊排隊的小夥伴,這個傢伙是自己人,叫xxx,兜裡有100塊錢。這個也搞定了。
  • 自動重新整理,因為加密標識每次請求都要傳輸,不能放在一起了,而且他們的作用也不一樣,那就頒發加密標識的時候順便再頒發一個重新整理的祕鑰吧,相當於入職的時候給你一張門禁卡,這個卡需要隨身攜帶,開門簽到少不了它,此外還有一張身份證明,這證明就不需要隨身攜帶了,放家裡都行,門禁卡掉了,沒關係,拿著證明到保安大哥那裡再領一張門禁卡,證明一次有效,領的時候保安大哥貼心的再給你一張證明。
  • 安全問題,加密可以加強一部分安全性。傳輸鏈路還用說嗎?上Https傳輸加密喲。至於客戶端本地的安全是一個哲學問題,嗯嗯嗯。哈哈。我們暫時認為本地私有空間儲存是安全的的,俗話說得好,計算機都被人破解了,還談個雞毛安全呀(所以大家沒事還是不要去ROOT手機了,ROOT之後私有儲存可以被訪問儂造嗎)
  • 子系統獨立問題,這個好辦了。身份認證過程再加入一個因子,暫且叫 Client 吧。這樣標識就互不影響了。

打完收工,要開始實現這套系統了。先別急呀,難道沒覺得似曾相識嗎?沒錯就是 Oauth 2.0 的 password Grant 模式!

Spring Security 是怎麼認證的

先來一段大家很熟悉的程式碼:

http.formLogin()
                    .loginPage("/auth/login")
                    .permitAll()
                    .failureHandler(loginFailureHandler)
                    .successHandler(loginSuccessHandler);
複製程式碼

Spring Security 就像一個害羞的大姑娘,就這麼一段鬼知道他是怎麼認證的,封裝的有點過哈。不著急先看一張圖:

最簡單易懂的 Spring Security 身份認證流程講解

這裡做了一個簡化,

根據JavaEE的流程,本質就是Filter過濾請求,轉發到不同處理模組處理,最後經過業務邏輯處理,返回Response的過程。

當請求匹配了我們定義的Security Filter的時候,就會導向Security 模組進行處理,例如UsernamePasswordAuthenticationFilter,原始碼獻上:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

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

            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.usernameParameter);
    }

    protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return this.usernameParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }
}
複製程式碼

有點複雜是吧,不用擔心,我來做一些虛擬碼,讓他看起來更友善,更好理解。注意我寫的單行註釋

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        //1.匹配URL和Method
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            //啥?你沒有用POST方法,給你一個異常,自己反思去
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            //從請求中獲取引數
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            //我不知道使用者名稱密碼是不是對的,所以構造一個未認證的Token先
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
            //順便把請求和Token存起來
            this.setDetails(request, token);
            //Token給誰處理呢?當然是給當前的AuthenticationManager嘍
            return this.getAuthenticationManager().authenticate(token);
        }
    }
}
複製程式碼

是不是很清晰,問題又來了,Token是什麼鬼?為啥還有已認證和未認證的區別?彆著急,我們們順藤摸瓜,來看看Token長啥樣。上UsernamePasswordAuthenticationToken:

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 510L;
    private final Object principal;
    private Object credentials;

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

    public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    public Object getCredentials() {
        return this.credentials;
    }

    public Object getPrincipal() {
        return this.principal;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}
複製程式碼

一坨坨的真鬧心,我再備註一下:

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 510L;
    //隨便怎麼理解吧,暫且理解為認證標識吧,沒看到是一個Object麼
    private final Object principal;
    //同上
    private Object credentials;

    //這個構造方法用來初始化一個沒有認證的Token例項
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
        super((Collection)null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }
	//這個構造方法用來初始化一個已經認證的Token例項,為啥要多此一舉,不能直接Set狀態麼,不著急,往後看
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }
	//便於理解無視他
    public Object getCredentials() {
        return this.credentials;
    }
	//便於理解無視他
    public Object getPrincipal() {
        return this.principal;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            //如果是Set認證狀態,就無情的給一個異常,意思是:
            //不要在這裡設定已認證,不要在這裡設定已認證,不要在這裡設定已認證
            //應該從構造方法裡建立,別忘了要帶上使用者資訊和許可權列表哦
            //原來如此,是避免犯錯吧
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
            super.setAuthenticated(false);
        }
    }

    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}
複製程式碼

搞清楚了Token是什麼鬼,其實只是一個載體而已啦。接下來進入核心環節,AuthenticationManager是怎麼處理的。這裡我簡單的過渡一下,但是會讓你明白。

AuthenticationManager會註冊多種AuthenticationProvider,例如UsernamePassword對應的DaoAuthenticationProvider,既然有多種選擇,那怎麼確定使用哪個Provider呢?我擷取了一段原始碼,大家一看便知:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;

    boolean supports(Class<?> var1);
}
複製程式碼

這是一個介面,我喜歡介面,簡潔明瞭。裡面有一個supports方法,返回時一個boolean值,引數是一個Class,沒錯,這裡就是根據Token的類來確定用什麼Provider來處理,大家還記得前面的那段程式碼嗎?

 //Token給誰處理呢?當然是給當前的AuthenticationManager嘍
 return this.getAuthenticationManager().authenticate(token);
複製程式碼

因此我們進入下一步,DaoAuthenticationProvider,繼承了AbstractUserDetailsAuthenticationProvider,恭喜您再堅持一會就到曙光啦。這個比較複雜,為了不讓你跑掉,我將兩個複雜的類合併,摘取直接觸達介面核心的邏輯,直接上程式碼,會有所刪減,讓你看得更清楚,注意看註釋:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    //熟悉的supports,需要UsernamePasswordAuthenticationToken
    public boolean supports(Class<?> authentication) {
            return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
        }

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        	//取出Token裡儲存的值
            String username = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
            boolean cacheWasUsed = true;
        	//從快取取
            UserDetails user = this.userCache.getUserFromCache(username);
            if (user == null) {
                cacheWasUsed = false;

                //啥,沒快取?使用retrieveUser方法獲取呀
                user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
            }
            //...刪減了一大部分,這樣更簡潔
            Object principalToReturn = user;
            if (this.forcePrincipalAsString) {
                principalToReturn = user.getUsername();
            }

            return this.createSuccessAuthentication(principalToReturn, authentication, user);
        }
         protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        try {
            //熟悉的loadUserByUsername
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException var4) {
            this.mitigateAgainstTimingAttack(authentication);
            throw var4;
        } catch (InternalAuthenticationServiceException var5) {
            throw var5;
        } catch (Exception var6) {
            throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
        }
    }
	//檢驗密碼
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        if (authentication.getCredentials() == null) {
            this.logger.debug("Authentication failed: no credentials provided");
            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        } else {
            String presentedPassword = authentication.getCredentials().toString();
            if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
                this.logger.debug("Authentication failed: password does not match stored value");
                throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
        }
    }
}
複製程式碼

到此為止,就完成了使用者名稱密碼的認證校驗邏輯,根據認證使用者的資訊,系統做相應的Session持久化和Cookie回寫操作。

Spring Security的基本認證流程先寫到這裡,其實複雜的背後是一些預定,熟悉了之後就不難了。

Filter->構造Token->AuthenticationManager->轉給Provider處理->認證處理成功後續操作或者不通過拋異常

有了這些基礎,後面我們再來擴充套件簡訊驗證碼登入,以及基於Oauth 2.0 的簡訊驗證碼登入。

相關文章