spring boot 前后端分离整合shiro(五)整合redis并实现并发登录控制
pom文件添加依赖:
<!--redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--shiro-redis--> <dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis</artifactId> <version>2.4.2.1-RELEASE</version> </dependency>
yml添加redis的配置:
#redis spring: redis: database: 0 # Redis数据库索引(默认为0) host: 127.0.0.1 # host: 192.168.65.130 port: 6379 # password: 111111 # 连接超时时间(毫秒) timeout: 1000 jedis: pool: # 连接池最大连接数(使用负值表示没有限制) max-active: 200 # 连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1 # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0
redis的配置类:
package com.example.shiroStudy.config.redis; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; /** * @author 黄豪琦 * 日期:2019-07-05 13:27 * 说明: */ @Configuration public class RedisConfig { @Bean @SuppressWarnings("all") public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<String, Object>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); // key采用String的序列化方式 template.setKeySerializer(stringRedisSerializer); // hash的key也采用String的序列化方式 template.setHashKeySerializer(stringRedisSerializer); // value序列化方式采用jackson template.setValueSerializer(jackson2JsonRedisSerializer); // hash的value序列化方式采用jackson template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template; } }
一个简单的redis增删改查工具类:
package com.example.shiroStudy.config.redis; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.util.CollectionUtils; import java.util.List; /** * @author 黄豪琦 * 日期:2019-07-05 13:28 * 说明: */ public class RedisUtils { @Autowired private RedisTemplate<String, Object> redisTemplate; //添加 获取 /** * 获取普通缓存 * @param key * @return */ public Object get(String key){ return key == null ? null : redisTemplate.opsForValue().get(key); } public boolean set(String key, Object obj){ try { redisTemplate.opsForValue().set(key, obj); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 添加普通缓存 * @param key * @param value * @param time * @return */ public boolean set(String key, Object value, long time){ try { if(time > 0){ redisTemplate.opsForValue().set(key, value, time); return true; } else { redisTemplate.opsForValue().set(key, value); return true; } } catch (Exception e) { e.printStackTrace(); return false; } } /** * 获取某个list中所有的值 * @param key * @return */ public List<Object> getList(String key){ try { return redisTemplate.opsForList().range(key, 0, -1); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 存一个list * @param key 键 * @param value 值 * @return false失败 */ public boolean setList(String key, Object value){ try { redisTemplate.opsForList().rightPush(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 判断key是否存在 * @param key 键 * @return true 存在 false不存在 */ public boolean hasKey(String key) { try { return redisTemplate.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 删除缓存 * @param key 可以传一个值 或多个 */ @SuppressWarnings("unchecked") public void del(String... key) { if (key != null && key.length > 0) { if (key.length == 1) { redisTemplate.delete(key[0]); } else { redisTemplate.delete(CollectionUtils.arrayToList(key)); } } } }
在shiroconfig中添加bean:
/** * redisManager */ @Bean public RedisManager redisManager(){ RedisManager redisManager = new RedisManager(); //设置过期时间 redisManager.setExpire(2000); return redisManager; } /** * 配置具体catch实现类 * @return * @return */ @Bean public RedisCacheManager redisCacheManager(){ RedisCacheManager redisCacheManager = new RedisCacheManager(); redisCacheManager.setRedisManager(redisManager()); return redisCacheManager; } /** * redis操作 * @return */ @Bean public RedisUtils redisUtils(){ RedisUtils redisUtils = new RedisUtils(); return redisUtils; }
session持久化
package com.example.shiroStudy.config.shiro; import org.apache.shiro.web.servlet.ShiroHttpServletRequest; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.util.WebUtils; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import java.io.Serializable; /** * @author 黄豪琦 * 日期:2019-07-03 11:16 * 说明: 自定义session管理 */ public class CustomSessionManager extends DefaultWebSessionManager { private static final String TOKEN = "token"; public CustomSessionManager() { super(); } @Override protected Serializable getSessionId(ServletRequest request, ServletResponse response) { String sessionId = WebUtils.toHttp(request).getHeader(TOKEN); if(null != sessionId){ request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "cookie"); //让shiro去判断sessionId是否过期 是否存在 request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId); //标记sessionId是有效的,如果是false则会抛异常 request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE); return sessionId; } else { return super.getSessionId(request, response); } } }
说明
RedisManager 和 RedisCacheManager 都是crazycake包下面的,这个是大犇写的工具,会用就行。RedisManager可以使用
redisManager.setHost(String host);和
redisManager.setPort(String port);来设置ip地址和端口号,如果不设置的话默认就是本地的6379端口:
测试
启动项目,登录一下
使用rdm工具查看缓存中的内容:
可以看到shiro已经帮我们把用户信息存到redis中了。
编写并发登录控制类:
KicoutSessionControlFilter:
import com.alibaba.fastjson.JSONObject; import com.example.shiroStudy.config.redis.RedisUtils; import com.example.shiroStudy.entity.JsonData; import com.example.shiroStudy.entity.Users; import org.apache.shiro.SecurityUtils; import org.apache.shiro.session.Session; import org.apache.shiro.session.SessionException; import org.apache.shiro.session.mgt.DefaultSessionKey; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.AccessControlFilter; import org.apache.shiro.web.util.WebUtils; //import org.crazycake.shiro.RedisManager; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.io.Serializable; import java.util.Deque; import java.util.LinkedList; /** * @author 黄豪琦 * 日期:2019-07-04 15:34 * 说明:自定义并发登录拦截器 */ public class KicoutSessionControlFilter extends AccessControlFilter { /** * 被踢出后重定向的地址 后台接口路径 */ private String kicOutUrl; /** * 最大登录人数 默认为1 */ private int maxNum = 1; /** * 踢出前者还是后者 为true踢出后者 默认踢出前者 */ private boolean kicoutAfter = false; private SessionManager sessionManager; private RedisUtils redisUtils; private static final String DEFAULT_KICKOUT_CACHE_KEY_PREFIX = "shiro:cache:kickout:"; private String keyPrefix = DEFAULT_KICKOUT_CACHE_KEY_PREFIX; private String getRedisKickoutKey(String username) { return this.keyPrefix + username; } private static final String KICOUT_PROPERTY_NAME = "kicout"; /** * 是否允许访问,true表示允许 * @param servletRequest * @param servletResponse * @param o * @return * @throws Exception */ @Override protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception { return false; } /** * 表示访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了(比如重定向到另一个页面)。 */ @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { Subject subject = SecurityUtils.getSubject(); //如果用户没有登录且没有配置『记住我』,则跳过 if(!subject.isAuthenticated() && !subject.isRemembered()){ return true; } Users user = (Users)subject.getPrincipal(); Session session = subject.getSession(); Serializable sessionId = session.getId(); //初始化用户队列 放进缓存 Deque<Serializable> deque = (Deque<Serializable>)redisUtils.get(getRedisKickoutKey(user.getLoginName())); if(deque == null || deque.size() == 0){ deque = new LinkedList<>(); } //如果用户队列里没有此sessionId,且没有被踢出 则放入队列 并放入缓存 if(!deque.contains(sessionId) && session.getAttribute(KICOUT_PROPERTY_NAME) == null){ deque.push(sessionId); redisUtils.set(getRedisKickoutKey(user.getLoginName()), deque, -1); } //如果队列里的用户数量超过最大值 开始踢人 while (deque.size() > maxNum){ Serializable kicoutSessionId; //为true踢出前者 kicoutAfter默认是false if(!kicoutAfter){ kicoutSessionId = deque.removeLast(); } else { //否则踢出后者 kicoutSessionId = deque.removeFirst(); } try { Session kicoutSession = sessionManager.getSession(new DefaultSessionKey(kicoutSessionId)); if(kicoutSession != null){ //设置此属性为true表示这个会话被踢出了 kicoutSession.setAttribute(KICOUT_PROPERTY_NAME, true); //更新redis redisUtils.set(getRedisKickoutKey(user.getLoginName()), deque, -1); } } catch (SessionException e) { e.printStackTrace(); } } if(session.getAttribute(KICOUT_PROPERTY_NAME) != null){ //会话被踢出了 try { //从shiro退出登录 subject.logout(); //给前端返回信息 HttpServletResponse response = WebUtils.toHttp(servletResponse); response.setCharacterEncoding("utf-8"); PrintWriter writer = response.getWriter(); writer.write(JSONObject.toJSON(new JsonData(-3, "您的账号在另一台设备登录。如果不是本人操作,请及时修改密码")).toString()); writer.close(); return false; } catch (Exception e) { e.printStackTrace(); } } return true; } public String getKicOutUrl() { return kicOutUrl; } public void setKicOutUrl(String kicOutUrl) { this.kicOutUrl = kicOutUrl; } public int getMaxNum() { return maxNum; } public void setMaxNum(int maxNum) { this.maxNum = maxNum; } public boolean isKicoutAfter() { return kicoutAfter; } public void setKicoutAfter(boolean kicoutAfter) { this.kicoutAfter = kicoutAfter; } public SessionManager getSessionManager() { return sessionManager; } public void setSessionManager(SessionManager sessionManager) { this.sessionManager = sessionManager; } public void setRedisUtils(RedisUtils redisUtils){ this.redisUtils = redisUtils; } public static String getKicoutPrefix(){return DEFAULT_KICKOUT_CACHE_KEY_PREFIX;} }
完整的shiroconfig:
shiroconfig
import com.example.shiroStudy.config.redis.RedisUtils; import com.example.shiroStudy.config.shiro.filter.CustomRolesAuthorizationFilter; import com.example.shiroStudy.config.shiro.filter.KicoutSessionControlFilter; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.crazycake.shiro.RedisCacheManager; import org.crazycake.shiro.RedisManager; import org.crazycake.shiro.RedisSessionDAO; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.Filter; import java.util.LinkedHashMap; import java.util.Map; /** * @author 黄豪琦 * 日期:2019-07-02 15:04 * 说明: */ @Configuration public class ShiroConfig { @Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); //SecurityManager 安全管理器 shiroFilterFactoryBean.setSecurityManager(securityManager); //登录,为后台接口名,非前台页面名, 未登录跳转 shiroFilterFactoryBean.setLoginUrl("/login"); //登录成功后跳转的地址,为后台接口名,非前台页面名 shiroFilterFactoryBean.setSuccessUrl("/pub/index"); //无权限跳转 shiroFilterFactoryBean.setUnauthorizedUrl("/error/unauthorized"); //设置自定义filter Map<String, Filter> cusFilterMap = new LinkedHashMap<>(); cusFilterMap.put("roleOn", new CustomRolesAuthorizationFilter()); cusFilterMap.put("kicout", kicoutSessionControlFilter()); shiroFilterFactoryBean.setFilters(cusFilterMap); // 配置访问权限 必须是LinkedHashMap,因为它必须保证有序 // 过滤链定义,从上向下顺序执行 LinkedHashMap<String, String> filterMap = new LinkedHashMap<String, String>(); filterMap.put("/dev/**", "anon"); filterMap.put("/wechart/**", "anon"); filterMap.put("/js/**", "anon"); //公开的请求,都可以访问 filterMap.put("/pub/**", "anon"); //需要登录 filterMap.put("/auth/**", "authc"); //需要root权限 filterMap.put("/root/**", "roles[root]"); filterMap.put("/manage/**", "roleOn[root, admin]"); //漏掉的都需要认证 filterMap.put("/**", "kicout,authc"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap); return shiroFilterFactoryBean; } /** * 配置核心安全管理器 * @return */ @Bean(name = "securityManager") public SecurityManager securityManager(){ DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(cusRealm()); securityManager.setCacheManager(redisCacheManager()); securityManager.setSessionManager(sessionManager()); return securityManager; } @Bean public CusRealm cusRealm(){ CusRealm realm = new CusRealm(); //配置密码比较器 realm.setCredentialsMatcher(hashedCredentialsMatcher()); return realm; } /** * 加密器 * @return */ @Bean public HashedCredentialsMatcher hashedCredentialsMatcher(){ HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher(); //设置散列算法 hashedCredentialsMatcher.setHashAlgorithmName("md5"); //散列次数 表示加密几次 此处为加密两次 hashedCredentialsMatcher.setHashIterations(2); return hashedCredentialsMatcher; } /** * 自定义session管理 * @return */ @Bean public SessionManager sessionManager(){ CustomSessionManager sessionManager = new CustomSessionManager(); //session过期时间 1800000为半小时 sessionManager.setGlobalSessionTimeout(1800000); //配置session持久化 sessionManager.setSessionDAO(redisSessionDAO()); return sessionManager; } /** * 开启 shiro 注解 * @param securityManager * @return */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } @Value("${redis.host}") private String redisHost; @Value("${redis.port}") private int redisPort; /** * redisManager */ @Bean public RedisManager redisManager(){ RedisManager redisManager = new RedisManager(); // redisManager.setHost(redisHost); // redisManager.setPort(redisPort); //设置过期时间 redisManager.setExpire(2000); return redisManager; } /** * 配置具体catch实现类 * @return * @return */ @Bean public RedisCacheManager redisCacheManager(){ RedisCacheManager redisCacheManager = new RedisCacheManager(); redisCacheManager.setRedisManager(redisManager()); return redisCacheManager; } /** * session持久化 * @return */ @Bean public RedisSessionDAO redisSessionDAO(){ RedisSessionDAO redisSessionDAO = new RedisSessionDAO(); redisSessionDAO.setRedisManager(redisManager()); return redisSessionDAO; } /** * 管理shiro一些bean的生命周期 初始化和销毁 * @return */ @Bean public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){ return new LifecycleBeanPostProcessor(); } /** * redis操作 * @return */ @Bean public RedisUtils redisUtils(){ RedisUtils redisUtils = new RedisUtils(); return redisUtils; } @Bean public KicoutSessionControlFilter kicoutSessionControlFilter(){ KicoutSessionControlFilter kicoutSessionControlFilter = new KicoutSessionControlFilter(); kicoutSessionControlFilter.setMaxNum(1); kicoutSessionControlFilter.setSessionManager(sessionManager()); kicoutSessionControlFilter.setRedisUtils(redisUtils()); return kicoutSessionControlFilter; } }
思路:
主要是利用缓存来实现并发登录。当用户登录时,以用户名为键,以一个deque(集合的一种)为值存入缓存中。当同一个用户在另一个地方登录时,那么deque中就会有两个sessionId。当deque的大小,超过了设定值的时候就开始踢人。给被踢的session添加一个kicout属性。然后判断当前subject的session,如果有kicout这个属性,说明是被踢下线了,这时就返回给前端信息,并返回false给shiro表示我自定义的拦截器已经处理过了,剩下的拦截器不用执行了。
前端返回有两个情况,一个是前后端分离一个是不分离。分离的情况下用response获取流然后输出就可以了;不分离的情况下可以使用shiro提供的WebUtils进行重定向:
WebUtils.issueRedirect(request, response, url);
参数url就是要重定向的地址,可以在这个地址上添加一个标志,比如
/login?kickout=1
然后在前台js里获取浏览器地址 截取url中kicout的位置,如果有,说明是被踢出的,就可以提示消息。
//被踢出时的提示消息 function kickout(){ var href=location.href; if(href.indexOf("kickout")>0){ alert("您的账号在另一台设备上登录,如非本人操作,请立即修改密码!"); } }
测试:
第一个用户登录:
随便访问一个接口:
第二个用户登录:
(之所以用这样的方式登录是因为potman有cookie,不能模拟)
第二个用户登录成功:
第一个用户再次请求时已被踢出
- spring boot 整合shiro和redis实现权限管理和登录功能
- Springboot + Vue + shiro 实现前后端分离、权限控制
- springboot整合shiro-spring-boot-web-starter实现前后端分离的跨域问题
- SpringBoot/SpringMVC整合Shiro(一):实现登录与注册(MD5加盐加密)
- shiro,基于springboot,基于前后端分离,从登录认证到鉴权,从入门到放弃
- spring boot 整合mybatis 和 shiro 实现登录
- SpringBoot+shiro整合学习之登录认证和权限控制
- springboot+shiro+redis(单机redis版)整合教程-续(添加动态角色权限控制)
- springboot整合shiro实现权限控制
- SpringBoot整合mybatis、shiro、redis实现基于数据库的细粒度动态权限管理系统实例
- spring boot整合Shiro实现单点登录
- SpringBoot+Vue前后端分离实现高并发秒杀——前端知识总结
- SpringBoot整合mybatis、shiro、redis实现基于数据库的细粒度动态权限管理系统实例(转)
- SpringBoot+shiro整合学习之登录认证和权限控制
- SpringBoot整合Shiro实现基于角色的权限访问控制(RBAC)系统简单设计从零搭建
- Springboot 使用 Jedis、RedisTemplate 整合 Redis 实现并发锁
- SpringBoot+shiro整合学习之登录认证和权限控制
- SpringBoot整合mybatis、shiro、redis实现基于数据库的细粒度动态权限管理系统实例
- 基于CAS的单点登录SSO[5]: 基于Springboot实现CAS客户端的前后端分离
- spring boot 1.5.4 集成shiro+cas,实现单点登录和权限控制