开启掘金成长之旅!这是我参与「掘金日新计划 · 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.AbstractSessionDAO
和 org.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 方法,而在其内部,做了两件事:
- 调用
session = getSessionFactory().createSession(context)
创建 Session 对象。 - 调用
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 框架调用的。 希望今天的内容能对你有所帮助。