前言
在后台管理系统中,因为客户端是浏览器,因此大多都是采用基于 Session-Cookie
实现的用户会话管理。在公司后台框架升级改造的背景下,借此梳理了一下后台管理系统用户会话管理的实现,并将其记录为本篇文章。本文主要介绍基于 Session-Cookie
的用户会话管理的实现,并围绕用户会话的生命周期逐一编码说明。
Session-Cookie 认证流程
在 Session-Cookie
的认证流程中,主要参与角色是客户端和服务端。客户端主要是浏览器,而服务端在这里又细分为后台服务器和分布式缓存服务。考虑到在集群服务器的环境下,会存在 Session
无法完全命中的问题,因此需要额外引入分布式缓存服务。这样整个认证流程除了展现服务器与客户端之间的交互,也能够展示服务器如何调用分布式缓存服务处理 Session
数据。
用户会话管理
在用户会话管理的功能需求中,主要围绕用户会话的生命周期,即从用户登录到退出登录,考虑以下几个功能:
- 创建用户会话,在用户登录认证通过后,为用户创建会话
- 删除用户会话,在用户退出登录时,删除用户的会话信息
- 续期用户会话,在用户每次访问请求时,自动为用户会话续期
除了上述三个主要的会话管理功能外,实际的需求中还可能继续细化出几个点:
- 限制用户最大登录数量,允许同一用户多地登录但限制最大数量
- 防止已登录用户被挤下线,防止已登录用户在异地重新登录后被挤下线
创建用户会话
在用户登录,服务器认证通过后要为用户创建会话,生产一个SessionID
,此时服务器需要将用户的会话信息缓存起来,以便保持用户的会话状态,这里可以用Redis
作为缓存;
在登录流程中每次登录都会生产一个新的SessionID
,然而在用户登录的行为中,用户有可能在短时间内重新登录,这时旧的还未过期的用户会话还存留着,就要考虑清除掉旧的用户会话;
上面提到了清除旧的会话,但是在实际业务中还要考虑允许用户的最大登录数量,这时就要条件性地清除旧用户会话,只有在用户已登录数量大于等于用户最大登录数量时才清除旧的用户会话;
同时,在需求中还有一个防止用户被挤下线的要求,本质上它是需要同时满足允许用户最大登录数量,并且在超过最大登录数量时中断用户的登录请求,以保护已登录的用户会话。
创建用户会话的流程如下:
创建用户会话的代码如下:
java
/**
* 用户会话管理
*/
@Component
public class AdminSessionManager {
private static final String SESSION_NAME = ApiHeader.SESSION; // session名
private static final int EXPIRATION = 7 * 24 * 3600; // 有效期(秒)
private static final int MAX_SESSIONS = 2; // 用户最多会话数量
private static final boolean PROTECT = false; // 登录保护
private static final boolean REFRESH = true; // 会话续期
private static final String ADMIN_ID = "session:admin:id:%s"; // 用户id及SessionID
private static final String ADMIN = "session:admin:%s"; // SessionID及用户会话信息
@Resource
private RedisService redisService;
/**
* 创建用户会话
* 1.清除过期会话
* 2.是否触发登录会话保护
* 3.缓存登录会话信息/Set-Cookie
*/
public String createSession(long id, String value, HttpServletResponse response) {
expireSession(id);
ifSessionProtect(id);
String id_key = String.format(ADMIN_ID, id);
String session = UUID.randomUUID().toString().replaceAll("-", ""); // 生成session
redisService.zAdd(id_key, session, DateUtil.getCurrSeconds() + EXPIRATION);
redisService.set(String.format(ADMIN, session), value, EXPIRATION);
ServletUtil.addCookie(response, SESSION_NAME, session, -1, false);
return session;
}
/**
* 是否触发登录会话保护
* 防止已登录的用户被挤下线
*/
private void ifSessionProtect(long id) {
long count = countCurrentSession(id);
if (count >= MAX_SESSIONS) {
if (PROTECT) {
throw new GlobalException(ApiResult.ACCOUNT_SESSION_PROTECT);
} else {
String id_key = String.format(ADMIN_ID, id);
Set<String> set = redisService.zRange(id_key, 0, count - MAX_SESSIONS);
redisService.zRemoveRange(id_key, 0, count - MAX_SESSIONS);
for (String e : set) {
redisService.delete(String.format(ADMIN, e));
}
}
}
}
/**
* 统计目前会话数量
*/
private long countCurrentSession(long id) {
String id_key = String.format(ADMIN_ID, id);
Long count = redisService.zSize(id_key);
return count != null ? count : 0;
}
/**
* 清除过期会话
*/
private void expireSession(long id) {
String id_key = String.format(ADMIN_ID, id);
redisService.zRemoveRangeByScore(id_key, 0, DateUtil.getCurrSeconds());
}
}
删除用户会话
在用户退出登录时,删除用户的会话信息;
删除用户的会话信息需要用户id作为索引,但实际上即使用户数据为空也不应该报错(有可能用户缓存已过期),因此在认证层面用户退出登录的接口应当加入白名单。
删除用户会话的代码如下:
java
/**
* 用户会话管理
*/
@Component
public class AdminSessionManager {
private static final String SESSION_NAME = ApiHeader.SESSION; // session名
private static final int EXPIRATION = 7 * 24 * 3600; // 有效期(秒)
private static final int MAX_SESSIONS = 2; // 用户最多会话数量
private static final boolean PROTECT = false; // 登录保护
private static final boolean REFRESH = true; // 会话续期
private static final String ADMIN_ID = "session:admin:id:%s"; // 用户id及SessionID
private static final String ADMIN = "session:admin:%s"; // SessionID及用户会话信息
@Resource
private RedisService redisService;
/**
* 删除用户会话
*/
public void deleteSession(Long id, HttpServletRequest request) {
String session = request.getHeader(SESSION_NAME);
String id_key = String.format(ADMIN_ID, id);
redisService.zRemove(id_key, session);
redisService.delete(String.format(ADMIN, session));
}
}
刷新/续期用户会话
在用户每次访问请求时,自动为用户会话续期;续期的策略有多种实现,比如延长多少时间,或者失效期指定到某个时间点,这里以后者作为实现,意思上就是刷新用户会话。
续期用户会话的代码如下:
java
/**
* 用户会话管理
*/
@Component
public class AdminSessionManager {
private static final String SESSION_NAME = ApiHeader.SESSION; // session名
private static final int EXPIRATION = 7 * 24 * 3600; // 有效期(秒)
private static final int MAX_SESSIONS = 2; // 用户最多会话数量
private static final boolean PROTECT = false; // 登录保护
private static final boolean REFRESH = true; // 会话续期
private static final String ADMIN_ID = "session:admin:id:%s"; // 用户id及SessionID
private static final String ADMIN = "session:admin:%s"; // SessionID及用户会话信息
@Resource
private RedisService redisService;
/**
* 刷新/续期用户会话
*/
public void refreshSession(long id, HttpServletRequest request, HttpServletResponse response) {
if (!REFRESH) return;
String session = request.getHeader(SESSION_NAME);
String id_key = String.format(ADMIN_ID, id);
redisService.zAdd(id_key, session, DateUtil.getCurrSeconds() + EXPIRATION);
redisService.expire(String.format(ADMIN, session), EXPIRATION);
ServletUtil.addCookie(response, SESSION_NAME, session, EXPIRATION, false);
}
}
用户会话拦截器
在前面用户会话管理中编写了为用户会话续期的功能,它对用户来说是无感知的,也就是在用户请求访问时调用;
在 SpringBoot
中,可以使用拦截器处理,在每一次请求进来时执行续期用户会话的方法。代码如下:
java
/**
* 用户会话拦截器
*/
@Component
public class AdminSessionInterceptor extends HandlerInterceptorAdapter {
@Resource
private AdminSessionManager adminSessionManager;
@Resource
private UserContext userContext;
@Override
public boolean preHandle(@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull Object handler) throws Exception {
String session = request.getHeader(ApiHeader.SESSION);
if (StringUtil.isNotEmpty(session)) {
Long id = userContext.getAdminUserId();
if (null != id) {
adminSessionManager.refreshSession(id, request, response);
}
}
return true;
}
}
Web配置添加拦截器
编写完用户会话拦截器,还需要将拦截器添加到 Web 配置中。代码如下:
java
/**
* Web配置
*/
@Configuration
public class AdminWebMvcConfig implements WebMvcConfigurer {
@Resource
private AdminSessionInterceptor adminSessionInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminSessionInterceptor);
}
}
Session数据结构设计回顾
在用户会话管理中,用户会话的创建、删除和续期是大致的框架,而其中的核心内容自然是对 Session
数据的处理,我们采用 Redis
来缓存会话信息,因此如何设计存储 Session
的数据结构就成了十分重要的一环。
从用户会话管理梳理的需求可知,同一个用户是可能存在多个会话信息的,因此首先考虑选择集合类型的数据结构,如:List
、Set
;同时又涉及到会话的过期时间,因此有序集合ZSet
是最佳的选择。
此外为了优化整体上的管理与检索,将session:admin:id:%s
(ZSet
结构)作为存储SessionID
的数据区,而SessionID
对应的用户数据序列化后以session:admin:%s
(String
结构)存储。
用户ID为2的数据示例:
SessionID
为dea52b5119474e92a4eaa1077e82838a
的数据示例: