项目实战九-登录功能二

鉴权功能

  1. 上一节讲过
    1. ShiroCfg中向容器添加Realm(TokenRealm)对象用来鉴权
    2. ShiroFilterFactoryBean对象配置拦截url
    3. TokenFilter校验token
    4. TokenMatcher用来自定义匹配算法
  2. 那么如何才能使用TokenRealm进行鉴权呢?TokenFilter校验token之后,触发鉴权方法
  3. TokenFilter的onAccessDenied方法,在对token进行校验之后,添加如下代码

     // 鉴权(进入Realm)
     // 这里调用login,并不是“登录”的意思,是为了触发Realm(TokenRealm)的相应方法(AuthorizationInfo、AuthenticationInfo)去加载用户的角色、权限信息,以便鉴权
     SecurityUtils.getSubject().login(new Token(token));
    
  4. 上面接收的Token类型必须是AuthenticationToken类型,因此自定义Token类

     @Data
     public class Token implements AuthenticationToken {
         private final String token;
        
         public Token(String token) {
             this.token = token;
         }
        
         @Override
         public Object getPrincipal() {
             return token;
         }
        
         @Override
         public Object getCredentials() {
             return token;
         }
     }
    
  5. 只有先认证通过,才会加载授权信息

认证功能

  1. TokenRealm代码如下

     @Slf4j
     public class TokenRealm extends AuthorizingRealm {
        
         //自己定义一个密码的比对算法类,传进来
         public TokenRealm (TokenMatcher matcher) {
             super(matcher);
         }
        
         /*
         执行流程1:
         先对token进行判断,返回true,才会调用doGetAuthorizationInfo、doGetAuthenticationInfo方法
         * */
         @Override
         public boolean supports(AuthenticationToken token) {
             log.debug("TokenRealm - supports - {}", token);
             //只有这个token类是自定义的Token类型,才会继续往下执行
             return token instanceof Token;
         }
        
         /*
         * 执行流程3:加载权限信息
         * */
         //获取权限信息
         @Override
         protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
             return null;
         }
        
         /*
         执行流程2:
         * 认证方法
         * AuthenticationToken token:TokenFilter中调用login传递过来的token
         *
         * */
         @Override
         protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
             String tk = ((Token) token).getToken();
             log.debug("TokenRealm - doGetAuthenticationInfo - {}", tk);
             //返回一个info,调用完该方法,会去调用Realm的matcher进行具体的密码校验,因此会到自定义的TokenMatcher类去执行方法,将token跟info传递给TokenMatcher
             return new SimpleAuthenticationInfo(tk, tk, getName());
         }
     }
    
  2. doGetAuthenticationInfo方法执行完,会调用当前Realm()的matcher(TokenMatcher)对象的doCredentialsMatch方法进行实际的认证

     @Slf4j
     public class TokenMatcher implements CredentialsMatcher {
         /*自定义实现,客户端传递过来的值与数据库的值进行匹配的算法*/
         @Override
         public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
             log.debug("TokenMatcher - doCredentialsMatch - {} {}", token, info);
             //这里就是个摆设,目的就是直接告诉密码没有问题,这样才会进入下一个环节,加载权限信息
             return true;
         }
     }
    
    1. 该类中并没有去做实际的认证功能,直接放行,这一切都是为了到授权流程doGetAuthorizationInfo
    2. 因为真正的认证:密码验证在登录的impl中已经校验

