Shiro实现session限制登录数量踢人下线
- 前言
- 实现
- ■ 架构准备
- ShiroConfig
- ■ redis内的存储分布
- ■ 代码修改
- 修改 JedisSessionDAO
- 修改 SystemAuthorizingRealm
- 新增 ApiLogoutFilter
- 再次修改 JedisSessionDAO
- 最后
前言
近年无状态登录兴起,但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在这个方法指的就是userIDif (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{// 验证SESSIONsession.validate();sessions.add(session);}// SESSION验证失败catch (Exception e2) {jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());}}// 存储的SESSION不符合规则else{jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());}}// 存储的SESSION无Valueelse if (StringUtils.isNotBlank(e.getKey())){jedis.hdel(sessionUserKeyPrefix + principal, e.getKey());}}// 剔除过期的session后得到的 sessions.size() 才是当前账号所存活的sessionlogger.info("该账户 session 数量: {} ", sessions.size());// 我定义的规则:如果存活的session大于某个值,就对sessions进行时间排序,并且剔除最后操作较早的sessionif(sessions.size() > SESSIONLIMTI) {sessions.sort(new Comparator<Session>() {@Overridepublic 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:";@Overrideprotected 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();// 如果是静态文件,则不更新SESSIONif (Servlets.isStaticFile(uri)){return;}// 如果是视图文件,则不更新SESSIONif (StringUtils.startsWith(uri, Global.getConfig("web.view.prefix"))&& StringUtils.endsWith(uri, Global.getConfig("web.view.suffix"))){return;}// 手动控制不更新SESSIONif (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里存储的时间戳修改为这些丰富的数据,就能实现这些很有趣的功能。