第七节:Token自动续签方案落地(滑动窗口、双token刷新方案、Token并发刷新冲突解决)
一. 说明
1. 什么是Token自动续签?
Token自动续签是指在用户使用应用程序时,能够在Token即将过期时(或已过期)自动延长其有效时间,从而保持用户的登录状态而无需重新登录。
(参考之前bk:第三十节:Asp.Net Core中JWT刷新Token解决方案 )
2. 业内主流方案有哪些?
方案1-滑动窗口机制
原理:每次请求时,如果 Token 剩余有效期小于某个阈值(如 5 分钟),则生成新 Token。
优点:无缝续签,用户无感知。
缺点:增加服务器负载,可能导致 Token 无限期延长。
方案2-双 Token 机制
原理:
Access Token:短期有效(如 1 小时),包含用户身份信息。
Refresh Token:长期有效(如 30 天),用于刷新 Access Token,通常存储在 HttpOnly Cookie 中。
优点:安全性高,降低 Token 泄露风险。
缺点:实现复杂度高,需要管理两个 Token
3. 推荐
推荐使用 "双Token机制" 的方案,但使用一部到位解决并发刷新token冲突问题。
二. 双Token刷新方案【临时方案】
1. 原理
(1) 登录的时候,同时生成双token,分别是accessToken和refreshToken
A accessToken:短期有效,比如1小时,包含用户信息,直接访问API接口
B refreshToken:长期有效,比如7天,仅用于刷新accessToken,存放cookie或localstorage中
(2) 当accessToken过期的时候(或即将过期),利用refreshToken重新生成双token,当然要有一系列校验
2. 常见问题剖析
(1)刷新accessToken的时候,为什么需要传入旧的的accessToken,只传入refreshToken可以吗?
A 对accessToken再次校验,并发防止被篡改,只有accessToken是过期状态,才走后面的流程。
B 可以从旧的accessToken中获取userId等信息,无需查询DB
(2)刷新accessToken的时候, 为什么要重新生成refreshToken呢?
A 每次刷新时生成新的 refreshToken 可以限制单个 Token 的使用时间,减少被滥用的可能性。
B 废弃旧的 Token,确保每个 refreshToken 只能使用一次,有效防止重放攻击
(3) 相关key
A. refreshToken 存放在redis的string接口中,key为:refreshToken_{userId}
3. 步骤实操
A. 登录接口Login中返回双token,其中refreshToken存放在DB或Redis中,这里存放在Redis中, 双Token返回给前端
代码分享
/// <summary> /// 01-登录 /// </summary> /// <param name="account">账号</param> /// <param name="pwd">密码</param> /// <returns></returns> [HttpPost] public IActionResult Login(string account, string pwd) { try { //1 前置判断 (模拟DB) if (!(account == "admin" && pwd == "123456")) { return Json(new { status = "error", msg = "账号密码不正确" }); } //2 模拟从DB中查询信息 string userId = "200001"; string accessTokenSecret = config["accessTokenSecret"].ToString(); string refreshTokenSecret = config["refreshTokenSecret"].ToString(); //3 生成accessToken double exp1 = (DateTime.UtcNow.AddMinutes(1) - new DateTime(1970, 1, 1)).TotalSeconds; //为了测试5min过期 Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } }; string accessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret); //4 生成refreshToken double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期 Dictionary<string, object> payload2 = new() { { "exp", exp2 } }; string refreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret); //5.将refreshToken存放到DB/Redis中 (这里采用redis存放, 此处使用String结构即可满足要求) RedisHelper.Set($"refreshToken_{userId}", refreshToken, (int)exp2); return Json(new { status = "ok", msg = "登录成功", data = new { accessToken, refreshToken } }); } catch (Exception) { return Json(new { status = "error", msg = "登录失败" }); } }
B. 过滤器CheckAccessToken中对accessToken进行校验,校验未通过统一返回401+原因。
代码分享
/// <summary> /// 验证AccessToken---服务于token续签方案Demo7 /// </summary> public class CheckAccessToken : ActionFilterAttribute { private readonly IConfiguration config; public CheckAccessToken(IConfiguration configuration) { this.config = configuration; } public override void OnActionExecuting(ActionExecutingContext context) { // 1 获取accessToken var accessToken = context.HttpContext.Request.Headers["accessToken"].ToString(); //2 token校验 if (accessToken == "null" || string.IsNullOrEmpty(accessToken)) { context.Result = new ContentResult() { StatusCode = 401, Content = "非法请求,token参数为空" }; return; } //校验token的正确性 var result = JWTHelp.JWTJieM(accessToken, config["accessTokenSecret"]); 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("accessToken", result); } base.OnActionExecuting(context); } }
C. 前端axios封装响应拦截器中,当code=401 且 原因是 expired 的时候,出发自动调用刷新接口 UpdateAccessToken
代码分享-axios封装
const api = axios.create({ baseURL: 'http://localhost:39475/', timeout: 10000 }); // 请求拦截器:添加 accessToken api.interceptors.request.use( config => { const accessToken = localStorage.getItem('accessToken'); if (accessToken) { config.headers["accessToken"] = accessToken; } return config; }, error => { return Promise.reject(error); } ); // 响应拦截器:处理 Token 刷新 api.interceptors.response.use( response => { return response; }, async error => { const originalRequest = error.config; // 1. 只有响应状态码为 401 且 是 expired 状态 ,尝试刷新 Token if (error.response.status === 401 && error.response.data == "expired") { try { //1.1 调用刷新 var params = new URLSearchParams(); params.append('accessToken', window.localStorage.getItem('accessToken')); params.append('refreshToken', window.localStorage.getItem('refreshToken')); const refreshResponse = await api.post('/Demo7/UpdateAccessToken', params); // 保存新的 双Token const { status, msg, data } = refreshResponse.data; if (status === "ok") { window.localStorage.setItem("accessToken", data.newAccessToken); window.localStorage.setItem("refreshToken", data.newRefreshToken); // 重新发送原始请求 originalRequest.headers["accessToken"] = data.newAccessToken; return api(originalRequest); } else { refreshErrorFunc(); //刷新失败后调用 return Promise.reject(refreshError); } } catch (refreshError) { refreshErrorFunc(); //刷新失败后调用 return Promise.reject(refreshError); } } //2. 其他类型的错误,直接抛出即可 else { alert(error.response.data) return Promise.reject(error); //想捕获的调用的时候,使用try-catch捕获 } } ); //刷新失败后调用 const refreshErrorFunc = () => { console.log('刷新失败,跳转到登陆页'); localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); // window.location.href = '/Guide/Index'; }
代码分享-前端调用
@{ Layout = null; } <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width" /> <title>Index</title> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/axios/axios.min.js"></script> <script src="~/lib/axios/axiosUtils1.js"></script> <script> $(document).ready(function() { //登录1 $('#j_btn1').click(async () => { var params = new URLSearchParams(); params.append('account', 'admin'); params.append('pwd', '123456'); let response = await api.post("Demo7/Login", params); let { status, msg, data } = response.data; if (status === "ok") { window.localStorage.setItem("accessToken", data.accessToken); window.localStorage.setItem("refreshToken", data.refreshToken); } alert(msg); }) //获取信息1 $('#j_btn2').click(async () => { let response = await api.post("Demo7/GetMsg"); let { status, msg } = response.data; alert(msg); }) //获取信息1_同时发送两个请求 $('#j_btn3').click(() => { api.post("Demo7/GetMsg1").then(response => { let { status, msg } = response.data; alert(msg); }) api.post("Demo7/GetMsg2").then(response => { let { status, msg } = response.data; alert(msg); }) }) }); </script> </head> <body> <p>双token普通版本测试</p> <button id="j_btn1">登录1</button> <button id="j_btn2">获取信息1</button> <button id="j_btn3">获取信息1_同时发送两个请求</button> </body> </html>
D. 刷新接口中需要对accessToken进行是否过期校验,refreshToken进行过期、准确性、DB/redis校验。
(1) 如果不通过,返回前端error,前端直接报错,跳转到登陆页
(2) 校验都通过,重新生成双token,返回给前端,前端自动调用原先接口,实现无缝刷新。
代码分享
查看代码 /// <summary>
/// 03-刷新token(重新生成双token)
/// </summary>
/// <param name="accessToken"></param>
/// <param name="refreshToken"></param>
/// <returns></returns>
[HttpPost]
public IActionResult UpdateAccessToken(string accessToken, string refreshToken)
{
try
{
string accessTokenSecret = config["accessTokenSecret"].ToString();
string refreshTokenSecret = config["refreshTokenSecret"].ToString();
//0. 前置非空校验
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{
return Json(new { status = "error", msg = "参数为空" });
}
//1. accessToken相关
//1.1 先校验accessToken是否过期
var result1 = JWTHelp.JWTJieM(accessToken, accessTokenSecret);
if (result1 != "expired")
{
return Json(new { status = "error", msg = "accessToken状态错误,应该为expired" });
}
//1.2 从accessToken中获取关键信息(即使过期也可以获取)
string payLoadStr = CommonHelp.DecodeUrlSafeBase64ToString(accessToken.Split(".")[1]);
JwtData data = JsonHelp.ToObject<JwtData>(payLoadStr);
string userId = data.userId;
//2 refreshToken相关
//2.1 先校验物理代码校验refreshToken的准确性(是否过期、是否准确)
var result2 = JWTHelp.JWTJieM(refreshToken, refreshTokenSecret);
if (result2 == "expired")
{
return Json(new { status = "error", msg = "refreshToken expired" });
}
if (result2 == "invalid")
{
return Json(new { status = "error", msg = "refreshToken invalid" });
}
if (result2 == "error")
{
return Json(new { status = "error", msg = "refreshToken error" });
}
//2.2 从DB或Redis等存储中校验refreshToken的准确性
string nowToken = RedisHelper.Get($"refreshToken_{userId}");
//测试
//RedisHelper.HSet("tempTest", Convert.ToString(new DateTimeOffset(DateTime.Now).ToUnixTimeMilliseconds()), nowToken);
if (nowToken != refreshToken)
{
return Json(new { status = "error", msg = "refreshToken check not pass" });
}
//3. 重新生成 双token
//3.1 生成accessToken
double exp1 = (DateTime.UtcNow.AddMinutes(1) - new DateTime(1970, 1, 1)).TotalSeconds; //为了测试5min过期
Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } };
string newAccessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret);
//3.2 生成refreshToken
double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期
Dictionary<string, object> payload2 = new() { { "exp", exp2 } };
string newRefreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret);
//4. 将refreshToken更新到DB或redis中
RedisHelper.Set($"refreshToken_{userId}", newRefreshToken, (int)exp2);
return Json(new { status = "ok", msg = "获取成功", data = new { newAccessToken, newRefreshToken } });
}
catch (Exception)
{
return Json(new { status = "error", msg = "获取失败" });
}
}
4. 测试
测试1:
(1) 登录接口将accessToken有效期设置1min,方便测试,访问登录接口
(2) 访问获取信息接口GetMsg,访问成功,然后1min后再次访问
(3) 依旧访问成功,F12查看,发现已经访问 UpdateAccessToken 重新生成Token了
测试2
使用后台代码,模拟并发测试,发现UpdateAccessToken返回的双token都不一样,这是不合理的
代码分享
/// <summary>
/// 04-后台代码模拟并发测试
/// </summary>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> ConcurrencyTest1()
{
//1 先模拟登录中直接生成双Token,然后存入Redis
//1.1 模拟从DB中查询信息
string userId = "200001";
string accessTokenSecret = config["accessTokenSecret"].ToString();
string refreshTokenSecret = config["refreshTokenSecret"].ToString();
//1.2 生成accessToken
double exp1 = (DateTime.UtcNow.AddMinutes(-1) - new DateTime(1970, 1, 1)).TotalSeconds; //直接就是过期的时间
Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } };
string accessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret);
//1.3 生成refreshToken
double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期
Dictionary<string, object> payload2 = new() { { "exp", exp2 } };
string refreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret);
//1.4 .将refreshToken存放到DB/Redis中 (这里采用redis存放, 此处使用String结构即可满足要求)
RedisHelper.Set($"refreshToken_{userId}", refreshToken, (int)exp2);
//2 模拟并发调用刷新接口
string url = "http://localhost:39475/Demo7/UpdateAccessToken";
var tasks = Enumerable.Range(0, 5).Select(async i =>
{
string resultStr = await RequestHelp.PostAsync(url, $"accessToken={accessToken}&refreshToken={refreshToken}");
TempResult resultObj = JsonHelp.ToObject<TempResult>(resultStr);
return (i, resultObj);
}).ToArray();
// 等待所有任务完成
var results = await Task.WhenAll(tasks);
// 处理结果
List<TempTokenInfo> tempTokenList = [];
foreach (var (index, content) in results)
{
Console.WriteLine($"请求 {index} 完成: {content}");
tempTokenList.Add(content.data);
}
return Json(new { status = "ok", data = tempTokenList });
}
结果:

