这是 Spring Security 学习小组有小伙伴提的一个问题:
感觉这个问题还有点意思,拿出来和各位小伙伴一起分享下。
一 问题分析
首先大家注意限制条件:常规 Session 方案。
如果不是这几个字,这个问题根本就不是问题,如果是 JWT+Redis 这种方案,这个问题很好解决,自己随随便便几段逻辑处理就行了。问题是常规 Session 方案,也就是 Spring Security 默认的方案,Spring Security 默认情况下,登录用户信息保存在 HttpSession 中,HttpSession 不同用户又是不一样的 HttpSession,相当于你在一个 HttpSession 对象中要使另外一个 HttpSession 对象失效,这是这个小伙伴困惑的地方。
二 解决思路
Spring Security 中提供了一个会话并发管理的功能,就是可以设置同一个用户并发登录的数量,比如 javaboy 的并发登录数量为 1,那么 javaboy 就只能在一台设备上登录,在在其他设备登录就会被拒绝,或者其他设备登录会自动踢掉当前登录。
这一功能实现的原理是 Spring Security 中用了一个会话注册器 SessionRegistry 去统一管理登录用户的会话,当用户登录成功之后,讲用户信息保存在一个类型为 ConcurrentMap<Object, Set<String>> principals
的 Map 中,这里的 key 就是登录的用户对象,value 就是登录用户的 sessionId,当然如果想获取到登录用户会话更为详细的信息,还有一个类型为 Map<String, SessionInformation> sessionIds
的 Map,这个 Map 的 key 则是 sessionId。通过对这两个 Map 中的数据进行管理,就能实现对用户并发登录的控制。
相同的道理,我这里也想借鉴已有的功能,在这个功能的基础上,实现管理员踢出已登录用户,这样就会方便很多。
管理员踢出用户的时候,只需要遍历 principals 集合,根据用户名找出来这个用户登录的 sessionId,然后再根据 sessionId 去 sessionIds 里找到会话对应的 SessionInformation,然后令这些会话失效即可。
三 参考代码
首先需要我们自己提供 SessionRegistry 对象:
@Configuration
public class SecurityConfig {
@Bean
SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
UserDetailsService us() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("zhangsan").password("{noop}123").roles("ADMIN").build());
manager.createUser(User.withUsername("lisi").password("{noop}123").roles("ADMIN").build());
return manager;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(c -> c.disable())
.sessionManagement(s -> s.maximumSessions(Integer.MAX_VALUE).sessionRegistry(sessionRegistry()));
return http.build();
}
@Bean
HttpSessionEventPublisher sessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}
在配置 SecurityFilterChain 的时候,传入自己配置的 sessionRegistry。
这里有一个需要注意的点,就是要开启会话并发管理,只有开启了会话并发管理,第二小节我们说的那些思路才是有效的,否则这些思路不会生效。那么怎么开启会话并发管理呢?设置会话的最大并发数即可,如果你本身并不想限制,那么这个并发数可以设置为 Integer.MAX_VALUE
。
这里涉及到的其他内容我就不多说了,都是课程中讲的关于会话并发管理的内容。
最后,踢出用户的逻辑如下:
@Service
public class LogoutService {
@Autowired
SessionRegistry sessionRegistry;
public void logout(String username) {
List<Object> principals = sessionRegistry.getAllPrincipals();
for (Object principal : principals) {
if (principal instanceof User u) {
String name = u.getUsername();
if (name.equals(username)) {
List<SessionInformation> allSessions = sessionRegistry.getAllSessions(u, false);
for (SessionInformation session : allSessions) {
session.expireNow();
}
}
}
}
}
}
参数 username 就是管理员要踢出去的用户名。
sessionRegistry.getAllPrincipals();
是获取到所有的登录用户信息,然后遍历,根据用户名找到要踢出去的用户,然后调用 sessionRegistry.getAllSessions
方法获取该用户的所有会话信息,遍历这些会话,挨个调用其 expireNow() 方法,使之失效。
这样,当用户被踢下线的感觉就像是会话并发控制的时候,被其他客户端挤下线的感觉。
当然,也可以给用户一个明确提示,类似下面这样:
.sessionManagement(s -> s.maximumSessions(Integer.MAX_VALUE).sessionRegistry(sessionRegistry()).expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setContentType("text/html;charset=utf-8");
response.getWriter()
.print("你被管理员踢下线了");
response.flushBuffer();
}));
OK,大功告成。