上篇文章,我们完成了 Shiro 权限系统,足以应对中小企业的内部应用。但随着业务量的扩大,我们会把单服务器环境,变成 服务器集群
,即系统部署在 2 台、3 台,甚至更多的服务器上。这时候,我们要改造系统,让它更适应集群环境。其中之一,就是单点登陆
,即用户登陆后,无论负载均衡到哪一台服务器,都能保持登陆状态。
接下来,我们用之前的权限项目,实现服务器集群
下的单点登陆
。
架构
服务器集群下,我们的服务器架构,也变得更加复杂,最基本的架构如下:
其中,服务端有下面几个模块:
- 负载均衡:监测应用服务器的状态,把“用户请求”分发到相对空闲的服务器;
- 应用服务器:运行我们开发的程序;
- 缓存服务器:储存用户登陆状态;
- 数据库服务器:储存供应用程序使用的“持久化数据”;
我们可以理解成:Tomcat 集群下,每个服务都是分开的,所以必须在改造代码,令每个服务之间的数据都能够共享。其中的一个功能,就是单点登陆。在这里,我们要做 2 件事:
- 增加
缓存服务器
,并开发基础的操作类
;- 改造 Session 管理的方式,使
Session
的增删改查,写入到缓存服务器
;
首先,增加缓存服务器
,可以参考《redis 缓存数据库整合》。
然后,接下来开始改造Session 管理的方式
,在 Shrio 中,我们通过覆盖默认的 SessionManager(Session 管理器)来实现。
覆盖默认的 SessionManager(Session 管理器)
在 Shiro 中,Session 管理器
负责用户登录后,session 的增删改查操作。因为 Shiro 默认的Session 管理器
,使用的是 Tomcat 缓存,只能支持单服务器的环境。如果在高并发的情况下,用户被分配到另外一台服务器,那么就得重新登陆。所以,要实现单点登陆
,我们要重写Session 管理器
,开发流程如下:
- 新建 RedisSessionDao:redis 中, session 的“增删改查”操作;
- 创建 RedisSessionDao 配置:用于覆盖默认的 session 操作类;
- 创建 Cookie 配置:在浏览器中,写入 sessionId,用来记住登陆状态;
- 创建 SessionManager 配置:利用上述配置,重新创建 SessionManager;
- 覆盖默认 SessionManager:覆盖 Shiro 的默认实现;
新建 RedisSessionDao(Session 操作类)
SessionDao 是用来操作 Session 的类,Shiro 提供了一个默认的实现。如果只是单服务器的环境,可以直接用默认配置,我们看看源码DefaultSessionManager
:
在源码截图中,我们可以看到DefaultSessionManager
的构造方法中,已经定义了默认的Session 操作类
,但这个操作类用的是 Tomcat 的内存,无法满足我们的业务需求。所以,我们要新建一个自己的Session 操作类
,用 Redis 缓存,来实现 Session 信息的独立储存。
首先,我们新建一个类:RedisSessionDao
,并让它继承 Shiro 的抽象类AbstractSessionDAO
,代码如下:
import com.mmall.demo2.common.Const;
import com.mmall.demo2.common.serialize.ObjectMapper;
import com.mmall.demo2.common.response.Servlets;
import com.mmall.demo2.common.redis.RedisPoolUtil;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import javax.servlet.http.HttpServletRequest;
import java.io.Serializable;
import java.util.Collection;
/**
* @Author: jiaru
* @Description: session 保存到 redis 的增删改查操作
* @redis写入方式: 1. 写入:对象转换成byte —> 存入redis;
* 2. 取出: 获取byte数据 —> 序列化为java对象;
* @Date: Created on 2018/10/25
* @Modify:
*/
public class RedisSessionDao extends AbstractSessionDAO {
/**
* 创建session 信息
* @param session 前端传来的 session 信息
* @return
*/
@Override
protected Serializable doCreate(Session session) {
// 创建唯一编号
Serializable sessionId = this.generateSessionId(session);
// 创建session
this.assignSessionId(session, sessionId);
// session 保存到redis缓存中
this.update(session);
// 返回session编号
return sessionId;
}
/**
* 读取 session 信息
* @param sessionId 前端传来的 session 编号
* @return
*/
@Override
protected Session doReadSession(Serializable sessionId) {
/**** 如果 sessionId 为空,则直接返回 ****/
if (sessionId == null) {
return null;
}
/**** 从 request 中获取 session 信息 ****/
Session session = null;
// 获取 request 请求
HttpServletRequest request = Servlets.getRequest();
// 获取 session
if (request != null) {
session = (Session) request.getAttribute((String) sessionId);
}
// 如果 session 非空,直接返回 session
if (session != null){
return session;
}
/**** 如果 sesssion 为空,则从redis中读取 ****/
// 获取的键名称
byte[] sessionKey = ObjectMapper.getBytes((String) sessionId);
// 从 redis 中取出的 byte[] 数据
byte[] sessionByte = RedisPoolUtil.get(sessionKey);
// redis 中的 byte[] 转换 Java 对象
session = (Session) ObjectMapper.toObject(sessionByte);
return session;
}
/**
* 更新redis中的 session 信息
* @param session 前端传来的 session 信息
* @throws UnknownSessionException
*/
@Override
public void update(Session session) throws UnknownSessionException {
if (session == null) {
return;
}
// 设置 session 过期时间
session.setTimeout(30 * 60 * 1000);
// session 信息
byte[] sessionId = ObjectMapper.getBytes((String) session.getId());
byte[] sessionVal = ObjectMapper.toBytes(session);
// 保存 session 到 redis 中
RedisPoolUtil.setEx(sessionId, sessionVal, Const.RedisCacheExTime.REDIS_SESSION_EXTIME);
}
/**
* 删除 session
* @param session 前端传来的 session 信息
*/
@Override
public void delete(Session session) {
if (session == null) {
return;
}
byte[] sessionId = ObjectMapper.getBytes((String)session.getId());
RedisPoolUtil.del(sessionId);
}
/**
* 获取在线用户
* @return
*/
@Override
public Collection getActiveSessions() {
return null;
}
}
在继承了这个抽象类后,我们按照业务需求,重写抽象方法,就能完成了 80% 的开发工作。接下来的所有工作,相对与开发,更加需要我们去阅读 Shiro 的架构图和运行流程。我们要用 Shiro 的配置类,来定义我们自己的 SessionManager。为此,我们要在上两篇文章的模版项目中,找到ShiroConfiguration
这个类。
创建 RedisSessionDao 配置
我们要在ShiroConfiguration
这个类中,来增加sessionDao
方法,代码如下:
/**
* 设置 session 操作器
* @return
*/
@Bean("sessionDAO")
public SessionDAO sessionDAO() {
return new RedisSessionDao();
}
这里,我们直接 new 刚才创建的RedisSessionDao
,然后直接返回,这样就完成创建 RedisSessionDao 配置
了。
创建 Cookie 配置
我们继续增加sessionIdCookie
方法,代码如下:
/**
* 设置用户 cookie 中 sessionId 的信息
* @return
*/
@Bean("sessionCookie")
public SimpleCookie sessionIdCookie() {
// 创建 Cookie 类,并设置名称
SimpleCookie cookie = new SimpleCookie("session.id");
// 不允许通过脚本访问 cookie。备注:无法防御所有脚本攻击,但可提高一定的安全性
cookie.setHttpOnly(true);
// 设置域名
// cookie.setDomain("/");
// 设置作用域
cookie.setPath("/");
return cookie;
}
那么创建 Cookie 配置
,也已经完成了。接下来,在配置中,是最重要的一步。
创建 SessionManager 配置
这是配置工作中,最重要的一步。这里要用到前面的配置类sessionDAO
和sessionIdCookie
,我们创建sessionManager
方法,代码如下:
/**
* 设置 session 管理器
* @param sessionDAO
* @return
*/
@Bean("sessionManager")
public WebSessionManager sessionManager(@Qualifier("sessionDAO") SessionDAO sessionDAO,
@Qualifier("sessionCookie") SimpleCookie cookie) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// 自动删除无效 session
sessionManager.setDeleteInvalidSessions(true);
// 设置 session 超时时间,30 分钟超时
sessionManager.setGlobalSessionTimeout(30 * 60 * 1000);
// 设置 sessionDao
sessionManager.setSessionDAO(sessionDAO);
// 设置 cookie 信息
sessionManager.setSessionIdCookie(cookie);
return sessionManager;
}
除了上面说到的两个配置类,我们还要配置其它参数,建议大家仔细阅读上面代码的注释。
覆盖默认 SessionManager(会话管理器)
在完成最后的工作前,我们先来回顾一下 Shiro 的配置顺序,也就是下面这幅图:
可以看到,SessionManager 是安全管理器的一部分。所以,要找到上篇文章中,安全管理器的配置方法,并加上我们自定义的SessionManager
,代码如下:
/**
* 配置安全管理器
* @param authRealm 已在 Spring 容器中注册的“用户域”
* @param sessionManager 自定义的“会话管理器”
* @return
*/
@Bean("securityManager")
public SecurityManager securityManager(@Qualifier("authRealm") AuthRealm authRealm,
@Qualifier("sessionManager") WebSessionManager sessionManager) {
// 新建web管理器
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 设置“用户域”
manager.setRealm(authRealm);
// 设置 session 管理器:用于单点登陆
manager.setSessionManager(sessionManager);
return manager;
}
到此为止,基于 Shiro 的单点登陆
已经完成了。
写在最后
至此,我们的 Shiro 权限系统的讲解,已经完结了。希望大家能够好好理解,然后早日把上面的技术用到工作上。这里附上源码地址:
shiro单点登陆版地址