Exploration of kicking people off the assembly line in Spring Security framework

Posted by saco721 on Tue, 07 Dec 2021 16:23:57 +0100

1. Background

In the development of a project, the Spring Security permission framework is used for permission verification of back-end permission development. The bottom layer integrates spring Session components, which is very convenient to integrate Redis for Session cluster deployment of distributed sessions. After the system is officially launched, each deployment node can carry out cluster deployment very conveniently. All the user's Session information is saved in the Redis middleware library. Developers do not need to care about the specific implementation. Spring Session components have been fully integrated.

However, the user management module of the system provides the function of deleting and disabling system user accounts. For these two functions, the specific requirements given by the demander are:

  • Delete: when the administrator deletes the current user account, if the current account has logged in to the system, it needs to be removed from the offline and cannot be logged in
  • Disable: when the administrator disables the current account, if the current account has logged in to the system, you need to remove the offline, and when logging in, you will be prompted that the current account has been disabled

2. Demand analysis

From the above requirements, whether deleting or disabling the function needs to be implemented. If the current account has logged in to the system, the offline needs to be removed, and the disabling operation only needs to give a prompt when logging in again. This can be implemented in the business login method without considering the underlying framework.

Therefore, when considering the underlying technology, we need to explore how to realize the function of kicking people off the line in the Spring Security permission framework.

Now that the requirements are clear, we need to consider the possibility of function realization from several aspects:

  • 1) In the Spring Security framework, where is the Session information stored when the user logs in?
  • 2) In the Spring Security framework, how is a Session stored and what information is mainly stored?
  • 3) How to collect all Session session information logged in by the account?
  • 4) How to actively destroy the Session object on the server?

1) In the Spring Security framework, where is the Session information stored when the user logs in?

If we do not consider the distributed Session, in the single Spring Boot project, the server Session must be stored in memory. This disadvantage is that if the current application needs load balancing for deployment, the Session will be lost when the user requests the server interface, because the Session information logged in by the user is stored in the JVM memory, There is no sharing and interworking between processes.

In order to solve the problem that the Session of the distributed application is not lost, the Spring Session component has been released. The component provides a way based on middleware such as JDBC\Redis to store the Session of the user in the middleware. In this way, when the distributed application obtains the user Session, it will obtain the Session from the middleware, This also ensures that the service can be deployed to ensure that the Session session is not lost. This paper mainly discusses this situation. The integrated Redis middleware is used to save user Session information.

2) In the Spring Security framework, how is a Session stored and what information is mainly stored?

Because we use Redis middleware, the Session session information generated in the Spring Security permission framework must be stored in Redis. There is no doubt about this. What information is stored? I will introduce it in the following source code analysis

3) How to collect all Session session information logged in by the account?

We have learned from the above requirements analysis that the Session session has been stored in Redis. Can we assume that we only need to find the Redis cache data related to the login user name according to the key values stored in Redis in Spring Security, and then we can get it by calling the method encapsulated in Security, Get the Session information of the current login account? We need to find the answer in the source code

4) How to actively destroy the Session object on the server?

If it is a single Spring Boot application, the Session information must be stored in the memory of the JVM. To actively destroy the Session object, the server only needs to find out how the Security permission framework stores it.

In the distributed Spring Boot application, we have learned from the above that the Session information is stored in Redis middleware, so we only need to get the key value of the currently logged in Session in Redis, and then we can call the method to delete it, so as to actively destroy the Session on the service side

3. Source code analysis

In the above requirements analysis, we have put forward assumptions and made technical judgments according to the assumptions. Next, we need to find the answers we need from the source code of Spring Security and Spring Session components.

First, before analyzing the source code, we need to find the entry, that is, how we use it when we use the Spring Security framework and the Spring Session component.

It is essential to introduce component dependency in pom.xml file, as follows:

<!--Spring Security assembly-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Spring in the light of Redis Operating components-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--Spring Session integrate Redis Distributed Session conversation-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

Next, in the Spring Boot project, we need to add the @ enablereredishttpsession annotation to enable the Redis component's support for Session sessions. With this annotation, we need to formulate the Redis namespace stored in Redis for spring sessions to ensure the timeliness of Session sessions. The example code is as follows:

@SpringBootApplication
@EnableRedisHttpSession(redisNamespace = "fish-admin:session",maxInactiveIntervalInSeconds = 7200)
public class FishAdminApplication {

