springspring-bootjdbcspring-securityspring-session

Spring security - maximumSessions() - not working


I have a project in Spring boot, with Spring security and spring session. I wanted to persist my session in a database so I specified it in application.properties:

spring.session.store-type=jdbc

I also wanted to login into my app via REST API (because I use React to create pages). This is how I've done it:

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;
    private final AuthenticationManager authenticationManager;
    private final SpringSessionBackedSessionRegistry<? extends Session>  sessionRegistry;
    private final SecurityContextRepository securityContextRepository;

    @Autowired
    public UserController(UserService userService, AuthenticationManager authenticationManager, SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry,
                          SecurityContextRepository securityContextRepository) {
        this.userService = userService;
        this.authenticationManager = authenticationManager;
        this.sessionRegistry = sessionRegistry;
        this.securityContextRepository = securityContextRepository;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getEmail(),
                        loginRequest.getPassword()
                )
        );

        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);
        securityContextRepository.saveContext(securityContext, request, response);
        sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());

        return ResponseEntity.ok("User authenticated successfully");
    }

When I make this POST request with valid credentials everything works as desired. I can see a record in the database (spring_session - table) with principal_name (column) corresponding to the just authenticated user.

Following spring docs I found out maximumSessions() method. I used it in my code (to value 1), however it doesn't work. I can login on one browser, and then once again on another browser. This is my SecurityConfig:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final UserDetailsServiceImpl userDetailsServiceImpl;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final JdbcIndexedSessionRepository jdbcIndexedSessionRepository;

    public SecurityConfig(UserDetailsServiceImpl userDetailsServiceImpl, BCryptPasswordEncoder bCryptPasswordEncoder,
                          CustomOAuth2UserService customOAuth2UserService, JdbcIndexedSessionRepository jdbcIndexedSessionRepository) {
        this.userDetailsServiceImpl = userDetailsServiceImpl;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.customOAuth2UserService = customOAuth2UserService;
        this.jdbcIndexedSessionRepository = jdbcIndexedSessionRepository;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/api/users/login").permitAll()
                        .requestMatchers("/api/users/register").permitAll()
                        .requestMatchers("/api/users/current/**").permitAll()
                        .requestMatchers("/api/users").hasRole("ADMIN")
                        .requestMatchers("/api/tasks").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .sessionManagement((sessionManagement) -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                        .sessionRegistry(sessionRegistry())
                )
                .securityContext((securityContext) -> securityContext
                        .securityContextRepository(securityContextRepository())
                )
                .anonymous(AbstractHttpConfigurer::disable)
                .logout((logout) -> logout
                        .logoutUrl("/api/users/logout")
                        .logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpStatus.OK.value()))
                        .invalidateHttpSession(true)
                        .clearAuthentication(true)
                        .deleteCookies("SESSION")
                        .permitAll()
                )
                .requestCache((cache) -> cache.requestCache(new NullRequestCache()))
                .cors((cors) -> cors
                        .configurationSource(corsConfigurationSource())
                )
                .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    public SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry() {
        return new SpringSessionBackedSessionRegistry<>(jdbcIndexedSessionRepository);
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsServiceImpl);
        authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);

        ProviderManager providerManager = new ProviderManager(authenticationProvider);
        providerManager.setEraseCredentialsAfterAuthentication(false);

        return providerManager;
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST", "PUT", "DELETE"));
        configuration.setAllowedHeaders(Collections.singletonList("*"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

I would assume that my configuration is not quite good.

I've been looking through similar problems on the Internet but nothing helped. I have overrided equals() and hashCode() methods in my User model and in UserDetails. I have tested it. Users are equals if emails are the same. Hashcode is generated using email field.

I would assume, that something wrong is in my login endpoint. Maybe sesionRegistry somehow does not communicate well with securityContextRepository. I have tried following spring security docs, but encountered some problems and the above code is what I came up with.

-----UPDATE_1-----

I've just discovered that the root of my problems lies in securityConfig precisely in SecurityFilterChain. The configuration stored there is not enough to automatically do things like maximumSessions(1). In the Controller I manually serve the AuthenticationManager while it should be declared in the SecurityFilterChain like:

.authenticationManager(authenticationManager())

It still does not resolve my problem but I believe this is a small step forward

-----UPDATE_2-----

With the help from @m-deinum the working code states as below:

//...
public class SecurityConfig{
private final UserDetailsServiceImpl userDetailsServiceImpl;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final JdbcIndexedSessionRepository jdbcIndexedSessionRepository;
    private final ObjectMapper mapper;

    public SecurityConfig(UserDetailsServiceImpl userDetailsServiceImpl, BCryptPasswordEncoder bCryptPasswordEncoder,
                          CustomOAuth2UserService customOAuth2UserService, JdbcIndexedSessionRepository jdbcIndexedSessionRepository, ObjectMapper mapper) {
        this.userDetailsServiceImpl = userDetailsServiceImpl;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.customOAuth2UserService = customOAuth2UserService;
        this.jdbcIndexedSessionRepository = jdbcIndexedSessionRepository;
        this.mapper = mapper;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(loginFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/api/users/login").permitAll()
                        .requestMatchers("/api/users/register").permitAll()
                        .requestMatchers("/api/users/current/**").permitAll()
                        .requestMatchers("/oauth2/**").permitAll()
                        .requestMatchers("/api/users").hasRole("ADMIN")
                        .requestMatchers("/api/tasks").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .sessionManagement((sessionManagement) -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                )
                .securityContext((securityContext) -> securityContext
                        .securityContextRepository(securityContextRepository())
                )
                .anonymous(AbstractHttpConfigurer::disable)
                .requestCache((cache) -> cache.requestCache(new NullRequestCache()))
                .logout((logout) -> logout
                        .logoutUrl("/api/users/logout")
                        .logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpStatus.OK.value()))
                        .invalidateHttpSession(true)
                        .clearAuthentication(true)
                        .deleteCookies("SESSION")
                        .permitAll()
                )
                .cors((cors) -> cors
                        .configurationSource(corsConfigurationSource())
                )
                .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }

 ///previous beans...

    @Bean
    public LoginFilter loginFilter() {
        return new LoginFilter(mapper, authenticationManager(), securityContextRepository(), sessionAuthenticationStrategy());
    }

    @Bean
    public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        ConcurrentSessionControlAuthenticationStrategy sessionControlAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
        sessionControlAuthenticationStrategy.setMaximumSessions(1);
sessionControlAuthenticationStrategy.setExceptionIfMaximumExceeded(false);
        return sessionControlAuthenticationStrategy;
    }

    @Bean
    public FilterRegistrationBean<LoginFilter> loginFilterRegistration() {
        FilterRegistrationBean<LoginFilter> registration = new FilterRegistrationBean<LoginFilter>(loginFilter());
        registration.setEnabled(false);
        return registration;
    }
}

