Shiro实现session限制登录数量踢人下线功能
前言
近年无状态登录兴起,但sessionId方式仍是主流方案,借用类似redis集群等方案存储session信息使得它也足以跟上微服务的浪潮。相对来说session方式更具有服务端控制感,而无状态登录要想实现服务端控制就得存储些东西,这么一来无状态就得打上一个问号。本文记录的是shiro采用session作为登录方案时,对用户进行限制数量登录,以及剔除下线。
实现
■ 架构准备
首先搭建好基于redis存储session的shiro鉴权框架底子,网上很容易找到各种实现代码。
ShiroConfig
找到spring中的ShiroConfig,应有类似如下代码
// 自定义授权缓存管理器
实现 CacheManager 的授权缓存管理器,改用redis存储授权信息。
@Bean public JedisCacheManager shiroCacheManager() { JedisCacheManager shiroCacheManager = new JedisCacheManager(); return shiroCacheManager; }
// 自定义Session存储容器
继承 AbstractSessionDAO 实现 SessionDAO ,对session的curd的具体实现方法自定义编写,采用redis存储与操作。也是本文的主要修改类。
@Bean public JedisSessionDAO sessionDAO(IdGen idGen) { JedisSessionDAO sessionDAO = new JedisSessionDAO(); sessionDAO.setSessionIdGenerator(idGen); sessionDAO.setSessionKeyPrefix(redis_keyPrefix + "_session:"); return sessionDAO; }
// 自定义会话管理配置
继承 DefaultWebSessionManager 的自定义WEB会话管理类。
@Bean public SessionManager sessionManager(JedisSessionDAO sessionDAO, SimpleCookie sessionIdCookie) { SessionManager sessionManager = new SessionManager(); sessionManager.setSessionDAO(sessionDAO); // 会话超时时间,单位:毫秒 sessionManager.setGlobalSessionTimeout(session_sessionTimeout); sessionManager.setSessionValidationSchedulerEnabled(true); sessionManager.setSessionIdCookie(sessionIdCookie); sessionManager.setSessionIdCookieEnabled(true); return sessionManager; }
// 自定义Shiro安全管理配置
@Bean public DefaultWebSecurityManager securityManager(SystemAuthorizingRealm systemAuthorizingRealm, SessionManager sessionManager , JedisCacheManager shiroCacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(systemAuthorizingRealm); securityManager.setSessionManager(sessionManager); securityManager.setCacheManager(shiroCacheManager); return securityManager; }
这些配置一层套一层,其它的省略了。。。主要修改的就是JedisSessionDAO
■ redis内的存储分布
如图,
上面一堆是session的存储,存储的字符串类型,key为前缀+sessionId,value为session内容;
下面一堆则是辅助session限制登陆的存储,key为前缀+userId,value则是map集合,map的key为sessionId,value可以存储一些我们需要的内容,此处我存的是session的最后活动时间。
这么设计即可少许的redis操作就达到我们的目的——限制登陆和踢人下线。
注:key的存储命名使用:
分隔是因为低版本的RDM默认使用:
符号分隔归档,方便我们的可视化查询,高版本以及其它工具是可以自定义分隔符的。
■ 代码修改
修改 JedisSessionDAO
新增以下方法,并对实现的接口 SessionDAO 添加抽象方法。
这个方法在登录时调用,用于判断一个账号登录session的数量并剔除超出规则的账号。
@Override public Collection<Session> limitSessions(Object principal){ // principal在这个方法指的就是userID if (principal != null){ principal = principal.toString(); } // 等会儿取出来的用户存活的session需要放入这个list进行时间排序,以剔除过旧的session。 ArrayList<Session> sessions = new ArrayList(); Jedis jedis = null; try { jedis = JedisUtils.getResource(); // 查询该userId的session map集合。 Map<String, String> map = jedis.hgetAll(sessionUserKeyPrefix + principal); for (Map.Entry<String, String> e : map.entrySet()){ // 遍历集合,剔除不规范的内容,一般来说是不会出现的 if (StringUtils.isNotBlank(e.getKey()) && StringUtils.isNotBlank(e.getValue())){ // 最后活动时间 String expire = e.getValue(); // 因为session的具体存储在redis的字符串中,可以自动过期, // 而这里session信息存储在map集合的其中一条键值对中无法设置自动过期, // 所以需要借助SimpleSession类对session是否存活进行校验。 // 每当该账号有认证操作时就会更新一遍map。 if (StringUtils.isNotBlank(expire)){ SimpleSession session = new SimpleSession(); session.setId(e.getKey()); session.setAttribute("principalId", principal); session.setTimeout(TokenUtils.cacheSeconds * 1000); session.setLastAccessTime(new Date(Long.valueOf(expire))); try{ // 验证SESSION session.validate(); sessions.add(session); } // SESSION验证失败 catch (Exception e2) { jedis.hdel(sessionUserKeyPrefix + principal, e.getKey()); } } // 存储的SESSION不符合规则 else{ jedis.hdel(sessionUserKeyPrefix + principal, e.getKey()); } } // 存储的SESSION无Value else if (StringUtils.isNotBlank(e.getKey())){ jedis.hdel(sessionUserKeyPrefix + principal, e.getKey()); } } // 剔除过期的session后得到的 sessions.size() 才是当前账号所存活的session logger.info("该账户 session 数量: {} ", sessions.size()); // 我定义的规则:如果存活的session大于某个值,就对sessions进行时间排序,并且剔除最后操作较早的session if(sessions.size() > SESSIONLIMTI) { sessions.sort(new Comparator<Session>() { @Override public int compare(Session o1, Session o2) { return (int)(o1.getLastAccessTime().getTime() - o2.getLastAccessTime().getTime()); } }); for (int i = 0; i < sessions.size() - SESSIONLIMTI; i++) { Session session = sessions.get(i); jedis.hdel(sessionUserKeyPrefix + principal, session.getId().toString()); jedis.del(JedisUtils.getBytesKey(sessionKeyPrefix + session.getId())); } } } catch (Exception e) { logger.error("limitSessions", e); } finally { JedisUtils.returnResource(jedis); } return sessions; }
修改 SystemAuthorizingRealm
如下代码,doGetAuthenticationInfo 是shiro认证的回调函数,重写内容一般有登录校验、登录日志之类,在这里就可以追加限制登录数量和剔除session的操作,也就是调用前面编写的方法。
/** * 认证回调函数, 登录时调用 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) { UsernamePasswordToken token = (UsernamePasswordToken) authcToken; // 校验登录验证码 //业务校验。。。。。。省略 // 校验用户名密码以及账号是否冻结 User user = getSystemService().。。。。。。 if (user != null) { if (Global.NO.equals(user.getLoginFlag())) { throw new AuthenticationException("msg:该帐号已禁止登录."); } else if (Global.YES.equals(user.getBlacklist())) { throw new AuthenticationException("msg:该帐号已被加入黑名单."); } byte[] salt = Encodes.decodeHex(。。。); Principal principal = new Principal(user, 。。。); // 无痕登录 不打日志 if(token.isTraceless()) { principal.setTraceless(true); } else { // 更新登录IP和时间 getSystemService().updateUserLoginInfo(user); // 记录登录日志 LogUtils.saveLog(Servlets.getRequest(), "系统登录", user); // 踢人 int limitSessionSize = getSystemService().getSessionDao().limitSessions(user.getId()).size(); } return new SimpleAuthenticationInfo(principal, 。。。); } else { return null; } }
新增 ApiLogoutFilter
重写 preHandle 方法,如果退出登录,就从map中移除该session,我本来是打算写在 JedisSessionDAO 的delete方法中,但是执行到这个方法的时候已经清除了用户信息,所以无法获得userId,当然可以采用再设置一个sessionId所对应的redis存储辅助,有些冗余,可能有更好的切入点写入,我目前是写在这里。
public class ApiLogoutFilter extends LogoutFilter { private static final Logger log = LoggerFactory.getLogger(ApiLogoutFilter.class); private String sessionUserKeyPrefix = "jes_map:"; @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { Subject subject = this.getSubject(request, response); if (this.isPostOnlyLogout() && !WebUtils.toHttp(request).getMethod().toUpperCase(Locale.ENGLISH).equals("POST")) { return this.onLogoutRequestNotAPost(request, response); } else { String redirectUrl = this.getRedirectUrl(request, response, subject); try { SystemAuthorizingRealm.Principal principal = (SystemAuthorizingRealm.Principal)subject.getPrincipal(); String sessionId = subject.getSession().getId().toString(); subject.logout(); JedisUtils.mapRemove(sessionUserKeyPrefix + principal, sessionId); } catch (SessionException var6) { log.debug("Encountered session exception during logout. This can generally safely be ignored.", var6); } this.issueRedirect(request, response, redirectUrl); return false; } } }
再次修改 JedisSessionDAO
这个方法里就可以获取userId了,如下代码就可以设置与更新这个登录的map集合,以及更新session的生命周期。
@Override public void update(Session session) throws UnknownSessionException { if (session == null || session.getId() == null) { return; } /** 现在项目基本前后端分离 这一段基本没用 HttpServletRequest request = Servlets.getRequest(); if (request != null){ String uri = request.getServletPath(); // 如果是静态文件,则不更新SESSION if (Servlets.isStaticFile(uri)){ return; } // 如果是视图文件,则不更新SESSION if (StringUtils.startsWith(uri, Global.getConfig("web.view.prefix")) && StringUtils.endsWith(uri, Global.getConfig("web.view.suffix"))){ return; } // 手动控制不更新SESSION if (Global.NO.equals(request.getParameter("updateSession"))){ return; } } **/ Jedis jedis = null; try { jedis = JedisUtils.getResource(); // 获取登录者编号 PrincipalCollection pc = (PrincipalCollection)session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY); String principalId = pc != null ? pc.getPrimaryPrincipal().toString() : StringUtils.EMPTY; if (StringUtils.isNotBlank(principalId)) { jedis.hset(sessionUserKeyPrefix + principalId, session.getId().toString(), "" + session.getLastAccessTime().getTime()); jedis.expire(sessionUserKeyPrefix + principalId, TokenUtils.cacheSeconds); } jedis.set(JedisUtils.getBytesKey(sessionKeyPrefix + session.getId()), JedisUtils.toBytes(session)); // 设置超期时间 int timeoutSeconds = (int)(session.getTimeout() / 1000); jedis.expire((sessionKeyPrefix + session.getId()), timeoutSeconds); logger.debug("update {} {}", session.getId(), request != null ? request.getRequestURI() : ""); } catch (Exception e) { logger.error("update {} {}", session.getId(), request != null ? request.getRequestURI() : "", e); } finally { JedisUtils.returnResource(jedis); } }
最后
在此,我只是规定了固定数量规则,这个限制登录数量当然可以是存储于关系型数据库里和账号绑定的,甚至可以是花里胡哨的规则,例如——手机登录限制只能登录1个,浏览器登录限制10个。还可以通过ws推送,主动告知被剔除的那个客户端——您的账号在福建省XX市XX登录,您被踢下线,如有异常,申请冻结账号。甚至可以列出登录设备列表,让客户可以选择性的剔除哪个设备。只要在map里存储的时间戳修改为这些丰富的数据,就能实现这些很有趣的功能。
到此这篇关于Shiro实现session限制登录数量踢人下线的文章就介绍到这了,更多相关Shiro实现session限制登录数量内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
最新评论