授权功能

  1. 前面一切的目的都是为了到达这个doGetAuthorizationInfo方法,拿到用户传递过来的token
    1. TokenFilter校验token,拿到token调用login方法传递,该方法会触发TokenRealm的认证方法doGetAuthenticationInfo
    2. doGetAuthenticationInfo将token包装成AuthenticationInfo,然后直接通过TokenMatcher用户名密码认证放行,将AuthenticationInfo的principals传递给了doGetAuthorizationInfo
    3. 最终token传递到获取权限信息方法doGetAuthorizationInfo
    4. 该方法的作用:就是通过传递过来的token,来查当前用户是否有相关的权限信息
  2. doGetAuthorizationInfo测试代码

     @Override
     protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
         // 拿到当前登录用户的token
         String token = (String) principals.getPrimaryPrincipal();
         log.debug("TokenRealm - doGetAuthorizationInfo - {}", token);
         SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
         //进来的所有用户都有这个权限
         info.addStringPermission("sysUser:list");
         return info;
     }
    
    1. 上面代表,所有的用户都有sysUser:list这个权限
  3. 在SysUserController的list方法,添加权限限制注解

     //分页查询方法
     @GetMapping
     //只有用户有sysUser:list权限,才会继续进入该接口
     @RequiresPermissions("sysUser:list")
     public PageJsonVo<SysUserVo> list(SysUserPageReqVo reqVo) {
         return JsonVos.ok(service.list(reqVo));
     }
    
  4. 在SysRoleController的list方法,添加权限限制注解

     //分页查询方法
     @GetMapping
     //只有用户有sysRole:list权限,才会继续进入该接口
     @RequiresPermissions("sysRole:list")
     public PageJsonVo<SysRoleVo> list(SysRolePageReqVo reqVo) {
         return JsonVos.ok(service.list(reqVo));
     }
    
  5. 此时运行后台项目、前端项目,在前端登录页面验证码显示错误,报错404,原因是如果添加了@RequiresPermissions注解,就会出现这样bug
    1. 解决办法,在ShiroCfg中添加
     /**
      * 解决:@RequiresPermissions导致控制器接口404
      */
     @Bean
     public DefaultAdvisorAutoProxyCreator proxyCreator() {
         DefaultAdvisorAutoProxyCreator proxyCreator = new DefaultAdvisorAutoProxyCreator();
         proxyCreator.setUsePrefix(true);
         return proxyCreator;
     }
    
  6. 再次运行项目,在前端页面登录成功后
    1. 点击系统-》用户,正常显示,说明有权限
    2. 点击 系统-》角色,弹框提示:没有相关权限,说明该用户没有该菜单权限
  7. 注意: @RequiresPermissions注解的本质就是到doGetAuthorizationInfo中去查找当前用户是否有相关权限

