Ao desenvolver uma aplicação web multiusuário, era necessário limitar o número de sessões ativas para um usuário. Neste artigo, quero compartilhar minhas soluções com você.
O controle de sessão é relevante para um grande número de projetos. Em nosso aplicativo, foi necessário implementar uma limitação no número de sessões ativas para um usuário. Ao efetuar login (login), uma sessão ativa é criada para o usuário. Quando o mesmo usuário efetua login de outro dispositivo, é necessário não abrir uma nova sessão, mas informar o usuário sobre uma sessão ativa já existente e oferecer-lhe 2 opções:
- feche a última sessão e abra uma nova
- não feche a sessão antiga e não abra uma nova sessão
Além disso, quando a sessão antiga for encerrada, é necessário enviar uma notificação ao administrador sobre este evento.
E você precisa levar em consideração 2 possibilidades de invalidação de sessão:
- logout do usuário (ou seja, o usuário clica no botão de logout)
- logout automático após 30 minutos de inatividade
Salvando sessões entre reinicializações
Primeiro você precisa aprender a criar e salvar sessões (vamos salvá-las no banco de dados, mas é possível salvá-las no redis, por exemplo). Spring security e spring session jdbc nos ajudarão com isso . Em build.gradle, adicione 2 dependendo de:
implementation(
'org.springframework.boot:spring-boot-starter-security',
'org.springframework.session:spring-session-jdbc'
)
Vamos criar nosso próprio WebSecurityConfig , no qual permitiremos salvar sessões no banco de dados usando a anotação @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);
}
}
Com a ajuda desta configuração, não só habilitamos o salvamento de sessões ativas no banco de dados, mas também escrevemos a lógica de logout do usuário, adicionamos nossa própria estratégia de tratamento de sessões e um interceptor de erros.
Para salvar sessões no banco de dados, você também precisa adicionar a propriedade em application.yml (postgresql é usado em meu projeto):
spring:
datasource:
url: jdbc:postgresql://localhost:5432/test-db
username: test
password: test
driver-class-name: org.postgresql.Driver
session:
store-type: jdbc
Você também pode especificar o tempo de vida da sessão (por padrão, 30 minutos) usando a propriedade:
server.servlet.session.timeout
Se você não especificar um sufixo, os segundos serão usados por padrão.
A seguir, precisamos criar uma tabela na qual as sessões serão salvas. Em nosso projeto, usamos liquibase , então registramos a criação de uma tabela no changeset:
<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>
Limitando o número de sessões
Usamos nossa estratégia personalizada para limitar o número de sessões. Para limitação, em princípio, seria suficiente escrever na configuração:
.maximumSessions(1)
No entanto, precisamos dar ao usuário uma escolha (fechar a sessão anterior ou não abrir uma nova) e informar o administrador sobre a decisão do usuário (se ele escolheu encerrar a sessão).
Nossa estratégia personalizada será a sucessora.
ConcurrentSessionControlAuthenticationStrategy , que permite determinar se o usuário excedeu o limite da sessão ou não.
@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);
}
}
}
Resta descrever a remoção das sessões ativas, exceto a atual. Para fazer isso, na implementação de SessionsManager , implementamos o método 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));
}
}
Tratamento de erros quando o limite da sessão é excedido
Como você pode ver, na ausência do parâmetro force (ou quando ele é falso ), lançamos uma SessionAuthenticationException de nossa estratégia. Gostaríamos de retornar não um erro à frente, mas o status 300 (para que a frente saiba que precisa mostrar uma mensagem ao usuário para selecionar uma ação). Para fazer isso, implementamos o interceptor, que adicionamos ao
.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());
}
}
Conclusão
O gerenciamento de sessões acabou não sendo tão assustador quanto parecia no início. O Spring permite que você personalize de forma flexível suas estratégias para isso. E com a ajuda de um interceptor de erro, você pode retornar qualquer mensagem e status para a frente.
Espero que este artigo seja útil para alguém.