1. 背景
不管出于安全的原因,或者三级等保要求,我们在实现站点的登录时,都少不了会面临需要限制同一账号(及同一应用)同时上线数的限制。
挤人下线从逻辑上来说,并不是一个非常困难的逻辑,但是面临以下挑战:
- 账号在线计数需要缓存session与用户(+应用)的关系及登录时间顺序,并绑定session的生命周期,否则容易误挤或者内存泄漏;
- 挤下线逻辑需要操作当前session外的其它session;
以上两点很容易造成挤下线逻辑与session强耦合,且代码分散在登录、session的实现中,复杂且难以维护。
如果我们使用Spring Session Redis,那事情就变得简单了,虽然没有实现在线挤人逻辑,但是Spring Session框架为我们提供了解决以上两个问题所需的扩展能力。
图1 效果图
2. 技术准备
由于Spring Session良好的封装性,其本身是通过装饰者模式,对Servlet的Session进行了透明封装,使得业务代码对用没用到Spring Session完全无感,其思想非常值得我们研究学习。
Spring Session是通过SessionRepository来提供对Session管理,关键是它有个子接口定义了一个很重要的方法:
/**
* Extends a basic {@link SessionRepository} to allow finding sessions by the specified
* index name and index value.
*
* @param <S> the type of Session being managed by this
* {@link FindByIndexNameSessionRepository}
* @author Rob Winch
* @author Vedran Pavic
*/
public interface FindByIndexNameSessionRepository<S extends Session> extends SessionRepository<S> {
/**
* A session index that contains the current principal name (i.e. username).
* <p>
* It is the responsibility of the developer to ensure the index is populated since
* Spring Session is not aware of the authentication mechanism being used.
*
* @since 1.1
*/
String PRINCIPAL_NAME_INDEX_NAME = FindByIndexNameSessionRepository.class.getName()
.concat(".PRINCIPAL_NAME_INDEX_NAME");
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the specified index name index value.
* @param indexName the name of the index (i.e.
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME})
* @param indexValue the value of the index to search for.
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified index name and index value. If no
* results are found, an empty {@code Map} is returned.
*/
Map<String, S> findByIndexNameAndIndexValue(String indexName, String indexValue);
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the index with the name
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME} and the
* specified principal name.
* @param principalName the principal name
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified principal name. If no results are found,
* an empty {@code Map} is returned.
* @since 2.1.0
*/
default Map<String, S> findByPrincipalName(String principalName) {
return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
}
}
findByIndexNameAndIndexValue()方法定义了如何通过索引来查询所有匹配的Session(确切地说是sessionId->Session的Map映射),而Spring Session Redis实现了这个接口。因此只要使用到了Spring Session Redis,我们就得到了解决背景中两个问题的手段:
- 通过FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()找出某账号的所有Session;
- 通过SessionRepository.deleteById()来使需要被挤掉的Session失效;
3. 方案设计
通过对Spring Session文档和源代码的研读,一个简单清晰的“账号被另一处登录挤下线”方案呼之欲出:
- 登录成功后,创建Session的索引,可以使用[用户名+应用ID]作为索引键,这样可以实现限制同一账号在不同应用(如移动端、PC端)中分别只能登录一次;
- 创建Session索引后,检查当前用户的[用户名+应用ID]去检查登录Session数有无超过上限,超过则将最早的Session失效,并记录失效原因;
- 对所有需要登录访问的URL,使用拦截器检查Session是否失效,如已失效返回失效原因;
以上逻辑是不是特别简单?看到这里,建议你可以自己开始撸代码了。
当然实际上Spring Session Redis的实现并非那么完善,里面有些小问题,下面就让我们边看代码边听我娓娓道来
4. 代码实现
4.1 工程依赖
项目开始前,确保有以下依赖,以maven为例
<!-- Spring Session Redis的依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
4.2 编写主要逻辑
4.2.1 创建MaxOnlineService类,主要逻辑都将在此实现
@Service
public class MaxOnlineService {
// 允许的单账号最大在线数,小于等于0时,表示不限制
private final int maxOnlinePerUser;
// Spring Session中可索引session的SessionRepository实例bean
private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;
// Spring的响应式Redis客户端,也可以用StringRedisTemplate代替
private final StringRedisTemplate redisTemplate;
// Session超时时间,重用于设置被挤下线状态的存储有效期
private final long sessionTimeout;
// 依赖注入,自动引入Spring properties和Bean
public MaxOnlineService(@Value("${sso.maxOnlinePerUser}") int maxOnlinePerUser,
FindByIndexNameSessionRepository<? extends Session> sessionRepository,
StringRedisTemplate redisTemplate,
@Value("${spring.session.timeout}") long sessionTimeout) {
this.maxOnlinePerUser = maxOnlinePerUser;
this.sessionRepository = sessionRepository;
this.redisTemplate = redisTemplate;
this.sessionTimeout = sessionTimeout;
}
/**
* 当登录成功后逻辑,创建session索引,并检查、挤掉多余的session
*
* @param session 当前用户的HttpSession
* @param sessionIndexKey session索引键名
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {}
/**
* 判断当前用户是否已经被挤下线,供拦截器使用
*
* @param request 请求对象,用于获取sessionId
* @return 是否被挤下线
*/
public boolean hasBeenKickoff(HttpServletRequest request) {}
}
4.2.2 实现创建Session索引逻辑
public class MaxOnlineService {
//...
/**
* 创建session索引
* @param session Servlet的HttpSession对象
* @param sessionIndexKey 账号唯一标识,据此限制在线数量,建议[用户名:应用标识]
*/
private void createSessionIndex(HttpSession session, String sessionIndexKey){
// 将索引与session关联,以便spring-session可按用户查询全部session
session.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, sessionIndexKey);
}
/**
* 当登录成功后,检查session数量是否已达上限
*
* @param session 当前用户的HttpSession
* @param sessionIndexKey 账号唯一标识,据此限制在线数量,建议[用户名:应用标识]
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {
// 创建session索引
createSessionIndex(session, sessionIndexKey);
// TODO: 检查并踢除同一indexKey超过数量上限的session
}
//...
}
按照FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()的设计,indexName本该支持任意自定义的参数,但是查看Spring Session Redis的代码,实现得并非那么完善:
public class RedisIndexedSessionRepository implements FindByIndexNameSessionRepository<RedisIndexedSessionRepository.RedisSession>, MessageListener {
//...
@Override
public Map<String, RedisSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) {
return Collections.emptyMap();
}
String principalKey = getPrincipalKey(indexValue);
Set<Object> sessionIds = this.sessionRedisOperations.boundSetOps(principalKey).members();
Map<String, RedisSession> sessions = new HashMap<>(sessionIds.size());
for (Object id : sessionIds) {
RedisSession session = findById((String) id);
if (session != null) {
sessions.put(session.getId(), session);
}
}
return sessions;
}
//...
}
RedisIndexedSessionRepository代码写死了只能使用FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,因此session.setAttribute的第一个参数只能是FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME
4.2.3 挤掉多余Session逻辑
public class MaxOnlineService {
// ...
/**
* 踢除session
*
* @param session spring的session对象
*/
private void kickoffSession(Session session) {
// 将被踢session记录到Redis,以备提示
redisTemplate.opsForValue().set(toKickoffSessionKey(session.getId()), "1", Duration.ofSeconds(sessionTimeout));
// 将session从session仓库中移除
sessionRepository.deleteById(session.getId());
log.info("Session:{}已被踢下线!", session.getId());
}
/**
* 确保只有指定数量的session在线
*
* @param session 当前用户的Servlet HttpSession
* @param sessionIndexKey 账号唯一标识,据此限制在线数量,建议[用户名:应用标识]
*/
private void ensureOnlineCount(final HttpSession session, final String sessionIndexKey) {
if (maxOnlinePerUser <= 0) {
return;
}
int allowedSessionCount = session.isNew() ? (maxOnlinePerUser - 1) : maxOnlinePerUser;
Map<String, ? extends Session> sessionMap = sessionRepository.findByPrincipalName(sessionIndexKey);
if (allowedSessionCount < sessionMap.size()) {
//踢除已达在线上限的session:按创建时间排序>取最早的多余条目>确保没有当前用户session>记录被踢状态>session过期
sessionMap.values().stream()
.sorted(Comparator.comparing(Session::getCreationTime))
.limit(sessionMap.size() - allowedSessionCount)
.filter(s -> !s.getId().equals(session.getId()))
.forEach(this::kickoffSession);
}
}
/**
* 当登录成功后,检查session数量是否已达上限
*
* @param session 当前用户的Servlet HttpSession
* @param sessionIndexKey 账号唯一标识,据此限制在线数量,建议[用户名:应用标识]
*/
public void onLoginSucceed(final HttpSession session, final String sessionIndexKey) {
// 创建session索引
createSessionIndex(session, sessionIndexKey);
// 检查同一系统-用户id的session数量是否已达上限
ensureOnlineCount(session, sessionIndexKey);
}
// ...
}
此处代码逻辑仍然比较简单,只不过使用到了java8的stream来地完成此事
4.2.4 编写检查是否在线的逻辑
public class MaxOnlineService {
//...
/**
* 判断当前用户是否已经被挤下线
*
* @param request 请求对象,用于获取sessionId
* @return 是否被挤下线
*/
public boolean hasBeenKickoff(HttpServletRequest request) {
String sessionId = request.getRequestedSessionId();
// 跳过无sessionId的情况,通常是未登录,使用其它的逻辑处理
if (sessionId == null) {
return false;
}
String v = redisTemplate.opsForValue().get(toKickoffSessionKey(sessionId));
return v != null && !v.isEmpty();
}
}
MaxOnlineService的全部逻辑完成,接下来完成登录成功后的调用。
4.3 登录成功后调用挤下线逻辑
@RestController
public class LoginController {
//...
//账号最大在线服务持有属性
private final MaxOnlineService maxOnlineService;
//spring构造函数注入
public LoginController(/* 其它流入代码 */MaxOnlineService maxOnlineService) {
//...
this.maxOnlineService = maxOnlineService;
}
@PostMapping(value = "/anonymous/login")
public Result<Map<String, Object>> loginByUserName(@RequestBody LoginBean loginBean, HttpSession session) {
// 登录逻辑
//...
final String userName = ...
final String appId = ...
maxOnlineService.onLoginSucceed(session, userName + ":" + appId);
// 返回登录成功响应
return Result.success(map);
}
4.4 拦截器实现被挤下线的提示
4.4.1 拦截器实现
@Service //将拦截器注册为Spring Bean
@Order(9) //通过Order可以标识拦截器的优先级
PathMapping(includes = "/**", excludes = {"/error", "/anonymous/**"}) //定义拦截器的拦截路径、忽略路径
public class MaxOnlineInterceptor implements AutoConfigInterceptor {
private final MaxOnlineService maxOnlineService;
// 依赖注入
public MaxOnlineInterceptor(MaxOnlineService maxOnlineService) {
this.maxOnlineService = maxOnlineService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 跳过Http协议的OPTIONS方法,此方法通常用于浏览器跨域检查,如果返回false浏览器提示的是跨域错误,并非本意
if (RequestMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) {
return true;
}
// 检查是否已被挤下线
if (maxOnlineService.hasBeenKickoff(request)) {
// 已被挤下线时发送被挤下线响应
return sendJsonError(ErrorCode.System.userLoginForcedOffline, "您的帐号在另一地点登录,您被迫下线。如果不是您本人操作,建议您修改密码。");
} else {
return true;
}
}
// 可重用的Json视图转换对象
private final View jsonView = new MappingJackson2JsonView();
/**
* 发送json错误消息作为响应
* @param errorCode 错误码
* @param errorMsg 错误消息
* @return 无返回值
* @throws ModelAndViewDefiningException
*/
private boolean sendJsonError(int errorCode, String errorMsg) throws ModelAndViewDefiningException {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setView(jsonView);
modelAndView.addObject("code", errorCode);
modelAndView.addObject("msg", errorMsg);
throw new ModelAndViewDefiningException(modelAndView);
}
}
此处使用了一些技巧:
- 将拦截器也作为Spring Bean拉起,从而拦截器也可以使用到Spring Bean;
- 通过注解声明拦截器的拦截路径,从而使装配代码能用化;
- 定义自定义的拦截器接口,以声明需要自动装配的拦截器
// 拦截路径定义注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PathMapping {
@AliasFor("includes")
String[] value() default {};
@AliasFor("value")
String[] includes() default {};
String[] excludes() default {};
}
// 自动装配拦截器接口
public interface AutoConfigInterceptor extends HandlerInterceptor{}
4.4.2 装配拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final List<AutoConfigInterceptor> interceptors;
public WebConfig(List<AutoConfigInterceptor> interceptors) {
this.interceptors = interceptors;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
interceptors.forEach(interceptor -> {
final PathMapping mapping = interceptor.getClass().getAnnotation(PathMapping.class);
if (mapping != null) {
registry.addInterceptor(interceptor)
.addPathPatterns(mapping.includes()).excludePathPatterns(mapping.excludes());
}
});
}
}
4.5 定义Spring属性
# application.properties
# [可选,默认30分钟]spring session超时时间
spring.session.timeout=1200
# 允许的同一账号最大在线数,数值为几则允许几次登录同时在线,大于零时生效
sso.maxOnlinePerUser=1
5. 结束语
以上就是全部代码和注意事项,编写完成后,你可以打开多个浏览器+无痕浏览器测试,还可以个性sso.maxOnlinePerUser的值来允许同时多个在线。
通过对spring session redis的深入使用,是不是发现代码逻辑变得很简单清晰?