spring-securityactive-directoryldapkerberosspnego

Why do POST requests not work using Spnego/Kerberos authentication with Spring Security Kerberos?


We have taken the sec-server-win-auth sample application from the Spring Security Kerberos documentation and extended it by a RestController. In this RestController we have defined some GET- and POST-mappings to handle the corresponding requests.
Additionally, we are using swagger to try out the requests.

After setting up an Active Directory server we can start up the application and upon opening the swagger-endpoint https://myserver.test.local/swagger-ui/index.html, the user is prompted with the Windows-login window. After authenticating, the swagger UI opens and one can try out the requests.

The GET requests are working fine, but after executing a POST request, the repsonse body contains the HTML-code from the login page, together with the message "Invalid username and password.":

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Spring Security Kerberos Example</title>
        <link rel="icon" href="data:,">
    </head>
    <body>
        <div>
            Invalid username and password.
        </div>
        
        <form action="/login" method="post">
            <div><label> User Name : <input type="text" name="username"/> </label></div>
            <div><label> Password: <input type="password" name="password"/> </label></div>
            <div><input type="submit" value="Sign In"/></div>
        </form>
    </body>
</html>

Repsonse Body

After digging and debugging we found, that during the spnego-authentication protocol, a request to /login is performed. This is not a problem if one is performing a GET request on e.g., /config, but if a POST request on /config is executed, the following happens

2024-05-08 11:30:13,508 [DEBUG|org.springframework.security.web.FilterChainProxy|FilterChainProxy] Securing POST /config
2024-05-08 11:30:13,509 [DEBUG|org.springframework.security.web.authentication.AnonymousAuthenticationFilter|AnonymousAuthenticationFilter] Set SecurityContextHolder to anonymous SecurityContext
2024-05-08 11:30:13,509 [DEBUG|org.springframework.security.web.savedrequest.HttpSessionRequestCache|HttpSessionRequestCache] Saved request https://myserver.test.local/config?continue to session
2024-05-08 11:30:13,510 [DEBUG|org.springframework.security.kerberos.web.authentication.SpnegoEntryPoint|SpnegoEntryPoint] Add header WWW-Authenticate:Negotiate to https://myserver.test.local/config, forward: /login
2024-05-08 11:30:13,515 [DEBUG|org.springframework.security.web.FilterChainProxy|FilterChainProxy] Securing POST /login
2024-05-08 11:30:13,517 [DEBUG|org.springframework.security.web.DefaultRedirectStrategy|DefaultRedirectStrategy] Redirecting to /login?error
2024-05-08 11:30:13,525 [DEBUG|org.springframework.security.web.FilterChainProxy|FilterChainProxy] Securing GET /login?error
2024-05-08 11:30:13,527 [DEBUG|org.springframework.security.web.FilterChainProxy|FilterChainProxy] Secured GET /login?error
2024-05-08 11:30:13,528 [DEBUG|org.springframework.web.servlet.DispatcherServlet|LogFormatUtils] GET "/login?error", parameters={masked}
2024-05-08 11:30:13,528 [DEBUG|org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping|AbstractHandlerMapping] Mapped to org.example.MainController#login()
2024-05-08 11:30:13,532 [DEBUG|org.springframework.web.servlet.DispatcherServlet|FrameworkServlet] Completed 200 OK
2024-05-08 11:30:13,532 [DEBUG|org.springframework.security.web.authentication.AnonymousAuthenticationFilter|AnonymousAuthenticationFilter] Set SecurityContextHolder to anonymous SecurityContext

For some reason, the AbstractLdapAuthenticationProvider::authenticate method gets called and throws a BadCredentialsException. Using the debugger we found out that the variables username and password in line 68 and 69 are empty strings. So the web-app thinks the user has entered bad credentials and responds with the login page, together with the message "Invalid username and password".
We suspect that the reason why the AbstractLdapAuthenticationProvider is called is because a POST request on /login was performed, which also happens if one clicks the login-button after entering username and password on the /login page.

It seems, that during the spnego-protocol, a request on the /login page is performed using the same HTTP-method as the initial request (we have also tried it with a DELETE).

Our question(s):

This is our WebSecurityConfig (modified from the sample):

