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;
}
只节选了部分代码,设计方案仅供参考,不同业务场景设计不一样。