And LoginFilter class:

public class LoginFilter extends AbstractAuthenticationProcessingFilter {

    private final ObjectMapper mapper;

    public LoginFilter(ObjectMapper mapper, AuthenticationManager authenticationManager, SecurityContextRepository securityContextRepository,
                       SessionAuthenticationStrategy sessionAuthenticationStrategy) {
        super(new AntPathRequestMatcher("/api/users/login", "POST"));
        this.mapper = mapper;
        setAuthenticationManager(authenticationManager);
        setSecurityContextRepository(securityContextRepository);
        setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        LoginRequest loginRequest = mapper.readValue(request.getInputStream(), LoginRequest.class);
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.getEmail(),
                loginRequest.getPassword());
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return getAuthenticationManager().authenticate(authRequest);
    }

    protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}

Solution

  • Ditch your controller and move your logic to a filter instead and let that filter extend the AbstractAuthenticationProcessingFilter. This will then properly integrate with the Spring Security infrastructure. Unless you want to do everything manually ofcourse.

    @Component
    public class LoginFilter extends AbstractAuthenticationProcessingFilter {
    
      private final ObjectMapper mapper;
    
      public LoginFilter(ObjectMapper mapper) {
        super(new AntPathRequestMatcher("/api/users/login", "POST");
        this.mapper = mapper;
      }
    
      public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
                throws AuthenticationException, IOException, ServletException {
         LoginRequest loginRequest = mapper.readValue(request.getInputStream(), LoginRequest.class);
         UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.getEmail(),
                    loginRequest.getPassword());
            // Allow subclasses to set the "details" property
            setDetails(request, authRequest);
    
         return authenticationManager.authenticate(authRequest);                
      } 
    
     protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
    authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
     }
    }
    

    Something like that. Now we need to add this to the security filter chain.

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        private final UserDetailsService userDetailsService;
        private final PasswordEncoder passwordEncoder;
        private final CustomOAuth2UserService customOAuth2UserService;
        private final JdbcIndexedSessionRepository jdbcIndexedSessionRepository;
        private final LoginFilter loginFilter;
    
        public SecurityConfig(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder,
                              CustomOAuth2UserService customOAuth2UserService, JdbcIndexedSessionRepository jdbcIndexedSessionRepository, LoginFilter loginFilter) {
            this.userDetailsService = userDetailsService;
            this.passwordEncoder = passwordEncoder;
            this.customOAuth2UserService = customOAuth2UserService;
            this.jdbcIndexedSessionRepository = jdbcIndexedSessionRepository;
            this.loginFilter = loginFilter;
        }
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http
                    addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class)
                    .authorizeHttpRequests((authorize) -> authorize
                            .requestMatchers("/api/users/login").permitAll()
                            .requestMatchers("/api/users/register").permitAll()
                            .requestMatchers("/api/users/current/**").permitAll()
                            .requestMatchers("/api/users").hasRole("ADMIN")
                            .requestMatchers("/api/tasks").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
                    .sessionManagement((sessionManagement) -> sessionManagement
                            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                            .maximumSessions(1)
                            .maxSessionsPreventsLogin(true)
                            .sessionRegistry(sessionRegistry())
                    )
                    .securityContext((securityContext) -> securityContext
                            .securityContextRepository(securityContextRepository())
                    )
                    .anonymous(AbstractHttpConfigurer::disable)
                    .logout((logout) -> logout
                            .logoutUrl("/api/users/logout")
                            .logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpStatus.OK.value()))
                            .invalidateHttpSession(true)
                            .clearAuthentication(true)
                            .deleteCookies("SESSION")
                            .permitAll()
                    )
                    .requestCache((cache) -> cache.requestCache(new NullRequestCache()))
                    .cors((cors) -> cors
                            .configurationSource(corsConfigurationSource())
                    )
                    .csrf(AbstractHttpConfigurer::disable);
    
            return http.build();
        }
    
        @Bean
        public SecurityContextRepository securityContextRepository() {
            return new HttpSessionSecurityContextRepository();
        }
    
        @Bean
        public SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry() {
            return new SpringSessionBackedSessionRegistry<>(jdbcIndexedSessionRepository);
        }
    
        @Bean
        public HttpSessionEventPublisher httpSessionEventPublisher() {
            return new HttpSessionEventPublisher();
        }
    
        @Bean
        public AuthenticationManager authenticationManager() {
            DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
            authenticationProvider.setUserDetailsService(userDetailsService);
            authenticationProvider.setPasswordEncoder(passwordEncoder);
    
            ProviderManager providerManager = new ProviderManager(authenticationProvider);
            providerManager.setEraseCredentialsAfterAuthentication(false);
            return providerManager;
        }
    
        @Bean
        public CorsConfigurationSource corsConfigurationSource() {
            CorsConfiguration configuration = new CorsConfiguration();
            configuration.setAllowedOrigins(List.of("http://localhost:3000"));
            configuration.setAllowedMethods(Arrays.asList("GET","POST", "PUT", "DELETE"));
            configuration.setAllowedHeaders(Collections.singletonList("*"));
            configuration.setAllowCredentials(true);
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            source.registerCorsConfiguration("/**", configuration);
            return source;
        }
    
        // Needed to prevent Spring Boot from automatically registering this filter in the regular filter chain. 
        @Bean
        public FilterRegistrationBean<LoginFilter> loginFilterRegistration(LoginFilter loginFilter) {
          FilterRegistrationBean<LoginFilter> registration = new FilterRegistrationBean<LoginFilter>(loginFilter);
          registration.setEnabled(false);
          return registration;
        }
    }
    

    This will add the filter to the chain.

    NOTE: It might be that you need to add some additional configuration to inject the proper session context etc. into the custom filter. I'm not sure if the filter will have the correct ones injected automatically.