权限控制

  1. 将所有的权限设置字符串常量放在Constants中

     public static class Permisson {
         public static final String SYS_USER_LIST = "sysUser:list";
         public static final String SYS_USER_ADD = "sysUser:add";
         public static final String SYS_USER_UPDATE = "sysUser:update";
         public static final String SYS_USER_REMOVE = "sysUser:remove";
    
         public static final String SYS_ROLE_LIST = "sysRole:list";
         public static final String SYS_ROLE_ADD = "sysRole:add";
         public static final String SYS_ROLE_UPDATE = "sysRole:update";
         public static final String SYS_ROLE_REMOVE = "sysRole:remove";
    
         public static final String SYS_RESOURCE_LIST = "sysResource:list";
         public static final String SYS_RESOURCE_ADD = "sysResource:add";
         public static final String SYS_RESOURCE_UPDATE = "sysResource:update";
         public static final String SYS_RESOURCE_REMOVE = "sysResource:remove";
    
         public static final String DICT_ITEM_LIST = "dictItem:list";
         public static final String DICT_ITEM_ADD = "dictItem:add";
         public static final String DICT_ITEM_UPDATE = "dictItem:update";
         public static final String DICT_ITEM_REMOVE = "dictItem:remove";
    
         public static final String DICT_TYPE_LIST = "dictType:list";
         public static final String DICT_TYPE_ADD = "dictType:add";
         public static final String DICT_TYPE_UPDATE = "dictType:update";
         public static final String DICT_TYPE_REMOVE = "dictType:remove";
    
         public static final String EXAM_PLACE_LIST = "examPlace:list";
         public static final String EXAM_PLACE_ADD = "examPlace:add";
         public static final String EXAM_PLACE_UPDATE = "examPlace:update";
         public static final String EXAM_PLACE_REMOVE = "examPlace:remove";
    
         public static final String EXAM_PLACE_COURSE_LIST = "examPlaceCourse:list";
         public static final String EXAM_PLACE_COURSE_ADD = "examPlaceCourse:add";
         public static final String EXAM_PLACE_COURSE_UPDATE = "examPlaceCourse:update";
         public static final String EXAM_PLACE_COURSE_REMOVE = "examPlaceCourse:remove";
    
         public static final String PROVINCE_LIST = "province:list";
         public static final String PROVINCE_ADD = "province:add";
         public static final String PROVINCE_UPDATE = "province:update";
         public static final String PROVINCE_REMOVE = "province:remove";
    
         public static final String CITY_LIST = "city:list";
         public static final String CITY_ADD = "city:add";
         public static final String CITY_UPDATE = "city:update";
         public static final String CITY_REMOVE = "city:remove";
     }
    
  2. controller添加权限,给所有的controller添加注解权限
    1. SysUserController

       //只有用户有sysUser:list权限,才会继续进入该接口
       @RequiresPermissions(Constants.Permisson.SYS_USER_LIST)
       public PageJsonVo<SysUserVo> list(SysUserPageReqVo reqVo){}
              
       //两个权限必须同时拥有logical = Logical.AND
       @RequiresPermissions(value = {
               Constants.Permisson.SYS_USER_UPDATE,
               Constants.Permisson.SYS_USER_ADD
       },logical = Logical.AND)
       public JsonVo save(@Valid SysUserReqVo reqVo) {}
              
       //重写父类的remove
       @RequiresPermissions(Constants.Permisson.SYS_USER_REMOVE)
       public JsonVo remove(String id) 
      
    2. 其他控制器的api依次添加
    3. 注解分析:
      1. @RequiresPermissions:设置资源权限
      2. @RequiresRoles("总经理"):设置角色权限
  3. TokenRealm中doGetAuthorizationInfo添加权限查询代码

     @Autowired
     private SysRoleService roleService;
     @Autowired
     private SysResourceService resourceService;
     @Override
     protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
         // 拿到当前登录用户的token
         String token = (String) principals.getPrimaryPrincipal();
         log.debug("TokenRealm - doGetAuthorizationInfo - {}", token);
    
         /*在controller中添加的权限注解RequiresPermissions,本质就是到info中去查找*/
         SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
         //测试:给所有用户都添加这两个权限
         //info.addStringPermission("sysUser:list");
         //info.addStringPermission("sysRole:list");
    
         //1. 根据token查找用户信息
         SysUser user = Caches.getToken(token);
         //2. 根据用户id获取用户的所有角色pos,sys_role、sys_user_role
         List<SysRole> roles = roleService.listByUserId(user.getId());
         if (CollectionUtils.isEmpty(roles)) return info;
         //2.1 将角色添加进info
         for (SysRole role : roles) {
             info.addRole(role.getName());
         }
         //3. 根据所有的角色ids去获取所有的资源
         //  List<Short> roleIds = Streams.map(roles,(role)->role.getId());
         List<Short> roleIds = Streams.map(roles, SysRole::getId);
         List<SysResource> resources = resourceService.listByRoleIds(roleIds);
         if (CollectionUtils.isEmpty(resources)) return info;
         //3.1 将用户所有的资源添加进info
         for (SysResource resource : resources) {
             info.addStringPermission(resource.getPermission());
         }
         return info;
     }
    
  4. service层封装
    1. SysRoleService添加根据用户id查询用户所有角色

       //SysRoleService
       List<SysRole> listByUserId(Integer userId);
       //SysRoleServiceImpl
       /*根据用户id查询该用户所有角色pos*/
       @Override
       @Transactional(readOnly = true)
       public List<SysRole> listByUserId(Integer userId) {
           if (userId == null || userId <= 0) return null;
           //1. 根据用户id获取所有的角色资源ids
           List<Short> ids = listIds(userId);
           if (CollectionUtils.isEmpty(ids)) return null;
           //2. 根据角色ids,查询所有对应的角色po
           MpLambdaQueryWrapper<SysRole> wrapper = new MpLambdaQueryWrapper<>();
           //wrapper.in,只查询ids数组中对应的数据
           wrapper.in(SysRole::getId, ids);
           //这种查询会有注入风险
           //String sql = "SELECT role_id FROM sys_user_role WHERE user_id = " + userId;
           //wrapper.inSql(SysRole::getId, sql);
           return  baseMapper.selectList(wrapper);
       }
      
    2. SysResourceService添加根据角色ids查询所有的资源

       //SysResourceService
       List<SysResource> listByRoleIds(List<Short> roleIds);
              
       //SysResourceServiceImpl
       /*根据角色ids查询所有的资源ids*/
       private List<Short> listIds(List<Short> roleIds) {
           MpLambdaQueryWrapper<SysRoleResource> wrapper = new MpLambdaQueryWrapper<>();
           wrapper.select(SysRoleResource::getResourceId);
           //in: 只查询roleids数组中对应的数据
           wrapper.in(SysRoleResource::getRoleId, roleIds);
          
           List<Object> ids = roleResourceMapper.selectObjs(wrapper);
           //将Integer转化为short,同时去掉重复的资源,因为不同的角色有相同的资源,因此使用HashSet存储,保持唯一性
           return Streams.map(new HashSet<>(ids), (id) -> ((Integer) id).shortValue());
       }
          
       /*根据角色ids,查询所有的资源pos*/
       @Override
       @Transactional(readOnly = true)
       public List<SysResource> listByRoleIds(List<Short> roleIds) {
           if (CollectionUtils.isEmpty(roleIds)) return null;
          
           //1. 根据roleids拿到所有resourcesids
           List<Short> ids = listIds(roleIds);
           if (CollectionUtils.isEmpty(ids)) return null;
          
           //2. 根据resourcesids获取所有的resources
           MpLambdaQueryWrapper<SysResource> wrapper = new MpLambdaQueryWrapper<>();
           wrapper.in(SysResource::getId, ids);
           return baseMapper.selectList(wrapper);
       }
      
  5. 运行项目,登录各种不同用户权限的账号查看情况

