Mengontrol dan menyimpan sesi menggunakan Spring

Halo, Habr.



Saat mengembangkan aplikasi web multi-pengguna, perlu untuk membatasi jumlah sesi aktif untuk satu pengguna. Pada artikel ini saya ingin berbagi solusi saya dengan Anda.



Kontrol sesi relevan untuk sejumlah besar proyek. Dalam aplikasi kami, perlu untuk menerapkan batasan jumlah sesi aktif untuk satu pengguna. Saat masuk (login), sesi aktif dibuat untuk pengguna. Saat pengguna yang sama masuk dari perangkat lain, Anda tidak perlu membuka sesi baru, tetapi memberi tahu pengguna tentang sesi aktif yang sudah ada dan menawarkan 2 opsi kepadanya:



  • tutup sesi terakhir dan buka yang baru
  • jangan tutup sesi lama dan jangan buka sesi baru


Juga, ketika sesi lama ditutup, perlu untuk mengirim pemberitahuan ke administrator tentang acara ini.



Dan Anda perlu mempertimbangkan 2 kemungkinan pembatalan sesi:



  • keluar dari pengguna (yaitu pengguna mengeklik tombol keluar)
  • logout otomatis setelah 30 menit tidak ada aktivitas


Menyimpan sesi saat reboot



Pertama, Anda perlu mempelajari cara membuat dan menyimpan sesi (kami akan menyimpannya di database, tetapi dimungkinkan untuk menyimpannya di redis, misalnya). Keamanan musim semi dan sesi musim semi jdbc akan membantu kami dalam hal ini . Dalam build.gradle tambahkan 2 tergantung pada:



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


Mari buat WebSecurityConfig kita sendiri , di mana kita akan mengaktifkan sesi penyimpanan ke database menggunakan anotasi @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);
    }

}


Dengan bantuan konfigurasi ini, kami tidak hanya mengaktifkan penyimpanan sesi aktif dalam database, tetapi juga menulis logika untuk logout pengguna, menambahkan strategi kami sendiri untuk menangani sesi dan interceptor untuk kesalahan.



Untuk menyimpan sesi ke database, Anda juga perlu menambahkan properti di application.yml (postgresql digunakan dalam proyek saya):



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


Anda juga dapat menentukan umur sesi (secara default 30 menit) menggunakan properti:



server.servlet.session.timeout


Jika Anda tidak menentukan sufiks, maka detik akan digunakan secara default.



Selanjutnya, kita perlu membuat tabel di mana sesi akan disimpan. Dalam proyek kami, kami menggunakan liquibase , jadi kami mendaftarkan pembuatan tabel di set perubahan:



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


Membatasi jumlah sesi



Kami menggunakan strategi kustom kami untuk membatasi jumlah sesi. Untuk batasan, pada prinsipnya, cukup menulis di konfigurasi:



.maximumSessions(1)


Namun, kita perlu memberi pengguna pilihan (menutup sesi sebelumnya atau tidak membuka yang baru) dan menginformasikan administrator tentang keputusan pengguna (jika dia memilih untuk menutup sesi).



Strategi kustom kami akan menjadi penerusnya.



ConcurrentSessionControlAuthenticationStrategy , yang memungkinkan Anda menentukan apakah pengguna telah melebihi batas sesi atau tidak.




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


Ini tetap menjelaskan penghapusan sesi aktif, kecuali yang saat ini. Untuk melakukannya, dalam implementasi SessionsManager , kami mengimplementasikan metode 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));
    }

}


Penanganan kesalahan saat batas sesi terlampaui



Seperti yang Anda lihat, jika parameter gaya tidak ada (atau jika salah ), kami melempar SessionAuthenticationException dari strategi kami. Kami ingin mengembalikan bukan kesalahan ke depan, tetapi 300 status (sehingga front tahu bahwa itu perlu menampilkan pesan kepada pengguna untuk memilih tindakan). Untuk melakukan ini, kami menerapkan pencegat, yang kami tambahkan



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


Kesimpulan



Manajemen sesi ternyata tidak seseram kelihatannya di awal. Spring memungkinkan Anda menyesuaikan strategi Anda secara fleksibel untuk ini. Dan dengan bantuan pencegat kesalahan, Anda dapat mengembalikan pesan dan status apa pun ke depan.



Semoga artikel ini bermanfaat bagi seseorang.



All Articles