第七节: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 = "登录失败" });
     }
 }
View Code

 

 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);
    }
}
View Code

 

 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';
}
View Code

 

代码分享-前端调用

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

 

 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 });
 }
View Code

测试结果:

DB截图:

 

 

 

 

 

!

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