【shiro】shiro整合JWT——2.如何整合

前言

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)

猜你喜欢

转载自blog.csdn.net/weixin_42516475/article/details/130712768