① 鑒權必須了解的 5 個兄弟:cookie、session、token、jwt、單點登錄
本文你將看到:
**「前端存儲」**這就涉及到一發、一存、一帶,發好辦,登陸介面直接返回給前端,存儲就需要前端想辦法了。
前端的存儲方式有很多。
有,cookie。cookie 也是前端存儲的一種,但相比於 localStorage 等其他方式,藉助 HTTP 頭、瀏覽器能力,cookie 可以做到前端無感知。一般過程是這樣的:
「配置:Domain / Path」
cookie 是要限制::「空間范圍」::的,通過 Domain(域)/ Path(路徑)兩級。
「配置:Expires / Max-Age」
cookie 還可以限制::「時間范圍」::,通過 Expires、Max-Age 中的一種。
「配置:Secure / HttpOnly」
cookie 可以限制::「使用方式」::。
**「HTTP 頭對 cookie 的讀寫」**回過頭來,HTTP 是如何寫入和傳遞 cookie 及其配置的呢?HTTP 返回的一個 Set-Cookie 頭用於向瀏覽器寫入「一條(且只能是一條)」cookie,格式為 cookie 鍵值 + 配置鍵值。例如:
那我想一次多 set 幾個 cookie 怎麼辦?多給幾個 Set-Cookie 頭(一次 HTTP 請求中允許重復)
HTTP 請求的 Cookie 頭用於瀏覽器把符合當前「空間、時間、使用方式」配置的所有 cookie 一並發給服務端。因為由瀏覽器做了篩選判斷,就不需要歸還配置內容了,只要發送鍵值就可以。
**「前端對 cookie 的讀寫」**前端可以自己創建 cookie,如果服務端創建的 cookie 沒加HttpOnly,那恭喜你也可以修改他給的 cookie。調用document.cookie可以創建、修改 cookie,和 HTTP 一樣,一次document.cookie能且只能操作一個 cookie。
調用document.cookie也可以讀到 cookie,也和 HTTP 一樣,能讀到所有的非HttpOnly cookie。
現在回想下,你刷卡的時候發生了什麼?
這種操作,在前後端鑒權系統中,叫 session。典型的 session 登陸/驗證流程:
**「Session 的存儲方式」**顯然,服務端只是給 cookie 一個 sessionId,而 session 的具體內容(可能包含用戶信息、session 狀態等),要自己存一下。存儲的方式有幾種:
「Session 的過期和銷毀」**很簡單,只要把存儲的 session 數據銷毀就可以。****「Session 的分布式問題」**通常服務端是集群,而用戶請求過來會走一次負載均衡,不一定打到哪台機器上。那一旦用戶後續介面請求到的機器和他登錄請求的機器不一致,或者登錄請求的機器宕機了,session 不就失效了嗎?這個問題現在有幾種解決方式。
但通常還是採用第一種方式,因為第二種相當於閹割了負載均衡,且仍沒有解決「用戶請求的機器宕機」的問題。**「node.js 下的 session 處理」**前面的圖很清楚了,服務端要實現對 cookie 和 session 的存取,實現起來要做的事還是很多的。在npm中,已經有封裝好的中間件,比如 express-session - npm,用法就不貼了。這是它種的 cookie:
express-session - npm 主要實現了:
session 的維護給服務端造成很大困擾,我們必須找地方存放它,又要考慮分布式的問題,甚至要單獨為了它啟用一套 Redis 集群。有沒有更好的辦法?
回過頭來想想,一個登錄場景,也不必往 session 存太多東西,那為什麼不直接打包到 cookie 中呢?這樣服務端不用存了,每次只要核驗 cookie 帶的「證件」有效性就可以了,也可以攜帶一些輕量的信息。這種方式通常被叫做 token。
token 的流程是這樣的:
**「客戶端 token 的存儲方式」 在前面 cookie 說過,cookie 並不是客戶端存儲憑證的唯一方式。token 因為它的「無狀態性」,有效期、使用限制都包在 token 內容里,對 cookie 的管理能力依賴較小,客戶端存起來就顯得更自由。但 web 應用的主流方式仍是放在 cookie 里,畢竟少操心。 「token 的過期」**那我們如何控制 token 的有效期呢?很簡單,把「過期時間」和數據一起塞進去,驗證時判斷就好。
編碼的方式豐儉由人。**「base64」**比如 node 端的 cookie-session - npm 庫
默認配置下,當我給他一個 userid,他會存成這樣:
這里的 eyJ1c2VyaWQiOiJhIn0=,就是 {"userid":"abb」} 的 base64 而已。 「防篡改」
是的。所以看情況,如果 token 涉及到敏感許可權,就要想辦法避免 token 被篡改。解決方案就是給 token 加簽名,來識別 token 是否被篡改過。例如在 cookie-session - npm 庫中,增加兩項配置:
這樣會多種一個 .sig cookie,裡面的值就是 {"userid":"abb」} 和 iAmSecret通過加密演算法計算出來的,常見的比如HMACSHA256 類 (System.Security.Cryptography) | Microsoft Docs。
好了,現在 cdd 雖然能偽造出eyJ1c2VyaWQiOiJhIn0=,但偽造不出 sig 的內容,因為他不知道 secret。**「JWT」**但上面的做法額外增加了 cookie 數量,數據本身也沒有規范的格式,所以 JSON Web Token Introction - jwt.io 橫空出世了。
它是一種成熟的 token 字元串生成方案,包含了我們前面提到的數據、簽名。不如直接看一下一個 JWT token 長什麼樣:
這串東西是怎麼生成的呢?看圖:
類型、加密演算法的選項,以及 JWT 標准數據欄位,可以參考 RFC 7519 - JSON Web Token (JWT)node 上同樣有相關的庫實現:express-jwt - npm koa-jwt - npm
token,作為許可權守護者,最重要的就是「安全」。業務介面用來鑒權的 token,我們稱之為 access token。越是許可權敏感的業務,我們越希望 access token 有效期足夠短,以避免被盜用。但過短的有效期會造成 access token 經常過期,過期後怎麼辦呢?一種辦法是,讓用戶重新登錄獲取新 token,顯然不夠友好,要知道有的 access token 過期時間可能只有幾分鍾。另外一種辦法是,再來一個 token,一個專門生成 access token 的 token,我們稱為 refresh token。
有了 refresh token 後,幾種情況的請求流程變成這樣:
如果 refresh token 也過期了,就只能重新登錄了。
session 和 token 都是邊界很模糊的概念,就像前面說的,refresh token 也可能以 session 的形式組織維護。狹義上,我們通常認為 session 是「種在 cookie 上、數據存在服務端」的認證方案,token 是「客戶端存哪都行、數據存在 token 里」的認證方案。對 session 和 token 的對比本質上是「客戶端存 cookie / 存別地兒」、「服務端存數據 / 不存數據」的對比。**「客戶端存 cookie / 存別地兒」**存 cookie 固然方便不操心,但問題也很明顯:
存別的地方,可以解決沒有 cookie 的場景;通過參數等方式手動帶,可以避免 CSRF 攻擊。 「服務端存數據 / 不存數據」
前面我們已經知道了,在同域下的客戶端/服務端認證系統中,通過客戶端攜帶憑證,維持一段時間內的登錄狀態。但當我們業務線越來越多,就會有更多業務系統分散到不同域名下,就需要「一次登錄,全線通用」的能力,叫做「單點登錄」。
簡單的,如果業務系統都在同一主域名下,比如wenku..com tieba..com,就好辦了。可以直接把 cookie domain 設置為主域名 .com,網路也就是這么乾的。
比如滴滴這么潮的公司,同時擁有didichuxing.com xiaojukeji.com didiglobal.com等域名,種 cookie 是完全繞不開的。這要能實現「一次登錄,全線通用」,才是真正的單點登錄。這種場景下,我們需要獨立的認證服務,通常被稱為 SSO。 「一次「從 A 系統引發登錄,到 B 系統不用登錄」的完整流程」
**「完整版本:考慮瀏覽器的場景」**上面的過程看起來沒問題,實際上很多 APP 等端上這樣就夠了。但在瀏覽器下不見得好用。看這里:
對瀏覽器來說,SSO 域下返回的數據要怎麼存,才能在訪問 A 的時候帶上?瀏覽器對跨域有嚴格限制,cookie、localStorage 等方式都是有域限制的。這就需要也只能由 A 提供 A 域下存儲憑證的能力。一般我們是這么做的:
圖中我們通過顏色把瀏覽器當前所處的域名標記出來。注意圖中灰底文字說明部分的變化。
謝謝大家哦
② 單點登錄JWT與Spring Security OAuth
通過 JWT 配合 Spring Security OAuth2 使用的方式,可以避免 每次請求 都 遠程調度 認證授權服務。 資源伺服器 只需要從 授權伺服器 驗證一次,返回 JWT。返回的 JWT 包含了 用戶 的所有信息,包括 許可權信息 。
1. 什麼是JWT
JSON Web Token(JWT)是一種開放的標准(RFC 7519),JWT 定義了一種 緊湊 且 自包含 的標准,旨在將各個主體的信息包裝為 JSON 對象。 主體信息 是通過 數字簽名 進行 加密 和 驗證 的。經常使用 HMAC 演算法或 RSA( 公鑰 / 私鑰 的 非對稱性加密 )演算法對 JWT 進行簽名, 安全性很高 。
2. JWT的結構
JWT 的結構由三部分組成:Header(頭)、Payload(有效負荷)和 Signature(簽名)。因此 JWT 通常的格式是 xxxxx.yyyyy.zzzzz。
2.1. Header
Header 通常是由 兩部分 組成:令牌的 類型 (即 JWT)和使用的 演算法類型 ,如 HMAC、SHA256 和 RSA。例如:
將 Header 用 Base64 編碼作為 JWT 的 第一部分 ,不建議在 JWT 的 Header 中放置 敏感信息 。
2.2. Payload
下面是 Payload 部分的一個示例:
將 Payload 用 Base64 編碼作為 JWT 的 第二部分 ,不建議在 JWT 的 Payload 中放置 敏感信息 。
2.3. Signature
要創建簽名部分,需要利用 秘鑰 對 Base64 編碼後的 Header 和 Payload 進行 加密 ,加密演算法的公式如下:
簽名 可以用於驗證 消息 在 傳遞過程 中有沒有被更改。對於使用 私鑰簽名 的 token,它還可以驗證 JWT 的 發送方 是否為它所稱的 發送方 。
3. JWT的工作方式
客戶端 獲取 JWT 後,對於以後的 每次請求 ,都不需要再通過 授權服務 來判斷該請求的 用戶 以及該 用戶的許可權 。在微服務系統中,可以利用 JWT 實現 單點登錄 。認證流程圖如下:
4. 案例工程結構
工程原理示意圖如下:
5. 構建auth-service授權服務
UserServiceDetail.java
UserRepository.java
實體類 User 和上一篇文章的內容一樣,需要實現 UserDetails 介面,實體類 Role 需要實現 GrantedAuthority 介面。
User.java
Role.java
jks 文件的生成需要使用 Java keytool 工具,保證 Java 環境變數沒問題,輸入命令如下:
其中,-alias 選項為 別名 ,-keyalg 為 加密演算法 ,-keypass 和 -storepass 為 密碼選項 ,-keystore 為 jks 的 文件名稱 ,-validity 為配置 jks 文件 過期時間 (單位:天)。
生成的 jks 文件作為 私鑰 ,只允許 授權服務 所持有,用作 加密生成 JWT。把生成的 jks 文件放到 auth-service 模塊的 src/main/resource 目錄下即可。
對於 user-service 這樣的 資源服務 ,需要使用 jks 的 公鑰 對 JWT 進行 解密 。獲取 jks 文件的 公鑰 的命令如下:
這個命令要求安裝 openSSL 下載地址,然後手動把安裝的 openssl.exe 所在目錄配置到 環境變數 。
輸入密碼 fzp123 後,顯示的信息很多,只需要提取 PUBLIC KEY,即如下所示:
新建一個 public.cert 文件,將上面的 公鑰信息 復制到 public.cert 文件中並保存。並將文件放到 user-service 等 資源服務 的 src/main/resources 目錄下。至此 auth-service 搭建完畢。
maven 在項目編譯時,可能會將 jks 文件 編譯 ,導致 jks 文件 亂碼 ,最後不可用。需要在 pom.xml 文件中添加以下內容:
6. 構建user-service資源服務
注入 JwtTokenStore 類型的 Bean,同時初始化 JWT 轉換器 JwtAccessTokenConverter,設置用於解密 JWT 的 公鑰 。
配置 資源服務 的認證管理,除了 注冊 和 登錄 的介面之外,其他的介面都需要 認證 。
新建一個配置類 GlobalMethodSecurityConfig,通過 @EnableGlobalMethodSecurity 註解開啟 方法級別 的 安全驗證 。
拷貝 auth-service 模塊的 User、Role 和 UserRepository 三個類到本模塊。在 Service 層的 UserService 編寫一個 插入用戶 的方法,代碼如下:
配置用於用戶密碼 加密 的工具類 BPwdEncoderUtil:
實現一個 用戶注冊 的 API 介面 /user/register,代碼如下:
在 Service 層的 UserServiceDetail 中添加一個 login() 方法,代碼如下:
AuthServiceClient 作為 Feign Client,通過向 auth-service 服務介面 /oauth/token 遠程調用獲取 JWT。在請求 /oauth/token 的 API 介面中,需要在 請求頭 傳入 Authorization 信息, 認證類型 ( grant_type )、用戶名 ( username ) 和 密碼 ( password ),代碼如下:
其中,AuthServiceHystrix 為 AuthServiceClient 的 熔斷器 ,代碼如下:
JWT 包含了 access_token、token_type 和 refresh_token 等信息,代碼如下:
UserLoginDTO 包含了一個 User 和一個 JWT 成員屬性,用於返回數據的實體:
登錄異常類 UserLoginException
全局異常處理 切面類 ExceptionHandle
在 Web 層的 UserController 類中新增一個登錄的 API 介面 /user/login 如下:
依次啟動 eureka-service,auth-service 和 user-service 三個服務。
7. 使用Postman測試
因為沒有許可權,訪問被拒絕。在資料庫手動添加 ROLE_ADMIN 許可權,並與該用戶關聯。重新登錄並獲取 JWT,再次請求 /user/foo 介面。
在本案例中,用戶通過 登錄介面 來獲取 授權服務 加密後的 JWT。用戶成功獲取 JWT 後,在以後每次訪問 資源服務 的請求中,都需要攜帶上 JWT。 資源服務 通過 公鑰解密 JWT, 解密成功 後可以獲取 用戶信息 和 許可權信息 ,從而判斷該 JWT 所對應的 用戶 是誰,具有什麼 許可權 。
獲取一次 Token,多次使用, 資源服務 不再每次訪問 授權服務 該 Token 所對應的 用戶信息 和用戶的 許可權信息 。
一旦 用戶信息 或者 許可權信息 發生了改變,Token 中存儲的相關信息並 沒有改變 ,需要 重新登錄 獲取新的 Token。就算重新獲取了 Token,如果原來的 Token 沒有過期,仍然是可以使用的。一種改進方式是在登錄成功後,將獲取的 Token 緩存 在 網關上 。如果用戶的 許可權更改 ,將 網關 上緩存的 Token 刪除 。當請求經過 網關 ,判斷請求的 Token 在 緩存 中是否存在,如果緩存中不存在該 Token,則提示用戶 重新登錄 。