Spring Boot + Vue 先后端分离项目,如何踢掉已登陆用户?

上篇文章中,咱们讲了在 Spring Security 中如何踢掉前一个登陆用户,或者禁止用户二次登陆,经过一个简单的案例,实现了咱们想要的效果。css

可是有一个不太完美的地方,就是咱们的用户是配置在内存中的用户,咱们没有将用户放到数据库中去。正常状况下,松哥在 Spring Security 系列中讲的其余配置,你们只须要参考Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文,将数据切换为数据库中的数据便可。html

可是,在作 Spring Security 的 session 并发处理时,直接将内存中的用户切换为数据库中的用户会有问题,今天咱们就来讲说这个问题,顺便把这个功能应用到微人事中(https://github.com/lenve/vhr)。git

本文是松哥最近在连载的 Spring Security 系列第 14 篇,阅读本系列前面的文章有助于更好的理解本文:github

  1. 挖一个大坑,Spring Security 开搞!
  2. 松哥手把手带你入门 Spring Security,别再问密码怎么解密了
  3. 手把手教你定制 Spring Security 中的表单登陆
  4. Spring Security 作先后端分离,咱就别作页面跳转了!通通 JSON 交互
  5. Spring Security 中的受权操做原来这么简单
  6. Spring Security 如何将用户数据存入数据库?
  7. Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!
  8. Spring Boot + Spring Security 实现自动登陆功能
  9. Spring Boot 自动登陆,安全风险要怎么控制?
  10. 在微服务项目中,Spring Security 比 Shiro 强在哪?
  11. SpringSecurity 自定义认证逻辑的两种方式(高级玩法)
  12. Spring Security 中如何快速查看登陆用户 IP 地址等信息?
  13. Spring Security 自动踢掉前一个登陆用户,一个配置搞定!

本文的案例将基于Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文来构建,因此重复的代码我就不写了,小伙伴们要是不熟悉能够参考该篇文章。web

1.环境准备

首先,咱们打开Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文中的案例,这个案例结合 Spring Data Jpa 将用户数据存储到数据库中去了。spring

而后咱们将上篇文章中涉及到的登陆页面拷贝到项目中(文末能够下载完整案例):数据库

图片

并在 SecurityConfig 中对登陆页面稍做配置:json

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/js/**""/css/**""/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            ...
            .and()
            .formLogin()
            .loginPage("/login.html")
            .loginProcessingUrl("/doLogin")
            ...
            .and()
            .sessionManagement()
            .maximumSessions(1);
}

这里都是常规配置,我就再也不多说。注意最后面咱们将 session 数量设置为 1。后端

好了,配置完成后,咱们启动项目,并行性多端登陆测试。浏览器

打开多个浏览器,分别进行多端登陆测试,咱们惊讶的发现,每一个浏览器都能登陆成功,每次登陆成功也不会踢掉已经登陆的用户!

这是怎么回事?

2.问题分析

要搞清楚这个问题,咱们就要先搞明白 Spring Security 是怎么保存用户对象和 session 的。

Spring Security 中经过 SessionRegistryImpl 类来实现对会话信息的统一管理,咱们来看下这个类的源码(部分):

public class SessionRegistryImpl implements SessionRegistry,
  ApplicationListener<SessionDestroyedEvent
{
 /** <principal:Object,SessionIdSet> */
 private final ConcurrentMap<Object, Set<String>> principals;
 /** <sessionId:Object,SessionInformation> */
 private final Map<String, SessionInformation> sessionIds;
 public void registerNewSession(String sessionId, Object principal) {
  if (getSessionInformation(sessionId) != null) {
   removeSessionInformation(sessionId);
  }
  sessionIds.put(sessionId,
    new SessionInformation(principal, sessionId, new Date()));

  principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
   if (sessionsUsedByPrincipal == null) {
    sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
   }
   sessionsUsedByPrincipal.add(sessionId);
   return sessionsUsedByPrincipal;
  });
 }
 public void removeSessionInformation(String sessionId) {
  SessionInformation info = getSessionInformation(sessionId);
  if (info == null) {
   return;
  }
  sessionIds.remove(sessionId);
  principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
   sessionsUsedByPrincipal.remove(sessionId);
   if (sessionsUsedByPrincipal.isEmpty()) {
    sessionsUsedByPrincipal = null;
   }
   return sessionsUsedByPrincipal;
  });
 }

}

