JsonWebToken(JWT)设计方案

版权声明:谁不是在与生活苦战。狼性的年级,就不要做个俗人 https://blog.csdn.net/Axela30W/article/details/89489350

Json web tokens :官网


What? - JsonWebToken是什么

请求访问后台,后台需要知道你是谁吧?Token就是一个令牌,每次请求后台都带上它,说明你是谁,并且可以携带一些你的信息。Token是后台根据你配置的算法,还有密钥,编码生成的,最后在通过base64加密返给请求。
它长这个样子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

注意到两个"."分隔符了嘛,Token分成3部分,上面是base64加密的,解密一下:
在这里插入图片描述
第一部分,说明了用的什么加密算法
第二部分,是我们手动放进去的部分信息,譬如放用户id什么的,注意不能放私密信息,因为这部分是可以解密的
第三部分,会发现解密不了,因为我们不知道后台用的什么密钥,so,别人是没法伪造token来访问你的(token也有缺陷,后面会提到)


Why? - 为什么要用Token

都知道session,Token的出现解决了使用session来存储用户身份信息的一些弊端,两个可以根据实际情况觉得用哪种。
参考:Session,Token相关区别


How? - 用户身份认证完整设计方案

Token必须有过期时间,不然登录的时候生成一次,后面的请求拿着一直访问也不合理,泄露了Token的话不安全。
(本文只节选部分代码,着重说下设计方案,Token的代码实现网上很多)
先看下Token怎么生成的:

/**
     * 生成token
     *
     * @param userId 把userId,versionCode编码到token负载中,同时把刷新token操作的过期时间也编码到负载中
     * @return token
     */
    public String generateToken(String userId, Integer versionCode) {
        //过期时间
        Date expire = Date.from(LocalDateTime.now().plusSeconds(jsonWebTokenProperties.getAccessTokenExpireTime()).atZone(ZoneId.systemDefault()).toInstant());
        //刷新时间
        Date refresh = Date.from(LocalDateTime.now().plusSeconds(jsonWebTokenProperties.getRefreshTokenExpireTime()).atZone(ZoneId.systemDefault()).toInstant());
        return JWT.create()
                .withClaim(JsonWebTokenConstant.TOKEN_USER_ID_FLAG, userId)
                .withClaim(JsonWebTokenConstant.REFRESH_TOKEN_FLAG, refresh)
                .withClaim(JsonWebTokenConstant.TOKEN_VERSION_ID_FLAG, versionCode)
                .withIssuer(JsonWebTokenConstant.ISSUER)
                .withExpiresAt(expire)
                .sign(algorithm);
    }

jsonWebTokenProperties和JsonWebTokenConstant是自定义的枚举或properties。
方案:
1、给Token设计一个过期时间和刷新时间

  • 过期时间:通过自带的withExpiresAt()方法设置,请求携带token访问,验证token的时候当前时间已经超过了这个过期时间,那么验证就不会通过,会直接报错,这个时候就该提示用户去登录。
  • 刷新时间:刷新时间只能放在token的荷载里面了,验证token的时候,当前时间已经超过了这个刷新时间,但是又不超过过期时间,这个时候就应该重新生成一个token,这个新token就会有新的过期时间,刷新时间,放在响应头返回给用户,前端拿到新token就替换掉原来的token。这样就可以实现用户的无衔接访问。(这个地方也会有个并发问题,后面会说到)。如果不设计刷新时间,只有过期时间,假如15天过期,这15天内用户都在访问,但是15天过去了,啪,验证不通过,让用户去登录,这样用户体验不好,我们就在用户不知情的情况下刷新了它的Token。假如用户15天都没有访问过,验证不通过,提示去登陆,这个很正常。

2、Token的荷载
可以通过withClaim()方法在token里面放入你需要的信息。我这里放入了刷新时间,userId,还有一个versionCode
验证token的时候可以这样拿到你放进去的信息:

	DecodedJWT decodedJWT = jwtVerifier.verify(token);
    Map<String, Claim> claimMap = decodedJWT.getClaims();
    Date refresh = claimMap.get(JsonWebTokenConstant.REFRESH_TOKEN_FLAG).asDate();

3、Token的版本-关键点!!!
这里设计的版本就是荷载里面的 versionCode
这个版本号用来做什么:

versionCode设计的默认值是:2147500033 
把它转换成二进制看看:1000 00000000000001 00000000000001(32位)
从左往右看,左边的14位:00000000000001 用来存放token版本号,每生成一次token就+1,说明每次刷新token的时候生成token,每次登录的时候生成token这个版本都要+1
再看,中间的14位:00000000000001  用来存放登录版本号,每登录一次才+1
最后剩下高位的4位:1000 暂时冗余出来的

