Spring Boot 整合 Shiro

时间:2024-03-23 17:16:13

Spring Boot 整合 Shiro

目录:
简介
一、Shiro基础配置
二、记住我
三、密码输入次数限制
四、session共享
五、同一个用户多设备登录(可实现单点登录)
六、全局异常统一处理
七、整合后可能遇到的问题
八、鸣谢和声明

简介:
Shiro是一个强大易用的Java安全框架,提供了认证、授权、加密和会话管理等功能。
Spring Boot 整合 Shiro
Authentication:身份认证/登录,验证用户是不是拥有相应的身份;

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

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

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

Web Support:Web支持,可以非常容易的集成到Web环境;

Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率;

Concurrency:shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;

Testing:提供测试支持;

Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;

Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。

记住一点,Shiro不会去维护用户、维护权限;这些需要我们自己去设计/提供;然后通过相应的接口注入给Shiro即可。

从应用程序角度观察Shiro如何工作:
Spring Boot 整合 Shiro
可以看到:应用代码直接交互的对象是Subject,也就是说Shiro的对外API核心就是Subject;其每个API的含义:

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

SecurityManager:安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有Subject;可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互,作用类似于SpringMVC中的DispatcherServlet,用于拦截所有请求并进行处理;

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

一、Shiro基础配置

1、导入 maven 依赖

    <!--shiro依赖-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
        </dependency>

2、创建 Shiro 配置类

创建 securityManager,之后要在里面添加自定义的ream;
创建 shiroFilterFactoryBean 设置请求的拦截方式,只做鉴权,不做权限控制,因为权限用注解来做,之后要在里面添加自定义的过滤器,常用的Filter如下:

Filter 解释
anon 无参,开放权限,可以理解为匿名用户或游客
authc 无参,需要认证
logout 无参,注销,执行后会直接跳转到shiroFilterFactoryBean.setLoginUrl(); 设置的 url
user 无参,表示必须存在用户,当登入操作时不做检查,记住我可以访问
@Configuration
public class ShiroConfig {
	@Bean
	public DefaultWebSecurityManager securityManager() {
		DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
		return securityManager;
	}

    @Bean 
	public ShiroFilterFactoryBean shiroFilterFactoryBean() {
		//shiroFilterFactoryBean对象
		ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
		// 配置shiro安全管理器 SecurityManager
		shiroFilterFactoryBean.setSecurityManager(securityManager());

		// 指定要求登录时的链接
		shiroFilterFactoryBean.setLoginUrl("/system/login");
		// 登录成功后要跳转的链接
		shiroFilterFactoryBean.setSuccessUrl("/system/index");
		// 未授权时跳转的界面;
		shiroFilterFactoryBean.setUnauthorizedUrl("/system/login");
		// filterChainDefinitions拦截器=map必须用:LinkedHashMap,因为它必须保证有序
		Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();

		filterChainDefinitionMap.put("/system/login", "anon");

		filterChainDefinitionMap.put("/static/**", "anon");
		filterChainDefinitionMap.put("/**", "user");
		shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
		return shiroFilterFactoryBean;
	}
}

3、创建自定义Ream,需要继承AuthorizingRealm并覆写doGetAuthorizationInfo()doGetAuthenticationInfo()方法,前者用于获取当前登录用户的角色和权限,后者用于登录时的身份校验。

我们先看 doGetAuthenticationInfo()方法:

	@Override
	protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {

		String userName = (String)authenticationToken.getPrincipal();
		User user = userService.selectByLoginCode(userName);
		if(user == null){
			throw new AuthenticationException("用户名密码错误");
		}
		SimpleAuthenticationInfo ai = new SimpleAuthenticationInfo(user, user.getPassword(),ByteSource.Util.bytes(userName + user.getSalt()),user.getUserName());
		return ai;
	}

这里只是返回了用户信息(用户名、密码、加盐),实现校验的地方是在AuthorizingRealm中的 CredentialsMatcher,这里我们在ShiroConfig配置类中进行了自定义配置:

	/**
	 * 凭证匹配器 (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
	 * 所以我们需要修改下doGetAuthenticationInfo中的代码; @return
	 */
	@Bean
	public HashedCredentialsMatcher hashedCredentialsMatcher() {
		HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(shiroEhCacheManager());
		// 散列算法:这里使用MD5算法;
		hashedCredentialsMatcher.setHashAlgorithmName("md5");
		// 散列的次数,比如散列两次,相当于md5(md5(""));
		hashedCredentialsMatcher.setHashIterations(2);
		//表示是否存储散列后的密码为16进制,需要和生成密码时的一样,默认是base64;
		hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
		return hashedCredentialsMatcher;
	}