这个类的源码仍是比较长,我这里提取出来一些比较关键的部分:

  1. 首先你们看到,一上来声明了一个 principals 对象,这是一个支持并发访问的 map 集合,集合的 key 就是用户的主体(principal),正常来讲,用户的 principal 其实就是用户对象,松哥在以前的文章中也和你们讲过 principal 是怎么样存入到 Authentication 中的(参见:松哥手把手带你捋一遍 Spring Security 登陆流程),而集合的 value 则是一个 set 集合,这个 set 集合中保存了这个用户对应的 sessionid。
  2. 若有新的 session 须要添加,就在 registerNewSession 方法中进行添加,具体是调用 principals.compute 方法进行添加,key 就是 principal。
  3. 若是用户注销登陆,sessionid 须要移除,相关操做在 removeSessionInformation 方法中完成,具体也是调用 principals.computeIfPresent 方法,这些关于集合的基本操做我就再也不赘述了。

看到这里,你们发现一个问题,ConcurrentMap 集合的 key 是 principal 对象,用对象作 key,必定要重写 equals 方法和 hashCode 方法,不然第一次存完数据,下次就找不到了,这是 JavaSE 方面的知识,我就不用多说了。

若是咱们使用了基于内存的用户,咱们来看下 Spring Security 中的定义:

public class User implements UserDetailsCredentialsContainer {
 private String password;
 private final String username;
 private final Set<GrantedAuthority> authorities;
 private final boolean accountNonExpired;
 private final boolean accountNonLocked;
 private final boolean credentialsNonExpired;
 private final boolean enabled;
 @Override
 public boolean equals(Object rhs) {
  if (rhs instanceof User) {
   return username.equals(((User) rhs).username);
  }
  return false;
 }
 @Override
 public int hashCode() {
  return username.hashCode();
 }
}

能够看到,他本身其实是重写了 equals 和 hashCode 方法了。

因此咱们使用基于内存的用户时没有问题,而咱们使用自定义的用户就有问题了。

找到了问题所在,那么解决问题就很容易了,重写 User 类的 equals 方法和 hashCode 方法便可:

@Entity(name = "t_user")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;
    @ManyToMany(fetch = FetchType.EAGER,cascade = CascadeType.PERSIST)
    private List<Role> roles;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(username, user.username);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username);
    }
    ...
    ...
}

配置完成后,重启项目,再去进行多端登陆测试,发现就能够成功踢掉已经登陆的用户了。

若是你使用了 MyBatis 而不是 Jpa,也是同样的处理方案,只须要重写登陆用户的 equals 方法和 hashCode 方法便可。

3.微人事应用

3.1 存在的问题

因为微人事目前是采用了 JSON 格式登陆,因此若是项目控制 session 并发数,就会有一些额外的问题要处理。

最大的问题在于咱们用自定义的过滤器代替了 UsernamePasswordAuthenticationFilter,进而致使前面所讲的关于 session 的配置,通通失效。全部相关的配置咱们都要在新的过滤器 LoginFilter 中进行配置 ,包括 SessionAuthenticationStrategy 也须要咱们本身手动配置了。

这虽然带来了一些工做量,可是作完以后,相信你们对于 Spring Security 的理解又会更上一层楼。

3.2 具体应用

咱们来看下具体怎么实现,我这里主要列出来一些关键代码,「完整代码你们能够从 GitHub 上下载」:https://github.com/lenve/vhr。

首先第一步,咱们重写 Hr 类的 equals 和 hashCode 方法,以下:

public class Hr implements UserDetails {
    ...
    ...
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Hr hr = (Hr) o;
        return Objects.equals(username, hr.username);
    }

    @Override
    public int hashCode() {
        return Objects.hash(username);
    }
    ...
    ...
}

接下来在 SecurityConfig 中进行配置。

