上篇文章中,咱们讲了在 Spring Security 中如何踢掉前一个登陆用户,或者禁止用户二次登陆,经过一个简单的案例,实现了咱们想要的效果。css
可是有一个不太完美的地方,就是咱们的用户是配置在内存中的用户,咱们没有将用户放到数据库中去。正常状况下,松哥在 Spring Security 系列中讲的其余配置,你们只须要参考Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文,将数据切换为数据库中的数据便可。html
可是,在作 Spring Security 的 session 并发处理时,直接将内存中的用户切换为数据库中的用户会有问题,今天咱们就来讲说这个问题,顺便把这个功能应用到微人事中(https://github.com/lenve/vhr)。git
本文是松哥最近在连载的 Spring Security 系列第 14 篇,阅读本系列前面的文章有助于更好的理解本文:github
本文的案例将基于Spring Security+Spring Data Jpa 强强联手,安全管理只有更简单!一文来构建,因此重复的代码我就不写了,小伙伴们要是不熟悉能够参考该篇文章。web
首先,咱们打开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。后端
好了,配置完成后,咱们启动项目,并行性多端登陆测试。浏览器
打开多个浏览器,分别进行多端登陆测试,咱们惊讶的发现,每一个浏览器都能登陆成功,每次登陆成功也不会踢掉已经登陆的用户!
这是怎么回事?
要搞清楚这个问题,咱们就要先搞明白 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
转自:https://mp.weixin.qq.com/s/nfqFDaLDH8UJVx7mqqgHmQ