5.存在的问题
问题一: 当一个页面存在多个ajax请求的时候,多个请求携带相同的accessToken和refreshToken请求不同的接口,过滤器判断这些请求的accessToken都是过期的,然后都进入到axios的响应拦截器中 → 都去调用 UpdateAccessToken 刷新接口,这个时候就会出现并发问题。
(1) 假设两个请求A 和 B 先后进入 UpdateAccessToken 接口
(2) A请求进的早,生成新的refreshToken,并存入Redis中
(3) B请求进的稍微晚点,校验refreshToken的时候,A请求已经执行完毕,那么refreshToken校验不通过
(4) 导致B请求无法自动刷新
PS:这个场景不是很好模拟测试。。。。
问题二:
还是上述问题一点触发场景, 但是多个请求同时进入UpdateAccessToken 接口,几乎没有先后顺序,那么从redis中获取的refreshToken就是相同的。
如代码:string nowToken = RedisHelper.Get($"refreshToken_{userId}");
带来的问题是都验证通过,但是都重新生成了双Token,然后又相互覆盖性的存入redis中
对于前端而言,同一个页面多个请求刷新得到的是不同的双token,前端又覆盖写入缓存,这显然是不合理的。
PS:通过后台代码模拟并发测试,详见上面测试
正确的场景:
同一个页面多个请求刷新双Token,返回的双Token应该都是相同的。
如果验证不通过,所有的请求都应该不通过,要么全部通过,要么全部失败。
三. Token刷新并发冲突解决【最终方案】
1. 上述问题分析
只有当accessToken过期的时候才会触发 且 当一个页面需要同时向后端发起多个ajax请求的时才会触发并发冲突问题
2 DB设计
详见文档T_UserTokens,此处要有截图

