springBoot实现无感刷新-双Token(实战)

一、理解token过期

登陆成功之后,接口会返回一个token值,这个值在后续请求时通过请求头时带上(就像是开门钥匙)。但是,这个值一般会有有效期(具体是多长,是由后端决定),假如在我这里有效期是2小时。如果你上午8点登陆成功,到了10:01分,则token就会失效,再去发请求时,就会报401错误。

二、解决方案

第一种方案

在服务器端保存 Token 状态,用户每次操作都会自动刷新(推迟) Token 的过期时间。

该方案在前后端分离的情况下,每秒可能发起很多次请求,每次都去刷新过期时间会产生非常大的代价。如果 Token 的过期时间被持久化到数据库或文件,代价就更大了。所以通常为了提升效率,减少消耗,会把 Token 的过期时保存在缓存或者内存中。

第二种方案,双token解决

使用 Refresh Token,它可以避免频繁的读写操作。

这种方案中,服务端不需要刷新 Token 的过期时间,一旦 Token 过期,就反馈给前端,前端使用 Refresh Token 申请一个全新 Token 继续使用。

该方案中服务端只需要在客户端请求更新 Token 的时候对 Refresh Token 的有效性进行一次检查,大大减少了更新有效期的操作,也就避免了频繁读写。

Refresh Token 也是有有效期的,但是这个有效期就可以长一点了,比如以天为单位。

三、双Token应用

简介

Access Token:这是用户直接使用来访问资源的token。它的有效期较短,一旦过期,用户需要重新认证来获取新的access token。

Refresh Token:Refresh token是用来在access token过期后重新获取新的access token的。它的有效期通常较长。

应用

模拟网约车登录

生成Token工具类

/**
 * @Author: Mr.zhongjiawei
 * @Date: 2023-05-04 17:47
 * @Description: 生成Token工具类
 */
public class JwtUtils {

    private JwtUtils(){}

    /**
     * 盐
     */
    private static final String SIGN = "CAS#¥%O@3OAD!6-+";
    /**
     * token Key
     */
    private static final String JWT_KEY_PHONE = "phone";
    /**
     * 乘客司机身份标识:1=乘客,2=司机
     */
    private static final String JWT_KEY_IDENTITY = "identity";
    /**
     * Token类型
     */
    private static final String TOKEN_TYPE = "tokenType";
    /**
     * 用于每次生成Token不唯一
     */
    private static final String JWT_TOKEN_TIME = "tokenTime";

    /**
     * 生成token
     *
     * @param passengerPhone 手机号
     * @param identity       标识
     * @param tokenType      token类型:access Token || refresh Token
     * @return Token
     */
    public static String generatorToken(String passengerPhone, Integer identity, String tokenType) {
        HashMap<String, String> map = Maps.newHashMap();
        map.put(JWT_KEY_PHONE, passengerPhone);
        map.put(JWT_KEY_IDENTITY, String.valueOf(identity));
        map.put(TOKEN_TYPE, tokenType);
        map.put(JWT_TOKEN_TIME,Calendar.getInstance().getTime().toString());
        JWTCreator.Builder jwt = JWT.create();
        //整合map
        map.forEach(jwt::withClaim);
        //生成Token
        return jwt.sign(Algorithm.HMAC256(SIGN));
    }

    /**
     * 解析Token值
     */
    public static TokenResult parseToken(String token) {
        //解析
        DecodedJWT verify = JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
        //获取指定值
        String phone = verify.getClaim(JWT_KEY_PHONE).asString();
        String identity = verify.getClaim(JWT_KEY_IDENTITY).asString();
        String tokenType = verify.getClaim(TOKEN_TYPE).asString();
        return TokenResult.builder()
                .phone(phone)
                .identity(Integer.valueOf(identity))
                .tokenType(tokenType)
                .build();
    }

    /**
     * 校验Token,判断Token是否异常,且返回token解析值
     */
    public static TokenResult checkToken(String token) {
        //#解析token
        TokenResult tokenResult;
        try {
            tokenResult = JwtUtils.parseToken(token);
        } catch (Exception e) {
            return null;
        }
        return tokenResult;
    }

