Spring Boot集成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,而不是对象本身:
- 继承
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(); } }
- 修改
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来跟踪会话:
- 实现自定义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) { // 可选实现,不需要的话留空 } }
- 在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

