Spring Security自定义Filter下maximumSessions失效问题排查
环境信息
- Spring Boot: 3.2.2
- Spring Security: 6.2.1
我自定义了Filter来支持JSON格式的登录请求,想要通过maximumSessions实现单用户仅能在一个浏览器登录的限制。按照网上文章的方法在自定义Filter Bean中配置了ConcurrentSessionControlAuthenticationStrategy,但功能始终不生效,希望得到问题排查方向。
配置代码
@Bean public LoginFilter loginFilter(AuthenticationManager authenticationManager) throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setFilterProcessesUrl("/doLogin"); loginFilter.setAuthenticationManager(authenticationManager); loginFilter.setSecurityContextRepository(new DelegatingSecurityContextRepository( new RequestAttributeSecurityContextRepository(), new HttpSessionSecurityContextRepository() )); loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> { ... }); loginFilter.setAuthenticationFailureHandler((request, response, exception) -> { ... }); ConcurrentSessionControlAuthenticationStrategy strategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry()); strategy.setMaximumSessions(1); loginFilter.setSessionAuthenticationStrategy(strategy); return loginFilter; } @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, LoginFilter loginFilter) throws Exception { httpSecurity .sessionManagement(session -> session.maximumSessions(1).maxSessionsPreventsLogin(true)) .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("Already logged in on another device."); out.flush(); out.close(); }), ConcurrentSessionFilter.class) .addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests( requests -> requests .requestMatchers("/css/**", "/js/**", "/images/**", "/getVerifyCode").permitAll() .requestMatchers("/error").permitAll() .anyRequest().authenticated() ) .logout(logout -> logout.logoutUrl("/doLogout") .logoutSuccessHandler((request, response, authentication) -> { ... }) ) .csrf(csrfConfigurer -> csrfConfigurer.disable()) .exceptionHandling(exceptionHandler -> exceptionHandler.authenticationEntryPoint((request, response, authException) -> { log.error(authException); })); return httpSecurity.build(); }
LoginFilter代码
@Log4j2 public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final ObjectMapper objectMapper; private SessionRegistry sessionRegistry; public LoginFilter() { super(); objectMapper = new ObjectMapper(); } @Autowired public void setSessionRegistry(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String verifyCode = (String) request.getSession().getAttribute("verify_code"); Map<String, String> requestParameters = new HashMap<>(); if (MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType())) { requestParameters = extractRequestParametersFromJson(request); } else { requestParameters = extractRequestParametersFromForm(request); } String code = requestParameters.get("code"); checkoutVerifyCode(code, verifyCode); String username = requestParameters.get(getUsernameParameter()); String password = requestParameters.get(getPasswordParameter()); username = username != null ? username.trim() : ""; password = password != null ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); this.setDetails(request, authRequest); sessionRegistry.registerNewSession(request.getSession().getId(), authRequest.getPrincipal()); return this.getAuthenticationManager().authenticate(authRequest); } }
更新
调试源码发现,用户登录时会调用ConcurrentSessionControlAuthenticationStrategy#onAuthentication,内部执行SessionRegistryImpl#getAllSessions中的this.principals.get(principal),principals是ConcurrentMap<Object, Set<String>>类型,principal是我自定义的User类(已重写equals()和hashCode())。但断点显示equals()的参数是String对象,而使用默认HttpSecurity#formLogin时参数类型为User,想知道二者差异的原因。
问题排查与修正方向
移除
attemptAuthentication中提前注册Session的代码
你在认证未完成的阶段,就调用sessionRegistry.registerNewSession(request.getSession().getId(), authRequest.getPrincipal()),此时authRequest.getPrincipal()只是用户名字符串,而非认证成功后生成的自定义User对象。这会导致后续SessionRegistry中存储的Principal类型混乱,并发校验自然失效。删掉这行代码,让ConcurrentSessionControlAuthenticationStrategy在认证成功后自动处理Session注册。避免Session管理配置重复
你同时通过sessionManagement(session -> session.maximumSessions(1).maxSessionsPreventsLogin(true))和手动添加ConcurrentSessionFilter配置并发Session控制,两种方式会产生冲突。建议二选一:要么完全依赖sessionManagement的API自动生成相关组件,要么手动配置所有Session控制组件但去掉sessionManagement中的对应配置。验证自定义User类的equals/hashCode实现
确保自定义User类的equals()和hashCode()是基于用户唯一标识(如userId或username)实现的,这样SessionRegistry才能正确识别同一用户的不同Session。
差异原因解释
默认formLogin流程中,认证成功后生成的Authentication对象里的Principal是UserDetails实例(即你的自定义User类),SessionAuthenticationStrategy会用这个User对象去SessionRegistry中查找已有Session。
而你的自定义Filter在认证完成前,就把字符串类型的用户名注册到了SessionRegistry,后续认证成功后,策略用User对象查找时,由于之前存储的Key是String类型,导致匹配失败,同时触发User对象与String对象的equals()比对,就出现了你看到的参数类型为String的情况。
内容的提问来源于stack exchange,提问作者Jumple

