Spring Boot「34」SSO 单点登录实现中使用 Redis 存储会话

877 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 19 天,点击查看活动详情

在前面的文章 Spring Boot「28」扩展:SSO 单点登录流程分析 中,我分析了 SSO 的业务逻辑,今天的内容接着上篇内容继续展开。 首先,我会介绍如何通过 Shiro 中的接口实现将 Session 保存到 Redis 中。 之后,我会分析一下我们实现的接口在 Shiro 中是如何被调用的,帮助你更好地理解整个过程的细节。

01-Redis 实现 Session 持久化

我在之前的 Spring Boot「24」Shiro 核心模块分析 分析中介绍过,Shiro 中设计了 SessionDAO 接口(即 Session 数据访问对象),可以将 Session 持久化到数据库等系统中。 在本节中,我会介绍如何实现自定义的 SessionDAO 对象,将 Session 信息持久化到 Redis 中。

首先,我们定义一个 Session 类,它继承自 org.apache.shiro.session.mgt.SimpleSession,我添加了额外的属性,例如会话状态:

public class DemoSession extends SimpleSession {
    private String userAgent;
    private SessionStatus status = SessionStatus.OFFLINE;
}

其中 SessionStatus 是一个枚举类,有三个状态:在线、离线和强制下线:

enum SessionStatus {
    ONLINE("在线"),
    OFFLINE("离线"),
    FORCE_LOGOUT("强制下线");
    private String desc;
}

然后,实现工厂类 DemoSessionFactory,它负责创建 DemoSession 对象,并在创建会话对象时设置它的一部分属性:

public class DemoSessionFactory implements SessionFactory {
    @Override
    public Session createSession(SessionContext context) {
        DemoSession session = new DemoSession();
        if (context != null && context instanceof WebSessionContext) {
            WebSessionContext webSessionContext = (WebSessionContext) context;
            HttpServletRequest servletRequest = (HttpServletRequest) webSessionContext.getServletRequest();
            if (servletRequest != null) {
                // 设置 Host 和 UserAgent 属性
                session.setHost(servletRequest.getRemoteHost());
                session.setUserAgent(servletRequest.getHeader("User-Agent"));
            }
        }
        return session;
    }
}

然后,实现自己的 SessionDAO。它实际上定义了对 Session 对象(我们这里是对 DemoSession 对象)的“增删改查”方法。

public interface SessionDAO {
    // 增
    Serializable create(Session session);
    // 查
    Session readSession(Serializable sessionId) throws UnknownSessionException;
    // 改
    void update(Session session) throws UnknownSessionException;
    // 删
    void delete(Session session);
}

这些方法在 org.apache.shiro.session.mgt.eis.AbstractSessionDAOorg.apache.shiro.session.mgt.eis.CachingSessionDAO 中都进行了基于模板方法的实现,暴露出其他的接口供开发者实现自己的逻辑:

// 定义在 AbstractSessionDAO 中
protected abstract Serializable doCreate(Session session); 
protected abstract Session doReadSession(Serializable sessionId);

// 定义在 CachingSessionDAO 中
protected abstract void doUpdate(Session session);
protected abstract void doDelete(Session session);

我自己实现了一个基于 Redis 的 SessionDAO 类,名为 RedisSessionDAO。 它主要实现了上面四个 doXxx 接口。

在介绍 RedisSessionDAO 的具体实现之前,我想先带大家回顾一下 Spring Boot「28」扩展:SSO 单点登录流程分析 中 CAS(即统一认证服务)的职责。 它在 SSO 单点登录流程中的职责主要分为:

  • 判断用户是否已登录。判断依据是,用户是否与 CAS 之间存在全局会话。
  • 若用户未登录,提供登录界面,处理用户登录请求。
  • 若用户已登录,即存在全局会话。重定向用户请求,并附带用户全局会话创建时生成的临时令牌(auth token / code)。
  • 验证用户的临时令牌是否有效。

针对上述职责,我们需要在 Redis 中存储相应的数据结构来存储必要数据。

  • 全局会话列表(或集合),这里我们选择集合。使用 "GLOBAL_SESSION_IDS" 作为 Redis 中的键,值为集合,存储的是全局会话的 session id。
  • 全局会话对象,将全局会话持久化到 Redis,使用 "SESSION_ID:${session_id}" 作为 Redis 中的键,值为 DemoSession 的序列化值。
  • 全局会话的临时令牌,将全局会话的令牌存放在 Redis 中,使用 "SESSION_CODE:${auth_code}" 作为 Redis 中的键,值为 auth code 临时令牌。

