上篇文章中,咱们讲了在 Spring Security 中如何踢掉前一个登陆用户,或者禁止用户二次登陆,经过一个简单的案例,实现了咱们想要的效果。css
可是有一个不太完美的地方,就是咱们的用户是配置在内存中的用户,咱们没有将用户放到数据库中去。正常状况下,松哥在 Spring Security 系列中讲的其余配置,你们只须要参考Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文,将数据切换为数据库中的数据便可。html
可是,在作 Spring Security 的 session 并发处理时,直接将内存中的用户切换为数据库中的用户会有问题,今天咱们就来讲说这个问题,顺便把这个功能应用到微人事中(https://github.com/lenve/vhr)。java
本文是松哥最近在连载的 Spring Security 系列第 14 篇,阅读本系列前面的文章有助于更好的理解本文:git
本文的案例将基于Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文来构建,因此重复的代码我就不写了,小伙伴们要是不熟悉能够参考该篇文章。github
首先,咱们打开Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文中的案例,这个案例结合 Spring Data Jpa 将用户数据存储到数据库中去了。web
而后咱们将上篇文章中涉及到的登陆页面拷贝到项目中(文末能够下载完整案例):spring
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7XB0viq6-1588898082940)(http://img.itboyhub.com/2020/04/20200506204420.png)]数据库
并在 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。后端
好了,配置完成后,咱们启动项目,并行性多端登陆测试。
打开多个浏览器,分别进行多端登陆测试,咱们惊讶的发现,每一个浏览器都能登陆成功,每次登陆成功也不会踢掉已经登陆的用户!
这是怎么回事?
要搞清楚这个问题,咱们就要先搞明白 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; }); } }
这个类的源码仍是比较长,我这里提取出来一些比较关键的部分:
看到这里,你们发现一个问题,ConcurrentMap 集合的 key 是 principal 对象,用对象作 key,必定要重写 equals 方法和 hashCode 方法,不然第一次存完数据,下次就找不到了,这是 JavaSE 方面的知识,我就不用多说了。
若是咱们使用了基于内存的用户,咱们来看下 Spring Security 中的定义:
public class User implements UserDetails, CredentialsContainer { 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 方法便可。
因为微人事目前是采用了 JSON 格式登陆,因此若是项目控制 session 并发数,就会有一些额外的问题要处理。
最大的问题在于咱们用自定义的过滤器代替了 UsernamePasswordAuthenticationFilter,进而致使前面所讲的关于 session 的配置,通通失效。全部相关的配置咱们都要在新的过滤器 LoginFilter 中进行配置 ,包括 SessionAuthenticationStrategy 也须要咱们本身手动配置了。
这虽然带来了一些工做量,可是作完以后,相信你们对于 Spring Security 的理解又会更上一层楼。
咱们来看下具体怎么实现,我这里主要列出来一些关键代码,完整代码你们能够从 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 实例时,须要两个参数:
最后,咱们还须要在处理完登陆数据以后,手动向 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 项目视频感兴趣,不妨看看这里:微人事项目视频教程
好了,本文主要和小伙伴们介绍了一个在 Spring Security 中处理 session 并发问题时,可能遇到的一个坑,以及在先后端分离状况下,如何处理 session 并发问题。不知道小伙伴们有没有 GET 到呢?
本文第二小节的案例你们能够从 GitHub 上下载:https://github.com/lenve/spring-security-samples