Spring Boot 整合 Shiro
目录:
简介
一、Shiro基础配置
二、记住我
三、密码输入次数限制
四、session共享
五、同一个用户多设备登录(可实现单点登录)
六、全局异常统一处理
七、整合后可能遇到的问题
八、鸣谢和声明
简介:
Shiro是一个强大易用的Java安全框架,提供了认证、授权、加密和会话管理等功能。
Authentication:身份认证/登录,验证用户是不是拥有相应的身份;
Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
Session Manager:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境的,也可以是如Web环境的;
Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
Web Support:Web支持,可以非常容易的集成到Web环境;
Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率;
Concurrency:shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
Testing:提供测试支持;
Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。
记住一点,Shiro不会去维护用户、维护权限;这些需要我们自己去设计/提供;然后通过相应的接口注入给Shiro即可。
从应用程序角度观察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)的功能,比如访问一些网站时,关闭了浏览器下次再打开时还是能记住你是谁,下次访问时无需再登录即可访问,基本流程如下:
- 首先在登录页面选中RememberMe然后登录成功;如果是浏览器登录,一般会把RememberMe的Cookie写到客户端并保存下来;
- 关闭浏览器再重新打开;会发现浏览器还是记住你的;
- 访问一般的网页服务器端还是知道你是谁,且能正常访问;
下面开始介绍配置流程:
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;
}
}
本示例中 Shiro
的 cache
交给 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
实现 shiro
的 cache
共享。
话说在前面: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
,覆写isAccessAllowed
、onAccessDenied
方法,这里设置最大会话数量为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)