عند تطوير تطبيق ويب متعدد المستخدمين ، كان من الضروري تحديد عدد الجلسات النشطة لمستخدم واحد. في هذه المقالة أريد أن أشارككم حلولي.
يعد التحكم في الجلسة مناسبًا لعدد كبير من المشاريع. في تطبيقنا ، كان من الضروري تنفيذ قيود على عدد الجلسات النشطة لمستخدم واحد. عند تسجيل الدخول (تسجيل الدخول) ، يتم إنشاء جلسة نشطة للمستخدم. عندما يقوم نفس المستخدم بتسجيل الدخول من جهاز آخر ، من الضروري عدم فتح جلسة جديدة ، ولكن إبلاغ المستخدم بجلسة نشطة موجودة بالفعل وتقديم خيارين له:
- أغلق الجلسة الأخيرة وافتح جلسة جديدة
- لا تغلق الجلسة القديمة ولا تفتح جلسة جديدة
أيضًا ، عند إغلاق الجلسة القديمة ، من الضروري إرسال إشعار إلى المسؤول حول هذا الحدث.
وتحتاج إلى مراعاة احتمالين لإبطال الجلسة:
- تسجيل الخروج من المستخدم (أي ينقر المستخدم على زر الخروج)
- تسجيل الخروج التلقائي بعد 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());
}
}
خاتمة
تبين أن إدارة الجلسة ليست مخيفة كما كانت تبدو في البداية. يتيح لك الربيع تخصيص استراتيجياتك لهذا الغرض بمرونة. وبمساعدة أداة اعتراض الأخطاء ، يمكنك إرجاع أي رسالة وحالة إلى المقدمة.
آمل أن تكون هذه المقالة مفيدة لشخص ما.