SpringBoot+Shiro学习之自定义拦截器管理在线用户(踢出用户)

sf2270 8年前
   <ol>     <li> <p>我们经常会有用到,当A 用户在北京登录 ,然后A用户在天津再登录 ,要踢出北京登录的状态。如果用户在北京重新登录,那么又要踢出天津的用户,这样反复。又或是需要限制同一用户的同时在线数量,超出限制后,踢出最先登录的或是踢出最后登录的。</p> </li>     <li> <p>第一个场景踢出用户是由用户触发的,有时候需要手动将某个在线用户踢出,也就是对当前在线用户的列表进行管理。</p> </li>    </ol>    <h2>实现思路</h2>    <p>spring security就直接提供了相应的功能;Shiro的话没有提供默认实现,不过可以很容易的在Shiro中加入这个功能。那就是使用shiro强大的自定义访问控制拦截器:AccessControlFilter,集成这个接口后要实现下面这三个方法。</p>    <pre>  <code class="language-java">abstractbooleanisAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)throwsException;      booleanonAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue)throwsException;      abstractbooleanonAccessDenied(ServletRequest request, ServletResponse response)throwsException;  </code></pre>    <p>isAccessAllowed:表示是否允许访问;mappedValue就是[urls]配置中拦截器参数部分,如果允许访问返回true,否则false;</p>    <p>onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;如果返回false表示该拦截器实例已经处理了,将直接返回即可。</p>    <p>onPreHandle:会自动调用这两个方法决定是否继续处理;</p>    <p>另外AccessControlFilter还提供了如下方法用于处理如登录成功后/重定向到上一个请求:</p>    <pre>  <code class="language-java">voidsetLoginUrl(String loginUrl)//身份验证时使用,默认/login.jsp  String getLoginUrl()    Subject getSubject(ServletRequest request, ServletResponse response) //获取Subject实例  boolean isLoginRequest(ServletRequest request, ServletResponse response)//当前请求是否是登录请求  void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //将当前请求保存起来并重定向到登录页面  void saveRequest(ServletRequest request) //将请求保存起来,如登录成功后再重定向回该请求  void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登录页面  </code></pre>    <p>比如基于表单的身份验证就需要使用这些功能。</p>    <p>到此基本的拦截器就完事了,如果我们想进行访问的控制就可以继承AccessControlFilter;如果我们要添加一些通用数据我们可以直接继承PathMatchingFilter。</p>    <p>下面就是我实现的访问控制拦截器:KickoutSessionControlFilter:</p>    <pre>  <code class="language-java">/**   * @author 作者 z77z   * @date 创建时间:2017年3月5日 下午1:16:38   * 思路:   * 1.读取当前登录用户名,获取在缓存中的sessionId队列   * 2.判断队列的长度,大于最大登录限制的时候,按踢出规则   *  将之前的sessionId中的session域中存入kickout:true,并更新队列缓存   * 3.判断当前登录的session域中的kickout如果为true,   * 想将其做退出登录处理,然后再重定向到踢出登录提示页面   */  public classKickoutSessionControlFilterextendsAccessControlFilter{        private String kickoutUrl; //踢出后到的地址      private boolean kickoutAfter = false; //踢出之前登录的/之后登录的用户 默认踢出之前登录的用户      private int maxSession = 1; //同一个帐号最大会话数 默认1        private SessionManager sessionManager;      private Cache<String, Deque<Serializable>> cache;        publicvoidsetKickoutUrl(String kickoutUrl){          this.kickoutUrl = kickoutUrl;      }        publicvoidsetKickoutAfter(booleankickoutAfter){          this.kickoutAfter = kickoutAfter;      }        publicvoidsetMaxSession(intmaxSession){          this.maxSession = maxSession;      }        publicvoidsetSessionManager(SessionManager sessionManager){          this.sessionManager = sessionManager;      }      //设置Cache的key的前缀      publicvoidsetCacheManager(CacheManager cacheManager){          this.cache = cacheManager.getCache("shiro_redis_cache");      }        @Override      protectedbooleanisAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)throwsException{          return false;      }        @Override      protectedbooleanonAccessDenied(ServletRequest request, ServletResponse response)throwsException{          Subject subject = getSubject(request, response);          if(!subject.isAuthenticated() && !subject.isRemembered()) {              //如果没有登录,直接进行之后的流程              return true;          }            Session session = subject.getSession();          SysUser user = (SysUser) subject.getPrincipal();          String username = user.getNickname();          Serializable sessionId = session.getId();            //读取缓存 没有就存入          Deque<Serializable> deque = cache.get(username);                    //如果队列里没有此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();              } 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              }          }            //如果被踢出了,直接退出,重定向到踢出后的地址          if ((Boolean)session.getAttribute("kickout")!=null&&(Boolean)session.getAttribute("kickout") == true) {              //会话被踢出了              try {               //退出登录                  subject.logout();              } catch (Exception e) { //ignore              }              saveRequest(request);              //重定向              WebUtils.issueRedirect(request, response, kickoutUrl);              return false;          }          return true;      }  }  </code></pre>    <p>将这个自定义的拦截器配置在ShiroConfig.java文件中:</p>    <pre>  <code class="language-java">/**    * 限制同一账号登录同时登录人数控制    * @return    */   publicKickoutSessionControlFilterkickoutSessionControlFilter(){    KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();    //使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;    //这里我们还是用之前shiro使用的redisManager()实现的cacheManager()缓存管理    //也可以重新另写一个,重新配置缓存时间之类的自定义缓存属性    kickoutSessionControlFilter.setCacheManager(cacheManager());    //用于根据会话ID,获取会话进行踢出操作的;    kickoutSessionControlFilter.setSessionManager(sessionManager());    //是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序。    kickoutSessionControlFilter.setKickoutAfter(false);    //同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;    kickoutSessionControlFilter.setMaxSession(1);    //被踢出后重定向到的地址;    kickoutSessionControlFilter.setKickoutUrl("/kickout");       return kickoutSessionControlFilter;    }  </code></pre>    <p>将这个kickoutSessionControlFilter()注入到shiroFilterFactoryBean中:</p>    <pre>  <code class="language-java">//自定义拦截器  Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();  //限制同一帐号同时在线的个数。  filtersMap.put("kickout", kickoutSessionControlFilter());  shiroFilterFactoryBean.setFilters(filtersMap);  </code></pre>    <p>由于我们链接权限的控制是动态存在数据库中的,这个可以去看我之前动态权限控制的博文,所以我们还要在数据库中修改链接的权限,将kickout这个自定义的权限配置在对应的链接上。如下图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/35ea05e6af11b3be484afc99b2009453.png"></p>    <p>权限表</p>    <p>还要编写对应的被踢出的跳转页面:</p>    <pre>  <code class="language-java"><%@pagelanguage="java"contentType="text/html; charset=UTF-8"   pageEncoding="UTF-8"%>  <%   String path = request.getContextPath();   String basePath = request.getScheme() + "://"     + request.getServerName() + ":" + request.getServerPort()     + path;  %>  <!DOCTYPE html>  <html>  <head>  <metahttp-equiv="Content-Type"content="text/html; charset=UTF-8">  <scripttype="text/javascript"   src="<%=basePath%>/static/js/jquery-1.11.3.js"></script>  <title>被踢出</title>  </head>  <body>  被踢出 或则在另一地方登录,或已经达到此账号登录上限被挤掉。  <inputtype="button"id="login"value="重新登录"/>  </body>  <scripttype="text/javascript">  $("#login").click(function(){   window.open("<%=basePath%>/login");   });  </script>  </html>  </code></pre>    <p>到此,第一个场景就实现了,写到这里实际第二个场景的实现思路已经就很明显了,可以通过sessionDAO获取到全部的shiro会话List,然后显示在前端页面,踢出对应用户就可以使用在对应sessionId的session域中设置key为kickout的值为true,上面的KickoutSessionControlFilter就会判断session域中的kickout值,做响应的处理。这里我就先不上代码了,大家可以自己试一试。之后再把代码同步到我的码云上,供大家学习交流。</p>    <p>处理了这个需求后,我发现一个问题,这里有一个前提,我们知道Ajax不能做页面redirect和forward跳转,所以Ajax请求假如没登录,那么这个请求给用户的感觉就是没有任何反应,而用户又不知道用户已经退出了。这个就要对ajax请求做相应的优化,我已经有解决思路了,大家也可以思考下,我也会在下一博提供代码。</p>    <p>还有我接下来会对之前的前端页面进行完善,比如下面是我更新的登录页面:</p>    <p><img src="https://simg.open-open.com/show/3ddbd251928e05f6a6fd9453debd6f15.png"></p>    <p>登录页面</p>    <p> </p>    <p> </p>    <p>来自:http://z77z.oschina.io/2017/03/05/SpringBoot Shiro学习之自定义拦截器管理在线用户(踢出用户)/</p>    <p> </p>