SSM整合Shiro
同一个账号登陆人数控制
实现思路
为每一个账号创建一个队列,用来保存登录该账号的sessionId。以账号ID(也就是userId)为key,对应的队列为value的形式存入Redis。每当用户登录时执行以下操作:
- 根据userId,从Redis中取得队列;
- 判断当前的session,如果sessionId不在队列中,而且该session不是被强制下线的,将sessionId放入队列;
- 判断队列大小是否大于设置的最大登录人数,如果大于最大登录人数,从队列中删除该sessionId,并且设置session为强制下线状态;
- 根据session是否是被强制下线的进行强制下线操作;
创建登录人数控制过滤器
package com.demo.filter;
import java.io.Serializable;
import java.util.concurrent.ConcurrentLinkedDeque;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.InitializingBean;
import com.demo.model.LearnShiroUser;
/**
*
* 控制同一账号登陆人数<br>
* <br>
* -------------------------------------<br>
* 创建人员: ToBeNumberTwo<br>
* 创建时间: 2020年5月19日 下午7:34:44<br>
* -------------------------------------<br>
* 修改人员: ToBeNumberTwo<br>
* 修改时间: 2020年5月19日 下午7:34:44<br>
* -------------------------------------<br>
*/
public class ShiroMyUserLoginCountFilter extends AccessControlFilter implements InitializingBean {
private String forceOfflineUrl;
private boolean forceOfflineHeadFlg;
private int maxUserCount;
private SessionManager sessionManager;
private String loginUserDequeCacheName;
private CacheManager cacheManager;
private Cache<String, ConcurrentLinkedDeque<Serializable>> loginUserDequeCache;
public void setForceOfflineUrl(String forceOfflineUrl) {
this.forceOfflineUrl = forceOfflineUrl;
}
public void setForceOfflineHeadFlg(boolean forceOfflineHeadFlg) {
this.forceOfflineHeadFlg = forceOfflineHeadFlg;
}
public void setMaxUserCount(int maxUserCount) {
this.maxUserCount = maxUserCount;
}
public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public void setCacheManager(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void setLoginUserDequeCacheName(String loginUserDequeCacheName) {
this.loginUserDequeCacheName = loginUserDequeCacheName;
}
@Override
public void afterPropertiesSet() throws Exception {
this.loginUserDequeCache = this.cacheManager.getCache(this.loginUserDequeCacheName);
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
// 未登录的场合
if (!subject.isAuthenticated() && !subject.isRemembered()) {
return true;
}
// 从subject中获取session和user信息
Session session = subject.getSession();
LearnShiroUser user = (LearnShiroUser) subject.getPrincipal();
String userId = "" + user.getUserId();
Serializable sessionId = session.getId();
// 从缓存中获取用户队列
ConcurrentLinkedDeque<Serializable> loginUserDeque = loginUserDequeCache.get(userId);
// 初始化用户的队列
if (loginUserDeque == null) {
loginUserDeque = new ConcurrentLinkedDeque<Serializable>();
}
// 如果队列里没有此sessionId,且用户没有被强制下线,放入队列
if (!loginUserDeque.contains(sessionId) && session.getAttribute("hasRemoved") == null) {
loginUserDeque.push(sessionId);
}
while (loginUserDeque.size() > maxUserCount) {
// 被强制下线的sessionId
Serializable removedSessionId = null;
if (forceOfflineHeadFlg) {
// 强制下线先登录用户
removedSessionId = loginUserDeque.pollLast();
} else {
// 强制下线后登录用户
removedSessionId = loginUserDeque.pollFirst();
}
try {
Session removedSession = sessionManager.getSession(new DefaultSessionKey(removedSessionId));
if (removedSession != null) {
// 设置被强制下线的Session
removedSession.setAttribute("hasRemoved", true);
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 刷新缓存中队列的值
loginUserDequeCache.put(userId, loginUserDeque);
// 判断当前用户session是强制下线状态
if (session.getAttribute("hasRemoved") != null) {
try {
subject.logout();
} catch (Exception e) {
}
WebUtils.issueRedirect(request, response, forceOfflineUrl);
return false;
}
return true;
}
}
创建自定义ShiroSession监听器,当Session失效时从对应的队列中删除sessionId
package com.demo.listener;
import java.io.Serializable;
import java.util.concurrent.ConcurrentLinkedDeque;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.SessionListener;
import org.springframework.beans.factory.InitializingBean;
import com.demo.model.LearnShiroUser;
/**
*
* 自定义ShiroSession监听器,当Session失效时从对应的队列中删除sessionId<br>
* <br>
* -------------------------------------<br>
* 创建人员: ToBeNumberTwo<br>
* 创建时间: 2020年5月19日 下午7:30:47<br>
* -------------------------------------<br>
* 修改人员: ToBeNumberTwo<br>
* 修改时间: 2020年5月19日 下午7:30:47<br>
* -------------------------------------<br>
*/
public class ShiroMyForceOfflineSessionListener implements SessionListener, InitializingBean {
/** 登陆用户session队列缓存 */
private Cache<String, ConcurrentLinkedDeque<Serializable>> loginUserDequeCache;
private String loginUserDequeCacheName;
private CacheManager cacheManager;
@Override
public void afterPropertiesSet() throws Exception {
this.loginUserDequeCache = this.cacheManager.getCache(this.loginUserDequeCacheName);
}
/**
* session创建时触发
*
* @see org.apache.shiro.session.SessionListener#onStart(org.apache.shiro.session.Session)
* @param session
*/
@Override
public void onStart(Session session) {
}
/**
* session停用时触发(例如注销登陆时)
*
* @see org.apache.shiro.session.SessionListener#onStop(org.apache.shiro.session.Session)
* @param session
*/
@Override
public void onStop(Session session) {
removeLoginUserDequeCache(session);
}
/**
* session过期时触发
*
* @see org.apache.shiro.session.SessionListener#onExpiration(org.apache.shiro.session.Session)
* @param session
*/
@Override
public void onExpiration(Session session) {
removeLoginUserDequeCache(session);
}
/**
* 清除缓存中该session对应的用户队列中的值
*
* @param session
*/
private void removeLoginUserDequeCache(Session session) {
Serializable sessionId = session.getId();
LearnShiroUser user = session.getAttribute("user") != null ? (LearnShiroUser) session.getAttribute("user") : null;
if (user != null) {
ConcurrentLinkedDeque<Serializable> loginUserDeque = loginUserDequeCache.get("" + user.getUserId());
if (loginUserDeque != null) {
loginUserDeque.remove(sessionId);
// 刷新缓存
loginUserDequeCache.put("" + user.getUserId(), loginUserDeque);
}
}
}
/**
* loginUserDequeCacheNameを取得します。
*
* @return loginUserDequeCacheName
*/
public String getLoginUserDequeCacheName() {
return loginUserDequeCacheName;
}
/**
* loginUserDequeCacheNameを設定します。
*
* @param loginUserDequeCacheName loginUserDequeCacheName
*/
public void setLoginUserDequeCacheName(String loginUserDequeCacheName) {
this.loginUserDequeCacheName = loginUserDequeCacheName;
}
/**
* cacheManagerを取得します。
*
* @return cacheManager
*/
public CacheManager getCacheManager() {
return cacheManager;
}
/**
* cacheManagerを設定します。
*
* @param cacheManager cacheManager
*/
public void setCacheManager(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
}
修改application-shiro.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 注入自定义Realm对象(用户IDRealm) -->
<bean id="userIdRealm" class="com.demo.realm.UserIdRealm">
<!-- 开启缓存 -->
<property name="cachingEnabled" value="true"></property>
<!-- 开启认证缓存 -->
<property name="authenticationCachingEnabled" value="true"></property>
<!-- 认证缓存名称 -->
<property name="authenticationCacheName" value="authentication:UserIdRealm"></property>
<!-- 开启授权缓存 -->
<property name="authorizationCachingEnabled" value="true"></property>
<!-- 授权缓存名称 -->
<property name="authorizationCacheName" value="authorization:UserIdRealm"></property>
</bean>
<!-- 注入Cookie对象 -->
<bean id="rememberMeCookie" class="org.apache.shiro.web.servlet.SimpleCookie">
<!-- Cookie名 必须赋值且value固定 -->
<constructor-arg value="rememberMe"></constructor-arg>
<!-- 设置cookie有效时间单位:秒。 -->
<!-- <property name="maxAge" value="604800"></property> -->
<!-- 使用spring el表达式来计算 -->
<property name="maxAge" value="#{7*24*60*60}"></property>
<!-- 只有HTTP请求才保存cookie -->
<property name="httpOnly" value="true"></property>
</bean>
<!-- 注入Cookie管理器 -->
<bean id="rememberMeCookieManager" class="org.apache.shiro.web.mgt.CookieRememberMeManager">
<property name="cookie" ref="rememberMeCookie"></property>
</bean>
<!-- 注入Redis管理器 -->
<bean id="redisManager" class="org.crazycake.shiro.RedisManager">
<!-- 连接Redis的ip和端口号 -->
<property name="host" value="xxxxxx:xxxx"></property>
<!-- 如果Redis设置了登陆密码就需要赋值 -->
<property name="password" value="xxxx"></property>
<!-- 连接Redis的哪个库 -->
<property name="database" value="2"></property>
<!-- 连接超时时间 -->
<property name="timeout" value="2000"></property>
</bean>
<!-- 注入缓存管理器 -->
<bean id="cacheManager" class="org.crazycake.shiro.RedisCacheManager">
<property name="redisManager" ref="redisManager"></property>
<!-- 存放登陆认证和授权信息的缓存Key的前缀 默认值为shiro:cache: -->
<property name="keyPrefix" value="learnshiroredisSSM:cache:"></property>
<!-- 认证时 创建SimpleAuthenticationInfo对象的时,给它构造方法赋的第一个参数(user),对应的类中要有这个属性而且还有get方法。默认值是id -->
<property name="principalIdFieldName" value="userId"></property>
<!-- 有效时间 -->
<property name="expire" value="1800"></property>
</bean>
<!-- 注入Shiro的redisSessionDAO -->
<bean id="redisSessionDAO" class="org.crazycake.shiro.RedisSessionDAO">
<property name="redisManager" ref="redisManager"></property>
<!-- 存放Session的缓存的Key的前缀 默认值为shiro:session: -->
<property name="keyPrefix" value="learnshiroredisSSM:session:"></property>
<!-- 有效时间 -->
<property name="expire" value="1800"></property>
</bean>
<!-- 注入自定义SessionListener -->
<bean id="shiroMyForceOfflineSessionListener" class="com.demo.listener.ShiroMyForceOfflineSessionListener">
<property name="cacheManager" ref="cacheManager"></property>
<property name="loginUserDequeCacheName" value="loginUserDequeCache"></property>
</bean>
<!-- 注入Shiro的Session管理器 -->
<bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
<property name="sessionDAO" ref="redisSessionDAO"></property>
<!-- 设置自定义监听器 -->
<property name="sessionListeners">
<list>
<ref bean="shiroMyForceOfflineSessionListener" />
</list>
</property>
</bean>
<!-- 注入安全管理器 -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
<property name="rememberMeManager" ref="rememberMeCookieManager"></property>
<property name="sessionManager" ref="sessionManager"></property>
<property name="cacheManager" ref="cacheManager"></property>
<property name="realm" ref="userIdRealm"></property>
</bean>
<!-- 配置授权属性 -->
<bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor">
<property name="securityManager" ref="securityManager" />
</bean>
<bean class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
<property name="staticMethod" value="org.apache.shiro.SecurityUtils.setSecurityManager" />
<property name="arguments" ref="securityManager" />
</bean>
<!-- 注入自定义过滤器 -->
<!-- 记住我功能使用 -->
<bean id="shiroMyRememberMeFilter" class="com.demo.filter.ShiroMyRememberMeFilter"></bean>
<!-- 控制同一账号登录人数使用 -->
<bean id="shiroMyUserLoginCountFilter" class="com.demo.filter.ShiroMyUserLoginCountFilter">
<property name="forceOfflineUrl" value="/login/hasRemoved"></property>
<property name="forceOfflineHeadFlg" value="true"></property>
<property name="maxUserCount" value="1"></property>
<property name="sessionManager" ref="sessionManager"></property>
<property name="cacheManager" ref="cacheManager"></property>
<property name="loginUserDequeCacheName" value="loginUserDequeCache"></property>
</bean>
<!-- 配置shiro的过滤器链 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<!-- 安全管理器 -->
<property name="securityManager" ref="securityManager" />
<!-- 未认证时跳转 -->
<property name="loginUrl" value="/login/init" />
<!-- 未授权时跳转 -->
<property name="unauthorizedUrl" value="/unauthorized.jsp" />
<!-- 将自定义过滤器添加进来 -->
<property name="filters">
<map>
<entry key="shiroMyRememberMeFilter" value-ref="shiroMyRememberMeFilter"></entry>
<entry key="shiroMyUserLoginCountFilter" value-ref="shiroMyUserLoginCountFilter"></entry>
</map>
</property>
<!-- 配置放行规则 -->
<property name="filterChainDefinitions">
<value>
#不需要认证
/login/init/** = anon
/login/login/** = anon
/login/logout/** = anon
/login/hasRemoved/** = anon
/resources/** = anon
#权限授权拦截
/home/superAdminByFilter=shiroMyUserLoginCountFilter,perms["superAdmin"]
#记住我功能
/**=shiroMyUserLoginCountFilter,shiroMyRememberMeFilter,user
#需要认证
#使用记住我功能时,不能用/**=authc改为以下方式
/*=shiroMyUserLoginCountFilter,authc
/*/*=shiroMyUserLoginCountFilter,authc
</value>
</property>
</bean>
</beans>
效果
补充
上面控制登录人数的实现方式有一个bug,当同一账号最大登录人数大于1时,不使用记住登录功能时一切正常。当有一个人登录时选了记住登录,然后不注销登录,直接关闭浏览器。在他的session未失效之前,重新用浏览器直接访问主页时,会新生成一个session,这就多占用了一个登录名额…
目前想到的两个都不是很完美的解决方案:
一个就是“js监听到关闭浏览器时,通知后台去销毁session。但是js监听浏览器事件兼容性一直没解决”。
另外一个就是“在用户认证的时候,也就是UserIdRealm的doGetAuthenticationInfo方法中,把当前的sessionId存到user对象里。当用户关闭浏览器后再重新用浏览器直接访问主页时,user中的sessionId和新产生的sessionId是不一样的,而队列中的sessionId和user中的sessionId相同。在ShiroMyUserLoginCountFilter 进行处理时判断,如果是通过记住登录功能进行登录的就把队列中的sessionId替换成新产生的sessionId。这样就不会有多占名额的问题了”。这样改了之后认证时就不能用缓存。
能力不足,还请各位手下留情…