一、传统单Token存在的问题

用户登录成功后会生成jwt的token,前端将此token保存起来,当请求后端服务时,在请求头中携带此token,服务端需要对token进行校验以及鉴权操作,这种模式就是【单token模式】。

该模式存在什么问题吗?

其实是有问题的,主要是token有效期设置长短的问题,如果设置的比较短,用户会频繁的登录,如果设置的比较长,会不太安全,因为token一旦被黑客截取的话,就可以通过此token与服务端进行交互了。
另外一方面,token是无状态的,也就是说,服务端一旦颁发了token就无法让其失效(除非过了有效期),这样的话,如果我们检测到token异常也无法使其失效,所以这也是无状态token存在的问题。
为了解决此问题,我们将采用【双token三验证】的解决方案来解决此问题。

二、双Token三验证

为了解决单token模式下存在的问题,所以我们可以通过【双token三验证】的模式进行改进实现,主要解决的两个问题如下:

  • token有效期长不安全
    • 登录成功后,生成2个token,分别是:access_token、refresh_token,前者有效期短(如:5分钟),后者的有效期长(如:24小时)
    • 正常请求后端服务时,携带access_token,如果发现access_token失效,就通过refresh_token到后台服务中换取新的access_token和refresh_token,这个可以理解为token的续签
    • 以此往复,直至refresh_token过期,需要用户重新登录
  • token的无状态性
    • 为了使token有状态,也就是后端可以控制其提前失效,需要将refresh_token设计成只能使用一次
    • 需要将refresh_token存储到redis中,并且要设置过期时间
    • 这样的话,服务端如果检测到用户token有安全隐患(如:异地登录),只需要将refresh_token失效即可

1-双Token三验证.png

三、代码实现

1、生成刷新Token

生成刷新refresh_token的主要逻辑有两点:

  • 生成jwt格式的token,有效期时间一般小时为单位
  • 将token存入到redis,使token有状态,并且确保只能使用一次
    public static final String REDIS_REFRESH_TOKEN_PREFIX = "SL_CUSTOMER_REFRESH_TOKEN_";

	@Override
    public String createRefreshToken(Map<String, Object> claims) {
        //生成长令牌的有效期时间单位为:小时
        Integer ttl = jwtProperties.getRefreshTtl();
        String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), ttl);

        //长令牌只能使用一次,需要将其存储到redis中,变成有状态的
        String redisKey = this.getRedisRefreshToken(refreshToken);
        this.stringRedisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofHours(ttl));

        return refreshToken;
    }

    private String getRedisRefreshToken(String refreshToken) {
        //md5是为了缩短key的长度
        return REDIS_REFRESH_TOKEN_PREFIX + SecureUtil.md5(refreshToken);
    }
  • 测试
    @Test
    void createRefreshToken() {
        Map<String, Object> claims = MapUtil.<String, Object>builder().put("id", 123)
                .build();
        String refreshToken = this.tokenService.createRefreshToken(claims);
        System.out.println(refreshToken);
    }

2、刷新Token

刷新token的动作是在refresh_token过期之后进行的,主要实现关键点有:

  • 校验refresh_token是否被伪造以及是否在有效期内
  • 从redis中查询,是否不存在,如果不存在说明已经失效或已经使用过,如果存在,就需要将其删除
  • 重新生成一对token,响应结果
    @Override
    public UserLoginVO refreshToken(String refreshToken) {
        if (StrUtil.isEmpty(refreshToken)) {
            return null;
        }

        Map<String, Object> originClaims = JwtUtils.checkToken(refreshToken, this.jwtProperties.getPublicKey());
        if (ObjectUtil.isEmpty(originClaims)) {
            //token无效
            return null;
        }

        //通过redis校验,原token是否使用过,来确保token只能使用一次
        String redisKey = this.getRedisRefreshToken(refreshToken);
        Boolean bool = this.stringRedisTemplate.hasKey(redisKey);
        if (ObjectUtil.notEqual(bool, Boolean.TRUE)) {
            //原token过期或已经使用过
            return null;
        }
        //删除原token
        this.stringRedisTemplate.delete(redisKey);

        //重新生成长短令牌
        String newRefreshToken = this.createRefreshToken(originClaims);
        String accessToken = this.createAccessToken(originClaims);

        return UserLoginVO.builder()
                .accessToken(accessToken)
                .refreshToken(newRefreshToken)
                .build();
    }