Shiro通过Redis管理会话实现集群
流程概要说明
1.Servlet容器在用户浏览器首次访问后会产生Session,并将Session的ID保存到Cookie中(浏览器不同key不一定相同),同时Shiro会将该Session缓存到Redis中;
2.用户登录认证成功后Shiro会修改Session属性,添加用户认证成功标识,并同步修改Redis中Session;
3.用户发起请求后,Shiro会先判断本地EhCache缓存中是否存在该Session,如果有,直接从本地EhCache缓存中读取,如果没有再从Redis中读取Session,并在此时判断Session是否认证通过,如果认证通过将该Session缓存到本地EhCache中;
4.如果Session发生改变,或被删除(用户退出登录),先对Redis中Session做相应修改(修改或删除);再通过Redis消息通道发布缓存失效消息,通知其它节点EhCache失效。
1.S
写在前面
1.在上一篇帖子 Shiro一些补充 中提到过Shiro可以使用Shiro自己的Session或者自定义的Session来代替HttpSession
2.Redis/Jedis参考我写的 http://sgq0085.iteye.com/category/317384 一系列内容
一. SessionDao
配置在sessionManager中,可选项,如果不修改默认使用MemorySessionDAO,即在本机内存中操作。
如果想通过Redis管理Session,从这里入手。只需要实现类似DAO接口的CRUD即可。
经过1:最开始通过继承AbstractSessionDAO实现,发现doReadSession方法调用过于频繁,所以改为通过集成CachingSessionDAO来实现。
注意,本地缓存通过EhCache实现,失效时间一定要远小于Redis失效时间,这样本地失效后,会访问Redis读取,并重新设置Redis上会话数据的过期时间。
因为Jedis API KEY和Value相同,同为String或同为byte[]为了方便扩展下面的方法
import com.google.common.collect.Lists; import org.apache.commons.lang3.SerializationUtils; import org.apache.shiro.codec.Base64; import org.apache.shiro.session.Session; import java.io.Serializable; import java.util.Collection; import java.util.List; public class SerializeUtils extends SerializationUtils { public static String serializeToString(Serializable obj) { try { byte[] value = serialize(obj); return Base64.encodeToString(value); } catch (Exception e) { throw new RuntimeException("serialize session error", e); } } public static <T> T deserializeFromString(String base64) { try { byte[] objectData = Base64.decode(base64); return deserialize(objectData); } catch (Exception e) { throw new RuntimeException("deserialize session error", e); } } public static <T> Collection<T> deserializeFromStringController(Collection<String> base64s) { try { List<T> list = Lists.newLinkedList(); for (String base64 : base64s) { byte[] objectData = Base64.decode(base64); T t = deserialize(objectData); list.add(t); } return list; } catch (Exception e) { throw new RuntimeException("deserialize session error", e); } } }
我的Dao实现,ShiroSession是我自己实现的,原因在后面说明,默认使用的是SimpleSession
import com.genertech.adp.web.common.utils.SerializeUtils; import com.genertech.adp.web.sys.authentication.component.ShiroSession; import com.genertech.adp.web.sys.redis.component.JedisUtils; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.session.Session; import org.apache.shiro.session.UnknownSessionException; import org.apache.shiro.session.mgt.ValidatingSession; import org.apache.shiro.session.mgt.eis.CachingSessionDAO; import org.apache.shiro.subject.support.DefaultSubjectContext; import org.apache.shiro.util.CollectionUtils; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import redis.clients.jedis.Jedis; import redis.clients.jedis.Transaction; import java.io.Serializable; import java.util.Collection; import java.util.List; import java.util.Set; /** * 针对自定义的ShiroSession的Redis CRUD操作,通过isChanged标识符,确定是否需要调用Update方法 * 通过配置securityManager在属性cacheManager查找从缓存中查找Session是否存在,如果找不到才调用下面方法 * Shiro内部相应的组件(DefaultSecurityManager)会自动检测相应的对象(如Realm)是否实现了CacheManagerAware并自动注入相应的CacheManager。 */ public class ShiroSessionDao extends CachingSessionDAO { private static final Logger logger = LoggerFactory.getLogger(ShiroSessionDao.class); // 保存到Redis中key的前缀 prefix+sessionId private String prefix = ""; // 设置会话的过期时间 private int seconds = 0; // 特殊配置 只用于没有Redis时 将Session放到EhCache中 private Boolean onlyEhCache; @Autowired private JedisUtils jedisUtils; /** * 重写CachingSessionDAO中readSession方法,如果Session中没有登陆信息就调用doReadSession方法从Redis中重读 */ // @Override public Session readSession(Serializable sessionId) throws UnknownSessionException { Session cached = null; try { cached = super.getCachedSession(sessionId); } catch (Exception e) { e.printStackTrace(); } if (onlyEhCache) { return cached; } // 如果缓存不存在或者缓存中没有登陆认证后记录的信息就重新从Redis中读取 if (cached == null || cached.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY) == null) { try { cached = this.doReadSession(sessionId); if (cached == null) { throw new UnknownSessionException(); } else { // 重置Redis中缓存过期时间并缓存起来 只有设置change才能更改最后一次访问时间 ((ShiroSession) cached).setChanged(true); super.update(cached); } } catch (Exception e) { logger.warn("There is no session with id [" + sessionId + "]"); } } return cached; } /** * 从Redis中读取Session,并重置过期时间 * * @param sessionId 会话ID * @return ShiroSession */ // @Override protected Session doReadSession(Serializable sessionId) { Session session = null; Jedis jedis = null; try { jedis = jedisUtils.getResource(); String key = prefix + sessionId; String value = jedis.get(key); if (StringUtils.isNotBlank(value)) { session = SerializeUtils.deserializeFromString(value); logger.info("shiro session id {} 被读取", sessionId); } } catch (Exception e) { logger.warn("读取Session失败", e); } finally { jedisUtils.returnResource(jedis); } return session; } /** * 从Redis中读取,但不重置Redis中缓存过期时间 */ public Session doReadSessionWithoutExpire(Serializable sessionId) { if (onlyEhCache) { return readSession(sessionId); } Session session = null; Jedis jedis = null; try { jedis = jedisUtils.getResource(); String key = prefix + sessionId; String value = jedis.get(key); if (StringUtils.isNotBlank(value)) { session = SerializeUtils.deserializeFromString(value); } } catch (Exception e) { logger.warn("读取Session失败", e); } finally { jedisUtils.returnResource(jedis); } return session; } /** * 如DefaultSessionManager在创建完session后会调用该方法; * 如保存到关系数据库/文件系统/NoSQL数据库;即可以实现会话的持久化; * 返回会话ID;主要此处返回的ID.equals(session.getId()); */ // @Override protected Serializable doCreate(Session session) { // 创建一个Id并设置给Session Serializable sessionId = this.generateSessionId(session); assignSessionId(session, sessionId); if (onlyEhCache) { return sessionId; } Jedis jedis = null; try { jedis = jedisUtils.getResource(); // session由Redis缓存失效决定,这里只是简单标识 session.setTimeout(seconds); jedis.setex(prefix + sessionId, seconds, SerializeUtils.serializeToString((ShiroSession) session)); logger.info("shiro session id {} 被创建", sessionId); } catch (Exception e) { logger.warn("创建Session失败", e); } finally { jedisUtils.returnResource(jedis); } return sessionId; } /** * 更新会话;如更新会话最后访问时间/停止会话/设置超时时间/设置移除属性等会调用 */ // @Override protected void doUpdate(Session session) { //如果会话过期/停止 没必要再更新了 try { if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) { return; } } catch (Exception e) { logger.error("ValidatingSession error"); } if (onlyEhCache) { return; } Jedis jedis = null; try { if (session instanceof ShiroSession) { // 如果没有主要字段(除lastAccessTime以外其他字段)发生改变 ShiroSession ss = (ShiroSession) session; if (!ss.isChanged()) { return; } Transaction tx = null; try { jedis = jedisUtils.getResource(); // 开启事务 tx = jedis.multi(); ss.setChanged(false); ss.setLastAccessTime(DateTime.now().toDate()); tx.setex(prefix + session.getId(), seconds, SerializeUtils.serializeToString(ss)); logger.info("shiro session id {} 被更新", session.getId(), session.getClass().getName()); // 执行事务 tx.exec(); } catch (Exception e) { if (tx != null) { // 取消执行事务 tx.discard(); } throw e; } } else if (session instanceof Serializable) { jedis = jedisUtils.getResource(); jedis.setex(prefix + session.getId(), seconds, SerializeUtils.serializeToString((Serializable) session)); logger.info("ID {} classname {} 作为非ShiroSession对象被更新, ", session.getId(), session.getClass().getName()); } else { logger.info("ID {} classname {} 不能被序列化 更新失败", session.getId(), session.getClass().getName()); } } catch (Exception e) { logger.warn("更新Session失败", e); } finally { jedisUtils.returnResource(jedis); } } /** * 删除会话;当会话过期/会话停止(如用户退出时)会调用 */ @Override public void doDelete(Session session) { Jedis jedis = null; try { jedis = jedisUtils.getResource(); jedis.del(prefix + session.getId()); logger.info("shiro session id {} 被删除", session.getId()); } catch (Exception e) { logger.warn("删除Session失败", e); } finally { jedisUtils.returnResource(jedis); } } /** * 删除cache中缓存的Session */ public void uncache(Serializable sessionId) { try { Session session = super.getCachedSession(sessionId); super.uncache(session); logger.info("shiro session id {} 的缓存失效", sessionId); } catch (Exception e) { e.printStackTrace(); } } /** * 获取当前所有活跃用户,如果用户量多此方法影响性能 */ @Override public Collection<Session> getActiveSessions() { Jedis jedis = null; try { jedis = jedisUtils.getResource(); Set<String> keys = jedis.keys(prefix + "*"); if (CollectionUtils.isEmpty(keys)) { return null; } List<String> valueList = jedis.mget(keys.toArray(new String[0])); return SerializeUtils.deserializeFromStringController(valueList); } catch (Exception e) { logger.warn("统计Session信息失败", e); } finally { jedisUtils.returnResource(jedis); } return null; } /** * 返回本机Ehcache中Session */ public Collection<Session> getEhCacheActiveSessions() { return super.getActiveSessions(); } public void setPrefix(String prefix) { this.prefix = prefix; } public void setSeconds(int seconds) { this.seconds = seconds; } public void setOnlyEhCache(Boolean onlyEhCache) { this.onlyEhCache = onlyEhCache; } }
二.Session和SessionFactory
步骤2:经过上面的开发已经可以使用的,但发现每次访问都会多次调用SessionDAO的doUpdate方法,来更新Redis上数据,过来发现更新的字段只有LastAccessTime(最后一次访问时间),由于会话失效是由Redis数据过期实现的,这个字段意义不大,为了减少对Redis的访问,降低网络压力,实现自己的Session,在SimpleSession上套一层,增加一个标识位,如果Session除lastAccessTime意外其它字段修改,就标识一下,只有标识为修改的才可以通过doUpdate访问Redis,否则直接返回。这也是上面SessionDao中doUpdate中逻辑判断的意义
package com.gqshao.authentication.session; import org.apache.shiro.session.mgt.SimpleSession; import java.io.Serializable; import java.util.Date; import java.util.Map; /** * 由于SimpleSession lastAccessTime更改后也会调用SessionDao update方法, * 增加标识位,如果只是更新lastAccessTime SessionDao update方法直接返回 */ public class ShiroSession extends SimpleSession implements Serializable { // 除lastAccessTime以外其他字段发生改变时为true private boolean isChanged; public ShiroSession() { super(); this.setChanged(true); } public ShiroSession(String host) { super(host); this.setChanged(true); } @Override public void setId(Serializable id) { super.setId(id); this.setChanged(true); } @Override public void setStopTimestamp(Date stopTimestamp) { super.setStopTimestamp(stopTimestamp); this.setChanged(true); } @Override public void setExpired(boolean expired) { super.setExpired(expired); this.setChanged(true); } @Override public void setTimeout(long timeout) { super.setTimeout(timeout); this.setChanged(true); } @Override public void setHost(String host) { super.setHost(host); this.setChanged(true); } @Override public void setAttributes(Map<Object, Object> attributes) { super.setAttributes(attributes); this.setChanged(true); } @Override public void setAttribute(Object key, Object value) { super.setAttribute(key, value); this.setChanged(true); } @Override public Object removeAttribute(Object key) { this.setChanged(true); return super.removeAttribute(key); } /** * 停止 */ @Override public void stop() { super.stop(); this.setChanged(true); } /** * 设置过期 */ @Override protected void expire() { this.stop(); this.setExpired(true); } public boolean isChanged() { return isChanged; } public void setChanged(boolean isChanged) { this.isChanged = isChanged; } @Override public boolean equals(Object obj) { return super.equals(obj); } @Override protected boolean onEquals(SimpleSession ss) { return super.onEquals(ss); } @Override public int hashCode() { return super.hashCode(); } @Override public String toString() { return super.toString(); } }
package com.gqshao.authentication.session; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.session.Session; import org.apache.shiro.session.mgt.SessionContext; import org.apache.shiro.session.mgt.SessionFactory; import org.apache.shiro.web.session.mgt.DefaultWebSessionContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.http.HttpServletRequest; public class ShiroSessionFactory implements SessionFactory { private static final Logger logger = LoggerFactory.getLogger(ShiroSessionFactory.class); @Override public Session createSession(SessionContext initData) { ShiroSession session = new ShiroSession(); HttpServletRequest request = (HttpServletRequest)initData.get(DefaultWebSessionContext.class.getName() + ".SERVLET_REQUEST"); session.setHost(getIpAddress(request)); return session; } public static String getIpAddress(HttpServletRequest request) { String localIP = "127.0.0.1"; String ip = request.getHeader("x-forwarded-for"); if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } }
三.SessionListener
步骤3:发现用户退出后,Session没有从Redis中销毁,虽然当前重新new了一个,但会对统计带来干扰,通过SessionListener解决这个问题
package com.gqshao.authentication.listener; import com.gqshao.authentication.dao.CachingShiroSessionDao; import org.apache.shiro.session.Session; import org.apache.shiro.session.SessionListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; public class ShiroSessionListener implements SessionListener { private static final Logger logger = LoggerFactory.getLogger(ShiroSessionListener.class); @Autowired private CachingShiroSessionDao sessionDao; @Override public void onStart(Session session) { // 会话创建时触发 logger.info("ShiroSessionListener session {} 被创建", session.getId()); } @Override public void onStop(Session session) { sessionDao.delete(session); // 会话被停止时触发 logger.info("ShiroSessionListener session {} 被销毁", session.getId()); } @Override public void onExpiration(Session session) { sessionDao.delete(session); //会话过期时触发 logger.info("ShiroSessionListener session {} 过期", session.getId()); } }
四.将账号信息放到Session中
修改realm中AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken)方法,在返回AuthenticationInfo之前添加下面的代码,把用户信息放到Session中
// 把账号信息放到Session中,并更新缓存,用于会话管理 Subject subject = SecurityUtils.getSubject(); Serializable sessionId = subject.getSession().getId(); ShiroSession session = (ShiroSession) sessionDao.doReadSessionWithoutExpire(sessionId); session.setAttribute("userId", su.getId()); session.setAttribute("loginName", su.getLoginName()); sessionDao.update(session);
五. 配置文件
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:util="http://www.springframework.org/schema/util" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-3.2.xsd"> <description>Shiro安全配置</description> <!-- Shiro's main business-tier object for web-enabled applications --> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="shiroDbRealm"/> <!-- 可选项 最好使用,SessionDao,中 doReadSession 读取过于频繁了--> <property name="cacheManager" ref="shiroEhcacheManager"/> <!--可选项 默认使用ServletContainerSessionManager,直接使用容器的HttpSession,可以通过配置sessionManager,使用DefaultWebSessionManager来替代--> <property name="sessionManager" ref="sessionManager"/> </bean> <!-- 項目自定义的Realm --> <bean id="shiroDbRealm" class="com.gqshao.authentication.realm.ShiroDbRealm"/> <!-- Shiro Filter --> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager"/> <!-- 指向登陆路径,整合spring时指向控制器方法地址 --> <property name="loginUrl" value="/login"/> <property name="successUrl" value="/"/> <!-- 可选配置,通过实现自己的AuthenticatingFilter实现表单的自定义 --> <property name="filters"> <util:map> <entry key="authc"> <bean class="com.gqshao.authentication.filter.MyAuthenticationFilter"/> </entry> </util:map> </property> <property name="filterChainDefinitions"> <value> /login = authc /logout = logout /static/** = anon /** = user </value> </property> </bean> <!-- 用户授权信息Cache, 采用EhCache,本地缓存最长时间应比*缓存时间短一些,以确保Session中doReadSession方法调用时更新*缓存过期时间 --> <bean id="shiroEhcacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager"> <property name="cacheManagerConfigFile" value="classpath:security/ehcache-shiro.xml"/> </bean> <bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager"> <!-- 设置全局会话超时时间,默认30分钟(1800000) --> <property name="globalSessionTimeout" value="1800000"/> <!-- 是否在会话过期后会调用SessionDAO的delete方法删除会话 默认true--> <property name="deleteInvalidSessions" value="false"/> <!-- 是否开启会话验证器任务 默认true --> <property name="sessionValidationSchedulerEnabled" value="false"/> <!-- 会话验证器调度时间 --> <property name="sessionValidationInterval" value="1800000"/> <property name="sessionFactory" ref="sessionFactory"/> <property name="sessionDAO" ref="sessionDao"/> <!-- 默认JSESSIONID,同tomcat/jetty在cookie中缓存标识相同,修改用于防止访问404页面时,容器生成的标识把shiro的覆盖掉 --> <property name="sessionIdCookie"> <bean class="org.apache.shiro.web.servlet.SimpleCookie"> <constructor-arg name="name" value="SHRIOSESSIONID"/> </bean> </property> <property name="sessionListeners"> <list> <bean class="com.gqshao.authentication.listener.ShiroSessionListener"/> </list> </property> </bean> <!-- 自定义Session工厂方法 返回会标识是否修改主要字段的自定义Session--> <bean id="sessionFactory" class="com.gqshao.authentication.session.ShiroSessionFactory"/> <!-- 普通持久化接口,不会被缓存 每次doReadSession会被反复调用 --> <!--<bean class="com.gqshao.authentication.dao.RedisSessionDao">--> <!-- 使用可被缓存的Dao ,本地缓存减轻网络压力 --> <!--<bean id="sessionDao" class="com.gqshao.authentication.dao.CachingSessionDao">--> <!-- 可缓存Dao,操作自定义Session,添加标识位,减少doUpdate方法中Redis的连接次数来减轻网络压力 --> <bean id="sessionDao" class="com.gqshao.authentication.dao.CachingShiroSessionDao"> <property name="prefix" value="ShiroSession_"/> <!-- 注意*缓存有效时间要比本地缓存有效时间长--> <property name="seconds" value="1800"/> <!-- 特殊配置 只用于没有Redis时 将Session放到EhCache中 --> <property name="onlyEhCache" value="false"/> </bean> <!-- 保证实现了Shiro内部lifecycle函数的bean执行 --> <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/> <!-- AOP式方法级权限检查 --> <bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator" depends-on="lifecycleBeanPostProcessor"> <property name="proxyTargetClass" value="true"/> </bean> <bean class="org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor"> <property name="securityManager" ref="securityManager"/> </bean> </beans>
<ehcache updateCheck="false" name="shiroCache"> <!-- timeToIdleSeconds 当缓存闲置n秒后销毁 为了保障会调用ShiroSessionDao的doReadSession方法,所以不配置该属性 timeToLiveSeconds 当缓存存活n秒后销毁 必须比Redis中过期时间短 --> <defaultCache maxElementsInMemory="10000" eternal="false" timeToLiveSeconds="60" overflowToDisk="false" diskPersistent="false" diskExpiryThreadIntervalSeconds="10" /> </ehcache>
六.测试会话管理
package com.gqshao.authentication.controller; import com.gqshao.authentication.dao.CachingShiroSessionDao; import org.apache.shiro.session.Session; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import java.io.Serializable; import java.util.Collection; @Controller @RequestMapping("/session") public class SessionController { @Autowired private CachingShiroSessionDao sessionDao; @RequestMapping("/active") @ResponseBody public Collection<Session> getActiveSessions() { return sessionDao.getActiveSessions(); } @RequestMapping("/read") @ResponseBody public Session readSession(Serializable sessionId) { return sessionDao.doReadSessionWithoutExpire(sessionId); } }
七.集群情况下的改造
1.问题上面启用了Redis*缓存、EhCache本地JVM缓存,AuthorizingRealm的doGetAuthenticationInfo登陆认证方法返回的AuthenticationInfo,默认情况下会被保存到Session的Attribute下面两个字段中
org.apache.shiro.subject.support.DefaultSubjectContext.PRINCIPALS_SESSION_KEY 保存 principal org.apache.shiro.subject.support.DefaultSubjectContext.AUTHENTICATED_SESSION_KEY 保存 boolean是否登陆
然后在每次请求过程中,在ShiroFilter中组装Subject时,读取Session中这两个字段
现在的问题是Session被缓存到本地JVM堆中,也就是说服务器A登陆,无法修改服务器B的EhCache中Session属性,导致服务器B没有登陆。
处理方法有很多思路,比如重写CachingSessionDAO,readSession如果没有这两个属性就不缓存(没登陆就不缓存),或者cache的session没有这两个属性就调用自己实现的doReadSession方法从Redis中重读一下。
2.readSession中每次调用doReadSession方法的时候,都代表第一次读取,或本地EhCache失效,我们可以在这个时候调用一下updateSession方法,重新设置一下最后一次访问时间,当然要把isChange设置为true才会保存到Redis中。
3.如果需要保持各个服务器Session是完全同步的,可以通过Redis消息订阅/发布功能,订阅一份消息,当得到消息后,可以调用SessionDao中已经实现了删除Session本地缓存的方法