Search code examples
spring-bootauthenticationjdbctemplateremember-mespring-security-6

How to implement Persistent Token based remember me services in custom Authentication Filter Spring Security 6 / Spring Boot 3.1


I am trying to understand Spring Boot security properly. This is the last sticking point I have and the documentation lacks a lot of detail. All the other examples and questions are too old and not relevant to Spring security 6

Basically after setting up all the Beans according to the documentation here: https://docs.spring.io/spring-security/reference/servlet/authentication/rememberme.html#remember-me-persistent-token

I got the following exception: java.lang.NullPointerException: Cannot invoke "org.springframework.jdbc.core.JdbcTemplate.update(String, Object[])" because the return value of "org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl.getJdbcTemplate()" is null All the examples show Spring security without Spring Boot where I am not using XML configuration. It does say a datasource needs to be provided, but I have already done this in application.properties and I have already autowired JdbcTemplate in my custom DAO class, So how is this null or what else is the bug here I am not sure

I have created this table according to the docs:

CREATE TABLE IF NOT EXISTS users.persistent_logins(
    username VARCHAR,
    series VARCHAR NOT NULL,
    token VARCHAR NOT NULL,
    last_used TIMESTAMP NOT NULL,
    CONSTRAINT persistent_loginsPk PRIMARY KEY(series)
);

The authentication works perfectly, but no tokens have been set, I presume its because the jdbc template is null. Do I need to create a new data source just for this ? The JdbcTokenRepositoryImpl doesn't even have a method or constructor that accepts a data source

but basically here is the configuration I have:

spring.application.name=FilterJsonPostAuthentication
server.port=8082

spring.datasource.url=jdbc:postgresql://localhost:5432/MythicalLearn

# Session variables for testing remember me:
server.servlet.session.timeout=3m


@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private MuserDetailsService muserDetailsService;

    private final String rememberMeKey = "Mama-is-the-best!";

    /**
     *
     * @param http
     * @return
     * @throws Exception
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        /**
         * Adding the Username, Password Authentication filter
         */
        http.addFilterAt(getAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterAfter(getRememberMeAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        /**
         * Here i setup the remember me services:
         */
        http.rememberMe(config -> {
            config.key(rememberMeKey);
            config.tokenValiditySeconds(1209600);
            config.rememberMeServices(getRememberMeServices());
        });

        /**
         * Setting up logout:
         * This worked perfectly!
         */
        http.logout(logout -> {
            logout.logoutSuccessUrl("/loggedout");
            logout.logoutUrl("/logout");
        });

        /**
         * This returns the Security filter chain
         */
        return http.build();
    }
    
    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
        return roleHierarchy;
    }

    @Bean
    public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy());
        return expressionHandler;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    public AuthenticationManager getAuthenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(muserDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(daoAuthenticationProvider, getRememberMeAuthenticationProvider());
    }

    @Bean
    public AbstractAuthenticationProcessingFilter getAuthenticationFilter() {
        AbstractAuthenticationProcessingFilter filter = new MyUsernamePasswordAuthenticationFilter(
            new AntPathRequestMatcher("/login", "POST"), getAuthenticationManager()
        );
        filter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
        filter.setRememberMeServices(getRememberMeServices());

        return filter;
    }

    @Bean
    public RememberMeServices getRememberMeServices() {

        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        PersistentTokenBasedRememberMeServices rememberMeServices = new PersistentTokenBasedRememberMeServices(
            rememberMeKey, muserDetailsService, jdbcTokenRepository
        );

        return rememberMeServices;
    }

    @Bean
    public RememberMeAuthenticationFilter getRememberMeAuthenticationFilter() {
        return new RememberMeAuthenticationFilter(
            getAuthenticationManager(),
            getRememberMeServices()
        );
    }

    @Bean
    public RememberMeAuthenticationProvider getRememberMeAuthenticationProvider() {
        return new RememberMeAuthenticationProvider(rememberMeKey);
    }

}

My custom filter because I want to be able to send JSON as the post body instead of url encoded:

public class MyUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    public MyUsernamePasswordAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher, AuthenticationManager authenticationManager) {
        super(requiresAuthenticationRequestMatcher, authenticationManager);
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        Map<String, String> usernamePassword = new HashMap<String, String>();

        try {
            usernamePassword = new ObjectMapper().readValue(request.getInputStream(), Map.class);
        }catch (IOException e) {
            throw new AuthenticationServiceException(e.getMessage(), e);
        }

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            usernamePassword.get("username"), usernamePassword.get("password")
        );

        return this.getAuthenticationManager().authenticate(authRequest);

    }

}

All other aspects of security, user details service, database access, role based heirarchy is working perfectly so I have not added all that code here. Happy to add anything that is necessary. Any advice is greatly appreciated


