一. 前言
1.關於JWT的Token過期問題,到底設置多久過期?
(1).有的人設置過期時間很長,比如一個月,甚至更長,等到過期了退回登錄頁面,重新登錄重新獲取token,期間登錄的時候也是重新獲取token,然后過期時間又重置為了1個月。這樣一旦token被人截取,就可能被人長期使用,如果你想禁止,只能修改token頒發的密鑰,這樣就會導致所有token都失效,顯然不太可取。
(2).有的人設置比較短,比如10分鍾,在使用過程中,一旦過期也是退回登錄頁面,這樣就可能使用過程中經常退回登錄頁面,體驗很不好。
2. 這里介紹一種比較主流的解決方案---雙Token機制
(1).訪問令牌:accessToken,訪問接口是需要攜帶的,也就是我們之前一直使用的那個,過期時間一般設置比較短,根據實際項目分析,比如:10分鍾
(2).刷新令牌:refreshToken,當accessToken過期后,用於獲取新的accessToken的時候使用,過期時間一般設置的比較長,比如:7天
3.獲取新的accessToken的時候, 為什么還需要傳入舊accessToken,只傳入refreshToken不行么?
仔細看下面的解決思路,只傳入refreshToken也可以,但是傳入雙Token安全性更高一些。
二. 解決方案
1. 登錄請求過來,將userId和userAccount存到payLoad中,設置不同的過期時間,分別生成accessToken和refreshToken,二者的區別密鑰不一樣,過期時間不一樣,然后把 生成refreshToken的相關信息存到對應的表中【id,userId,token,expire】,一個用戶對應一條記錄(也可以存到Redis中,這里為了測試,存在一個全局變量中), 每次登錄的時候,添加或者更新記錄,最后將雙Token返回給前端,前端存到LocalStorage中。
2. 前端訪問GetMsg獲取信息接口,表頭需要攜帶accessToken,服務器端通過JwtCheck2過濾器進行校驗,驗證通過則正常訪問,如果不通過返回401和不通過的原因,前端在Error中進行獲取,這里區分造成401的原因。
1 //獲取信息接口 2 function GetMsg() { 3 var accessToken = window.localStorage.getItem("accessToken"); 4 $.ajax({ 5 url: "/Home/GetMsg", 6 type: "Post", 7 data: {}, 8 datatype: "json", 9 beforeSend: function (xhr) { 10 xhr.setRequestHeader("Authorization", "Bearer " + accessToken); 11 }, 12 success: function (data) { 13 if (data.status == "ok") { 14 alert(data.msg); 15 } else { 16 alert(data.msg); 17 } 18 }, 19 //當安全校驗未通過的時候進入這里 20 error: function (xhr) { 21 if (xhr.status == 401) { 22 var errorMsg = xhr.responseText; 23 console.log(errorMsg); 24 //alert(errorMsg); 25 if (errorMsg == "expired") { 26 //表示過期,需要自動刷新 27 GetTokenAgain(GetMsg); 28 } else { 29 //表示是非法請求,給出提示,可以直接退回登錄頁 30 alert("非法請求"); 31 } 32 } 33 } 34 }); 35 }
3. 如果是表頭為空、校驗錯誤等等,則直接提示請求非法,返回登錄頁。
4. 如果捕獲的是expired即過期,則調用GetTokenAgain(func)方法,即重新獲取accessToken和refreshToken,這里func代表傳遞進來一個方法名,以便調用成功后重新調用原方法,實現無縫刷新; 向服務器端傳遞 雙Token, 服務器端的驗證邏輯如下:
(1). 先通過純代碼校驗refreshToken的物理合法性,如果非法,前端直接報錯,返回到登錄頁面。
(2). 從accessToken中解析出來userId等其它數據(即使accessToken已經過期,依舊可以解析出來)
(3). 拿着userId、refreshToken、當前時間去RefreshToken表中查數據,如果查不到,直接返回前端報錯,返回到登錄頁面。
(4). 如果能查到,重新生成 accessToken和refreshToken,並寫入RefreshToken表
(5). 向前端返回雙token,前端進行覆蓋存儲,然后自動調用原方法,攜帶新的accessToken,進行訪問,從而實現無縫刷新token的問題。
1 //重新獲取訪問令牌和刷新令牌 2 function GetTokenAgain(func) { 3 var model = { 4 accessToken: window.localStorage.getItem("accessToken"), 5 refreshToken: window.localStorage.getItem("refreshToken") 6 }; 7 $.ajax({ 8 url: '/Home/UpdateAccessToken', 9 type: "POST", 10 dataType: "json", 11 data: model, 12 success: function (data) { 13 if (data.status == "error") { 14 debugger; 15 // 表示重新獲取令牌失敗,可以退回登錄頁 16 alert("重新獲取令牌失敗"); 17 18 } else { 19 window.localStorage.setItem("accessToken", data.data.accessToken); 20 window.localStorage.setItem("refreshToken", data.data.refreshToken); 21 func(); 22 } 23 } 24 });
PS:以上方案,適用於單個頁面發送單個ajax請求,如果是多個請求,有順序的發送,比如第一個發送完,然后再發送第二個,這種場景是沒問題的。
但是,特殊情況如果一個頁面多個ajax並行的過來了,如果其中有一個accessToken過期了,那么它會走更新token的機制,這時候refreshToken和accessToken都更新了(數據庫中refreshToken也更新了),會導致剛才同時進來的其它ajax的refreshToken驗證不過,從而無法刷新雙token。
針對這種特殊情況,作為取舍,更新accessToken的方法中,不更新refreshToken, 那么refreshToken過期,本來也是要進入 登錄頁的,所以針對這類情況,這種取舍也無可厚非。
下面分享完整版代碼:
前端代碼:

1 @{ 2 Layout = null; 3 } 4 5 <!DOCTYPE html> 6 7 <html> 8 <head> 9 <meta name="viewport" content="width=device-width" /> 10 <title>Index</title> 11 <script src="~/lib/jquery/dist/jquery.js"></script> 12 <script> 13 $(function () { 14 $('#btn1').click(function () { 15 Login(); 16 }); 17 $('#btn2').click(function () { 18 GetMsg(); 19 }); 20 }); 21 22 //登錄接口 23 function Login() { 24 $.ajax({ 25 url: "/Home/CheckLogin", 26 type: "Post", 27 data: { userAccount: "admin", userPwd: "123456" }, 28 datatype: "json", 29 success: function (data) { 30 if (data.status == "ok") { 31 alert(data.msg); 32 console.log(data.data.accessToken); 33 console.log(data.data.refreshToken); 34 window.localStorage.setItem("accessToken", data.data.accessToken); 35 window.localStorage.setItem("refreshToken", data.data.refreshToken); 36 37 } else { 38 alert(data.msg); 39 } 40 }, 41 //當安全校驗未通過的時候進入這里 42 error: function (xhr) { 43 if (xhr.status == 401) { 44 console.log(xhr.responseText); 45 alert(xhr.responseText) 46 } 47 } 48 }); 49 50 } 51 52 //獲取信息接口 53 function GetMsg() { 54 var accessToken = window.localStorage.getItem("accessToken"); 55 $.ajax({ 56 url: "/Home/GetMsg", 57 type: "Post", 58 data: {}, 59 datatype: "json", 60 beforeSend: function (xhr) { 61 xhr.setRequestHeader("Authorization", "Bearer " + accessToken); 62 }, 63 success: function (data) { 64 if (data.status == "ok") { 65 alert(data.msg); 66 } else { 67 alert(data.msg); 68 } 69 }, 70 //當安全校驗未通過的時候進入這里 71 error: function (xhr) { 72 if (xhr.status == 401) { 73 var errorMsg = xhr.responseText; 74 console.log(errorMsg); 75 //alert(errorMsg); 76 if (errorMsg == "expired") { 77 //表示過期,需要自動刷新 78 GetTokenAgain(GetMsg); 79 } else { 80 //表示是非法請求,給出提示,可以直接退回登錄頁 81 alert("非法請求"); 82 } 83 } 84 } 85 }); 86 } 87 88 //重新獲取訪問令牌和刷新令牌 89 function GetTokenAgain(func) { 90 var model = { 91 accessToken: window.localStorage.getItem("accessToken"), 92 refreshToken: window.localStorage.getItem("refreshToken") 93 }; 94 $.ajax({ 95 url: '/Home/UpdateAccessToken', 96 type: "POST", 97 dataType: "json", 98 data: model, 99 success: function (data) { 100 if (data.status == "error") { 101 debugger; 102 // 表示重新獲取令牌失敗,可以退回登錄頁 103 alert("重新獲取令牌失敗"); 104 105 } else { 106 window.localStorage.setItem("accessToken", data.data.accessToken); 107 window.localStorage.setItem("refreshToken", data.data.refreshToken); 108 func(); 109 } 110 } 111 }); 112 } 113 114 </script> 115 </head> 116 <body> 117 <button id="btn1">模擬登陸邏輯</button> 118 <button id="btn2">獲取系統信息</button> 119 120 </body> 121 </html>
服務器端代碼1:
(PS:如果有上面提到的特殊情況,則去掉更新機制中 4.2和4.3的代碼)

1 public class HomeController : Controller 2 { 3 private static List<RefreshToken> rTokenList = new List<RefreshToken>(); 4 5 public IConfiguration _Configuration { get; } 6 7 public HomeController(IConfiguration Configuration) 8 { 9 this._Configuration = Configuration; 10 } 11 12 /// <summary> 13 /// 測試頁面 14 /// </summary> 15 /// <returns></returns> 16 public IActionResult Index() 17 { 18 return View(); 19 } 20 21 /// <summary> 22 /// 校驗登錄 23 /// </summary> 24 /// <param name="userAccount"></param> 25 /// <param name="userPwd"></param> 26 /// <returns></returns> 27 [HttpPost] 28 public IActionResult CheckLogin(string userAccount, string userPwd) 29 { 30 31 if (userAccount == "admin" && userPwd == "123456") 32 { 33 34 string AccessTokenKey = _Configuration["AccessTokenKey"]; 35 string RefreshTokenKey = _Configuration["RefreshTokenKey"]; 36 37 //1.先去數據庫中吧userId查出來 38 string userId = "001"; 39 40 //2. 生成accessToken 41 //過期時間(下面表示簽名后 5分鍾過期,這里設置20s為了演示) 42 double exp = (DateTime.UtcNow.AddSeconds(20) - new DateTime(1970, 1, 1)).TotalSeconds; 43 var payload = new Dictionary<string, object> 44 { 45 {"userId", userId }, 46 {"userAccount", userAccount }, 47 {"exp",exp } 48 }; 49 var accessToken = JWTHelp.JWTJiaM(payload, AccessTokenKey); 50 51 //3.生成refreshToken 52 //過期時間(可以不設置,下面表示 2天過期) 53 var expireTime = DateTime.Now.AddDays(2); 54 double exp2 = (expireTime - new DateTime(1970, 1, 1)).TotalSeconds; 55 var payload2 = new Dictionary<string, object> 56 { 57 {"userId", userId }, 58 {"userAccount", userAccount }, 59 {"exp",exp2 } 60 }; 61 var refreshToken = JWTHelp.JWTJiaM(payload2, RefreshTokenKey); 62 63 //4.將生成refreshToken的原始信息存到數據庫/Redis中 (這里暫時存到一個全局變量中) 64 //先查詢有沒有,有則更新,沒有則添加 65 var RefreshTokenItem = rTokenList.Where(u => u.userId == userId).FirstOrDefault(); 66 if (RefreshTokenItem == null) 67 { 68 RefreshToken rItem = new RefreshToken() 69 { 70 id = Guid.NewGuid().ToString("N"), 71 userId = userId, 72 expire = expireTime, 73 Token = refreshToken 74 }; 75 rTokenList.Add(rItem); 76 77 } 78 else 79 { 80 RefreshTokenItem.Token = refreshToken; 81 RefreshTokenItem.expire = expireTime; //要和前面生成的過期時間相匹配 82 83 } 84 return Json(new 85 { 86 status = "ok", 87 msg="登錄成功", 88 data = new 89 { 90 accessToken, 91 refreshToken 92 } 93 }); 94 } 95 else 96 { 97 return Json(new 98 { 99 status = "error", 100 msg = "登錄失敗", 101 data = new { } 102 }); 103 } 104 105 106 } 107 108 109 110 /// <summary> 111 /// 獲取系統信息接口 112 /// </summary> 113 /// <returns></returns> 114 [TypeFilter(typeof(JwtCheck2))] 115 public IActionResult GetMsg() 116 { 117 string msg = "windows10"; 118 return Json(new { status = "ok", msg = msg }); 119 } 120 121 122 123 /// <summary> 124 /// 更新訪問令牌(同時也更新刷新令牌) 125 /// </summary> 126 /// <returns></returns> 127 public IActionResult UpdateAccessToken(string accessToken, string refreshToken) 128 { 129 130 string AccessTokenKey = _Configuration["AccessTokenKey"]; 131 string RefreshTokenKey = _Configuration["RefreshTokenKey"]; 132 133 //1.先通過純代碼校驗refreshToken的物理合法性 134 var result = JWTHelp.JWTJieM(refreshToken, _Configuration["RefreshTokenKey"]); 135 if (result== "expired"|| result == "invalid" || result == "error") 136 { 137 return Json(new { status = "error", data = "" }); 138 } 139 140 //2.從accessToken中解析出來userId等其它數據(即使accessToken已經過期,依舊可以解析出來) 141 JwtData myJwtData = JsonConvert.DeserializeObject<JwtData>(this.Base64UrlDecode(accessToken.Split('.')[1])); 142 143 //3. 拿着userId、refreshToken、當前時間去RefreshToken表中查數據 144 var rTokenItem = rTokenList.Where(u => u.userId == myJwtData.userId && u.Token == refreshToken && u.expire > DateTime.Now).FirstOrDefault(); 145 if (rTokenItem==null) 146 { 147 return Json(new { status = "error", data = "" }); 148 } 149 150 //4.重新生成 accessToken和refreshToken,並寫入RefreshToken表 151 //4.1. 生成accessToken 152 //過期時間(下面表示簽名后 5分鍾過期,這里設置20s為了演示) 153 double exp = (DateTime.UtcNow.AddSeconds(20) - new DateTime(1970, 1, 1)).TotalSeconds; 154 var payload = new Dictionary<string, object> 155 { 156 {"userId", myJwtData.userId }, 157 {"userAccount", myJwtData.userAccount }, 158 {"exp",exp } 159 }; 160 var MyAccessToken = JWTHelp.JWTJiaM(payload, AccessTokenKey); 161 162 //4.2.生成refreshToken 163 //過期時間(可以不設置,下面表示簽名后 2天過期) 164 var expireTime = DateTime.Now.AddDays(2); 165 double exp2 = (expireTime - new DateTime(1970, 1, 1)).TotalSeconds; 166 var payload2 = new Dictionary<string, object> 167 { 168 {"userId", myJwtData.userId }, 169 {"userAccount", myJwtData.userAccount }, 170 {"exp",exp2 } 171 }; 172 var MyRefreshToken = JWTHelp.JWTJiaM(payload2, RefreshTokenKey); 173 174 //4.3 更新refreshToken表 175 rTokenItem.Token = MyRefreshToken; 176 rTokenItem.expire = expireTime; 177 178 179 //5. 返回雙Token 180 return Json(new 181 { 182 status = "ok", 183 data = new 184 { 185 accessToken= MyAccessToken, 186 refreshToken= MyRefreshToken 187 } 188 }); 189 190 } 191 192 193 /// <summary> 194 /// Base64解碼 195 /// </summary> 196 /// <param name="base64UrlStr"></param> 197 /// <returns></returns> 198 199 public string Base64UrlDecode(string base64UrlStr) 200 { 201 base64UrlStr = base64UrlStr.Replace('-', '+').Replace('_', '/'); 202 switch (base64UrlStr.Length % 4) 203 { 204 case 2: 205 base64UrlStr += "=="; 206 break; 207 case 3: 208 base64UrlStr += "="; 209 break; 210 } 211 var bytes = Convert.FromBase64String(base64UrlStr); 212 return Encoding.UTF8.GetString(bytes); 213 } 214 215 216 }
服務器端代碼2:

1 /// <summary> 2 /// Jwt的加密和解密 3 /// 注:加密和加密用的是用一個密鑰 4 /// 依賴程序集:【JWT】 5 /// </summary> 6 public class JWTHelp 7 { 8 9 /// <summary> 10 /// JWT加密算法 11 /// </summary> 12 /// <param name="payload">負荷部分,存儲使用的信息</param> 13 /// <param name="secret">密鑰</param> 14 /// <param name="extraHeaders">存放表頭額外的信息,不需要的話可以不傳</param> 15 /// <returns></returns> 16 public static string JWTJiaM(IDictionary<string, object> payload, string secret, IDictionary<string, object> extraHeaders = null) 17 { 18 IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); 19 IJsonSerializer serializer = new JsonNetSerializer(); 20 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 21 IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder); 22 var token = encoder.Encode(payload, secret); 23 return token; 24 } 25 26 /// <summary> 27 /// JWT解密算法 28 /// </summary> 29 /// <param name="token">需要解密的token串</param> 30 /// <param name="secret">密鑰</param> 31 /// <returns></returns> 32 public static string JWTJieM(string token, string secret) 33 { 34 try 35 { 36 IJsonSerializer serializer = new JsonNetSerializer(); 37 IDateTimeProvider provider = new UtcDateTimeProvider(); 38 IJwtValidator validator = new JwtValidator(serializer, provider); 39 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 40 IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder); 41 42 var json = decoder.Decode(token, secret, true); 43 //校驗通過,返回解密后的字符串 44 return json; 45 } 46 catch (TokenExpiredException) 47 { 48 //表示過期 49 return "expired"; 50 } 51 catch (SignatureVerificationException) 52 { 53 //表示驗證不通過 54 return "invalid"; 55 } 56 catch (Exception) 57 { 58 return "error"; 59 } 60 } 61 62 63 }
服務器端代碼3:

1 public class RefreshToken 2 { 3 //主鍵 4 public string id { get; set; } 5 //用戶編號 6 public string userId { get; set; } 7 //refreshToken 8 public string Token { get; set; } 9 //過期時間 10 public DateTime expire { get; set; } 11 } 12 } 13 14 public class JwtData 15 { 16 public DateTime expire { get; set; } //代表過期時間 17 18 public string userId { get; set; } 19 20 public string userAccount { get; set; } 21 }
過濾器代碼:

1 /// <summary> 2 /// Bearer認證,返回ajax中的error 3 /// 校驗訪問令牌的合法性 4 /// </summary> 5 public class JwtCheck2 : ActionFilterAttribute 6 { 7 8 private IConfiguration _configuration; 9 public JwtCheck2(IConfiguration configuration) 10 { 11 _configuration = configuration; 12 } 13 14 /// <summary> 15 /// action執行前執行 16 /// </summary> 17 /// <param name="context"></param> 18 public override void OnActionExecuting(ActionExecutingContext context) 19 { 20 //1.判斷是否需要校驗 21 var isSkip = context.ActionDescriptor.EndpointMetadata.Any(x => x.GetType() == typeof(SkipAttribute)); 22 if (isSkip == false) 23 { 24 //2. 判斷是什么請求(ajax or 非ajax) 25 var actionContext = context.HttpContext; 26 if (IsAjaxRequest(actionContext.Request)) 27 { 28 //表示是ajax 29 var token = context.HttpContext.Request.Headers["Authorization"].ToString(); //ajax請求傳過來 30 string pattern = "^Bearer (.*?)$"; 31 if (!Regex.IsMatch(token, pattern)) 32 { 33 context.Result = new ContentResult { StatusCode = 401, Content = "token格式不對!格式為:Bearer {token}" }; 34 return; 35 } 36 token = Regex.Match(token, pattern).Groups[1]?.ToString(); 37 if (token == "null" || string.IsNullOrEmpty(token)) 38 { 39 context.Result = new ContentResult { StatusCode = 401, Content = "token不能為空" }; 40 return; 41 } 42 //校驗auth的正確性 43 var result = JWTHelp.JWTJieM(token, _configuration["AccessTokenKey"]); 44 if (result == "expired") 45 { 46 context.Result = new ContentResult { StatusCode = 401, Content = "expired" }; 47 return; 48 } 49 else if (result == "invalid") 50 { 51 context.Result = new ContentResult { StatusCode = 401, Content = "invalid" }; 52 return; 53 } 54 else if (result == "error") 55 { 56 context.Result = new ContentResult { StatusCode = 401, Content = "error" }; 57 return; 58 } 59 else 60 { 61 //表示校驗通過,用於向控制器中傳值 62 context.RouteData.Values.Add("auth", result); 63 } 64 65 } 66 else 67 { 68 //表示是非ajax請求,則auth拼接在參數中傳過來 69 context.Result = new RedirectResult("/Home/NoPerIndex?reason=null"); 70 return; 71 } 72 } 73 74 } 75 76 77 /// <summary> 78 /// 判斷該請求是否是ajax請求 79 /// </summary> 80 /// <param name="request"></param> 81 /// <returns></returns> 82 private bool IsAjaxRequest(HttpRequest request) 83 { 84 string header = request.Headers["X-Requested-With"]; 85 return "XMLHttpRequest".Equals(header); 86 } 87 }
三. 測試
將accessToken的過期時間設置為20s,點擊登錄授權后,等待20s,然后點擊獲取信息按鈕,依舊能獲取信息,無縫銜接,進行了雙token的更新。
!
- 作 者 : Yaopengfei(姚鵬飛)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 聲 明1 : 如有錯誤,歡迎討論,請勿謾罵^_^。
- 聲 明2 : 原創博客請在轉載時保留原文鏈接或在文章開頭加上本人博客地址,否則保留追究法律責任的權利。