Quick Sketch of Hacking Spring Session

Introduction

Recently I learned how to use MySQL DB as the session storage and use JDBC Session management for security and some minor session work in web applications. This is done with annotation based injection. One weakness with it is that the max session duration can only be set as a constant value for the annotation. It just seems strange that it can't be loaded from a configurable source.

After some digging, I found a way. First I found out how this annotation injection works, then I was able to use the original source and add some of my design to it, so that it can load the configuration from a configuration file. Here is how the original implementation works. On my data access configuration class, I have to use @EnableJdbcHttpSession annotation:

@EnableJdbcHttpSession
@Configuration
public class SessionDataAccessConfiguration
{
   @Value("${db.jdbc.driver}")
   private String dbJdbcDriver;
   
   @Value("${db.session.conn.string}")
   private String dbSesnConnString;
   
   @Value("${db.session.access.username}")
   private String dbSesnAccessUserName;

   @Value("${db.session.access.password}")
   private String dbSesnAccessPassword;

   @Value("${db.session.access.validity.query}")
   private String dbSesnAccessValityQuery;
   
   @SpringSessionDataSource
   @Bean
   public DataSource sessionDataSource()
   {
      BasicDataSource dataSource = new BasicDataSource();

      dataSource.setDriverClassName(dbJdbcDriver);
      dataSource.setUrl(dbSesnConnString);
      dataSource.setUsername(dbSesnAccessUserName);
      dataSource.setPassword(dbSesnAccessPassword);
      dataSource.setMaxIdle(4);
      dataSource.setMaxTotal(20);
      dataSource.setInitialSize(4);
      dataSource.setMaxWaitMillis(900000);
      dataSource.setTestOnBorrow(true);
      dataSource.setValidationQuery(dbSesnAccessValityQuery);
      
      return dataSource;
   }
}

This is pretty simple, I create a configuration class, and annotate the class with @EnableJdbcHttpSession. What this does is that it will indirectly create an object of type JdbcHttpSessionConfiguration. This JdbcHttpSessionConfiguration object uses a method called setDataSource() to set the data source (info about how to connect to a database). In my config class, I create a bean that provides this data source. And this object (of JdbcHttpSessionConfiguration) can create a bean via method sessionRepository(). These setup is pretty cool. However. As I have mentioned before, the session duration cannot be set dynamically. It has to be a constant value to be specified in the annotation, like this:

@EnableJdbcHttpSession(maxInactiveIntervalInSeconds=900)
@Configuration
public class SessionDataAccessConfiguration
{
...
}

I don't like this. Since Spring is open source, finding the source code of this JdbcHttpSessionConfiguration is easy. From that, I can either extend the existing class with a new sub class or create my own. Unfortunately extending is not an option. The properties of the class has only public getter, and private setter. So I cannot just subclass. It would be neat if I could. So I have to create my own class that looks similar to original implementation. And this approach worked.

Accomplishments

Here are the goals I wanted to accomplish:

  • I want to be able to specify the session specific settings with config file.
  • In the config file, I don't have to specify all the config keys and values. Some of them can be optional and can take default value if not in config file.
  • Application configuration file can live outside of the application jar and can be passed in as command line arguments.

I was able to get all these done. And learn something along the way.

New @EnableJdbcHttpSession and New JdbcHttpSessionConfiguration

In order to get this new setup to work, I have to create both types anew. The reason, the annotation type @EnableJdbcHttpSession hard coded the JdbcHttpSessionConfiguration as injection object type. So I need new annotation to hard code my own JdbcHttpSessionConfiguration like implementation.

Here it is:

package org.hanbo.boot.app.security;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.session.FlushMode;
import org.springframework.session.MapSession;
import org.springframework.session.SaveMode;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(HanBo2JdbcHttpSessionConfiguration.class)
@Configuration(proxyBeanMethods = false)
public @interface EnableHanBo2JdbcHttpSession {

   int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;

   String tableName() default JdbcIndexedSessionRepository.DEFAULT_TABLE_NAME;

   String cleanupCron() default HanBo2JdbcHttpSessionConfiguration.DEFAULT_CLEANUP_CRON;

   FlushMode flushMode() default FlushMode.ON_SAVE;

   SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE;
}

I have used the bold letter to highlight the place where my HanBo2JdbcHttpSessionConfiguration are used. The most important part is this: @Import(HanBo2JdbcHttpSessionConfiguration.class). if you check the original implementation, it is the same way how this works.

Next, is my definition of the class HanBo2JdbcHttpSessionConfiguration:

package org.hanbo.boot.app.security;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import javax.sql.DataSource;

import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.EmbeddedValueResolverAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportAware;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.core.serializer.support.DeserializingConverter;
import org.springframework.core.serializer.support.SerializingConverter;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.MetaDataAccessException;
import org.springframework.jdbc.support.lob.DefaultLobHandler;
import org.springframework.jdbc.support.lob.LobHandler;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.session.FlushMode;
import org.springframework.session.IndexResolver;
import org.springframework.session.MapSession;
import org.springframework.session.SaveMode;
import org.springframework.session.Session;
import org.springframework.session.config.SessionRepositoryCustomizer;
import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionOperations;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import org.springframework.util.StringValueResolver;



@Configuration(proxyBeanMethods = false)
public class HanBo2JdbcHttpSessionConfiguration extends SpringHttpSessionConfiguration
      implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware {

   static final String DEFAULT_CLEANUP_CRON = "0 * * * * *";

   private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;

   private String tableName = JdbcIndexedSessionRepository.DEFAULT_TABLE_NAME;

   private String cleanupCron = DEFAULT_CLEANUP_CRON;

   private FlushMode flushMode = FlushMode.ON_SAVE;

   private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;

   private DataSource dataSource;

   private PlatformTransactionManager transactionManager;

   private TransactionOperations transactionOperations;

   private IndexResolver<Session> indexResolver;

   private LobHandler lobHandler;

   private ConversionService springSessionConversionService;

   private ConversionService conversionService;

   private List<SessionRepositoryCustomizer<JdbcIndexedSessionRepository>> sessionRepositoryCustomizers;

   private ClassLoader classLoader;

   private StringValueResolver embeddedValueResolver;
   
   
   // ** Hanbo Configuration from properties file.
   @Value("${hanbo.spring.session.jdbc.tableName:#{null}}")
   private Optional<String> sessionJdbcTableName;
   
   @Value("${hanbo.spring.servlet.session.timeoutInSeconds:#{null}}")
   private Optional<String> servletSessionTimeout;
   
   @Value("${hanbo.spring.session.jdbc.cleanupCron:#{null}}")
   private Optional<String> servletSessionCleanupcron;
   
   @Value("${hanbo.spring.session.jdbc.flushMode:#{null}}")
   private Optional<String> servletSessionFlushMode;
   
   @Value("${hanbo.spring.session.jdbc.saveMode:#{null}}")
   private Optional<String> servletSessionSaveMode;
   
   

   @Bean
   public JdbcIndexedSessionRepository sessionRepository() {
      JdbcTemplate jdbcTemplate = createJdbcTemplate(this.dataSource);
      if (this.transactionOperations == null) {
         this.transactionOperations = createTransactionTemplate(this.transactionManager);
      }
      JdbcIndexedSessionRepository sessionRepository = new JdbcIndexedSessionRepository(jdbcTemplate,
            this.transactionOperations);
      if (StringUtils.hasText(this.tableName)) {
         sessionRepository.setTableName(this.tableName);
      }
      sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
      sessionRepository.setFlushMode(this.flushMode);
      sessionRepository.setSaveMode(this.saveMode);
      if (this.indexResolver != null) {
         sessionRepository.setIndexResolver(this.indexResolver);
      }
      if (this.lobHandler != null) {
         sessionRepository.setLobHandler(this.lobHandler);
      }
      else if (requiresTemporaryLob(this.dataSource)) {
         DefaultLobHandler lobHandler = new DefaultLobHandler();
         lobHandler.setCreateTemporaryLob(true);
         sessionRepository.setLobHandler(lobHandler);
      }
      if (this.springSessionConversionService != null) {
         sessionRepository.setConversionService(this.springSessionConversionService);
      }
      else if (this.conversionService != null) {
         sessionRepository.setConversionService(this.conversionService);
      }
      else {
         sessionRepository.setConversionService(createConversionServiceWithBeanClassLoader(this.classLoader));
      }
      this.sessionRepositoryCustomizers
            .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
      return sessionRepository;
   }

   private static boolean requiresTemporaryLob(DataSource dataSource) {
      try {
         String productName = JdbcUtils.extractDatabaseMetaData(dataSource, "getDatabaseProductName");
         return "Oracle".equalsIgnoreCase(JdbcUtils.commonDatabaseName(productName));
      }
      catch (MetaDataAccessException ex) {
         return false;
      }
   }

   public void setMaxInactiveIntervalInSeconds(Integer maxInactiveIntervalInSeconds) {
      this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds;
   }

   public void setTableName(String tableName) {
      this.tableName = tableName;
   }

   public void setCleanupCron(String cleanupCron) {
      this.cleanupCron = cleanupCron;
   }

   public void setFlushMode(FlushMode flushMode) {
      this.flushMode = flushMode;
   }

   public void setSaveMode(SaveMode saveMode) {
      this.saveMode = saveMode;
   }

   @Autowired
   public void setDataSource(@SpringSessionDataSource ObjectProvider<DataSource> springSessionDataSource,
         ObjectProvider<DataSource> dataSource) {
      DataSource dataSourceToUse = springSessionDataSource.getIfAvailable();
      if (dataSourceToUse == null) {
         dataSourceToUse = dataSource.getObject();
      }
      this.dataSource = dataSourceToUse;
   }

   @Autowired
   public void setTransactionManager(PlatformTransactionManager transactionManager) {
      this.transactionManager = transactionManager;
   }

   @Autowired(required = false)
   @Qualifier("springSessionTransactionOperations")
   public void setTransactionOperations(TransactionOperations transactionOperations) {
      this.transactionOperations = transactionOperations;
   }

   @Autowired(required = false)
   public void setIndexResolver(IndexResolver indexResolver) {
      this.indexResolver = indexResolver;
   }

   @Autowired(required = false)
   @Qualifier("springSessionLobHandler")
   public void setLobHandler(LobHandler lobHandler) {
      this.lobHandler = lobHandler;
   }

   @Autowired(required = false)
   @Qualifier("springSessionConversionService")
   public void setSpringSessionConversionService(ConversionService conversionService) {
      this.springSessionConversionService = conversionService;
   }

   @Autowired(required = false)
   @Qualifier("conversionService")
   public void setConversionService(ConversionService conversionService) {
      this.conversionService = conversionService;
   }

   @Autowired(required = false)
   public void setSessionRepositoryCustomizer(
         ObjectProvider<SessionRepositoryCustomizer<JdbcIndexedSessionRepository>> sessionRepositoryCustomizers) {
      this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList());
   }

   @Override
   public void setBeanClassLoader(ClassLoader classLoader) {
      this.classLoader = classLoader;
   }

   @Override
   public void setEmbeddedValueResolver(StringValueResolver resolver) {
      this.embeddedValueResolver = resolver;
   }

   // ** Note: 
   @Override
   public void setImportMetadata(AnnotationMetadata importMetadata) {
      Map<String, Object> attributeMap = importMetadata
            .getAnnotationAttributes(EnableHanBo2JdbcHttpSession.class.getName());
      AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap);
      
      
      if (servletSessionTimeout.isPresent() && StringUtils.hasText(servletSessionTimeout.get()))
      {
         try
         {
            System.out.println("Get maxInactiveIntervalInSeconds from app.properties.");
            this.maxInactiveIntervalInSeconds = Integer.parseInt(servletSessionTimeout.get());
         }
         catch (NumberFormatException ex)
         {
            if (attributes.containsKey("maxInactiveIntervalInSeconds"))
            {
               System.out.println("Get maxInactiveIntervalInSeconds from annotation.");
               this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds");
            }
            else
            {
               System.out.println("Get default maxInactiveIntervalInSeconds.");
               this.maxInactiveIntervalInSeconds = 1800;
            }
         }
      }
      else
      {
         if (attributes.containsKey("maxInactiveIntervalInSeconds"))
         {
            System.out.println("Get maxInactiveIntervalInSeconds from annotation.");
            this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds");
         }
         else
         {
            System.out.println("Get default maxInactiveIntervalInSeconds.");            
            this.maxInactiveIntervalInSeconds = 1800;
         }
      }
      
      if (sessionJdbcTableName.isPresent() && StringUtils.hasText(sessionJdbcTableName.get())) 
      {
         System.out.println("Session Table name is taken from the application.properties.");
         this.tableName = sessionJdbcTableName.get();
      }
      else
      {
         if (attributes != null)
         {
            String tableNameValue = attributes.getString("tableName");
            if (StringUtils.hasText(tableNameValue))
            {
               System.out.println("Session Table name is taken from annotation.");
               this.tableName = this.embeddedValueResolver
                     .resolveStringValue(tableNameValue);
            }
            else
            {
               System.out.println("Use Default Table Name");
               this.tableName = "SPRING_SESSION";
            }
         }
      }
      
      if (servletSessionCleanupcron.isPresent() && StringUtils.hasText(servletSessionCleanupcron.get())) 
      {
         System.out.println("Session Cleanup Cron is taken from the application.properties.");
         this.cleanupCron = servletSessionCleanupcron.get();
      }
      else
      {
         String cleanupCron = attributes.getString("cleanupCron");
         if (StringUtils.hasText(cleanupCron))
         {
            this.cleanupCron = cleanupCron;
         }
      }
      
      if (servletSessionFlushMode.isPresent() && StringUtils.hasText(servletSessionFlushMode.get())) 
      {
         System.out.println("Session Flush Mode is taken from the application.properties.");
         this.flushMode = FlushMode.valueOf(servletSessionFlushMode.get());
      }
      else(maxInactiveIntervalInSeconds=900)
      {
         this.flushMode = attributes.getEnum("flushMode");
      }
      
      if (servletSessionSaveMode.isPresent() && StringUtils.hasText(servletSessionSaveMode.get())) 
      {
         System.out.println("Session Save Mode is taken from the application.properties.");
         this.saveMode = SaveMode.valueOf(servletSessionSaveMode.get());
      }
      else
      {
         this.saveMode = attributes.getEnum("saveMode");
      }
      
   }

   private static JdbcTemplate createJdbcTemplate(DataSource dataSource) {
      JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
      jdbcTemplate.afterPropertiesSet();
      return jdbcTemplate;
   }

   private static TransactionTemplate createTransactionTemplate(PlatformTransactionManager transactionManager) {
      TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
      transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
      transactionTemplate.afterPropertiesSet();
      return transactionTemplate;
   }

   private static GenericConversionService createConversionServiceWithBeanClassLoader(ClassLoader classLoader) {
      GenericConversionService conversionService = new GenericConversionService();
      conversionService.addConverter(Object.class, byte[].class, new SerializingConverter());
      conversionService.addConverter(byte[].class, Object.class, new DeserializingConverter(classLoader));
      return conversionService;
   }

   /**
    * Configuration of scheduled job for cleaning up expired sessions.
    */
   @EnableScheduling
   @Configuration(proxyBeanMethods = false)
   class SessionCleanupConfiguration implements SchedulingConfigurer {

      private final JdbcIndexedSessionRepository sessionRepository;

      SessionCleanupConfiguration(JdbcIndexedSessionRepository sessionRepository) {
         this.sessionRepository = sessionRepository;
      }

      @Override
      public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
         taskRegistrar.addCronTask(this.sessionRepository::cleanUpExpiredSessions,
               HanBo2JdbcHttpSessionConfiguration.this.cleanupCron);
      }
   }
}

The idea is very simple. I want to inject the database and connection info from application configuration. It can be done by declaring some private properties and annotate them with @Value. And I default these properties to null if these keys and values are not found in the application configuration file. In Java 8, nullable type where introduced which allow me to do this. if any of these properties where null, then the default values from the @EnableHanBo2JdbcHttpSession definition will be used.

How They are Used

Here is how these two classes can be used. Here is my session data access configuration class:

@EnableHanbo2JdbcHttpSession
@Configuration
public class SessionDataAccessConfiguration
{
...
}

Here is my application configuration for the session duration and other settings. Some of them were commented out to see if they can be set with default value:

...
#JDBC table and timeout config for session.
hanbo.spring.session.jdbc.tableName=SPRING_SESSION
hanbo.spring.servlet.session.timeoutInSeconds=89
#hanbo.spring.session.jdbc.cleanupCron=0 * * * * *
#hanbo.spring.session.jdbc.flushMode=ON_SAVE
#hanbo.spring.session.jdbc.saveMode=ON_SET_ATTRIBUTE
...

As shown the session duration is set to 89 seconds, and use the same sample code I was able to test it, it was working. It feels good that this whole thing worked as expected.

As shown the session duration is set to 89 seconds, and use the same sample code I was able to test it, it was working. It feels good that this whole thing worked as expected.

One last thing I tried was moving the application configuration file out of the resources folder and putting it in a different location. Then I build the application and run it with the following command:

java -jar Admin/target/<my application jar file>.jar --spring.config.location=file:///<my external path to my application configuration file>

And it works too.


Add Comment

Comments