性能改造

  1. 性能问题:上面的代码是调用每个API都会去通过TokenRealm的doGetAuthorizationInfo方法内部去查询数据库,是否有对应权限,当用户量大时比较消耗资源
  2. 改造方法:
    1. 在用户登录的时候到数据库去获取用户的角色信息、资源信息,然后缓存起来
    2. 在TokenRealm使用缓存,避免每次访问api都要从数据库获取,提高性能
  3. 步骤
    1. 新建SysUserDto,用于存储用户信息、用户角色信息、用户资源信息

       //com.zh.jk.pojo.dto
       @Data
       public class SysUserDto {
           /*用户信息*/
           private SysUser user;
           /*用户的角色信息*/
           private List<SysRole> roles;
           /*用户的资源信息*/
           private List<SysResource> resources;
       }
      
    2. SysUserServiceImpl在登录时缓存用户、角色、资源信息

       @Autowired
       private SysRoleService roleService;
       @Autowired
       private SysResourceService resourceService;
              
       @Override
       public LoginVo login(LoginReqVo reqVo) {
           //根据用户名查询用户
           MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>();
           wrapper.eq(SysUser::getUsername,reqVo.getUsername());
           SysUser po = baseMapper.selectOne(wrapper);
           if (po == null) { //用户名不存在
               return JsonVos.raise(CodeMsg.WRONG_USERNAME);
           }
           //密码不正确
           if (!po.getPassword().equals(reqVo.getPassword())){
               return JsonVos.raise(CodeMsg.WRONG_PASSWORD);
           }
           //账号锁定
           if(po.getStatus() == Constants.SysUserStatus.LOCKED){
               return JsonVos.raise(CodeMsg.USER_LOCKED);
           }
           //更新登录时间
           po.setLoginTime(new Date());
           baseMapper.updateById(po);
          
           //生成token,发送token给用户
           String token = UUID.randomUUID().toString();
          
          
           /*************缓存用户权限信息********************/
           //登录时将用户的权限信息获取,然后缓存起来,后序调用接口时,从缓存中获取,提升性能
           SysUserDto dto = new SysUserDto();
           dto.setUser(po);
          
           // 1. 根据用户id查询所有的角色:sys_role,sys_user_role
           List<SysRole> roles = roleService.listByUserId(po.getId());
          
           // 2. 根据角色id查询所有的资源:sys_resource、sys_role_resource
           if (!CollectionUtils.isEmpty(roles)) {
               dto.setRoles(roles);
               List<Short> roleIds = Streams.map(roles, SysRole::getId);
               List<SysResource> resources = resourceService.listByRoleIds(roleIds);
               dto.setResources(resources);
           }
           //存储token到缓存中,使用EhCache封装的类
           Caches.putToken(token, dto);
          
           // 返回给客户端的具体数据
           LoginVo vo = MapStructs.INSTANCE.po2loginVo(po);
           vo.setToken(token);
          
           return vo;
       }
      
    3. TokenRealm的doGetAuthorizationInfo中,直接从缓存中获取

       @Override
       protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
           // 拿到当前登录用户的token
           String token = (String) principals.getPrimaryPrincipal();
           log.debug("TokenRealm - doGetAuthorizationInfo - {}", token);
          
           /*在controller中添加的权限注解RequiresPermissions,本质就是到info中去查找*/
           SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
           // 1. 根据token查找SysUserDto,缓存的用户、资源、权限信息
           SysUserDto user = Caches.getToken(token);
           //2. 到缓存中根据用户id获取用户的所有角色pos
           List<SysRole> roles = user.getRoles();
           if (CollectionUtils.isEmpty(roles)) return info;
           //2.1 将角色添加进info
           for (SysRole role : roles) {
               info.addRole(role.getName());
           }
           //3. 到缓存中根据所有的角色ids去获取所有的资源
           List<SysResource> resources = user.getResources();
           if (CollectionUtils.isEmpty(resources)) return info;
           //3.1 将用户所有的资源添加进info
           for (SysResource resource : resources) {
               info.addStringPermission(resource.getPermission());
           }
           return info;
       }
      

