前言
shiro整合JWT系列,主要记录核心思路–如何在shiro+redis整合JWTToken。
上一篇中,我们知道了需要创建JwtToken、JwtUtil、JwtFilter。
该篇主要讲如何在shiro框架中,配置Jwt。
ps:本文主要以记录核心思路为主。
1、ShiroConfig配置
- 核心片段代码:
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 拦截器
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 配置不会被拦截的链接 顺序判断
filterChainDefinitionMap.put("/sys/login", "anon"); //登录接口排除
filterChainDefinitionMap.put("/", "anon");
// 添加自己的过滤器并且取名为jwt 核心部分
Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
filterMap.put("jwt", new JwtFilter());
shiroFilterFactoryBean.setFilters(filterMap);
// <!-- 过滤链定义,从上向下顺序执行,一般将/**放在最为下边
filterChainDefinitionMap.put("/**", "jwt");
// 未授权界面返回JSON
shiroFilterFactoryBean.setUnauthorizedUrl("/sys/common/403");
shiroFilterFactoryBean.setLoginUrl("/sys/common/403");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
在ShiroConfig中,创建了一个filterMap
,里面就存储了("jwt", new JwtFilter())
的键值对,并作为Fileters设置到shiroFilterFactoryBean
中,在最后put到filterChainDefinitionMap
里进行拦截。
疑惑:有人感觉这里怪怪的,但又说不出来,到底哪里不一样了?
回答:原本shiro在最后是filterChainDefinitionMap.put("/**", "authc");
,让剩下的请求必须登录认证;现在这里改成了自定义的filterChainDefinitionMap.put("/**", "jwt");
,意味着,剩下的请求全都交给JwtFilter类来处理。
2、ShiroRealm配置
@Component
@Slf4j
public class ShiroRealm extends AuthorizingRealm {
// 下面这个两个类,这里就不给出具体内容了,作用在下面一目了然
@Autowired
@Lazy
private ISysUserService sysUserService;
@Autowired
@Lazy
private RedisUtil redisUtil;
/** 2.1 supports */
/** 2.2 认证 */
/** 2.3 校验token的有效性 */
/** 2.4 token刷新(续签) */
/** 2.5 授权 */
}
2.1 supports
- 代码:
/**
* 2.1 supports
* 必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
我们可以看到Realm提供的接口supports,原本是由AuthenticatingRealm实现的。
上图中,AuthenticatingRealm的getAuthenticationTokenClass
方法默认值是UsernamePasswordToken.class,xxx.isAssignableFrom
是判断传入的类cls
能否(通过标识转换或扩展引用转换转换)转换为xxx
对象表示的类型。
isAssignableFrom
是Class.java中的方法,其解释:
确定此class对象所表示的类或接口是否与指定的class参数所表示的类别或接口相同,或者是该类别或接口的超类别或超接口。如果是,则返回true;否则返回false。如果此Class对象表示基元类型,则如果指定的Class参数正是此Class对象,则此方法返回true;否则返回false。【百度翻译】
- 总结:
1.原来的就是token的String类型是否能转换成getAuthenticationTokenClass
方法中的类型(UsernamePasswordToken.class)。
2.这里重写supports
方法,return token instanceof JwtToken;
判断token是否属于JwtToken类型,就不用原来默认的判断了。
2.2 认证 (doGetAuthenticationInfo)
- 代码:
/**
* 2.2 认证
* @param auth 用户身份信息 token
* @return 返回封装了用户信息的 AuthenticationInfo 实例
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
// 这里的AuthenticationToken是用 JwtToken重写的实现方法getPrincipal()/getCredentials()都返回token
String token = (String) auth.getCredentials();
if (token == null) {
log.info("————————身份认证失败——————————");
throw new AuthenticationException("token为空!");
}
// 校验token有效性
SysUser loginUser = this.checkUserTokenIsEffect(token);
return new SimpleAuthenticationInfo(loginUser, token, getName());
}
看到这里,会发现这段代码和以往的shiro差距还蛮大的,可以对比【Shiro】SimpleAuthenticationInfo如何验证password中自定义的ShiroRealm类给出的doGetAuthenticationInfo方法;但是其实变化不是很大,请先继续往下看checkUserTokenIsEffect方法。
2.3 校验token的有效性(checkUserTokenIsEffect)
- 代码:
/**
* 2.3 校验token的有效性
*
* @param token
*/
public SysUser checkUserTokenIsEffect(String token) throws AuthenticationException {
// 解码获得username,用于查询数据库
String username = JwtUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token非法无效!");
}
// 查询用户信息
SysUser loginUser = new SysUser();
SysUser sysUser = sysUserService.getUserByName(username);
//判断账号是否存在
if (sysUser == null) {
throw new AuthenticationException("用户不存在!");
}
// 校验token是否超时失效 & 或者账号密码是否错误 核心部分
if (!jwtTokenRefresh(token, username, sysUser.getPassWord())) {
throw new AuthenticationException("Token失效请重新登录!");
}
// 判断用户状态
if (!"0".equals(sysUser.getDelFlag())) {
throw new AuthenticationException("账号已被删除,请联系管理员!");
}
// 复制对象,为什么要这么做?麻烦懂的大佬留言指教一下
BeanUtils.copyProperties(sysUser, loginUser);
return loginUser;
}
从整体的角度看下,会发现这个部分的大体逻辑和以前的shiro差不多,我们来看下他们的异同:
- 相同部分:
用AuthenticationToken对象获取用户名(账号),然后根据用户名查询数据库,得到该用户的User对象(用户账号,加密密码,盐值等等)。
PS:这些逻辑只是被抽象成一个新的方法checkUserTokenIsEffect。 - 区别部分:
1、以前AuthenticationToken是存放username和password信息的,现在是token字符串。
2、相比以前,现需要多考虑token的有效性(具体看2.4 jwtTokenRefresh),也就出现了大家常听到的token续签。
2.4 token刷新(jwtTokenRefresh)
前提了解:
JWTToken刷新生命周期 (解决用户一直在线操作,提供Token失效问题)
1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样)
2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证
3、当该用户这次请求JWTToken值还在生命周期内,则会通过重新PUT的方式k、v都为Token值,缓存中的token值生命周期时间重新计算(这时候k、v值一样)
4、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算
5、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。
6、每次当返回为true情况下,都会给Response的Header中设置Authorization,该Authorization映射的v为cache对应的v值。
7、注:当前端接收到Response的Header中的Authorization值会存储起来,作为以后请求token使用
参考方案:https://blog.csdn.net/qq394829044/article/details/82763936
- 代码:
/**
* 2.4 token刷新
*
* @param token
* @param userName
* @param passWord
* @return
*/
public boolean jwtTokenRefresh(String token, String userName, String passWord) {
// 定义前缀+token 为缓存中的key,得到对应的value(cacheToken)
String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token));
// 判断缓存中的token是否存在
if (cacheToken != null && !cacheToken.equals("") && !cacheToken.equals("null")) {
// 校验token有效性
// 缓存中存在,验证失败(JwtUtil.verify在上一篇文章中已经介绍)
if (!JwtUtil.verify(cacheToken, userName, passWord)) {
// 重新sign,得到新的token
String newAuthorization = JwtUtil.sign(userName, passWord);
// 写入到缓存中,key不变,将value换成新的token
redisUtil.set("PREFIX_TOKEN" + token, newAuthorization);
// 设置超时时间【这里除以1000是因为设置时间单位为秒了】【一般续签的时间都会乘以2】
redisUtil.expire("PREFIX_TOKEN" + token, JwtUtil.EXPIRE_TIME / 1000);
// 缓存中存在,验证成功
} else {
// 上面的写法,与下面的相同
// 用户这次请求JWTToken值还在生命周期内,重新put新的生命周期时间(有效时间)
redisUtil.set("PREFIX_TOKEN" + token, cacheToken, JwtUtil.EXPIRE_TIME / 1000);
}
return true;
}
return false;
}
PS:开篇代码已经提到redisUtil和sysUserService不花篇幅说明,用到的方法在代码中已经有相应的解释。
2.5 授权(doGetAuthorizationInfo)
- 代码:
/**
* 2.5 授权
*
* @param principals token
* @return AuthorizationInfo 权限信息
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("————权限认证 [ roles、permissions]————");
SysUser sysUser = null;
String username = null;
if (principals != null) {
sysUser = (SysUser) principals.getPrimaryPrincipal();
username = sysUser.getUserName();
}
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 设置用户拥有的角色集合,比如“admin,test”
Set<String> roleSet = sysUserService.getUserRolesSet(username);
info.setRoles(roleSet);
// 设置用户拥有的权限集合,比如“sys:role:add,sys:user:add”
Set<String> permissionSet = sysUserService.getUserPermissionsSet(username);
info.addStringPermissions(permissionSet);
return info;
}
- 注意:
我们这里使用的redis依赖是spring-boot-starter-data-redis
;上面代码中sysUserService查询用户角色和权限集合,需要在这些方法的实现类上加上@Cacheable(value = "用来指定缓存组件的名字", key = "缓存数据时使用的 key")
,以此来将查询的信息存入缓存中。
疑惑:一定要用@Cacheable吗?直接用redis的操作加入缓存可以吗?
回答:不一定;可以。@Cacheable是Spring的注解,实现了很多缓存的方法,具体可以看SpringBoot 缓存之 @Cacheable 详细介绍。
3、token执行的流程图
这里解释以下图中第2步和第6步:
- 第2步:Controller层处理的 4缓存token,我的测试过程中是使用了redis,在生成完token后,会将其token存入到redis中。
- 第6步:有些人会问为什么是无状态登录?还有人会问使用token就是为了无状态登录,为什么还需要结合redis缓存?和用session有什么区别?
- 解释:
1、有状态登录:是用户请求的时候,在服务器上已经缓存(利用session缓存)了用户的信息,cookie存储在客户端,session存储在服务端。
无状态登录:是用户的信息不在服务端进行存储,只将token存储在客户端。
2、使用redis的目的是为了后续的分布式支持,应对高并发的扩展性(集群模式),性能好,同时使用上变得更加灵活。(目前才刚了解,还不太熟悉)
3、redis 的性能要比传统的 session 存储方式更高效,因为 redis 是基于内存的,而且支持异步方式存储数据。除了性能上的区别,就是更加的灵活,如:
1、token+redis 方案,服务器可以清除 redis 中对应的 token,这样就可以在服务器端对该指定用户进行注销下线了。
2、token+session+redis 方案,拿session用来存储 token,然后再将 session 存储在 redis 中。这样有个好处,就是一个 session 可以存储多个 token,可以让同一个账户在多设备端共用一个会话。
session,cookie,token,redis
Session机制详解及分布式中Session共享解决方案 - 解释:
4、简单整理:
-
Old:
以前的登录,在controller层的登录接口执行subject.login(token)
,然后就执行到doGetAuthenticationInfo方法,这里的token为UsernamePasswordToken。在doGetAuthenticationInfo中主要是从数据库中查出用户对象,密码,盐值,然后加上realm名字放入SimpleAuthenticationInfo对象中,用于assertCredentialsMatch中的info进行验证。 -
New:
整合Jwt后,在controller层的登录接口不执行subject.login(token)
,只是生成token返回给前端。后面所有的请求,前端都将在header中放入token,每一次被JwtFilter拦截下来的请求,都将会执行到executeLogin方法中的getSubject(request, response).login(jwtToken);
实现本次请求的登录(每一次请求都得执行一次实际登录代码,而不是Controller层的登录接口),在该方法执行完后,会执行到Reealm中的doGetAuthenticationInfo方法,这里主要作为token的验证或续签(如上面介绍的checkUserTokenIsEffect和jwtTokenRefresh)。