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;

}

 参考文章:

Shiro在多线程环境中

线程池shiro获取当前user出错问题,及解决方案 

netty整合shiro,报There is no session with id [xxxxxx]问题定位及解决

session 莫名丢失

分类:

技术点:

相关文章: