shiro+redis环境中session错乱问题
在shiro+redis环境中使用RedisSessionDAO 操作session遇到的session错乱的问题
1. 问题描述
环境为Spring boot的项目中使用shiro框架(Shiro-Core 为1.6版本)作为会话管理,session存储在redis中,redisSession操作使用的是org.crazycake的shiro-redis。系统登录页面login(),输入用户名、密码,验证成功后进入到默认首页,然后马上点击任一菜单之后,偶尔会发生退回到登录页面的情况。
2. 分析过程
该情况只在启用session存储在redis的情况下会发生,所以分析和redis的存储或读取有关系,因为该情况偶尔会发生,也没有什么规律,只能采用记录日志的方式。
分析日志,发现是在shiro过滤器判断用户是否登录时,判断当前请求未登录而导致退出。
Subject subject = SecurityUtils.getSubject(httpServletRequest,httpServletResponse); if (!subject.isAuthenticated() /*&& !subject.isRemembered()*/) {//没有登录的情况 if (httpServletRequest.getHeader("x-requested-with") != null && "XMLHttpRequest".equalsIgnoreCase(httpServletRequest.getHeader("x-requested-with"))) { httpServletResponse.setHeader("sessionstatus", "timeout"); return false; } else { String referer = httpServletRequest.getHeader("Referer"); if (referer == null) { jumpToPage(request, response,"未登录"); return false; } else if (ShiroKit.getSession().getAttribute("sessionFlag") == null) { logger.error("174 subject:{}",subject.toString()); logger.info(httpServletRequest.getServletPath()); logger.info("174 session-id:" +ShiroKit.getSession().getId().toString()); Collection<Object> attributeKeys = ShiroKit.getSession().getAttributeKeys(); for (Object object: attributeKeys) { logger.info("174 session-content:" +object); } request.setAttribute("tips", ShiroKit.getSession().getAttribute("tips")); forward(request,response,"174"); return false; } else { jumpToPage(request, response,"未登录"); return false; } } }
第一就是怀疑是读取cookie创建sessionid的时候有问题,但是根据打出的日志内容,发现此时传递的cookie和创建的sessionid都是正常的。然后怀疑session读取有问题,但是打出的日志也看不出,下面记录了如何RedisSessionDAO打印出日志。
而org.crazycake的shiro-redis中的RedisSessionDAO类,只记录一些异常情况,所以新建一个SessionDAO类,在shiroConfig中进行配置
@Bean @ConditionalOnProperty( prefix = "global", name = {"stand-alone"}, havingValue = "false", matchIfMissing = false ) public SessionDAO redisSessionDAO(IRedisManager redisManager) { SessionDAO sessionDAO = null; /* sessionDAO = new RedisSessionDAO(); ((RedisSessionDAO) sessionDAO).setRedisManager(redisManager);*/ sessionDAO = new MyRedisSessionDAO(); ((MyRedisSessionDAO) sessionDAO).setRedisManager(redisManager); return sessionDAO; }
@Bean @ConditionalOnProperty( prefix = "global", name = {"spring-session-open"}, havingValue = "false" ) public DefaultWebSessionManager defaultWebSessionManager(CacheManager cacheShiroManager, Collection<SessionListener> listeners, SessionDAO sessionDAO) { DefaultWebSessionManager sessionManager = new AdminWebSessionManager(); sessionManager.setSessionValidationScheduler(this.sessionValidationScheduler(sessionManager)); sessionManager.setSessionValidationInterval((long) (this. Properties.getSessionValidationInterval() * this.kilo)); sessionManager.setGlobalSessionTimeout((long) (this. Properties.getSessionInvalidateTime() * this.kilo)); sessionManager.setDeleteInvalidSessions(true); sessionManager.setSessionValidationSchedulerEnabled(true); sessionManager.setSessionIdUrlRewritingEnabled(false); sessionManager.setSessionListeners(listeners); sessionManager.setCacheManager(cacheShiroManager); sessionManager.setSessionDAO(sessionDAO); sessionManager.setSessionIdCookieEnabled(true); Cookie cookie = new SimpleCookie(this.globalProperties.getTitle() + "_cookie"); cookie.setHttpOnly(true); sessionManager.setSessionIdCookie(cookie); return sessionManager; }
新建的类MyRedisSessionDAO的读取session代码修改如下:
protected Session doReadSession(Serializable sessionId) { if (sessionId == null) { logger.warn("session id is null"); return null; } else { Session session; if (this.sessionInMemoryEnabled) { session = this.getSessionFromThreadLocal(sessionId);//从当前线程的threadlocal中获取session logger.info("read session from memory"); if (session != null) { return session; } } session = null; logger.info("read session from redis"); try { String content = ""; byte[] bytes = this.redisManager.get(this.keySerializer.serialize(this.getRedisSessionKey(sessionId))); if(bytes != null){ content = new String(bytes); } session = (Session)this.valueSerializer.deserialize(this.redisManager.get(this.keySerializer.serialize(this.getRedisSessionKey(sessionId)))); logger.info("session's content is :" +content); if (this.sessionInMemoryEnabled) { this.setSessionToThreadLocal(sessionId, session); } } catch (SerializationException var4) { logger.error("read session error. settionId=" + sessionId); } return session; } }
分析SessionDAO读取session的逻辑,RedisSessionDAO的设计中,为了避免频繁的读取redis,默认设置了1000ms时间范围内先在当前线程的ThreadLocal中获取,如果没有则再读取redis,读取后再写到当前线程的ThreadLocal中。感觉这里有可能会在某些情况下有问题。
再往下分析日志,通过在shiro的判断用户是否登录的过滤器中打印的日志,发现出现问题的时候,处理url地址为/login请求方式为get的线程和登录成功后点击某个菜单的线程名称一样,同为“http-nio-exec-18”,而且此时异常退出的url,其日志打印出的session中的内容和url地址为/login请求方式为get的线程的session相同,那么就怀疑是使用了url地址为/login请求方式为get的线程,而url地址为/login请求方式为get的线程没有释放threadlocal中的内容所导致的问题。
3. 结论
发生问题的具体流程
一. 用户访问登录页面,地址为/login,请求方式为get,Shiro框架为其分配一个sessionid,假如为1,存储在cookie中,此时后端服务器也在redis中存储了sessionid为1的session对象,同时由于redisSessionDao考虑频繁读取redis的原因,还将该session对象存储到当前request线程的threadlocal中,此时的session对象没有用户的相关信息
二. 用户输入用户名、密码,验证成功后,将sessionid为1的session对象存储到redis中(替换之前存储在redis中的sessionid为1的对象),此时的session对象已经包含用户的相关信息,标识已经登录,并将该对象放到当前request线程的threadlocal中,然后跳转到默认首页,先执行shiro的判断用户是否登陆的过滤器代码,该过滤器判断当前subject的session是否应经登录,此时如果分配的不是第一步的处理url为/login,请求方式为get的线程,那么过滤器判断已经登录,放行到首页。
三. 用户立即点击某个菜单,访问一个url地址,tomcat从线程池中为当前请求分配一个线程,此时刚好分配了之前处理url地址为/login、请求方式为get的线程;此时再次执行shiro的过滤器判断当前subject是否登录,而subject获取session的代码就是先判断当前线程的threadlocal中是否有sessionid为1的session对象,由于当前线程就是刚刚处理url地址为/login、请求方式为get的线程,并且sessionid也是相同(登录前login页面分配的sessionid和登录后的sessionid始终都是相同的),所以刚好能从当前线程中取到session对象,也就不会再去redis中取session对象(redis中的session对象是正确的),但是该session对象是不包含用户登录信息的,所以过滤器中的逻辑就是判断用户没有登录,就退出到登录页面了。
4. 解决方法
新建一个过滤器,该过滤器优先级最高(职责链上第一个执行,最后一个退出),该过滤器在职责链最后将当前线程的threadlocal清除掉。代码如下:
import javax.servlet.*; import java.io.IOException; public class RemoveShiroThreadContextFilter implements Filter { private static Logger LOGGER = LoggerFactory.getLogger(RemoveShiroThreadContextFilter.class); @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { try { filterChain.doFilter(servletRequest, servletResponse); } finally { ThreadContext.remove(); } } }
WebConfig中的配置:
@Bean public FilterRegistrationBean<RemoveShiroThreadContextFilter> shiroThreadFilterRegistration() { RemoveShiroThreadContextFilter shiroThreadContextFilter = new RemoveShiroThreadContextFilter(); FilterRegistrationBean<RemoveShiroThreadContextFilter> registration = new FilterRegistrationBean(shiroThreadContextFilter, new ServletRegistrationBean[0]); registration.addUrlPatterns(new String[]{"/*"}); registration.setOrder(-100); return registration; }
参考文章: