- ruoyi-cloud认证-token改造为双token
- 前言
- 什么是双token
- ruoyi-cloud的token机制简述
- 存在的问题
- 如何改造
- 主要处理逻辑
- 实现关键代码
- 登录token创建
- token刷新
- token的验证
- 最后
Token在计算机身份认证中是令牌(临时)的意思,在词法分析中是标记的意思。一般作为邀请、登录系统使用。
什么是双token双token一般是指:access_token和refresh_token。
access_token是一种JWT(json web token),有效时间通常较短,用户在获取资源的时候需要携带access_token,当access_token过期后,如果是活跃用户,就需要使用refresh_token获取一个新的access_token,这样就避免了用户使用正high被踢出去,重新登录,那估计摔手机都有可能。但是对于登录上去,长时间不操作的用户呢,一般会设置超时时间,比如:设置5分钟超时时间,那连续5分钟没有任何操作就会被认为是超时,就会被请下去,需要重新登录,获取新的token。
ruoyi-cloud的token机制简述- 在登录成功后,创建token。
首先创建UUID,作为userKey,以该key为主键存储用户信息到redis,设置过期时间。
其次,使用userKey及用户部分信息生成JWT token。而该JWT token即为对客户端暴漏的用户token。 - 在业务调用期间,由Gateway的com.ruoyi.gateway.filter.AuthFilter对token的合法性进行校验。上面说到,JWT token中包含userKey信息,则解析JWT token后,即可通过userKey从redis中获取到用户信息。当redis中该信息不存在,则意味着用户token失效。
- 那么ruoyi-cloud是怎么对token延期的呢?在com.ruoyi.common.security.interceptor.HeaderInterceptor中可以看到,在每次请求中都调用方法com.ruoyi.common.security.auth.AuthUtil.verifyLoginUserExpire(loginUser),该方法最终执行逻辑如下:
public void verifyToken(LoginUser loginUser)
{
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
{
refreshToken(loginUser);
}
}
即当过期时间减去当前时间小于某一固定时间时,则刷新token。实际是做了token延期处理。
以上,即为ruoyi-cloud的token机制。
存在的问题- 一个token可无限延期下去,过期时间越长,则安全性越低;
- token延期的机制,取决于用户请求的时间点、token过期时长、及MILLIS_MINUTE_TEN的取值。比如:token有效时长30分钟,MILLIS_MINUTE_TEN的取值为15分钟,那么在登录后的前15分钟,不会刷新token,即不会延长token。这样就形成了,我在第14分钟还在操作,本身人为要到第44分钟token才会过期,结果在第30分钟token就过期了。(有点绕)
主要流程见下图:
- 用户登录验证通过,则分别生成accessToken、refreshToken,并将它们对应的过期时间一并返回给客户端;
- 客户端存储信息,在每次业务交互时,验证本地的token过期时间;
- 如果accessToken未过期,则携带accessToken调用业务接口;
- 如果accessToken已过期,refreshToken未过期,则携带refreshToken调用/auth/refresh接口,获取新的accessToken;
- 如果accessToken已过期,refreshToken已过期,则跳转要求重新登录。
public MapcreateAllToken(LoginUser loginUser) { String token = IdUtils.fastUUID(); String refToken = IdUtils.fastUUID(); Long userId = loginUser.getSysUser().getUserId(); Long deptId = loginUser.getSysUser().getDeptId(); String userName = loginUser.getSysUser().getUserName(); long currentTimeMillis = System.currentTimeMillis(); long expires_time = currentTimeMillis+ expireTime * MILLIS_MINUTE; long refresh_expires_time = currentTimeMillis + refreshExpireTime * MILLIS_MINUTE; loginUser.setUserid(userId); loginUser.setUsername(userName); loginUser.setDeptId(deptId); loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest())); loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setToken(token); loginUser.setRefToken(refToken); loginUser.setExpireTime(expires_time); loginUser.setRefExpireTime(refresh_expires_time); cacheAllToken(loginUser); // Jwt存储信息 Map claimsMap = new HashMap<>(); claimsMap.put(SecurityConstants.USER_KEY, token); claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId); claimsMap.put(SecurityConstants.DETAILS_DEPT_ID, deptId); claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName); claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS); String accToken = JwtUtils.createToken(claimsMap); // Jwt ref token claimsMap.put(SecurityConstants.USER_KEY, refToken); claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_REFRESH); String refreshToken = JwtUtils.createToken(claimsMap); // 接口返回信息 Map rspMap = new HashMap (); rspMap.put("access_token", accToken); rspMap.put("expires_in", expireTime); rspMap.put("expires_time", expires_time); rspMap.put("refresh_token", refreshToken); rspMap.put("refresh_expires_in", refreshExpireTime); rspMap.put("refresh_expires_time", refresh_expires_time); return rspMap; }
可以看到,创建两个token,并返回。且两个token的信息中,仅userKey、过期时间不一致,其余信息都一致。两个token都作为userKey,存储用户信息到redis。refreshToken的过期时间长与accessToken的过期时间。
token刷新根据refreshToken获取到用户信息
String userkey = JwtUtils.getUserKey(refreshToken); return redisService.getCacheObject(getRefTokenKey(userkey));
从refreshToken中解析出userKey,然后取出登录用户信息。
创建accessToken,并建立新的accessToken与refreshToken的映射关系
public MapcreateAccessToken(LoginUser loginUser) { // 在创建新的accessToken前,判断之前的token是否存在,如果存在则删除 String oldToken = loginUser.getToken(); delAccessToken(oldToken); String token = IdUtils.fastUUID(); long currentTimeMillis = System.currentTimeMillis(); long expires_time = currentTimeMillis+ expireTime * MILLIS_MINUTE; loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest())); loginUser.setLoginTime(System.currentTimeMillis()); loginUser.setToken(token); loginUser.setExpireTime(expires_time); cacheAccessToken(loginUser); // Jwt存储信息 Map claimsMap = new HashMap<>(); claimsMap.put(SecurityConstants.USER_KEY, token); claimsMap.put(SecurityConstants.DETAILS_USER_ID, loginUser.getUserid()); claimsMap.put(SecurityConstants.DETAILS_DEPT_ID, loginUser.getDeptId()); claimsMap.put(SecurityConstants.DETAILS_USERNAME, loginUser.getUsername()); claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS); String accToken = JwtUtils.createToken(claimsMap); // 接口返回信息 Map rspMap = new HashMap (); rspMap.put("access_token", accToken); rspMap.put("expires_in", expireTime); rspMap.put("expires_time", expires_time); return rspMap; }
token的刷新,实际我这里仅创建了新的accessToken,这里就要求将refreshToken的超时时间设置的足够长。这个需要根据业务实际需要综合考虑。实际也可以通过延长refreshToken的过期时间解决。
token的验证private static final String REFRESH_PATH = "/auth/refresh"; public Monofilter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpRequest.Builder mutate = request.mutate(); String url = request.getURI().getPath(); // 跳过不需要验证的路径 if (StringUtils.matches(url, ignoreWhite.getWhites())) { return chain.filter(exchange); } String token = getToken(request); if (StringUtils.isEmpty(token)) { return unauthorizedResponse(exchange, "令牌不能为空"); } Claims claims = JwtUtils.parseToken(token); if (claims == null) { return unauthorizedResponse(exchange, "令牌已过期或验证不正确!"); } String userkey = JwtUtils.getUserKey(claims); String tokenType = JwtUtils.getTokenType(claims); boolean isLogin; if(SecurityConstants.TOKEN_TYPE_ACCESS.equalsIgnoreCase(tokenType)){ isLogin = redisService.hasKey(getTokenKey(userkey)); }else{ // 如果时refreshToken,则url只能是刷新接口。 if(!REFRESH_PATH.equalsIgnoreCase(url)){ return unauthorizedResponse(exchange, "令牌验证失败"); } isLogin = redisService.hasKey(getRefTokenKey(userkey)); } if (!isLogin) { return accUnauthorizedResponse(exchange, "登录状态已过期"); } String userid = JwtUtils.getUserId(claims); String username = JwtUtils.getUserName(claims); String deptId = JwtUtils.getDeptId(claims); if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) { return unauthorizedResponse(exchange, "令牌验证失败"); } // 设置用户信息到请求 addHeader(mutate, SecurityConstants.USER_KEY, userkey); addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid); addHeader(mutate, SecurityConstants.DETAILS_DEPT_ID, deptId); addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username); // 内部请求来源参数清除 removeHeader(mutate, SecurityConstants.FROM_SOURCE); return chain.filter(exchange.mutate().request(mutate.build()).build()); }
这里为了严格控制,实际对accessToken、refreshToken增加了tokenType字段。限定refreshToken只能在访问/auth/refresh接口时访问。
以上步骤即完成了双token的改造。
最后至于为什么改造为双token,以及双token有哪些好处?我在这里就不一一阐述了。
大家有兴趣可以看下这篇文章:http://www.mobiletrain.org/about/BBS/77900.html