    static Logger logger= LoggerFactory.getLogger(FishAdminApplication.class);

    public static void main(String[] args) throws UnknownHostException {
        ConfigurableApplicationContext application=SpringApplication.run(FishAdminApplication.class, args);
        Environment env = application.getEnvironment();
        String host= InetAddress.getLocalHost().getHostAddress();
        String port=env.getProperty("server.port");
        logger.info("\n----------------------------------------------------------\n\t" +
                        "Application '{}' is running! Access URLs:\n\t" +
                        "Local: \t\thttp://localhost:{}\n\t" +
                        "External: \thttp://{}:{}\n\t"+
                        "Doc: \thttp://{}:{}/doc.html\n\t"+
                        "----------------------------------------------------------",
                env.getProperty("spring.application.name"),
                env.getProperty("server.port"),
                host,port,
                host,port);
    }

In the above code, we specify that the Redis namespace is fish admin: session. By default, the maximum expiration time is 7200 seconds.

If the developer does not specify these two attributes by default, the default namespace value is spring:session, and the default maximum aging is 1800 seconds

As we have said above, since we are looking at the source code, we need to find an entry, which is the best way to look at the source code. When we use the Spring Session component, we need to use the @ enablereredishttpsession annotation. Then the annotation is the object we need to focus on. We need to find out what the role of the annotation is?

The source code of enablereredishttpsession.java is as follows:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)
@Configuration(proxyBeanMethods = false)
public @interface EnableRedisHttpSession {
     //more property..   
}

In this annotation, we can see that the key is to use the @ Import annotation to Import the RedisHttpSessionConfiguration.java configuration class. If you often look at the source code related to Spring Boot, you will keenly realize that this configuration class is the last class we are looking for

Let's take a look at the UML diagram of this class, as follows:

This class implements many Aware type interfaces in the Spring framework. As we all know, after the Spring container starts to create an entity Bean, it will call the set method of Aware series to pass parameter assignment

Of course, the most important thing we can see from the source code is that the Spring Session component will inject two entity beans into the Spring container. The code is as follows:

@Bean
public RedisIndexedSessionRepository sessionRepository() {
    RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
    RedisIndexedSessionRepository sessionRepository = new RedisIndexedSessionRepository(redisTemplate);
    sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
    if (this.indexResolver != null) {
        sessionRepository.setIndexResolver(this.indexResolver);
    }
    if (this.defaultRedisSerializer != null) {
        sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
    }
    sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
    if (StringUtils.hasText(this.redisNamespace)) {
        sessionRepository.setRedisKeyNamespace(this.redisNamespace);
    }
    sessionRepository.setFlushMode(this.flushMode);
    sessionRepository.setSaveMode(this.saveMode);
    int database = resolveDatabase();
    sessionRepository.setDatabase(database);
    this.sessionRepositoryCustomizers
        .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
    return sessionRepository;
}

@Bean
public RedisMessageListenerContainer springSessionRedisMessageListenerContainer(
    RedisIndexedSessionRepository sessionRepository) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(this.redisConnectionFactory);
    if (this.redisTaskExecutor != null) {
        container.setTaskExecutor(this.redisTaskExecutor);
    }
    if (this.redisSubscriptionExecutor != null) {
        container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
    }
    container.addMessageListener(sessionRepository,
                                 Arrays.asList(new ChannelTopic(sessionRepository.getSessionDeletedChannel()),
                                               new ChannelTopic(sessionRepository.getSessionExpiredChannel())));
    container.addMessageListener(sessionRepository,
                                 Collections.singletonList(new PatternTopic(sessionRepository.getSessionCreatedChannelPrefix() + "*")));
    return container;
}

Entity beans of RedisIndexedSessionRepository and RedisMessageListenerContainer

  • RedisMessageListenerContainer: this class is redis's message notification callback mechanism entity class. Redis provides operation callback message notifications for different keys, such as common callbacks for deleting keys, key expiration and other events. This entity Bean is injected into the Spring Session component. It can also be seen from the code that it is used to listen to and handle Session expiration and deletion events
  • RedisIndexedSessionRepository: this class is a specific implementation class provided by Spring Session components for a series of Session session operations based on Redis, which is the focus of our next source code analysis.

Let's first look at the UML class diagram structure of RedisIndexedSessionRepository class, as shown in the following figure:

