Shiro权限管理框架(四):深入分析Shiro中的Session管理

  • 2019 年 10 月 31 日
  • 筆記

其实关于Shiro的一些学习笔记很早就该写了,因为懒癌和拖延症晚期一直没有落实,直到今天公司的一个项目碰到了在集群环境的单点登录频繁掉线的问题,为了解决这个问题,Shiro相关的文档和教程没少翻。最后问题解决了,但我觉得我也是时候来做一波Shiro学习笔记了。

本篇是Shiro系列第四篇,Shiro中的过滤器初始化流程和实现原理。Shiro基于URL的权限控制是通过Filter实现的,本篇从我们注入的ShiroFilterFactoryBean开始入手,翻看源码追寻Shiro中的过滤器的实现原理。

首发地址:https://www.guitu18.com/post/2019/08/08/45.html

Session

SessionManager

我们在配置Shiro时配置了一个DefaultWebSecurityManager,先来看下

DefaultWebSecurityManager

    public DefaultWebSecurityManager() {          super();          ((DefaultSubjectDAO) this.subjectDAO).setSessionStorageEvaluator(new DefaultWebSessionStorageEvaluator());          this.sessionMode = HTTP_SESSION_MODE;          setSubjectFactory(new DefaultWebSubjectFactory());          setRememberMeManager(new CookieRememberMeManager());          setSessionManager(new ServletContainerSessionManager());      }

在它的构造方法中注入了一个ServletContainerSessionManager

public class ServletContainerSessionManager implements WebSessionManager {      public Session getSession(SessionKey key) throws SessionException {          if (!WebUtils.isHttp(key)) {              String msg = "SessionKey must be an HTTP compatible implementation.";              throw new IllegalArgumentException(msg);          }          HttpServletRequest request = WebUtils.getHttpRequest(key);          Session session = null;          HttpSession httpSession = request.getSession(false);          if (httpSession != null) {              session = createSession(httpSession, request.getRemoteHost());          }          return session;      }        private String getHost(SessionContext context) {          String host = context.getHost();          if (host == null) {              ServletRequest request = WebUtils.getRequest(context);              if (request != null) {                  host = request.getRemoteHost();              }          }          return host;      }        protected Session createSession(SessionContext sessionContext) throws AuthorizationException {          if (!WebUtils.isHttp(sessionContext)) {              String msg = "SessionContext must be an HTTP compatible implementation.";              throw new IllegalArgumentException(msg);          }          HttpServletRequest request = WebUtils.getHttpRequest(sessionContext);          HttpSession httpSession = request.getSession();          String host = getHost(sessionContext);          return createSession(httpSession, host);      }        protected Session createSession(HttpSession httpSession, String host) {          return new HttpServletSession(httpSession, host);      }  }

ServletContainerSessionManager本身并不管理会话,它最终操作的还是HttpSession,所以只能在Servlet容器中起作用,它不能支持除使用HTTP协议的之外的任何会话。

所以一般我们配置Shiro都会配置一个DefaultWebSessionManager,它继承了DefaultSessionManager,看看DefaultSessionManager的构造方法:

public DefaultSessionManager() {      this.deleteInvalidSessions = true;      this.sessionFactory = new SimpleSessionFactory();      this.sessionDAO = new MemorySessionDAO();  }

这里的sessionDAO初始化了一个MemorySessionDAO,它其实就是一个Map,在内存中通过键值对管理Session。

public MemorySessionDAO() {      this.sessions = new ConcurrentHashMap<Serializable, Session>();  }

HttpServletSession

public class HttpServletSession implements Session {      public HttpServletSession(HttpSession httpSession, String host) {          if (httpSession == null) {              String msg = "HttpSession constructor argument cannot be null.";              throw new IllegalArgumentException(msg);          }          if (httpSession instanceof ShiroHttpSession) {              String msg = "HttpSession constructor argument cannot be an instance of ShiroHttpSession.  This " +                      "is enforced to prevent circular dependencies and infinite loops.";              throw new IllegalArgumentException(msg);          }          this.httpSession = httpSession;          if (StringUtils.hasText(host)) {              setHost(host);          }      }      protected void setHost(String host) {          setAttribute(HOST_SESSION_KEY, host);      }      public void setAttribute(Object key, Object value) throws InvalidSessionException {          try {              httpSession.setAttribute(assertString(key), value);          } catch (Exception e) {              throw new InvalidSessionException(e);          }      }  }

