spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.78k stars 5.89k forks source link

Spring saml does not send logout request to IDP #13965

Closed EmanuelCozariz closed 1 year ago

EmanuelCozariz commented 1 year ago

I'm using spring security saml2, version 5.7.6, ADFS as Identity Provider According to spring documentation, upon normal Spring /logout, initiated by the user, spring is supposed to send a LogoutRequest, from service provider to IDP

But the logout request sent to ADFS Identity Provider is not sent. Moreover, Saml2LogoutRequest is only created by the OpenSaml3LogoutRequestResolver -> that is in turn invoked by the Saml2RelyingPartyInitiatedLogoutSuccessHandler Do we need to add this Saml2RelyingPartyInitiatedLogoutSuccessHandler to the standard spring logout filter?

Configuration, according to spring documentation:

http.authorizeHttpRequests(). ...
                .and().authorizeHttpRequests().antMatchers("/**").authenticated() //

                .and().authorizeHttpRequests().
                                       antMatchers("/saml2/service-provider-metadata/{registrationId}").permitAll() //
                .and().authorizeHttpRequests().anyRequest().authenticated() //
                .and().csrf().disable() //
                .saml2Login(saml2 -> saml2.relyingPartyRegistrationRepository(relyingPartyRegistrationRepository) //
                        .authenticationConverter(saml2AuthenticationTokenConverter)
                        .authenticationManager(new ProviderManager(smartkycSamlAuthenticationProvider))) //

                .saml2Logout(saml2 -> saml2.logoutRequest(request -> request.logoutUrl("/logout/saml2/slo/{registrationId}"))
                        .logoutResponse(response -> response.logoutUrl("/"))) //        

                                .addFilterBefore(saml2MetadataFilter, Saml2WebSsoAuthenticationFilter.class)    

                                .logout(logout -> logout.logoutUrl("/saml/logout"));

Relying party registration is configured like this:


        final RelyingPartyRegistration.Builder relyingPartyRegistrationBuilder = RelyingPartyRegistrations //
                .fromMetadataLocation(authenticationConfiguration.getIdentityProviderMetadataFilePath()) //
                .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo") //
                .registrationId(REG_ID);

        final KeyStore keyStore = authenticationConfiguration.getKeyStore();
        if (keyStore != null) {
            final PrivateKey key = (PrivateKey) keyStore.getKey(authenticationConfiguration.getKeyStoreSaml2KeyAlias(),
                    authenticationConfiguration.getKeystoreSaml2KeyPasswordAsText().toCharArray());
            final X509Certificate cert = (X509Certificate) keyStore
                    .getCertificate(authenticationConfiguration.getKeyStoreSaml2KeyAlias());

            relyingPartyRegistrationBuilder.signingX509Credentials(c -> c.add(Saml2X509Credential.signing(key, cert))) //
                    .decryptionX509Credentials(c -> c.add(Saml2X509Credential.decryption(key, cert)));
        }

        return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistrationBuilder.build());
marcusdacoregio commented 1 year ago

Hi, @EmanuelCozariz, are you able to compare your configuration with the one present in this sample? It would also be useful to add logging.level.org.springframework.security=TRACE to your application.properties and check the console.

After doing those steps, is it still a problem? Can you provide a minimal, reproducible sample where we can check? You can use the same Okta URLs from the sample that I linked above.

EmanuelCozariz commented 1 year ago

Still a problem yes, even after i've applied your above suggestions. Full configuration class sample:


@EnableWebSecurity
@Slf4j
@Configuration
public class obfuscate_this_SAML2Configuration
{

    public static final String obfuscate_this__REG_ID = "obfuscate_this_RegId";

    @Bean
    public Saml2SecurityConfiguration authenticationConfiguration(final ConfigurationSectionsRepository confSecRepo)
    {
        return Saml2SecurityConfiguration.build(confSecRepo);
    }

