JWT TOKEN刷新方案
一、环境
Springboot,Redis
二、需求
最近在做用户中心,需要向其他服务签发JWT Token,使用Token来获取用户信息,保证用户信息安全可靠,不会被重放攻击。
三、问题
JWT Token设置有效期,一旦失效用户就要重新登录,这样的体验非常差,需要做到用户在无感知的情况下,解决如何刷新Token的问题。
四、解决方案
1.设计思路
看了很多文章,大都是通过refresh Token刷新,其实做法上是类似的。
2.Token设计
说明
正常Token:Token未过期,且未达到建议更换时间。
濒死Token:Token未过期,已达到建议更换时间。
正常过期Token:Token已过期,但存在于缓存中。
非正常过期Token:Token已过期,不存在于缓存中。
过期时间
Token过期时间越短越安全,如设置Token过期时间15分钟,建议更换时间设置为Token前5分钟,则Token生命周期如下:
时间 Token类型 说明
0-10分钟 正常Token 正常访问
10-15分钟 濒死Token 正常访问,返回新Token,建议使用新Token
>15分钟 过期Token 需校验是否正常过期。正常过期则能访问,并返回新Token;
非过期Token拒绝访问
生成一个正常Token
在缓存中,通过用户标识查询老Token。
如存在,将老Token(Token,用户标识)本条缓存设置过期时间,作为新老Token交替的过渡期。
将新Token以(Token,用户标识)、(用户标识,Token)一对的形式存入缓存,不设置过期时间。
获取一个正常Token
在缓存中,通过用户标识查询用户当前Token,校验该Token是否为正常Token,如正常则返回;不正常则生成一个正常Token。
3.情景
正常Token传入
当正常Token请求时,返回当前Token。
濒死Token传入
当濒死Token请求时,获取一个正常Token并返回。
正常过期Token
当正常过期Token请求时,获取一个正常Token并返回。
非正常过期过期Token
当非正常过期Token请求时,返回错误信息,需重新登录。
4.代码
Maven依赖
<dependency> <groupId>io.jsonwebtokengroupId> <artifactId>jjwtartifactId> <version>0.7.0version> dependency>
jwt token工具类
/** * 获取用户从token中 */ public String getUserFromToken(String token) { return getClaimFromToken(token).getSubject(); } /** ** 验证token是否失效 * true:过期 false:没过期 **/ public Boolean isTokenExpired(String token) { try { final Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } catch (ExpiredJwtException expiredJwtException) { return true; } } /** * 获取可用的token * 如该用户当前token可用,即返回 * 当前token不可用,则返回一个新token * @param userId * @return */ public String getGoodToken(String userId){ String token = redisTemplate.opsForValue().get("userJwtToken_"+userId); boolean flag = this.checkToken(token); //校验当前token能否使用,不能使用则生成新token if(flag){ return token; }else{ String newToken = this.createToken(userId); //初始化新token this.initNewToken(userId, newToken); return newToken; } } /** * 判断过期token是否合法 * @param token * @return */ public String checkExpireToken(String token){ //判断token是否需要更新 boolean expireFlag = this.checkToken(token); //false:不建议使用 if(!expireFlag){ String userId = redisTemplate.opsForValue().get(token); if(ToolUtil.isNotEmpty(userId)){ return userId + "-1"; } }else{ String userId = this.getUserFromToken(token); return userId; } return ""; } /** * 检查当前token是否还能继续使用 * true:可以 false:不建议 * @param token * @return */ public boolean checkToken(String token){ SecretKey secretKey = this.createSecretKey(); try { // jwt正常情况 则判断失效时间是否大于5分钟 long expireTime = Jwts.parser() //得到DefaultJwtParser .setSigningKey(secretKey) //设置签名的秘钥 .parseClaimsJws(token.replace("jwt_", "")) .getBody().getExpiration().getTime(); long diff = expireTime - System.currentTimeMillis(); //如果有效期小于5分钟,则不建议继续使用该token if (diff < ADVANCE_EXPIRE_TIME) { return false; } } catch (Exception e) { return false; } return true; } /** * 创建新token * @param userId 用户ID * @return */ public String createToken(String userId){ SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //指定签名的时候使用的签名算法,也就是header那部分,jjwt已经将这部分内容封装好了。 long nowMillis = System.currentTimeMillis();//生成JWT的时间 Date now = new Date(nowMillis); // Mapclaims = new HashMap //创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证方式的) SecretKey secretKey = createSecretKey();//生成签名的时候使用的秘钥secret,这个方法本地封装了的,一般可以从本地配置文件中读取,切记这个秘钥不能外露哦。它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。 //下面就是在为payload添加各种标准声明和私有声明了 JwtBuilder builder = Jwts.builder() //这里其实就是new一个JwtBuilder,设置jwt的body // .setClaims(claims) //如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的 .setId(UUID.randomUUID().toString()) //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。 .setIssuedAt(now) //iat: jwt的签发时间 .setSubject(userId + "-" + jwtVersion) //sub(Subject):代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串,可以存放什么userid,roldid之类的,作为什么用户的唯一标志。 .signWith(signatureAlgorithm, secretKey);//设置签名使用的签名算法和签名使用的秘钥 //设置过期时间 if (JWT_EXPIRE_TIME_LONG >= 0) { long expMillis = nowMillis + JWT_EXPIRE_TIME_LONG; Date exp = new Date(expMillis); builder.setExpiration(exp); } String newToken = "jwt_" + builder.compact(); return newToken; } /** * 生成新token时,初始化token * @param userId * @param newToken */ public void initNewToken(String userId, String newToken){ String token = redisTemplate.opsForValue().get("userJwtToken_"+userId); if(ToolUtil.isNotEmpty(token)){ //老token设置过期时间 5分钟 redisTemplate.opsForValue().set(token, userId, OLD_TOKEN_EXPIRE_TIME, TimeUnit.MINUTES); } //新token初始化 redisTemplate.opsForValue().set(newToken, userId); redisTemplate.opsForValue().set("userJwtToken_"+userId, newToken); } /** * 获取jwt失效时间 */ public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token).getExpiration(); } /** * 获取jwt的payload部分 */ public Claims getClaimFromToken(String token) { SecretKey secretKey = createSecretKey(); return Jwts.parser() //得到DefaultJwtParser .setSigningKey(secretKey) //设置签名的秘钥 .parseClaimsJws(token.replace("jwt_", "")) .getBody(); } // 签名私钥 private SecretKey createSecretKey(){ byte[] encodedKey = Base64.decodeBase64(signKey);//本地的密码解码 SecretKey secretKey = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");// 根据给定的字节数组使用AES加密算法构造一个密钥,使用 encodedKey中的始于且包含 0 到前 leng 个字节这是当然是所有。(后面的文章中马上回推出讲解Java加密和解密的一些算法) return secretKey; }();
拦截器
//判断过期token是否合法 String userId = jwtTokenUtil.checkExpireToken(token); try { if(ToolUtil.isEmpty(userId)) { userId = jwtTokenUtil.getUserFromToken(token); } if (userId != null) { String[] split = userId.split("-")[1].split(","); ArrayListauthorities = new ArrayList<>(); for (int i = 0; i < split.length; i++) { authorities.add(new GrantedAuthorityImpl(split[i])); } //判断token是否需要更新,返回当前可用token String newToken = jwtTokenUtil.getGoodToken(userId.split("-")[0]); //每次认证把uId塞入UserIdThreadLocal //这样可以在当前请求线程里面 通过 UidHelper.get()获取用户id UidHelper.set(userId.split("-")[0]); response.setHeader("token", newToken); return new UsernamePasswordAuthenticationToken(userId, null, authorities); } logger.error("解析用户ID为空,token:{}",token); } catch (Exception e) { logger.error("Token已过期,token:{}",token); }