Solution

  • This might help anyone who wants to implement remember me when a user logs in with a JSON form for example if we are using a JavaScript Single page app front end.

    Spring's remember me is designed to work with the username and password authentication and it requires a request parameter remember-me. I am sure the name could be configured but it expects something that can come from request.getParameter method or alwaysRemember would need to be set which is not flexible and the way I see it, its not ideal.

    The loginSuccess method internally calls the onLoginSuccess method which checks for this parameter.

    The easiest work around is to simply create your own Remember me implementation that extends the PersistentTokenBasedRememberMeServices. The trick is to use the parent's onLoginSuccess method where the PersistentTokenBasedRememberMeServices already has a concrete implementation, I just need to work around the condition that checks for that parameter.

    First I create my remember me services:

    public class MyRememberMeServices extends PersistentTokenBasedRememberMeServices {
    
        private LoginRequest loginRequest;
    
        private boolean rememberMeEnabled = false;
    
        public MyRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
            super(key, userDetailsService, tokenRepository);
        }
    
        public void setLoginRequest(LoginRequest loginRequest) {
            this.loginRequest = loginRequest;
        }
    
        public void setRememberMeEnabled(boolean rememberMeEnabled) {
            this.rememberMeEnabled = rememberMeEnabled;
        }
    
        /**
         * This method overrides the parent class and calls its onLoginSuccess in order to execute it,
         * When I create my own starter, I will do this properly
         * @param request that contained the valid authentication request
         * @param response to change, cancel or modify the remember-me token
         * @param successfulAuthentication representing the successfully authenticated
         * principal
         */
        @Override
        public void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
            if (rememberMeEnabled) {
                super.onLoginSuccess(request, response, successfulAuthentication);
            }
        }
    
    }
    

    Then I set the remember me in the filter:

    @Slf4j
    public class MyUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
        public MyUsernamePasswordAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher, AuthenticationManager authenticationManager) {
            super(requiresAuthenticationRequestMatcher, authenticationManager);
        }
    
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
            LoginRequest loginRequest = new LoginRequest();
    
            try {
                loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
    
            }catch (IOException e) {
                throw new AuthenticationServiceException(e.getMessage(), e);
            }
    
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                loginRequest.username, loginRequest.password
            );
    
            Authentication authResult = getAuthenticationManager().authenticate(authRequest);
    
            MyRememberMeServices rememberMeServices = (MyRememberMeServices) this.getRememberMeServices();
    
            //This is a hacky work around, When I do my own remember me for some reason it always sets it ?
            rememberMeServices.setLoginRequest(loginRequest);
            if (loginRequest.rememberMe == true) {
                rememberMeServices.setRememberMeEnabled(true);
                rememberMeServices.loginSuccess(request, response, authResult);
            }
    
            return authResult;
    
        }
    
    }
    

    To set it in the configuration:

    @Configuration
    @EnableWebSecurity(debug = true)
    @EnableMethodSecurity
    public class SecurityConfig {
    
        @Value("${rememberMeKey}")
        private String remembermeKey;
    
        @Autowired
        private MuserDetailsService muserDetailsService;
    
        @Autowired
        private DataSource dataSource;
    
        /**
         * First I create the SecurityFilter
         * @return
         */
        @Bean
        public SecurityFilterChain getSecurityFilterChain(HttpSecurity http) throws Exception {
    
            http.addFilterAt(
                    getAuthenticationFilter(),
                    UsernamePasswordAuthenticationFilter.class
                );
    
            http.logout(logout -> {
                logout.logoutSuccessUrl("/loggedout");
                logout.logoutUrl("/logout");
                logout.deleteCookies("remember-me");
            });
    
            return http.build();
        }
    
        /**
         * I am creating the filter here,
         * This is important, as it allows the HttpSession to work
         * @return
         * @throws Exception
         */
        @Bean
        public MyUsernamePasswordAuthenticationFilter getAuthenticationFilter() throws Exception {
            var authenticationFilter = new MyUsernamePasswordAuthenticationFilter(
                new AntPathRequestMatcher("/login", HttpMethod.POST.name()),
                getAuthenticationManager()
            );
            authenticationFilter.setSecurityContextRepository(
                new HttpSessionSecurityContextRepository()
            );
    
            authenticationFilter.setRememberMeServices(getRememberMeServices());
            authenticationFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
    
            return authenticationFilter;
        }
    
        @Bean
        public AuthenticationManager getAuthenticationManager() throws Exception {
            DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
            daoAuthenticationProvider.setUserDetailsService(muserDetailsService);
            daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
            return new ProviderManager(daoAuthenticationProvider);
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return NoOpPasswordEncoder.getInstance();
        }
    
        @Bean
        public RoleHierarchy roleHierarchy() {
            RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
            roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
            return roleHierarchy;
        }
    
        @Bean
        public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
            DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
            expressionHandler.setRoleHierarchy(roleHierarchy());
            return expressionHandler;
        }
    
        @Bean
        public RememberMeServices getRememberMeServices() {
    
            var rememberMeServices = new MyRememberMeServices(
                remembermeKey,
                muserDetailsService,
                getPersistentTokenRepository()
            );
            rememberMeServices.setAlwaysRemember(false);
            return rememberMeServices;
        }
    
        @Bean
        public PersistentTokenRepository getPersistentTokenRepository() {
            var tokenRepository = new JdbcTokenRepositoryImpl();
            tokenRepository.setDataSource(dataSource);
            return tokenRepository;
        }
    
    }