    @Bean
    public RelyingPartyRegistrationResolver relyingPartyRegistrationResolver(
            final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository)
    {
        return new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository);
    }

    @Bean
    public obfuscate_this_Resolver openSaml3LogoutRequestResolver(final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver)
    {
        return new obfuscate_this_Resolver(new OpenSaml3LogoutRequestResolver(relyingPartyRegistrationResolver));
    }

    @Bean
    public SecurityFilterChain filterChain(final HttpSecurity http,
            final OpenSamlAuthenticationProvider obfuscate_this_SamlAuthenticationProvider,
            final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository,
            final obfuscate_this_AuthenticationTokenConverter saml2AuthenticationTokenConverter,
            final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver,
            final obfuscate_this_Resolver openSaml3LogoutRequestResolver) throws Exception
    {
        final Saml2MetadataFilter saml2MetadataFilter = new Saml2MetadataFilter(relyingPartyRegistrationResolver,
                new OpenSamlMetadataResolver());
        saml2MetadataFilter.setRequestMatcher(new AntPathRequestMatcher("/saml2/service-provider-metadata/{registrationId}"));

        final Saml2RelyingPartyInitiatedLogoutSuccessHandler logoutHandler = new Saml2RelyingPartyInitiatedLogoutSuccessHandler(
                openSaml3LogoutRequestResolver);

        final RelyingPartyRegistration.AssertingPartyDetails idpDetails = relyingPartyRegistrationRepository
                .findByRegistrationId(obfuscate_this__REG_ID).getAssertingPartyDetails();

        http.authorizeHttpRequests().antMatchers("/version.txt").permitAll() //
                .and().authorizeHttpRequests().antMatchers("/**").authenticated() //
                .and().authorizeHttpRequests().antMatchers("/saml2/service-provider-metadata/{registrationId}").permitAll() //
                .and().authorizeHttpRequests().anyRequest().authenticated() //
                .and().csrf().disable() //
                .saml2Login(saml2 -> saml2.relyingPartyRegistrationRepository(relyingPartyRegistrationRepository) //
                        .authenticationConverter(saml2AuthenticationTokenConverter)
                        .authenticationManager(new ProviderManager(obfuscate_this_SamlAuthenticationProvider))) //
                .saml2Logout(Customizer.withDefaults()) //
                .addFilterBefore(saml2MetadataFilter, Saml2WebSsoAuthenticationFilter.class)
                .logout(logout -> logout.logoutUrl("/saml/logout"));

        return http.build();
    }

    @Bean
    public Saml2LogoutResponseResolver logoutResponseResolver(final RelyingPartyRegistrationResolver registrationResolver)
    {
        final OpenSaml3LogoutResponseResolver logoutRequestResolver = new OpenSaml3LogoutResponseResolver(registrationResolver);
        logoutRequestResolver.setParametersConsumer(
                parameters -> parameters.getLogoutResponse().getStatus().getStatusCode().setValue(StatusCode.SUCCESS));
        return logoutRequestResolver;
    }

    @Bean
    public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(
            final Saml2SecurityConfiguration authenticationConfiguration)
            throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException
    {
        final RelyingPartyRegistration.Builder relyingPartyRegistrationBuilder = RelyingPartyRegistrations //
                .fromMetadataLocation(authenticationConfiguration.getIdentityProviderMetadataFilePath()) //
                .singleLogoutServiceLocation("https://aaaa.obfuscate_this_.com/adfs/ls/") //
                .singleLogoutServiceResponseLocation("{baseUrl}/logout/saml2/slo")
                .singleLogoutServiceBinding(Saml2MessageBinding.POST)//
                .registrationId(obfuscate_this__REG_ID);

        final KeyStore keyStore = authenticationConfiguration.getKeyStore();
        if (keyStore != null) {
            final PrivateKey key = (PrivateKey) keyStore.getKey(authenticationConfiguration.getKeyStoreSaml2KeyAlias(),
                    authenticationConfiguration.getKeystoreSaml2KeyPasswordAsText().toCharArray());
            final X509Certificate cert = (X509Certificate) keyStore
                    .getCertificate(authenticationConfiguration.getKeyStoreSaml2KeyAlias());

            relyingPartyRegistrationBuilder.signingX509Credentials(c -> c.add(Saml2X509Credential.signing(key, cert))) //
                    .decryptionX509Credentials(c -> c.add(Saml2X509Credential.decryption(key, cert)));
        }

        return new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistrationBuilder.build());
    }

    @Bean
    public OpenSamlAuthenticationProvider obfuscate_this_SamlAuthenticationProvider(final LdapRolesExtractor ldapRolesExtractor,
            final obfuscate_this_Resolver openSaml3LogoutRequestResolver)
    {
        final OpenSamlAuthenticationProvider obfuscate_this_SamlAuthenticationProvider = new OpenSamlAuthenticationProvider();
        obfuscate_this_SamlAuthenticationProvider.setResponseAuthenticationConverter(responseToken -> {
            final Assertion assertion = responseToken.getResponse().getAssertions().get(0);
            final String username = assertion.getSubject().getNameID().getValue();

            final List<String> attributes = assertion.getAttributeStatements().stream().flatMap(a -> a.getAttributes().stream()) //
                    .flatMap(a -> a.getAttributeValues().stream()).filter(XSAnyImpl.class::isInstance)
                    .map(a -> ((XSAnyImpl) a).getTextContent()).collect(Collectors.toList());
            final List<SimpleGrantedAuthority> grantedAuthorities = ldapRolesExtractor.extractRolesFromLdapDNs(username,
                    attributes);

            final obfuscate_this_SAML2AuthenticationToken authToken = new obfuscate_this_SAML2AuthenticationToken(username, grantedAuthorities);
            openSaml3LogoutRequestResolver.setAuthentication(
                    OpenSamlAuthenticationProvider.createDefaultResponseAuthenticationConverter().convert(responseToken));
            return authToken;
        });

        return obfuscate_this_SamlAuthenticationProvider;
    }

    @Bean
    obfuscate_this_AuthenticationTokenConverter saml2AuthenticationTokenConverter(
            final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository)
    {
        final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver(
                relyingPartyRegistrationRepository);
        final Saml2AuthenticationTokenConverter saml2AuthenticationTokenConverter = new Saml2AuthenticationTokenConverter(
                relyingPartyRegistrationResolver);
        return new obfuscate_this_AuthenticationTokenConverter(saml2AuthenticationTokenConverter, relyingPartyRegistrationResolver);
    }

}
EmanuelCozariz commented 1 year ago

