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.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency>
jwt token工具類
/** * 獲取用戶從token中 */ public String getUserFromToken(String token) { return getClaimFromToken(token).getSubject(); } /** * <pre> * 驗證token是否失效 * true:過期 false:沒過期 * </pre> */ 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); // Map<String,Object> claims = new HashMap<String,Object>();//創建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(","); ArrayList<GrantedAuthority> authorities = 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); }