深入Spring Security-獲取認證機制核心原理講解
文/朱季謙
本文基於Springboot+Vue+Spring Security框架而寫的原創筆記,demo程式碼參考《Spring Boot+Spring Cloud+Vue+Element專案實戰:手把手教你開發許可權管理系統》一書。能力有限,存在不足還請指出,本文僅當做學習筆記。
在神祕的Web系統世界裡,有一座名為Spring Security的山谷,它高聳入雲,蔓延千里,鳥飛不過,獸攀不了。這座山谷只有一條逼仄的道路可通。然而,若要通過這條道路前往另一頭的世界,就必須先拿到一塊名為token的令牌,只有這樣,道路上戍守關口的士兵才會放行。
想要獲得這個token令牌,必須帶著一把有用的userName鑰匙和password密碼,進入到山谷深處,找到藏匿寶箱的山洞(資料庫),若能用鑰匙開啟其中一個寶箱,就證明這把userName鑰匙是有用的。正常情況下,寶箱裡會有一塊記錄各種資訊的木牌,包含著鑰匙名和密碼,其密碼只有與你所攜帶的密碼檢驗一致時,才能繼續往前走,得到的通行資訊將會在下一個關口處做認證,進而在道路盡頭處的JWT魔法屋裡獲得加密的token令牌。
慢著,既然山谷關口處有士兵戍守,令牌又在山谷當中,在還沒有獲得令牌的情況下,又怎麼能進入呢?
設定關口的軍官早已想到這種情況,因此,他特意設定了一條自行命名為“login”的道路,沒有令牌的外來人員可從這條道路進入山谷,去尋找傳說中的token令牌。這條道路僅僅只能進入到山谷,卻無法通過山谷到達另一頭的世界,因此,它更像是一條專門為了給外來人員獲取token令牌而開闢出來的道路。
這一路上都會有各種關口被士兵把守檢查,只有都一一通過了,才能繼續往前走,路上會遇到一位名為ProviderManager的管理員,他管理著所有資訊提供者Provider......
那麼,在遊戲開始之前,我們先了解下戍守山谷的軍官是如何設定這道許可權關口的......
關口的自定義設定主要有三部分:通過鑰匙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 10private 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介面,它只有一個作用,即對使用者登入認證資訊的查詢,而在它所實現的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的子類我們自定義實現了loadUserByUsername()方法。再做認證的過程中,只需找到注入了userDetailsService的provider物件,即可執行loadUserByUsername去根據username獲取資料庫裡資訊。
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 }
此時,在一扇刻著“登入”二字的大門前,有一個小兵正在收拾他的包袱,準備跨過大門,踏上通往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一步一步揭示,執行緒是如何認證的。
執行緒經過過濾器後,來到登入方法裡,開始進行一系列的檢查操作。
1.傳入userName,password屬性,封裝成一個token物件。
進入到該物件裡,可看到使用者名稱會賦值給this.principal,密碼賦值給this.credentials,其中setAuthenticated(false)意味著尚未進行認證。
注意一點是,UsernamePasswordAuthenticationToken繼承了AbstractAuthenticationToken,而AbstractAuthenticationToken則實現Authentication,由傳遞關係可知,Authentication是UsernamePasswordAuthenticationToken的基類,故而UsernamePasswordAuthenticationToken是可以轉換為Authentication,理解這一點,就能明白,為何接下來authenticationManager.authenticate(token)方法傳進去的是UsernamePasswordAuthenticationToken,但在原始碼裡,方法引數則為Authentication。
2.將username,password封裝成token物件後,將通過Authentication authentication=authenticationManager.authenticate(token)方法進行認證,裡面會執行一系列認證操作,需要看懂原始碼,才能知道這行程式碼背後藏著的密碼,然,有一點是可以從表面上看懂的,即,若認證通過,將會返回認證成功的資訊。
3.進入到這個AuthenticationManager裡,發現該接口裡只有一個方法:
1 Authentication authenticate(Authentication authentication) 2 throws AuthenticationException;
由此可知,它的具體實現,是通過實現類來操作的,它的主要實現類是ProviderManager。
正如前文提到的,ProviderManager是一個提供者的管理者,它底下管理了所有的資訊提供者,首先我們得找到提供者的管理者ProviderManager,再去尋找能夠匹配到的提供者,通過提供者,便可以獲取到資料庫裡的資訊,與登陸時所傳入的資訊做比較,若能比較成功,則證明登陸資訊是對的。
4.ProviderManager類實現AuthenticationManager介面,故而重寫了authenticate方法。
debug進去後
繼續往下執行,通過getProviders() 獲取到內部維護在List中的AuthenticationProvider遍歷進行驗證,若該提供者能支援傳入的token進行驗證,則繼續往下執行。
其中,DaoAuthenticationProvider可執行本次驗證。
DaoAuthenticationProvider是一個具體實現類,它繼承AbstractUserDetailsAuthenticationProvider抽象類。
而AbstractUserDetailsAuthenticationProvider實現了AuthenticationProvider介面。
5.ProviderManager執行到result = provider.authenticate(authentication)時,其中provider是由AuthenticationProvider定義的,但AuthenticationProvider是一個介面,需由其子類具體實現。根據上面分析,可知,AbstractUserDetailsAuthenticationProvider會具體實現provider.authenticate(authentication)方法。debug進入到其authenticate方法當中:
5.1.第一步,先通過this.userCache.getUserFromCache(username)獲取快取裡的資訊(該類快取一般是在xml檔案裡事先定義好,執行到這裡再去獲取,這種方法比較死板,很少用到吧。)
5.2 若快取裡沒有UserDetails資訊,將會繼續往下執行,執行到retrieveUser方法,該方法的作用是,通過登入時傳入的userName去資料庫裡做查詢,若查詢成功,便將資料庫的User資訊包裝成UserDetails物件返回。注意一點是,一般新手接觸到security框架,都會有一個疑問,即我登入時傳入了username,是如何去獲取到資料庫裡的使用者資訊的,其實,關鍵就是在這個方法裡。
5.3 點選跳轉到該方法,發現這是一個抽象方法,故而其具體實現將在子類中進行。
5.4 點選進入到其子類實現的方法當中,發現會進入前面提到AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider,它也是一個AuthenticationProvider,即所謂的資訊提供者。在DaoAuthenticationProvider類裡,實現了父類的retrieveUser方法中,其中,有一個關鍵的方法loadUserByUserName()。
點進loadUserByUsername()方法裡,會進入到UserDetailsService接口裡,該介面只有loadUserByUsername一個方法,該方法具體在子類裡實現。
這個介面被我們自定義重寫了,即:
在DaoAuthenticationProvider類中,呼叫loadUserByUserName()方法時,最終會執行我們重寫的loadUserByUsername()方法,該方法將會去資料庫裡查詢username的資訊,返回SysUser物件,最後SysUser物件轉換成UserDetails,返回給DaoAuthenticationProvider物件裡的UserDetails,跳轉如下圖:
5.5 DaoAuthenticationProvider的retirieveUser執行完後,會將資料庫查詢到的UserDetails返回給上一層,即AbstractUserDetailsAuthenticationProvider執行的retrieveUser()方法,得到的UserDetails賦值給user。
6.接下來就是各種檢查,其中,有一個檢查方法需要特別關注,即
注:additionalAuthenticationChecks()方法的作用是檢查密碼是否一致的,前面已根據username去資料庫裡查詢出user資料,接下來就需要在該方法檢查資料庫裡user的密碼與登入時傳入的密碼是否一致了。
6.1 點選additionalAuthenticationChecks()跳轉到方法裡,發現AbstractUserDetailsAuthenticationProvider當中的additionalAuthenticationChecks是一個抽象方法,沒有具體實現,它與前面的retrieveUser()方法一樣,具體實現都在AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider中具體實現。
6.2.跳轉進入子類實現的方法,方法當中,先通過authentication.getCredentials().toString()從token物件中獲取登入時輸入的密碼,再通過passwordEncoder.matches(presentedPassword, userDetails.getPassword())進行比較,即拿登入的密碼與資料庫裡取出的密碼做對比,執行到這一步,若兩個密碼一致時,即登入的username和password能與資料庫裡某個username和密碼匹配,則可登入成功。
7.使用者名稱與密碼都驗證通過後,則可繼續執行下一步操作,中間還有幾個檢查方法讀者若感興趣,可自行研究。最後會把user賦值給一個principalToReturn物件,然後連同authentication還有user,一塊傳入到createSuccessAuthentication方法當中。
8.在createSuccessAuthentication方法裡,會建立一個已經認證通過的token。
點進該token物件當中,可以看到,這次的setAuthenticated設定成了true,即意味著已經認證通過。
最後,將生成一個新的token,並以Authentication物件形式返回到最開始的地方。
執行到這一步,就可以把認證通過的資訊進行儲存了,到這裡,就完成了核心的認證部分。