My question would be: where in the spring saml implementation, does this happen: "spring sends a LogoutRequest, from service provider to IDP"

I would then debug and see calling class hierarchy, then I would understand why this request is not sent, by service provider.

marcusdacoregio commented 1 year ago

If you do not change the URL in Saml2LogoutConfigurer#logoutUrl, Spring Security will create a logout filter for SAML 2.0 Relying Party Initiated Logout and place it in the filter chain. Using the default configuration, I'd expect that a POST /logout would hit that filter.

EmanuelCozariz commented 1 year ago

Right, that just customizes the logout endpoint. Fine, I removed the line logout(logout -> logout.logoutUrl("/saml/logout")); So now logout is on /logout

But that does not make spring send the logout request to ADFS. Spring logout only happens on spring side, not on Identity provider side: image

Previous saml spring implementation had these 2 filters that would send a request to IDP: SAMLLogoutFilter - sends request to IDP SAMLLogoutProcessingFilter - processes response from IDP

What is the equivalent of SAMLLogoutFilter in the new spring version?

EmanuelCozariz commented 1 year ago

Resolution: new spring saml version expects a POST /logout. I was sending a GET. The configuration is done in Saml2LogoutConfigurer createRelyingPartyLogoutFilter The createLogoutMatcher hardcodes the httpMethod, i don't think there is a way to change this value.

Solution: I needed to create my own filter, and add it to the httpSecurity object: addFilterBefore(createCustomRelyingPartyLogoutFilter(...)) - this will accept a GET /logout

jradeskic commented 10 months ago

Can I ask you how do you handle global signOut initiated from the IDP party and from seperate http session?

Faisul commented 3 months ago

Resolution: new spring saml version expects a POST /logout. I was sending a GET. The configuration is done in Saml2LogoutConfigurer createRelyingPartyLogoutFilter The createLogoutMatcher hardcodes the httpMethod, i don't think there is a way to change this value.

Solution: I needed to create my own filter, and add it to the httpSecurity object: addFilterBefore(createCustomRelyingPartyLogoutFilter(...)) - this will accept a GET /logout

did this solution work? it seems like Saml2RelyingPartyInitiatedLogoutFilter is a private class and cannot be overrided/configured from an external class