「同一賬號同一時間僅允許一處登錄」是眾多系統的安全合規要求。Apache Shiro 通過 SessionManager + SessionDAO 提供會話管理能力,結合自定義 Realm 在認證階段清理舊會話,即可實現優雅的強制下線。本文基於 Shiro 1.4+(非 Spring 整合模式,使用 shiro.ini 配置)完整演示實現方案。
Subject.login(token)Realm.doGetAuthenticationInfo() 執行認證[main] # 登錄成功/失敗跳轉 authc.loginUrl = /login.jsp authc.successUrl = /web/index.jsp # 緩存管理器(內存,生產建議 Redis) cacheManager = org.apache.shiro.cache.MemoryConstrainedCacheManager # SecurityManager securityManager = org.apache.shiro.web.mgt.DefaultWebSecurityManager securityManager.cacheManager = $cacheManager securityManager.sessionManager = $sessionManager securityManager.realms = $localAuthorizingRealm # Session 管理器(必須配置,支持強制下線) sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager sessionManager.sessionDAO = $sessionDAO sessionManager.globalSessionTimeout = 1800000 # 30分鐘 # SessionDAO(內存實現,集群需換 Redis/JDBC 實現) sessionDAO = org.apache.shiro.session.mgt.eis.MemorySessionDAO # 自定義 Realm(實現踢下線邏輯) localAuthorizingRealm = com.example.shiro.LocalAuthorizingRealm # localAuthorizingRealm.credentialsMatcher = $credentialsMatcher
package com.example.shiro;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.util.CollectionUtils;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import java.util.Collection;
public class LocalAuthorizingRealm extends AuthorizingRealm {
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String username = (String) token.getPrincipal();
// ========== 核心:踢下線邏輯 ==========
// 1. 獲取 SecurityManager
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager)
org.apache.shiro.SecurityUtils.getSecurityManager();
// 2. 獲取 SessionManager
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager)
securityManager.getSessionManager();
// 3. 獲取所有活躍 Session
SessionDAO sessionDAO = sessionManager.getSessionDAO();
Collection activeSessions = sessionDAO.getActiveSessions();
if (CollectionUtils.isEmpty(activeSessions)) {
return doAuthenticate(username, token); // 無活躍會話,直接認證
}
// 4. 遍歷查找並刪除同用戶的舊 Session
for (Session session : activeSessions) {
// Session 中存儲的主體標識鍵
Object principalObj = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (principalObj instanceof PrincipalCollection) {
PrincipalCollection principals = (PrincipalCollection) principalObj;
String existingUsername = (String) principals.getPrimaryPrincipal();
if (username.equals(existingUsername)) {
// 發現同用戶舊會話 → 刪除(強制下線)
sessionDAO.delete(session);
System.out.println("踢下線:用戶 [" + username + "] 舊會話 " + session.getId() + " 已刪除");
}
}
}
// ======================================
return doAuthenticate(username, token);
}
private AuthenticationInfo doAuthenticate(String username, AuthenticationToken token) {
// 實際項目中:查詢數據庫驗證密碼、鎖定狀態等
// 此處簡化:假設密碼驗證通過
String passwordFromDb = "123456"; // 模擬從 DB 獲取
return new SimpleAuthenticationInfo(username, passwordFromDb, getName());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 權限授權邏輯(角色/權限查詢)
return null;
}
}
| 組件 | 作用 | 關鍵方法 |
|---|---|---|
DefaultWebSecurityManager |
Web 環境安全管理器 | getSessionManager() |
DefaultWebSessionManager |
Web 會話管理器 | getSessionDAO(), getActiveSessions() |
SessionDAO |
會話持久化/查詢接口 | getActiveSessions(), delete(Session) |
MemorySessionDAO |
內存實現(單機) | 集群需替換為 RedisSessionDAO/JdbcSessionDAO |
DefaultSubjectContext.PRINCIPALS_SESSION_KEY |
Session 中存儲主體標識的鍵常量 | 值為 shiroPrincipalCollection |
login.jsp# shiro-redis 示例 sessionDAO = org.crazycake.shiro.RedisSessionDAO sessionDAO.redisManager = $redisManager sessionDAO.expire = 1800
getActiveSessions() 遍歷所有會話,用戶量大時性能差Map<String, SessionId> usernameToSessionId(Redis Hash 或 ConcurrentHashMap)| 現象 | 原因 | 解決 |
|---|---|---|
| 踢下線不生效,舊端仍可操作 | SessionDAO 非集群實現,多節點會話不同步 | |
| 刪除 Session 後報 NullPointerException | Session 屬性鍵不匹配,PRINCIPALS_SESSION_KEY 為空 |
|
| 並發登錄導致數據不一致 | ||
| RememberMe 導致自動登錄繞過踢下線 |
sessionManager + sessionDAO 必須在 securityManager 中注入MemorySessionDAO 僅適合單機,生產必換 RedisSessionDAO單用戶登錄不僅是安全合規,更是會話管理能力的綜合體現。掌握 Shiro Session 體系,可輕鬆擴展會話審計、異地登錄預警、設備指紋綁定等高級特性。