最簡單易懂的spring security 身份認證流程講解
導言
相信大伙對spring security這個框架又愛又恨,愛它的強大,恨它的繁瑣,其實這是一個誤區(qū),spring security確實非常繁瑣,繁瑣到讓人生厭。討厭也木有辦法呀,作為javaee的工程師們還是要面對的,在開始之前,先打一下比方(比方好可憐):
spring security 就像一個行政服務中心,如果我們去里面辦事,可以辦啥事呢?可以小到咨詢簡單問題、查詢社保信息,也可以戶籍登記、補辦身份證,同樣也可以大到企業(yè)事項、各種復雜的資質辦理。但是我們并不需要跑一次行政服務中心,就挨個把業(yè)務全部辦理一遍,現(xiàn)實中沒有這樣的人吧。
啥意思呢,就是說選擇您需要的服務(功能),無視那些不需要的,等有需要的時候再了解不遲。這也是給眾多工程師們的一個建議,特別是體系異常龐大的java系,別動不動就精通,擼遍源碼之類的,真沒啥意義,我大腦的存儲比較小,人生苦短,沒必要。
回到正題!本文會以一種比較輕松的方式展開,不會是堆代碼。
關于身份認證
web 身份認證是一個后端工程師永遠無法避開的領域,身份認證authentication,和授權authorization是不同的,authentication指的是用戶身份的認證,并不介入這個用戶能夠做什么,不能夠做什么,僅僅是確認存在這個用戶而已。而authorization授權是建立的認證的基礎上的,存在這個用戶了,再來約定這個用戶能補能夠做一件事,這點大家要區(qū)分開。本文講的是authentication的故事,并不會關注權限。
熱熱身,讓我們來溫習一下身份認證的方式演變:
先是最著名的入門留言板程序,相信很多做后端的工程師都做過留言板,那是一個基本沒有框架的階段,回想一下是怎么認證的。表單輸入用戶名密碼submit,然后后端取到數(shù)據(jù)數(shù)據(jù)庫查詢,查不到的話無情地拋出一個異常,哦,密碼錯了;查到了,愉快的將用戶id和相關信息加密寫入到session標識中存起來,響應寫入cookie,后續(xù)的請求都解密后驗證就行了,對吧。是的,身認證真可以簡單到僅僅是匹配session標識而已。令人沮喪的是現(xiàn)代互聯(lián)網(wǎng)的發(fā)展早已經過了 web2.0 的時代,客戶端的出現(xiàn)讓身份認證更加復雜。我們繼續(xù)
隨著移動端的崛起,android和ios占據(jù)主導,同樣是用戶登錄認證,取到用戶信息,正準備按圖索驥寫入session回寫cookie的時候,等等!啥?android不支持cookie?這聽起來不科學是吧,有點反人類是吧,有點手足無措是吧。
嘿嘿,聰明的人兒也許想到了辦法,嗯,android客戶端不是有本地存儲嗎?把回傳的數(shù)據(jù)存起來不就行了嗎?又要抱歉了,android本地存儲并沒有瀏覽器cookie那么人性化,不會自動過期。沒事,再注明過期時間,每次讀取的時候判斷就行啦,貌似可以了。
等等。客戶端的api接口要求輕量級,某一天一個隊友想實現(xiàn)個性化的事情,竟然往cookie了回傳了一串字符串,貌似很方便,嗯。于是其他隊友也效仿,然后cookie變得更加復雜。此時android隊友一聲吼,你們夠了!stop!我只要一個認證標識而已,夠簡單你們知道嗎?還有cookie過期了就要重新登陸,用戶體驗極差,產品經理都找我談了幾十次了,用戶都快跑光了,你們還在往cookie里加一些奇怪的東西。
oauth 2.0來了
有問題總要想辦法解決是吧。客戶端不是瀏覽器,有自己特有的交互約定,cookie還是放棄掉了。這里就要解決五個問題:
- [ ] 只需要簡單的一個字符串標識,不需要遵守cookie的規(guī)則
- [ ] 服務器端需要能夠輕松認證這個標識,最好是做成標準化
- [ ] 不要讓用戶反復輸入密碼登錄,能夠自動刷新
- [ ] 這段秘鑰要安全,從網(wǎng)絡傳輸鏈路層到客戶端本地層都要是安全的,就算被中途捕獲,也可以讓其失效
- [ ] 多個子系統(tǒng)的客戶端需要獨立的認證標識,讓他們能夠獨立存在(例如淘寶的認證狀態(tài)不會影響到阿里旺旺的登錄認證狀態(tài))
需求一旦確定,方案呼之欲出,讓我們來簡單構思一下。
- [x] 首先是標識,這個最簡單了,將用戶標識數(shù)據(jù)進行可逆加密,ok,這個搞定。
- [x] 然后是標識認證的標準化,最好輕量級,并且讓她不干擾請求的表現(xiàn)方式,例如get和post數(shù)據(jù),聰明的你想到了吧,沒錯,就是header,我們暫且就統(tǒng)一成 userkey 為header名,值就是那個加密過的標識,夠簡潔粗暴吧,后端對每一個請求都攔截處理,如果能夠解密成功并且表示有效,就告訴后邊排隊的小伙伴,這個家伙是自己人,叫xxx,兜里有100塊錢。這個也搞定了。
- [x] 自動刷新,因為加密標識每次請求都要傳輸,不能放在一起了,而且他們的作用也不一樣,那就頒發(fā)加密標識的時候順便再頒發(fā)一個刷新的秘鑰吧,相當于入職的時候給你一張門禁卡,這個卡需要隨身攜帶,開門簽到少不了它,此外還有一張身份證明,這證明就不需要隨身攜帶了,放家里都行,門禁卡掉了,沒關系,拿著證明到保安大哥那里再領一張門禁卡,證明一次有效,領的時候保安大哥貼心的再給你一張證明。
- [x] 安全問題,加密可以加強一部分安全性。傳輸鏈路還用說嗎?上https傳輸加密喲。至于客戶端本地的安全是一個哲學問題,嗯嗯嗯。哈哈。我們暫時認為本地私有空間存儲是安全的的,俗話說得好,計算機都被人破解了,還談個雞毛安全呀(所以大家沒事還是不要去root手機了,root之后私有存儲可以被訪問儂造嗎)
- [x] 子系統(tǒng)獨立問題,這個好辦了。身份認證過程再加入一個因子,暫且叫 client 吧。這樣標識就互不影響了。
打完收工,要開始實現(xiàn)這套系統(tǒng)了。先別急呀,難道沒覺得似曾相識嗎?沒錯就是 oauth 2.0 的 password grant 模式!
spring security 是怎么認證的
先來一段大家很熟悉的代碼:
1
2
3
4
5
|
http.formlogin() .loginpage( "/auth/login" ) .permitall() .failurehandler(loginfailurehandler) .successhandler(loginsuccesshandler); |
spring security 就像一個害羞的大姑娘,就這么一段鬼知道他是怎么認證的,封裝的有點過哈。不著急先看一張圖:
這里做了一個簡化,
根據(jù)javaee的流程,本質就是filter過濾請求,轉發(fā)到不同處理模塊處理,最后經過業(yè)務邏輯處理,返回response的過程。
當請求匹配了我們定義的security filter的時候,就會導向security 模塊進行處理,例如usernamepasswordauthenticationfilter,源碼獻上:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
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; } } |
有點復雜是吧,不用擔心,我來做一些偽代碼,讓他看起來更友善,更好理解。注意我寫的單行注釋
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
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); //我不知道用戶名密碼是不是對的,所以構造一個未認證的token先 usernamepasswordauthenticationtoken token = new usernamepasswordauthenticationtoken(username, password); //順便把請求和token存起來 this .setdetails(request, token); //token給誰處理呢?當然是給當前的authenticationmanager嘍 return this .getauthenticationmanager().authenticate(token); } } } |
是不是很清晰,問題又來了,token是什么鬼?為啥還有已認證和未認證的區(qū)別?別著急,咱們順藤摸瓜,來看看token長啥樣。上usernamepasswordauthenticationtoken:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
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 ; } } |
一坨坨的真鬧心,我再備注一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
public class usernamepasswordauthenticationtoken extends abstractauthenticationtoken { private static final long serialversionuid = 510l; //隨便怎么理解吧,暫且理解為認證標識吧,沒看到是一個object么 private final object principal; //同上 private object credentials; //這個構造方法用來初始化一個沒有認證的token實例 public usernamepasswordauthenticationtoken(object principal, object credentials) { super ((collection) null ); this .principal = principal; this .credentials = credentials; this .setauthenticated( false ); } //這個構造方法用來初始化一個已經認證的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),就無情的給一個異常,意思是: //不要在這里設置已認證,不要在這里設置已認證,不要在這里設置已認證 //應該從構造方法里創(chuàng)建,別忘了要帶上用戶信息和權限列表哦 //原來如此,是避免犯錯吧 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對應的daoauthenticationprovider,既然有多種選擇,那怎么確定使用哪個provider呢?我截取了一段源碼,大家一看便知:
1
2
3
4
5
|
public interface authenticationprovider { authentication authenticate(authentication var1) throws authenticationexception; boolean supports( class <?> var1); } |
這是一個接口,我喜歡接口,簡潔明了。里面有一個supports方法,返回時一個boolean值,參數(shù)是一個class,沒錯,這里就是根據(jù)token的類來確定用什么provider來處理,大家還記得前面的那段代碼嗎?
1
2
|
//token給誰處理呢?當然是給當前的authenticationmanager嘍 return this .getauthenticationmanager().authenticate(token); |
因此我們進入下一步,daoauthenticationprovider,繼承了abstractuserdetailsauthenticationprovider,恭喜您再堅持一會就到曙光啦。這個比較復雜,為了不讓你跑掉,我將兩個復雜的類合并,摘取直接觸達接口核心的邏輯,直接上代碼,會有所刪減,讓你看得更清楚,注意看注釋:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
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)做相應的session持久化和cookie回寫操作。
spring security的基本認證流程先寫到這里,其實復雜的背后是一些預定,熟悉了之后就不難了。
filter->構造token->authenticationmanager->轉給provider處理->認證處理成功后續(xù)操作或者不通過拋異常
有了這些基礎,后面我們再來擴展短信驗證碼登錄,以及基于oauth 2.0 的短信驗證碼登錄。
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持服務器之家。