这里咱们要本身提供 SessionAuthenticationStrategy,而前面处理 session 并发的是 ConcurrentSessionControlAuthenticationStrategy,也就是说,咱们须要本身提供一个 ConcurrentSessionControlAuthenticationStrategy 的实例,而后配置给 LoginFilter,可是在建立 ConcurrentSessionControlAuthenticationStrategy 实例的过程当中,还须要有一个 SessionRegistryImpl 对象。

前面咱们说过,SessionRegistryImpl 对象是用来维护会话信息的,如今这个东西也要咱们本身来提供,SessionRegistryImpl 实例很好建立,以下:

@Bean
SessionRegistryImpl sessionRegistry() {
    return new SessionRegistryImpl();
}

而后在 LoginFilter 中配置 SessionAuthenticationStrategy,以下:

@Bean
LoginFilter loginFilter() throws Exception {
    LoginFilter loginFilter = new LoginFilter();
    loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
                //省略
            }
    );
    loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
                //省略
            }
    );
    loginFilter.setAuthenticationManager(authenticationManagerBean());
    loginFilter.setFilterProcessesUrl("/doLogin");
    ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
    sessionStrategy.setMaximumSessions(1);
    loginFilter.setSessionAuthenticationStrategy(sessionStrategy);
    return loginFilter;
}

咱们在这里本身手动构建 ConcurrentSessionControlAuthenticationStrategy 实例,构建时传递 SessionRegistryImpl 参数,而后设置 session 的并发数为 1,最后再将 sessionStrategy 配置给 LoginFilter。

其实上篇文章中,咱们的配置方案,最终也是像上面这样,只不过如今咱们本身把这个写出来了而已。

这就配置完了吗?没有!session 处理还有一个关键的过滤器叫作 ConcurrentSessionFilter,原本这个过滤器是不须要咱们管的,可是这个过滤器中也用到了 SessionRegistryImpl,而 SessionRegistryImpl 如今是由咱们本身来定义的,因此,该过滤器咱们也要从新配置一下,以下:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            ...
    http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> {
        HttpServletResponse resp = event.getResponse();
        resp.setContentType("application/json;charset=utf-8");
        resp.setStatus(401);
        PrintWriter out = resp.getWriter();
        out.write(new ObjectMapper().writeValueAsString(RespBean.error("您已在另外一台设备登陆,本次登陆已下线!")));
        out.flush();
        out.close();
    }), ConcurrentSessionFilter.class);
    http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}

在这里,咱们从新建立一个 ConcurrentSessionFilter 的实例,代替系统默认的便可。在建立新的 ConcurrentSessionFilter 实例时,须要两个参数:

  1. sessionRegistry 就是咱们前面提供的 SessionRegistryImpl 实例。
  2. 第二个参数,是一个处理 session 过时后的回调函数,也就是说,当用户被另一个登陆踢下线以后,你要给什么样的下线提示,就在这里来完成。

最后,咱们还须要在处理完登陆数据以后,手动向 SessionRegistryImpl 中添加一条记录:

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    @Autowired
    SessionRegistry sessionRegistry;
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        //省略
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                    username, password);
            setDetails(request, authRequest);
            Hr principal = new Hr();
            principal.setUsername(username);
            sessionRegistry.registerNewSession(request.getSession(true).getId(), principal);
            return this.getAuthenticationManager().authenticate(authRequest);
        } 
        ...
        ...
    }
}

在这里,咱们手动调用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一条 session 记录。

OK,如此以后,咱们的项目就配置完成了。

接下来,重启 vhr 项目,进行多端登陆测试,若是本身被人踢下线了,就会看到以下提示:

图片

完整的代码,我已经更新到 vhr 上了,你们能够下载学习。

若是小伙伴们对松哥录制的 vhr 项目视频感兴趣,不妨看看这里:微人事项目视频教程

4.小结

好了,本文主要和小伙伴们介绍了一个在 Spring Security 中处理 session 并发问题时,可能遇到的一个坑,以及在先后端分离状况下,如何处理 session 并发问题。不知道小伙伴们有没有 GET 到呢?

本文第二小节的案例你们能够从 GitHub 上下载:https://github.com/lenve/spring-security-samples

转自:https://mp.weixin.qq.com/s/nfqFDaLDH8UJVx7mqqgHmQ

相关文章
相关标签/搜索