    /**
     * 检验token是否正确
     */
    public static boolean verify(String token) {
        try {
            JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    
}

redis前缀工具类

/**
 * @Author: Mr.zhongjiawei
 * @Date: 2023-05-08 15:18
 * @Description: redis前缀工具类
 */
public class RedisPrefixUtils {

    /**
     * 乘客验证码的前缀
     */
    public static final String VERIFICATION_CODE_PREFIX = "verification-code-";

    /**
     * token前缀
     */
    public static final String TOKEN_KEY = "token-";

    /**
     * 设备号前缀
     */
    public static final String DEVICE_CODE_PREFIX = "device-code-";

    /**
     * 获取token key
     *
     * @param phone     手机号
     * @param identity  乘客司机标识:1=乘客,2=司机
     * @param tokenType token类型:accessToken || refreshToken
     * @return token key
     */
    public static String generatorTokenKey(String phone, Integer identity, String tokenType) {
        return TOKEN_KEY + phone + "-" + identity + "-" + tokenType;
    }

    /**
     * 通过手机号获取验证码Key
     *
     * @param phone    手机号
     * @param identity 用户标识:1=乘客,2=司机
     * @return 验证码Key
     */
    public static String generatorKeyByPhone(String phone, Integer identity) {
        return VERIFICATION_CODE_PREFIX + identity + "-" + phone;
    }


}

token类型配置类

public class TokenConstants {

    /**
     * access Token Key类型标识
     */
    public static final String ACCESS_TOKEN = "accessToken";

    /**
     * refresh Token Key类型标识
     */
    public static final String REFRESH_TOKEN = "refreshToken";

}

token相关配置,例如过期时间等配置

@Data
@Component
@ConfigurationProperties("token")
public class TokenBaseConfig {

    /**
     * accessToken有效期
     */
    private Long accessTokenIndate;

    /**
     * refreshToken有效期
     */
    private Long refreshTokenIndate;

}

在这里插入图片描述

获取双Token并且存储Redis

    /**
     * 获取双Token并且存储Redis
     *
     * @param phone    手机号
     * @param identity 用户标识:1=乘客,2=司机
     * @return token
     */
    @Override
    public TokenResponse getToken(String phone, Integer identity) {
        //#基础校验
        PaAssert.notBlank(phone, "手机号");
        PaAssert.notNull(identity, "用户标识");
        //#生成accessToken&&存储redis&&设置有效期
        String accessToken = JwtUtils.generatorToken(phone, identity, TokenConstants.ACCESS_TOKEN);
        String accessTokenKey = RedisPrefixUtils.generatorTokenKey(phone, identity, TokenConstants.ACCESS_TOKEN);
        redisTemplate.opsForValue().set(accessTokenKey, accessToken, tokenBaseConfig.getAccessTokenIndate(), TimeUnit.MINUTES);
        //#生成refreshToken&&存储redis&&设置有效期
        String refreshToken = JwtUtils.generatorToken(phone, identity, TokenConstants.REFRESH_TOKEN);
        String refreshTokenKey = RedisPrefixUtils.generatorTokenKey(phone, identity, TokenConstants.REFRESH_TOKEN);
        redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, tokenBaseConfig.getRefreshTokenIndate(), TimeUnit.MINUTES);
        //#封装数据且返回
        return TokenResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

验证码交互实体

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class VerifyCationCodeDTO {

    /**
     * 乘客手机号
     */
    private String passengerPhone;

    /**
     * 司机手机号
     */
    private String driverPhone;

    /**
     * 验证码
     */
    private String verificationCode;
    
}

根据手机号获取验证码

    /**
     * 获取验证码
     *
     * @param passengerPhone 手机号
     * @return 验证码
     */
    @Override
    public String generatorCode(String passengerPhone) {
        //#校验
        PaAssert.notBlank(passengerPhone, "手机号");
        //用户手机号码校验
        if (!RegexUtil.regexPhone(passengerPhone)) {
            throw new BaseException("手机号格式错误");
        }
        //生成普通验证码
        SpecCaptcha specCaptcha = new SpecCaptcha();
        specCaptcha.setLen(6);
        //验证码结果
        String verificationCode = specCaptcha.text();
        //#存入redis
        //验证码key
        String key = RedisPrefixUtils.generatorKeyByPhone(passengerPhone, IdentityEnum.PASSENGER.getCode());
        stringRedisTemplate.opsForValue().set(key, verificationCode, 2, TimeUnit.MINUTES);
        //#todo 通过短信服务将验证码发送到乘客手机号上
        return verificationCode;
    }

登录

    /**
     * 校验验证码
     *
     * @param passengerPhone   手机号
     * @param verificationCode 验证码
     * @return 结果
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public TokenResponse login(String passengerPhone, String verificationCode) {
        //#读取redis验证码
        String redisVerification = stringRedisTemplate.boundValueOps(RedisPrefixUtils.generatorKeyByPhone(passengerPhone
                , IdentityEnum.PASSENGER.getCode())).get();
        if (StringUtils.isBlank(redisVerification)) {
            throw new BaseException("验证码已过期");
        }
        if (!redisVerification.trim().equals(verificationCode.trim())) {
            throw new BaseException("验证码错误,请重新输入");
        }
        //#todo 判断是否新用户相关业务

        //#生成accessToken&&存储redis&&设置有效期
        TokenResponse tokenResponse = tokenService.getToken(passengerPhone, IdentityEnum.PASSENGER.getCode());
        //#删除redis验证码
        stringRedisTemplate.delete(RedisPrefixUtils.generatorKeyByPhone(passengerPhone, IdentityEnum.PASSENGER.getCode()));
        //#返回Token
        return tokenResponse;
    }

刷新token

   @Override
    public TokenResponse refreshToken(String refreshToken) {
        //#校验
        PaAssert.notBlank(refreshToken, "refreshToken");
        //#解析refreshToken
        TokenResult tokenResult = JwtUtils.checkToken(refreshToken);
        if (null == tokenResult) {
            throw new BaseException("token invalid");
        }
        //#从redis获取refreshToken
        //手机号
        String phone = tokenResult.getPhone();
        //用户标识
        Integer identity = tokenResult.getIdentity();
        //拼接refreshTokenKey
        String refreshTokenKey = RedisPrefixUtils.generatorTokenKey(phone, identity, TokenConstants.REFRESH_TOKEN);
        //从redis获取token
        String redisToken = redisTemplate.opsForValue().get(refreshTokenKey);
        //校验token
        if ((StringUtils.isBlank(redisToken)) || !refreshToken.trim().equals(redisToken.trim())) {
            throw new BaseException("token invalid");
        }
        //#延续,生成双token返回
        return getToken(phone, identity);
    }

四、业务流程

在这里插入图片描述

五、测试

1、根据手机号获取验证码

在这里插入图片描述

2、根据手机号和验证码进行登录

在这里插入图片描述

查看redis
accessToken:
在这里插入图片描述

refreshToken:
在这里插入图片描述

3、根据refreshToken刷新accessToken

删掉redis中的accessToken,模拟accessToken过期

在这里插入图片描述
用refreshToken刷新accessToken
在这里插入图片描述
查看redis,accessToken重新生成了
在这里插入图片描述

在这里插入图片描述

  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

喝汽水的猫^

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值