前后端分离项目springBoot +shiro权限控制+redis

本次使用shiro主要实现以下几个功能

1.用户没有登录无法访问任何为权限控制的接口

2.用户登录后只能访问该用户所拥有的权限,实现了对后端接口颗粒级的权限控制

3.两个用户登录同一个账号时,后登录的用户会将先登录的用户挤掉

一.数据库设计

用户表 user 角色表role 用户角色中间表user_role

资源权限表resource 角色关联权限表role_resource

用户表,角色表,资源权限表都是多对多的关系,在中间表中均通过id关联

资源权限表中url字段配置对应的接口路径,用户拥有的角色有对应的资源路径便可以访问对应的接口权限.

二.shiro核心

1.Shiro四大核心功能:

**Authentication:**身份认证/登录,验证用户是不是拥有相应的身份;

**Authorization:**授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做情, 常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;

**Cryptography:**加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;

**Session Manager:**会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中; 会话可以是普通JavaSE环境的,也可以是如Web环境的;

2.Shiro三个核心组件

**Subject:**主体,代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager;可以把Subject认为是一个门面;SecurityManager才是实际的执行者;

**SecurityManager:**安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有Subject;可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互,如果学习过SpringMVC,你可以把它看成DispatcherServlet前端控制器;

**Realm:**域,Shiro从从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从Realm获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm得到用户相应的角色/权限进行验证用户是否能进行操作;可以把Realm看成DataSource,即安全数据源。

三.springboot整合使用shiro

首先要写一个shiro的配置类,初始化shiro中的bean

shiro的核心配置类:

/**
 * Shiro 配置类
 * shiro的核心配置类 shiro的所有初始化bean都在这个类中操作
 */
@Configuration
@SuppressWarnings("ALL")
public class ShiroConfig {
    @Autowired(required = false)
    private IResourcesService resourcesService;

    /**
     * 配置文件
     */
    @Autowired
    private ShiroProperties shiroProperties;
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * shiro的拦截器
     *
     * @param securityManager
     * @return
     **/
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 设置 securityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 登录的 url
        shiroFilterFactoryBean.setLoginUrl(shiroProperties.getLoginUrl());
        // 登录成功后跳转的 url
        shiroFilterFactoryBean.setSuccessUrl(shiroProperties.getSuccessUrl());
        // 未授权 url,如果配置了,则跳转该页面,没有配置则向前端响应401状态码,前后端分离则不配置
//       shiroFilterFactoryBean.setUnauthorizedUrl(shiroProperties.getUnauthorizedUrl());
        //自定义拦截器
        Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
        //限制同一帐号同时在线的个数。
        filtersMap.put("authc", new MyAuthenticationFilter());
        filtersMap.put("kickout", kickoutSessionControlFilter());
        shiroFilterFactoryBean.setFilters(filtersMap);

        // 这里配置授权链,跟mvc的xml配置一样
        LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        // 设置免认证 url
        String[] anonUrls = StringUtils.split(shiroProperties.getAnonUrl(), ',');
        for (String url : anonUrls) {
            filterChainDefinitionMap.put(url, "anon");
        }
        // 配置退出过滤器
//        filterChainDefinitionMap.put(shiroProperties.getLogoutUrl(), "logout");
        // 除上以外所有 url都必须认证通过才可以访问,未通过认证自动访问 LoginUrl
        List<Resources> resourcesList = resourcesService.queryAll();
        for (Resources resources : resourcesList) {
            String url = resources.getUrl();
            if (StringUtil.isNotEmpty(url)) {
                String[] splitUrls = url.split(",");
                for (String splitUrl : splitUrls) {
                    String permission = "perms[" + splitUrl + "]";

                    filterChainDefinitionMap.put(splitUrl, "kickout,authc," + permission);

                }
            }
        }
        filterChainDefinitionMap.put("/**", "kickout,authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

    /**
     * 配置各种manager,跟xml的配置很像,但是,这里有一个细节,就是各个set的次序不能乱
     *
     * @param realm
     * @return
     * @author
     **/
    @Bean
    @DependsOn({"myShiroRealm"})
    public SecurityManager securityManager(@Qualifier("sessionManager") DefaultWebSessionManager sessionManager) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 配置 缓存管理类 cacheManager,这个cacheManager必须要在前面执行,因为setRealm 和 setSessionManage都有方法初始化了cachemanager,看下源码就知道了
        securityManager.setCacheManager(cacheManager());
        // 配置 SecurityManager,并注入 shiroRealm 这个跟springmvc集成很像,不多说了
        securityManager.setRealm(myShiroRealm());
        // 配置 sessionManager
        securityManager.setSessionManager(sessionManager);
        return securityManager;
    }

