【Spring Security】如何实现多设备同一时间只允许一个账号登录(即前登录用户被后登录用户挤下线)?只需简单两步!

1.需求分析

在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,需求就是业务原因要求一个用户只能在一个设备上登录。

要实现一个用户不可以同时在两台设备上登录,我们有两种思路:

  • 后来的登录自动踢掉前面的登录。
  • 如果用户已经登录,则不允许后来者登录。

这种思路都能实现这个功能,具体使用哪一个,还要看我们具体的需求。

在 Spring Security 中,这两种都很好实现,一个配置就可以搞定。

2.具体实现

2.1 踢掉已经登录用户

想要用新的登录踢掉旧的登录,我们只需要将最大会话数设置为 1 即可,配置如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .permitAll()
            .and()
            .csrf().disable()
            .sessionManagement()
            .maximumSessions(1);
}

maximumSessions 表示配置最大会话数为 1,这样后面的登录就会自动踢掉前面的登录。这里其他的配置都是我们前面文章讲过的,我就不再重复介绍,配置完成后,用 Chrome 浏览器进行测试(可以利用 Chrome 中隐私模式登录模拟多用户功能)。

  1. Chrome 上登录成功后,访问 /hello 接口。
  2. 利用 Chrome隐私模式模式上登录成功后,访问 /hello 接口。
  3. 在 Chrome 上再次访问 /hello 接口,此时会看到如下提示:
This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).

可以看到,这里说这个 session 已经过期,原因则是由于使用同一个用户进行并发登录。
注意:如果你自定义用户user直接实现UserDetails接口的实体类,需要将复写toString、hashCode、equals 函数,不然看不到效果,原因也很简单,不复写两个用户实例对象比较会有问题!代码如下:

    @Override
    public String toString() {
        return this.username;
    }
    @Override
    public int hashCode() {
        return username.hashCode();
    }
    @Override
    public boolean equals(Object obj) {
        return this.toString().equals(obj.toString());
    }

2.2 禁止新的登录

如果相同的用户已经登录了,你不想踢掉他,而是想禁止新的登录操作,那也好办,配置方式如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login.html")
            .permitAll()
            .and()
            .csrf().disable()
            .sessionManagement()
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true);
}

添加 maxSessionsPreventsLogin 配置即可。此时一个浏览器登录成功后,另外一个浏览器就登录不了了。是不是很简单?

不过还没完,我们还需要再提供一个 Bean:

@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

为什么要加这个 Bean 呢?因为在 Spring Security 中,它是通过监听 session 的销毁事件,来及时的清理 session 的记录。用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,但是默认的失效是通过调用 StandardSession#invalidate 方法来实现的,这一个失效事件无法被 Spring 容器感知到,进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来(小伙伴们可以自行尝试不添加上面的 Bean,然后让用户注销登录之后再重新登录)。

为了解决这一问题,我们提供一个 HttpSessionEventPublisher ,这个类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到,该类部分源码如下:

public void sessionCreated(HttpSessionEvent event) {
 HttpSessionCreatedEvent e = new HttpSessionCreatedEvent(event.getSession());
 getContext(event.getSession().getServletContext()).publishEvent(e);
}
public void sessionDestroyed(HttpSessionEvent event) {
 HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
 getContext(event.getSession().getServletContext()).publishEvent(e);
}

OK,虽然多了一个配置,但是依然很简单!

3.实现原理

上面这个功能,在 Spring Security 中是怎么实现的呢?我们来稍微分析一下源码。

首先我们知道,在用户登录的过程中,会经过 UsernamePasswordAuthenticationFilter(参考:手把手带你捋一遍 Spring Security 登录流程),而 UsernamePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFilter 中触发的,我们来看下 AbstractAuthenticationProcessingFilter#doFilter 方法的调用:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
  throws IOException, ServletException {
 HttpServletRequest request = (HttpServletRequest) req;
 HttpServletResponse response = (HttpServletResponse) res;
 if (!requiresAuthentication(request, response)) {
  chain.doFilter(request, response);
  return;
 }
 Authentication authResult;
 try {
  authResult = attemptAuthentication(request, response);
  if (authResult == null) {
   return;
  }
  sessionStrategy.onAuthentication(authResult, request, response);
 }
 catch (InternalAuthenticationServiceException failed) {
  unsuccessfulAuthentication(request, response, failed);
  return;
 }
 catch (AuthenticationException failed) {
  unsuccessfulAuthentication(request, response, failed);
  return;
 }
 // Authentication success
 if (continueChainBeforeSuccessfulAuthentication) {
  chain.doFilter(request, response);
 }
 successfulAuthentication(request, response, chain, authResult);

在这段代码中,我们可以看到,调用 attemptAuthentication 方法走完认证流程之后,回来之后,接下来就是调用 sessionStrategy.onAuthentication 方法,这个方法就是用来处理 session 的并发问题的。具体在:

public class ConcurrentSessionControlAuthenticationStrategy implements
  MessageSourceAware, SessionAuthenticationStrategy {
 public void onAuthentication(Authentication authentication,
   HttpServletRequest request, HttpServletResponse response) {

  final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
    authentication.getPrincipal(), false);

  int sessionCount = sessions.size();
  int allowedSessions = getMaximumSessionsForThisUser(authentication);

  if (sessionCount < allowedSessions) {
   // They haven't got too many login sessions running at present
   return;
  }

  if (allowedSessions == -1) {
   // We permit unlimited logins
   return;
  }

  if (sessionCount == allowedSessions) {
   HttpSession session = request.getSession(false);

   if (session != null) {
    // Only permit it though if this request is associated with one of the
    // already registered sessions
    for (SessionInformation si : sessions) {
     if (si.getSessionId().equals(session.getId())) {
      return;
     }
    }
   }
   // If the session is null, a new one will be created by the parent class,
   // exceeding the allowed number
  }

  allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
 }
 protected void allowableSessionsExceeded(List<SessionInformation> sessions,
   int allowableSessions, SessionRegistry registry)
   throws SessionAuthenticationException {
  if (exceptionIfMaximumExceeded || (sessions == null)) {
   throw new SessionAuthenticationException(messages.getMessage(
     "ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
     new Object[] {allowableSessions},
     "Maximum sessions of {0} for this principal exceeded"));
  }

  // Determine least recently used sessions, and mark them for invalidation
  sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
  int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
  List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
  for (SessionInformation session: sessionsToBeExpired) {
   session.expireNow();
  }
 }
}

这段核心代码我来给大家稍微解释下:

  1. 首先调用 sessionRegistry.getAllSessions 方法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户的 authentication,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionid 存起来,其中 key 是用户的主体(principal),value 则是该主题对应的 sessionid 组成的一个集合)。
  2. 接下来计算出当前用户已经有几个有效 session 了,同时获取允许的 session 并发数。
  3. 如果当前 session 数(sessionCount)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,表示对 session 数量不做任何限制。
  4. 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先看看当前 session 是否不为 null,并且已经存在于 sessions 中了,如果已经存在了,那都是自家人,不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
  5. 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
  6. allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximumExceeded 属性,这就是我们在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false,如果为 true,就直接抛出异常,那么这次登录就失败了(对应 2.2 小节的效果),如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可(对应 2.1 小节的效果)。

4.小结

如此,两行简单的配置就实现了 Spring Security 中 session 的并发管理。是不是很简单?该项目源码及往期教程我已经放在码云上了,有需要的朋友可以下载看看https://gitee.com/wavefar/spring-security-samples,不过这里还有一个小小的坑,我们将在下篇文章中继续和大家分析。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

52it.club

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值