第三十節:Asp.Net Core中JWT刷新Token解決方案


一. 前言

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>
View Code

服務器端代碼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     }
JWT幫助類

 服務器端代碼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     }
View Code

 

三. 測試

   將accessToken的過期時間設置為20s,點擊登錄授權后,等待20s,然后點擊獲取信息按鈕,依舊能獲取信息,無縫銜接,進行了雙token的更新。

 

 

 

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鵬飛)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 聲     明1 : 如有錯誤,歡迎討論,請勿謾罵^_^。
  • 聲     明2 : 原創博客請在轉載時保留原文鏈接或在文章開頭加上本人博客地址,否則保留追究法律責任的權利。
 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM