實戰!Spring Boot Security+JWT前後端分離架構登入認證!

愛撒謊的男孩發表於2021-12-04

大家好,我是不才陳某~

認證、授權是實戰專案中必不可少的部分,而Spring Security則將作為首選安全元件,因此陳某新開了 《Spring Security 進階》 這個專欄,寫一寫從單體架構到OAuth2分散式架構的認證授權。

Spring security這裡就不再過多介紹了,相信大家都用過,也都恐懼過,相比Shiro而言,Spring Security更加重量級,之前的SSM專案更多企業都是用的Shiro,但是Spring Boot出來之後,整合Spring Security更加方便了,用的企業也就多了。

今天陳某就來介紹一下在前後端分離的專案中如何使用Spring Security進行登入認證。文章的目錄如下:

前後端分離認證的思路

前後端分離不同於傳統的web服務,無法使用session,因此我們採用JWT這種無狀態機制來生成token,大致的思路如下:

  1. 客戶端呼叫服務端登入介面,輸入使用者名稱、密碼登入,登入成功返回兩個token,如下:

    1. accessToken:客戶端攜帶這個token訪問服務端的資源
    2. refreshToken:重新整理令牌,一旦accessToken過期了,客戶端需要使用refreshToken重新獲取一個accessToken。因此refreshToken的過期時間一般大於accessToken。
  2. 客戶請求頭中攜帶accessToken訪問服務端的資源,服務端對accessToken進行鑑定(驗籤、是否失效....),如果這個accessToken沒有問題則放行。
  3. accessToken一旦過期需要客戶端攜帶refreshToken呼叫重新整理令牌的介面重新獲取一個新的accessToken

專案搭建

陳某使用的是Spring Boot 框架,演示專案新建了兩個模組,分別是common-basesecurity-authentication-jwt

1、common-base模組

這是一個抽象出來的公共模組,這個模組主要放一些公用的類,目錄如下:

2、security-authentication-jwt模組

一些需要定製的類,比如security的全域性配置類、Jwt登入過濾器的配置類,目錄如下:

3、五張表

許可權設計根據業務的需求往往有不同的設計,陳某用的RBAC規範,主要涉及到五張表,分別是使用者表角色表許可權表使用者<->角色表角色<->許可權表,如下圖:

上述幾張表的SQL會放在案例原始碼中(這幾張表欄位為了省事,設計的並不全,自己根據業務逐步擴充即可)

登入認證過濾器

登入介面的邏輯寫法有很多種,今天陳某介紹一種使用過濾器的定義的登入介面。

Spring Security預設的表單登入認證的過濾器是UsernamePasswordAuthenticationFilter,這個過濾器並不適用於前後端分離的架構,因此我們需要自定義一個過濾器。

邏輯很簡單,參照UsernamePasswordAuthenticationFilter這個過濾器改造一下,程式碼如下:

認證成功處理器AuthenticationSuccessHandler

上述的過濾器介面一旦認證成功,則會呼叫AuthenticationSuccessHandler進行處理,因此我們可以自定義一個認證成功處理器進行自己的業務處理,程式碼如下:

陳某僅僅返回了accessTokenrefreshToken,其他的業務邏輯處理自己完善。

認證失敗處理器AuthenticationFailureHandler

同樣的,一旦登入失敗,比如使用者名稱或者密碼錯誤等等,則會呼叫AuthenticationFailureHandler進行處理,因此我們需要自定義一個認證失敗的處理器,其中根據異常資訊返回特定的JSON資料給客戶端,程式碼如下:

邏輯很簡單,AuthenticationException有不同的實現類,根據異常的型別返回特定的提示資訊即可。

AuthenticationEntryPoint配置

AuthenticationEntryPoint這個介面當使用者未通過認證訪問受保護的資源時,將會呼叫其中的commence()方法進行處理,比如客戶端攜帶的token被篡改,因此我們需要自定義一個AuthenticationEntryPoint返回特定的提示資訊,程式碼如下:

AccessDeniedHandler配置

AccessDeniedHandler這處理器當認證成功的使用者訪問受保護的資源,但是許可權不夠,則會進入這個處理器進行處理,我們可以實現這個處理器返回特定的提示資訊給客戶端,程式碼如下:

UserDetailsService配置

UserDetailsService這個類是用來載入使用者資訊,包括使用者名稱密碼許可權角色集合....其中有一個方法如下:

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

在認證邏輯中Spring Security會呼叫這個方法根據客戶端傳入的username載入該使用者的詳細資訊,這個方法需要完成的邏輯如下:

  • 密碼匹配
  • 載入許可權、角色集合

我們需要實現這個介面,從資料庫載入使用者資訊,程式碼如下:

