Steuern und Speichern von Sitzungen mit Spring

Hallo Habr.



Bei der Entwicklung einer Webanwendung für mehrere Benutzer musste die Anzahl der aktiven Sitzungen für einen Benutzer begrenzt werden. In diesem Artikel möchte ich meine Lösungen mit Ihnen teilen.



Die Sitzungssteuerung ist für eine große Anzahl von Projekten relevant. In unserer Anwendung musste die Anzahl der aktiven Sitzungen für einen Benutzer begrenzt werden. Beim Anmelden (Anmelden) wird eine aktive Sitzung für den Benutzer erstellt. Wenn sich derselbe Benutzer von einem anderen Gerät aus anmeldet, muss keine neue Sitzung geöffnet, sondern der Benutzer über eine bereits vorhandene aktive Sitzung informiert und ihm zwei Optionen angeboten werden:



  • Schließen Sie die letzte Sitzung und öffnen Sie eine neue
  • Schließen Sie die alte Sitzung nicht und öffnen Sie keine neue Sitzung


Wenn die alte Sitzung geschlossen ist, muss dem Administrator eine Benachrichtigung über dieses Ereignis gesendet werden.



Und Sie müssen zwei Möglichkeiten der Sitzungsinvalidierung berücksichtigen:



  • Abmelden vom Benutzer (d. h. der Benutzer klickt auf die Schaltfläche zum Abmelden)
  • automatische Abmeldung nach 30 Minuten Inaktivität


Speichern von Sitzungen über Neustarts hinweg



Zuerst müssen Sie lernen, wie Sie Sitzungen erstellen und speichern (wir speichern sie in der Datenbank, aber es ist beispielsweise möglich, sie in Redis zu speichern). Spring Security und Spring Session JDBC werden uns dabei helfen . Fügen Sie in build.gradle 2 hinzu, abhängig von:



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


Erstellen wir unsere eigene WebSecurityConfig , in der wir das Speichern von Sitzungen in der Datenbank mithilfe der Annotation @EnableJdbcHttpSession aktivieren



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

}


Mit Hilfe dieser Konfiguration haben wir nicht nur das Speichern aktiver Sitzungen in der Datenbank aktiviert, sondern auch die Logik für die Benutzerabmeldung geschrieben, unsere eigene Strategie für die Behandlung von Sitzungen und einen Interceptor für Fehler hinzugefügt.



Um Sitzungen in der Datenbank zu speichern, müssen Sie auch eine Eigenschaft in application.yml hinzufügen (postgresql wird in meinem Projekt verwendet):



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


Sie können die Sitzungslebensdauer (standardmäßig 30 Minuten) auch über die Eigenschaft angeben:



server.servlet.session.timeout


Wenn Sie kein Suffix angeben, werden standardmäßig Sekunden verwendet.



Als nächstes müssen wir eine Tabelle erstellen, in der Sitzungen gespeichert werden. In unserem Projekt verwenden wir Liquibase , daher registrieren wir die Erstellung einer Tabelle im Änderungssatz:



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


Begrenzung der Anzahl der Sitzungen



Wir verwenden unsere benutzerdefinierte Strategie, um die Anzahl der Sitzungen zu begrenzen. Zur Einschränkung würde es im Prinzip ausreichen, in die Konfiguration zu schreiben:



.maximumSessions(1)


Wir müssen dem Benutzer jedoch eine Auswahl geben (die vorherige Sitzung schließen oder keine neue öffnen) und den Administrator über die Entscheidung des Benutzers informieren (falls er die Sitzung schließen möchte).



Unsere maßgeschneiderte Strategie wird der Nachfolger sein.



ConcurrentSessionControlAuthenticationStrategy , mit der Sie feststellen können, ob der Benutzer das Sitzungslimit überschritten hat oder nicht.




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


Es bleibt zu beschreiben, wie aktive Sitzungen mit Ausnahme der aktuellen entfernt werden. Zu diesem Zweck implementieren wir in der SessionsManager- Implementierung die Methode 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));
    }

}


Fehlerbehandlung bei Überschreitung des Sitzungslimits



Wie Sie sehen können, lösen wir in Abwesenheit des Force- Parameters (oder wenn dieser falsch ist ) eine SessionAuthenticationException aus unserer Strategie aus. Wir möchten keinen Fehler an die Front zurückgeben, sondern den Status 300 (damit die Front dem Benutzer eine Nachricht zur Auswahl einer Aktion anzeigen kann). Dazu implementieren wir den Interceptor, den wir hinzugefügt haben



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


Fazit



Das Sitzungsmanagement erwies sich als nicht so beängstigend, wie es am Anfang schien. Mit Spring können Sie Ihre Strategien flexibel anpassen. Und mit Hilfe eines Fehler-Interceptors können Sie jede Nachricht und jeden Status an die Front zurückgeben.



Ich hoffe, dass dieser Artikel für jemanden nützlich sein wird.



All Articles