使用Spring Session Redis优雅地实现“账号被另一处登录挤下线”

1. 背景

    不管出于安全的原因,或者三级等保要求,我们在实现站点的登录时,都少不了会面临需要限制同一账号(及同一应用)同时上线数的限制。

    挤人下线从逻辑上来说,并不是一个非常困难的逻辑,但是面临以下挑战:

  1. 账号在线计数需要缓存session与用户(+应用)的关系及登录时间顺序,并绑定session的生命周期,否则容易误挤或者内存泄漏;
  2. 挤下线逻辑需要操作当前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,我们就得到了解决背景中两个问题的手段:

  1. 通过FindByIndexNameSessionRepository.findByIndexNameAndIndexValue()找出某账号的所有Session;
  2. 通过SessionRepository.deleteById()来使需要被挤掉的Session失效;

3. 方案设计

    通过对Spring Session文档和源代码的研读,一个简单清晰的“账号被另一处登录挤下线”方案呼之欲出:

  1. 登录成功后,创建Session的索引,可以使用[用户名+应用ID]作为索引键,这样可以实现限制同一账号在不同应用(如移动端、PC端)中分别只能登录一次;
  2. 创建Session索引后,检查当前用户的[用户名+应用ID]去检查登录Session数有无超过上限,超过则将最早的Session失效,并记录失效原因;
  3. 对所有需要登录访问的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);
    }
}

此处使用了一些技巧:

  1. 将拦截器也作为Spring Bean拉起,从而拦截器也可以使用到Spring Bean;
  2. 通过注解声明拦截器的拦截路径,从而使装配代码能用化;
  3. 定义自定义的拦截器接口,以声明需要自动装配的拦截器
// 拦截路径定义注解
@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的深入使用,是不是发现代码逻辑变得很简单清晰?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章