其中的LoginService是根據使用者名稱從資料庫中查詢出密碼、角色、許可權,程式碼如下:

UserDetails這個也是個介面,其中定義了幾種方法,都是圍繞著使用者名稱密碼許可權+角色集合這三個屬性,因此我們可以實現這個類擴充這些欄位,SecurityUser程式碼如下:

擴充UserDetailsService這個類的實現一般涉及到5張表,分別是使用者表角色表許可權表使用者<->角色對應關係表角色<->許可權對應關係表,企業中的實現必須遵循RBAC設計規則。這個規則陳某後面會詳細介紹。

Token校驗過濾器

客戶端請求頭攜帶了token,服務端肯定是需要針對每次請求解析、校驗token,因此必須定義一個Token過濾器,這個過濾器的主要邏輯如下:

  • 從請求頭中獲取accessToken
  • accessToken解析、驗籤、校驗過期時間
  • 校驗成功,將authentication存入ThreadLocal中,這樣方便後續直接獲取使用者詳細資訊。

上面只是最基礎的一些邏輯,實際開發中還有特定的處理,比如將使用者的詳細資訊放入Request屬性中、Redis快取中,這樣能夠實現feign的令牌中繼效果。

校驗過濾器的程式碼如下:

重新整理令牌介面

accessToken一旦過期,客戶端必須攜帶著refreshToken重新獲取令牌,傳統web服務是放在cookie中,只需要服務端完成重新整理,完全做到無感知令牌續期,但是前後端分離架構中必須由客戶端拿著refreshToken調介面手動重新整理。

程式碼如下:

主要邏輯很簡單,如下:

  • 校驗refreshToken
  • 重新生成accessTokenrefreshToken返回給客戶端。
注意:實際生產中refreshToken令牌的生成方式、加密演算法可以和accessToken不同。

登入認證過濾器介面配置

上述定義了一個認證過濾器JwtAuthenticationLoginFilter,這個是用來登入的過濾器,但是並沒有注入加入Spring Security的過濾器鏈中,需要定義配置,程式碼如下:

/**
 * @author 公號:碼猿技術專欄
 * 登入過濾器的配置類
 */