别忘了把此bean注入到 CustomerRealm 中,并把 CustomerRealm设置到securityManager中,ShiroConfig配置如下:

	@Bean
	public CustomerRealm realm(){
		CustomerRealm  customerRealm = new CustomerRealm();
		customerRealm.setCredentialsMatcher(hashedCredentialsMatcher());
		return customerRealm;
	}
	
		@Bean
	public DefaultWebSecurityManager securityManager() {
		DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
		// 设置realm.
		securityManager.setRealm(realm());
		return securityManager;
	}

这里的配置直接关系到用户密码的创建方式,加入创建用户新密码是123456,则代码如下:

      //新增初始化密码为:123456
		String algorithmName = "md5";
		//获取用户名
		String loginCode = user.getLoginCode();
		String password = "123456";

		String salt1 = loginCode;
		String salt2 = new SecureRandomNumberGenerator().nextBytes().toHex();
		//散列的次数
		int hashIterations = 2;

		SimpleHash hash = new SimpleHash(algorithmName, password, salt1 + salt2, hashIterations);

hash.toHex()是加密后的密码,存入数据库中,salt2 加的盐也要存入数据库中,用来验证身份。
在用户登录的代码段中可以这样书写:

Subject subject = SecurityUtils.getSubject();
    try {
		  subject.login(new UsernamePasswordToken(realUserName, realPassword,realRememberMe));
		}catch (Exception e){
		  return AjaxResult.fail("用户名或密码错误!");
		}

校验失败会抛出异常,这里进行捕获,并返回错误信息,如果没有抛异常则说明登录成功。
退出登录我们可以这样书写(如果session做了自定义缓存处理,退出别忘了清除):

SecurityUtils.getSubject().logout();

我们再看doGetAuthorizationInfo()方法

	/**
	 * 当用户访问资源时需要鉴权,这里提供鉴权用的基础数据
	 * 提供当前登录用户的角色和权限
	 * @param principalCollection
	 * @return
	 */
	@Override
	protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
		SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();

		// 获取当前登陆用户
		Subject subject = SecurityUtils.getSubject();
		User user = (User) subject.getPrincipal();
		if("corpAdmin".equals(user.getId())){
			// 超级管理员,添加所有角色、添加所有权限
			simpleAuthorizationInfo.addRole("*");
			simpleAuthorizationInfo.addStringPermission("*");
		}else {
			List<UserRole> userRoles = userRoleService.queryListByUserCode(user.getId());
			if(userRoles != null && userRoles.size() != 0){
				for(UserRole userRole : userRoles){
					simpleAuthorizationInfo.addRole(userRole.getRole().getRoleCode());
					List<Menu> menus = menuRoleService.queryPermissionMenuByRoleID(userRole.getRoleCode());
					if(menus != null && menus.size() != 0){
						for(Menu menu : menus){
							simpleAuthorizationInfo.addStringPermission(menu.getPermission());
						}
					}
				}
			}
		}

		return simpleAuthorizationInfo;
	}

当用户登录成功后,会自动调用此方法获取该用户的角色和权限,这里简单介绍两者的概念:
1、权限,即操作资源的权利,通过权限我们可以表示在应用中用户有没有操作某个资源的权力。即权限表示在应用中用户能不能访问某个资源,比如访问某个页面,以及对某个模块的数据的添加,修改,删除,查看的权利;
可以看出,权限代表了用户有没有操作某个资源的权利,即反映在某个资源上的操作允不允许,不反映谁去执行这个操作。所以后续还需要把权限赋予给用户,即定义哪个用户允许在某个资源上做什么操作(权限),Shiro不会去做这件事情,而是由实现人员提供。
Shiro支持粗粒度权限(如用户模块的所有权限)和细粒度权限(操作某个用户的权限,即实例级别的),
2、角色,是权限的集合,一中角色可以包含多种权限;
角色代表了操作集合,可以理解为权限的集合,一般情况下我们会赋予用户角色而不是权限,即这样用户可以拥有一组权限,赋予权限时比较方便。典型的如:项目经理、技术总监、CTO、开发工程师等都是角色,不同的角色拥有一组不同的权限。
隐式角色:即直接通过角色来验证用户有没有操作权限,如在应用中CTO、技术总监、开发工程师可以使用打印机,假设某天不允许开发工程师使用打印机,此时需要从应用中删除相应代码;再如在应用中CTO、技术总监可以查看用户、查看权限;突然有一天不允许技术总监查看用户、查看权限了,需要在相关代码中把技术总监角色从判断逻辑中删除掉;即粒度是以角色为单位进行访问控制的,粒度较粗;如果进行修改可能造成多处代码修改。
显示角色:在程序中通过权限控制谁能访问某个资源,角色聚合一组权限集合;这样假设哪个角色不能访问某个资源,只需要从角色代表的权限集合中移除即可;无须修改多处代码;即粒度是以资源/实例为单位的;粒度较细。

