При разработке многопользовательского web-приложение, понадобилось ограничить количество активных сессий для одного пользователя. В этой статье хочу поделиться с Вами свои решением.
Контроль сессий актуален для большого количества проектов. В нашем приложение необходимо было реализовать ограничение количества активных сессий для одного пользователя. При входе в систему (login) для пользователя создается активная сессия. При входе этого же пользователя с другого устройства необходимо не открывать новую сессию, а проинформировать пользователя об уже существующей активной сессии и предложить ему 2 варианта:
- закрыть прошлую сессию и открыть новую
- не закрывать старую сессию и не открывать новую сессию
Так же при закрытие старой сессии необходимо отправить уведомление администратору об этом событие.
И нужно учесть 2 возможности инвалидации сессии:
- разлогин пользователя (т.е. нажатие пользователем кнопки logout)
- автоматический разлогин после 30 минут бездействия
Сохранение сессий при перезагрузке
Для начала нужно научиться создавать и сохранять сессии(сохранять будем в бд, но возможно сохранять и в redis, например). В этом нам поможет Spring security и spring session jdbc. В 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));
}
}
Обработка ошибок при превышении ограничения сессий
Как можно заметить, при отсутствии параметра 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 позволяет гибко настраивать свои стратегии для этого. А с помощью перехватчика ошибок можно вернуть фронту любое сообщение и статус.
Надеюсь, что эта статья будем кому-нибудь полезна.
vitos73
Ограничиваем количество сессия --> Ограничиваем количество сессий
Arb9i Автор
спасибо, исправила