@Configuration
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    /**
     * userDetailService
     */
    @Qualifier("jwtTokenUserDetailsService")
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登入成功處理器
     */
    @Autowired
    private LoginAuthenticationSuccessHandler loginAuthenticationSuccessHandler;

    /**
     * 登入失敗處理器
     */
    @Autowired
    private LoginAuthenticationFailureHandler loginAuthenticationFailureHandler;

    /**
     * 加密
     */
    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 將登入介面的過濾器配置到過濾器鏈中
     * 1. 配置登入成功、失敗處理器
     * 2. 配置自定義的userDetailService(從資料庫中獲取使用者資料)
     * 3. 將自定義的過濾器配置到spring security的過濾器鏈中,配置在UsernamePasswordAuthenticationFilter之前
     * @param http
     */
    @Override
    public void configure(HttpSecurity http) {
        JwtAuthenticationLoginFilter filter = new JwtAuthenticationLoginFilter();
        filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //認證成功處理器
        filter.setAuthenticationSuccessHandler(loginAuthenticationSuccessHandler);
        //認證失敗處理器
        filter.setAuthenticationFailureHandler(loginAuthenticationFailureHandler);
        //直接使用DaoAuthenticationProvider
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        //設定userDetailService
        provider.setUserDetailsService(userDetailsService);
        //設定加密演算法
        provider.setPasswordEncoder(passwordEncoder);
        http.authenticationProvider(provider);
        //將這個過濾器新增到UsernamePasswordAuthenticationFilter之前執行
        http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

所有的邏輯都在public void configure(HttpSecurity http)這個方法中,如下:

  • 設定認證成功處理器loginAuthenticationSuccessHandler
  • 設定認證失敗處理器loginAuthenticationFailureHandler
  • 設定userDetailService的實現類JwtTokenUserDetailsService
  • 設定加密演算法passwordEncoder
  • JwtAuthenticationLoginFilter這個過濾器加入到過濾器鏈中,直接加入到UsernamePasswordAuthenticationFilter這個過濾器之前。

Spring Security全域性配置

上述僅僅配置了登入過濾器,還需要在全域性配置類做一些配置,如下:

  • 應用登入過濾器的配置
  • 將登入介面、令牌重新整理介面放行,不需要攔截
  • 配置AuthenticationEntryPointAccessDeniedHandler
  • 禁用session,前後端分離+JWT方式不需要session
  • 將token校驗過濾器TokenAuthenticationFilter新增到過濾器鏈中,放在UsernamePasswordAuthenticationFilter之前。

完整配置如下:

/**
 * @author 公眾號:碼猿技術專欄
 * @EnableGlobalMethodSecurity 開啟許可權校驗的註解
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;
    @Autowired
    private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
    @Autowired
    private RequestAccessDeniedHandler requestAccessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                //禁用表單登入,前後端分離用不上
                .disable()
                //應用登入過濾器的配置,配置分離
                .apply(jwtAuthenticationSecurityConfig)

                .and()
                // 設定URL的授權
                .authorizeRequests()
                // 這裡需要將登入頁面放行,permitAll()表示不再攔截,/login 登入的url,/refreshToken重新整理token的url
                //TODO 此處正常專案中放行的url還有很多,比如swagger相關的url,druid的後臺url,一些靜態資源
                .antMatchers(   "/login","/refreshToken")
                .permitAll()
                //hasRole()表示需要指定的角色才能訪問資源
                .antMatchers("/hello").hasRole("ADMIN")
                // anyRequest() 所有請求   authenticated() 必須被認證
                .anyRequest()
                .authenticated()

                //處理異常情況:認證失敗和許可權不足
                .and()
                .exceptionHandling()
                //認證未通過,不允許訪問異常處理器
                .authenticationEntryPoint(entryPointUnauthorizedHandler)
                //認證通過,但是沒許可權處理器
                .accessDeniedHandler(requestAccessDeniedHandler)

                .and()
                //禁用session,JWT校驗不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                //將TOKEN校驗過濾器配置到過濾器鏈中,否則不生效,放到UsernamePasswordAuthenticationFilter之前
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class)
                // 關閉csrf
                .csrf().disable();
    }

    // 自定義的Jwt Token校驗過濾器
    @Bean
    public TokenAuthenticationFilter authenticationTokenFilterBean()  {
        return new TokenAuthenticationFilter();
    }

    /**
     * 加密演算法
     * @return
     */
    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

註釋的很詳細了,有不理解的認真看一下。

案例原始碼已經上傳GitHub,關注公眾號:碼猿技術專欄,回覆關鍵詞:9529 獲取!

測試

1、首先測試登入介面,postman訪問http://localhost:2001/securit...,如下:

可以看到,成功返回了兩個token。

2、請求頭不攜帶token,直接請求http://localhost:2001/securit...,如下:

可以看到,直接進入了EntryPointUnauthorizedHandler這個處理器。

3、攜帶token訪問http://localhost:2001/securit...,如下:

成功訪問,token是有效的。

4、重新整理令牌介面測試,攜帶一個過期的令牌訪問如下:

5、重新整理令牌介面測試,攜帶未過期的令牌測試,如下:

可以看到,成功返回了兩個新的令牌。

原始碼追蹤

以上一系列的配置完全是參照UsernamePasswordAuthenticationFilter這個過濾器,這個是web服務表單登入的方式。

Spring Security的原理就是一系列的過濾器組成,登入流程也是一樣,起初在org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#doFilter()方法,進行認證匹配,如下:

attemptAuthentication()這個方法主要作用就是獲取客戶端傳遞的username、password,封裝成UsernamePasswordAuthenticationToken交給ProviderManager的進行認證,原始碼如下:

ProviderManager主要流程是呼叫抽象類AbstractUserDetailsAuthenticationProvider#authenticate()方法,如下圖:

retrieveUser()方法就是呼叫userDetailService查詢使用者資訊。然後認證,一旦認證成功或者失敗,則會呼叫對應的失敗、成功處理器進行處理。

總結

Spring Security雖然比較重,但是真的好用,尤其是實現Oauth2.0規範,非常簡單方便。

案例原始碼已經上傳GitHub,關注公眾號:碼猿技術專欄,回覆關鍵詞:9529 獲取!

最後說一句(別白嫖,求關注)

陳某每一篇文章都是精心輸出,已經寫了3個專欄,整理成PDF,獲取方式如下:

  1. 《Spring Cloud 進階》PDF:關注公號:【碼猿技術專欄】回覆關鍵詞 Spring Cloud 進階 獲取!
  2. 《Spring Boot 進階》PDF:關注公號:【碼猿技術專欄】回覆關鍵詞 Spring Boot進階 獲取!
  3. 《Mybatis 進階》PDF:關注公號:【碼猿技術專欄】回覆關鍵詞 Mybatis 進階 獲取!

如果這篇文章對你有所幫助,或者有所啟發的話,幫忙點贊在看轉發收藏,你的支援就是我堅持下去的最大動力!

關注公號:【碼猿技術專欄】,公眾號內有超讚的粉絲福利,回覆:加群,可以加入技術討論群,和大家一起討論技術,吹牛逼!

相關文章