1. 程式人生 > >SpringBoot整合Shiro,許可權的動態載入、更新,Shiro-Redis實現分散式Session共享

SpringBoot整合Shiro,許可權的動態載入、更新,Shiro-Redis實現分散式Session共享

本文章是介紹SpringBoot整合Apache Shiro,並實現在專案啟動時從資料庫中讀取許可權列表,在對角色進行增刪改時,動態更新許可權以及在分散式環境下的Session共享,Session共享使用的是shiro-redis框架,是根據真實專案寫的一個Demo。網上有很多關於Shiro相關的文章,但是大多都是零零散散的,要麼就只介紹上述功能中的一兩個功能,要麼就是缺少配置相關的內容。所以,我整理了一下,給大家一個參考的。廢話不多說,直接上程式碼。關於Shiro相關的概念,大家可以在網上自行百度。

一、使用到的相關的表

CREATE TABLE `t_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `username` varchar(32) DEFAULT '' COMMENT '使用者名稱',
  `password` varchar(255) DEFAULT '' COMMENT '密碼',
  `role_id` int(11) DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;

CREATE TABLE `t_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `role_name` varchar(64) DEFAULT '' COMMENT '角色名稱',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;

CREATE TABLE `t_authority` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `authority_name` varchar(64) DEFAULT '' COMMENT '許可權名稱',
  `icon` varchar(255) DEFAULT '' COMMENT '圖示',
  `uri` varchar(255) DEFAULT '' COMMENT '請求uri',
  `permission` varchar(1000) DEFAULT '',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;

