开发多用户Web应用程序时,有必要限制一个用户的活动会话数。在本文中,我想与您分享我的解决方案。
会话控制与大量项目相关。在我们的应用程序中,有必要限制一个用户的活动会话数。登录(登录)时,将为用户创建一个活动会话。当同一用户从另一台设备登录时,不必打开新会话,而是要通知用户有关已经存在的活动会话并为他提供2个选项:
- 关闭上一个会话并打开一个新会话
- 不要关闭旧会话,也不要打开新会话
同样,当旧会话关闭时,有必要向管理员发送有关此事件的通知。
您需要考虑两种会话无效的可能性:
- 注销用户(即用户单击注销按钮)
- 闲置30分钟后自动注销
重新启动时保存会话
首先,您需要学习如何创建和保存会话(我们会将它们保存在数据库中,但是可以将它们保存在Redis中)。Spring安全性和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>
限制会话数
我们使用自定义策略来限制会话数。出于限制,原则上,在config中编写就足够了:
.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允许您为此灵活地自定义策略。借助错误拦截器,您可以将任何消息和状态返回到最前面。
我希望本文对某人有用。