RedisIndexedSessionRepository implements the FindByIndexNameSessionRepository interface, which inherits the top-level SessionRepository interface provided by the Spring Security permission framework. In the UML class diagram, we can get several important information:

  • RedisIndexedSessionRepository has the ability to create Session sessions, destroy and delete Session sessions
  • RedisIndexedSessionRepository implements the FindByIndexNameSessionRepository interface, which provides the ability to find Session sessions according to PrincipalName
  • It has the ability to handle Redis callback events because it implements the MessageListener interface

SessionRepository is the top-level interface provided by Spring Security. The source code is as follows:

public interface SessionRepository<S extends Session> {

	/**
	 * Creates a new {@link Session} that is capable of being persisted by this
	 * {@link SessionRepository}.
	 *
	 * <p>
	 * This allows optimizations and customizations in how the {@link Session} is
	 * persisted. For example, the implementation returned might keep track of the changes
	 * ensuring that only the delta needs to be persisted on a save.
	 * </p>
	 * @return a new {@link Session} that is capable of being persisted by this
	 * {@link SessionRepository}
	 */
	S createSession();

	/**
	 * Ensures the {@link Session} created by
	 * {@link org.springframework.session.SessionRepository#createSession()} is saved.
	 *
	 * <p>
	 * Some implementations may choose to save as the {@link Session} is updated by
	 * returning a {@link Session} that immediately persists any changes. In this case,
	 * this method may not actually do anything.
	 * </p>
	 * @param session the {@link Session} to save
	 */
	void save(S session);

	/**
	 * Gets the {@link Session} by the {@link Session#getId()} or null if no
	 * {@link Session} is found.
	 * @param id the {@link org.springframework.session.Session#getId()} to lookup
	 * @return the {@link Session} by the {@link Session#getId()} or null if no
	 * {@link Session} is found.
	 */
	S findById(String id);

	/**
	 * Deletes the {@link Session} with the given {@link Session#getId()} or does nothing
	 * if the {@link Session} is not found.
	 * @param id the {@link org.springframework.session.Session#getId()} to delete
	 */
	void deleteById(String id);

}

The interface provides four methods:

  • createSession: create Session
  • Save: save Session
  • findById: find and obtain the Session session object information according to the SessionId
  • deleteById: delete according to SessionId

The source code of FindByIndexNameSessionRepository mainly provides the function of querying according to the account name, as follows:

public interface FindByIndexNameSessionRepository<S extends Session> extends SessionRepository<S> {

	/**
	 * The currently stored user name prefix. When Redis is used for storage, the stored key value is redisNamespace+
	 */
	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 core function of this interface is to provide an interface to find and obtain Session sessions according to user names, which is very helpful for us to implement the kicking function later.

By viewing the source code of the SessionRepository interface and FindByIndexNameSessionRepository interface, we know:

  • Redis finally implements these two interfaces, so it has the ability to create and destroy Session sessions based on redis middleware
  • Find all the current login sessions according to the account. The Session meets the functional requirements that we finally need the server to take the initiative to kick people off the line.

Next, we only need to focus on the implementation of RedisIndexedSessionRepository. First, let's look at the findByPrincipalName method. The source code is as follows:

@Override
public Map<String, RedisSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
    //If the names do not match, the empty set Map will be fed back directly
    if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) {
        return Collections.emptyMap();
    }
    //Get the assembled Key value
    String principalKey = getPrincipalKey(indexValue);
    //The number of members that get the Key value from Redis
    Set<Object> sessionIds = this.sessionRedisOperations.boundSetOps(principalKey).members();
    //Initialize Map collection
    Map<String, RedisSession> sessions = new HashMap<>(sessionIds.size());
    //Loop traversal
    for (Object id : sessionIds) {
        //Find Session session by id
        RedisSession session = findById((String) id);
        if (session != null) {
            sessions.put(session.getId(), session);
        }
    }
    return sessions;
}

String getPrincipalKey(String principalName) {
    return this.namespace + "index:" + FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":"
        + principalName;
}

Next, let's look at the implementation of the method to delete a Session:

