詳解最簡單易懂的Spring Security 身份認證流程講解
最簡單易懂的Spring Security 身份認證流程講解
導言
相信大伙對Spring Security這個框架又愛又恨,愛它的強大,恨它的繁瑣,其實這是一個誤區(qū),Spring Security確實非常繁瑣,繁瑣到讓人生厭。討厭也木有辦法呀,作為JavaEE的工程師們還是要面對的,在開始之前,先打一下比方(比方好可憐):
Spring Security 就像一個行政服務(wù)中心,如果我們?nèi)ダ锩孓k事,可以辦啥事呢?可以小到咨詢簡單問題、查詢社保信息,也可以戶籍登記、補辦身份證,同樣也可以大到企業(yè)事項、各種復雜的資質(zhì)辦理。但是我們并不需要跑一次行政服務(wù)中心,就挨個把業(yè)務(wù)全部辦理一遍,現(xiàn)實中沒有這樣的人吧。
啥意思呢,就是說選擇您需要的服務(wù)(功能),無視那些不需要的,等有需要的時候再了解不遲。這也是給眾多工程師們的一個建議,特別是體系異常龐大的Java系,別動不動就精通,擼遍源碼之類的,真沒啥意義,我大腦的存儲比較小,人生苦短,沒必要。
回到正題!本文會以一種比較輕松的方式展開,不會是堆代碼。
關(guān)于身份認證
Web 身份認證是一個后端工程師永遠無法避開的領(lǐng)域,身份認證Authentication,和授權(quán)Authorization是不同的,Authentication指的是用戶身份的認證,并不介入這個用戶能夠做什么,不能夠做什么,僅僅是確認存在這個用戶而已。而Authorization授權(quán)是建立的認證的基礎(chǔ)上的,存在這個用戶了,再來約定這個用戶能補能夠做一件事,這點大家要區(qū)分開。本文講的是Authentication的故事,并不會關(guān)注權(quán)限。
熱熱身,讓我們來溫習一下身份認證的方式演變:
先是最著名的入門留言板程序,相信很多做后端的工程師都做過留言板,那是一個基本沒有框架的階段,回想一下是怎么認證的。表單輸入用戶名密碼Submit,然后后端取到數(shù)據(jù)數(shù)據(jù)庫查詢,查不到的話無情地拋出一個異常,哦,密碼錯了;查到了,愉快的將用戶ID和相關(guān)信息加密寫入到Session標識中存起來,響應(yīng)寫入Cookie,后續(xù)的請求都解密后驗證就行了,對吧。是的,身認證真可以簡單到僅僅是匹配Session標識而已。令人沮喪的是現(xiàn)代互聯(lián)網(wǎng)的發(fā)展早已經(jīng)過了 Web2.0 的時代,客戶端的出現(xiàn)讓身份認證更加復雜。我們繼續(xù)
隨著移動端的崛起,Android和ios占據(jù)主導,同樣是用戶登錄認證,取到用戶信息,正準備按圖索驥寫入Session回寫Cookie的時候,等等!啥?Android不支持Cookie?這聽起來不科學是吧,有點反人類是吧,有點手足無措是吧。
嘿嘿,聰明的人兒也許想到了辦法,嗯,Android客戶端不是有本地存儲嗎?把回傳的數(shù)據(jù)存起來不就行了嗎?又要抱歉了,Android本地存儲并沒有瀏覽器Cookie那么人性化,不會自動過期。沒事,再注明過期時間,每次讀取的時候判斷就行啦,貌似可以了。
等等??蛻舳说腁pi接口要求輕量級,某一天一個隊友想實現(xiàn)個性化的事情,竟然往Cookie了回傳了一串字符串,貌似很方便,嗯。于是其他隊友也效仿,然后Cookie變得更加復雜。此時Android隊友一聲吼,你們夠了!STOP!我只要一個認證標識而已,夠簡單你們知道嗎?還有Cookie過期了就要重新登陸,用戶體驗極差,產(chǎn)品經(jīng)理都找我談了幾十次了,用戶都快跑光了,你們還在往Cookie里加一些奇怪的東西。
Oauth 2.0來了
有問題總要想辦法解決是吧??蛻舳瞬皇菫g覽器,有自己特有的交互約定,Cookie還是放棄掉了。這里就要解決五個問題:
- [ ] 只需要簡單的一個字符串標識,不需要遵守Cookie的規(guī)則
- [ ] 服務(wù)器端需要能夠輕松認證這個標識,最好是做成標準化
- [ ] 不要讓用戶反復輸入密碼登錄,能夠自動刷新
- [ ] 這段秘鑰要安全,從網(wǎng)絡(luò)傳輸鏈路層到客戶端本地層都要是安全的,就算被中途捕獲,也可以讓其失效
- [ ] 多個子系統(tǒng)的客戶端需要獨立的認證標識,讓他們能夠獨立存在(例如淘寶的認證狀態(tài)不會影響到阿里旺旺的登錄認證狀態(tài))
需求一旦確定,方案呼之欲出,讓我們來簡單構(gòu)思一下。
- [x] 首先是標識,這個最簡單了,將用戶標識數(shù)據(jù)進行可逆加密,OK,這個搞定。
- [x] 然后是標識認證的標準化,最好輕量級,并且讓她不干擾請求的表現(xiàn)方式,例如Get和Post數(shù)據(jù),聰明的你想到了吧,沒錯,就是Header,我們暫且就統(tǒng)一成 Userkey 為Header名,值就是那個加密過的標識,夠簡潔粗暴吧,后端對每一個請求都攔截處理,如果能夠解密成功并且表示有效,就告訴后邊排隊的小伙伴,這個家伙是自己人,叫xxx,兜里有100塊錢。這個也搞定了。
- [x] 自動刷新,因為加密標識每次請求都要傳輸,不能放在一起了,而且他們的作用也不一樣,那就頒發(fā)加密標識的時候順便再頒發(fā)一個刷新的秘鑰吧,相當于入職的時候給你一張門禁卡,這個卡需要隨身攜帶,開門簽到少不了它,此外還有一張身份證明,這證明就不需要隨身攜帶了,放家里都行,門禁卡掉了,沒關(guān)系,拿著證明到保安大哥那里再領(lǐng)一張門禁卡,證明一次有效,領(lǐng)的時候保安大哥貼心的再給你一張證明。
- [x] 安全問題,加密可以加強一部分安全性。傳輸鏈路還用說嗎?上Https傳輸加密喲。至于客戶端本地的安全是一個哲學問題,嗯嗯嗯。哈哈。我們暫時認為本地私有空間存儲是安全的的,俗話說得好,計算機都被人破解了,還談個雞毛安全呀(所以大家沒事還是不要去ROOT手機了,ROOT之后私有存儲可以被訪問儂造嗎)
- [x] 子系統(tǒng)獨立問題,這個好辦了。身份認證過程再加入一個因子,暫且叫 Client 吧。這樣標識就互不影響了。
打完收工,要開始實現(xiàn)這套系統(tǒng)了。先別急呀,難道沒覺得似曾相識嗎?沒錯就是 Oauth 2.0 的 password Grant 模式!
Spring Security 是怎么認證的
先來一段大家很熟悉的代碼:
http.formLogin() .loginPage("/auth/login") .permitAll() .failureHandler(loginFailureHandler) .successHandler(loginSuccessHandler);
Spring Security 就像一個害羞的大姑娘,就這么一段鬼知道他是怎么認證的,封裝的有點過哈。不著急先看一張圖:
這里做了一個簡化,
根據(jù)JavaEE的流程,本質(zhì)就是Filter過濾請求,轉(zhuǎn)發(fā)到不同處理模塊處理,最后經(jīng)過業(yè)務(wù)邏輯處理,返回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 { //從請求中獲取參數(shù) String username = this.obtainUsername(request); String password = this.obtainPassword(request); //我不知道用戶名密碼是不是對的,所以構(gòu)造一個未認證的Token先 UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); //順便把請求和Token存起來 this.setDetails(request, token); //Token給誰處理呢?當然是給當前的AuthenticationManager嘍 return this.getAuthenticationManager().authenticate(token); } } }
是不是很清晰,問題又來了,Token是什么鬼?為啥還有已認證和未認證的區(qū)別?別著急,咱們順藤摸瓜,來看看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; //這個構(gòu)造方法用來初始化一個沒有認證的Token實例 public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super((Collection)null); this.principal = principal; this.credentials = credentials; this.setAuthenticated(false); } //這個構(gòu)造方法用來初始化一個已經(jīng)認證的Token實例,為啥要多此一舉,不能直接Set狀態(tài)么,不著急,往后看 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認證狀態(tài),就無情的給一個異常,意思是: //不要在這里設(shè)置已認證,不要在這里設(shè)置已認證,不要在這里設(shè)置已認證 //應(yīng)該從構(gòu)造方法里創(chuàng)建,別忘了要帶上用戶信息和權(quán)限列表哦 //原來如此,是避免犯錯吧 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是什么鬼,其實只是一個載體而已啦。接下來進入核心環(huán)節(jié),AuthenticationManager是怎么處理的。這里我簡單的過渡一下,但是會讓你明白。
AuthenticationManager會注冊多種AuthenticationProvider,例如UsernamePassword對應(yīng)的DaoAuthenticationProvider,既然有多種選擇,那怎么確定使用哪個Provider呢?我截取了一段源碼,大家一看便知:
public interface AuthenticationProvider { Authentication authenticate(Authentication var1) throws AuthenticationException; boolean supports(Class<?> var1); }
這是一個接口,我喜歡接口,簡潔明了。里面有一個supports方法,返回時一個boolean值,參數(shù)是一個Class,沒錯,這里就是根據(jù)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")); } } } }
到此為止,就完成了用戶名密碼的認證校驗邏輯,根據(jù)認證用戶的信息,系統(tǒng)做相應(yīng)的Session持久化和Cookie回寫操作。
Spring Security的基本認證流程先寫到這里,其實復雜的背后是一些預定,熟悉了之后就不難了。
Filter->構(gòu)造Token->AuthenticationManager->轉(zhuǎn)給Provider處理->認證處理成功后續(xù)操作或者不通過拋異常
有了這些基礎(chǔ),后面我們再來擴展短信驗證碼登錄,以及基于Oauth 2.0 的短信驗證碼登錄。
以上就是本文的全部內(nèi)容,希望對大家的學習有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
SpringCloud中Zuul網(wǎng)關(guān)原理及其配置
Spring?Cloud是一個基于Spring?Boot實現(xiàn)的微服務(wù)應(yīng)用開發(fā)工具,其中的Zuul網(wǎng)關(guān)可以實現(xiàn)負載均衡、路由轉(zhuǎn)發(fā)、鑒權(quán)、限流等功能,本文將從Spring?Cloud中Zuul網(wǎng)關(guān)的原理、使用場景和配置過程詳細介紹,幫助大家更好地了解和應(yīng)用Zuul網(wǎng)關(guān),需要的朋友可以參考下2023-06-06Java VisualVM監(jiān)控遠程JVM(詳解)
下面小編就為大家?guī)硪黄狫ava VisualVM監(jiān)控遠程JVM(詳解)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-10-10SpringCloud之loadbalancer負載均衡組件實戰(zhàn)詳解
LoadBalancer是Spring Cloud官方提供的負載均衡組件,可用于替代Ribbon,這篇文章主要介紹了SpringCloud之loadbalancer負載均衡組件,需要的朋友可以參考下2023-06-06解決Mybatis在IDEA中找不到mapper映射文件的問題
這篇文章主要介紹了解決Mybatis在IDEA中找不到mapper映射文件的問題,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10Java BeanMap實現(xiàn)Bean與Map的相互轉(zhuǎn)換
這篇文章主要介紹了利用BeanMap進行對象與Map的相互轉(zhuǎn)換,通過net.sf.cglib.beans.BeanMap類中的方法來轉(zhuǎn)換,效率極高,本文給大家分享實現(xiàn)代碼,感興趣的朋友一起看看吧2022-11-11