Shiro的HttpServletSession只是对javax.servlet.http.HttpSession进行了简单的封装,所以在Web应用中对Session的相关操作最终都是对javax.servlet.http.HttpSession进行的,比如上面代码中的setHost()是将内容以键值对的形式保存在httpSession中。

先来看下这张图:

先了解上面这几个类的关系和作用,然后我们想管理Shiro中的一些数据就非常方便了。

SessionDao是Session管理的顶层接口,定义了Session的增删改查相关方法。

public interface SessionDAO {      Serializable create(Session session);      Session readSession(Serializable sessionId) throws UnknownSessionException;      void update(Session session) throws UnknownSessionException;      void delete(Session session);      Collection<Session> getActiveSessions();  }

AbstractSessionDao是一个抽象类,在它的构造方法中定义了JavaUuidSessionIdGenerator作为SessionIdGenerator用于生成SessionId。它虽然实现了create()和readSession()两个方法,但具体的流程调用的是它的两个抽象方法doCreate()和doReadSession(),需要它的子类去干活。

public abstract class AbstractSessionDAO implements SessionDAO {      private SessionIdGenerator sessionIdGenerator;      public AbstractSessionDAO() {          this.sessionIdGenerator = new JavaUuidSessionIdGenerator();      }        public Serializable create(Session session) {          Serializable sessionId = doCreate(session);          verifySessionId(sessionId);          return sessionId;      }      protected abstract Serializable doCreate(Session session);        public Session readSession(Serializable sessionId) throws UnknownSessionException {          Session s = doReadSession(sessionId);          if (s == null) {              throw new UnknownSessionException("There is no session with id [" + sessionId + "]");          }          return s;      }      protected abstract Session doReadSession(Serializable sessionId);  }

看上面那张类图AbstractSessionDao的子类有三个,查看源码发现CachingSessionDAO是一个抽象类,它并没有实现这两个方法。在它的子类EnterpriseCacheSessionDAO中实现了doCreate()和doReadSession(),但doReadSession()是一个空实现直接返回null。

    public EnterpriseCacheSessionDAO() {          setCacheManager(new AbstractCacheManager() {              @Override              protected Cache<Serializable, Session> createCache(String name) throws CacheException {                  return new MapCache<Serializable, Session>(name, new ConcurrentHashMap<Serializable, Session>());              }          });      }

EnterpriseCacheSessionDAO依赖于它的父级CachingSessionDAO,在他的构造方法中向父类注入了一个AbstractCacheManager的匿名实现,它是一个基于内存的SessionDao,它所创建的MapCache就是一个Map。

我们在Shiro配置类里通过 sessionManager.setSessionDAO(new EnterpriseCacheSessionDAO()); 来使用它。然后在CachingSessionDAO.getCachedSession() 打个断点测试一下,可以看到cache就是一个ConcurrentHashMap,在内存中以Key-Value的形式保存着JSESSIONID和Session的映射关系。

再来看AbstractSessionDao的第三个实现MemorySessionDAO,它就是一个基于内存的SessionDao,简单直接,构造方法直接new了一个ConcurrentHashMap。

    public MemorySessionDAO() {          this.sessions = new ConcurrentHashMap<Serializable, Session>();      }

那么它和EnterpriseCacheSessionDAO有啥区别,其实EnterpriseCacheSessionDAO只是CachingSessionDAO的一个默认实现,在CachingSessionDAO中cacheManager是没有默认值的,在EnterpriseCacheSessionDAO的构造方法将其初始化为一个ConcurrentHashMap。

如果我们直接用EnterpriseCacheSessionDAO其实和MemorySessionDAO其实没有什么区别,都是基于Map的内存型SessionDao。但是CachingSessionDAO的目的是为了方便扩展的,用户可以继承CachingSessionDAO并注入自己的Cache实现,比如以Redis缓存Session的RedisCache。

其实在业务上如果需要Redis来管理Session,那么直接继承AbstractSessionDao更好,有Redis支撑中间的Cache就是多余的,这样还可以做分布式或集群环境的Session共享(分布式或集群环境如果中间还有一层Cache那么还要考虑同步问题)。

回到开篇提到的我们公司项目集群环境下单点登录频繁掉线的问题,其实就是中间那层Cache造成的。我们的业务代码中RedisSessionDao是继承自EnterpriseCacheSessionDAO的,这样一来那在Redis之上还有一个基于内存的Cache层。此时用户的Session如果发生变更,虽然Redis中的Session是同步的,Cache层没有同步,导致的现象就是用户在一台服务器的Session是有效的,另一台服务器Cache中的Session还是旧的,然后用户就被迫下线了。