Shiro實現(xiàn)session限制登錄數(shù)量踢人下線功能
前言
近年無狀態(tài)登錄興起,但sessionId方式仍是主流方案,借用類似redis集群等方案存儲session信息使得它也足以跟上微服務(wù)的浪潮。相對來說session方式更具有服務(wù)端控制感,而無狀態(tài)登錄要想實現(xiàn)服務(wù)端控制就得存儲些東西,這么一來無狀態(tài)就得打上一個問號。本文記錄的是shiro采用session作為登錄方案時,對用戶進行限制數(shù)量登錄,以及剔除下線。
實現(xiàn)
■ 架構(gòu)準備
首先搭建好基于redis存儲session的shiro鑒權(quán)框架底子,網(wǎng)上很容易找到各種實現(xiàn)代碼。
ShiroConfig
找到spring中的ShiroConfig,應(yīng)有類似如下代碼
// 自定義授權(quán)緩存管理器
實現(xiàn) CacheManager 的授權(quán)緩存管理器,改用redis存儲授權(quán)信息。
@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內(nèi)的存儲分布
如圖,
上面一堆是session的存儲,存儲的字符串類型,key為前綴+sessionId,value為session內(nèi)容;
下面一堆則是輔助session限制登陸的存儲,key為前綴+userId,value則是map集合,map的key為sessionId,value可以存儲一些我們需要的內(nèi)容,此處我存的是session的最后活動時間。
這么設(shè)計即可少許的redis操作就達到我們的目的——限制登陸和踢人下線。
注:key的存儲命名使用:分隔是因為低版本的RDM默認使用:符號分隔歸檔,方便我們的可視化查詢,高版本以及其它工具是可以自定義分隔符的。

■ 代碼修改
修改 JedisSessionDAO
新增以下方法,并對實現(xiàn)的接口 SessionDAO 添加抽象方法。
這個方法在登錄時調(diào)用,用于判斷一個賬號登錄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ī)范的內(nèi)容,一般來說是不會出現(xiàn)的
if (StringUtils.isNotBlank(e.getKey()) && StringUtils.isNotBlank(e.getValue())){
// 最后活動時間
String expire = e.getValue();
// 因為session的具體存儲在redis的字符串中,可以自動過期,
// 而這里session信息存儲在map集合的其中一條鍵值對中無法設(shè)置自動過期,
// 所以需要借助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認證的回調(diào)函數(shù),重寫內(nèi)容一般有登錄校驗、登錄日志之類,在這里就可以追加限制登錄數(shù)量和剔除session的操作,也就是調(diào)用前面編寫的方法。
/**
* 認證回調(diào)函數(shù), 登錄時調(diào)用
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) {
UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
// 校驗登錄驗證碼
//業(yè)務(wù)校驗。。。。。。省略
// 校驗用戶名密碼以及賬號是否凍結(jié)
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,當然可以采用再設(shè)置一個sessionId所對應(yīng)的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了,如下代碼就可以設(shè)置與更新這個登錄的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));
// 設(shè)置超期時間
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ù)量當然可以是存儲于關(guān)系型數(shù)據(jù)庫里和賬號綁定的,甚至可以是花里胡哨的規(guī)則,例如——手機登錄限制只能登錄1個,瀏覽器登錄限制10個。還可以通過ws推送,主動告知被剔除的那個客戶端——您的賬號在福建省XX市XX登錄,您被踢下線,如有異常,申請凍結(jié)賬號。甚至可以列出登錄設(shè)備列表,讓客戶可以選擇性的剔除哪個設(shè)備。只要在map里存儲的時間戳修改為這些豐富的數(shù)據(jù),就能實現(xiàn)這些很有趣的功能。
到此這篇關(guān)于Shiro實現(xiàn)session限制登錄數(shù)量踢人下線的文章就介紹到這了,更多相關(guān)Shiro實現(xiàn)session限制登錄數(shù)量內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring Cloud Gateway實現(xiàn)流量染色的示例代碼
流量染色是一種在網(wǎng)絡(luò)請求中添加標識字段,本文詳細介紹了流量染色的原理,并通過SpringCloudGateway實現(xiàn)流量染色的方法,具有一定的參考價值,感興趣的可以來了解一下2026-03-03
java控制臺實現(xiàn)學(xué)生信息管理系統(tǒng)(集合版)
這篇文章主要為大家詳細介紹了java控制臺實現(xiàn)學(xué)生信息管理系統(tǒng)的集合版,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-04-04

