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

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,想知道二者差异的原因。


问题排查与修正方向

  1. 移除attemptAuthentication中提前注册Session的代码
    你在认证未完成的阶段,就调用sessionRegistry.registerNewSession(request.getSession().getId(), authRequest.getPrincipal()),此时authRequest.getPrincipal()只是用户名字符串,而非认证成功后生成的自定义User对象。这会导致后续SessionRegistry中存储的Principal类型混乱,并发校验自然失效。删掉这行代码,让ConcurrentSessionControlAuthenticationStrategy在认证成功后自动处理Session注册。

  2. 避免Session管理配置重复
    你同时通过sessionManagement(session -> session.maximumSessions(1).maxSessionsPreventsLogin(true))和手动添加ConcurrentSessionFilter配置并发Session控制,两种方式会产生冲突。建议二选一:要么完全依赖sessionManagement的API自动生成相关组件,要么手动配置所有Session控制组件但去掉sessionManagement中的对应配置。

  3. 验证自定义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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.30 02:30:57