基于权限和角色的访问控制网上有很多教程,可以参考这里

二、记住我

记住我功能在各个网站是比较常见的,实现起来也都差不多,主要就是利用cookie来实现,而shiro对记住我功能的实现也是比较简单的,只需要几步即可。
Shiro提供了记住我(RememberMe)的功能,比如访问一些网站时,关闭了浏览器下次再打开时还是能记住你是谁,下次访问时无需再登录即可访问,基本流程如下:

  1. 首先在登录页面选中RememberMe然后登录成功;如果是浏览器登录,一般会把RememberMe的Cookie写到客户端并保存下来;
  2. 关闭浏览器再重新打开;会发现浏览器还是记住你的;
  3. 访问一般的网页服务器端还是知道你是谁,且能正常访问;

下面开始介绍配置流程:

1、ShiroConfig 中配置

	/**
	 * 设置记住我cookie过期时间
	 * @return
	 */
	@Bean
	public SimpleCookie remeberMeCookie(){
		//cookie名称;对应前端的checkbox的name = rememberMe
		SimpleCookie scookie=new SimpleCookie("rememberMe");
		scookie.setHttpOnly(true);
		//记住我cookie生效时间,单位秒
		scookie.setMaxAge(Integer.valueOf(Global.get("session.rememberMeTimeOut")));
		return scookie;
	}
	
	/**
	 * 配置cookie记住我管理器
	 * @return
	 */
	@Bean
	public CookieRememberMeManager rememberMeManager(){
		CookieRememberMeManager cookieRememberMeManager=new CookieRememberMeManager();
		cookieRememberMeManager.setCookie(remeberMeCookie());
		return cookieRememberMeManager;
	}
	
	@Bean
	public DefaultWebSecurityManager securityManager() {
		DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
		// 设置realm.
		securityManager.setRealm(realm());
		//记住我管理器
		securityManager.setRememberMeManager(rememberMeManager());
		return securityManager;
	}

2、登录配置

  • 在网页中添加记住我选项,并在登录后台获取记住我是否选中:
String rememberMe = WebUtils.getCleanParam(request, "rememberMe");
boolean realRememberMe = ObjectUtils.toBoolean(rememberMe);
Subject subject = SecurityUtils.getSubject();
subject.login(new UsernamePasswordToken(realUserName, realPassword,realRememberMe));
  • 配置过滤器时,选择user,如果有支付地址或重要的业务地址,请慎重配置user:
filterChainDefinitionMap.put("/**", "user");

三、密码输入次数限制

防止用户暴力**,减轻服务器压力,对密码输入次数进行限制,是个不错的方法。
上文中提到 Shiro 进行身份的校验是在 HashedCredentialsMatcher 类中,具体是在里面的 doCredentialsMatch()中,可以覆写这个方法来实现我们的目的:

1、创建 RetryLimitHashedCredentialsMatcher 类并覆写 doCredentialsMatch 方法

/**
 * shiro之密码输入次数限制5次,并锁定2分钟
 **/
public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {

	//集群中可能会导致出现验证多过5次的现象,因为AtomicInteger只能保证单节点并发
	//解决方案,利用ehcache、redis(记录错误次数)和mysql数据库(锁定)的方式处理:密码输错次数限制; 或两者结合使用
	private Cache<String, AtomicInteger> passwordRetryCache;

	public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {
		//读取ehcache中配置的登录限制锁定时间
		passwordRetryCache = cacheManager.getCache("passwordRetryCache");
	}

	/**
	 * 在回调方法doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info)中进行身份认证的密码匹配,
	 * </br>这里我们引入了Ehcahe用于保存用户登录次数,如果登录失败retryCount变量则会一直累加,如果登录成功,那么这个count就会从缓存中移除,
	 * </br>从而实现了如果登录次数超出指定的值就锁定。
	 * @param token
	 * @param info
	 * @return
	 */
	@Override
	public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {

		ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
		HttpServletRequest request = requestAttributes.getRequest();
		String remoteAddr = request.getRemoteAddr();

		//从ehcache中获取密码输错次数
		// retryCount
		AtomicInteger retryCount = passwordRetryCache.get(remoteAddr);
		if (retryCount == null) {
			//第一次
			retryCount = new AtomicInteger(0);
			passwordRetryCache.put(remoteAddr, retryCount);
		}
		//retryCount.incrementAndGet()自增:count + 1
		if (retryCount.incrementAndGet() > 5) {
			// if retry count > 5 throw  超过5次 锁定
			throw new ExcessiveAttemptsException("IP:"+remoteAddr+" tried to login more than 5 times in period");
		}
		//否则走判断密码逻辑
		boolean matches = super.doCredentialsMatch(token, info);
		if (matches) {
			// clear retry count  清楚ehcache中的count次数缓存
			passwordRetryCache.remove(remoteAddr);
		}
		return matches;
	}
}

