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

Spring Security SAML登出失败:Saml2LogoutResponseFilter验证错误及404问题

SAML2登出回调404及Destination验证失败问题解决

我们实现了简单的SAML2登出流程,用户在IdP完成登出后,回调应用销毁会话时出现HTTP 404错误,开启调试日志后发现如下错误:

2023-02-03 14:42:37.845 DEBUG 1 --- [p-nio-80-exec-2] .s.s.p.s.w.a.l.Saml2LogoutResponseFilter : Failed to validate LogoutResponse: [[invalid_destination] Failed to match destination to configured destination]

相关配置代码

import org.opensaml.xmlsec.signature.support.SignatureConstants;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.web.SecurityFilterChain;

import java.security.PrivateKey;
import java.security.cert.X509Certificate;

import static org.springframework.security.config.Customizer.withDefaults;

@Profile("!local")
@EnableWebSecurity
@Configuration
public class Saml2WebSecurityConfig {

    private static final String LOGOUT_CALLBACK_URL = "/logout/saml2/slo";
    private final EIAMConfigProperties eiamConfigProperties;

    public Saml2WebSecurityConfig(EIAMConfigProperties eiamConfigProperties) {
        this.eiamConfigProperties = eiamConfigProperties;
    }

    @Bean
    public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() throws Exception {
        final X509Certificate[] cert = ...;
        final PrivateKey privateKey = ...;
        final X509Certificate[] verificationCertificate = ...;

        Saml2X509Credential signingCredential = Saml2X509Credential.signing(privateKey, cert[0]);
        Saml2X509Credential verificationCredential = Saml2X509Credential.verification(verificationCertificate[0]);

        RelyingPartyRegistration registration = RelyingPartyRegistration
                .withRegistrationId(eiamConfigProperties.getRegistrationId())
                .entityId(eiamConfigProperties.getEntity())
                .signingX509Credentials(c -> c.add(signingCredential))
                .assertingPartyDetails(party -> party.entityId(eiamConfigProperties.getAssertionId())
                        .singleSignOnServiceLocation(eiamConfigProperties.getSingleSignOnServiceLocation())
                        .singleSignOnServiceBinding(Saml2MessageBinding.POST)
                        .singleLogoutServiceLocation(eiamConfigProperties.getSingleLogoutServiceLocation())
                        .singleLogoutServiceBinding(Saml2MessageBinding.POST)
                        .wantAuthnRequestsSigned(true)
                        .signingAlgorithms(sign -> sign.add(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256))
                        .verificationX509Credentials(c -> c.add(verificationCredential))
                )
                .build();
        return new InMemoryRelyingPartyRegistrationRepository(registration);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated())
                .saml2Login(withDefaults())
                .saml2Logout((saml2) -> saml2
                        .logoutRequest((request) -> request.logoutUrl(LOGOUT_CALLBACK_URL))
                        .logoutResponse((response) -> response.logoutUrl(LOGOUT_CALLBACK_URL))
                );

        http.csrf().disable();
        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers(LOGOUT_CALLBACK_URL);
    }
}

问题分析

这里存在两个核心问题:

  1. WebSecurity忽略了登出回调URL:配置中把/logout/saml2/slo加入了忽略列表,导致Spring Security的SAML2登出过滤器无法处理该请求,直接返回404。
  2. Destination地址不匹配:IdP返回的LogoutResponse中的Destination地址,与Relying Party配置的单登出回调地址不一致,触发验证失败。

修复步骤

1. 移除对登出回调URL的忽略

删除WebSecurityCustomizer中对LOGOUT_CALLBACK_URL的忽略配置,让Spring Security的SAML2过滤器可以处理该请求:

// 移除LOGOUT_CALLBACK_URL的忽略配置,保留其他需要忽略的资源(如静态文件)
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring().requestMatchers("/static/**"); // 示例:仅忽略静态资源
}

2. 配置正确的SP单登出回调地址

在RelyingPartyRegistration中明确配置SP的单登出服务位置,确保和IdP后台配置的回调地址完全一致(包括协议、域名、端口和路径):

RelyingPartyRegistration registration = RelyingPartyRegistration
        .withRegistrationId(eiamConfigProperties.getRegistrationId())
        .entityId(eiamConfigProperties.getEntity())
        // 新增SP单登出回调地址,需与IdP配置完全一致
        .singleLogoutServiceLocation("https://your-app-domain.com/logout/saml2/slo")
        .singleLogoutServiceBinding(Saml2MessageBinding.POST)
        .signingX509Credentials(c -> c.add(signingCredential))
        .assertingPartyDetails(party -> party.entityId(eiamConfigProperties.getAssertionId())
                .singleSignOnServiceLocation(eiamConfigProperties.getSingleSignOnServiceLocation())
                .singleSignOnServiceBinding(Saml2MessageBinding.POST)
                .singleLogoutServiceLocation(eiamConfigProperties.getSingleLogoutServiceLocation())
                .singleLogoutServiceBinding(Saml2MessageBinding.POST)
                .wantAuthnRequestsSigned(true)
                .signingAlgorithms(sign -> sign.add(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256))
                .verificationX509Credentials(c -> c.add(verificationCredential))
        )
        .build();

注意:如果应用部署在反向代理后,需确保Spring Security能正确获取外部请求地址,可通过配置server.forward-headers-strategy=NATIVE或在RelyingPartyRegistration中直接使用完整URL。

3. 简化SAML2登出配置

SecurityFilterChain中的saml2Logout无需手动指定logoutUrl,默认会使用RelyingPartyRegistration中配置的singleLogoutServiceLocation:

http.authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated())
        .saml2Login(withDefaults())
        .saml2Logout(withDefaults());

4. 自定义会话销毁逻辑(可选)

如果需要自定义登出后的会话处理逻辑,可通过LogoutSuccessHandler实现:

.saml2Logout(saml2 -> saml2
        .logoutSuccessHandler((request, response, authentication) -> {
            // 销毁当前会话
            request.getSession().invalidate();
            // 重定向到自定义登出成功页面
            response.sendRedirect("/logout-success");
            // 其他自定义操作,比如清除缓存、记录登出日志等
        })
)

内容的提问来源于stack exchange,提问作者marco

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.02 13:21:24