采用JWT有效期内刷新Token方案,解决并发请求问题

继前面在Shiro整合JWT+Token过期刷新一文中,已经集成了shiro以及JWT,并且玩儿的开心觉得一切都很自然。然而有一天当看到日志中,同一时间报出了十多条AuthenticationException时,才发现有些东西被忽略了。回顾下我们之前的思路,当服务端在检查到请求的令牌过期之后,会刷新Token重新颁发令牌,并且再次做登录操作,看似平静友好无感知,但试想一下,在页面加载后倘若同一个页面中有多个请求几乎同一时间发起,每一个请求都携带原始令牌,在这样的设计下,就有可能出现在第一个请求到达后刷新了Token,并更改了缓存中的refreshToken的时间戳,以至于剩余请求校验时发现时间戳不一致导致验证失败而在日志中多次打印出当前Token已经失效的log。同时发起的请求越多,log中的异常也就会越多。虽然第一个请求已经刷新了Token,但是其余的请求是失败的,页面中的数据并不完整,显然这是不正常的,那该如何解决呢?

当然实现的方式可以有多种,如我们现在Token过期后刷新再加synchronized生成Token策略,或者前端定时去调用服务端API刷新Token,再如这里即将采用的Token在有效期内定时更新的方式。

在采用有效期内定时刷新的逻辑之前,引用一段介绍:

一个好的模式是在它过期之前刷新令牌。
将令牌过期时间设置为一周,并在每次用户打开Web应用程序并每隔一小时刷新令牌。如果用户超过一周没有打开过应用程序,那他们就需要再次登录,这是可接受的Web应用程序UX(用户体验)。
要刷新令牌,API需要一个新的端点,它接收一个有效的,没有过期的JWT,并返回与新的到期字段相同的签名的JWT。然后Web应用程序会将令牌存储在某处。

我认为这种方式更趋于合理,Token过期,直接跳转到登录即可,我们无需关注Token后的额外动作,只要考虑何时颁发新的令牌即可,甚至实现起来将更加的简单。
好了已经明确了目标,下面来说说我们在原有集成的项目中,要如何改动。

1. 移除之前的令牌到期后更换的设计

在设计之初,我们在生成Token的时候,同时向redis中写入了一个refreshToken的时间戳,当Token过期后,以此时间出的比对来判断是否需要刷新Token,现在我们来移除Token过期后刷新的设计。
1.1 BpUserServiceImpl类的loginSuccess方法中,我们移除如下代码:

//更新RefreshToken缓存的时间戳
//String refreshTokenKey= SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account;
//if (cacheClient.exists(refreshTokenKey)) {
//    cacheClient.set(refreshTokenKey, currentTimeMillis, jwtProperties.getRefreshTokenExpireTime()*60*60l);
//}else{
//    cacheClient.set(refreshTokenKey, currentTimeMillis, jwtProperties.getRefreshTokenExpireTime()*60*60l);
//}

1.2 移除JwtFilter类中的refreshToken方法,并去掉在isAccessAllowed方法中的调用
1.3 ShiroRealm类中的doGetAuthenticationInfo方法中,我们替换如下代码

//移除以下rerefreshToken校验代码
//String refreshTokenCacheKey = SecurityConsts.PREFIX_SHIRO_REFRESH_TOKEN + account;
//if (JwtUtil.verify(token) && cacheClient.exists(refreshTokenCacheKey)) {
//    String currentTimeMillisRedis = cacheClient.get(refreshTokenCacheKey);
//    // 获取AccessToken时间戳,与RefreshToken的时间戳对比
//    if (JwtUtil.getClaim(token, SecurityConsts.CURRENT_TIME_MILLIS).equals(currentTimeMillisRedis)) {
//        return new SimpleAuthenticationInfo(token, token, "shiroRealm");
//    }
//}

//替换为:
if (JwtUtil.verify(token)) {
    return new SimpleAuthenticationInfo(token, token, "shiroRealm");
}

1.4 移除配置文件application.ymlrefreshTokenExpireTime属性的配置。
1.5 移除JwtProperties类中 的refreshTokenExpireTime属性。
1.6 移除SecurityConsts类中的 PREFIX_SHIRO_BACKLIST_TOKEN 变量。


2. 采用Token在有效期内定时更换的逻辑

2.1 增加检查令牌时间配置。

application.yml配置文件中增加配置refreshCheckTime

  #  Token有效时间,单位分钟 24*60=1440
  TokenExpireTime: 1440
  # 更新令牌时间检查配置,单位分钟 2*60=120
  refreshCheckTime: 120

这里的refreshCheckTime表示自Token颁发后,超过2个小时,就为请求更新一次Token,同时,我们的refreshCheckTime时间应当小于令牌的有效期tokenExpireTime,即我们是要令牌在有效期内进行更新。

同样我们的JwtProperties类中也相应的增加属性:

	/**
     * 更新令牌时间,单位分钟
     */
    Integer refreshCheckTime;

2.2 引入Redis锁机制