有了上述数据结构,就能够得到如何判断用户登录,以及如何验证令牌有效:

  • 用户是否登录 -> 判断 Redis 中是否存在 "SESSION_ID:${session_id}" 或 GLOBAL_SESSION_IDS 中是否包含 session_id
  • 用户令牌是否有效 -> 判断 "SESSION_CODE:${auth_code}" 的值是否存在且值与 auth_code 相等

有了上面的介绍,接下来我们一起看下 RedisSessionDAO 的实现:

public class RedisSessionDAO extends ChachingSessionDao {

    /**
     * Session 创建后,会回调此接口,将 Session 及相关的内容存储到 Redis 中
     * 注:这里的 Session 指的是全局会话,即用户与 CAS 之间的 Session
     * @param session
     * @return
     */
    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = generateSessionId(session);
        assignSessionId(session, sessionId);
        LOGGER.info("doCreate session[{}]", sessionId);
        
        final String authCode = UUID.randomUUID().toString();
        session.setAttribute("authCode", authCode);

        // redis 中存储 GLOBAL_SESSION_IDS -> { $session_id }
        redisTemplate.opsForSet().add(GLOBAL_SESSION_IDS, sessionId.toString());
        
        // redis 中存储 SESSION_ID:$session_id -> serialized_session
        redisTemplate.opsForValue().set(getSessionIdKey(sessionId), 
                SerializableUtils.serialize(session), 
                session.getTimeout(), 
                TimeUnit.MILLISECONDS);

        // redis 中存储 SESSION_CODE:$session_id -> $auth_code
        redisTemplate.opsForValue().set(getSessionCodeKey(sessionId), 
                authCode, 
                session.getTimeout(), 
                TimeUnit.MILLISECONDS);
        
        return sessionId;
    }

    /**
     * 从 Redis 中根据 session id 获取 Session 对象
     * @param sessionId
     * @return
     */
    @Override
    protected Session doReadSession(Serializable sessionId) {
        LOGGER.info("doReadSession session[{}]", sessionId);
        // 将 SESSION_ID:${session_id} 的内容反序列化
        String sessionStr = redisTemplate.opsForValue().get(getSessionIdKey(sessionId));
        return SerializableUtils.deserialize(sessionStr);
    }

    /**
     * 以当前 Session 为基础,更新 Redis 中存储的持久化信息
     * @param session
     */
    @Override
    protected void doUpdate(Session session) {
        LOGGER.info("doUpdate session[{}]", session.getId());
        if (session instanceof ValidatingSession && !(((ValidatingSession) session).isValid())) {
            return ;
        }

        DemoSession demoSession = (DemoSession) session;
        DemoSession cachedDemoSession = (DemoSession) doReadSession(session.getId());
        if (null != cachedDemoSession) {
            demoSession.setStatus(cachedDemoSession.getStatus());
            demoSession.setAttribute("FORCE_LOGOUT", cachedDemoSession.getAttribute("FORCE_LOGOUT"));
        }
        
        redisTemplate.opsForValue().set(getSessionIdKey(session.getId()), SerializableUtils.serialize(demoSession), demoSession.getTimeout(), TimeUnit.MILLISECONDS);

        redisTemplate.expire(getSessionCodeKey(session.getId()), demoSession.getTimeout(), TimeUnit.MILLISECONDS);
        final String authCode = redisTemplate.opsForValue().get(getSessionCodeKey(session.getId()));
        redisTemplate.expire(getAuthCodeKey(authCode), demoSession.getTimeout(), TimeUnit.MILLISECONDS);
    }

    /**
     * 当 Session 关闭时,删除掉 Redis 中存储的信息
     * @param session
     */
    @Override
    protected void doDelete(Session session) {
        LOGGER.info("doDelete session[{}]", session.getId());
        final Serializable sessionId = session.getId();
        redisTemplate.opsForSet().remove(GLOBAL_SESSION_IDS, sessionId);
        redisTemplate.delete(getSessionIdKey(sessionId));
        final String authCode = redisTemplate.opsForValue().getAndDelete(getSessionCodeKey(sessionId));
        redisTemplate.delete(getAuthCodeKey(authCode));
    }

    
} 

02-doCreate 方法什么时候会被调用?

所有被 Shiro Filter 拦截的请求,在交由 SecurityManager 处理时,都会生成一个对应的 Subject(结合之前的介绍,它其实表示安全认证系统的用户)。 Subject 中定义了 getSession 方法,来获得与之关联的会话,若会话不存在,则创建,具体如下:

if (this.session == null && create) {
    // 省略其他 
    SessionContext sessionContext = createSessionContext();
    Session session = this.securityManager.start(sessionContext);
    // 省略其他
}

可以看到,创建请求最终由 SecurityManager 的 start 方法创建。 start 方法其实是 SessionManager 接口中定义的方法,(SecurityManager 也是 SessionManager)。 它有两种不同的实现,一种是有 Servlet 容器来管理 Session 的实现,实现类是 org.apache.shiro.web.session.mgt.ServletContainerSessionManager; 另一类是自己管理 Session,抽象实现类是 org.apache.shiro.session.mgt.AbstractNativeSessionManager

在后一种情况中,才会用到我们之前说的 SessionFactory。 所以,我们重点看这个类的实现。

public Session start(SessionContext context) {
    Session session = createSession(context);   // 创建 session
    applyGlobalSessionTimeout(session);   // 设置 session 过期时间等参数
    onStart(session, context);    // 生命周期回调,留给 session manager 的具体实现在 session 创建之后做相应的动作
    notifyStart(session);     // 通知所有的监听器,Session 创建
    //Don't expose the EIS-tier Session object to the client-tier:
    return createExposedSession(session, context);  // 对 session 进行封装,避免对外暴露过多的信息
}

看到这里,大致能够猜到在什么时候调用 doCreate 了吧。 createSession 方法调用了 doCreateSession 方法,而在其内部,做了两件事:

  1. 调用 session = getSessionFactory().createSession(context) 创建 Session 对象。
  2. 调用 sessionDao.create(session) 来回调 SessionDAO 的具体实现,最终到达了我们实现的 doCreate 方法。

03-doDelete 方法什么时候会被调用?

AbstractValidatingSessionManager 对 SessionManager 接口增加的功能是验证 Session 是否过期以及过期时的一些操作。 它向 SecurityManager 中增加了一个 Scheduler 对象:

// 整理后的代码
ExecutorServiceSessionValidationScheduler scheduler = new ExecutorServiceSessionValidationScheduler(this);
scheduler.setInterval(getSessionValidationInterval());  // 设置检查 session 是否过期的间隔,默认是 每小时 检查一次

ExecutorServiceSessionValidationScheduler 内部包含了一个 ScheduledExecutorService 对象,它是一个单线程线程池:

this.service = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {  
    private final AtomicInteger count = new AtomicInteger(1);

    public Thread newThread(Runnable r) {  
        Thread thread = new Thread(r);  
        thread.setDaemon(true);  
        thread.setName(threadNamePrefix + count.getAndIncrement());
        return thread;  
    }  
});
this.service.scheduleAtFixedRate(this, interval, interval, TimeUnit.MILLISECONDS);

它按照固定频率去检查线程是否失效,具体来看就是调用 sessionManager 的 validateSessions。

public void run() {
    Thread.currentThread().setUncaughtExceptionHandler((t, e) -> {
        log.error("Error while validating the session, the thread will be stopped and session validation disabled", e);
        this.disableSessionValidation();
    });
    long startTime = System.currentTimeMillis();
    try {
        this.sessionManager.validateSessions();   
    } catch (RuntimeException e) {
        log.error("Error while validating the session", e);
        //we don't stop the thread
    }
    long stopTime = System.currentTimeMillis();
    if (log.isDebugEnabled()) {
        log.debug("Session validation completed successfully in " + (stopTime - startTime) + " milliseconds.");
    }
}

最终会调用到 SessionManager 中的 validate 方法中:

protected void validate(Session session, SessionKey key) throws InvalidSessionException {
    try {
        doValidate(session);
    } catch (ExpiredSessionException ese) {
        onExpiration(session, ese, key);    
        throw ese;
    } catch (InvalidSessionException ise) {
        onInvalidation(session, ise, key);
        throw ise;
    }
}

在上述两种异常情况中,过期会话、不可用会话,最终会回调到 SessionDAO 中的 doUpdate 和 doDelete 方法。

doReadSession 方法我就不再详细分析其过程,有兴趣的读者可以自行去分析下源码。 如遇到什么问题或有不同的看法,欢迎与我讨论。

04-总结

今天这篇文章是使用 Shiro + Redis 实现 SSO 单点登录实践过程记录中的一篇内容。 我介绍了如何通过实现 Shiro 中暴露的接口来达到将 Session 保存在 Redis 中。 并且,在后面分析了我们实现的接口是如何被 Shiro 框架调用的。 希望今天的内容能对你有所帮助。

refs