微服務架構中用戶認證的設計與實現
傳統的用戶認證方案
我們直奔主題,什麼是用戶認證呢?對於大多數與用戶相關的操作,軟件系統首先要確認用戶的身份,因此會提供一個用戶登錄功能。用戶輸入用戶名、密碼等信息,後臺系統對其進行校驗的操作就是用戶認證。用戶認證的形式有多種,最常見的有輸入用戶名密碼、手機驗證碼、人臉識別、指紋識別等,但其目的都是爲了確認用戶的身份並與之提供服務。
在傳統的單體單點應用時代,我們會開發用戶認證的服務類,從登錄界面提交的用戶名密碼等信息通過用戶認證類進行校驗,然後獲取該用戶對象將其保存在 Tomcat 的 Session 中,如下所示:
隨着系統流量的增高,單點應用以無法支撐業務運行,應用出現高延遲、宕機等狀況,此時很多公司會將應用改爲 Nginx 軟負載集羣,通過水平擴展提高系統的性能,於是應用架構就變成了這個樣子。
雖然改造後系統性能顯著提高,但你發現了麼,因爲之前用戶登錄的會話數據都保存在本地,當 Nginx 將請求轉發到其他節點後,因爲其他節點沒有此會話數據,系統就會認爲沒有登錄過,請求的業務就會被拒絕。從使用者的角度會變成一刷新頁面後,系統就讓我重新登錄,這個使用體驗非常糟糕。
我們來分析下,這個問題的根本原因在於利用 Session 本地保存用戶數據會讓 Java Web 應用變成有狀態的,在集羣環境下必須保證每一個 Tomcat 節點的會話狀態一致的纔不會出問題。因此基於 Redis 的分佈式會話存儲方案應運而生,在原有架構後端增加 Redis 服務器,將用戶會話統一轉存至 Redis 中,因爲該會話數據是集中存儲的,所以不會出現數據一致性的問題。
但是,傳統方案在互聯網環境下就會遇到瓶頸,Redis 充當了會話數據源,這也意味着 Redis 承擔了所有的外部壓力,在互聯網數以億計的龐大用戶羣規模下,如果出現突發流量洪峯,Redis 能否經受考驗就會成爲系統的關鍵風險,稍有差池系統就會崩潰。
那如何解決呢?其實還有一種巧妙的設計,在用戶認證成功,後用戶數據不再存儲在後端,而改爲在客戶端存儲,客戶端每一次發送請求時附帶用戶數據到 Web 應用端,Java 應用讀取用戶數據進行業務處理,因爲用戶數據分散存儲在客戶端中,因此並不會對後端產生額外的負擔,此時認證架構會變成下面的情況。
當用戶認證成功後,在客戶端的 Cookie、LocalStorage 會持有當前用戶數據,在 Tomcat 接收到請求後便可獲取用戶數據進行業務處理。但細心的你肯定也發現,用戶的敏感數據是未經過加密的,在存儲與傳輸過程中隨時都有泄密的風險,決不能使用明文,必須要對其進行加密。
那如何進行加密處理呢?當然,你可以自己寫加解密類,但更通用的做法是使用 JWT 這種標準的加密方案進行數據存儲與傳輸。
Json Web Token(JWT)介紹
無論是微服務架構,還是前後端分離應用,在客戶端存儲並加密數據時有一個通用的方案:Json Web Token(JWT),JWT 是一個經過加密的,包含用戶信息的且具有時效性的固定格式字符串。下面這是一個標準的 JWT 字符串。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9.NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU
這段加密字符串由三部分組成,中間由點 “.” 分隔,具體含義如下。
- 第一部分 標頭(Header):標頭通常由兩部分組成:令牌的類型(即 JWT)和所使用的簽名算法,例如 HMAC SHA256 或 RSA,下面是標頭的原文:
{
"alg": "HS256",
"typ": "JWT"
}
然後,此 JSON 被 Base64 編碼以形成 JWT 的第一部分。
eyJhbGciOiJIUzI1NiJ9
- 第二部分 載荷(Payload):載荷就是實際的用戶數據以及其他自定義數據。載荷原文如下所示。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然後對原文進行 Base64 編碼形成 JWT 的第二部分。
eyJzdWIiOiJ7XCJ1c2VySWRcIjoyLFwidXNlcm5hbWVcIjpcImxpc2lcIixcIm5hbWVcIjpcIuadjuWbm1wiLFwiZ3JhZGVcIjpcInZpcFwifSJ9
- 第三部分 簽名(Sign):簽名就是通過前面兩部分標頭 + 載荷 + 私鑰再配合指定的算法,生成用於校驗 JWT 是否有效的特殊字符串,簽名的生成規則如下。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
生成的簽名字符串爲:
NT8QBdoK4S-PbnhS0msJAqL0FG2aruvlsBSyG226HiU
將以上三部分通過 “.” 連接在一起,就是 JWT 的標準格式了。
JWT 的創建與校驗
此時,你肯定有疑問 JWT 是如何生成的,又是如何完成有效性校驗呢?因爲 JWT 的格式與算法是固定的,在 Java 就有非常多的優秀開源項目幫我們實現了 JWT 的創建與驗籤,其中最具代表性的產品就是 JJWT。JJWT 是一個提供端到端的 JWT 創建和驗證的 Java 庫,它的官網是:https://github.com/jwtk/jjwt
,有興趣的話你可以到官網閱讀它的源碼。
JJWT 的使用是非常簡單的,下面我們用代碼進行說明,關鍵代碼我已做好註釋。
- 第一步,pom.xml 引入 JJWT 的 Maven 依賴。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
- 第二步,編寫創建 JWT 的測試用例,模擬真實環境 UserID 爲 123 號的用戶登錄後的 JWT 生成過程。
@SpringBootTest
public class JwtTestor {
/**
* 創建Token
*/
@Test
public void createJwt(){
//私鑰字符串
String key = "1234567890_1234567890_1234567890";
//1.對祕鑰做BASE64編碼
String base64 = new BASE64Encoder().encode(key.getBytes());
//2.生成祕鑰對象,會根據base64長度自動選擇相應的 HMAC 算法
SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());
//3.利用JJWT生成Token
String data = "{\"userId\":123}"; //載荷數據
String jwt = Jwts.builder().setSubject(data).signWith(secretKey).compact();
System.out.println(jwt);
}
}
運行結果產生 JWT 字符串如下:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw
- 第三步,驗籤代碼,從 JWT 中提取 123 號用戶數據。這裏要保證 JWT 字符串、key 私鑰與生成時保持一致。否則就會拋出驗籤失敗 JwtException。
/**
* 校驗及提取JWT數據
*/
@Test
public void checkJwt(){
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxMjN9In0.1p_VTN46sukRJTYFxUg93CmfR3nJZRBm99ZK0e3d9Hw";
//私鑰
String key = "1234567890_1234567890_1234567890";
//1.對祕鑰做BASE64編碼
String base64 = new BASE64Encoder().encode(key.getBytes());
//2.生成祕鑰對象,會根據base64長度自動選擇相應的 HMAC 算法
SecretKey secretKey = Keys.hmacShaKeyFor(base64.getBytes());
//3.驗證Token
try {
//生成JWT解析器
JwtParser parser = Jwts.parserBuilder().setSigningKey(secretKey).build();
//解析JWT
Jws<Claims> claimsJws = parser.parseClaimsJws(jwt);
//得到載荷中的用戶數據
String subject = claimsJws.getBody().getSubject();
System.out.println(subject);
}catch (JwtException e){
//所有關於Jwt校驗的異常都繼承自JwtException
System.out.println("Jwt校驗失敗");
e.printStackTrace();
}
}
運行結果如下:
{"userId":123}
以上便是 JWT 的生成與校驗代碼,你會發現在加解密過程中,服務器私鑰 key 是保障 JWT 安全的命脈。對於這個私鑰在生產環境它不能寫死在代碼中,而是加密後保存在 Nacos 配置中心統一存儲,同時定期更換私鑰以防止關鍵信息泄露。
講到這應該你已掌握 JWT 的基本用法,但是在微服務架構下又該如何設計用戶認證體系呢?
基於網關的統一用戶認證
關於網關統一用戶認證和鑑權可以看陳某之前的文章:實戰乾貨!Spring Cloud Gateway 整合 OAuth2.0 實現分佈式統一認證授權!
下面我們結合場景講解 JWT 在微服務架構下的認證過程。這裏我將介紹兩種方案:
-
服務端自主驗籤方案;
-
API 網關統一驗籤方案。
服務端自主驗籤方案
首先咱們來看服務端驗籤的架構圖。
首先梳理下執行流程:
-
第一步,認證中心微服務負責用戶認證任務,在啓動時從 Nacos 配置中心抽取 JWT 加密用私鑰;
-
第二步,用戶在登錄頁輸入用戶名密碼,客戶端向認證中心服務發起認證請求:
http://usercenter/login #認證中心用戶認證(登錄)地址
- 第三步,認證中心服務根據輸入在用戶數據庫中進行認證校驗,如果校驗成功則返回認證中心將生成用戶的 JSON 數據並創建對應的 JWT 返回給客戶端,下面是認證中心返回的數據樣本;
{
"code": "0",
"message": "success",
"data": {
"user": {
"userId": 1,
"username": "zhangsan",
},
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxLFwidXNlcm5hbWVcIjpcInpoYW5nc2FuXCIsXCJuYW1lXCI6XCLlvKDkuIlcIixcImdyYWRlXCI6XCJub3JtYWxcIn0ifQ.1HtfszarTxLrqPktDkzArTEc4ah5VO7QaOOJqmSeXEM"
}
}
-
第四步,在收到上述 JSON 數據後,客戶端將其中 token 數據保存在 cookie 或者本地緩存中;
-
第五步,隨後客戶端向具體某個微服務發起新的請求,這個 JWT 都會附加在請求頭或者 cookie 中發往 API 網關,網關根據路由規則將請求與 jwt 數據轉發至具體的微服務。中間過程網關不對 JWT 做任何處理;
-
第六步,微服務接收到請求後,發現請求附帶 JWT 數據,於是將 JWT 再次轉發給用戶認證服務,此時用戶認證服務對 JWT 進行驗籤,驗籤成功提取其中用戶編號,查詢用戶認證與授權的詳細數據,數據結構如下所示:
{
"code": "0",
"message": "success",
"data": {
"user": { #用戶詳細數據
"userId": 1,
"username": "zhangsan",
"name": "張三",
"grade": "normal"
"age": 18,
"idno" : 130.......,
...
},
"authorization":{ #權限數據
"role" : "admin",
"permissions" : [{"addUser","delUser","..."}]
}
}
}
- 第七步,具體的微服務收到上述 JSON 後,對當前執行的操作進行判斷,檢查是否擁有執行權限,權限檢查通過執行業務代碼,權限檢查失敗返回錯誤響應。
到此從登錄創建 JWT 到驗籤後執行業務代碼的完整流程已經完成。
下面咱們來聊一聊第二種方案:
API 網關統一驗籤方案
API 網關統一驗籤與服務端驗籤最大的區別是在 API 網關層面就發起 JWT 的驗籤請求,之後路由過程中附加的是從認證中心返回的用戶與權限數據,其他的操作步驟與方案一是完全相同的。
在這你可能又會有疑惑,爲什麼要設計兩種不同的方案呢?其實這對應了不同的應用場景:
服務端驗籤的時機是在業務代碼執行前,控制的粒度更細。比如微服務 A 提供了 “商品查詢” 與“創建訂單”兩個功能,前者不需要登錄用戶就可以使用,因此不需要向認證中心額外發起驗籤工作;而後者是登錄後的功能,因此必須驗籤後纔可執行。因爲服務端驗籤是方法層面上的,所以可以精確控制方法是否驗籤。但也有不足,正是因爲驗籤是在方法前執行,所以需要在所有業務方法上聲明是否需要額外驗籤,儘管這個工作可以通過 Spring AOP + 註解的方式無侵入實現,但這也無疑需要程序員額外關注,分散了開發業務的精力。
相應的,服務端驗籤的缺點反而成爲 API 網關驗籤的優勢。API 網關不關心後端的服務邏輯,只要請求附帶 JWT,就自動向認證中心進行驗籤。這種簡單粗暴的策略確實讓模塊耦合有所降低,處理起來也更簡單,但也帶來了性能問題,因爲只要請求包含 JWT 就會產生認證中心的遠程通信。如果前端工程師沒有對 JWT 進行精確控制,很可能帶來大量多餘的認證操作,系統性能肯定會受到影響。
那在項目中到底如何選擇呢?服務端驗籤控制力度更細,適合應用在低延遲、高併發的應用,例如導航、實時交易系統、軍事應用。而 API 統一網關則更適合用在傳統的企業應用,可以讓程序員專心開發業務邏輯,同時程序也更容易維護。
全新的挑戰
雖然 JWT 看似很美,在實施落地過程中也會遇到一些特有的問題,例如:
JWT 生成後失效期是固定的,很多業務中需要客戶端在不改變 JWT 的前提下,實現 JWT 的 “續簽” 功能,但這單靠 JWT 自身特性是無法做到的,因爲 JWT 的設計本身就不允許生成完全相同的字符串。爲了解決這個問題,很多項目在生成的 JWT 設爲“永久生效”,架構師利用 Redis 的 Expire 過期特性在後端控制 JWT 的時效性。這麼做雖然讓 JWT 本身變得有狀態,但這可能也是在各種權衡後的“最優解”。類似的,例如:強制 JWT 立即失效、動態 JWT 有效期都可以使用這個辦法解決。
對於上面兩種認證方案,還有優化的空間,比如在服務 A 第一次對某個 JWT 進行驗籤後獲取用戶與權限數據,那在 JWT 的有效期內便可將數據在本地內存或者 Redis 中進行緩存,這樣下一次同樣的 JWT 訪問時直接從緩存中提取即可,可以節省大量服務間通信時間。但引入緩存後你也要時刻關注緩存與用戶數據的一致性問題,是要性能還是要數據可靠,這又是一個架構師需要面對的抉擇。
小結
今天主要涉及三方面內容,首先咱們回顧了基於 Session 的有狀態用戶認證解決方案,其次介紹了 JWT 與 JJWT 的使用,最後講解了利用 JWT 實現微服務架構認證的兩種方案,對產生的新問題也進行了梳理。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/UFzDxqWBTZmeVvrc2Mzqpw