@Override
public void deleteById(String sessionId) {
    //Get Session session based on sessionId
    RedisSession session = getSession(sessionId, true);
    if (session == null) {
        return;
    }
	//Remove all stored key values for principal from Redis
    cleanupPrincipalIndex(session);
    //Delete the key value corresponding to SessionId in Redis
    this.expirationPolicy.onDelete(session);
    //Remove the expired key value stored during Session creation
    String expireKey = getExpiredKey(session.getId());
    this.sessionRedisOperations.delete(expireKey);
    //Set the maximum lifetime of the current session to 0
    session.setMaxInactiveInterval(Duration.ZERO);
    //Execute the save method
    save(session);
}

From the above code, we already know the Spring Session component's Session related processing methods. In fact, based on the above two core methods, we have obtained the ability to kick people off the line. However, since RedisIndexedSessionRepository implements the MessageListener interface, we need to continue to track the specific implementation method of the interface, Let's directly look at the onMessage method. The code is as follows:

@Override
public void onMessage(Message message, byte[] pattern) {
    byte[] messageChannel = message.getChannel();
    byte[] messageBody = message.getBody();

    String channel = new String(messageChannel);

    if (channel.startsWith(this.sessionCreatedChannelPrefix)) {
        // TODO: is this thread safe?
        @SuppressWarnings("unchecked")
        Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer.deserialize(message.getBody());
        handleCreated(loaded, channel);
        return;
    }

    String body = new String(messageBody);
    if (!body.startsWith(getExpiredKeyPrefix())) {
        return;
    }

    boolean isDeleted = channel.equals(this.sessionDeletedChannel);
    if (isDeleted || channel.equals(this.sessionExpiredChannel)) {
        int beginIndex = body.lastIndexOf(":") + 1;
        int endIndex = body.length();
        String sessionId = body.substring(beginIndex, endIndex);

        RedisSession session = getSession(sessionId, true);

        if (session == null) {
            logger.warn("Unable to publish SessionDestroyedEvent for session " + sessionId);
            return;
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
        }

        cleanupPrincipalIndex(session);

        if (isDeleted) {
            handleDeleted(session);
        }
        else {
            handleExpired(session);
        }
    }
}

private void handleDeleted(RedisSession session) {
		publishEvent(new SessionDeletedEvent(this, session));
}

private void handleExpired(RedisSession session) {
    publishEvent(new SessionExpiredEvent(this, session));
}

private void publishEvent(ApplicationEvent event) {
    try {
        this.eventPublisher.publishEvent(event);
    }
    catch (Throwable ex) {
        logger.error("Error publishing " + event + ".", ex);
    }
}

In the onMessage method, the core is the last judgment. Execute the handleDeleted and handleExpired methods respectively. From the source code, we can see that when the current Session is deleted or invalidated, the Spring Session will broadcast an event through ApplicationEventPublisher to process the SessionExpiredEvent and SessionDeletedEvent events respectively

This is an Event event for the Session session reserved by the Spring Session component for developers. If developers have special processing requirements for deleting or invalidating the current Session, they can handle it by listening to this Event.

For example, developers need to do business operations for Session sessions and save the log to the DB database. At this time, developers only need to use the EventListener implementation provided by Spring to easily implement it. The example code is as follows:

@Component
public class SecuritySessionEventListener {

    @EventListener
    public void sessionDestroyed(SessionDestroyedEvent event) {
        //session destroy event handling method
    }

    @EventListener
    public void sessionCreated(SessionCreatedEvent event) {
        //Session create session event handling method
    }

    @EventListener
    public void sessionExired(SessionExpiredEvent event) {
        //Session session expiration event handling method
    }
}

4. Solutions

We analyzed the Redis based implementation of Spring Session for Session. Next, we know how to find Session sessions and how to destroy them from the source code. At this time, we can transform our framework code

Create SessionService interface with the following code:

public interface SessionService {

    /**
     *
     * @param account
     * @return
     */
    boolean hasLogin(String account);

    /**
     * Find the current session according to the account
     * @param account account number
     * @return
     */
    Map<String, ? extends Session> loadByAccount(String account);

    /**
     * Destroy the current session
     * @param account
     */
    void destroySession(String account);
}

Declare that the interface mainly contains three methods:

  • hasLogin: judge whether the account has been logged in by passing the login account. This method is an extension of the business. For example, we judge whether the current account has been logged in. If you log in, you will be prompted to exit before you can continue logging in
  • loadByAccount: obtain the currently logged in Session Map collection according to the login account
  • destroySession: destroy all Session session information of the current account according to the login account. This interface is consistent with the kickout operation required by the product manager

