深入Spring Security魔幻山谷-獲取認證機制核心原理講解(新版)

語言: CN / TW / HK

文/朱季謙

本文基於Springboot+Vue+Spring Security框架而寫的原創學習筆記,demo程式碼參考《Spring Boot+Spring Cloud+Vue+Element專案實戰:手把手教你開發許可權管理系統》一書。

這是一個古老的傳說。

在神祕的Web系統世界裡,有一座名為Spring Security的山谷,它高聳入雲,蔓延千里,鳥飛不過,獸攀不了。這座山谷只有一條逼仄的道路可通。然而,若要通過這條道路前往另一頭的世界,就必須先拿到一塊名為token的令牌,只有這樣,道路上戍守關口的士兵才會放行。

img

想要獲得這塊token令牌,必須帶著一把有用的userName鑰匙和password密碼,進入到山谷深處,找到藏匿寶箱的山洞(資料庫),若能用鑰匙開啟其中一個寶箱,就證明這把userName鑰匙是有用的。正常情況下,寶箱裡會有一塊記錄各種資訊的木牌,包含著鑰匙名和密碼,其密碼只有與你所攜帶的密碼檢驗一致時,才能繼續往前走,得到的通行資訊將會在下一個關口處做認證,進而在道路盡頭處的JWT魔法屋裡獲得加密的token令牌。

慢著,既然山谷關口處有士兵戍守,令牌又在山谷當中,在還沒有獲得令牌的情況下,又怎麼能進入呢?

設定關口的軍官早已想到這種情況,因此,他特意設定了一條自行命名為“login”的道路,沒有令牌的外來人員可從這條道路進入山谷,去尋找傳說中的token令牌。這條道路僅僅只能進入到山谷,卻無法通過山谷到達另一頭的世界,因此,它更像是一條專門為了給外來人員獲取token令牌而開闢出來的道路。

img

這一路上會有各種關口被士兵把守檢查,只有都一一通過了,才能繼續往前走,路上會遇到一位名為ProviderManager的管理員,他管理著所有資訊提供者Provider......需找到一位可正確帶路的資訊提供者Provider,在他的引導下,前往山洞(資料庫),成功獲取到寶箱,拿到裡面記錄資訊的木牌,這樣方能驗證所攜帶的username和password是否正確。若都正確,那麼接下來就可將資訊進行認證,並前往JWT魔法屋獲取token令牌。最後攜帶著token返回到家鄉,讓族人都可穿過山谷而進入到web系統,去獲取更多珍貴的資源。

這就是整個security的遊戲規則原理。

那麼,在遊戲開始之前,我們先了解下當年戍守山谷的軍官是如何設定這道許可權關口的......

關口的自定義設定主要有三部分:通過鑰匙username獲取到寶箱;寶箱裡的UserDetails通行資訊設定;關口通行過往檢查SecurityConfig設定。

1.寶箱裡的通行資訊:

  1 /**
  2  * 安全使用者模型
  3  *
  4  * @author zhujiqian
  5  * @date 2020/7/30 15:27
  6  */
  7 public class JwtUserDetails implements UserDetails {
  8     private static final long serialVersionUID = 1L;
  9 
 10     private String username;
 11     private String password;
 12     private String salt;
 13     private Collection<? extends GrantedAuthority> authorities;
 14 
 15     JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) {
 16         this.username = username;
 17         this.password = password;
 18         this.salt = salt;
 19         this.authorities = authorities;
 20     }
 21 
 22     @Override
 23     public String getUsername() {
 24         return username;
 25     }
 26 
 27     @JsonIgnore
 28     @Override
 29     public String getPassword() {
 30         return password;
 31     }
 32 
 33     public String getSalt() {
 34         return salt;
 35     }
 36 
 37     @Override
 38     public Collection<? extends GrantedAuthority> getAuthorities() {
 39         return authorities;
 40     }
 41 
 42     @JsonIgnore
 43     @Override
 44     public boolean isAccountNonExpired() {
 45         return true;
 46     }
 47 
 48     @JsonIgnore
 49     @Override
 50     public boolean isAccountNonLocked() {
 51         return true;
 52     }
 53 
 54     @JsonIgnore
 55     @Override
 56     public boolean isCredentialsNonExpired() {
 57         return true;
 58     }
 59 
 60     @JsonIgnore
 61     @Override
 62     public boolean isEnabled() {
 63         return true;
 64     }
 65 
 66 }

這裡JwtUserDetails實現Spring Security 裡的UserDetails類,這個類是長這樣的,下面對各個欄位做了註釋:

  1 public interface UserDetails extends Serializable {
  2 	/**
  3 	*使用者許可權集,預設需要新增ROLE_字首
  4 	*/
  5 	Collection<? extends GrantedAuthority> getAuthorities();
  6 
  7 	/**
  8 	*使用者的加密密碼,不加密會使用{noop}字首
  9 	*/
 10 	String getPassword();
 11 
 12 	/**
 13 	*獲取應用裡唯一使用者名稱
 14 	*/
 15 	String getUsername();
 16 
 17 	/**
 18 	*檢查賬戶是否過期
 19 	*/
 20 	boolean isAccountNonExpired();
 21 
 22 	/**
 23 	*檢查賬戶是否鎖定
 24 	*/
 25 	boolean isAccountNonLocked();
 26 
 27 	/**
 28 	*檢查憑證是否過期
 29 	*/
 30 	boolean isCredentialsNonExpired();
 31 
 32 	/**
 33 	*檢查賬戶是否可用
 34 	*/
 35 	boolean isEnabled();
 36 }

說明:JwtUserDetails自定義實現了UserDetails類,增加username和password欄位,除此之外,還可以擴充套件儲存更多使用者資訊,例如,身份證,手機號,郵箱等等。其作用在於可構建成一個使用者安全模型,用於裝載從資料庫查詢出來的使用者及許可權資訊。

2.通過鑰匙username獲取到寶箱方法:

  1 /**
  2  * 使用者登入認證資訊查詢
  3  *
  4  * @author zhujiqian
  5  * @date 2020/7/30 15:30
  6  */
  7 @Service
  8 public class UserDetailsServiceImpl implements UserDetailsService {
  9 
 10     @Resource
 11     private SysUserService sysUserService;
 12 
 13     @Override
 14     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
 15         SysUser user = sysUserService.findByName(username);
 16         if (user == null) {
 17             throw new UsernameNotFoundException("該使用者不存在");
 18         }
 19 
 20         Set<String> permissions = sysUserService.findPermissions(user.getName());
 21         List<GrantedAuthority> grantedAuthorities = permissions.stream().map(AuthorityImpl::new).collect(Collectors.toList());
 22         return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities);
 23     }
 24 }

這個自定義的UserDetailsServiceImpl類實現了Spring Security框架自帶的UserDetailsService介面,這個介面只定義一個簡單的loadUserByUsername方法:

  1 public interface UserDetailsService {
  2 	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
  3 }

根據loadUserByUsername方法名便能看出,這是一個可根據username使用者名稱獲取到User物件資訊的方法,並返回一個UserDetails物件,即前頭的“寶箱裡的通行資訊”,換言之,通過重寫這個方法,我們能在該方法裡實現使用者登入認證資訊的查詢,並返回對應查詢資訊。

綜合以上程式碼,先用開頭提到的寶箱意象做一個總結,即拿著userName這把鑰匙,通過loadUserByUsername這個方法指引,可進入到山洞(資料庫),去尋找能開啟的寶箱(在資料庫裡select查詢userName對應資料),若能開啟其中一個寶箱(即資料庫裡存在userName對應的資料),則獲取寶箱裡的通行資訊(實現UserDetails的JwtUserDetails物件資訊)。

3.關口通行過往檢查設定

自定義的SecurityConfig配置類是SpringBoot整合Spring Security的關鍵靈魂所在。該配置資訊會在springboot啟動時進行載入。其中,authenticationManager() 會建立一個可用於傳token做認證的AuthenticationManager物件,而AuthenticationManagerBuilder中的auth.authenticationProvider()則會建立一個provider提供者,並將userDetailsService注入進去,該userDetailsService的子類被自定義的UserDetailsServiceImpl類繼承,並重寫loadUserByUsername()方法,因此,當原始碼裡執行userDetailsService的loadUserByUsername()方法時,即會執行被重寫的子類loadUserByUsername()方法。

由此可見,在做認證的過程中,只需找到注入userDetailsService的provider物件,即可執行loadUserByUsername去根據username獲取資料庫裡資訊。