/* imports omitted */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Autowired
    private SpringConfig config;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider = kerberosServiceAuthenticationProvider();
        ActiveDirectoryLdapAuthenticationProvider activeDirectoryLdapAuthenticationProvider = activeDirectoryLdapAuthenticationProvider();
        ProviderManager providerManager = new ProviderManager(kerberosServiceAuthenticationProvider, activeDirectoryLdapAuthenticationProvider);

        http
            .authorizeHttpRequests(authz -> authz
                .anyRequest()
                .authenticated()
            )
            .exceptionHandling(exceptionHandling -> exceptionHandling
                .authenticationEntryPoint(spnegoEntryPoint())
            )
            .formLogin(formLogin -> formLogin
                .loginPage(config.getActiveDirectoryLoginSerlvet())
                .permitAll()
            )
            .logout(logout -> logout
                .permitAll()
            )
            .authenticationProvider(activeDirectoryLdapAuthenticationProvider)
            .authenticationProvider(kerberosServiceAuthenticationProvider)
            .addFilterBefore(spnegoAuthenticationProcessingFilter(providerManager), BasicAuthenticationFilter.class)
            .csrf(csrf -> csrf
                .disable()
            );

        return http.build();
    }

    @Bean
    public ActiveDirectoryLdapAuthenticationProvider activeDirectoryLdapAuthenticationProvider() {
        return new ActiveDirectoryLdapAuthenticationProvider(config.getActiveDirectoryDomain(), config.getActiveDirectoryServer());
    }

    @Bean
    public SpnegoEntryPoint spnegoEntryPoint() {
        return new SpnegoEntryPoint(config.getActiveDirectoryLoginSerlvet());
    }

    // @Bean
    public SpnegoAuthenticationProcessingFilter spnegoAuthenticationProcessingFilter(AuthenticationManager authenticationManager) {
        SpnegoAuthenticationProcessingFilter filter = new SpnegoAuthenticationProcessingFilter();
        filter.setAuthenticationManager(authenticationManager);
        return filter;
    }

    public KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider() throws Exception {
        KerberosServiceAuthenticationProvider provider = new KerberosServiceAuthenticationProvider();
        provider.setTicketValidator(sunJaasKerberosTicketValidator());
        provider.setUserDetailsService(ldapUserDetailsService());
        return provider;
    }

    @Bean
    public SunJaasKerberosTicketValidator sunJaasKerberosTicketValidator() {
        SunJaasKerberosTicketValidator ticketValidator = new SunJaasKerberosTicketValidator();
        ticketValidator.setServicePrincipal(config.getActiveDirectoryServicePrincipal());
        ticketValidator.setKeyTabLocation(new FileSystemResource(config.getActiveDirectoryKeytabLocation()));
        ticketValidator.setDebug(true);
        return ticketValidator;
    }

    @Bean
    public KerberosLdapContextSource kerberosLdapContextSource() throws Exception {
        KerberosLdapContextSource contextSource = new KerberosLdapContextSource(config.getActiveDirectoryServer());
        contextSource.setLoginConfig(loginConfig());
        return contextSource;
    }

    public SunJaasKrb5LoginConfig loginConfig() throws Exception {
        SunJaasKrb5LoginConfig loginConfig = new SunJaasKrb5LoginConfig();
        loginConfig.setKeyTabLocation(new FileSystemResource(config.getActiveDirectoryKeytabLocation()));
        loginConfig.setServicePrincipal(config.getActiveDirectoryServicePrincipal());
        loginConfig.setDebug(true);
        loginConfig.setIsInitiator(true);
        loginConfig.afterPropertiesSet();
        return loginConfig;
    }

    @Bean
    public LdapUserDetailsService ldapUserDetailsService() throws Exception {
        FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch(config.getActiveDirectoryLdapSearchBase(), config.getActiveDirectoryLdapSearchFilter(), kerberosLdapContextSource());
        LdapUserDetailsService service = new LdapUserDetailsService(userSearch, new ActiveDirectoryLdapAuthoritiesPopulator());
        service.setUserDetailsMapper(new LdapUserDetailsMapper());
        return service;
    }

}

Thank you!

Edit: Added links to spring sample and to github repo of AuthenticatinoProvider


Solution

  • TLDR: Use the SpnegoEntryPoint() constructor instead of the SpnegoEntryPoint(String forwardUrl).


    After some more digging I have figured it out:

    In the sample the SpnegoEntryPoint is constructed with the SpnegoEntryPoint(String forwardUrl) constructur where forwardUrl = "/login". Since the initial request is a POST request and the SpnegoEntryPoint forwards to "/login" (see line 105 in SpnegoEntryPoint.java), the AbstractLdapAuthenticationProvider::authenticate gets called and throws the exception.
    If one uses the SpnegoEntryPoint() constructor without any arguments, the problem is solved completely and POST requests work as well.

    The only downside I have experienced so far is that, if one does not want to use Kerberos to authenticate against the app but standard LDAP password querying, he/she has to type in the "/login" endpoint explicitly, the redirection to this endpoint if one cancels the Windows-login window is turned off.