前言
之前在写shiro整合JWT的时候,在ShiroRealm中有写到token的刷新;但后来看了很多别人的项目demo和博客发现之前的写法不太合适。这里参考之前看过的各个项目与博客,延续这之前shiro整合JWT内容的做了一波缝合怪。
主要对之前的ShiroRealm,JwtUtil,JwtFilter类进行修改。
ps:本文主要以记录核心思路为主。
1、Token设计
1.1 Token的情况
声明:Token设计内容参考JWT Token刷新方案
1、正常Token:Token未过期,且未达到建议更换时间。
2、濒死Token:Token未过期,已达到建议更换时间。
3、正常过期Token:Token已过期,但存在于缓存中。
4、非正常过期Token:Token已过期,不存在于缓存中。
1. 正常Token传入
当正常Token请求时,返回当前Token。
2. 濒死Token传入
当濒死Token请求时,获取一个正常Token并返回。
3. 正常过期Token
当正常过期Token请求时,获取一个正常Token并返回。
4. 非正常过期过期Token
当非正常过期Token请求时,返回错误信息,需重新登录。
1.2 具体设计
根据Token的情况,我们设置 token有效时间 外,还需要在设置一个 token刷新时间(建议更换时间) 和 token缓存有效期(redis缓存)。
- 判断是否需要刷新
token有效时间 - 当前时间 <= token刷新时间
true:需要刷新
false:有效期内
2、代码实现
2.1 Maven依赖
<!--JWT-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
2.2 JwtUtil
根据1.2具体设计的内容,修改JwtUtil。
2.2.1 新变量与checkRefresh方法
/** token cache过期时间30分钟 设置redis过期时间**/
public static final long CACHE_EXPIRE_TIME = 30 * 60 * 1000L;
/** token 过期时间15分钟 **/
public static final long EXPIRE_TIME = 15 * 60 * 1000L;
/** token 最后5分钟更新token **/
public static final long REFRESH_TIME = 5 * 60 * 1000L;
/**
* 判断是否需要刷新
* @param cacheToken 缓存中的token
* @param currentTime 当前时间
* @return
*/
public static boolean checkRefresh(String cacheToken, long currentTime){
// 获取token的生成时间
long current= (long) JwtUtil.getExpire(cacheToken);
// token有效时间-当前时间↑ <= 需要刷新的有效时间?true需要刷新:false有效期内
if (current+JwtUtil.EXPIRE_TIME - currentTime <= JwtUtil.REFRESH_TIME)
return true;
else
return false;
}
redis缓存token的时间一般为token过期时间的2倍。这里设置了3个静态变量是为了更直观的阅读理解各个地方表示的时间;在实际开发中,只需要设置EXPIRE_TIME
就可以,而CACHE_EXPIRE_TIME
直接替换为2 * EXPIRE_TIME
;REFRESH_TIME
使用到的地方只有刷新判断,所以不需要单独设置一个静态变量。
2.2.2 修改verify方法
/**
* 校验token是否被篡改、伪造或过期
* 校验token的有效性,1、token的header和payload是否没改过;2、没有过期
* @param token
* @return
*/
public static String verify(String token) {
try {
// 根据密钥(这里是密码)生成一个算法实例
Algorithm algorithm = Algorithm.HMAC256(SECRET);
// 生成JWT效验器
JWTVerifier verifier = JWT.require(algorithm) // 设置一个以该算法为基础的校验器
.acceptLeeway(2) // 设置允许误差时间
.build(); // 创建校验器
// 效验TOKEN,验证JWT是否有效,包括过期时间的判断,篡改、伪造或过期就会出现异常
DecodedJWT jwt = verifier.verify(token);
return "success";
} catch (SignatureVerificationException e){
// 签名无效
return "SignatureVerificationException";
} catch (InvalidClaimException e){
// 获取token的服务器比使用token的服务器时钟快,请求分发到时间慢的服务器上导致时间还没到token的开始时间。token无效!失效的payload异常
return "InvalidClaimException";
} catch (AlgorithmMismatchException e){
// token算法不一致
return "AlgorithmMismatchException";
} catch (TokenExpiredException e){
// token过期
return "TokenExpiredException";
} catch (Exception exception) {
return "otherException";
}
}
2.3 ShiroRealm
去掉了原来的 校验token的有效性 和 token刷新(续签),然后修改了 认证 的方法。
PS:根据了解与个人理解,感觉token的续签应该放在JwtFilter 类中。
2.3.1 修改后的doGetAuthenticationInfo方法
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
log.info("————身份认证 ————");
// 这里的AuthenticationToken是用 JwtToken重写的实现方法getPrincipal()/getCredentials()都返回token
String token = (String) auth.getCredentials();
if (token == null) {
log.info("————————身份认证失败——————————IP地址: " + CommonUtils.getIpAddrByRequest(SpringUtils.getHttpServletRequest()));
throw new AuthenticationException("token为空!");
}
// 解码获得username,用于查询数据库
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token非法无效!");
}
// 查询用户信息
SysUser sysUser = sysUserService.getUserByName(username);
//判断账号是否存在
if (sysUser == null) {
throw new AuthenticationException("用户不存在!");
}
// 判断用户状态
if (!"0".equals(sysUser.getDelFlag())) {
throw new AuthenticationException("账号已被删除,请联系管理员!");
}
// 定义前缀+username 为缓存中的key,得到对应的value(cacheToken)
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + username));
// 判断缓存中是否存在token
if (CommonUtils.isNotEmpty(cacheToken)) {
// 验证token
if ("TokenExpiredException".equals(JwtUtil.verify(cacheToken))){
throw new TokenExpiredException("token认证失效,token过期,重新登陆(人为抛出异常)");
}else if ("success".equals(JwtUtil.verify(cacheToken))){
long currentTime = System.currentTimeMillis();
// token有效时间-当前时间↑ <= 需要刷新的有效时间?true需要刷新:false有效期内直接登录
if (JwtUtil.checkRefresh(cacheToken,currentTime)){
// 2、濒死Token:Token未过期,已达到建议更换时间。
throw new TokenWillRefreshException("token认证即将失效,重新执行登陆返回新token(人为抛出异常)");
}else {
// 1、正常Token:Token未过期,且未达到建议更换时间。
return new SimpleAuthenticationInfo(sysUser, cacheToken, getName());
}
}else if ("InvalidClaimException".equals(JwtUtil.verify(cacheToken))){
throw new InvalidClaimException("token无效!失效的payload异常(Realm人为抛出异常)");
}else if ("AlgorithmMismatchException".equals(JwtUtil.verify(cacheToken))){
throw new AlgorithmMismatchException("token算法不一致(Realm人为抛出异常)");
}else {
throw null;
}
}
return null;
}
2.4 JwtFilter
先添加isLoginAttempt
方法,再改写isAccessAllowed
方法,然后添加refreshToken
方法。
2.4.1 isLoginAttempt方法
/**
* 判断是否存在token(是否可以登录)
* @param request incoming ServletRequest
* @param response outgoing ServletResponse
* @return
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req= (HttpServletRequest) request;
// 从请求头header中获取字段名为ACCESS_TOKEN的值(也就是我们说的token)
String token=req.getHeader("ACCESS_TOKEN");
return token !=null;
}
2.4.2 改写的isAccessAllowed方法
/**
* 权限校验
* 执行登录认证
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)){
try {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
executeLogin(request, response);
return true;
}catch (Exception e){
/*
* 注意这里捕获的异常其实是在Realm抛出的,但是由于executeLogin()方法抛出的异常是从login()来的,
* login抛出的异常类型是AuthenticationException,所以要去获取它的子类异常才能获取到我们在Realm抛出的异常类型。
* */
Throwable cause = e.getCause();
/*
TokenWillRefreshException:2、濒死Token:Token未过期,已达到建议更换时间。
TokenExpiredException:3、正常过期Token:Token已过期,但存在于缓存中。
4、非正常过期Token:Token已过期,不存在于缓存中。
*/
if (cause!=null&&(cause instanceof TokenExpiredException || cause instanceof TokenWillRefreshException)){
//尝试去刷新token
String result = refreshToken(request, response);
if (result.equals("success")) {
return true;
}
}
// 如果不是以上情况,执行onAccessDenied
return false;
}
}
return false;
}
2.4.3 refreshToken方法
/**
* token续签
* @param request
* @param response
* @return
*/
private String refreshToken(ServletRequest request,ServletResponse response) {
HttpServletRequest req= (HttpServletRequest) request;
// 原因:拦截器在bean初始化前执行的,这时候redisUtil是null,需要通过下面这个方式去获取
RedisUtil redisUtil= SpringUtils.getBean(RedisUtil.class);
// 获取传递过来的accessToken
// 从请求头header中获取字段名为ACCESS_TOKEN的值(也就是我们说的token)
String token = req.getHeader("ACCESS_TOKEN");
// 获取token里面的用户名
String userName = JwtUtil.getUsername(token);
// redis中的token 定义前缀+username 为缓存中的key,得到对应的value(cacheToken)
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + userName));
// 判断refreshToken是否过期了
if (CommonUtils.isNotEmpty(cacheToken)){
// 判断是否超时
long currentTime = System.currentTimeMillis();
// 在这里进来,只可能属于
// 2、濒死Token:Token未过期,已达到建议更换时间。
// 3、正常过期Token:Token已过期,但存在于缓存中。
// jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,
// 程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
// 重新sign,得到新的token(生成刷新的token)
String newAuthorization = JwtUtil.sign(userName, currentTime);
// 写入到缓存中,key不变,将value换成新的token
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + userName, newAuthorization);
// 设置超时时间【这里除以1000是因为设置时间单位为秒了】
redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + userName, JwtUtil.CACHE_EXPIRE_TIME / 1000);
// 转换类型
JwtToken jwtToken = new JwtToken(newAuthorization);
try {
// 提交给realm,再次让shiro进行认证
getSubject(request, response).login(jwtToken);
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("ACCESS_TOKEN", newAuthorization);
httpServletResponse.setHeader("Access-Control-Expose-Headers", CommonConstant.ACCESS_TOKEN);
}catch (Exception e){
e.getMessage();
}
// 返回成功刷新标识
return "success";
}
// 4、非正常过期Token:Token已过期,不存在于缓存中。
// token认证失效,token过期,重新登陆
return "token认证失效,token过期,重新登陆(JwtFilter)";
}