3. 解决方案
(1) 登录接口,需要将accessToken 和 refreshToken 存入DB,且version自增1,除此外关键字段、isRevoked、replacedByTokenId
代码分享
/// <summary>
/// 01-登录
/// </summary>
/// <param name="account">账号</param>
/// <param name="pwd">密码</param>
/// <returns></returns>
[HttpPost]
public IActionResult Login2(string account, string pwd)
{
try
{
//1 前置判断 (模拟DB)
if (!(account == "admin" && pwd == "123456"))
{
return Json(new { status = "error", msg = "账号密码不正确" });
}
//2 模拟从DB中查询信息
string userId = "200001";
string accessTokenSecret = config["accessTokenSecret"].ToString();
string refreshTokenSecret = config["refreshTokenSecret"].ToString();
//3 生成accessToken
double exp1 = (DateTime.UtcNow.AddMinutes(1) - new DateTime(1970, 1, 1)).TotalSeconds; //为了测试1min过期
Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } };
string accessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret);
//4 生成refreshToken
double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期
Dictionary<string, object> payload2 = new() { { "exp", exp2 } };
string refreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret);
//5.将refreshToken 和 accessToken 存入DB中
var nowTime = DateTime.Now;
var currentVersion = dBContext.Set<T_UserTokens>().Where(u => u.userId == userId && !u.isRevoked)
.Select(u => (int?)u.version).Max() ?? 0;
T_UserTokens userTokens = new()
{
id = Guid.NewGuid().ToString("N"),
userId = userId,
refreshToken = refreshToken,
accessToken = accessToken,
version = currentVersion + 1,
createdAt = nowTime,
accessTokenExpiresAt = nowTime.AddMinutes(1),
refreshTokenExpiresAt = nowTime.AddDays(7),
isRevoked = false, //是否取消
replacedByTokenId = ""
};
dBContext.Add(userTokens);
int count = dBContext.SaveChanges();
return Json(new { status = "ok", msg = "登录成功", data = new { accessToken, refreshToken } });
}
catch (Exception)
{
return Json(new { status = "error", msg = "登录失败" });
}
}
(2) 刷新接口
A.前面对于accessToken 和 refreshToken 纯代码校验不变
B.后续DB层次的校验,使用用户级别的分布式锁,确保同一用户的刷新操作串行化
(PS:这里的分布式锁是基于redis的setnx进行封装的,支持重试)
C. 根据传递过来的refreshToken,去DB中查询该userId对应的未过期、version最大的tokenInfo信息
D. 若tokenInfo != null && tokenInfo.isRevoked == false ,表示当前记录是生效的,则直接生成双token,重新插入DB,且需要把上面查出来 的记录取消掉 isRevoked=true 且 配置 replacedByTokenId
E. 若tokenInfo != null && tokenInfo.isRevoked == true,表示当前记录已经被取消掉了,需要根据replacedByTokenId去查询出来当前正在生效的信息,并返回给前端
F. 其他情况,校验未通过
代码分享
查看代码 /// <summary>
/// 03-刷新token(重新生成双token)
/// </summary>
/// <param name="accessToken"></param>
/// <param name="refreshToken"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> UpdateAccessToken2(string accessToken, string refreshToken)
{
try
{
string accessTokenSecret = config["accessTokenSecret"].ToString();
string refreshTokenSecret = config["refreshTokenSecret"].ToString();
//0. 前置非空校验
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
{
return Json(new { status = "error", msg = "参数为空" });
}
//1. accessToken相关
//1.1 先校验accessToken是否过期
var result1 = JWTHelp.JWTJieM(accessToken, accessTokenSecret);
if (result1 != "expired")
{
return Json(new { status = "error", msg = "accessToken状态错误,应该为expired" });
}
//1.2 从accessToken中获取关键信息(即使过期也可以获取)
string payLoadStr = CommonHelp.DecodeUrlSafeBase64ToString(accessToken.Split(".")[1]);
JwtData data = JsonHelp.ToObject<JwtData>(payLoadStr);
string userId = data.userId;
//2 refreshToken相关
//2.1 先校验物理代码校验refreshToken的准确性(是否过期、是否准确)
var result2 = JWTHelp.JWTJieM(refreshToken, refreshTokenSecret);
if (result2 == "expired")
{
return Json(new { status = "error", msg = "refreshToken expired" });
}
if (result2 == "invalid")
{
return Json(new { status = "error", msg = "refreshToken invalid" });
}
if (result2 == "error")
{
return Json(new { status = "error", msg = "refreshToken error" });
}
/**********************************上面的判断与旧版本完全相同**********************************************/
/**********************************下面开始全新的DB层次的校验**********************************************/
//3 用户级别的分布式锁,确保同一用户的刷新操作串行化
string lockKey = $"lock_{userId}";
string lockValue = Guid.NewGuid().ToString("N");// 生成唯一的锁值
TimeSpan expiration = TimeSpan.FromSeconds(10); //锁过期时间
TimeSpan retryTime = TimeSpan.FromSeconds(1); //重试间隔
var lockAcquired = await distributedLockService.TryAcquireLockAsync(lockKey, lockValue, expiration, 3, retryTime);
if (lockAcquired)
{
try
{
//获取该传递过来refreshToken对应的最新的记录
var tokenInfo = dBContext.Set<T_UserTokens>().Where(u => u.userId == userId && u.refreshTokenExpiresAt > DateTime.Now)
.Where(u => u.refreshToken == refreshToken)
.OrderByDescending(u => u.version).FirstOrDefault();
//4 表示传过来refreshToken在DB中是最新的未被取消,正生效中...
string newAccessToken = "";
string newRefreshToken = "";
if (tokenInfo != null && tokenInfo.isRevoked == false)
{
//4.1 重新生成双Token,存入DB
//4.1.1 生成accessToken
double exp1 = (DateTime.UtcNow.AddMinutes(1) - new DateTime(1970, 1, 1)).TotalSeconds; //为了测试5min过期
Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } };
newAccessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret);
//4.1.2 生成refreshToken
double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期
Dictionary<string, object> payload2 = new() { { "exp", exp2 } };
newRefreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret);
//4.1.3 存入DB
var nowTime = DateTime.Now;
var currentVersion = dBContext.Set<T_UserTokens>().Where(u => u.userId == userId && !u.isRevoked)
.Select(u => (int?)u.version).Max() ?? 0;
T_UserTokens userTokens = new()
{
id = Guid.NewGuid().ToString("N"),
userId = userId,
refreshToken = newRefreshToken,
accessToken = newAccessToken,
version = currentVersion + 1,
createdAt = nowTime,
accessTokenExpiresAt = nowTime.AddMinutes(1),
refreshTokenExpiresAt = nowTime.AddDays(7),
isRevoked = false, //是否取消
replacedByTokenId = ""
};
dBContext.Add(userTokens);
//4.2 撤销旧版
var oldTokenInfo = await dBContext.Set<T_UserTokens>().Where(u => u.id == tokenInfo.id).FirstOrDefaultAsync();
if (oldTokenInfo != null)
{
oldTokenInfo.isRevoked = true; //表示该token记录已经取消
oldTokenInfo.replacedByTokenId = userTokens.id;
}
int count = await dBContext.SaveChangesAsync();
return Json(new { status = "ok", msg = "获取成功", data = new { newAccessToken, newRefreshToken } });
}
//5 表示传过来的refreshToken在DB中最新的已经被取消了
else if (tokenInfo != null && tokenInfo.isRevoked == true)
{
//返回当前TokenInfo被替代的Token信息,而该被替代的Token信息应该是最新的、且正在生效中的
var latestTokenInfo = dBContext.Set<T_UserTokens>().Where(u => u.id == tokenInfo.replacedByTokenId).FirstOrDefault();
if (latestTokenInfo != null && latestTokenInfo.isRevoked == false && latestTokenInfo.refreshTokenExpiresAt > DateTime.Now)
{
newAccessToken = latestTokenInfo.accessToken;
newRefreshToken = latestTokenInfo.refreshToken;
return Json(new { status = "ok", msg = "获取成功", data = new { newAccessToken, newRefreshToken } });
}
return Json(new { status = "error", msg = "refreshToken check not pass" });
}
//6 直接校验不通过(比如tokenInfo为null)
else
{
return Json(new { status = "error", msg = "refreshToken check not pass" });
}
}
finally
{
// 释放锁
await distributedLockService.ReleaseLockAsync(lockKey, lockValue);
}
}
else
{
// 锁获取失败,表示资源被锁定,前端重新登录吧
return Json(new { status = "error", msg = "System error. Please log in again." });
}
}
catch (Exception)
{
return Json(new { status = "error", msg = "获取失败" });
}
}
(3) 前端不需要做排队机制了,上述方案已经可以解决问题
代码同上
4. 测试
(1) 使用前端测试,过期的情况多次刷新,发现UpdateAccessToken2接口返回的都是相同的accessToken和refreshToken,测试通过。
(2) 使用后端代码,模拟并发测试,发现UpdateAccessToken2接口返回的都是相同的accessToken和refreshToken,测试通过。
代码分享:
/// <summary> /// 04-后台代码模拟并发测试 【最终方案】 /// </summary> /// <returns></returns> [HttpPost] public async Task<IActionResult> ConcurrencyTest2() { //1 先模拟登录中直接生成双Token,然后存入Redis //1.1 模拟从DB中查询信息 string userId = "200001"; string accessTokenSecret = config["accessTokenSecret"].ToString(); string refreshTokenSecret = config["refreshTokenSecret"].ToString(); //1.2 生成accessToken double exp1 = (DateTime.UtcNow.AddMinutes(-1) - new DateTime(1970, 1, 1)).TotalSeconds; //直接就是过期的时间 Dictionary<string, object> payload1 = new() { { "userId", userId }, { "exp", exp1 } }; string accessToken = JWTHelp.JWTJiaM(payload1, accessTokenSecret); //1.3 生成refreshToken double exp2 = (DateTime.UtcNow.AddDays(7) - new DateTime(1970, 1, 1)).TotalSeconds; //7天有效期 Dictionary<string, object> payload2 = new() { { "exp", exp2 } }; string refreshToken = JWTHelp.JWTJiaM(payload2, refreshTokenSecret); //1.4 插入DB var nowTime = DateTime.Now; var currentVersion = dBContext.Set<T_UserTokens>().Where(u => u.userId == userId && !u.isRevoked) .Select(u => (int?)u.version).Max() ?? 0; T_UserTokens userTokens = new() { id = Guid.NewGuid().ToString("N"), userId = userId, refreshToken = refreshToken, accessToken = accessToken, version = currentVersion + 1, createdAt = nowTime, accessTokenExpiresAt = nowTime.AddMinutes(1), refreshTokenExpiresAt = nowTime.AddDays(7), isRevoked = false, //是否取消 replacedByTokenId = "" }; dBContext.Add(userTokens); int count = dBContext.SaveChanges(); //2 模拟并发调用刷新接口 string url = "http://localhost:39475/Demo7/UpdateAccessToken2"; var tasks = Enumerable.Range(0, 5).Select(async i => { string resultStr = await RequestHelp.PostAsync(url, $"accessToken={accessToken}&refreshToken={refreshToken}"); TempResult resultObj = JsonHelp.ToObject<TempResult>(resultStr); return (i, resultObj); }).ToArray(); // 等待所有任务完成 var results = await Task.WhenAll(tasks); // 处理结果 List<TempTokenInfo> tempTokenList = []; foreach (var (index, content) in results) { Console.WriteLine($"请求 {index} 完成: {content}"); tempTokenList.Add(content.data); } return Json(new { status = "ok", data = tempTokenList }); }
测试结果:

DB截图:

!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。

浙公网安备 33010602011771号