通過Spring Security魔幻山谷講解獲取認證機制核心原理
本文基於Springboot+Vue+Spring Security框架而寫的原創學習筆記,demo代碼參考《Spring Boot+Spring Cloud+Vue+Element項目實戰:手把手教你開發權限管理系統》一書。
這是一個古老的傳說。
在神秘的Web系統世界裡,有一座名為SpringSecurity的山谷,它高聳入雲,蔓延千裡,鳥飛不過,獸攀不瞭。這座山谷隻有一條逼仄的道路可通。然而,若要通過這條道路前往另一頭的世界,就必須先拿到一塊名為token的令牌,隻有這樣,道路上戍守關口的士兵才會放行。
想要獲得這塊token令牌,必須帶著一把有用的userName鑰匙和password密碼,進入到山谷深處,找到藏匿寶箱的山洞(數據庫),若能用鑰匙打開其中一個寶箱,就證明這把userName鑰匙是有用的。正常情況下,寶箱裡會有一塊記錄各種信息的木牌,包含著鑰匙名和密碼,其密碼隻有與你所攜帶的密碼檢驗一致時,才能繼續往前走,得到的通行信息將會在下一個關口處做認證,進而在道路盡頭處的JWT魔法屋裡獲得加密的token令牌。
慢著,既然山谷關口處有士兵戍守,令牌又在山谷當中,在還沒有獲得令牌的情況下,又怎麼能進入呢?
設置關口的軍官早已想到這種情況,因此,他特意設置瞭一條自行命名為“login”的道路,沒有令牌的外來人員可從這條道路進入山谷,去尋找傳說中的token令牌。這條道路僅僅隻能進入到山谷,卻無法通過山谷到達另一頭的世界,因此,它更像是一條專門為瞭給外來人員獲取token令牌而開辟出來的道路。
這一路上會有各種關口被士兵把守檢查,隻有都一一通過瞭,才能繼續往前走,路上會遇到一位名為ProviderManager的管理員,他管理著所有信息提供者Provider……需找到一位可正確帶路的信息提供者Provider,在他的引導下,前往山洞(數據庫),成功獲取到寶箱,拿到裡面記錄信息的木牌,這樣方能驗證所攜帶的username和password是否正確。若都正確,那麼接下來就可將信息進行認證,並前往JWT魔法屋獲取token令牌。最後攜帶著token返回到傢鄉,讓族人都可穿過山谷而進入到web系統,去獲取更多珍貴的資源。
這就是整個security的遊戲規則原理。
那麼,在遊戲開始之前,我們先瞭解下當年戍守山谷的軍官是如何設置這道權限關口的……
關口的自定義設置主要有三部分:通過鑰匙username獲取到寶箱;寶箱裡的UserDetails通行信息設置;關口通行過往檢查SecurityConfig設置。
一.寶箱裡的通行信息:
/** * 安全用戶模型 * * @author zhujiqian * @date 2020/7/ 15: */ public class JwtUserDetails implements UserDetails { private static final long serialVersionUID = 1L; private String username; private String password; private String salt; private Collection<? extends GrantedAuthority> authorities; JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) { this.username = username; this.password = password; this.salt = salt; this.authorities = authorities; } @Override public String getUsername() { return username; } @JsonIgnore @Override public String getPassword() { return password; } public String getSalt() { return salt; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @JsonIgnore @Override public boolean isAccountNonExpired() { return true; } @JsonIgnore @Override public boolean isAccountNonLocked() { return true; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return true; } @JsonIgnore @Override public boolean isEnabled() { return true; } }
這裡JwtUserDetails實現Spring Security 裡的UserDetails類,這個類是長這樣的,下面對各個字段做瞭註釋:
public interface UserDetails extends Serializable { /** *用戶權限集,默認需要添加ROLE_前綴 */ Collection<? extends GrantedAuthority> getAuthorities(); /** *用戶的加密密碼,不加密會使用{noop}前綴 */ String getPassword(); /** *獲取應用裡唯一用戶名 */ String getUsername(); /** *檢查賬戶是否過期 */ boolean isAccountNonExpired(); /** *檢查賬戶是否鎖定 */ boolean isAccountNonLocked(); /** *檢查憑證是否過期 */ boolean isCredentialsNonExpired(); /** *檢查賬戶是否可用 */ boolean isEnabled(); }
說明:JwtUserDetails自定義實現瞭UserDetails類,增加username和password字段,除此之外,還可以擴展存儲更多用戶信息,例如,身份證,手機號,郵箱等等。其作用在於可構建成一個用戶安全模型,用於裝載從數據庫查詢出來的用戶及權限信息。
二.通過鑰匙username獲取到寶箱方法:
/** * 用戶登錄認證信息查詢 * * @author zhujiqian * @date 2020/7/30 15:30 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { @Resource private SysUserService sysUserService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = sysUserService.findByName(username); if (user == null) { throw new UsernameNotFoundException("該用戶不存在"); } Set<String> permissions = sysUserService.findPermissions(user.getName()); List<GrantedAuthority> grantedAuthorities = permissions.stream().map(AuthorityImpl::new).collect(Collectors.toList()); return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities); } }
這個自定義的UserDetailsServiceImpl類實現瞭Spring Security框架自帶的UserDetailsService接口,這個接口隻定義一個簡單的loadUserByUsername方法:
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
根據loadUserByUsername方法名便能看出,這是一個可根據username用戶名獲取到User對象信息的方法,並返回一個UserDetails對象,即前頭的“寶箱裡的通行信息”,換言之,通過重寫這個方法,我們能在該方法裡實現用戶登錄認證信息的查詢,並返回對應查詢信息。
綜合以上代碼,先用開頭提到的寶箱意象做一個總結,即拿著userName這把鑰匙,通過loadUserByUsername這個方法指引,可進入到山洞(數據庫),去尋找能打開的寶箱(在數據庫裡select查詢userName對應數據),若能打開其中一個寶箱(即數據庫裡存在userName對應的數據),則獲取寶箱裡的通行信息(實現UserDetails的JwtUserDetails對象信息)。
三.關口通行過往檢查設置
自定義的SecurityConfig配置類是SpringBoot整合Spring Security的關鍵靈魂所在。該配置信息會在springboot啟動時進行加載。其中,authenticationManager()會創建一個可用於傳token做認證的AuthenticationManager對象,而AuthenticationManagerBuilder中的auth.authenticationProvider()則會創建一個provider提供者,並將userDetailsService註入進去,該userDetailsService的子類被自定義的UserDetailsServiceImpl類繼承,並重寫loadUserByUsername()方法,因此,當源碼裡執行userDetailsService的loadUserByUsername()方法時,即會執行被重寫的子類loadUserByUsername()方法。
由此可見,在做認證的過程中,隻需找到註入userDetailsService的provider對象,即可執行loadUserByUsername去根據username獲取數據庫裡信息。
那具體是在哪個provider對象?請看下面詳細解析。
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private UserDetailsService userDetailsService; @Override public void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService)); } @Bean @Override public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { //使用的是JWT,禁用csrf httpSecurity.cors().and().csrf().disable() //設置請求必須進行權限認證 .authorizeRequests() //跨域預檢請求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //permitAll()表示所有用戶可認證 .antMatchers( "/webjars/**").permitAll() //首頁和登錄頁面 .antMatchers("/").permitAll() .antMatchers("/login").permitAll() // 驗證碼 .antMatchers("/captcha.jpg**").permitAll() // 其他所有請求需要身份認證 .anyRequest().authenticated(); //退出登錄處理 httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()); //token驗證過濾器 httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class); } }
首先,雙擊SecurityConfig 類裡的JwtAuthenticationProvider——
進入到JWTAuthenticationProvider類內部,發現原來該類是繼承瞭DaoAuthenticationProvider。
請註意這段話,很關鍵:
點擊setUserDetailsService(userDetailsService)。進入到方法裡面後,發現這裡其實是把UserDetailsService通過set方式依賴註入到DaoAuthenticationProvider類中,換言之,我們接下來在加載完成的框架裡隻需通過DaoAuthenticationProvider的getUserDetailsService()方法,便可獲取前面註入的userDetailsService,進而調用其子類實現的loadUserByUsername()方法。
看到這裡,您須重點關註一下DaoAuthenticationProvider這個類,它將會在後面再次與我們碰面,而它是一個AuthenticationProvider。
若您還不是很明白AuthenticationProvider究竟是什麼,那就暫且統一把它當做信息提供者吧,而它是ProviderManager管理員底下其中一個信息提供者Provider。
寫到這裡,還有一個疑問,即security框架是如何將信息提供者Provider歸納到ProviderManager管理員手下的呢?
解答這個問題,需回到SecurityConfig配置文件裡,點擊authenticationProvider進入到底層方法當中。
進入後,裡面是具體的方法實現,大概功能就是把註入瞭userDetailsService的信息提供者DaoAuthenticationProvider添加到一個List集合裡,然後再將集合裡的所有提供者,通過構造器傳入ProviderManager,命名生成一個新的提供者管理員providerManager。這裡面還涵蓋不少細節,感興趣的讀者可自行再擴展深入研究。
以上,就初步設置好瞭遊戲規則。
接下來,就是主角上場瞭。
在所有的遊戲裡,都會有一個主角,而我們這個故事,自然也不例外。
此時,在一扇刻著“登錄”二字的大門前,有一個小兵正在收拾他的包袱,準備跨過大門,踏上通往SpringSecurity山谷的道路。他背負著整個傢族賦予的任務,需前往Security山谷,拿到token令牌,隻有把它成功帶回來,傢族裡的其他成員,才能有機會穿過這座山谷,前往另一頭的神秘世界,獲取到珍貴的資源。
這個小兵,便是我們這故事裡的主角,我把他叫做線程,他將帶著整個線程傢族的希望,尋找可通往神秘系統世界的令牌。
線程把族長給予的鑰匙和密碼放進包袱,他回頭看瞭一眼自己的傢鄉,然後揮瞭揮手,跨過“登錄”這扇大門,勇敢地上路瞭。
線程來到戒備森嚴的security關口前,四周望瞭一眼,忽然發現關口旁立著一塊顯眼的石碑,上面刻著一些符號。他走上前一看,發現原來是當年軍官設置的指令與對應的說明:
@Override protected void configure(HttpSecurity httpSecurity) throws Exception { //使用的是JWT,禁用csrf httpSecurity.cors().and().csrf().disable() //設置請求必須進行權限認證 .authorizeRequests() //跨域預檢請求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //首頁和登錄頁面 .antMatchers("/login").permitAll() // 其他所有請求需要身份認證 .anyRequest().authenticated(); //退出登錄處理 httpSecurity.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()); //token驗證過濾器 httpSecurity.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class); }
其中,permitAll()代表所有請求都可訪問,當它設置成類似“.antMatchers(“/login”).permitAll()”的形式時,則代表該/login路徑請求無需認證便可通過,相反,代碼anyRequest().authenticated()則意味著其他的所有請求都必須進行身份驗證方能通過,否則,會被拒絕訪問。
下面,將通過debug一步一步揭示,線程是如何闖關升級的,最後成功獲取到傳說中的token令牌。
線程來到關口處,不久,在戍守士兵的指引下,開始往login道路走去,前面迎接他,將是一系列的關口檢查。
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對象後,通過Authenticationauthentication=authenticationManager.authenticate(token)方法進行認證,裡面會執行一系列認證操作,需要看懂源碼,才能知道這行代碼背後藏著的水月洞天,然,有一點是可以從表面上看懂的,即若成功認證通過,將會返回一個認證成功的Authentication對象,至於對象裡是什麼信息,請繼續 往下看。
3.點擊進入到AuthenticationManager裡,發現該接口裡隻有一個方法:
Authentication authenticate(Authentication authentication) throws AuthenticationException;
由此可知,它的具體實現,是通過實現類來操作的,它的主要實現類有N多個,其中,在認證過程中,我們需關註的是ProviderManager這個類。
這個ProviderManager,即前面提到的Provider管理員,他管理著一堆信息提供者provider。線程此行的目的,就是先找到這個Provider管理員,再去管理員手中尋找能夠匹配到的提供者provider,隻有通過匹配到的提供者,才能找到獲取數據庫的方法loadUserByUsername。
4.ProviderManager類實際上是實現AuthenticationManager接口,重寫瞭authenticate方法。因此,當前面代碼執行authenticationManager.authenticate(token)方法時,具體實現將由其子類重寫的方法操作,子類即ProviderManager。
debug進去後——
繼續往下執行,通過getProviders() 可獲取到內部維護在List中的AuthenticationProvider遍歷進行驗證,若該提供者能支持傳入的token進行驗證,則繼續往下執行。
其中,JwtAuthAuthenticationProvider可執行本次驗證,而JwtAuthAuthenticationProvider是繼承DaoAuthenticationProvider後自定義的類,可以理解成,進行認證驗證的Provider是前面重點提到的DaoAuthenticationProvider。
DaoAuthenticationProvider是一個具體實現類,它繼承AbstractUserDetailsAuthenticationProvider抽象類。
而AbstractUserDetailsAuthenticationProvider實現瞭AuthenticationProvider接口。
5.在ProviderManager中,執行到result =provider.authenticate(authentication)時,其中provider是由AuthenticationProvider定義的,但AuthenticationProvider是一個接口,需由其子類具體實現。根據上面分析,可知,AbstractUserDetailsAuthenticationProvider會具體實現provider.authenticate(authentication)方法。debug進入到其authenticate方法當中,會跳轉到AbstractUserDetailsAuthenticationProvider重寫的authenticate()方法當中,接下來會詳細介紹該authenticate()執行的代碼模塊:
5.1.首先,第一步,會執行this.userCache.getUserFromCache(username)獲取緩存裡的信息。
5.2若緩存裡沒有UserDetails信息,將會繼續往下執行,執行到retrieveUser方法,該方法的總體作用是:通過登錄時傳入的userName去數據庫裡做查詢,若查詢成功,便將數據庫的User信息包裝成UserDetails對象返回,當然,具體如何從數據庫裡獲取到信息,則需要重寫一個方法,即前面提到的loadUserByUsername()方法。
值得註意一點是,一般新手接觸到security框架,都會有一個疑問,即我登錄時傳入瞭username,是如何獲取到數據庫裡的用戶信息?
其實,這個疑問的關鍵答案,就藏在這個retrieveUser()方法裡。該方法名的英文解析是:“(訓練成能尋回獵物的)獵犬”。我覺得這個翻譯在這裡很有意思,暫且可以把它當成信息提供者Provider馴養的一頭獵犬,它可以幫我們的遊戲主角線程在茫茫的森林裡,尋找到藏匿寶箱的山洞(數據庫)。
5.3 ,接下來,就讓這頭獵犬給我們帶路吧——點擊retrieveUser(),進入到方法當中,發現,這其實是一個抽象方法,故而其具體實現將在子類中進行。
5.4進入到其子類實現的方法當中,發現會進入前面提到AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider,它也是一個AuthenticationProvider,即所謂的信息提供者之一。在DaoAuthenticationProvider類裡,實現瞭父類的retrieveUser方法。
在獵犬的(retrieveUser)的帶路下,我們最後看到 瞭熟悉的老朋友,關鍵方法loadUserByUserName()。
點進loadUserByUsername()方法裡,會進入到UserDetailsService接口裡,該接口隻有loadUserByUsername一個方法,該方法具體在子類裡實現。
這個接口被我們自定義重寫瞭,即前面露過面的:
在DaoAuthenticationProvider類中,調用loadUserByUserName()方法時,最終會執行我們重寫的loadUserByUsername()方法,該方法將會去數據庫裡查詢username的信息,並返回一個User對象,最後SysUser對象轉換成UserDetails,返回給DaoAuthenticationProvider對象裡的UserDetails,跳轉如下圖:
5.5DaoAuthenticationProvider的retirieveUser執行完後,會將數據庫查詢到的UserDetails返回給上一層,即AbstractUserDetailsAuthenticationProvider執行的retrieveUser()方法,得到的UserDetails賦值給user。
6.接下來就是各種檢查,其中,有一個檢查方法需要特別關註,即
註:additionalAuthenticationChecks()方法的作用是檢查密碼是否一致的,前面已根據username去數據庫裡查詢出user數據,接下來,就需要在該方法裡,檢查數據庫裡user的密碼與登錄時傳入的密碼是否一致瞭。
6.1點擊additionalAuthenticationChecks()進入到方法裡,發現AbstractUserDetailsAuthenticationProvider當中的additionalAuthenticationChecks同樣是一個抽象方法,沒有具體實現,它與前面的retrieveUser()方法一樣,具體實現都在AbstractUserDetailsAuthenticationProvider的子類DaoAuthenticationProvider中重寫瞭。
6.2.跳轉進入子類重寫的additionalAuthenticationChecks()當中,先通過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對象形式返回到最開始的地方。
執行到這一步,就可以把認證通過的信息進行存儲,到這裡,就完成瞭核心的認證部分。
接下來,我們的主角線程就可以前往JWT魔法屋獲取加密的token令牌,然後攜帶令牌返回故土,屆時,其線程傢族裡的其他成員,都可穿過這座Spring Security山谷,前往山谷另一邊的web系統世界瞭。
那是另外一個世界的故事,我們將在以後漫長的歲月當中,緩緩道來…..
而這個關於Spring Security山谷的故事,就暫且記到這裡,若當中有不當之處,還需各位大佬指出而加以改進。
到此這篇關於通過Spring Security魔幻山谷講解獲取認證機制核心原理的文章就介紹到這瞭,更多相關Spring Security內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- None Found