为避免多个请求同一时间分别生成不同的Token,我们引入redis锁机制。即我们的目的是同一个用户同一时间的不同请求,只允许获得锁的请求进行令牌刷新,其他的请求因为是在令牌有效期因此直接放行。

public interface ISyncCacheService {
	Boolean getLock(String lockName, int expireTime);
	Boolean releaseLock(String lockName);
}
@Service
public class SyncCacheServiceImpl implements ISyncCacheService {
    private static final Logger LOGGER = LoggerFactory.getLogger(SyncCacheServiceImpl.class);

    @Autowired
    JwtProperties jwtProperties;
    @Autowired
    cacheClient cacheClient;

    /**
     * 获取redis中key的锁,乐观锁实现
     * @param lockName
     * @param expireTime 锁的失效时间
     * @return
     */
    @Override
    public Boolean getLock(String lockName, int expireTime) {
        Boolean result = Boolean.FALSE;
        try {
            boolean isExist = cacheClient.exists(lockName);
            if(!isExist){
                cacheClient.incrBy(lockName,0);
                cacheClient.expire(lockName,expireTime<=0? Constants.ExpireTime.ONE_HOUR:expireTime);
            }
            long reVal =  cacheClient.incrBy(lockName,1);
            if(1l==reVal){
                //获取锁
                result = Boolean.TRUE;
                LOGGER.info("获取redis锁:"+lockName+",成功");
            }else {
                LOGGER.info("获取redis锁:"+lockName+",失败"+reVal);
            }
        } catch (Exception e) {
            LOGGER.error("获取redis锁失败:"+lockName, e);
        }
        return result;
    }

    /**
     * 释放锁,直接删除key(直接删除会导致任务重复执行,所以释放锁机制设为超时30s)
     * @param lockName
     * @return
     */
    @Override
    public Boolean releaseLock(String lockName) {
        Boolean result = Boolean.FALSE;
        try {
            cacheClient.expire(lockName, Constants.ExpireTime.TEN_SEC);
            LOGGER.info("释放redis锁:"+lockName+",成功");
        } catch (Exception e) {
            LOGGER.error("释放redis锁失败:"+lockName, e);
        }
        return result;
    }
}

同时,我们在SecurityConsts常量类中,定义好检查Token刷新的redis Key。

	/**
     * redis-key-前缀-shiro:refresh_check
     */
    public final static String PREFIX_SHIRO_REFRESH_CHECK = "storyweb-bp:refresh_check:";

2.3 加入Token验证通过后定时刷新Token的逻辑

由于我们已经移除了Token过期后刷新的逻辑代码,那么我们需要增加Token过期前更新的逻辑。在JwtFilter类中,我们增加如下方法:

	@Autowired
    ISyncCacheService syncCacheService;
	
	/**
     * 检查是否需要,刷新Token
     * @param account
     * @param authorization
     * @param response
     * @return
     */
    private boolean refreshTokenIfNeed(String account,String authorization, ServletResponse response) {
        Long currentTimeMillis= System.currentTimeMillis();
        //检查刷新规则
        if(this.refreshCheck(authorization,currentTimeMillis)){
            String lockName = SecurityConsts.PREFIX_SHIRO_REFRESH_CHECK + account;
            boolean b = syncCacheService.getLock(lockName, Constants.ExpireTime.ONE_HOUR);
            if (b) {
                LOGGER.info(String.format("为账户%s颁发新的令牌",account));
                String newToken = JwtUtil.sign(account, String.valueOf(currentTimeMillis));
                HttpServletResponse httpServletResponse = (HttpServletResponse) response;
                httpServletResponse.setHeader(SecurityConsts.REQUEST_AUTH_HEADER, newToken);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", SecurityConsts.REQUEST_AUTH_HEADER);
            }
            syncCacheService.releaseLock(lockName);
        }
        return true;
    }

    /**
     * 检查是否需要更新Token
     * @param authorization
     * @param currentTimeMillis
     * @return
     */
    private boolean refreshCheck(String authorization,Long currentTimeMillis){
        String tokenMillis=JwtUtil.getClaim(authorization, SecurityConsts.CURRENT_TIME_MILLIS);
        if(Long.parseLong(tokenMillis)-(jwtProperties.refreshCheckTIme*60*1000) < currentTimeMillis) {
            return true;
        }
        return false;
    }
	