那具體是在哪個provider物件?請看下面詳細解析。

  1 @Configuration
  2 @EnableWebSecurity
  3 @EnableGlobalMethodSecurity(prePostEnabled = true)
  4 public class SecurityConfig extends WebSecurityConfigurerAdapter {
  5 
  6     @Resource
  7     private UserDetailsService userDetailsService;
  8 
  9     @Override
 10     public void configure(AuthenticationManagerBuilder auth) {
 11      auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
 12     }
 13 
 14     @Bean
 15     @Override
 16     public AuthenticationManager authenticationManager() throws Exception {
 17         return super.authenticationManager();
 18     }
 19 
 20     @Override
 21     protected void configure(HttpSecurity httpSecurity) throws Exception {
 22         //使用的是JWT,禁用csrf
 23         httpSecurity.cors().and().csrf().disable()
 24                 //設定請求必須進行許可權認證
 25                 .authorizeRequests()
 26                 //跨域預檢請求
 27                 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
 28                 //permitAll()表示所有使用者可認證
 29                 .antMatchers( "/webjars/**").permitAll()
 30                 //首頁和登入頁面
 31                 .antMatchers("/").permitAll()
 32                 .antMatchers("/login").permitAll()
 33                 // 驗證碼
 34                 .antMatchers("/captcha.jpg**").permitAll()
 35                 // 其他所有請求需要身份認證
 36                 .anyRequest().authenticated();
 37         //退出登入處理
 38         httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
 39         //token驗證過濾器
 40         httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
 41     }
 42 }

首先,雙擊SecurityConfig 類裡的JwtAuthenticationProvider——

img

進入到JWTAuthenticationProvider類內部,發現原來該類是繼承了DaoAuthenticationProvider。

img

請注意這段話,很關鍵:

點選setUserDetailsService(userDetailsService)。進入到方法裡面後,發現這裡其實是把UserDetailsService通過set方式依賴注入到DaoAuthenticationProvider類中,換言之,我們接下來在載入完成的框架裡只需通過DaoAuthenticationProvider的getUserDetailsService()方法,便可獲取前面注入的userDetailsService,進而呼叫其子類實現的loadUserByUsername()方法。

看到這裡,您須重點關注一下DaoAuthenticationProvider這個類,它將會在後面再次與我們碰面,而它是一個AuthenticationProvider。

若您還不是很明白AuthenticationProvider究竟是什麼,那就暫且統一把它當做資訊提供者吧,而它是ProviderManager管理員底下其中一個資訊提供者Provider。

img

寫到這裡,還有一個疑問,即security框架是如何將資訊提供者Provider歸納到ProviderManager管理員手下的呢?

解答這個問題,需回到SecurityConfig配置檔案裡,點選authenticationProvider進入到底層方法當中。

img

進入後,裡面是具體的方法實現,大概功能就是把注入了userDetailsService的資訊提供者DaoAuthenticationProvider新增到一個List 集合裡,然後再將集合裡的所有提供者,通過構造器傳入ProviderManager,命名生成一個新的提供者管理員providerManager。這裡面還涵蓋不少細節,感興趣的讀者可自行再擴充套件深入研究。

img

以上,就初步設定好了遊戲規則。

接下來,就是主角上場了。

在所有的遊戲裡,都會有一個主角,而我們這個故事,自然也不例外。

img

此時,在一扇刻著“登入”二字的大門前,有一個小兵正在收拾他的包袱,準備跨過大門,踏上通往Spring Security山谷的道路。他揹負著整個家族賦予的任務,需前往Security山谷,拿到token令牌,只有把它成功帶回來,家族裡的其他成員,才能有機會穿過這座山谷,前往另一頭的神祕世界,獲取到珍貴的資源。

這個小兵,便是我們這故事裡的主角,我把他叫做執行緒,他將帶著整個執行緒家族的希望,尋找可通往神祕系統世界的令牌。

執行緒把族長給予的鑰匙和密碼放進包袱,他回頭看了一眼自己的家鄉,然後揮了揮手,跨過“登入”這扇大門,勇敢地上路了。

執行緒來到戒備森嚴的security關口前,四周望了一眼,忽然發現關口旁立著一塊顯眼的石碑,上面刻著一些符號。他走上前一看,發現原來是當年軍官設定的指令與對應的說明:

  1     @Override
  2     protected void configure(HttpSecurity httpSecurity) throws Exception {
  3         //使用的是JWT,禁用csrf
  4         httpSecurity.cors().and().csrf().disable()
  5                 //設定請求必須進行許可權認證
  6                 .authorizeRequests()
  7                 //跨域預檢請求
  8                 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
  9                 //首頁和登入頁面
 10                 .antMatchers("/login").permitAll()
 11                 // 其他所有請求需要身份認證
 12                 .anyRequest().authenticated();
 13         //退出登入處理
 14         httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
 15         //token驗證過濾器
 16         httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
 17     }

