在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; }
參考文章: