文章目录
ruoyi-cloud认证-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已过期,则跳转要求重新登录。
实现关键代码
登录token创建
public Map<String, Object> createAllToken(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<String, Object> 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<String, Object> rspMap = new HashMap<String, Object>();
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 Map<String, Object> createAccessToken(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<String, Object> 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<String, Object> rspMap = new HashMap<String, Object>();
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 Mono<Void> filter(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