Springを使用したセッションの制御と保存

こんにちは、Habr。



マルチユーザーWebアプリケーションを開発する場合、1人のユーザーのアクティブなセッションの数を制限する必要がありました。この記事では、私のソリューションをあなたと共有したいと思います。



セッション制御は、多数のプロジェクトに関連しています。このアプリケーションでは、1人のユーザーのアクティブなセッションの数に制限を実装する必要がありました。ログイン(ログイン)すると、ユーザーに対してアクティブなセッションが作成されます。同じユーザーが別のデバイスからログインする場合、新しいセッションを開くのではなく、既存のアクティブなセッションについてユーザーに通知し、次の2つのオプションを提供する必要があります。



  • 最後のセッションを閉じて、新しいセッションを開きます
  • 古いセッションを閉じたり、新しいセッションを開いたりしないでください


また、古いセッションが閉じられた場合、このイベントについて管理者に通知を送信する必要があります。



また、セッション無効化の2つの可能性を考慮する必要があります。



  • ユーザーからログアウトする(つまり、ユーザーがログアウトボタンをクリックする)
  • 30分間操作がないと自動ログアウト


再起動後のセッションの保存



まず、セッションを作成して保存する方法を学ぶ必要があります(セッションはデータベースに保存しますが、たとえば、redisに保存することもできます)。春のセキュリティ春のセッションjdbcはこれで私たちを助けますbuild.gradleに応じて2を追加します。



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


@EnableJdbcHttpSessionアノテーションを使用してデータベースへのセッションの保存有効にする独自のWebSecurityConfigを作成しましょう



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

}


セッション制限を超えた場合のエラー処理



ご覧のとおり、forceパラメーターがない場合(またはfalseに等しい場合)、戦略から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());
    }
}


結論



セッション管理は、当初のように怖くはなかったことが判明しました。Springを使用すると、このための戦略を柔軟にカスタマイズできます。また、エラーインターセプターを使用すると、メッセージとステータスを前面に戻すことができます。



この記事が誰かに役立つことを願っています。



All Articles