    /**
     * 生成一个ShiroRedisCacheManager 这没啥好说的
     *
     * @param template
     * @return
     * @author
     **/
    private ShiroRedisCacheManager cacheManager() {
        return new ShiroRedisCacheManager(redisTemplate);
    }


    /**
     * 这是我自己的realm 我自定义了一个密码解析器,这个比较简单,稍微跟一下源码就知道这玩意
     *
     * @param matcher
     * @param userService
     * @return
     */
    @Bean("myShiroRealm")
    @DependsOn({"hashedCredentialsMatcher"})
    public MyShiroRealm myShiroRealm() {
        MyShiroRealm myShiroRealm = new MyShiroRealm();
        myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
        return myShiroRealm;
    }


    /**
     * 密码解析器 有好几种,这里是MD5 2次加密
     *
     * @return
     */
    @Bean("hashedCredentialsMatcher")
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
        HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();

        hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
        hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));

        return hashedCredentialsMatcher;
    }

    /**
     * session 管理对象
     *
     * @return DefaultWebSessionManager
     */
    @Bean("sessionManager")
    public DefaultWebSessionManager sessionManager() {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        // 设置session超时时间,单位为毫秒
        sessionManager.setGlobalSessionTimeout(shiroProperties.getSessionTimeout());
        sessionManager.setSessionIdCookie(new SimpleCookie(shiroProperties.getSessionIdName()));
        // 网上各种说要自定义sessionDAO 其实完全不必要,shiro自己就自定义了一个,可以直接使用,还有其他的DAO,自行查看源码即可
//        sessionManager.setSessionDAO(new EnterpriseCacheSessionDAO());
        sessionManager.setSessionDAO(new MemorySessionDAO());
        return sessionManager;
    }

    /**
     * 限制同一账号登录同时登录人数控制
     *
     * @return
     */
    public KickoutSessionControlFilter kickoutSessionControlFilter() {
        KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
        //使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
        //这里我们还是用之前shiro使用的redisManager()实现的cacheManager()缓存管理
        //也可以重新另写一个,重新配置缓存时间之类的自定义缓存属性
        kickoutSessionControlFilter.setCacheManager(cacheManager());
        //用于根据会话ID,获取会话进行踢出操作的;
        kickoutSessionControlFilter.setSessionManager(sessionManager());
        //是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序。
        kickoutSessionControlFilter.setKickoutAfter(false);
        //同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
        kickoutSessionControlFilter.setMaxSession(1);
        //被踢出后重定向到的地址;
        kickoutSessionControlFilter.setKickoutUrl("/kickout");
        return kickoutSessionControlFilter;
    }
}

配置完后,要使用shiro的权限认证,需要自己写一个Realm继承AuthorizingRealm,实现shiro的省份认证,以及授权。如下:

public class MyShiroRealm extends AuthorizingRealm {    
    @Autowired    
    private IUserService userService;   
    @Autowired    
    private IUserRoleOrganizationService userRoleOrganizationService;    
    @Resource    
    private IResourcesService resourcesService;    
    //授权    
    @Override    
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection 					principalCollection) {        
        Integer roleId = (Integer) SecurityUtils.getSubject().getPrincipal();        			List<Resources> resourcesList = resourcesService.loadRoleResources(roleId);        	 		// 权限信息对象info,用来存放查出的用户的所有的角色(role)及权限(permission);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();       
        for (Resources resources : resourcesList) {            											info.addStringPermission(resources.getUrl());       
                                                  }        
        return info;    
    }    
    //认证    
    @Override    
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {       
        //获取用户的输入的账号.        
        String username = (String) token.getPrincipal();        
        if (username == null) {   
            return null;       
        }        
        User user = userService.selectByUsername(username);        
        if (user == null) {           
            throw new UnknownAccountException();       
        }        
        if (DatabaseConstant.DATA_UNAVAILABLE == user.getStatus()) {
            // 帐号锁定   
            throw new LockedAccountException();      
         }       
        // 查询当前用户的所有角色列表        
        List<UserRoleOrganization> userRoleOrganizationList = 									userRoleOrganizationService.selectUserRoleOrganizationList(user.getId());        if (userRoleOrganizationList == null || userRoleOrganizationList.size() == 0) {            throw new DisabledAccountException();        
        }        
        UserRoleOrganization userRoleOrganization = userRoleOrganizationList.get(0);        	SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
          // 此处我存储的是角色id,将每个角色对应的权限存起来,此处存的是什么,通过							subject.getPrincipal() 获取的就是什么
        userRoleOrganization.getRoleId(), //用户                
        user.getPassword(), //密码               
        ByteSource.Util.bytes(username),                
        getName()  //realm name        
        );        
        return authenticationInfo;    
    }
}

