当前位置 博文首页 > 快乐的小三菊的博客:springboot + shiro 实现登录人数控制

    快乐的小三菊的博客:springboot + shiro 实现登录人数控制

    作者:[db:作者] 时间:2021-07-28 20:46

    背景:

    ? ? ? ?shiro 可以实现控制系统内用户登录人数的功能,比如用户 A 在天津登录,同一时刻用户 B 在北京登录,若要保证某一时刻只能有一个人登录的话,就需要踢掉用户 A,因为它是第一次登录的,若此时用户 A 再次登录,则会踢掉用户 B,如此反复。即可实现某一时刻系统只有一个人登录。

    ? ? ? ?实现起来很简单,只需要自定义实现 shirofilter 即可,下面我们来看下具体是如何实现的。

    新增拦截器:

    ? ? ? ?首先,我们新建一个拦截器类?KickoutSessionControlFilter ,它继承了?AccessControlFilter 。如果被顶掉了,那么用户 A 应该点击界面的任意按钮就会提示用户”此账户被其他人顶掉“类似的提示语,而一般点击任意按钮都会触发 ajax 的请求,还有一种情况是点击菜单提示用户,但这种情况不太好判断。我们这里只考虑 ajax 请求触发,我们在下面的拦截器中判断该请求是否是 ajax 的请求,若是,则添加 header ,然后前端的 js 里面再判断 header 里面的值,就可以进行完整的校验了。

    import java.io.Serializable;
    import java.util.Deque;
    import java.util.LinkedList;
    
    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    
    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;
    
    
    public class KickoutSessionControlFilter extends AccessControlFilter{
    
    	// 踢出后到的地址
    	private String kickoutUrl;
    
    	// 踢出之前登录的或者之后登录的用户, 默认踢出之前登录的用户
    	private boolean kickoutAfter = false;
    
    	// 同一个帐号最大会话数 默认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 void setCacheManager(CacheManager cacheManager) {
    		this.cache = cacheManager.getCache("shiro-activeSessionCache-new");
    	}
    
    	/**
    	 * 是否允许访问,返回 true 表示允许
    	 */
    	@Override
    	protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)
    			throws Exception {
    		return false;
    	}
    	/**
    	 * 表示访问拒绝时是否自己处理,如果返回 true 表示自己不处理且继续拦截器链执行,返回 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();
    		// 传的是userName 这里拿到的就是 userName
    		// new SimpleAuthenticationInfo(userName, password, getName()); 
    		String username = subject.getPrincipal().toString();
    		Serializable sessionId = session.getId();
    
    		// 初始化用户的队列放到缓存里
    		Deque<Serializable> deque = cache.get(username);
    		if(deque == null) {
    			deque = new LinkedList<Serializable>();
    			cache.put(username, deque);
    		}
    		// 如果队列里没有此sessionId,且用户没有被踢出;放入队列
    		if(!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
    			deque.push(sessionId);
    			// 将用户的sessionId队列缓存
    			cache.put(username, deque);
    		}
    
    		// 如果队列里的sessionId数超出最大会话数,开始踢人
    		while(deque.size() > maxSession) {
    			Serializable kickoutSessionId = null;
    			// 如果踢出后者
    			if(kickoutAfter) { 
    				kickoutSessionId=deque.getFirst();
    				kickoutSessionId = deque.removeFirst();
    			} else {
    				// 否则踢出前者
    				kickoutSessionId = deque.removeLast();
    			}
    			try {
    				Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
    				if(kickoutSession != null) {
    					// 设置会话的 kickout 属性表示踢出了
    					kickoutSession.setAttribute("kickout", true);
    				}
    			} catch (Exception e) {//ignore exception
    				e.printStackTrace();
    			}
    		}
    
    		// 如果被踢出了,直接退出,重定向到踢出后的地址
    		if (session.getAttribute("kickout") != null) {
    			// 会话被踢出了
    			try {
    				subject.logout();
    			} catch (Exception e) {
    			}
    			// WebUtils.issueRedirect(request, response, kickoutUrl);
    			HttpServletResponse httpServletResponse = (HttpServletResponse) response;
    			if (isAjax(request)) {
    				httpServletResponse.setCharacterEncoding("UTF-8");
    				httpServletResponse.setContentType("application/json");
    				httpServletResponse.setHeader("session-status", "two-user");
    			} else {
    				httpServletResponse.sendRedirect(kickoutUrl);
    			}
                return false;
    		}
    		return true;
    	}
    }

    ? ? ? ?我们看一下 isAccessAllowed() 方法,在这个方法中,如果返回 true,则表示“通过”,走到下一个过滤器。如果没有下一个过滤器的话,表示具有了访问某个资源的权限。如果返回? false ,则会调用 onAccessDenied()?方法,去实现过滤不通过的时候执行的操作,例如:检查用户是否已经登陆过,如果登陆过,根据自定义规则选择踢出前一个用户还是后一个用户。?
    ? ? ? ?onAccessDenied 方法返回 true 表示 自己处理完成,然后继续拦截器链执行。?只有当两者都返回 false 时,才会终止后面的 filter 执行。

    添加缓存:?

    ? ? ? ?在?ehcache-shiro.xml 文件中添加缓存 shiro-activeSessionCache-new ,用于判断系统中的登录用户的数量。

        <!-- 用户存储控制登录人数的缓存 -->
    	<cache name="shiro-activeSessionCache-new"
           maxEntriesLocalHeap="2000"
           eternal="false"
           timeToIdleSeconds="0"
           timeToLiveSeconds="0"
           overflowToDisk="false"
           statistics="true">
    	</cache>

    配置?shiroConfig :

    ? ? ? ?在?shiroConfig 配置类中添加?KickoutSessionControlFilter 实现并发控制,代码如下所示:

        /**
    	 * 并发登录控制
    	 * @return
    	*/
    	@Bean
    	public KickoutSessionControlFilter kickoutSessionControlFilter(){
    	    KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
    	    // 用于根据会话ID,获取会话进行踢出操作的;
    	    kickoutSessionControlFilter.setSessionManager(sessionManager());
    	    // 使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
    	    kickoutSessionControlFilter.setCacheManager(ehCacheManager());
    	    // 是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;
    	    kickoutSessionControlFilter.setKickoutAfter(false);
    	    // 同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
    	    kickoutSessionControlFilter.setMaxSession(1);
    	    // 被踢出后重定向到的地址;
    	    kickoutSessionControlFilter.setKickoutUrl("/");
    	    return kickoutSessionControlFilter;
    	} 

    ? ? ? ?在?shiroConfig 配置类中,修改?ShiroFilterFactoryBean 的拦截规则,代码如下所示:

        // Filter工厂,设置对应的过滤条件和跳转条件
    	@Bean
    	public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    		ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
    		// Shiro的核心安全接口,这个属性是必须的
    		shiroFilter.setSecurityManager(securityManager);
    	
    		//不输入地址的话会自动寻找
    		shiroFilter.setLoginUrl("/login");
    		//登录成功默认跳转页面,不配置则跳转至”/”。如果登陆前点击的一个需要登录的页面,则在登录自动跳转到那个需要登录的页面。不跳转到此。
    		//shiroFilter.setSuccessUrl("/");
    		
    		// 自定义拦截器限制并发人数
    	    LinkedHashMap<String, Filter> filtersMap = new LinkedHashMap<>();
    	    // 限制同一帐号同时在线的个数
    	    filtersMap.put("kickout", kickoutSessionControlFilter());
    	    shiroFilter.setFilters(filtersMap);	    				
    		Map<String, String> map = new LinkedHashMap<>();		
    		// 不能对login方法进行拦截,若进行拦截的话,这辈子都登录不上去了,这个login是LoginController里面登录校验的方法
    		map.put("/login", "anon");
    		map.put("/static/**", "anon");
    		//对所有用户认证
    		map.put("/**", "kickout,authc");	
    		shiroFilter.setFilterChainDefinitionMap(map);
    		return shiroFilter;
    	}

    ? ? ? ?map.put("/**", "kickout,authc") ,表示 访问 /** 下的资源首先要通过 kickout 对应的 filter 拦截处理下,然后再通过 authc 后面对应的 filter 才可以访问。

    前端界面实现:

    ? ? ? ?在我们平常的项目中,一般都会引入一些公共的 js 文件,在里面添加一些常用的工具方法或者常量,这时我们需要在 js 文件中添加一个 ajax 的父级方法?$.ajaxSetup ,用于拦截ajax 返回后的数据,用于校验当前的 session 是否处于过期状态,若处于过期状态则跳转到首页,代码如下所示:

    function addRoleIds(){
    
    	$.ajax({
    		url :"addRoleIds",
    		data : {"userName" : "lisi"} ,
    		async:false,
    		type : "get",
    		success : function(data) {
    			alert(data);
    		}
    	})
    }
    
    function delRoleIds(){
    
    	$.ajax({
    		url :"delRoleIds",
    		data : {"userName" : "zhangsan"} ,
    		async:false,
    		type : "get",
    		success : function(data) {
    			alert(data);
    		},
    
    	})
    }
    function logOther(){
    	window.location.href = "page_skip";
    }
    function logout(){
    
    	$.ajax({
    		url :"logout",
    		data : {"userName" : "zhangsan"} ,
    		async:false,
    		type : "get",
    		success : function(data) {
    			window.location.href = "http://localhost:8080/page/"+data+".jsp";
    		}
    	})
    }
    
    $.ajaxSetup({
    	complete:function(XMLHttpRequest,textStatus){
    		var sessionstatus=XMLHttpRequest.getResponseHeader("session-status");
    		if(sessionstatus == "timeout"){
    			alert("会话超时,请重新登录!")
    			//如果超时就处理 ,指定要跳转的页面
    			window.location.href= "/login";
    		}
    		if(sessionstatus == "two-user"){
    			alert("您的账户在异地登录,请重新登录!")
    			// 踢掉第一个登录的用户
    			window.location.href= "/login";
    		}
    	}
    }); 
    

    测试方法:

    ? ? ? ?启动项目,先用 google 浏览器登录账户 A ,然后再用 360 浏览器登录账户 A,然后在 google 浏览器里面随意点击按钮,即可弹出下面的提示框。

    统计在线人数:

    ? ? ? ?我们在 shiro 实现 session 管理的文章中 ,配置过一个监听类 ShiroSessionListener ,在该类中有统计 session 创建个数,我们就用 session 的个数来统计在线的人数,但是这个统计人数是不准确的,存在这样一种情况,用户登录之后,强制退出浏览器,再次打开浏览器重新登录,在线人数一直在增加。直到 session 过期后这个值才会减少,目前没啥更好的办法精确统计。

    ? ? ? ?想要获取数量的话,直接调用下面的方法即可。

    int count = shiroSessionListener.getSessionCount();

    ?

    cs