管理员如何踢掉登录用户?

发布于:2024-06-21 ⋅ 阅读:(124) ⋅ 点赞:(0)

这是 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,大功告成。