第三十节: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>
服务器端代码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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。