本示例中 Shirocache 交给 ehcache 来管理,您可以用redis或者其它持久化方式去处理。
ehcache 的配置文件中,关于 passwordRetryCache 缓存键值是这样配置的:

    <!-- 登录记录缓存 锁定2分钟 -->
    <cache name="passwordRetryCache"
           maxEntriesLocalHeap="10000"
           eternal="false"
           timeToIdleSeconds="120"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="false">
    </cache>

利用缓存的有效期限来控制用户的锁定分钟数。
然后需要在ShiroConfig中替换原有的 HashedCredentialsMatcher

	/**
	 * 凭证匹配器 (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
	 * 所以我们需要修改下doGetAuthenticationInfo中的代码; @return
	 */
	@Bean
	public HashedCredentialsMatcher hashedCredentialsMatcher() {
		HashedCredentialsMatcher hashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher(shiroEhCacheManager());
		// 散列算法:这里使用MD5算法;
		hashedCredentialsMatcher.setHashAlgorithmName("md5");
		// 散列的次数,比如散列两次,相当于md5(md5(""));
		hashedCredentialsMatcher.setHashIterations(2);
		//表示是否存储散列后的密码为16进制,需要和生成密码时的一样,默认是base64;
		hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
		return hashedCredentialsMatcher;
	}

在登录代码段中可以捕获密码锁定异常ExcessiveAttemptsException

    try {
			subject.login(new UsernamePasswordToken(realUserName, realPassword,realRememberMe));
		}catch (Exception e){
			String errorMessage;
			if(e instanceof ExcessiveAttemptsException){
				errorMessage = "密码输错超过5次,您暂时被锁定,请稍后再试!";
			}else {
				errorMessage = "用户名或密码错误!";

			}
			return AjaxResult.fail(errorMessage);
		}

四、session共享

不管是使用负载均衡还是多个系统需要用户登录状态一致,都需要进行session共享,shiro有两种方式可以实现session共享,一种是session的共享,一个是cache共享,参考文档点击这里,文后有示例代码可以借鉴。
本文介绍集成 ehCache 实现 shirocache共享。
话说在前面:
ehCache 还是适合在单机状态下的简单使用,虽然多机提供了组播机制,很不稳定,且目前没有成熟的框架组件进行监控,需要自己去开发监控组件,因此大家要综合各方面的考虑进行选型。

1、导入 maven 依赖

        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-ehcache</artifactId>
        </dependency>

2、配置ShiroConfig

	/**
	 * EhCache管理器
	 * @return
	 */
	@Bean
	public EhCacheManagerFactoryBean ehCacheManager(){
		EhCacheManagerFactoryBean factoryBean = new EhCacheManagerFactoryBean();
		factoryBean.setConfigLocation(new ClassPathResource("config/ehcache-local.xml"));
		return factoryBean;
	}
	
	/**
	 * ehcache缓存管理器;shiro整合ehcache:
	 * 通过安全管理器:securityManager
	 * 单例的cache防止热部署重启失败
	 * @return EhCacheManager
	 */
	@Bean(name = "shiroEhCacheManager")
	public EhCacheManager shiroEhCacheManager() {
		EhCacheManager ehcache = new EhCacheManager();
		ehcache.setCacheManager(ehCacheManager().getObject());
		return ehcache;
	}
	
	/**
	 * EnterpriseCacheSessionDAO shiro sessionDao层的实现;
	 * 提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
	 */
	@Bean
	public EnterpriseCacheSessionDAO enterCacheSessionDAO() {
		EnterpriseCacheSessionDAO enterCacheSessionDAO = new EnterpriseCacheSessionDAO();
		//添加缓存管理器
		//enterCacheSessionDAO.setCacheManager(ehCacheManager());
		//添加ehcache活跃缓存名称(必须和ehcache缓存名称一致)
		enterCacheSessionDAO.setActiveSessionsCacheName("shiro-activeSessionCache");
		return enterCacheSessionDAO;
	}
	
	@Bean(name = "sessionValidationScheduler")
	public ExecutorServiceSessionValidationScheduler getExecutorServiceSessionValidationScheduler() {
		ExecutorServiceSessionValidationScheduler scheduler = new ExecutorServiceSessionValidationScheduler();
		scheduler.setInterval(Long.valueOf(Global.get("session.sessionTimeoutClean")));
		return scheduler;
	}
	
	/**
	 *
	 * @描述:sessionManager添加session缓存操作DAO
	 * @return
	 */
	@Bean
	public DefaultWebSessionManager sessionManager() {
		DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
		//sessionManager.setCacheManager(ehCacheManager());
		sessionManager.setSessionDAO(enterCacheSessionDAO());
		sessionManager.setSessionIdCookieEnabled(true);
		sessionManager.setSessionIdCookie(sessionIdCookie());
		sessionManager.setGlobalSessionTimeout(Long.valueOf(Global.get("session.sessionTimeout")));
		sessionManager.setSessionValidationSchedulerEnabled(true);
		sessionManager.setSessionValidationScheduler(getExecutorServiceSessionValidationScheduler());
		sessionManager.setDeleteInvalidSessions(true);
		return sessionManager;
	}	
	
	@Bean
	public DefaultWebSecurityManager securityManager() {
		DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
		// 设置realm.
		securityManager.setRealm(realm());
		//注入缓存管理器;
		securityManager.setCacheManager(shiroEhCacheManager());
		//注入记住我管理器;
		securityManager.setRememberMeManager(rememberMeManager());
		// //注入session管理器;
		securityManager.setSessionManager(sessionManager());
		return securityManager;
	}
	
	
	/**
	 *
	 * @描述:自定义cookie中session名称等配置
	 * @创建人:wyait
	 * @创建时间:2018年5月8日 下午1:26:23
	 * @return
	 */
	@Bean
	public SimpleCookie sessionIdCookie() {
		SimpleCookie simpleCookie = new SimpleCookie();
		//如果在Cookie中设置了"HttpOnly"属性,那么通过程序(JS脚本、Applet等)将无法读取到Cookie信息,这样能有效的防止XSS攻击。
		simpleCookie.setHttpOnly(true);
		simpleCookie.setName(SysGlobal.get("session.sessionIdCookieName"));
		
		//本机多个应用session一致时,请将此值设置成一样,重要!!
		simpleCookie.setPath("/");
		return simpleCookie;
	}
		

3、配置 ehcache-local.xml

<?xml version="1.0" encoding="UTF-8"?>
<ehcache updateCheck="false" name="defaultCache">
    <!-- RMI组播方式,只能在一个网段内传输,不能跨网段 -->
    <cacheManagerPeerProviderFactory class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
                                     properties="peerDiscovery=automatic,multicastGroupAddress=230.0.0.1,multicastGroupPort=4446"/>
    <cacheManagerPeerListenerFactory class="net.sf.ehcache.distribution.RMICacheManagerPeerListenerFactory"/>

    <!--     参数说明:
            (0)diskStore: 临时缓存存放路径
            (1)name:Cache的唯一标识。
            (2)maxElementsInMemory:内存中最大缓存对象数。
            (3)eternal:Element是否永久有效,一旦设置true,timeout将不起作用。
            (4)timeToIdleSeconds:设置Element在失效前的允许闲置时间。仅当element不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
            (5)timeToLiveSeconds:设置Element在失效前允许存活时间。最大时间介于创建时间和失效时间之间。仅当element不是永久有效时使用,默认是0.,也就是element存活时间无穷大。
            (6)overflowToDisk:配置此属性,当内存中Element数量达到maxElementsInMemory时,Ehcache将会Element写到磁盘中。
            (7)maxElementsOnDisk:磁盘中最大缓存对象数,若是0表示无穷大。
            (8) memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略 去清理缓存中的内容。默认策略是LRU(最近最少使用),你也可以设置为FIFO(先进先出)或是LFU(较少使用)
            diskPersistent:是否在磁盘上持久化。指重启jvm后,数据是否有效。默认为false。

     -->

    <diskStore path="java.io.tmpdir/web"/>

    <!-- 默认缓存配置,自动失效,超过300秒未访问此缓存失效,缓存最多可以存活600秒,溢出不存储到磁盘,不持久化,增加统计。-->
    <defaultCache maxEntriesLocalHeap="1000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="false" diskPersistent="false" statistics="true">
        <cacheEventListenerFactory class="net.sf.ehcache.distribution.RMICacheReplicatorFactory"/>
    </defaultCache>
    
    <!-- 设定缓存的默认数据过期策略 -->
    <cache name="shiro"
           maxElementsInMemory="10000"
           timeToIdleSeconds="120"
           timeToLiveSeconds="120"
           maxElementsOnDisk="10000000"
           diskExpiryThreadIntervalSeconds="120"
           memoryStoreEvictionPolicy="LRU">
        <!-- <persistence strategy="localTempSwap"/>-->
    </cache>

    <!-- shiro-activeSessionCache活跃用户session缓存策略 -->
    <cache name="shiro-activeSessionCache"
           maxElementsInMemory="100000"
           eternal="true" overflowToDisk="true" diskPersistent="false" statistics="true">
    </cache>



    <!-- 登录记录缓存 锁定2分钟 -->
    <cache name="passwordRetryCache"
           maxEntriesLocalHeap="10000"
           eternal="false"
           timeToIdleSeconds="120"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="false">
    </cache>

</ehcache>

五、同一个用户多设备登录(可实现单点登录)

有时候同一个账号在不同地点登录,需要进行一些必要的限制。

1、创建 KickoutSessionFilter 过滤器并继承 AccessControlFilter,覆写isAccessAllowedonAccessDenied方法,这里设置最大会话数量为1,您可以根据情况自定义设置。

/**
 * 
 * @项目名称:wyait-manage
 * @类名称:KickoutSessionFilter
 * @类描述:自定义过滤器,进行用户访问控制
 * @创建人:wyait
 * @创建时间:2018年4月24日 下午5:18:29
 * @version:
 */
public class KickoutSessionFilter extends AccessControlFilter {

	private static final Logger logger = LoggerFactory
			.getLogger(KickoutSessionFilter.class);

	/**
	 * 踢出后到的地址
	 */
	private String kickoutUrl;
	/**
	 * // 踢出之前登录的/之后登录的用户 默认false踢出之前登录的用户
	 */
	private boolean kickoutAfter;
	/**
	 * 同一个帐号最大会话数 默认1
	 */
	private int maxSession = 1;

	private SessionManager sessionManager;

	private Cache<String, Deque<Serializable>> cache;

	public void setKickoutUrl(String kickoutUrl) {
		this.kickoutUrl = kickoutUrl;
	}

	public void setKickoutAfter(boolean kickoutAfter) {
		this.kickoutAfter = kickoutAfter;
	}

	public void setMaxSession(int maxSession) {
		this.maxSession = maxSession;
	}

	public void setSessionManager(SessionManager sessionManager) {
		this.sessionManager = sessionManager;
	}

	public static String CACHEKEY = "shiro-activeSessionCache";

	// 设置Cache的key的前缀
	public void setCacheManager(CacheManager cacheManager) {
		//必须和ehcache缓存配置中的缓存name一致
		this.cache = cacheManager.getCache(CACHEKEY);
	}

	/**
	 * 清除缓存
	 */
	public void clearCache(){
		User loginUser = UserUtils.getLoginUser();
		cache.remove(loginUser.getLoginCode());
	}



	@Override
	protected boolean isAccessAllowed(ServletRequest request,
									  ServletResponse response, Object mappedValue) throws Exception {
		return false;
	}

	@Override
	protected boolean onAccessDenied(ServletRequest request,
			ServletResponse response) throws Exception {
		Subject subject = getSubject(request, response);
		// 没有登录授权 且没有记住我
		if (!subject.isAuthenticated() && !subject.isRemembered()) {
			// 如果没有登录,直接进行之后的流程
			//判断是不是Ajax请求,异步请求,直接响应返回未登录
			if (ShiroFilterUtils.isAjax(request) ) {
				logger.debug(getClass().getName()+ "当前用户已经在其他地方登录,并且是Ajax请求!");
				HttpServletResponse res = (HttpServletResponse) response;
				//返回状态码为401
				res.setStatus(HttpStatus.UNAUTHORIZED.value());
				ShiroFilterUtils.out(res, AjaxResult.fail("您已在别处登录,请您修改密码或重新登录"));
				return false;
			}else{
				return true;
			}
		}

		Session session = subject.getSession();
		try {
			// 当前用户
			User user = (User) subject.getPrincipal();
			String username = user.getLoginCode();
			Serializable sessionId = session.getId();
			// 读取缓存用户 没有就存入
			Deque<Serializable> deque = cache.get(username);
			if (deque == null) {
				// 初始化队列
				deque = new ArrayDeque<Serializable>();
			}
			// 如果队列里没有此sessionId,且用户没有被踢出;放入队列
			if (!deque.contains(sessionId)
					&& session.getAttribute("kickout") == null) {
				// 将sessionId存入队列
				deque.push(sessionId);
				// 将用户的sessionId队列缓存
				cache.put(username, deque);
			}
			// 如果队列里的sessionId数超出最大会话数,开始踢人
			while (deque.size() > maxSession) {
				Serializable kickoutSessionId = null;
				// 是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
				// 如果踢出后者
				if (kickoutAfter) {
					kickoutSessionId = deque.removeFirst();
				} else {
					// 否则踢出前者
					kickoutSessionId = deque.removeLast();
				}
				// 踢出后再更新下缓存队列
				cache.put(username, deque);
				try {
					// 获取被踢出的sessionId的session对象
					Session kickoutSession = sessionManager
							.getSession(new DefaultSessionKey(kickoutSessionId));
					if (kickoutSession != null) {
						// 设置会话的kickout属性表示踢出了
						kickoutSession.setAttribute("kickout", true);
					}
				} catch (Exception e) {
					// ignore exception
					System.out.println(e);
				}
			}

			// 如果被踢出了,(前者或后者)直接退出,重定向到踢出后的地址
			if ((Boolean) session.getAttribute("kickout") != null
					&& (Boolean) session.getAttribute("kickout") == true) {
				// 会话被踢出了
				try {
					// 退出登录
					subject.logout();
				} catch (Exception e) { // ignore
				}
				saveRequest(request);
				// ajax请求
				// 重定向
				//WebUtils.issueRedirect(request, response, kickoutUrl);
				return isAjaxResponse(request,response);
			}
			return true;
		} catch (Exception e) { // ignore
			logger.error(
					"控制用户在线数量【lyd-admin-->KickoutSessionFilter.onAccessDenied】异常!",
					e);
			// 重启后,ajax请求,报错:java.lang.ClassCastException:
			// 处理 ajax请求
			return isAjaxResponse(request,response);
		}
	}