其中,permitAll()代表所有請求都可訪問,當它設定成類似“.antMatchers("/login").permitAll()”的形式時,則代表該/login路徑請求無需認證便可通過,相反,程式碼anyRequest().authenticated()則意味著其他的所有請求都必須進行身份驗證方能通過,否則,會被拒絕訪問。

下面,將通過debug一步一步揭示,執行緒是如何闖關升級的,最後成功獲取到傳說中的token令牌。

執行緒來到關口處,不久,在戍守士兵的指引下,開始往login道路走去,前面迎接他,將是一系列的關口檢查。

1.傳入userName,password屬性,封裝成一個token物件。

img

進入到該物件裡,可看到使用者名稱賦值給this.principal,密碼賦值給this.credentials,其中setAuthenticated(false)意味著尚未進行認證。

img

注意一點是,UsernamePasswordAuthenticationToken繼承了AbstractAuthenticationToken,而AbstractAuthenticationToken實現Authentication,由傳遞關係可知,Authentication是UsernamePasswordAuthenticationToken的基類,故而UsernamePasswordAuthenticationToken是可以向上轉換為Authentication,理解這一點,就能明白,為何接下來authenticationManager.authenticate(token)方法傳進去的是UsernamePasswordAuthenticationToken,但在原始碼裡,方法引數則為Authentication。

2.將username,password封裝成token物件後,通過Authentication authentication=authenticationManager.authenticate(token)方法進行認證,裡面會執行一系列認證操作,需要看懂原始碼,才能知道這行程式碼背後藏著的水月洞天,然,有一點是可以從表面上看懂的,即若成功認證通過,將會返回一個認證成功的Authentication物件,至於物件裡是什麼資訊,請繼續 往下看。

img

3.點選進入到AuthenticationManager裡,發現該接口裡只有一個方法:

  1 Authentication authenticate(Authentication authentication)
  2 			throws AuthenticationException;

由此可知,它的具體實現,是通過實現類來操作的,它的主要實現類有N多個,其中,在認證過程中,我們需關注的是ProviderManager這個類。

img

這個ProviderManager,即前面提到的Provider管理員,他管理著一堆資訊提供者provider。執行緒此行的目的,就是先找到這個Provider管理員,再去管理員手中尋找能夠匹配到的提供者provider,只有通過匹配到的提供者,才能找到獲取資料庫的方法loadUserByUsername。

4.ProviderManager類實際上是實現AuthenticationManager介面,重寫了authenticate方法。因此,當前面程式碼執行authenticationManager.authenticate(token)方法時,具體實現將由其子類重寫的方法操作,子類即ProviderManager。

img

debug進去後——

img

繼續往下執行,通過getProviders() 可獲取到內部維護在List中的AuthenticationProvider遍歷進行驗證,若該提供者能支援傳入的token進行驗證,則繼續往下執行。

img

其中,JwtAuthAuthenticationProvider可執行本次驗證,而JwtAuthAuthenticationProvider是繼承DaoAuthenticationProvider後自定義的類,可以理解成,進行認證驗證的Provider是前面重點提到的DaoAuthenticationProvider。

img

DaoAuthenticationProvider是一個具體實現類,它繼承AbstractUserDetailsAuthenticationProvider抽象類。

img

而AbstractUserDetailsAuthenticationProvider實現了AuthenticationProvider介面。

img

5.在ProviderManager中,執行到result = provider.authenticate(authentication)時,其中provider是由AuthenticationProvider定義的,但AuthenticationProvider是一個介面,需由其子類具體實現。根據上面分析,可知,AbstractUserDetailsAuthenticationProvider會具體實現provider.authenticate(authentication)方法。debug進入到其authenticate方法當中,會跳轉到AbstractUserDetailsAuthenticationProvider重寫的authenticate()方法當中,接下來會詳細介紹該authenticate()執行的程式碼模組:

img

5.1.首先,第一步,會執行this.userCache.getUserFromCache(username)獲取快取裡的資訊。

img

5.2 若快取裡沒有UserDetails資訊,將會繼續往下執行,執行到retrieveUser方法,該方法的總體作用是:通過登入時傳入的userName去資料庫裡做查詢,若查詢成功,便將資料庫的User資訊包裝成UserDetails物件返回,當然,具體如何從資料庫裡獲取到資訊,則需要重寫一個方法,即前面提到的loadUserByUsername()方法。

