springboot2+shiro+redis限制同一账号同时在线人数
我们在写系统的时候,需要注意账号安全问题,最好的处理方法就是同一个账号只能在一个地方登录。
原理
大概的原理就是每次登录的时候将登录的sessionId存入缓存,然后登录之前或者在做任何操作之前去读取这个缓存里面是否还保存有这个登录的sessionId,如果没有了说明被踢下线了,直接跳转到登录页面。(大概是这个逻辑哈,我也没深究)
代码
话不多说,上代码:
拦截器
这个网上很多版本,我随便找的一个版本自己改的。
java
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
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 com.alibaba.fastjson.JSON;
import com.qk.common.util.BaseResultUtils;
import com.qk.platform.database.model.HtUser;
/**
* @时间:2023年7月17日 上午10:31:51
* @作者:秦二少
* @描述:限制同一个账号同时在线人数拦截器
*/
public class KickoutSessionControlFilter extends AccessControlFilter {
private String kickoutUrl; //踢出后到的地址
private boolean kickoutAfter = false; //踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
private int maxSession = 1; //同一个帐号最大会话数 默认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;
}
//设置Cache的key的前缀
public void setCacheManager(CacheManager cacheManager) {
this.cache = cacheManager.getCache("shiro_redis_cache");
}
@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()) {
//如果没有登录,直接进行之后的流程
return true;
}
Session session = subject.getSession();
HtUser user = (HtUser) subject.getPrincipal();
String username = user.getUserName();
Serializable sessionId = session.getId();
//读取缓存 没有就存入
Deque<Serializable> deque = cache.get(username);
//如果此用户没有session队列,也就是还没有登录过,缓存中没有
//就new一个空队列,不然deque对象为空,会报空指针
if(deque==null){
deque = new LinkedList<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;
if(kickoutAfter) { //如果踢出后者
kickoutSessionId = deque.removeFirst();
//踢出后再更新下缓存队列
cache.put(username, deque);
} else { //否则踢出前者
kickoutSessionId = deque.removeLast();
//踢出后再更新下缓存队列
cache.put(username, deque);
}
try {
//获取被踢出的sessionId的session对象
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
//这里向上一个登录此账号的前端发送提示消息
WebSocketServer.sendMessage(BaseResultUtils.sendMsg("loginOut", "您的账号在其他地方登录,您已被挤下线!如果不是您在登录,请立即修改密码!"),user.getId()+"");
if(kickoutSession != null) {
//设置会话的kickout属性表示踢出了
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {//ignore exception
}
}
//如果被踢出了,直接退出,重定向到踢出后的地址
if (session.getAttribute("kickout") != null) {
//会话被踢出了
try {
//退出登录
subject.logout();
} catch (Exception e) { //ignore
}
saveRequest(request);
Map<String, String> resultMap = new HashMap<String, String>();
//判断是不是Ajax请求
if ("XMLHttpRequest".equalsIgnoreCase(((HttpServletRequest) request).getHeader("X-Requested-With"))) {
resultMap.put("user_status", "300");
resultMap.put("message", "您已经在其他地方登录,请重新登录!");
//输出json串
out(response, resultMap);
}else{
//重定向
WebUtils.issueRedirect(request, response, kickoutUrl);
}
return false;
}
return true;
}
private void out(ServletResponse hresponse, Map<String, String> resultMap)
throws IOException {
try {
hresponse.setCharacterEncoding("UTF-8");
PrintWriter out = hresponse.getWriter();
out.println(JSON.toJSONString(resultMap));
out.flush();
out.close();
} catch (Exception e) {
System.err.println("KickoutSessionFilter.class 输出JSON异常,可以忽略。");
}
}
}
这里要注意的是我在踢出登录之前,给上一个登录的人发了一个提示的:
WebSocketServer.sendMessage(BaseResultUtils.sendMsg("loginOut", "您的账号在其他地方登录,您已被挤下线!如果不是您在登录,请立即修改密码!"),user.getId()+"");
我这里是用的websocket方法(具体可以看我另外一篇文章),这就会实现 你这里一登录,上一个登录那边就会马上弹出一个提示框,提示他被挤下线了,并且会自动跳转到登录页面。
shiro配置类
这里才是重点,这个配置类网上也有很多版本,下面是我自己的版本
java
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
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.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@Configuration
public class ShiroConfig {
/**
* 身份认证realm; (自己写的账号密码校验;权限等)
*/
@Bean
public MyShiroRealm myShiroRealm() {
MyShiroRealm myShiroRealm = new MyShiroRealm();
myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return myShiroRealm;
}
/**
* @类名: ShiroConfig.java
* @时间: 2021年12月6日 下午4:37:42
* @作者: 秦二少
* @return
* @描述: 权限管理,配置主要是Realm的管理认证
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm.
securityManager.setRealm(myShiroRealm());
// 自定义缓存实现 使用redis
securityManager.setCacheManager(cacheManager());
// 自定义session管理 使用redis
securityManager.setSessionManager(sessionManager());
return securityManager;
}
/**
* @类名: ShiroConfig.java
* @时间: 2021年12月6日 下午4:38:40
* @作者: 秦二少
* @return
* @描述: 凭证匹配器(密码校验交给Shiro的SimpleAuthenticationInfo进行处理)
*/
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashIterations(1);//散列的次数,比如散列两次,相当于 md5(md5(""));
return hashedCredentialsMatcher;
}
/**
* @类名: ShiroConfig.java
* @时间: 2021年12月6日 下午4:39:33
* @作者: 秦二少
* @param securityManager
* @return
* @描述: 权限管理
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 没有登陆的用户只能访问登陆页面
shiroFilterFactoryBean.setLoginUrl("/login");
// 登录成功后要跳转的链接
shiroFilterFactoryBean.setSuccessUrl("/index");
//未授权跳转链接
shiroFilterFactoryBean.setUnauthorizedUrl("/error/toUnauthorized");
//自定义拦截器
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
//限制同一帐号同时在线的个数。
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);
// 权限控制map.
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// <!-- authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问-->
//filterChainDefinitionMap.put("/index/**", "authc");//主页
filterChainDefinitionMap.put("/company/register/**", "anon");//跳转到企业注册页
filterChainDefinitionMap.put("/company/addCompany/**", "anon");//企业注册
filterChainDefinitionMap.put("/register/**", "anon");//跳转到新供应商注册页
filterChainDefinitionMap.put("/login/**", "anon");//跳转到登录页
filterChainDefinitionMap.put("/user/LoginValidate/**", "anon");//登录验证
filterChainDefinitionMap.put("/drawRandomJPG/**", "anon");//登录验证码
filterChainDefinitionMap.put("/getSMSCode/**", "anon");//短信验证码
filterChainDefinitionMap.put("/error/**", "anon");//错误
filterChainDefinitionMap.put("/script/**", "anon");//静态文件
filterChainDefinitionMap.put("/api/**", "anon");//接口
//主要这行代码必须放在所有权限设置的最后,不然会导致所有 url 都被拦截 剩余的都需要认证
//filterChainDefinitionMap.put("/**", "authc");
filterChainDefinitionMap.put("/**", "kickout,authc");//必须要这样写才能进入 限制同一个帐号同时在线的个数 的拦截器中。
//加载权限
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* cacheManager 缓存 redis实现
* 使用的是shiro-redis开源插件
* @return
*/
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
// 配置缓存的话要求放在session里面的实体类必须有个id标识
redisCacheManager.setPrincipalIdFieldName("id");
return redisCacheManager;
}
/**
* 配置shiro redisManager
* 使用的是shiro-redis开源插件
* @return
*/
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
//在比较旧的版本是host和port分开写,新的版本需要把host和port拼接起来,
redisManager.setHost("127.0.0.1");//127.0.0.1
redisManager.setPort(6379);//6379
redisManager.setTimeout(3000);//3000
//redisManager.setExpire(2160000);// 配置缓存过期时间
redisManager.setDatabase(2);//2
//redisManager.setPassword(password);
return redisManager;
}
/**
* Session Manager
* 使用的是shiro-redis开源插件
*/
@Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
return sessionManager;
}
/**
* RedisSessionDAO shiro sessionDao层的实现 通过redis
* 使用的是shiro-redis开源插件
*/
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setExpire(21600000);
redisSessionDAO.setRedisManager(redisManager());
return redisSessionDAO;
}
/**
* 限制同一账号登录同时登录人数控制
* 注意:这里不能加Bean注解,因为那个拦截器里面已经有了
*/
public KickoutSessionControlFilter kickoutSessionControlFilter() {
KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
kickoutSessionControlFilter.setCacheManager(cacheManager());
kickoutSessionControlFilter.setSessionManager(sessionManager());
kickoutSessionControlFilter.setKickoutAfter(false);
kickoutSessionControlFilter.setMaxSession(1);
kickoutSessionControlFilter.setKickoutUrl("/login");
return kickoutSessionControlFilter;
}
/***
* 授权所用配置
*/
@Bean
public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
/**
* @类名: ShiroConfig.java
* @时间: 2021年12月6日 下午4:40:03
* @作者: 秦二少
* @param securityManager
* @return
* @描述: 开启shiro aop注解支持.
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* Shiro生命周期处理器
*
*/
@Bean
public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
其他的地方都差不多,重点需要注意的就是 shiroFilter 这个方法,
filterChainDefinitionMap.put("/**", "kickout,authc"); 一定要这样加,我就是加错了,这个限制始终不生效,搞了很多久才发现是这里的问题。
其他的都是常见的配置了,如果有不明白的可以找我。大家一起学习!