使用jwt完成sso單點登入
JWT
在瞭解jwt之前,先了解一下常用的會話管理
- 基於
server-session
的管理方式 cookie-based
的管理方式token-based
的管理方式
一.基於server-session
的管理
- 服務端
session
是使用者第一次訪問應用時,伺服器就會建立的物件,代表使用者的一次會話過程,伺服器為每一個session
都分配一個唯一的sessionid
,以保證每個使用者都有一個不同的session
物件。 - 伺服器在建立完
session
後,會把sessionid
通過cookie
返回給使用者所在的瀏覽器,這樣當用戶第二次及以後向伺服器傳送請求的時候,就會通過cookie
sessionid
傳回給伺服器,以便伺服器能夠根據sessionid
找到與該使用者對應的session
物件。 session
通常有失效時間的設定,比如2個小時。當失效時間到,伺服器會銷燬之前的session
,並建立新的session
返回給使用者。但是隻要使用者在失效時間內,有傳送新的請求給伺服器,通常伺服器都會把他對應的session
的失效時間根據當前的請求時間再延長2個小時。session
在一開始並不具備會話管理的作用。它只有在使用者登入認證成功之後,並且往session
物件裡面放入了使用者登入成功的憑證,才能用來管理會話。管理會話的邏輯也很簡單,只要拿到使用者的session
物件,看它裡面有沒有登入成功的憑證,就能判斷這個使用者是否已經登入。當用戶主動退出的時候,會把它的session
session
物件失效時,肯定都是拿不到需要的登入憑證的。
以上過程可簡單使用流程圖描述如下:
它還有一個比較大的優點就是安全性好,因為在瀏覽器端與伺服器端保持會話狀態的媒介始終只是一個sessionid
串,只要這個串夠隨機,攻擊者就不能輕易冒充他人的sessionid
進行操作;除非通過CSRF或http劫持的方式,才有可能冒充別人進行操作;即使冒充成功,也必須被冒充的使用者session
裡面包含有效的登入憑證才行。但是在真正決定用它管理會話之前,也得根據自己的應用情況考慮以下幾個問題:
- 這種方式將會話資訊儲存在web伺服器裡面,所以在使用者同時線上量比較多時,這些會話資訊會佔據比較多的記憶體;
- 當應用採用叢集部署的時候,會遇到多臺web伺服器之間如何做
session
共享的問題。因為session
是由單個伺服器建立的,但是處理使用者請求的伺服器不一定是那個建立session
的伺服器,這樣他就拿不到之前已經放入到session
中的登入憑證之類的資訊了; - 多個應用要共享
session
時,除了以上問題,還會遇到跨域問題,因為不同的應用可能部署的主機不一樣,需要在各個應用做好cookie
跨域的處理。
針對問題1和問題2,我見過的解決方案是採用redis
這種中間伺服器來管理session
的增刪改查,一來減輕web伺服器的負擔,二來解決不同web伺服器共享session
的問題。針對問題3,由於服務端的session
依賴cookie
來傳遞sessionid
,所以在實際專案中,只要解決各個專案裡面如何實現sessionid
的cookie
跨域訪問即可,這個是可以實現的,就是比較麻煩,前後端有可能都要做處理。
二. cookie-based
的管理方式
由於前一種方式會增加伺服器的負擔和架構的複雜性,所以後來就有人想出直接把使用者的登入憑證直接存到客戶端的方案,當用戶登入成功之後,把登入憑證寫到cookie
裡面,並給cookie
設定有效期,後續請求直接驗證存有登入憑證的cookie
是否存在以及憑證是否有效,即可判斷使用者的登入狀態。使用它來實現會話管理的整體流程如下:
- 使用者發起登入請求,服務端根據傳入的使用者密碼之類的身份資訊,驗證使用者是否滿足登入條件,如果滿足,就根據使用者資訊建立一個登入憑證,這個登入憑證簡單來說就是一個物件,最簡單的形式可以只包含使用者
id
,憑證建立時間和過期時間三個值。 - 服務端把上一步建立好的登入憑證,先對它做數字簽名,然後再用對稱加密演算法做加密處理,將簽名、加密後的字串,寫入
cookie
。cookie
的名字必須固定(如ticket
),因為後面再獲取的時候,還得根據這個名字來獲取cookie
值。這一步新增數字簽名的目的是防止登入憑證裡的資訊被篡改,因為一旦資訊被篡改,那麼下一步做簽名驗證的時候肯定會失敗。做加密的目的,是防止cookie
被別人擷取的時候,無法輕易讀到其中的使用者資訊。 - 使用者登入後發起後續請求,服務端根據上一步存登入憑證的
cookie
名字,獲取到相關的cookie
值。然後先做解密處理,再做數字簽名的認證,如果這兩步都失敗,說明這個登入憑證非法;如果這兩步成功,接著就可以拿到原始存入的登入憑證了。然後用這個憑證的過期時間和當前時間做對比,判斷憑證是否過期,如果過期,就需要使用者再重新登入;如果未過期,則允許請求繼續。
這種方式最大的優點就是實現了服務端的無狀態化,徹底移除了服務端對會話的管理的邏輯,服務端只需要負責建立和驗證登入cookie
即可,無需保持使用者的狀態資訊。對於第一種方式的第二個問題,使用者會話資訊共享的問題,它也能很好解決:因為如果只是同一個應用做叢集部署,由於驗證登入憑證的程式碼都是一樣的,所以不管是哪個伺服器處理使用者請求,總能拿到cookie
中的登入憑證來進行驗證;如果是不同的應用,只要每個應用都包含相同的登入邏輯,那麼他們也是能輕易實現會話共享的,不過這種情況下,登入邏輯裡面數字簽名以及加密解密要用到的金鑰檔案或者金鑰串,需要在不同的應用裡面共享,總而言之,就是需要演算法完全保持一致。
這種方式由於把登入憑證直接存放客戶端,並且需要cookie
傳來傳去,所以它的缺點也比較明顯:
cookie
有大小限制,儲存不了太多資料,所以要是登入憑證存的訊息過多,導致加密簽名後的串太長,就會引發別的問題,比如其它業務場景需要cookie
的時候,就有可能沒那麼多空間可用了;所以用的時候得謹慎,得觀察實際的登入cookie
的大小;比如太長,就要考慮是非是數字簽名的演算法太嚴格,導致簽名後的串太長,那就適當調整簽名邏輯;比如如果一開始用4096位的RSA演算法做數字簽名,可以考慮換成1024、2048位;- 每次傳送
cookie
,增加了請求的數量,對訪問效能也有影響; - 也有跨域問題,畢竟還是要用
cookie
。
前面兩種會話管理方式因為都用到cookie
,不適合用在native app裡面:native app不好管理cookie
,畢竟它不是瀏覽器。這兩種方案都不適合用來做純api服務的登入認證。要實現api服務的登入認證,就要考慮下面要介紹的第三種會話管理方式。
三.token-based
的管理方式
這種方式從流程和實現上來說,跟cookie-based
的方式沒有太多區別,只不過cookie-based
裡面寫到cookie
裡面的ticket
在這種方式下稱為token
,這個token
在返回給客戶端之後,後續請求都必須通過url引數或者是http header的形式,主動帶上token
,這樣服務端接收到請求之後就能直接從http header或者url裡面取到token進行驗證:
這種方式不通過cookie
進行token
的傳遞,而是每次請求的時候,主動把token
加到http header裡面或者url後面,所以即使在native app裡面也能使用它來呼叫我們通過web釋出的api介面。app裡面還要做兩件事情:
- 有效儲存
token
,得保證每次調介面的時候都能從同一個位置拿到同一個token
; - 每次調介面的的程式碼裡都得把
token
加到header或者介面地址裡面。
看起來麻煩,其實也不麻煩,這兩件事情,對於app來說,很容易做到,只要對介面呼叫的模組稍加封裝即可。
這種方式同樣適用於網頁應用,token
可以存於localStorage
或者sessionStorage
裡面,然後每發ajax請求的時候,都把token
拿出來放到ajax請求的header裡即可。不過如果是非介面的請求,比如直接通過點選連結請求一個頁面這種,是無法自動帶上token
的。所以這種方式也僅限於走純介面的web應用。
這種方式用在web應用裡也有跨域的問題,比如應用如果部署在a.com,api服務部署在b.com,從a.com裡面發出ajax請求到b.com,預設情況下是會報跨域錯誤的,這種問題可以用CORS(跨域資源共享)的方式來快速解決。
這種方式跟cookie-based
的方式同樣都還有的一個問題就是ticket
或者token
重新整理的問題。有的產品裡面,你肯定不希望使用者登入後,操作了半個小時,結果ticket
或者token
到了過期時間,然後使用者又得去重新登入的情況出現。這個時候就得考慮ticket
或token
的自動重新整理的問題,簡單來說,可以在驗證ticket
或token
有效之後,自動把ticket
或token
的失效時間延長,然後把它再返回給客戶端;客戶端如果檢測到伺服器有返回新的ticket
或token
,就替換原來的ticket
或token
。
四. 安全問題
在web應用裡面,會話管理的安全性始終是最重要的安全問題,這個對使用者的影響極大。
首先從會話管理憑證來說,第一種方式的會話憑證僅僅是一個sessionid
,所以只要這個sessionid
足夠隨機,而不是一個自增的數字id值,那麼其它人就不可能輕易地冒充別人的sessionid
進行操作;第二種方式的憑證ticket
以及第三種方式的憑證token
都是一個在服務端做了數字簽名,和加密處理的串,所以只要金鑰不洩露,別人也無法輕易地拿到這個串中的有效資訊並對它進行篡改。總之,這三種會話管理方式的憑證本身是比較安全的。
然後從客戶端和服務端的http過程來說,當別人截獲到客戶端請求中的會話憑證,就能拿這個憑證冒充原使用者,做一些非法操作,而伺服器也認不出來。這種安全問題,可以簡單採用https來解決,雖然可能還有http劫持這種更高程度的威脅存在,但是我們從程式碼能做的防範,確實也就是這個層次了。
JSON Web Token(JWT)
是一個開放標準(RFC 7519),它定義了一種緊湊和自包含的方式,用於在各方之間作為JSON物件安全地傳輸資訊。作為標準,它沒有提供技術實現,但是大部分的語言平臺都有按照它規定的內容提供了自己的技術實現,所以實際在用的時候,只要根據自己當前專案的技術平臺,到官網上選用合適的實現庫即可。
使用JWT
來傳輸資料,實際上傳輸的是一個字串,這個字串就是所謂的json web token字串。所以廣義上,JWT
是一個標準的名稱;狹義上,JWT
指的就是用來傳遞的那個token
字串。這個串有兩個特點:
1. 緊湊:指的是這個串很小,能通過url 引數,http請求提交的資料以及http header的方式來傳遞;
2. 自包含:這個串可以包含很多資訊,比如使用者的id、角色等,別人拿到這個串,就能拿到這些關鍵的業務資訊,從而避免再通過資料庫查詢等方式才能得到它們。
通常一個JWT
是長這個樣子的:
要知道一個JWT
是怎麼產生以及如何用於會話管理,只要弄清楚JWT
的資料結構以及它簽發和驗證的過程即可。
一. JWT
的資料結構以及簽發過程
一個JWT
實際上是由三個部分組成:header(頭部)
、payload(載荷)
和signature(簽名
)。這三個部分在JWT
裡面分別對應英文句號分隔出來的三個串:
先來看header
部分的結構以及它的生成方法。header
部分是由下面格式的json結構生成出來:
這個json中的typ
屬性,用來標識整個token
字串是一個JWT
字串;它的alg
屬性,用來說明這個JWT
簽發的時候所使用的簽名和摘要演算法,常用的值以及對應的演算法如下:
typ
跟alg
屬性的全稱其實是type
跟algorithm
,分別是型別跟演算法的意思。之所以都用三個字母來表示,也是基於JWT
最終字串大小的考慮,同時也是跟JWT
這個名稱保持一致,這樣就都是三個字元了…typ
跟alg
是JWT
中標準中規定的屬性名稱,雖然在簽發JWT
的時候,也可以把這兩個名稱換掉,但是如果隨意更換了這個名稱,就有可能在JWT
驗證的時候碰到問題,因為拿到JWT
的人,預設會根據typ
和alg
去拿JWT
中的header
資訊,當你改了名稱之後,顯然別人是拿不到header
資訊的,他又不知道你把這兩個名字換成了什麼。JWT
作為標準的意義在於統一各方對同一個事情的處理方式,各個使用方都按它約定好的格式和方法來簽發和驗證token
,這樣即使執行的平臺不一樣,也能夠保證token
進行正確的傳遞。
一般簽發JWT
的時候,header
對應的json結構只需要typ
和alg
屬性就夠了。JWT
的header
部分是把前面的json結構,經過Base64Url編碼之後生成出來的:
再來看payload
部分的結構和生成過程。payload
部分是由下面類似格式的json結構生成出來:
payload
的json結構並不像header
那麼簡單,payload
用來承載要傳遞的資料,它的json結構實際上是對JWT
要傳遞的資料的一組宣告,這些宣告被JWT
標準稱為claims
,它的一個“屬性值對”其實就是一個claim
,每一個claim
的都代表特定的含義和作用。比如上面結構中的sub
代表這個token
的所有人,儲存的是所有人的ID
;name
表示這個所有人的名字;admin
表示所有人是否管理員的角色。當後面對JWT
進行驗證的時候,這些claim
都能發揮特定的作用。
根據JWT
的標準,這些claims
可以分為以下三種類型:
1. Reserved claims(保留)
,它的含義就像是程式語言的保留字一樣,屬於JWT
標準裡面規定的一些claim
。JWT
標準裡面定好的claim
有:
iss(Issuser)
:代表這個JWT的簽發主體;
sub(Subject)
:代表這個JWT的主體,即它的所有人;
aud(Audience)
:代表這個JWT的接收物件;
exp(Expiration time)
:是一個時間戳,代表這個JWT的過期時間;
nbf(Not Before)
:是一個時間戳,代表這個JWT生效的開始時間,意味著在這個時間之前驗證JWT是會失敗的;
iat(Issued at)
:是一個時間戳,代表這個JWT的簽發時間;
jti(JWT ID)
:是JWT的唯一標識。
Public claims
,略(不重要)Private claims
,這個指的就是自定義的claim
。比如前面那個結構舉例中的admin
和name
都屬於自定的claim
。這些claim
跟JWT
標準規定的claim
區別在於:JWT
規定的claim
,JWT
的接收方在拿到JWT
之後,都知道怎麼對這些標準的claim
進行驗證;而private claims
不會驗證,除非明確告訴接收方要對這些claim進行驗證以及規則才行。
按照JWT
標準的說明:保留的claims
都是可選的,在生成payload
不強制用上面的那些claim
,你可以完全按照自己的想法來定義payload
的結構,不過這樣搞根本沒必要:第一是,如果把JWT
用於認證, 那麼JWT
標準內規定的幾個claim
就足夠用了,甚至只需要其中一兩個就可以了,假如想往JWT
裡多存一些使用者業務資訊,比如角色和使用者名稱等,這倒是用自定義的claim
來新增;第二是,JWT
標準裡面針對它自己規定的claim
都提供了有詳細的驗證規則描述,每個實現庫都會參照這個描述來提供JWT
的驗證實現,所以如果是自定義的claim
名稱,那麼你用到的實現庫就不會主動去驗證這些claim
。
最後也是把這個json結構做base64url編碼之後,就能生成payload
部分的串:
最後看signature
部分的生成過程。簽名是把header
和payload
對應的json結構進行base64url編碼之後得到的兩個串用英文句點號拼接起來,然後根據header
裡面alg
指定的簽名演算法生成出來的。演算法不同,簽名結果不同,但是不同的演算法最終要解決的問題是一樣的。以alg: HS256
為例來說明前面的簽名如何來得到。按照前面alg
可用值的說明,HS256其實包含的是兩種演算法:HMAC演算法和SHA256演算法,前者用於生成摘要,後者用於對摘要進行數字簽名。這兩個演算法也可以用HMACSHA256來統稱。運用HMACSHA256實現signature
的演算法是:
正好找到一個線上工具能夠測試這個簽名演算法的結果,比如我們拿前面的header
和payload
串來測試,並把“secret”這個字串就當成金鑰來測試:
最後的結果B其實就是JWT需要的signature。不過對比我在介紹JWT的開始部分給出的JWT的舉例:
會發現通過線上工具生成的header
與payload
都與這個舉例中的對應部分相同,但是通過線上工具生成的signature
與上面圖中的signature
有細微區別,在於最後是否有“=”字元。這個區別產生的原因在於上圖中的JWT
是通過JWT
的實現庫簽發的JWT
,這些實現庫最後編碼的時候都用的是base64url編碼,而前面那些線上工具都是bas64編碼,這兩種編碼方式不完全相同,導致編碼結果有區別。
以上就是一個JWT
包含的全部內容以及它的簽發過程。接下來看看該如何去驗證一個JWT
是否為一個有效的JWT
。
二.JWT
的驗證過程
這個部分介紹JWT
的驗證規則,主要包括簽名驗證和payload
裡面各個標準claim
的驗證邏輯介紹。只有驗證成功的JWT
,才能當做有效的憑證來使用。
先說簽名驗證。當接收方接收到一個JWT
的時候,首先要對這個JWT
的完整性進行驗證,這個就是簽名認證。它驗證的方法其實很簡單,只要把header
做base64url解碼,就能知道JWT
用的什麼演算法做的簽名,然後用這個演算法,再次用同樣的邏輯對header
和payload
做一次簽名,並比較這個簽名是否與JWT
本身包含的第三個部分的串是否完全相同,只要不同,就可以認為這個JWT
是一個被篡改過的串,自然就屬於驗證失敗了。接收方生成簽名的時候必須使用跟JWT
傳送方相同的金鑰,意味著要做好金鑰的安全傳遞或共享。
再來看payload
的claim
驗證,拿前面標準的claim
來一一說明:
iss(Issuser)
:如果簽發的時候這個claim
的值是“a.com”,驗證的時候如果這個claim
的值不是“a.com”就屬於驗證失敗;
sub(Subject)
:如果簽發的時候這個claim
的值是“liuyunzhuge”,驗證的時候如果這個claim
的值不是“liuyunzhuge”就屬於驗證失敗;
(Audience)
:如果簽發的時候這個claim
的值是“[‘b.com’,’c.com’]”,驗證的時候這個claim
的值至少要包含b.com,c.com的其中一個才能驗證通過;
exp(Expiration time)
:如果驗證的時候超過了這個claim
指定的時間,就屬於驗證失敗;
nbf(Not Before)
:如果驗證的時候小於這個claim
指定的時間,就屬於驗證失敗;
iat(Issued at)
:它可以用來做一些maxAge之類的驗證,假如驗證時間與這個claim
指定的時間相差的時間大於通過maxAge指定的一個值,就屬於驗證失敗;
jti(JWT ID)
:如果簽發的時候這個claim
的值是“1”,驗證的時候如果這個claim
的值不是“1”就屬於驗證失敗;
需要注意的是,在驗證一個JWT
的時候,簽名認證是每個實現庫都會自動做的,但是payload
的認證是由使用者來決定的。因為JWT
裡面可能不會包含任何一個標準的claim
,所以它不會自動去驗證這些claim
。
以登入認證來說,在簽發JWT
的時候,完全可以只用sub
跟exp
兩個claim
,用sub
儲存使用者的id
,用exp
儲存它本次登入之後的過期時間,然後在驗證的時候僅驗證exp
這個claim
,以實現會話的有效期管理。
JWT SSO
場景一:使用者發起對業務系統的第一次訪問,假設他第一次訪問的是系統A的some/page這個頁面,它最終成功訪問到這個頁面的過程是:
在這個過程裡面,我認為理解的關鍵點在於:
它用到了兩個
cookie
(jwt
和sid
)和三次重定向來完成會話的建立和會話的傳遞;jwt
的cookie
是寫在systemA.com這個域下的,所以每次重定向到systemA.com的時候,jwt
這個cookie
只要有就會帶過去;sid
的cookie
是寫在cas.com這個域下的,所以每次重定向到cas.com的時候,sid
這個cookie
只要有就會帶過去;在驗證
jwt
的時候,如何知道當前使用者已經建立了sso的會話?
因為jwt
的payload
裡面儲存了之前建立的sso會話的sessionid
,所以當cas拿到jwt
,就相當於拿到了sessionid
,然後用這個sessionid
去判斷有沒有的對應的session
物件即可。
還要注意的是:CAS服務裡面的session
屬於服務端建立的物件,所以要考慮sessionid
唯一性以及session
共享(假如CAS採用叢集部署的話)的問題。sessionid
的唯一性可以通過使用者名稱密碼加隨機數然後用hash演算法如md5簡單處理;session
共享,可以用memcached
或者redis
這種專門的支援叢集部署的快取伺服器管理session
來處理。
由於服務端session
具有生命週期的特點,到期需自動銷燬,所以不要自己去寫session
的管理,免得引發其它問題,到github裡找開源的快取管理中介軟體來處理即可。儲存session
物件的時候,只要用sessionid
作為key,session
物件本身作為value
,存入快取即可。session
物件裡面除了sessionid
,還可以存放登入之後獲取的使用者資訊等業務資料,方便業務系統呼叫的時候,從session
裡面返回會話資料。
場景二:使用者登入之後,繼續訪問系統A的其它頁面,如some/page2,它的處理過程是:
從這一步可以看出,即使登入之後,也要每次跟CAS校驗jwt
的有效性以及會話的有效性,其實jwt
的有效性也可以放在業務系統裡面處理的,但是會話的有效性就必須到CAS那邊才能完成了。當CAS拿到jwt
裡面的sessionid
之後,就能到session
快取伺服器裡面去驗證該sessionid
對應的session
物件是否存在,不存在,就說明會話已經銷燬了(退出)。
場景三:使用者登入了系統A之後,再去訪問其他系統如系統B的資源,比如系統B的some/page,它最終能訪問到系統B的some/page的流程是:
這個過程的關鍵在於第一次重定向的時候,它會把sid
這個cookie
帶回給CAS伺服器,所以CAS伺服器能夠判斷出會話是否已經建立,如果已經建立就跳過登入頁的邏輯。
場景四:使用者繼續訪問系統B的其它資源,如系統B的some/page2:
這個場景的邏輯跟場景二完全一致。
場景五:退出登入,假如它從系統B發起退出,最終的流程是:
最重要的是要清除sid
的cookie
,jwt
的cookie
可能業務系統都有建立,所以不可能在退出的時候還挨個去清除那些系統的cookie
,只要sid
一清除,那麼即使那些jwt
的cookie
在下次訪問的時候還會被傳遞到業務系統的服務端,由於jwt
裡面的sid
已經無效,所以最後還是會被重定向到CAS登入頁進行處理。
方案總結
以上方案兩個關鍵的前提:
- 整個會話管理其實還是基於服務端的
session
來做的,只不過這個session
只存在於CAS服務裡面; - CAS之所以信任業務系統的
jwt
,是因為這個jwt
是CAS簽發的,理論上只要認證通過,就可以認為這個jwt
是合法的。
jwt
本身是不可偽造,不可篡改的,但是不代表非法使用者冒充正常用法發起請求,所以常規的幾個安全策略在實際專案中都應該使用:
- 使用https
- 使用http-only的
cookie
,針對sid
和jwt
- 管理好金鑰
- 防範CSRF攻擊。
尤其是CSRF攻擊形式,很多都是鑽程式碼的漏洞發生的,所以一旦出現CSRF漏洞,並且被人利用,那麼別人就能用獲得的jwt
,冒充正常使用者訪問所有業務系統,這個安全問題的後果還是很嚴重的。考慮到這一點,為了在即使有漏洞的情況將損害減至最小,可以在jwt
裡面加入一個系統標識,新增一個驗證,只有傳過來的jwt
內的系統標識與發起jwt
驗證請求的服務一致的情況下,才允許驗證通過。這樣的話,一個非法使用者拿到某個系統的jwt
,就不能用來訪問其它業務系統了。
在業務系統跟CAS發起attach/validate請求的時候,也可以在CAS端做些處理,因為這個請求,在一次SSO過程中,一個系統只應該發一次,所以只要之前已經給這個系統簽發過jwt了,那麼後續 同一系統的attach/validate請求都可以忽略掉。
總的來說,這個方案的好處有:
- 完全分散式,跨平臺,CAS以及業務系統均可採用不同的語言來開發;
- 業務系統如系統A和系統B,可實現服務端無狀態
- 假如是自己來實現,那麼可以輕易的在CAS裡面整合使用者註冊服務以及第三方登入服務,如微信登入等。
它的缺陷是:
- 第一次登入某個系統,需要三次重定向;
- 登入後的後續請求,每次都需要跟CAS進行會話驗證,所以CAS的效能負載會比較大
- 登陸後的後續請求,每次都跟CAS互動,也會增加請求響應時間,影響使用者體驗。