	private boolean isAjaxResponse(ServletRequest request,
								   ServletResponse response) throws IOException {
		// ajax请求
		/**
		 * 判断是否已经踢出
		 * 1.如果是Ajax 访问,那么给予json返回值提示。
		 * 2.如果是普通请求,直接跳转到登录页
		 */
		//判断是不是Ajax请求
		if (ShiroFilterUtils.isAjax(request) ) {
			//当前用户已经在其他地方登录,并且是Ajax请求!
			HttpServletResponse res = (HttpServletResponse) response;
			//返回状态码为401
			res.setStatus(HttpStatus.UNAUTHORIZED.value());
			ShiroFilterUtils.out(res, AjaxResult.fail("您已在别处登录,请您修改密码或重新登录"));
		}else{
			// 重定向
			WebUtils.issueRedirect(request, response, kickoutUrl);
		}
		return false;
	}
}

2、配置过滤器

	/**
	 *
	 * @描述:kickoutSessionFilter同一个用户多设备登录限制
	 * @创建人:wyait
	 * @创建时间:2018年4月24日 下午8:14:28
	 * @return
	 */
	public KickoutSessionFilter kickoutSessionFilter(){
		KickoutSessionFilter kickoutSessionFilter = new KickoutSessionFilter();
		//使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
		//这里我们还是用之前shiro使用的ehcache实现的cacheManager()缓存管理
		//也可以重新另写一个,重新配置缓存时间之类的自定义缓存属性
		kickoutSessionFilter.setCacheManager(shiroEhCacheManager());
		//用于根据会话ID,获取会话进行踢出操作的;
		kickoutSessionFilter.setSessionManager(sessionManager());
		//是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序。
		kickoutSessionFilter.setKickoutAfter(ObjectUtils.toBoolean(Global.get("session.kickoutUrl")));
		//同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
		kickoutSessionFilter.setMaxSession(Integer.valueOf(Global.get("session.maxSession")));
		//被踢出后重定向到的地址;
		kickoutSessionFilter.setKickoutUrl(Global.get("session.kickoutUrl"));
		return kickoutSessionFilter;
	}
	
	
	@Bean
	public ShiroFilterFactoryBean shiroFilterFactoryBean() {
		//shiroFilterFactoryBean对象
		ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
		// 配置shiro安全管理器 SecurityManager
		shiroFilterFactoryBean.setSecurityManager(securityManager());

		// 指定要求登录时的链接
		shiroFilterFactoryBean.setLoginUrl("/system/login");
		// 登录成功后要跳转的链接
		shiroFilterFactoryBean.setSuccessUrl("/system/index");
		// 未授权时跳转的界面;
		shiroFilterFactoryBean.setUnauthorizedUrl("/system/login");

		//添加kickout认证
		HashMap<String,Filter> hashMap=new HashMap<String,Filter>();
		hashMap.put("kickout",kickoutSessionFilter());
		shiroFilterFactoryBean.setFilters(hashMap);

		// filterChainDefinitions拦截器=map必须用:LinkedHashMap,因为它必须保证有序
		Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();

		filterChainDefinitionMap.put("/system/login", "anon");

		filterChainDefinitionMap.put("/static/**", "anon");
		filterChainDefinitionMap.put("/**", "kickout,user");
		shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
		return shiroFilterFactoryBean;
	}	

3、退出清理缓存

退出登录以后,需要清除相关的缓存,代码如下:

