业务场景
在某些项目中可能会遇到如每个账户同时只能有一个人登录或几个人同时登录,如果同时有多人登录:要么不让后者登录;要么踢出前者登录(强制退出)。
思路
Shiro没有提供默认实现,不过可以很容易的在Shiro中加入这个功能。通过Shiro Filter机制扩展自己的过滤器实现。
来看看AccessControlFilter
AccessControlFilter提供了访问控制的基础功能;比如是否允许访问/当访问拒绝时如何处理等:
isAccessAllowed:表示是否允许访问;mappedValue就是[urls]配置中拦截器参数部分,如果允许访问返回true,否则false;
onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;如果返回false表示该拦截器实例已经处理了,将直接返回即可。
shiro拦截器机制可以看这篇文章:https://jinnianshilongnian.iteye.com/blog/2025656
代码实现思路
如果当前访问没有登录则直接放行;否则获取当前session信息,根据session获取redis中保存帐号session的List队列,如果List中没有这个seesion信息且session状态没被T除则将当前session存入redis;接下在判断队列里面session个数如果大于1个则把先前session的状态改为已T除;这样当前一个session再次访问时直接告诉用户已被强制下线。
参考代码
https://github.com/gemingyi/shiro_demo/tree/master/shiro
代码实现
下面我们来看看自定义KickoutSessionControlFilter具体实现
public class KickoutSessionControlFilter extends AccessControlFilter {
private String kickoutPrefix;
private RedisTemplate redisTemplate;
private SessionManager sessionManager;
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
Subject subject = getSubject(servletRequest, servletResponse);
//如果没有登录,不进行多出登录判断
if (!subject.isAuthenticated() && !subject.isRemembered()) {
return true;
}
Session session = subject.getSession();
String username = (String) subject.getPrincipal();
Serializable sessionId = session.getId();
//获取redis中数据
ArrayList<Serializable> deque = (ArrayList<Serializable>) redisTemplate.opsForList().range(kickoutPrefix + username, 0, -1);
if (deque == null || deque.size() == 0) {
deque = new ArrayList<>();
}
//如果队列里没有此sessionId,且用户没有被踢出,当前session放入队列
if (!deque.contains(sessionId) && session.getAttribute("kickout") == null) {
deque.add(sessionId);
redisTemplate.opsForList().leftPush(kickoutPrefix + username, sessionId);
}
//如果队列里的sessionId数大于1,开始踢人
while (deque.size() > 1) {
//获取第一个sessionId(arrayList方法有限转成LinkedList)
Serializable kickoutSessionId = (Serializable) new LinkedList(deque).removeFirst();
deque.remove(kickoutSessionId);
redisTemplate.opsForList().remove(kickoutPrefix + username, 1, kickoutSessionId);
try {
Session kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
//设置会话的kickout属性表示踢出了
if (kickoutSession != null) {
kickoutSession.setAttribute("kickout", true);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//session包含kickout属性,T出
if (session.getAttribute("kickout") != null) {
try {
subject.logout();
} catch (Exception e) {
e.printStackTrace();
}
saveRequest(servletRequest);
//返回401
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setStatus(HttpStatus.OK.value());
httpResponse.setContentType("application/json;charset=utf-8");
httpResponse.getWriter().write("{\"code\":" + CodeAndMsgEnum.UNAUTHENTIC.getcode() + ", \"msg\":\"" + "当前帐号在其他地方登录,您已被强制下载!" + "\"}");
return false;
}
return true;
}
public void setKickoutPrefix(String kickoutPrefix) {
this.kickoutPrefix = kickoutPrefix;
}
public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
}
ShiroConfiguration配置类
@Bean(name = "kickoutSessionControlFilter")
public KickoutSessionControlFilter jwtFilter(SessionManager sessionManager, RedisTemplate redisTemplate) {
KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
kickoutSessionControlFilter.setSessionManager(sessionManager);
kickoutSessionControlFilter.setRedisTemplate(redisTemplate);
kickoutSessionControlFilter.setKickoutPrefix(kickoutPrefix);
return kickoutSessionControlFilter;
}
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, KickoutSessionControlFilter kickoutSessionControlFilter) {
…
//注意拦截链配置顺序 不能颠倒
Map<String, String> filterChainDefinitionMap = new LinkedHashMap();
…
//拦截所有请求
filterChainDefinitionMap.put("/**", "kickout,authc");
);
…
}
接口测试
先登录统一帐号2次登录
第一个session访问正常
看看redis中队列session
第二个session访问接口正常
再看看redis中的数据
第一个session再次访问