一. 簡介
1. 背景
傳統的基於Session的校驗存在諸多問題,比如:Session過期、服務器開銷過大、不能分布式部署、不適合前后端分離的項目。 傳統的基於Token的校驗需要存儲Key-Value信息,存在Session或數據庫中都有弊端,如果按照一定規律采用對稱加密算法生成token,雖然能解決上面問題,但是一旦對稱加密算法泄露,很容被反編譯;所以在此基礎上繼續升級,利用userId生成Token,只要保存好秘鑰即可,從而引出JWT。
2. 什么是JWT
Json web token 簡稱:JWT, 是為了在網絡應用環境間傳遞聲明而執行的一種基於JSON的開放標准。該token被設計為緊湊且安全的,特別適用於分布式站點的單點登錄(SSO)場景。JWT的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也可以增加一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。
下面就是一段JWT字符串(后面詳細分析)
1 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiJ9.Qjw1epD5P6p4Yy2yju3-fkq28PddznqRj3ESfALQy_U
3. JWT的優點
(1). JWT是無狀態的,不需要服務器端保存會話信息,減輕服務器端的讀取壓力(存儲在客戶端上),同時易於擴展、易於分布式部署。
(2). JWT可以跨語言支持。
(3). 便於傳輸,jwt的構成很簡單,字節占用空間少,所以是非常便於傳輸的。
(4). 自身構成有payload部分,可以存儲一下業務邏輯相關的非敏感信息。
特別聲明:JWT最大的優勢是無狀態的,相對傳統的Session驗證能減輕服務器端的存儲壓力,安全性更高,但也不是絕對的,比如針對同一個接口,JWT字符串被截取后,且在有效期內,在不篡改JWT字符串的情況下,也是可以模擬請求進行訪問的。(隨着下面的內容深入體會JWT的核心)
二. JWT深度剖析
1. JWT的長相
下面的一段字符串就是JWT加密后的顯示格式,我們仔細看,中間通過兩個 “點” 將這段字符串分割成三部分了。
eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8
上面一段很長的字符串到底是怎么來的呢?就需要了解JWT的構成原理。
2. JWT的構成
JWT由三部分組成,如下圖,分別是:Header頭部、Payload負載、Signature簽名。
(1). 頭部(Header)
通常包括兩部分,類型(如 “typ”:“JWT”)和加密算法(如“alg”:"HS256"),當然你也可以添加其它自定義的一些參數,然后對這個對象機型base64編碼,生成一段字符串,
如“eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0”,我們可以對其進行反編碼一下,看一下其廬山真面目。
注:Base64是一種編碼,也就是說,它是可以被翻譯回原來的樣子來的。它並不是一種加密過程。
(2). 負載(Payload)
通常用來存放一些業務需要但不敏感的信息,比如:用戶編號(userId)、用戶賬號(userAccount)、權限等等,該部分也有一些默認的聲明,如下圖,很多不常用。
- iss: jwt簽發者
- sub: jwt所面向的用戶
- aud: 接收jwt的一方
- exp: jwt的過期時間,這個過期時間必須要大於簽發時間
- nbf: 定義在什么時間之前,該jwt都是不可用的.
- iat: jwt的簽發時間
- jti: jwt的唯一身份標識,主要用來作為一次性token,從而回避重放攻擊。