CREATE TABLE `t_role_authority` (
  `role_id` int(11) NOT NULL DEFAULT '0' COMMENT '角色id',
  `authority_id` int(11) NOT NULL DEFAULT '0' COMMENT '許可權id',
  PRIMARY KEY (`role_id`,`authority_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

二、初始化資料

INSERT INTO `spring-boot-shiro`.`t_user` (`id`, `username`, `password`, `role_id`) VALUES ('1', 'admin', 'e10adc3949ba59abbe56e057f20f883e', '1');
INSERT INTO `spring-boot-shiro`.`t_user` (`id`, `username`, `password`, `role_id`) VALUES ('2', 'guest', 'e10adc3949ba59abbe56e057f20f883e', '2');

INSERT INTO `spring-boot-shiro`.`t_role` (`id`, `role_name`) VALUES ('1', 'admin');
INSERT INTO `spring-boot-shiro`.`t_role` (`id`, `role_name`) VALUES ('2', '普通使用者');

INSERT INTO `spring-boot-shiro`.`t_authority` (`id`, `authority_name`, `icon`, `uri`, `permission`) VALUES ('1', '查詢使用者列表', '', '/user/list', 'roles[admin,普通使用者]');
INSERT INTO `spring-boot-shiro`.`t_authority` (`id`, `authority_name`, `icon`, `uri`, `permission`) VALUES ('2', '查詢角色列表', '', '/role/list', 'roles[admin]');

INSERT INTO `spring-boot-shiro`.`t_role_authority` (`role_id`, `authority_id`) VALUES ('1', '1');
INSERT INTO `spring-boot-shiro`.`t_role_authority` (`role_id`, `authority_id`) VALUES ('1', '2');
INSERT INTO `spring-boot-shiro`.`t_role_authority` (`role_id`, `authority_id`) VALUES ('2', '1');

三、pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>spring-boot-shiro</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>spring-boot-shiro</name>
    <description>Demo project for Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.3.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.2</version>
        </dependency>

        <!-- shiro -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-core</artifactId>
            <version>1.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis</artifactId>
            <version>3.0.0</version>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- druid -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.9</version>
        </dependency>

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

四、Shiro和自定義MessageConverter的配置Bean

@Configuration
public class ShiroConfig {

    private static final String CACHE_KEY = "shiro:cache:";
    private static final String SESSION_KEY = "shiro:session:";
    private static final String NAME = "custom.name";
    private static final String VALUE = "/";

    @Bean
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, ShiroService shiroService) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);
        Map<String, Filter> filterMap = new LinkedHashMap<>(1);
        filterMap.put("roles", rolesAuthorizationFilter());
        shiroFilter.setFilters(filterMap);
        shiroFilter.setFilterChainDefinitionMap(shiroService.loadFilterChainDefinitions());
        return shiroFilter;
    }

    @Bean
    public CustomRolesAuthorizationFilter rolesAuthorizationFilter() {
        return new CustomRolesAuthorizationFilter();
    }

    @Bean("securityManager")
    public SecurityManager securityManager(Realm realm, SessionManager sessionManager, RedisCacheManager redisCacheManager) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setSessionManager(sessionManager);
        manager.setCacheManager(redisCacheManager);
        manager.setRealm(realm);
        return manager;
    }


    @Bean("defaultAdvisorAutoProxyCreator")
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        //指定強制使用cglib為action建立代理物件
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean("lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean("delegatingFilterProxy")
    public FilterRegistrationBean delegatingFilterProxy(){
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        DelegatingFilterProxy proxy = new DelegatingFilterProxy();
        proxy.setTargetFilterLifecycle(true);
        proxy.setTargetBeanName("shiroFilter");
        filterRegistrationBean.setFilter(proxy);
        return filterRegistrationBean;
    }

    /**
     * Redis叢集使用RedisClusterManager,單個Redis使用RedisManager
     * @param redisProperties
     * @return
     */
    @Bean
    public RedisClusterManager redisManager(RedisProperties redisProperties) {
        RedisClusterManager redisManager = new RedisClusterManager();
        redisManager.setHost(redisProperties.getHost());
        redisManager.setPassword(redisProperties.getPassword());
        return redisManager;
    }

    @Bean
    public RedisCacheManager redisCacheManager(RedisClusterManager redisManager) {
        RedisCacheManager redisCacheManager = new RedisCacheManager();
        redisCacheManager.setRedisManager(redisManager);
        redisCacheManager.setExpire(86400);
        redisCacheManager.setKeyPrefix(CACHE_KEY);
        return redisCacheManager;
    }

    @Bean
    public RedisSessionDAO redisSessionDAO(RedisClusterManager redisManager) {
        RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
        redisSessionDAO.setExpire(86400);
        redisSessionDAO.setKeyPrefix(SESSION_KEY);
        redisSessionDAO.setRedisManager(redisManager);
        return redisSessionDAO;
    }

    @Bean
    public DefaultWebSessionManager sessionManager(RedisSessionDAO sessionDAO, SimpleCookie simpleCookie) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionDAO(sessionDAO);
        sessionManager.setSessionIdCookieEnabled(true);
        sessionManager.setSessionIdCookie(simpleCookie);
        return sessionManager;
    }

    @Bean
    public SimpleCookie simpleCookie() {
        SimpleCookie simpleCookie = new SimpleCookie();
        simpleCookie.setName(NAME);
        simpleCookie.setValue(VALUE);
        return simpleCookie;
    }

    @Bean
    public Realm realm(RedisCacheManager redisCacheManager) {
        PasswordRealm realm = new PasswordRealm();
        realm.setCacheManager(redisCacheManager);
        realm.setAuthenticationCachingEnabled(false);
        realm.setAuthorizationCachingEnabled(false);
        return realm;
    }
}

@Configuration
public class MessageConverterConfig {
    @Bean
    public HttpMessageConverters fastJsonHttpMessageConverter() {
        //定義一個轉換訊息的物件
        FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
        //新增fastjson的配置資訊 比如 :是否要格式化返回的json資料
        FastJsonConfig config = new FastJsonConfig();
        config.setSerializerFeatures(SerializerFeature.PrettyFormat);
        //在轉換器中新增配置資訊
        converter.setFastJsonConfig(config);
        return new HttpMessageConverters(converter);
    }
}

五、自定義的Realm

public class PasswordRealm extends AuthorizingRealm {

    @Autowired
    private RoleMapper roleMapper;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private AuthorityMapper authorityMapper;

    /**
     * 授權查詢回撥函式, 進行鑑權但快取中無使用者的授權資訊時呼叫,負責在應用程式中決定使用者的訪問控制的方法
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        User user = (User) principalCollection.getPrimaryPrincipal();
        System.out.println(user.getUsername() + "進行授權操作");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        Integer roleId = user.getRoleId();
        Role role = roleMapper.findRoleById(roleId);
        info.addRole(role.getRoleName());
        List<Authority> authorities = authorityMapper.findAuthoritiesByRoleId(roleId);
        if (authorities.size() == 0) {
            return null;
        }
        return info;
    }

    /**
     * 認證回撥函式,登入資訊和使用者驗證資訊驗證
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //toke強轉
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        String username = usernamePasswordToken.getUsername();
        //根據使用者名稱查詢密碼,由安全管理器負責對比查詢出的資料庫中的密碼和頁面輸入的密碼是否一致
        User user = userMapper.findUserByUserName(username);
        if (user == null) {
            return null;
        }

        //單使用者登入
        //處理session
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
        DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();
        //獲取當前已登入的使用者session列表
        Collection<Session> sessions = sessionManager.getSessionDAO().getActiveSessions();
        User temp;
        for(Session session : sessions){
            //清除該使用者以前登入時儲存的session,強制退出
            Object attribute = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
            if (attribute == null) {
                continue;
            }

            temp = (User) ((SimplePrincipalCollection) attribute).getPrimaryPrincipal();
            if(username.equals(temp.getUsername())) {
                sessionManager.getSessionDAO().delete(session);
            }
        }

        String password = user.getPassword();
        //最後的比對需要交給安全管理器,三個引數進行初步的簡單認證資訊物件的包裝,由安全管理器進行包裝執行
        return new SimpleAuthenticationInfo(user, password, getName());
    }
}

六、自定義的角色過濾器

public class CustomRolesAuthorizationFilter extends RolesAuthorizationFilter {
    @Override
    public boolean isAccessAllowed(ServletRequest req, ServletResponse resp, Object mappedValue) {
        Subject subject = getSubject(req, resp);
        String[] rolesArray = (String[]) mappedValue;
        //如果沒有角色限制,直接放行
        if (rolesArray == null || rolesArray.length == 0) {
            return true;
        }
        for (int i = 0; i < rolesArray.length; i++) {
            //若當前使用者是rolesArray中的任何一個,則有許可權訪問
            if (subject.hasRole(rolesArray[i])) {
                return true;
            }
        }

        return false;
    }

    @Override
    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        HttpServletRequest servletRequest = (HttpServletRequest) request;
        HttpServletResponse servletResponse = (HttpServletResponse) response;
        //處理跨域問題,跨域的請求首先會發一個options型別的請求
        if (servletRequest.getMethod().equals(HttpMethod.OPTIONS.name())) {
            return true;
        }
        boolean isAccess = isAccessAllowed(request, response, mappedValue);
        if (isAccess) {
            return true;
        }
        servletResponse.setCharacterEncoding("UTF-8");
        Subject subject = getSubject(request, response);
        PrintWriter printWriter = servletResponse.getWriter();
        servletResponse.setContentType("application/json;charset=UTF-8");
        servletResponse.setHeader("Access-Control-Allow-Origin", servletRequest.getHeader("Origin"));
        servletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        servletResponse.setHeader("Vary", "Origin");
        String respStr;
        if (subject.getPrincipal() == null) {
            respStr = JSONObject.toJSONString(new BaseResponse<>(300, "您還未登入,請先登入"));
        } else {
            respStr = JSONObject.toJSONString(new BaseResponse<>(403, "您沒有此許可權,請聯絡管理員"));
        }
        printWriter.write(respStr);
        printWriter.flush();
        servletResponse.setHeader("content-Length", respStr.getBytes().length + "");
        return false;
    }
}

七、ShiroService

@Service("shiroService")
public class ShiroServiceImpl implements ShiroService {

    @Autowired
    private AuthorityMapper authorityMapper;

    /**
     * 初始化許可權
     */
    @Override
    public Map<String, String> loadFilterChainDefinitions() {
        List<Authority> authorities = authorityMapper.findAuthorities();
        // 許可權控制map.從資料庫獲取
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
        if (authorities.size() > 0) {
            String uris;
            String[] uriArr;
            for (Authority authority : authorities) {
                if (StringUtils.isEmpty(authority.getPermission())) {
                    continue;
                }
                uris = authority.getUri();
                uriArr = uris.split(",");
                for (String uri : uriArr) {
                    filterChainDefinitionMap.put(uri, authority.getPermission());
                }
            }
        }
        filterChainDefinitionMap.put("/user/login", "anon");
        //配置退出 過濾器,其中的具體的退出程式碼Shiro已經替我們實現了
        filterChainDefinitionMap.put("/user/logout", "anon");
        filterChainDefinitionMap.put("/**", "authc");
        return filterChainDefinitionMap;
    }

    /**
     * 在對角色進行增刪改操作時,需要呼叫此方法進行動態重新整理
     * @param shiroFilterFactoryBean
     */
    @Override
    public void updatePermission(ShiroFilterFactoryBean shiroFilterFactoryBean) {
        synchronized (this) {
            AbstractShiroFilter shiroFilter;
            try {
                shiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean.getObject();
            } catch (Exception e) {
                throw new RuntimeException("get ShiroFilter from shiroFilterFactoryBean error!");
            }

            PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) shiroFilter.getFilterChainResolver();
            DefaultFilterChainManager manager = (DefaultFilterChainManager) filterChainResolver.getFilterChainManager();

            // 清空老的許可權控制
            manager.getFilterChains().clear();

            shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
            shiroFilterFactoryBean.setFilterChainDefinitionMap(loadFilterChainDefinitions());
            // 重新構建生成
            Map<String, String> chains = shiroFilterFactoryBean.getFilterChainDefinitionMap();
            for (Map.Entry<String, String> entry : chains.entrySet()) {
                String url = entry.getKey();
                String chainDefinition = entry.getValue().trim()
                        .replace(" ", "");
                manager.createChain(url, chainDefinition);
            }
        }
    }
}

其他的Service、Mapper等檔案就不貼出來了。

用guest使用者登入,呼叫/user/list可以查詢到資料,但是呼叫/role/list則會提示無許可權,如下圖:


使用者登入和認證後都會在Redis中儲存相應的資料:


同時在控制檯只打印了一次xxx進行授權操作:


需要注意的是,退出登入時需要呼叫Subject.logout()方法,該方法會自動刪除redis中的session和cache快取。

使用shiro-redis做Session共享後,跟蹤原始碼發現在修改角色名稱後AuthorizationInfo中的角色名稱依然是修改之前的。所以就需要使用者退出後重登才會更新認證資訊。

============================華麗的分割線===========================

為了解決上述問題,我想了兩天的時間。一開始思維進入了誤區,一心的想通過RedisCache和RedisCacheManager來刪除授權相關的資訊。RedisCache提供了remove(key)方法來刪除快取,但是由於本人能力有限,實在沒看明白Redis中的key是怎麼拿到Realm類的全限定名,然後拼湊出來的。後來想到當呼叫subject.logout()方法會刪除cache和session,於是跟蹤原始碼,發現是下圖紅框中的方法刪除cache的:


因此,我寫了下面的工具類,來刪除cache和session:

public class ShiroUtil {

    private static RedisSessionDAO redisSessionDAO = SpringUtil.getBean(RedisSessionDAO.class);

    private ShiroUtil() {
    }

    /**
     * 獲取指定使用者名稱的Session
     * @param username
     * @return
     */
    private static Session getSessionByUsername(String username){
        Collection<Session> sessions = redisSessionDAO.getActiveSessions();
        User user;
        Object attribute;
        for(Session session : sessions){
            attribute = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
            if (attribute == null) {
                continue;
            }
            user = (User) ((SimplePrincipalCollection) attribute).getPrimaryPrincipal();
            if (user == null) {
                continue;
            }
            if (Objects.equals(user.getUsername(), username)) {
                return session;
            }
        }
        return null;
    }

    /**
     * 刪除使用者快取資訊
     * @param username 使用者名稱
     * @param isRemoveSession 是否刪除session,刪除後用戶需重新登入
     */
    public static void kickOutUser(String username, boolean isRemoveSession){
        Session session = getSessionByUsername(username);
        if (session == null) {
            return;
        }

        Object attribute = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
        if (attribute == null) {
            return;
        }

        User user = (User) ((SimplePrincipalCollection) attribute).getPrimaryPrincipal();
        if (!username.equals(user.getUsername())) {
            return;
        }

        //刪除session
        if (isRemoveSession) {
            redisSessionDAO.delete(session);
        }
        DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
        Authenticator authc = securityManager.getAuthenticator();
        //刪除cache,在訪問受限介面時會重新授權
        ((LogoutAware) authc).onLogout((SimplePrincipalCollection) attribute);
    }

}

在修改角色時,呼叫ShiroUtil.kickOutUser(username, isRemoveSession)方法就可以刪除session和cache了。刪除session主要是在禁用使用者或角色時,強制使用者退出的。如果僅僅修改角色資訊是不需要刪除session的,只需要刪除cache,使使用者在訪問受限介面時重新授權即可。

附上原始碼下載地址,錯誤之處請各位大神多多指正。