Controlando e salvando sessões usando Spring

Olá, Habr.



Ao desenvolver uma aplicação web multiusuário, era necessário limitar o número de sessões ativas para um usuário. Neste artigo, quero compartilhar minhas soluções com você.



O controle de sessão é relevante para um grande número de projetos. Em nosso aplicativo, foi necessário implementar uma limitação no número de sessões ativas para um usuário. Ao efetuar login (login), uma sessão ativa é criada para o usuário. Quando o mesmo usuário efetua login de outro dispositivo, é necessário não abrir uma nova sessão, mas informar o usuário sobre uma sessão ativa já existente e oferecer-lhe 2 opções:



  • feche a última sessão e abra uma nova
  • não feche a sessão antiga e não abra uma nova sessão


Além disso, quando a sessão antiga for encerrada, é necessário enviar uma notificação ao administrador sobre este evento.



E você precisa levar em consideração 2 possibilidades de invalidação de sessão:



  • logout do usuário (ou seja, o usuário clica no botão de logout)
  • logout automático após 30 minutos de inatividade


Salvando sessões entre reinicializações



Primeiro você precisa aprender a criar e salvar sessões (vamos salvá-las no banco de dados, mas é possível salvá-las no redis, por exemplo). Spring security e spring session jdbc nos ajudarão com isso . Em build.gradle, adicione 2 dependendo de:



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


Vamos criar nosso próprio WebSecurityConfig , no qual permitiremos salvar sessões no banco de dados usando a anotação @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);
    }

}


Com a ajuda desta configuração, não só habilitamos o salvamento de sessões ativas no banco de dados, mas também escrevemos a lógica de logout do usuário, adicionamos nossa própria estratégia de tratamento de sessões e um interceptor de erros.



Para salvar sessões no banco de dados, você também precisa adicionar a propriedade em application.yml (postgresql é usado em meu projeto):



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


Você também pode especificar o tempo de vida da sessão (por padrão, 30 minutos) usando a propriedade:



server.servlet.session.timeout


Se você não especificar um sufixo, os segundos serão usados ​​por padrão.



A seguir, precisamos criar uma tabela na qual as sessões serão salvas. Em nosso projeto, usamos liquibase , então registramos a criação de uma tabela no changeset:



<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 o número de sessões



Usamos nossa estratégia personalizada para limitar o número de sessões. Para limitação, em princípio, seria suficiente escrever na configuração:



.maximumSessions(1)


No entanto, precisamos dar ao usuário uma escolha (fechar a sessão anterior ou não abrir uma nova) e informar o administrador sobre a decisão do usuário (se ele escolheu encerrar a sessão).



Nossa estratégia personalizada será a sucessora.



ConcurrentSessionControlAuthenticationStrategy , que permite determinar se o usuário excedeu o limite da sessão ou não.




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


Resta descrever a remoção das sessões ativas, exceto a atual. Para fazer isso, na implementação de SessionsManager , implementamos o 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));
    }

}


Tratamento de erros quando o limite da sessão é excedido



Como você pode ver, na ausência do parâmetro force (ou quando ele é falso ), lançamos uma SessionAuthenticationException de nossa estratégia. Gostaríamos de retornar não um erro à frente, mas o status 300 (para que a frente saiba que precisa mostrar uma mensagem ao usuário para selecionar uma ação). Para fazer isso, implementamos o interceptor, que adicionamos ao



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


Conclusão



O gerenciamento de sessões acabou não sendo tão assustador quanto parecia no início. O Spring permite que você personalize de forma flexível suas estratégias para isso. E com a ajuda de um interceptor de erro, você pode retornar qualquer mensagem e status para a frente.



Espero que este artigo seja útil para alguém.



All Articles