每次登录的时候很简单,从数据库取该用户的versionCode(数据库存的Integer类型),先把versionCode转换成二进制,然后截取出登录版本,token版本分别+1,然后再拼接成二进制字符串,再转换成Integer,放在Token荷载,更新数据库。

每次验证token的时候,如果是需要刷新token的情况,都需要验证版本号,为了防止Token泄露恶意访问

拿Token携带的versionCode与数据库的versionCode比较
若登录版本不一致,或Token版本不一致,都提示去登录
若两个版本都一致,才继续执行刷新token的逻辑

这里会有个并发问题,App有时候请求是好几个一起到达的,这些请求都携带的同样的token。有可能第一个请求更新了数据的versionCode,那么之后的请求进来验证的时候就会验证不通过,因为验证token是放在拦截器里面的,几乎所有请求都要走这里,加锁不现实。
这里这样解决的:刷新token的时候如果生成了token,redis里面也会缓存30秒这个token,数据库增加了一个最后刷新token时间字段,验证token的时候若在这个30秒内,说明30秒内有请求刷新过token,并且携带了新token返回。直接略过验证。(这里会有点绕,慢慢掰才行),上部分代码:

BasedInterceptor preHandle()节选:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
	//验证Token
    Integer tokenVerifyCode = jsonWebTokenUtils.verification(request).getCode();
    if (tokenVerifyCode.equals(JsonWebTokenVerifyStatus.LOGIN.getCode())) {
        //登陆过期或登录异常,需要重新登录!
        throw new BusinessException(BusinessErrorCode.LOGIN_EXPIRE);
    } else if (tokenVerifyCode.equals(JsonWebTokenVerifyStatus.LOGIN_OTHER.getCode())) {
        //该账号在别的设备登录!  提示信息
        String message = userBaseGrpcService.getLoginRecord(headerUserId, jsonWebTokenUtils.getVersionCodeFromHttpServletRequest(request).toString(), request.getIntHeader(RequestConstans.CLIENT_OS), 2).getData().toString();
        this.loginOtherMessagePrompt(BusinessErrorCode.LOGIN_OTHER, response, message);
        return false;
    } else if (tokenVerifyCode.equals(JsonWebTokenVerifyStatus.SUCCESS.getCode())) {
        //验证成功放行
        return true;
    } else if (tokenVerifyCode.equals(JsonWebTokenVerifyStatus.CREATE_NEW.getCode())) {
        String userId = CurrentUserSession.getUserId().toString();
        //需要刷新token
        String existToken = jedisCommands.get(userId + JsonWebTokenConstant.REDIS_TOKEN_SUFFIX);
        if (!StringUtils.isEmpty(existToken)) {
            //如果redis已存在最近刚刚刷新生成的token
            response.setHeader(JsonWebTokenConstant.RESPONSE_HEADER_USER_TOKEN_FLAG, existToken);
        } else {
            //需要重新生成token 继续请求数据  把新token放在header
            String allNewToken = jsonWebTokenUtils.refreshGenerateTokenByRequestCode(userId, request);
            if (StringUtils.isNotEmpty(allNewToken)) {
                response.setHeader(JsonWebTokenConstant.RESPONSE_HEADER_USER_TOKEN_FLAG, allNewToken);
                jedisCommands.setex(userId + JsonWebTokenConstant.REDIS_TOKEN_SUFFIX, JsonWebTokenConstant.REDIS_TOKEN_EXPIRE, allNewToken);
            }
        }
        return true;
    }
}

下面是jsonWebTokenUtils.verification(request).getCode()方法

