采用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

8 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安全。

发表评论

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