前言
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
的原因。
//获取信息接口
function GetMsg() {
var accessToken = window.localStorage.getItem("accessToken");
$.ajax({
url: "/Home/GetMsg",
type: "Post",
data: {},
datatype: "json",
beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "Bearer " + accessToken);
},
success: function (data) {
if (data.status == "ok") {
alert(data.msg);
} else {
alert(data.msg);
}
},
//当安全校验未通过的时候进入这里
error: function (xhr) {
if (xhr.status == 401) {
var errorMsg = xhr.responseText;
console.log(errorMsg);
//alert(errorMsg);
if (errorMsg == "expired") {
//表示过期,需要自动刷新
GetTokenAgain(GetMsg);
} else {
//表示是非法请求,给出提示,可以直接退回登录页
alert("非法请求");
}
}
}
});
}
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
的问题。
//重新获取访问令牌和刷新令牌
function GetTokenAgain(func) {
var model = {
accessToken: window.localStorage.getItem("accessToken"),
refreshToken: window.localStorage.getItem("refreshToken")
};
$.ajax({
url: '/Home/UpdateAccessToken',
type: "POST",
dataType: "json",
data: model,
success: function (data) {
if (data.status == "error") {
debugger;
// 表示重新获取令牌失败,可以退回登录页
alert("重新获取令牌失败");
} else {
window.localStorage.setItem("accessToken", data.data.accessToken);
window.localStorage.setItem("refreshToken", data.data.refreshToken);
func();
}
}
});
PS:以上方案,适用于单个页面发送单个ajax
请求,如果是多个请求,有顺序的发送,比如第一个发送完,然后再发送第二个,这种场景是没问题的。
但是,特殊情况如果一个页面多个ajax并行的过来了,如果其中有一个accessToken
过期了,那么它会走更新token
的机制,这时候refreshToken
和accessToken
都更新了(数据库中refreshToken
也更新了),会导致刚才同时进来的其它ajax
的refreshToken
验证不过,从而无法刷新双token
。
针对这种特殊情况,作为取舍,更新accessToken
的方法中,不更新refreshToken
, 那么refreshToken
过期,本来也是要进入 登录页的,所以针对这类情况,这种取舍也无可厚非。
下面分享完整版代码:
前端代码:
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>Index</title>
<script src="~/lib/jquery/dist/jquery.js"></script>
<script>
$(function () {
$('#btn1').click(function () {
Login();
});
$('#btn2').click(function () {
GetMsg();
});
});
//登录接口
function Login() {
$.ajax({
url: "/Home/CheckLogin",
type: "Post",
data: { userAccount: "admin", userPwd: "123456" },
datatype: "json",
success: function (data) {
if (data.status == "ok") {
alert(data.msg);
console.log(data.data.accessToken);
console.log(data.data.refreshToken);
window.localStorage.setItem("accessToken", data.data.accessToken);
window.localStorage.setItem("refreshToken", data.data.refreshToken);
} else {
alert(data.msg);
}
},
//当安全校验未通过的时候进入这里
error: function (xhr) {
if (xhr.status == 401) {
console.log(xhr.responseText);
alert(xhr.responseText)
}
}
});
}
//获取信息接口
function GetMsg() {
var accessToken = window.localStorage.getItem("accessToken");
$.ajax({
url: "/Home/GetMsg",
type: "Post",
data: {},
datatype: "json",
beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "Bearer " + accessToken);
},
success: function (data) {
if (data.status == "ok") {
alert(data.msg);
} else {
alert(data.msg);
}
},
//当安全校验未通过的时候进入这里
error: function (xhr) {
if (xhr.status == 401) {
var errorMsg = xhr.responseText;
console.log(errorMsg);
//alert(errorMsg);
if (errorMsg == "expired") {
//表示过期,需要自动刷新
GetTokenAgain(GetMsg);
} else {
//表示是非法请求,给出提示,可以直接退回登录页
alert("非法请求");
}
}
}
});
}
//重新获取访问令牌和刷新令牌
function GetTokenAgain(func) {
var model = {
accessToken: window.localStorage.getItem("accessToken"),
refreshToken: window.localStorage.getItem("refreshToken")
};
$.ajax({
url: '/Home/UpdateAccessToken',
type: "POST",
dataType: "json",
data: model,
success: function (data) {
if (data.status == "error") {
debugger;
// 表示重新获取令牌失败,可以退回登录页
alert("重新获取令牌失败");
} else {
window.localStorage.setItem("accessToken", data.data.accessToken);
window.localStorage.setItem("refreshToken", data.data.refreshToken);
func();
}
}
});
}
</script>
</head>
<body>
<button id="btn1">模拟登陆逻辑</button>
<button id="btn2">获取系统信息</button>
</body>
</html>
服务器端代码1:
(PS:如果有上面提到的特殊情况,则去掉更新机制中 4.2和4.3的代码)
相关接口
public class HomeController : Controller
{
private static List<RefreshToken> rTokenList = new List<RefreshToken>();
public IConfiguration _Configuration { get; }
public HomeController(IConfiguration Configuration)
{
this._Configuration = Configuration;
}
/// <summary>
/// 测试页面
/// </summary>
/// <returns></returns>
public IActionResult Index()
{
return View();
}
/// <summary>
/// 校验登录
/// </summary>
/// <param name="userAccount"></param>
/// <param name="userPwd"></param>
/// <returns></returns>
[HttpPost]
public IActionResult CheckLogin(string userAccount, string userPwd)
{
if (userAccount == "admin" && userPwd == "123456")
{
string AccessTokenKey = _Configuration["AccessTokenKey"];
string RefreshTokenKey = _Configuration["RefreshTokenKey"];
//1.先去数据库中吧userId查出来
string userId = "001";
//2. 生成accessToken
//过期时间(下面表示签名后 5分钟过期,这里设置20s为了演示)
double exp = (DateTime.UtcNow.AddSeconds(20) - new DateTime(1970, 1, 1)).TotalSeconds;
var payload = new Dictionary<string, object>
{
{"userId", userId },
{"userAccount", userAccount },
{"exp",exp }
};
var accessToken = JWTHelp.JWTJiaM(payload, AccessTokenKey);
//3.生成refreshToken
//过期时间(可以不设置,下面表示 2天过期)
var expireTime = DateTime.Now.AddDays(2);
double exp2 = (expireTime - new DateTime(1970, 1, 1)).TotalSeconds;
var payload2 = new Dictionary<string, object>
{
{"userId", userId },
{"userAccount", userAccount },
{"exp",exp2 }
};
var refreshToken = JWTHelp.JWTJiaM(payload2, RefreshTokenKey);
//4.将生成refreshToken的原始信息存到数据库/Redis中 (这里暂时存到一个全局变量中)
//先查询有没有,有则更新,没有则添加
var RefreshTokenItem = rTokenList.Where(u => u.userId == userId).FirstOrDefault();
if (RefreshTokenItem == null)
{
RefreshToken rItem = new RefreshToken()
{
id = Guid.NewGuid().ToString("N"),
userId = userId,
expire = expireTime,
Token = refreshToken
};
rTokenList.Add(rItem);
}
else
{
RefreshTokenItem.Token = refreshToken;
RefreshTokenItem.expire = expireTime; //要和前面生成的过期时间相匹配
}
return Json(new
{
status = "ok",
msg = "登录成功",
data = new
{
accessToken,
refreshToken
}
});
}
else
{
return Json(new
{
status = "error",
msg = "登录失败",
data = new { }
});
}
}
/// <summary>
/// 获取系统信息接口
/// </summary>
/// <returns></returns>
[TypeFilter(typeof(JwtCheck2))]
public IActionResult GetMsg()
{
string msg = "windows10";
return Json(new { status = "ok", msg = msg });
}
/// <summary>
/// 更新访问令牌(同时也更新刷新令牌)
/// </summary>
/// <returns></returns>
public IActionResult UpdateAccessToken(string accessToken, string refreshToken)
{
string AccessTokenKey = _Configuration["AccessTokenKey"];
string RefreshTokenKey = _Configuration["RefreshTokenKey"];
//1.先通过纯代码校验refreshToken的物理合法性
var result = JWTHelp.JWTJieM(refreshToken, _Configuration["RefreshTokenKey"]);
if (result == "expired" || result == "invalid" || result == "error")
{
return Json(new { status = "error", data = "" });
}
//2.从accessToken中解析出来userId等其它数据(即使accessToken已经过期,依旧可以解析出来)
JwtData myJwtData = JsonConvert.DeserializeObject<JwtData>(this.Base64UrlDecode(accessToken.Split('.')[1]));
//3. 拿着userId、refreshToken、当前时间去RefreshToken表中查数据
var rTokenItem = rTokenList.Where(u => u.userId == myJwtData.userId && u.Token == refreshToken && u.expire > DateTime.Now).FirstOrDefault();
if (rTokenItem == null)
{
return Json(new { status = "error", data = "" });
}
//4.重新生成 accessToken和refreshToken,并写入RefreshToken表
//4.1. 生成accessToken
//过期时间(下面表示签名后 5分钟过期,这里设置20s为了演示)
double exp = (DateTime.UtcNow.AddSeconds(20) - new DateTime(1970, 1, 1)).TotalSeconds;
var payload = new Dictionary<string, object>
{
{"userId", myJwtData.userId },
{"userAccount", myJwtData.userAccount },
{"exp",exp }
};
var MyAccessToken = JWTHelp.JWTJiaM(payload, AccessTokenKey);
//4.2.生成refreshToken
//过期时间(可以不设置,下面表示签名后 2天过期)
var expireTime = DateTime.Now.AddDays(2);
double exp2 = (expireTime - new DateTime(1970, 1, 1)).TotalSeconds;
var payload2 = new Dictionary<string, object>
{
{"userId", myJwtData.userId },
{"userAccount", myJwtData.userAccount },
{"exp",exp2 }
};
var MyRefreshToken = JWTHelp.JWTJiaM(payload2, RefreshTokenKey);
//4.3 更新refreshToken表
rTokenItem.Token = MyRefreshToken;
rTokenItem.expire = expireTime;
//5. 返回双Token
return Json(new
{
status = "ok",
data = new
{
accessToken = MyAccessToken,
refreshToken = MyRefreshToken
}
});
}
/// <summary>
/// Base64解码
/// </summary>
/// <param name="base64UrlStr"></param>
/// <returns></returns>
public string Base64UrlDecode(string base64UrlStr)
{
base64UrlStr = base64UrlStr.Replace('-', '+').Replace('_', '/');
switch (base64UrlStr.Length % 4)
{
case 2:
base64UrlStr += "==";
break;
case 3:
base64UrlStr += "=";
break;
}
var bytes = Convert.FromBase64String(base64UrlStr);
return Encoding.UTF8.GetString(bytes);
}
}
服务器端代码2:
JWT帮助类
/// <summary>
/// Jwt的加密和解密
/// 注:加密和加密用的是用一个密钥
/// 依赖程序集:【JWT】
/// </summary>
public class JWTHelp
{
/// <summary>
/// JWT加密算法
/// </summary>
/// <param name="payload">负荷部分,存储使用的信息</param>
/// <param name="secret">密钥</param>
/// <param name="extraHeaders">存放表头额外的信息,不需要的话可以不传</param>
/// <returns></returns>
public static string JWTJiaM(IDictionary<string, object> payload, string secret, IDictionary<string, object> extraHeaders = null)
{
IJwtAlgorithm algorithm = new HMACSHA256Algorithm();
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
var token = encoder.Encode(payload, secret);
return token;
}
/// <summary>
/// JWT解密算法
/// </summary>
/// <param name="token">需要解密的token串</param>
/// <param name="secret">密钥</param>
/// <returns></returns>
public static string JWTJieM(string token, string secret)
{
try
{
IJsonSerializer serializer = new JsonNetSerializer();
IDateTimeProvider provider = new UtcDateTimeProvider();
IJwtValidator validator = new JwtValidator(serializer, provider);
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder);
var json = decoder.Decode(token, secret, true);
//校验通过,返回解密后的字符串
return json;
}
catch (TokenExpiredException)
{
//表示过期
return "expired";
}
catch (SignatureVerificationException)
{
//表示验证不通过
return "invalid";
}
catch (Exception)
{
return "error";
}
}
}
服务器端代码3:
实体类
public class RefreshToken
{
//主键
public string id { get; set; }
//用户编号
public string userId { get; set; }
//refreshToken
public string Token { get; set; }
//过期时间
public DateTime expire { get; set; }
}
public class JwtData
{
public DateTime expire { get; set; } //代表过期时间
public string userId { get; set; }
public string userAccount { get; set; }
}
过滤器代码:
/// <summary>
/// Bearer认证,返回ajax中的error
/// 校验访问令牌的合法性
/// </summary>
public class JwtCheck2 : ActionFilterAttribute
{
private IConfiguration _configuration;
public JwtCheck2(IConfiguration configuration)
{
_configuration = configuration;
}
/// <summary>
/// action执行前执行
/// </summary>
/// <param name="context"></param>
public override void OnActionExecuting(ActionExecutingContext context)
{
//1.判断是否需要校验
var isSkip = context.ActionDescriptor.EndpointMetadata.Any(x => x.GetType() == typeof(SkipAttribute));
if (isSkip == false)
{
//2. 判断是什么请求(ajax or 非ajax)
var actionContext = context.HttpContext;
if (IsAjaxRequest(actionContext.Request))
{
//表示是ajax
var token = context.HttpContext.Request.Headers["Authorization"].ToString(); //ajax请求传过来
string pattern = "^Bearer (.*?)$";
if (!Regex.IsMatch(token, pattern))
{
context.Result = new ContentResult { StatusCode = 401, Content = "token格式不对!格式为:Bearer {token}" };
return;
}
token = Regex.Match(token, pattern).Groups[1]?.ToString();
if (token == "null" || string.IsNullOrEmpty(token))
{
context.Result = new ContentResult { StatusCode = 401, Content = "token不能为空" };
return;
}
//校验auth的正确性
var result = JWTHelp.JWTJieM(token, _configuration["AccessTokenKey"]);
if (result == "expired")
{
context.Result = new ContentResult { StatusCode = 401, Content = "expired" };
return;
}
else if (result == "invalid")
{
context.Result = new ContentResult { StatusCode = 401, Content = "invalid" };
return;
}
else if (result == "error")
{
context.Result = new ContentResult { StatusCode = 401, Content = "error" };
return;
}
else
{
//表示校验通过,用于向控制器中传值
context.RouteData.Values.Add("auth", result);
}
}
else
{
//表示是非ajax请求,则auth拼接在参数中传过来
context.Result = new RedirectResult("/Home/NoPerIndex?reason=null");
return;
}
}
}
/// <summary>
/// 判断该请求是否是ajax请求
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private bool IsAjaxRequest(HttpRequest request)
{
string header = request.Headers["X-Requested-With"];
return "XMLHttpRequest".Equals(header);
}
}
测试
将accessToken
的过期时间设置为20s,点击登录授权后,等待20s,然后点击获取信息按钮,依旧能获取信息,无缝衔接,进行了双token
的更新。