التحكم في الجلسات وحفظها باستخدام الربيع

مرحبا هبر.



عند تطوير تطبيق ويب متعدد المستخدمين ، كان من الضروري تحديد عدد الجلسات النشطة لمستخدم واحد. في هذه المقالة أريد أن أشارككم حلولي.



يعد التحكم في الجلسة مناسبًا لعدد كبير من المشاريع. في تطبيقنا ، كان من الضروري تنفيذ قيود على عدد الجلسات النشطة لمستخدم واحد. عند تسجيل الدخول (تسجيل الدخول) ، يتم إنشاء جلسة نشطة للمستخدم. عندما يقوم نفس المستخدم بتسجيل الدخول من جهاز آخر ، من الضروري عدم فتح جلسة جديدة ، ولكن إبلاغ المستخدم بجلسة نشطة موجودة بالفعل وتقديم خيارين له:



  • أغلق الجلسة الأخيرة وافتح جلسة جديدة
  • لا تغلق الجلسة القديمة ولا تفتح جلسة جديدة


أيضًا ، عند إغلاق الجلسة القديمة ، من الضروري إرسال إشعار إلى المسؤول حول هذا الحدث.



وتحتاج إلى مراعاة احتمالين لإبطال الجلسة:



  • تسجيل الخروج من المستخدم (أي ينقر المستخدم على زر الخروج)
  • تسجيل الخروج التلقائي بعد 30 دقيقة من عدم النشاط


حفظ الجلسات عبر عمليات إعادة التشغيل



تحتاج أولاً إلى معرفة كيفية إنشاء الجلسات وحفظها (سنحفظها في قاعدة البيانات ، ولكن من الممكن حفظها في redis ، على سبيل المثال). الربيع أمن و الربيع جلسة جدبك ستساعدنا مع هذا . في build.gradle ، أضف 2 اعتمادًا على:



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


لنقم بإنشاء WebSecurityConfig الخاص بنا ، حيث سنعمل على تمكين حفظ الجلسات في قاعدة البيانات باستخدام التعليق التوضيحي 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);
    }

}


بمساعدة هذا التكوين ، لم نقم فقط بتمكين حفظ الجلسات النشطة في قاعدة البيانات ، ولكننا كتبنا أيضًا منطق تسجيل خروج المستخدم ، وأضفنا إستراتيجيتنا الخاصة للتعامل مع الجلسات ومعترض للأخطاء.



لحفظ الجلسات في قاعدة البيانات ، تحتاج أيضًا إلى إضافة خاصية في application.yml (يتم استخدام postgresql في مشروعي):



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


يمكنك أيضًا تحديد مدة الجلسة (افتراضيًا 30 دقيقة) باستخدام الخاصية:



server.servlet.session.timeout


إذا لم تحدد لاحقة ، فسيتم استخدام الثواني افتراضيًا.



بعد ذلك ، نحتاج إلى إنشاء جدول يتم فيه حفظ الجلسات. في مشروعنا ، نستخدم Liquibase ، لذلك نسجل إنشاء جدول في مجموعة التغييرات:



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


الحد من عدد الجلسات



نحن نستخدم استراتيجيتنا المخصصة للحد من عدد الجلسات. على سبيل التحديد ، من حيث المبدأ ، سيكون كافياً أن تكتب في التكوين:



.maximumSessions(1)


ومع ذلك ، نحتاج إلى منح المستخدم خيارًا (إغلاق الجلسة السابقة أو عدم فتح جلسة جديدة) وإبلاغ المسؤول بقرار المستخدم (إذا اختار إغلاق الجلسة).



ستكون استراتيجيتنا المخصصة هي الخلف.



ConcurrentSessionControlAuthenticationStrategy ، التي تتيح لك تحديد ما إذا كان المستخدم قد تجاوز حد الجلسة أم لا.




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


يبقى وصف إزالة الجلسات النشطة ، باستثناء الجلسة الحالية. للقيام بذلك ، في تنفيذ SessionsManager ، نطبق طريقة 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));
    }

}


خطأ في معالجة عند تجاوز حد الجلسة



كما ترى ، في حالة عدم وجود متغير القوة (أو عندما تكون خاطئة ) ، فإننا نطرح SessionAuthenticationException من استراتيجيتنا. لا نرغب في إرجاع الخطأ إلى المقدمة ، ولكن الحالة 300 (بحيث تعرف المقدمة أنها بحاجة إلى إظهار رسالة للمستخدم لتحديد إجراء ما). للقيام بذلك ، نقوم بتنفيذ المعترض الذي أضفناه إليه



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


خاتمة



تبين أن إدارة الجلسة ليست مخيفة كما كانت تبدو في البداية. يتيح لك الربيع تخصيص استراتيجياتك لهذا الغرض بمرونة. وبمساعدة أداة اعتراض الأخطاء ، يمكنك إرجاع أي رسالة وحالة إلى المقدمة.



آمل أن تكون هذه المقالة مفيدة لشخص ما.



All Articles