Shiro實現(xiàn)session限制登錄數(shù)量踢人下線功能
前言
近年無狀態(tài)登錄興起,但sessionId方式仍是主流方案,借用類似redis集群等方案存儲session信息使得它也足以跟上微服務的浪潮。相對來說session方式更具有服務端控制感,而無狀態(tài)登錄要想實現(xiàn)服務端控制就得存儲些東西,這么一來無狀態(tài)就得打上一個問號。本文記錄的是shiro采用session作為登錄方案時,對用戶進行限制數(shù)量登錄,以及剔除下線。
實現(xiàn)
■ 架構準備
首先搭建好基于redis存儲session的shiro鑒權框架底子,網(wǎng)上很容易找到各種實現(xiàn)代碼。
ShiroConfig
找到spring中的ShiroConfig,應有類似如下代碼
// 自定義授權緩存管理器
實現(xiàn) CacheManager 的授權緩存管理器,改用redis存儲授權信息。
@Bean public JedisCacheManager shiroCacheManager() { JedisCacheManager shiroCacheManager = new JedisCacheManager(); return shiroCacheManager; }
// 自定義Session存儲容器
繼承 AbstractSessionDAO 實現(xiàn) SessionDAO ,對session的curd的具體實現(xiàn)方法自定義編寫,采用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
新增以下方法,并對實現(xiàn)的接口 SessionDAO 添加抽象方法。
這個方法在登錄時調用,用于判斷一個賬號登錄session的數(shù)量并剔除超出規(guī)則的賬號。
@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()){ // 遍歷集合,剔除不規(guī)范的內容,一般來說是不會出現(xiàn)的 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不符合規(guī)則 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 數(shù)量: {} ", sessions.size()); // 我定義的規(guī)則:如果存活的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認證的回調函數(shù),重寫內容一般有登錄校驗、登錄日志之類,在這里就可以追加限制登錄數(shù)量和剔除session的操作,也就是調用前面編寫的方法。
/** * 認證回調函數(shù), 登錄時調用 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) { UsernamePasswordToken token = (UsernamePasswordToken) authcToken; // 校驗登錄驗證碼 //業(yè)務校驗。。。。。。省略 // 校驗用戶名密碼以及賬號是否凍結 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(), "系統(tǒng)登錄", user); // 踢人 int limitSessionSize = getSystemService().getSessionDao().limitSessions(user.getId()).size(); } return new SimpleAuthenticationInfo(principal, 。。。); } else { return null; } }
新增 ApiLogoutFilter
重寫 preHandle 方法,如果退出登錄,就從map中移除該session,我本來是打算寫在 JedisSessionDAO 的delete方法中,但是執(zhí)行到這個方法的時候已經(jīng)清除了用戶信息,所以無法獲得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; } /** 現(xiàn)在項目基本前后端分離 這一段基本沒用 HttpServletRequest request = Servlets.getRequest(); if (request != null){ String uri = request.getServletPath(); // 如果是靜態(tài)文件,則不更新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); } }
最后
在此,我只是規(guī)定了固定數(shù)量規(guī)則,這個限制登錄數(shù)量當然可以是存儲于關系型數(shù)據(jù)庫里和賬號綁定的,甚至可以是花里胡哨的規(guī)則,例如——手機登錄限制只能登錄1個,瀏覽器登錄限制10個。還可以通過ws推送,主動告知被剔除的那個客戶端——您的賬號在福建省XX市XX登錄,您被踢下線,如有異常,申請凍結賬號。甚至可以列出登錄設備列表,讓客戶可以選擇性的剔除哪個設備。只要在map里存儲的時間戳修改為這些豐富的數(shù)據(jù),就能實現(xiàn)這些很有趣的功能。
到此這篇關于Shiro實現(xiàn)session限制登錄數(shù)量踢人下線的文章就介紹到這了,更多相關Shiro實現(xiàn)session限制登錄數(shù)量內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Spring @Cacheable redis異常不影響正常業(yè)務方案
這篇文章主要介紹了Spring @Cacheable redis異常不影響正常業(yè)務方案,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-02-02springboot訪問template下的html頁面的實現(xiàn)配置
這篇文章主要介紹了springboot訪問template下的html頁面的實現(xiàn)配置,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-12-12SpringBoot?使用AOP?+?Redis?防止表單重復提交的方法
Spring?Boot是一個用于構建Web應用程序的框架,通過AOP可以實現(xiàn)防止表單重復提交,本文介紹了在Spring?Boot應用程序中使用AOP和Redis來防止表單重復提交的方法,需要的朋友可以參考下2023-04-04