The next step is to implement the class. Since we process it based on Redis, we need to introduce the RedisIndexedSessionRepository entity Bean in the source code analysis to implement the interface method with the help of this class

RedisSessionService method is implemented as follows:

/**
 * SpringSession Integrate the underlying Redis implementation. If the underlying distributed session holding method is not based on Redis, this class cannot be used normally
 * @author <a href="mailto:xiaoymin@foxmail.com">xiaoymin@foxmail.com</a>
 * 2021/04/20 16:23
 * @since:fish 1.0
 */
public class RedisSessionService implements SessionService {

    Logger logger= LoggerFactory.getLogger(RedisSessionService.class);

    final RedisIndexedSessionRepository redisIndexedSessionRepository;

    final ApplicationEventPublisher applicationEventPublisher;

    public RedisSessionService(RedisIndexedSessionRepository redisIndexedSessionRepository, ApplicationEventPublisher applicationEventPublisher) {
        this.redisIndexedSessionRepository = redisIndexedSessionRepository;
        this.applicationEventPublisher = applicationEventPublisher;
    }


    @Override
    public boolean hasLogin(String account) {
        return CollectionUtil.isNotEmpty(loadByAccount(account));
    }

    @Override
    public Map<String, ? extends Session> loadByAccount(String account) {
        logger.info("Collect current logon sessions session,account number:{}",account);
        return redisIndexedSessionRepository.findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,account);
    }

    @Override
    public void destroySession(String account) {
        logger.info("Destroy current login session conversation,account number:{}",account);
        Map<String,? extends Session> sessionMap=loadByAccount(account);
        if (CollectionUtil.isNotEmpty(sessionMap)){
            logger.info("Current logon session size:{}",sessionMap.size());
            for (Map.Entry<String,? extends Session> sessionEntry:sessionMap.entrySet()){
                String key=sessionEntry.getKey();
                Session session=sessionEntry.getValue();
                logger.info("destroy session key:{}",key);
                //delete
                redisIndexedSessionRepository.deleteById(session.getId());
                //Broadcast Session destroy event
                applicationEventPublisher.publishEvent(new SessionDestroyedEvent(redisIndexedSessionRepository,session));
            }
        }
    }
}

In the implementation of the destroySession method, first obtain the information of all current login sessions according to the account. If the session is not empty, traverse the session Map collection, delete the session, and broadcast an event that the session is destroyed through applicationEventPublisher. This broadcast event is not necessary, but it needs to be added in consideration of the overall situation of the code

Next, we can inject this class into the Spring container. The injected entity Bean code is as follows:

@Bean
public RedisSessionService sessionService(RedisIndexedSessionRepository redisIndexedSessionRepository, ApplicationEventPublisher applicationEventPublisher){
    return new RedisSessionService(redisIndexedSessionRepository,applicationEventPublisher);
}

PS: why do we need to create an interface instead of directly creating a class to inject through @ Service and other annotations, but implement the class through an abstract interface, and finally inject through JavaConfig? From the perspective of code coupling, Spring Session provides the ability to handle Redis based sessions, and also provides diversified extension methods such as JDBC\mongo. Therefore, in order to decouple the code, it is more reasonable to use the abstract interface.

Next, we can operate in our user managed business Service method

Delete user's business Service method

/**
* Delete user management based on primary key id
* @param id Primary key id
* @return Delete successfully
*/
@Override
public RestfulMessage<String> delete(Integer id) {
    logger.info("According to primary key id Delete user management,id:{}",id);
    FishUserInfo fishUserInfo=fishUserInfoMapper.selectByPrimaryKey(id);
    assertArgumentNotEmpty(fishUserInfo,"The requested data is illegal");
    int ret=fishUserInfoMapper.deleteByPrimaryKey(id);
    //The deletion is successful. If the role is online, the offline will be forcibly removed
    if (ret>0){
        logger.info("User session offline");
        sessionService.destroySession(fishUserInfo.getAccount());
    }
    return ret>0?RestfulMessage.success("Delete succeeded"):RestfulMessage.error("Deletion failed");
}

Disable user

The operation method of disabling a user is the same as that of deleting. The difference is that disabling only changes the user's state in the database, while deleting is to delete the user's data from the database DB. After updating the user status of the library, call destroySession to delete all Session session operations of the account.

Topics: Java Back-end Programmer