这样,我们只需要在当前请求executeLogin验证成功后,调用refreshTokenIfNeed检查请求中携带的Token是否已经生效2小时,如果是就重新颁发。那么executeLogin的完整的代码如下:

	/**
     * 登录验证
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String authorization = httpServletRequest.getHeader(SecurityConsts.REQUEST_AUTH_HEADER);

        JwtToken token = new JwtToken(authorization);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(token);

        String account = JwtUtil.getClaim(authorization, SecurityConsts.ACCOUNT);//获取账号

        // 绑定上下文
        UserContext userContext= new UserContext(new LoginUser(account));

        // 检查是否需要更换Token,需要则重新颁发
        this.refreshTokenIfNeed(account,authorization,response);

        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

至此,我们就将原来设计的Token到期后刷新,重新修改为Token在有效期内刷新,使得Token一旦到期,则直接跳转到登录页,保证了同一个用户,并发的请求只会更换一次令牌,顺利解决问题。


3. Git地址

项目后端地址:https://github.com/sunnj/story-admin


4. 参考资料:

  1. JWT(JSON Web Token)自动延长到期时间
  2. JWT (JSON Web Token) automatic prolongation of expiration

13 thoughts on “采用JWT有效期内刷新Token方案,解决并发请求问题

  1. 最近在做相关的内容。读了你的文章很受启发。很感谢你把他分享出来

  2. 这个方案有点问题,这个redis锁只能锁住并发,如果持有原有token的请求时间上有一定间隔,则每次请求都会刷新一次token。最主要的问题是,这不能拦住恶意持有老的token的人再次做请求,并得到新的token。

    1. 是的,这里没有强制旧的token无效。
      当然针对提出的两个问题,可以通过在服务器中的某处,诸如redis中,存储新产生的token,并使用新的token来校验,即可解决。你可以在github中提交新的Issues,我会修复它。

      1. 我用的就是这种方案解决的,不过还是要考虑同一页面的多个请求,需要一点冗余

  3. Token有效时间,单位分钟 24*60=1440

    TokenExpireTime: 1440

    更新令牌时间检查配置,单位分钟 2*60=120

    refreshCheckTime: 120

    如果更新令牌时间(2小时)都没有人访问,Token有效时间(24小时)是否太长不安全?

    1. 减少token的有效期是可以降低token被非法获取的几率,而真正要使token安全还是应当采用https,增加更多的验证条件等额外方式确保token安全。

  4. 看完前篇时, 我就觉得博主的 refreshToken 有问题, 到这篇时终于也是回到了最初蛋疼的方法, 前端轮询…我本觉得要是 JWT 本来是用作无状态管理用的, 比如一次性认证, 密码重叠, Jwt 自带的时间限制就帮我们管理好期限. jwt+redis 不就是 cookie+session 的变种, 道理还是不变, 后端依然要管控前端的状态…而 jwt 的刷新, 自己写来写去, 就感觉好像自己又实现了一个 oauth 一样, 那干嘛不直接用 oauth. 另外一小时刷新也有不同的问题, 如果前端多页面的, 那是每个需要登录的页面都有自己的计时器么, 单页的话还好说些, 打开就计时, 但如果有些用户每次就看 10 分钟, 那一星期后他还是过期了…

      1. 这里的redis只对token多做了一次校验,redis只保存了token生成的时间,目的是更换令牌时判断token是服务器颁发的而不是篡改过的,虽显得多余且并不是必须的,因此你完全可以舍去redis。并且有效期内刷新是无感知的,并不需要前端单独去调用refresh接口,因此还是与cookie+session大相径庭的。
      2. 本示例原理确实与oauth类似,你也可以直接使用oauth。
      3. 使用oauth的话refreshToken不是非要轮询,可以通过封装统一的请求方法,或者前端增加拦截器去refresh,都应该可以。
      1. jwt作登录蛋疼的地方
        1. 修改密码. 当用户修改了密码, 就应该保证 token 不能再使用, 因为后端无法在签发后再修改过期的时间, 较好的方法是每个用户都有一个自己 secret 来签发 token, 这样一时修改密码, 那么就更改 secret, 这样所有端登录的 token 都会失效
        2. 注销. 1) 前端把 token 删除了, 这样请求时不再带 token 也就解除登录状态. 不过这样 token 还是有效的. 2) 改自己的 secret, 那这样所有端都退出了. 3) 借助像 redis 来管理, 没在 redis 中的就是登出了, 这种方法 jwt 已经不是无状态了.
        3. jwt 的刷新. 1) 暴力刷新, 每次请求重新都给一个新的, 或者轮询刷新 2) 快过期前刷新. 但总有人运气不好, 如果 token 是一小时过期, 在过期前 10 分钟就刷新, 但如果只浏览了 49 分钟, 然后不使用了, 过 10 来分钟会再来时发现帐号登出了. 3) 后端 redis 记录 token 的过期时间(非 jwt 里的 expire time), 假设 7 天过期, 则是生成 jwt 的时间 + 7 天, 访问时, 如果没过期就刷新这个时间, 或者设定 7 天一定过期要重新登录
        感觉 redis 作 session 是最无奈的方案了, 可以控制修改密码, 注销, 时间过期. 不过这样 jwt 也不是无状态了, 所以才说有的选的话, 还是 cookie+session 方便

        1. 兄弟见解深刻,这些确实是使用jwt做登录时需要面对的问题。
          JWT的无状态是会话完全交给客户端来管理,但在处理修改密码及退出时却又必须使后端修改状态,势必会使服务弱依赖于redis。
          倘若如此设计,依赖于redis,那确实跟cookie+session无异了。当然session方案若多节点部署也依然需要第三方redis等存储登录状态。
          所以jwt若想真正受益于无状态,还是应当用在类似于第三方授权这种一次性的鉴权场景中。

  5. 最近在想刷新token的并发问题上卡住了,看了你的文章很有帮助 🙂

发表评论

电子邮件地址不会被公开。 必填项已用*标注