权限更新及时性问题

  1. 上面的方法解决了每次查询权限的性能问题,但是带来了另外一个问题,即时性问题
  2. 如果给A用户添加、修改、删除 了一个角色、权限资源,那么是对A用户没有任何影响的,因为缓存中并没有将A用户的信息更新
  3. 解决办法:
    1. 如果修改(增加、删除、修改)了某个用户角色、资源,需要从缓存中将相应的角色、资源进行操作,同时将token失效,这样别的用户必须重新登录
    2. 需要在缓存中存储:key值为用户id,value为token的数据
    3. 当操作某个用户时,可以根据用户id获取对应的token,然后将token缓存删除
  4. 实现步骤
    1. ehcache.xml中给token这个缓存添加监听器,用来监听缓存的增加、删除、修改

       <listeners>
           <listener>
               <!--监听器类:里面添加需要监听的事件,操作内容-->
               <class>com.zh.jk.common.cache.TokenCacheListener</class>
               <!-- 触发监听时:使用 异步回调 -->
               <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
               <!-- 触发监听时:不用按顺序处理事件 -->
               <event-ordering-mode>UNORDERED</event-ordering-mode>
               <!-- 哪些操作会触发监听器:添加、过期、删除 -->
               <events-to-fire-on>CREATED</events-to-fire-on>
               <events-to-fire-on>EXPIRED</events-to-fire-on>
               <events-to-fire-on>REMOVED</events-to-fire-on>
           </listener>
       </listeners>
      
    2. 创建监听器类TokenCacheListener

       //com.zh.jk.common.cache
       //必须实现CacheEventListener
       public class TokenCacheListener implements CacheEventListener<Object, Object> {
           //监听token缓存
           @Override
           public void onEvent(CacheEvent cacheEvent) {
               //1. 拿到key即token值。登录方法中:Caches.putToken(token, dto);
               String token = (String) cacheEvent.getKey();
               switch (cacheEvent.getType()) {
                   case CREATED: {// 监听:缓存有数据添加(说明有一个用户刚登录)
                       // 2. 根据token获取到value即dto
                       SysUserDto user = (SysUserDto) cacheEvent.getNewValue();
                       // 3. 添加以用户id为key,token为value的缓存,以便将来通过用户id找到他对应的token
                       Caches.put(user.getUser().getId(), token);
                       break;
                   }
              
                   case REMOVED:
                   case EXPIRED: { //监听缓存有清除: token被移除或者过期了
                       SysUserDto user = (SysUserDto) cacheEvent.getOldValue();
                       //删除通过用户id获取token的缓存:用户id作为key,token作为value
                       Caches.remove(user.getUser().getId());
                       break;
                   }
              
                   default:
                       break;
               }
           }
       }
      
      1. 疑问:在Caches缓存的监听方法中使用put、remove,难道不会造成死循环吗?—不会
      2. 原因是:ehcache.xml文件中的配置监听,是添加在了“token”这个别名的配置中,即Caches类中的TOKEN_CACHE缓存中,即只监听TOKEN_CACHE这个缓存的增删改
      3. 监听方法中的put、remove 使用的是Caches中的DEFAULT_CACHE缓存,因此并不会触发监听事件,因此不会造成死循环
    3. 改造修改、添加、删除用户角色的方法: SysUserServiceImpl中saveOrUpdate

       //编辑/添加
       Integer id = reqVo.getId();
       if (id != null && id > 0) { // 如果是做更新
      
           // 将更新成功的用户从缓存中移除(让token失效,用户必须重新登录)
           //根据用户id获取token,然后将token从缓存中删除:token作为key,pto作为value
           Caches.removeToken(Caches.get(id));
           ....
       }
       //保存角色信息
       ...
      
    4. 改造修改、添加、删除角色资源信息的方法: SysRoleServiceImpl中saveOrUpdate

       //2. 更新角色资源表sys_role_resource
       Short id = reqVo.getId();
       if (id != null && id > 0) {
           MpLambdaQueryWrapper<SysUserRole> wrapper = new MpLambdaQueryWrapper<>();
           wrapper.select(SysUserRole::getUserId);
           wrapper.eq(SysUserRole::getRoleId, id);
           //查询出这个角色对应的所有用户userIds
           List<Object> userIds = userRoleMapper.selectObjs(wrapper);
           if (!CollectionUtils.isEmpty(userIds)) {
               for (Object userId : userIds) {
                   // 将拥有这个角色的用户从缓存中移除(让token失效,用户必须重新登录)
                   Caches.removeToken(Caches.get(userId));
               }
           }
           ...
       }
      
       // 保存资源信息
       ...
      
  5. 测试:重新运行服务器;用两个浏览器一个登录admin,一个登录vvv;然后用admin修改vvv的角色或者资源信息,则VVV的浏览器点击其他页面会提示”token失效,重新登录“

退出登录

  1. 为什么token通常存放请求头?因为token不是业务参数,跟业务无关
  2. SysUserController控制器添加logout方法

     @PostMapping("logout")
     //RequestHeader,请求头必须传递一个token,否则就会报错
     public JsonVo logout(@RequestHeader("Token") String token){
         //清除token
         Caches.removeToken(token);
         return JsonVos.ok();
     }
    
Table of Contents