最后對該部分組裝成的對象進行base64編碼,如:“eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ”,我們可以對其進行反編碼看一下廬山真面目,如下圖:
注:該部分也是可以解碼的,所以不要存儲敏感信息。
(3). 簽名(Signature)
這個部分需要base64加密后的header和base64加密后的payload使用.
連接組成的字符串,然后通過header中聲明的加密方式進行加鹽secret
組合加密,然后就構成了jwt的第三部分。
偽代碼如下:
1 var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); 2 var signature = HMACSHA256(encodedString, 'sercret密鑰')
說明:密鑰存在服務器端,不要泄露,在不知道密鑰的情況下,是不能進行解密的,jwt的簽發生成也是在服務器端的,secret就是用來進行jwt的簽發和jwt的驗證,所以,它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret, 那就意味着客戶端是可以自我簽發jwt了。
特別說明:即使payload中的信息被篡改,服務器端通過signature就可以判斷出來是非法請求,即校驗不能通過。
3. 代碼嘗鮮
需要通過Nuget裝JWT包,新版本的jwt建議.Net 版本4.6起。
1 [HttpGet] 2 public string JiaM() 3 { 4 //設置過期時間(可以不設置,下面表示簽名后 20分鍾過期) 5 double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds; 6 var payload = new Dictionary<string, object> 7 { 8 { "UserId", 123 }, 9 { "UserName", "admin" }, 10 {"exp",exp } //該參數也可以不寫 11 }; 12 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";//不要泄露 13 IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); 14 15 //注意這個是額外的參數,默認參數是 typ 和alg 16 var headers = new Dictionary<string, object> 17 { 18 { "typ1", "1234" }, 19 { "alg2", "admin" } 20 }; 21 22 IJsonSerializer serializer = new JsonNetSerializer(); 23 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 24 IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder); 25 var token = encoder.Encode(headers, payload, secret); 26 return token; 27 } 28 29 [HttpGet] 30 public string JieM(string token) 31 { 32 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 33 try 34 { 35 IJsonSerializer serializer = new JsonNetSerializer(); 36 IDateTimeProvider provider = new UtcDateTimeProvider(); 37 IJwtValidator validator = new JwtValidator(serializer, provider); 38 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 39 IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder); 40 var json = decoder.Decode(token, secret, true); 41 return json; 42 } 43 catch (TokenExpiredException) 44 { 45 //過期了自動進入這里 46 return "Token has expired"; 47 } 48 catch (SignatureVerificationException) 49 { 50 //校驗未通過自動進入這里 51 return "Token has invalid signature"; 52 } 53 catch (Exception) 54 { 55 //其它錯誤,自動進入到這里 56 return "other error"; 57 }
上述代碼方便通過PostMan進行快速測試,注意解密的方法中的三個catch,token過期,會自動進入到TokenExpiredException異常中,token驗證不通過,會自動進入SignatureVerificationException中。
三. JWT的使用流程
整體流程,大致如下圖:
1. 客戶端(前端或App端)通過一個Http請求把用戶名和密碼傳到登錄接口,建議采用Https的模式,避免信息被嗅探。
2. 服務器端校驗登錄的接口驗證用戶名和密碼通過后,把一些業務邏輯需要的信息如:userId、userAccount放到Payload中,進而生成一個xxx.yyy.zzz形式的JWT字符串返回給客戶端。
3. 客戶端獲取到JWT的字符串,可以存放到LocalStorage中,注意退出的登錄的時候刪除該值。
4. 登錄成功,每次請求其它接口的時候都在表頭帶着該jwt字符串,建議放入HTTP Header中的Authorization位。(解決XSS和XSRF問題) 或者自己命名比如:“auth”,進行該字符串的傳遞。
5. 服務器端要寫一個過濾器,在該過濾器中進行校驗jwt的有效性(簽名是否正確、是否過期),驗證通過進行接口的業務邏輯,驗證不通過,返回給客戶端。
這里要解決兩個問題?
(1). 在WebApi的過濾器中,如果校驗通過了,如何將解密后的值傳遞到action中。(解密兩次就有點坑了)
(2). 在WebApi的過濾器中,如果校驗不通過,如何返回給客戶端,然后客戶端針對這種情況,又該如何接受呢。
(實戰中揭曉)。
四. 項目實戰
一. 整體目標:
通過一個登陸接口和一個獲取信息的接口模擬JWT的整套驗證邏輯。
二. 詳細步驟
1. 封裝JWT加密和解密的方法。
需要通過Nuget安裝JWT的程序集,JWT的最新版本建議使用.Net 4.6 起。

1 public class JWTHelp 2 { 3 4 /// <summary> 5 /// JWT加密算法 6 /// </summary> 7 /// <param name="payload">負荷部分,存儲使用的信息</param> 8 /// <returns></returns> 9 public static string JWTJiaM(IDictionary<string, object> payload) 10 { 11 //密鑰保存好,不要泄露 12 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 13 IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); 14 IJsonSerializer serializer = new JsonNetSerializer(); 15 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 16 IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder); 17 var token = encoder.Encode(payload, secret); 18 return token; 19 } 20 21 /// <summary> 22 /// JWT解密算法 23 /// </summary> 24 /// <param name="token"></param> 25 /// <returns></returns> 26 public static string JWTJieM(string token) 27 { 28 //密鑰保存好,不要泄露 29 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 30 try 31 { 32 IJsonSerializer serializer = new JsonNetSerializer(); 33 IDateTimeProvider provider = new UtcDateTimeProvider(); 34 IJwtValidator validator = new JwtValidator(serializer, provider); 35 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 36 IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder); 37 var json = decoder.Decode(token, secret, true); 38 //校驗通過,返回解密后的字符串 39 return json; 40 } 41 catch (TokenExpiredException) 42 { 43 //表示過期 44 return "expired"; 45 } 46 catch (SignatureVerificationException) 47 { 48 //表示驗證不通過 49 return "invalid"; 50 } 51 catch (Exception ex) 52 { 53 return "error"; 54 } 55 } 56 57 58 }
2. 模擬登陸接口
在登錄接口中,模擬數據庫校驗,即賬號和密碼為admin和12345,即校驗通過,然后把賬號和userId(實際應該到數據庫中查),這里也可以設置一下過期時間,比如20分鍾,一同存放到PayLoad中,然后生成JWT字符串,返回給客戶端。
/// <summary> /// 模擬登陸 /// </summary> /// <param name="userAccount"></param> /// <param name="pwd"></param> /// <returns></returns> [HttpGet] public string Login1(string userAccount, string pwd) { try { //這里模擬數據操作,只要是admin和123456就驗證通過 if (userAccount == "admin" && pwd == "123456") { //1. 進行業務處理(這里模擬獲取userId) string userId = "0806"; //過期時間(可以不設置,下面表示簽名后 20分鍾過期) double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds; //進行組裝 var payload = new Dictionary<string, object> { {"userId", userId }, {"userAccount", userAccount }, {"exp",exp } }; //2. 進行JWT簽名 var token = JWTHelp.JWTJiaM(payload); var result = new { result = "ok", token = token }; return JsonConvert.SerializeObject(result); } else { var result = new { result = "error", token = "" }; return JsonConvert.SerializeObject(result); } } catch (Exception) { var result = new { result = "error", token = "" }; return JsonConvert.SerializeObject(result); } }
3. 客戶端調用登錄接口
這里只是單純為了測試,使用的get請求,實際項目中建議post請求,且配置Https,請求成功后,把jwt字符串存放到localStorage中。
1 //1.登錄 2 $('#j_jwtLogin').on('click', function () { 3 $.get("/api/Seventh/Login1", { userAccount: "admin", pwd: "123456" }, function (data) { 4 var jsonData = JSON.parse(data); 5 if (jsonData.result == "ok") { 6 console.log(jsonData.token); 7 //存放到本地緩存中 8 window.localStorage.setItem("token", jsonData.token); 9 alert("登錄成功,ticket=" + jsonData.token); 10 } else { 11 alert("登錄失敗"); 12 } 13 }); 14 });
運行結果:
4. 服務器端過濾器
代碼中分享了兩種獲取header中信息的方式,獲取到“auth”后,進行校驗,校驗不通過的話,通過狀態碼401返回給客戶端,校驗通過的話,則使用 actionContext.RequestContext.RouteData.Values.Add("auth", result); 進行解密值的存儲,方便后續action的直接獲取。
1 /// <summary> 2 /// 驗證JWT算法的過濾器 3 /// </summary> 4 public class JWTCheck : AuthorizeAttribute 5 { 6 public override void OnAuthorization(HttpActionContext actionContext) 7 { 8 //獲取表頭Header中值的幾種方式 9 //方式一: 10 //{ 11 // var authHeader2 = from t in actionContext.Request.Headers 12 // where t.Key == "auth" 13 // select t.Value.FirstOrDefault(); 14 // var token2 = authHeader2.FirstOrDefault(); 15 //} 16 17 //方式二: 18 IEnumerable<string> auths; 19 if (!actionContext.Request.Headers.TryGetValues("auth", out auths)) 20 { 21 //HttpContext.Current.Response.Write("報文頭中的auth為空"); 22 //返回狀態碼驗證未通過,並返回原因(前端進行401狀態碼的捕獲),注意:這句話並不能截斷該過濾器,還會繼續往下走,要借助if-else,如果想直接截斷,需要加 return; 23 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("報文頭中的auth為空")); 24 } 25 else 26 { 27 var token = auths.FirstOrDefault(); 28 if (token != null) 29 { 30 if (!string.IsNullOrEmpty(token)) 31 { 32 var result = JWTHelp.JWTJieM(token); 33 if (result == "expired") 34 { 35 //返回狀態碼驗證未通過,並返回原因(前端進行401狀態碼的捕獲) 36 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("expired")); 37 } 38 else if (result == "invalid") 39 { 40 //返回狀態碼驗證未通過,並返回原因(前端進行401狀態碼的捕獲) 41 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("invalid")); 42 } 43 else if (result == "error") 44 { 45 //返回狀態碼驗證未通過,並返回原因(前端進行401狀態碼的捕獲) 46 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("error")); 47 } 48 else 49 { 50 //表示校驗通過,用於向控制器中傳值 51 actionContext.RequestContext.RouteData.Values.Add("auth", result); 52 } 53 } 54 } 55 else 56 { 57 //返回狀態碼驗證未通過,並返回原因(前端進行401狀態碼的捕獲) 58 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("token 空")); 59 } 60 } 61 62 } 63 }
5.服務器端獲取信息的的方法
將上說過濾器以特性的形式作用在該方法中,然后通過 RequestContext.RouteData.Values["auth"] 獲取到解密后的值,進而進行其它業務處理。
1 /// <summary> 2 /// 加密后的獲取信息 3 /// </summary> 4 /// <returns></returns> 5 [JWTCheck] 6 [HttpGet] 7 public string GetInfor() 8 { 9 var userData = JsonConvert.DeserializeObject<userData>(RequestContext.RouteData.Values["auth"].ToString()); ; 10 if (userData == null) 11 { 12 var result = new { Message = "error", data = "" }; 13 return JsonConvert.SerializeObject(result); 14 } 15 else 16 { 17 var data = new { userId = userData.userId, userAccount = userData.userAccount }; 18 var result = new { Message = "ok", data =data }; 19 return JsonConvert.SerializeObject(result); 20 } 21 }
6. 客戶端調用獲取信息的方法
前端獲取到localStorage中token值,采用自定義header的方式以“auth”進行傳遞調用服務器端的方法,由於服務器的驗證token不正確的時候,是以狀態碼的形式返回,所以這里要采用error方法,通過xhr.status==401進行判斷,凡是進入到這個401中,均是token驗證沒有通過,具體是什么原因,可以通過xhr.responseText獲取詳細的值進行判斷。
1 //2.獲取信息 2 $('#j_jwtGetInfor').on('click', function () { 3 //從本地緩存中讀取token值 4 var token = window.localStorage.getItem("token"); 5 $.ajax({ 6 url: "/api/Seventh/GetInfor", 7 type: "Get", 8 data: {}, 9 datatype: "json", 10 //設置header的方式1 11 headers: { "auth": token}, 12 //設置header的方式2 13 //beforeSend: function (xhr) { 14 // xhr.setRequestHeader("auth", token) 15 //}, 16 success: function (data) { 17 console.log(data); 18 var jsonData = JSON.parse(data); 19 if (jsonData.Message == "ok") { 20 var myData = jsonData.data; 21 console.log("獲取成功"); 22 console.log(myData.userId); 23 console.log(myData.userAccount); 24 } else { 25 console.log("獲取失敗"); 26 } 27 }, 28 //當安全校驗未通過的時候進入這里 29 error: function (xhr) { 30 if (xhr.status == 401) { 31 console.log(xhr.responseText); 32 var jsonData = JSON.parse(xhr.responseText); 33 console.log("授權失敗,原因為:" + jsonData.Message); 34 } 35 } 36 }); 37 });
運行結果:
其他的如token過期只需要改一下電腦時間即可以測試,token不正確改一下獲取到的jwt字符串可以測試,這里不再進行 了。
!
- 作 者 : Yaopengfei(姚鵬飛)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 聲 明1 : 本人才疏學淺,用郭德綱的話說“我是一個小學生”,如有錯誤,歡迎討論,請勿謾罵^_^。
- 聲 明2 : 原創博客請在轉載時保留原文鏈接或在文章開頭加上本人博客地址,否則保留追究法律責任的權利。