值得注意一點是,一般新手接觸到security框架,都會有一個疑問,即我登入時傳入了username,是如何獲取到資料庫裡的使用者資訊?

其實,這個疑問的關鍵答案,就藏在這個retrieveUser()方法裡。該方法名的英文解析是:“(訓練成能尋回獵物的)獵犬”。我覺得這個翻譯在這裡很有意思,暫且可以把它當成資訊提供者Provider馴養的一頭獵犬,它可以幫我們的遊戲主角執行緒在茫茫的森林裡,尋找到藏匿寶箱的山洞(資料庫)。

img

5.3 ,接下來,就讓這頭獵犬給我們帶路吧——點選retrieveUser(),進入到方法當中,發現,這其實是一個抽象方法,故而其具體實現將在子類中進行。

img

5.4 進入到其子類實現的方法當中,發現會進入前面提到AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider,它也是一個AuthenticationProvider,即所謂的資訊提供者之一。在DaoAuthenticationProvider類裡,實現了父類的retrieveUser方法。

在獵犬的(retrieveUser)的帶路下,我們最後看到 了熟悉的老朋友,關鍵方法loadUserByUserName()。

img

點進loadUserByUsername()方法裡,會進入到UserDetailsService接口裡,該介面只有loadUserByUsername一個方法,該方法具體在子類裡實現。

img

這個介面被我們自定義重寫了,即前面露過面的:

img

在DaoAuthenticationProvider類中,呼叫loadUserByUserName()方法時,最終會執行我們重寫的loadUserByUsername()方法,該方法將會去資料庫裡查詢username的資訊,並返回一個User物件,最後SysUser物件轉換成UserDetails,返回給DaoAuthenticationProvider物件裡的UserDetails,跳轉如下圖:

img

5.5 DaoAuthenticationProvider的retirieveUser執行完後,會將資料庫查詢到的UserDetails返回給上一層,即AbstractUserDetailsAuthenticationProvider執行的retrieveUser()方法,得到的UserDetails賦值給user。

img

6.接下來就是各種檢查,其中,有一個檢查方法需要特別關注,即

img

注:additionalAuthenticationChecks()方法的作用是檢查密碼是否一致的,前面已根據username去資料庫裡查詢出user資料,接下來,就需要在該方法裡,檢查資料庫裡user的密碼與登入時傳入的密碼是否一致了。

6.1 點選additionalAuthenticationChecks()進入到方法裡,發現AbstractUserDetailsAuthenticationProvider當中的additionalAuthenticationChecks同樣是一個抽象方法,沒有具體實現,它與前面的retrieveUser()方法一樣,具體實現都在AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider中重寫了。

img

6.2.跳轉進入子類重寫的additionalAuthenticationChecks()當中,先通過authentication.getCredentials().toString()從token物件中獲取登入時輸入的密碼,再通過passwordEncoder.matches(presentedPassword, userDetails.getPassword())進行比較,即拿登入的密碼與資料庫裡取出的密碼做對比,執行到這一步,若兩個密碼一致時,即登入的username和password能與資料庫裡某個username和密碼匹配,則可登入成功。

img

7.使用者名稱與密碼都驗證通過後,可繼續執行下一步操作,中間還有幾個檢查方法,讀者若感興趣,可自行研究。最後會把user賦值給一個principalToReturn物件,然後連同authentication還有user,一塊傳入到createSuccessAuthentication方法當中。

img

8.在createSuccessAuthentication方法裡,會建立一個已經認證通過的token。

img

點進該token物件當中,可以看到,這次的setAuthenticated設定成了true,即意味著已經認證通過。

img

最後,將生成一個新的token,並以Authentication物件形式返回到最開始的地方。

img

執行到這一步,就可以把認證通過的資訊進行儲存,到這裡,就完成了核心的認證部分。

接下來,我們的主角執行緒就可以前往JWT魔法屋獲取加密的token令牌,然後攜帶令牌返回故土,屆時,其執行緒家族裡的其他成員,都可穿過這座Spring Security山谷,前往山谷另一邊的web系統世界了。

img

那是另外一個世界的故事,我們將在以後漫長的歲月當中,緩緩道來.....

而這個關於Spring Security山谷的故事,就暫且記到這裡,若當中有不當之處,還需各位大佬指出而加以改進。

img

本文完,插圖皆來自網路。

本文同步分享在 部落格“朱季謙”(CNBlog)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。