EhCacheManager shiroEhCacheManager = SpringContextHolder.getBean("shiroEhCacheManager");
Cache<Object, Object> cache = shiroEhCacheManager.getCache(KickoutSessionFilter.CACHEKEY);
cache.remove(loginUser.getLoginCode());
SecurityUtils.getSubject().logout();

六、全局异常统一处理

我们访问页面或者通过ajax访问后台,如果出现异常,比如用户被踢出、用户登录失效等情况,都要进行妥善的处理,使交互更友善一些。需要进行如下的配置:

1、系统后台

创建GlobalExceptionHandler类,并标注@ControllerAdvice

/**
 * 全局异常处理
 **/
@ControllerAdvice
public class GlobalExceptionHandler {
	/**
	 * 错误信息的构建工具.
	 */
	@Autowired
	private ErrorInfoBuilder errorInfoBuilder;
	protected Logger logger = LoggerFactory.getLogger(this.getClass());

	@ExceptionHandler(value = Throwable.class)
	public ModelAndView defaultErrorHandler(HttpServletRequest request,HttpServletResponse response, Exception e){
		LoggerUtil.saveLogException(request,e.getMessage());
		logger.error("访问:" + request.getRequestURL() + "出现异常!!",e);
		if(e instanceof UnauthorizedException){

			String requestType = request.getHeader("X-Requested-With");
			if("XMLHttpRequest".equals(requestType)){
				writeJson(AjaxResult.fail("您暂无权限访问此资源!"),response);
				return null;
			}else{
				ErrorInfo errorInfo = errorInfoBuilder.getErrorInfo(request);
				ModelAndView modelAndView = new ModelAndView();
				modelAndView.addObject("errorInfo",errorInfo);
				modelAndView.setViewName("error/401");
				return modelAndView;
			}

		}else {
			String requestType = request.getHeader("X-Requested-With");
			if("XMLHttpRequest".equals(requestType)){
				writeJson(AjaxResult.fail("系统繁忙,请稍后再试!"),response);
				return null;
			}else{
				ErrorInfo errorInfo = errorInfoBuilder.getErrorInfo(request);
				ModelAndView modelAndView = new ModelAndView();
				modelAndView.addObject("errorInfo",errorInfo);
				modelAndView.setViewName("error/500");
				return modelAndView;
			}

		}

    }




	/**
	 * 将对象转换成JSON字符串,并响应回前台
	 * @param object
	 * @throws IOException
	 */
	public void writeJson(Object object,HttpServletResponse res) {
		try {
			String json = JsonMapper.getInstance().toJsonString(object);
			res.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
			res.getWriter().write(json);
			res.getWriter().flush();
			res.getWriter().close();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

标注@ExceptionHandler(value = Throwable.class),意味着所有的异常都会执行defaultErrorHandler方法,根据 e instanceof UnauthorizedException判断是否为权限异常,根据"XMLHttpRequest".equals(requestType)判断是否为ajax请求并分别进行处理。ErrorInfo类只是异常信息的载体,您可自定义返回的信息,在此不赘述。

2、前端配置

可以在全局的 js中进行这样的配置,用来控制所有ajax的返回结果:

;$(document).ajaxComplete(function( event, xhr, settings ) {
        if(xhr.status == 401){
            setInterval(function () {
                location = ctx + "/system/login"
            },2000);
        }
    }
);

七、整合后可能遇到的问题

1、管理shiro bean的生命周期

需要在ShiroConfig类中配置如下信息:

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

2、防止重复代理出现异常信息

需要在ShiroConfig类中配置如下信息:

	@Bean
	@DependsOn("lifecycleBeanPostProcessor")
	public static DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
		DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
		// 强制使用cglib,防止重复代理和可能引起代理出错的问题
		creator.setUsePrefix(true);
		return creator;
	}

3、使用定时方法或者异步方法去做持久化操作时,访问当前登录用户出现异常:No SecurityManager accessible to the call code

请把获取当前用户信息的代码放在异步之前。

八、鸣谢和声明

感谢无私热爱编程且乐于分享的博主们。
本文参考很多博主文章且对代码有 Ctrl+C 的行为,如果侵犯您的权益,请联系笔者进行删除。
再次感谢,并分享参考过的博客地址:

店蛋蛋 shiro实现session共享
小黑客xhk shiro redis session共享总结
学习编程shou Spring boot + shiro + redis 实现session共享(伪单点登录)
wyait springboot + shiro 权限注解、统一异常处理、请求乱码解决
ismezy shiro使用ehcache实现集群同步和session复制
SmithCruise Shiro+JWT+Spring Boot Restful简易教程
hafizzhang GitHub开源Shiro项目spring+springmvc+mybatis+shiro+redis实现tomcat集群session共享
一直问 Shiro基础知识03----shiro授权(编程式授权),Permission详解,授权流程(zz)