You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot集成Keycloak无法禁用并发登录问题排查

解决Spring Security + Keycloak 并发登录控制失效问题

我来帮你搞定这个问题!你遇到的核心问题是Spring Security的SessionRegistry无法识别Keycloak返回的同一个用户的不同Authentication对象,导致并发登录控制不生效。

问题根源

Spring Security的SessionRegistryImpl(你用的默认实现)是通过Authentication对象的equals()和hashCode()来判断是否为同一用户的。但Keycloak每次认证成功后,都会生成一个全新的KeycloakAuthenticationToken实例——哪怕两个Token的Principal ID完全相同,它们也是不同的对象,equals()返回false,hashCode也不一样。这就导致SessionRegistry把同一个用户的多次登录当成了不同用户,自然不会触发maximumSessions(1)的限制。

从你打印的日志也能看出来:两个Principal的ID都是c8827230-1916-4026-923c-642be90d0d38,但Token对象的哈希值完全不同,SessionRegistry根本没把它们当成同一个用户。

解决方案

这里给你两种可行的方案,选一种适合你的就行:

方案一:自定义KeycloakAuthenticationToken,重写equals/hashCode

让Token的相等性判断基于Principal ID,而不是对象本身:

  1. 继承KeycloakAuthenticationToken,重写equals和hashCode方法:
public class CustomKeycloakAuthenticationToken extends KeycloakAuthenticationToken {

    public CustomKeycloakAuthenticationToken(KeycloakAccount account, boolean interactive) {
        super(account, interactive);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        CustomKeycloakAuthenticationToken that = (CustomKeycloakAuthenticationToken) o;
        // 基于Principal的ID判断是否为同一用户
        return getPrincipal().equals(that.getPrincipal());
    }

    @Override
    public int hashCode() {
        // 用Principal的ID作为哈希值
        return getPrincipal().hashCode();
    }
}
  1. 修改configureGlobal方法,让KeycloakAuthenticationProvider返回自定义的Token:
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    KeycloakAuthenticationProvider keycloakAuthenticationProvider = new KeycloakAuthenticationProvider() {
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            // 调用父类方法获取默认Token
            KeycloakAuthenticationToken originalToken = (KeycloakAuthenticationToken) super.authenticate(authentication);
            // 包装成自定义Token返回
            return new CustomKeycloakAuthenticationToken(originalToken.getAccount(), originalToken.isInteractive());
        }
    };
    keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
    auth.authenticationProvider(keycloakAuthenticationProvider);
}

方案二:自定义SessionRegistry,基于Principal ID管理会话

如果不想修改Token的行为,可以直接实现自己的SessionRegistry,用用户的Principal ID作为key来跟踪会话:

  1. 实现自定义SessionRegistry:
@Component
public class PrincipalIdSessionRegistry implements SessionRegistry {

    // 存储用户ID到会话ID的映射
    private final Map<String, Set<String>> userSessionMap = new ConcurrentHashMap<>();
    // 存储会话ID到用户ID的映射
    private final Map<String, String> sessionUserMap = new ConcurrentHashMap<>();

    @Override
    public void registerNewSession(String sessionId, Object principal) {
        // 这里principal是Keycloak的用户ID字符串,直接转成String即可
        String userId = principal.toString();
        // 注册会话到用户名下
        userSessionMap.computeIfAbsent(userId, k -> new ConcurrentHashSet<>()).add(sessionId);
        sessionUserMap.put(sessionId, userId);
    }

    @Override
    public void removeSessionInformation(String sessionId) {
        String userId = sessionUserMap.remove(sessionId);
        if (userId != null) {
            Set<String> userSessions = userSessionMap.get(userId);
            if (userSessions != null) {
                userSessions.remove(sessionId);
                if (userSessions.isEmpty()) {
                    userSessionMap.remove(userId);
                }
            }
        }
    }

    @Override
    public SessionInformation getSessionInformation(String sessionId) {
        // 如果不需要查询会话详情,返回null即可
        return null;
    }

    @Override
    public List<Object> getAllPrincipals() {
        return new ArrayList<>(userSessionMap.keySet());
    }

    @Override
    public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
        String userId = principal.toString();
        Set<String> sessionIds = userSessionMap.getOrDefault(userId, Collections.emptySet());
        return sessionIds.stream()
                .map(sessionId -> new SessionInformation(principal, sessionId, new Date()))
                .collect(Collectors.toList());
    }

    @Override
    public void refreshLastRequest(String sessionId) {
        // 可选实现,不需要的话留空
    }
}
  1. 在HttpSecurity配置中注入自定义的SessionRegistry:
@Autowired
private PrincipalIdSessionRegistry sessionRegistry;

@Override
protected void configure(HttpSecurity http) throws Exception {
    super.configure(http);
    http
            .authorizeRequests()
                .antMatchers("/login").hasRole("user")
                .antMatchers("/app/*").hasRole("user")
                .antMatchers("/admin*").hasRole("admin")
                .anyRequest().permitAll()
            .and()
            .logout().logoutUrl("/logout")
            .and()
            .sessionManagement()
                .maximumSessions(1)
                // 指定自定义的SessionRegistry
                .sessionRegistry(sessionRegistry)
                .maxSessionsPreventsLogin(true);
}

验证修改

修改完成后,用同一个用户在不同浏览器/隐身窗口尝试登录:

  • 如果maxSessionsPreventsLogin(true),第二次登录会直接被拒绝
  • 如果设置为false,第二次登录会踢掉第一次的会话

这样就能实现你想要的“禁用并发登录”的效果了!

内容的提问来源于stack exchange,提问作者Galaxy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.28 06:19:45