Controlar y guardar sesiones usando Spring

Hola, Habr.



Al desarrollar una aplicación web multiusuario, era necesario limitar el número de sesiones activas para un usuario. En este artículo quiero compartir contigo mis soluciones.



El control de sesiones es relevante para una gran cantidad de proyectos. En nuestra aplicación, fue necesario implementar una limitación en el número de sesiones activas para un usuario. Al iniciar sesión (iniciar sesión) se crea una sesión activa para el usuario. Cuando el mismo usuario inicia sesión desde otro dispositivo, es necesario no abrir una nueva sesión, sino informar al usuario sobre una sesión activa ya existente y ofrecerle 2 opciones:



  • cierra la última sesión y abre una nueva
  • no cierre la sesión anterior y no abra una nueva sesión


Además, cuando se cierra la sesión anterior, es necesario enviar una notificación al administrador sobre este evento.



Y debe tener en cuenta 2 posibilidades de invalidación de sesión:



  • cerrar la sesión del usuario (es decir, el usuario hace clic en el botón cerrar sesión)
  • cierre de sesión automático después de 30 minutos de inactividad


Guardar sesiones tras reiniciar



Primero necesitas aprender a crear y guardar sesiones (las guardaremos en la base de datos, pero es posible guardarlas en redis, por ejemplo). Spring Security y Spring Session jdbc nos ayudarán con esto . En build.gradle agregue 2 dependiendo de:



implementation(
            'org.springframework.boot:spring-boot-starter-security',
            'org.springframework.session:spring-session-jdbc'
    )


Creemos nuestra propia WebSecurityConfig , en la que habilitamos guardar sesiones en la base de datos usando la anotación @EnableJdbcHttpSession



@EnableWebSecurity
@EnableJdbcHttpSession
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationFailureHandler securityErrorHandler;
    private final ConcurrentSessionStrategy concurrentSessionStrategy;
    private final SessionRegistry sessionRegistry;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors().and()
                //   csrf 
                .csrf().and()
                .httpBasic().and()
                .authorizeRequests()
                .anyRequest()
                .authenticated().and()
                //
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))
                //   200(   203)
                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))
                //   
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                //      (..  ,   ..)
                .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL)))
                .permitAll().and()
                //  (   )
                .sessionManagement()
                //    (   1, ..      ,   )
                .maximumSessions(3)
                //    (3)    SessionAuthenticationException
                .maxSessionsPreventsLogin(true)
                //     (        )
                .sessionRegistry(sessionRegistry).and()
                //       
                .sessionAuthenticationStrategy(concurrentSessionStrategy)
                //   
                .sessionAuthenticationFailureHandler(securityErrorHandler);
    }

    //    
    @Bean
    public static ServletListenerRegistrationBean httpSessionEventPublisher() {
        return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
    }

    @Bean
    public static SessionRegistry sessionRegistry(JdbcIndexedSessionRepository sessionRepository) {
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }

    @Bean
    public static PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

}


Con la ayuda de esta configuración, no solo habilitamos el guardado de sesiones activas en la base de datos, sino que también escribimos la lógica para el cierre de sesión del usuario, agregamos nuestra propia estrategia para manejar sesiones y un interceptor de errores.



Para guardar sesiones en la base de datos, también necesita agregar una propiedad en application.yml (postgresql se usa en mi proyecto):



spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/test-db
    username: test
    password: test
    driver-class-name: org.postgresql.Driver
  session:
    store-type: jdbc


También puede especificar la duración de la sesión (30 minutos por defecto) usando la propiedad:



server.servlet.session.timeout


Si no especifica un sufijo, los segundos se utilizarán de forma predeterminada.



A continuación, necesitamos crear una tabla en la que se guardarán las sesiones. En nuestro proyecto, usamos liquibase , por lo que registramos la creación de una tabla en el conjunto de cambios:



<changeSet id="0.1" failOnError="true">
    <comment>Create sessions table</comment>

    <createTable tableName="spring_session">
      <column name="primary_id" type="char(36)">
        <constraints primaryKey="true"/>
      </column>
      <column name="session_id" type="char(36)">
        <constraints nullable="false" unique="true"/>
      </column>
      <column name="creation_time" type="bigint">
        <constraints nullable="false"/>
      </column>
      <column name="last_access_time" type="bigint">
        <constraints nullable="false"/>
      </column>
      <column name="max_inactive_interval" type="int">
        <constraints nullable="false"/>
      </column>
      <column name="expiry_time" type="bigint">
        <constraints nullable="false"/>
      </column>
      <column name="principal_name" type="varchar(1024)"/>
    </createTable>

    <createIndex tableName="spring_session" indexName="spring_session_session_id_idx">
      <column name="session_id"/>
    </createIndex>

    <createIndex tableName="spring_session" indexName="spring_session_expiry_time_idx">
      <column name="expiry_time"/>
    </createIndex>

    <createIndex tableName="spring_session" indexName="spring_session_principal_name_idx">
      <column name="principal_name"/>
    </createIndex>

    <createTable tableName="spring_session_attributes">
      <column name="session_primary_id" type="char(36)">
        <constraints nullable="false" foreignKeyName="spring_session_attributes_fk" references="spring_session(primary_id)" deleteCascade="true"/>
      </column>
      <column name="attribute_name" type="varchar(1024)">
        <constraints nullable="false"/>
      </column>
      <column name="attribute_bytes" type="bytea">
        <constraints nullable="false"/>
      </column>
    </createTable>

    <addPrimaryKey tableName="spring_session_attributes" columnNames="session_primary_id,attribute_name" constraintName="spring_session_attributes_pk"/>

    <createIndex tableName="spring_session_attributes" indexName="spring_session_attributes_idx">
      <column name="session_primary_id"/>
    </createIndex>

    <rollback>
      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_idx"/>
      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_pk"/>
      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_fk"/>
      <dropIndex tableName="spring_session" indexName="spring_session_principal_name_idx"/>
      <dropIndex tableName="spring_session" indexName="spring_session_expiry_time_idx"/>
      <dropIndex tableName="spring_session" indexName="spring_session_session_id_idx"/>
      <dropTable tableName="spring_session_attributes"/>
      <dropTable tableName="spring_session"/>
    </rollback>
  </changeSet>


Limitando el número de sesiones



Usamos nuestra estrategia personalizada para limitar el número de sesiones. Por limitación, en principio, bastaría con escribir en la configuración:



.maximumSessions(1)


Sin embargo, debemos darle al usuario una opción (cerrar la sesión anterior o no abrir una nueva) e informar al administrador sobre la decisión del usuario (si opta por cerrar la sesión).



Nuestra estrategia personalizada será la sucesora.



ConcurrentSessionControlAuthenticationStrategy , que le permite determinar si el usuario ha superado el límite de sesiones o no.




@Slf4j
@Component
public class ConcurrentSessionStrategy extends ConcurrentSessionControlAuthenticationStrategy {
    //    (true -    )
    private static final String FORCE_PARAMETER_NAME = "force";
    //   
    private final NotificationService notificationService;
    //    
    private final SessionsManager sessionsManager;

    public ConcurrentSessionStrategy(SessionRegistry sessionRegistry, NotificationService notificationService,
            SessionsManager sessionsManager) {
        super(sessionRegistry);
        //     
        super.setExceptionIfMaximumExceeded(true);
       //   ,       1
        super.setMaximumSessions(1);
        this.notificationService = notificationService;
        this.sessionsManager = sessionsManager;
    }

    @Override
    public void onAuthentication(Authentication authentication, HttpServletRequest request,
            HttpServletResponse response)
            throws SessionAuthenticationException {
        try {
            //   (  SessionAuthenticationException      1)
            super.onAuthentication(authentication, request, response);
        } catch (SessionAuthenticationException e) {
            log.debug("onAuthentication#SessionAuthenticationException");
            //    (    ,     )
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();

            String force = request.getParameter(FORCE_PARAMETER_NAME);

            //     'force' , ,    
            if (StringUtils.isBlank(force)) {
                log.debug("onAuthentication#Multiple choices when login for user: {}", userDetails.getUsername());
                throw e;
            }

           //     'force' = false, ,     (       )
            if (!Boolean.parseBoolean(force)) {
                log.debug("onAuthentication#Invalidate current session for user: {}", userDetails.getUsername());
                throw e;
            }

            log.debug("onAuthentication#Invalidate old session for user: {}", userDetails.getUsername());
            //    ,  
            sessionsManager.deleteSessionExceptCurrentByUser(userDetails.getUsername());
            //  (   ip    - . ,  )
            notificationService.notify(request, userDetails);
        }
    }
}


Queda por describir la eliminación de sesiones activas, a excepción de la actual. Para ello, en la implementación de SessionsManager , implementamos el método deleteSessionExceptCurrentByUser :




@Service
@RequiredArgsConstructor
@Slf4j
public class SessionsManagerImpl implements SessionsManager {

    private final FindByIndexNameSessionRepository sessionRepository;

    @Override
    public void deleteSessionExceptCurrentByUser(String username) {
        log.debug("deleteSessionExceptCurrent#user: {}", username);
        // session id  
        String sessionId = RequestContextHolder.currentRequestAttributes().getSessionId();

        //    
        sessionRepository.findByPrincipalName(username)
                .keySet().stream()
                .filter(key -> !sessionId.equals(key))
                .forEach(key -> sessionRepository.deleteById((String) key));
    }

}


Manejo de errores cuando se excede el límite de sesiones



Como puede ver, en ausencia del parámetro force (o cuando es falso ), arrojamos una SessionAuthenticationException desde nuestra estrategia. Nos gustaría devolver no un error al frente, sino el estado 300 (para que el frente sepa que necesita mostrar un mensaje al usuario para seleccionar una acción). Para hacer esto, implementamos el interceptor que agregamos a



.sessionAuthenticationFailureHandler(securityErrorHandler)


@Component
@Slf4j
public class SecurityErrorHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception)
            throws IOException, ServletException {
        if (!exception.getClass().isAssignableFrom(SessionAuthenticationException.class)) {
            super.onAuthenticationFailure(request, response, exception);
        }
        log.debug("onAuthenticationFailure#set multiple choices for response");
        response.setStatus(HttpStatus.MULTIPLE_CHOICES.value());
    }
}


Conclusión



La gestión de sesiones resultó no ser tan aterradora como parecía al principio. Spring le permite personalizar de manera flexible sus estrategias para esto. Y con la ayuda de un interceptor de errores, puede devolver cualquier mensaje y estado al frente.



Espero que este artículo le sea útil a alguien.



All Articles