如上,有了shiro的核心配置,以及重写的Realm后,便能够使用shiro的权限控制了。但是在前后端分离的项目中,我们一般都需要自己控制登录和登出的逻辑。这时候就要用到shiro中的subject.login(token)和subject.logout()方法了,这样便可以在登录登出中写自己需要的逻辑控制了。

@PostMapping("/login")
    public Result login(HttpServletRequest request, HttpServletResponse response, @RequestBody User user) {
        if (StringUtils.isEmpty(user.getUsername()) || StringUtils.isEmpty(user.getPassword())) {
            logger.info("登录失败,用户名或密码为空!");
            return ResultUtil.failed(ErrorCode.THE_USERNAME_OR_PASSWORD_IS_NULL);
        }
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
        try {
            subject.login(token);
            logger.info("登录成功!");
            Session session = SecurityUtils.getSubject().getSession();
            session.setAttribute("kickout", false);
            User cacheUser = userService.selectByUsername(user.getUsername());
            cacheUser.setPassword("");
            List<UserRoleOrganization> userRoleOrganizationList = userRoleOrganizationService.selectUserRoleOrganizationList(cacheUser.getId());
            session.setAttribute("user", cacheUser);
            session.setAttribute("userRoleOrganization", userRoleOrganizationList.get(0));
            OperationLogUtil.addLog(OperationType.LOGIN, "login", "success");
            return ResultUtil.success();
        } catch (LockedAccountException e) {
            token.clear();
            //用户已经被锁定(被逻辑删除的用户)
            logger.info("该账户已经被锁定");
            return ResultUtil.failed(ErrorCode.THE_USER_IS_LOCKED);
        } catch (DisabledAccountException e) {
            token.clear();
            //用户已经被锁定(被逻辑删除的用户)
            logger.info("该账户还未被激活");
            return ResultUtil.failed(ErrorCode.THE_USER_IS_NOT_ACTIVE);
        } catch (AuthenticationException e) {
            token.clear();
            //用户名或密码不正确
            logger.info("用户名或密码不正确");
            return ResultUtil.failed(ErrorCode.THE_USER_INVALID);
        }
    }

    @GetMapping("/logout")
    public Result logout() {
        Subject subject = SecurityUtils.getSubject();
        User user = (User) subject.getSession().getAttribute("user");
        UserRoleOrganization userRoleOrganization = (UserRoleOrganization) subject.getSession().getAttribute("userRoleOrganization");
        Integer orgId;
        if (userRoleOrganization == null) {
            orgId = null;
        } else {
            orgId = userRoleOrganization.getOrganizationId();
        }
        subject.logout();
        if (user != null) {
            // 如果当前用户处于登录状态
            logger.info("登出成功");
            OperationLogUtil.logoutLog(OperationType.LOGOUT, "logout", "success", user, orgId);
        }
        return ResultUtil.success();
    }

四.使用redis缓存权限信息

首先需要自己写一个shiro的缓存管理器继承自AbstractCacheManager,使用redis来缓存其中的权限信息。

public class ShiroRedisCacheManager extends AbstractCacheManager {

    private RedisTemplate<byte[], byte[]> redisTemplate;

    public ShiroRedisCacheManager(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    //为了个性化配置redis存储时的key,我们选择了加前缀的方式,所以写了一个带名字及redis操作的构造函数的Cache类
    @Override
    protected Cache createCache(String name) throws CacheException {
        // 为了使使用cache缓存数据的前缀相同,直接没有传入name,在ShiroRedisCache中将缓存前缀写死
        return new ShiroRedisCache(redisTemplate);
    }
}

自己要实现shiroRediscache中操作redis的逻辑,具体如下:

public class ShiroRedisCache<K, V> implements Cache<K, V> {
    private static Logger logger = LoggerFactory.getLogger(ShiroRedisCache.class);
    private RedisTemplate redisTemplate;
    private String prefix = "shiro_redis_cache:";