JsonWebTokenUtils节选

    /**
     * 验证token,返回状态
     *
     * @param request request
     * @return JsonWebTokenVerifyStatus
     */
    public JsonWebTokenVerifyStatus verification(HttpServletRequest request) {
        String token = this.getToken(request);
        if (StringUtils.isEmpty(token)) {
            logger.info("Token is empty!To login!");
            return JsonWebTokenVerifyStatus.LOGIN;
        }
        logger.debug("uid:{},token:{}", request.getHeader(RequestConstans.USER_ID), token);
        try {
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            Map<String, Claim> claimMap = decodedJWT.getClaims();
            Date refresh = claimMap.get(JsonWebTokenConstant.REFRESH_TOKEN_FLAG).asDate();
            String userId = claimMap.get(JsonWebTokenConstant.TOKEN_USER_ID_FLAG).asString();
            //保存当前userId
            CurrentUserSession.setUserId(Long.valueOf(userId));
            if (!userId.equals(request.getHeader(RequestConstans.USER_ID))) {
                //验证token中的uid是否与header中的uid一致
                logger.error("header in uid and user_token in uid differ error {} ", userId);
                throw new BusinessException(BusinessErrorCode.VOICE_CALL_PARAM_ERROR);
            }
            if (refresh.getTime() < System.currentTimeMillis()) {
                //刷新 token
                Integer versionCode = claimMap.get(JsonWebTokenConstant.TOKEN_VERSION_ID_FLAG).asInt();
                //验证token中的版本号是否与数据库中的是否一致,不一致的话转登录
                Integer result = this.checkVersion(versionCode, request, Long.valueOf(userId)).getCode();
                if (result.equals(LoginTokenVersionCompareEnum.TOKEN_DIFFERENCE.getCode())) {
                    return JsonWebTokenVerifyStatus.LOGIN;
                } else if (result.equals(LoginTokenVersionCompareEnum.IN_CONSISTENT.getCode())) {
                    return JsonWebTokenVerifyStatus.LOGIN_OTHER;
                } else if (result.equals(LoginTokenVersionCompareEnum.LATEST_REFRESH.getCode())) {
                    return JsonWebTokenVerifyStatus.SUCCESS;
                } else {
                    logger.info(JsonWebTokenVerifyStatus.CREATE_NEW.getMessage());
                    return JsonWebTokenVerifyStatus.CREATE_NEW;
                }
            }
            return JsonWebTokenVerifyStatus.SUCCESS;
        } catch (BusinessException b) {
            throw b;
        } catch (Exception e) {
            logger.info(JsonWebTokenVerifyStatus.LOGIN.getMessage());
            return JsonWebTokenVerifyStatus.LOGIN;
        }
    }

下面是this.checkVersion(versionCode, request, Long.valueOf(userId)).getCode();方法

    /**
     * 校验版本号
     *
     * @param requestVersionCode 请求token里携带的版本号
     * @param request            request
     * @param userId             userId
     * @return Boolean
     */
    public LoginTokenVersionCompareEnum checkVersion(Integer requestVersionCode, HttpServletRequest request, Long userId) {
        int loginAndTokenLength = jsonWebTokenProperties.getLoginTokenVersionCodeTokenLength();
        ImUserBase imUserBase = imUserBaseMapper.selectByPrimaryKey(userId);
        PlatformEnum platformEnum = this.getPlatformFromRequest(request);
        Date date;
        Integer dbVersionCode;
        if (platformEnum.getCode().equals(PlatformEnum.ANDROID.getCode()) || platformEnum.getCode().equals(PlatformEnum.IOS.getCode())) {
            dbVersionCode = imUserBase.getAppTokenVersion();
            date = imUserBase.getAppTokenRefreshTime();
        } else {
            dbVersionCode = imUserBase.getWebTokenVersion();
            date = imUserBase.getWebTokenRefreshTime();
        }
        String requestVersionCodeBinary = BitUtils.decimalToBinary(requestVersionCode);
        String dbVersionCodeBinary = BitUtils.decimalToBinary(dbVersionCode);
        //比对登录版本
        Boolean loginCompareResult = requestVersionCodeBinary.substring(requestVersionCodeBinary.length() - loginAndTokenLength)
                .equals(dbVersionCodeBinary.substring(dbVersionCodeBinary.length() - loginAndTokenLength));
       //比对token版本
        Boolean tokenCompareResult = requestVersionCodeBinary.substring(requestVersionCodeBinary.length() - 2 * loginAndTokenLength, requestVersionCodeBinary.length() - loginAndTokenLength)
                .equals(dbVersionCodeBinary.substring(dbVersionCodeBinary.length() - 2 * loginAndTokenLength, dbVersionCodeBinary.length() - loginAndTokenLength));
        //当前请求的versionCode的token版本+1之后的值
        Integer addResultCode = this.versionAdd(requestVersionCode, JsonWebTokenConstant.TOKEN_VERSION_TAG);
        logger.debug("requestVersionCode:{},addResultCode:{},dbVersionCode:{}", requestVersionCode, addResultCode, dbVersionCode);
        //为true  说明当前请求的versionCode与数据的versionCode版本相差1
        boolean tokenVersionDifferOne = addResultCode.equals(dbVersionCode);
        if (loginCompareResult && tokenCompareResult) {
            //都一致
            return LoginTokenVersionCompareEnum.CONSISTENT;
        }
        if (loginCompareResult && tokenVersionDifferOne && System.currentTimeMillis() - date.getTime() > JsonWebTokenConstant.REDIS_TOKEN_EXPIRE) {
            //版本相差1且同时期有请求刷新过token  30秒内
            return LoginTokenVersionCompareEnum.LATEST_REFRESH;
        }
        if (loginCompareResult) {
            //login版本一致,token版本不一致
            return LoginTokenVersionCompareEnum.TOKEN_DIFFERENCE;
        }
        //都不一致
        return LoginTokenVersionCompareEnum.IN_CONSISTENT;
    }

只节选了部分代码,设计方案仅供参考,不同业务场景设计不一样。


在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/Axela30W/article/details/89489350