当前位置 博文首页 > 快乐的小三菊的博客:springboot + shiro 实现登录人数控制
? ? ? ?shiro 可以实现控制系统内用户登录人数的功能,比如用户 A 在天津登录,同一时刻用户 B 在北京登录,若要保证某一时刻只能有一个人登录的话,就需要踢掉用户 A,因为它是第一次登录的,若此时用户 A 再次登录,则会踢掉用户 B,如此反复。即可实现某一时刻系统只有一个人登录。
? ? ? ?实现起来很简单,只需要自定义实现 shiro 的 filter 即可,下面我们来看下具体是如何实现的。
? ? ? ?首先,我们新建一个拦截器类?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 配置类中添加?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