    public String getPrefix() {
        return prefix;
    }

    public void setPrefix(String prefix) {
        this.prefix = prefix;
    }

    public ShiroRedisCache(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public ShiroRedisCache(RedisTemplate redisTemplate, String prefix) {
        this(redisTemplate);
        this.prefix = prefix;
    }

    @Override
    public V get(K k) throws CacheException {
        if (k == null) {
            return null;
        }
        String jsonKey = getJsonString(k);
        return (V) redisTemplate.opsForValue().get(jsonKey);

    }

    @Override
    public V put(K k, V v) throws CacheException {
        if (k == null || v == null) {
            return null;
        }

        String jsonKey = getJsonString(k);
        redisTemplate.opsForValue().set(jsonKey, v);
        logger.info("put key : " + jsonKey);
        return v;
    }

    @Override
    public V remove(K k) throws CacheException {
        if (k == null) {
            return null;
        }
        String jsonKey = getJsonString(k);
        V v = (V) redisTemplate.opsForValue().get(jsonKey);
        logger.info("delete key : " + jsonKey);
        redisTemplate.delete(jsonKey);
        return v;
    }

    @Override
    public void clear() throws CacheException {
        redisTemplate.getConnectionFactory().getConnection().flushDb();

    }

    @Override
    public int size() {
        return redisTemplate.getConnectionFactory().getConnection().dbSize().intValue();
    }

    @Override
    public Set<K> keys() {
//        byte[] bytes = (getPrefix() + "*").getBytes();
        String jsonString = this.prefix + "*";
        Set<String> keys = redisTemplate.keys(jsonString);
        Set<K> sets = new HashSet<>();
        for (String key : keys) {
            sets.add((K) key);
        }
        return sets;
    }

    @Override
    public Collection<V> values() {
        Set<K> keys = keys();
        List<V> values = new ArrayList<>(keys.size());
        for (K k : keys) {
            values.add(get(k));
        }
        return values;
    }

    private String getJsonString(Object key) {
        String s = JSON.toJSONString(key);
        //这里纯粹是为了方便用可视化工具管理redis中的缓存数据
        s = s.replace(":", "|");
        return prefix + s;
    }

如此便整合使用redis中缓存的shiro重点数据。

**RememberMe功能:**本代码并没有做shiro中RememberMe的功能,原因是代码中需要做同一账号只能单用户登录的功能(即同一账号同一时间只能允许一个用户登录),而这两个功能之间有冲突。在下一篇博客实现同一账号只能单用户登录的功能时会详细的解释。有兴趣的可以看下一篇博客。

小结:

**1.**shiro有自己的一套登录逻辑,如果访问登录接口时,前端传过来的是表单形式的username和password, shiro可以自己去去生成token进行登录校验,最终走的逻辑还是MyShiroRealm类中doGetAuthenticationInfo 方法中的代码,也能实现登录功能,但是当登录出差时就无法返回特定的错误信息给前端,所以一般都是自己实现登录逻辑,因此在前后端分离中无需配置shiro中的loginUrl,只需将后端的登录方法对应的url放开(即配置为anno)

**2.**由于是前后端分离,所以在用户没有认证或者没有对应的资源权限时,不应该直接跳转页面,而应该返回json信息给前端。
未认证时:shiro中只直接跳转到登录页面的(在shiro的认证过滤器FormAuthenticationFilter中的 onAccessDenied方法中可以看到),因此需要重写此过滤器,避免直接跳转页面
未授权时:shiro会在PermissionsAuthorizationFilter的isAccessAllowed中进行权限校验,如果没有权 限,会返回false,再执行AuthorizationFilter的onAccessDenied中的逻辑,onAccessDenied方法中会先校验用户是否登录,没有登录则会跳转到登录页面,登录了则会判断开发者是否配置了unauthorizedUrl,如果配置了,则跳转该页面,没有配置则向前端响应401状态码,因此为了避免直接跳转页面,不应配置unauthorizedUrl,这样shiro便可以直接响应401给前端(这样就不需要再次重写过滤器)
(一般情况用户没有登录是不会有被权限控制的接口访问的,不会跳转到shiro配置的登录页面,而当用户登录后如果长时间不操作,session会失效,这时可能会出现没有登录信息,访问到被权限控制的接口,直接跳转页面,所以需要被权限控制的接口,也需要配置authc,即认证过滤器)