Lors du développement d'une application Web multi-utilisateurs, il était nécessaire de limiter le nombre de sessions actives pour un utilisateur. Dans cet article, je souhaite partager mes solutions avec vous.
Le contrôle de session est pertinent pour un grand nombre de projets. Dans notre application, il était nécessaire d'implémenter une limitation du nombre de sessions actives pour un utilisateur. Lors de la connexion (connexion), une session active est créée pour l'utilisateur. Lorsque le même utilisateur se connecte depuis un autre appareil, il est nécessaire de ne pas ouvrir une nouvelle session, mais d'informer l'utilisateur d'une session active déjà existante et de lui proposer 2 options:
- fermer la dernière session et en ouvrir une nouvelle
- ne fermez pas l'ancienne session et n'ouvrez pas de nouvelle session
De plus, lors de la fermeture d'une ancienne session, vous devez envoyer une notification à l'administrateur concernant cet événement.
Et vous devez prendre en compte 2 possibilités d'invalidation de session:
- se déconnecter de l'utilisateur (c'est-à-dire que l'utilisateur clique sur le bouton de déconnexion)
- déconnexion automatique après 30 minutes d'inactivité
Sauvegarde des sessions lors des redémarrages
Vous devez d'abord apprendre à créer et enregistrer des sessions (nous les enregistrerons dans la base de données, mais il est possible de les enregistrer dans redis, par exemple). Spring security et spring session jdbc nous y aideront . Dans build.gradle, ajoutez 2 en fonction de:
implementation(
'org.springframework.boot:spring-boot-starter-security',
'org.springframework.session:spring-session-jdbc'
)
Créons notre propre WebSecurityConfig , dans lequel nous activerons l' enregistrement des sessions dans la base de données à l'aide de l'annotation @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);
}
}
Avec l'aide de cette configuration, nous avons non seulement activé la sauvegarde des sessions actives dans la base de données, mais également écrit la logique de déconnexion de l'utilisateur, ajouté notre propre stratégie de gestion des sessions et un intercepteur pour les erreurs.
Pour enregistrer les sessions dans la base de données, vous devez également ajouter une propriété dans application.yml (postgresql est utilisé dans mon projet):
spring:
datasource:
url: jdbc:postgresql://localhost:5432/test-db
username: test
password: test
driver-class-name: org.postgresql.Driver
session:
store-type: jdbc
Vous pouvez également spécifier la durée de vie de la session (par défaut 30 minutes) à l'aide de la propriété:
server.servlet.session.timeout
Si vous ne spécifiez pas de suffixe, les secondes seront utilisées par défaut.
Ensuite, nous devons créer une table dans laquelle les sessions seront enregistrées. Dans notre projet, nous utilisons liquibase , nous enregistrons donc la création d'une table dans le 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>
Limiter le nombre de sessions
Nous utilisons notre stratégie personnalisée pour limiter le nombre de sessions. Pour limitation, en principe, il suffirait d'écrire dans la config:
.maximumSessions(1)
Cependant, nous devons donner à l'utilisateur un choix (fermer la session précédente ou ne pas en ouvrir une nouvelle) et informer l'administrateur de la décision de l'utilisateur (s'il a choisi de fermer la session).
Notre stratégie personnalisée sera le successeur.
ConcurrentSessionControlAuthenticationStrategy , qui vous permet de déterminer si l'utilisateur a dépassé la limite de session ou non.
@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);
}
}
}
Il reste à décrire la suppression des sessions actives, à l'exception de celle en cours. Pour ce faire, dans l' implémentation SessionsManager , nous implémentons la méthode 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));
}
}
Gestion des erreurs lorsque la limite de session est dépassée
Comme vous pouvez le voir, en l'absence du paramètre force (ou lorsqu'il est faux ), nous lançons une SessionAuthenticationException de notre stratégie. Nous aimerions renvoyer non pas une erreur au front, mais un statut 300 (pour que le front sache qu'il doit montrer un message à l'utilisateur pour sélectionner une action). Pour ce faire, nous implémentons l'intercepteur, auquel nous avons ajouté
.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());
}
}
Conclusion
La gestion de session s'est avérée moins effrayante qu'elle le paraissait au début. Spring vous permet de personnaliser vos stratégies de manière flexible pour cela. Et avec l'aide d'un intercepteur d'erreur, vous pouvez renvoyer n'importe quel message et statut au premier plan.
J'espère que cet article sera utile à quelqu'un.