Use Spring Session Redis to elegantly implement "account is squeezed offline by another login"

1. Background

    Regardless of security reasons or the third-level guarantee requirements, when we log in to the site, we will inevitably face restrictions on the number of simultaneous online lines of the same account (and the same application).

    Logically speaking, crowding people offline is not a very difficult logic, but it faces the following challenges:

  1. Account online counting needs to cache the relationship between session and user (+ application) and the sequence of login time, and bind the life cycle of the session, otherwise it is easy to miscrowd or memory leak;
  2. Squeeze offline logic needs to operate other sessions other than the current session;

    The above two points can easily lead to strong coupling between the offline logic and the session, and the code is scattered in the implementation of login and session, which is complex and difficult to maintain.

    If we use Spring Session Redis, then things become simpler. Although the online crowding logic is not implemented, the Spring Session framework provides us with the expansion capabilities needed to solve the above two problems.


Figure 1 Rendering

2. Technical preparation

    Due to the good encapsulation of Spring Session, it transparently encapsulates the Servlet Session through the decorator mode, so that the business code has no sense of whether or not Spring Session is used. Its idea is very worth our study and study.

    Spring Session provides Session management through SessionRepository. The key is that it has a sub-interface that defines a very important method:

/**
 * 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);

	}

}

The findByIndexNameAndIndexValue() method defines how to query all matching Sessions by index (specifically, the Map mapping of sessionId->Session), and Spring Session Redis implements this interface. So as long as Spring Session Redis is used, we have the means to solve the two problems in the background:

  1. Find all sessions of an account through FindByIndexNameSessionRepository.findByIndexNameAndIndexValue() ;
  2. Use SessionRepository.deleteById() to invalidate the Session that needs to be squeezed out;

3. Schematic design

    Through the study of Spring Session documentation and source code, a simple and clear "account is pushed offline by another login" solution is ready to emerge:

  1. After successful login, create a session index, you can use [user name + application ID] as the index key, so that the same account can only be logged in once in different applications (such as mobile terminal, PC terminal);
  2. After creating the session index, check the current user's [username + application ID] to check whether the number of logged-in sessions exceeds the upper limit. If the number exceeds the upper limit, the earliest session will be invalidated, and the reason for the failure will be recorded;
  3. For all URLs that require login access, use the interceptor to check whether the session is invalid, and return the failure reason if it is invalid;

    Is the above logic very simple? Seeing this, I suggest that you can start coding yourself.

    Of course, in fact, the implementation of Spring Session Redis is not so perfect, and there are some small problems. Let's listen to me while watching the code.

4. Code implementation

4.1 Engineering dependencies

Before starting the project, make sure that there are the following dependencies, taking maven as an example

    <!-- Spring Session Redis的依赖 -->
    <dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
    </dependency>

4.2 Write the main logic

4.2.1 Create the MaxOnlineService class, the main logic will be implemented here

@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 Implement the logic to create a session index

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
    }

    //...
}

According to the design of FindByIndexNameSessionRepository.findByIndexNameAndIndexValue() , indexName should support any custom parameters, but looking at the code of Spring Session Redis, the implementation is not so perfect:

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;
	}

    //...
}

The RedisIndexedSessionRepository code is hard-coded and can only use FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME , so the first parameter of session.setAttribute can only be FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME

4.2.3 Squeeze out redundant Session logic

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);
    }
    // ...
}

The code logic here is still relatively simple, but the stream of java8 is used to complete this.

4.2.4 Write logic to check online

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();
    }
}

All the logic of MaxOnlineService is completed, and then the call after successful login is completed.

4.3 Call the squeeze-out logic after successful login

@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 Tips for the interceptor to be squeezed out of the line

4.4.1 Interceptor Implementation

@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);
    }
}

Some tricks are used here:

  1. The interceptor is also pulled up as a Spring Bean, so that the interceptor can also use Spring Bean;
  2. Declare the interception path of the interceptor through annotations, so as to make the assembly code usable;
  3. Define a custom interceptor interface to declare interceptors that need to be autowired
// 拦截路径定义注解
@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 Assembling the interceptor

@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 Defining Spring Properties

# application.properties

# [可选,默认30分钟]spring session超时时间
spring.session.timeout=1200
# 允许的同一账号最大在线数,数值为几则允许几次登录同时在线,大于零时生效
sso.maxOnlinePerUser=1

5. Conclusion

The above is all the code and precautions. After writing, you can open multiple browsers + incognito browser testing, and you can also customize the value of sso.maxOnlinePerUser to allow multiple online at the same time.

Through the in-depth use of spring session redis, did you find that the code logic became very simple and clear?

{{o.name}}
{{m.name}}

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=324182839&siteId=291194637