执行单点登出

除了其其他登出机制外,Spring Security 还支持由 RP(依赖方)和 AP(声明方)发起的 SAML 2.0 单点登出。

简而言之,Spring Security 支持两种使用场景:

  • 由 RP 发起 - 您的应用有一个端点,当接收到 POST 请求时,会使用户登出并向声明方发送一个 saml2:LogoutRequest。之后,声明方会回送一个 saml2:LogoutResponse 并允许您的应用做出响应。

  • 由 AP 发起 - 您的应用有一个端点,用于接收来自声明方的 saml2:LogoutRequest。您的应用将在此时完成其登出,然后向声明方发送一个 saml2:LogoutResponse

由 AP 发起的场景中,您的应用在登出后可能进行的任何本地重定向都变得毫无意义。一旦您的应用发送了 saml2:LogoutResponse,它将不再控制浏览器。

单点登出的最小配置

要使用 Spring Security 的 SAML 2.0 单点登出特性,您需要以下事项:

  • 首先,声明方必须支持 SAML 2.0 单点登出。

  • 其次,声明方应配置为签署并 POST saml2:LogoutRequestsaml2:LogoutResponse 到您的应用的 /logout/saml2/slo 端点。

  • 第三,您的应用必须拥有 PKCS#8 私钥和 X.509 证书,用于签署 saml2:LogoutRequestsaml2:LogoutResponse

在 Spring Boot 中,您可以通过以下方式实现这一点:

spring:
  security:
    saml2:
      relyingparty:
        registration:
          metadata:
            signing.credentials: (3)
              - private-key-location: classpath:credentials/rp-private.key
                certificate-location: classpath:credentials/rp-certificate.crt
            singlelogout.url: "{baseUrl}/logout/saml2/slo" (2)
            assertingparty:
              metadata-uri: https://ap.example.com/metadata (1)
1 - IDP 的元数据 URI,它将向您的应用指示其对 SLO 的支持。
2 - 您的应用中的 SLO 端点。
3 - 用于签署 <saml2:LogoutRequest><saml2:LogoutResponse> 的签名凭证。
An asserting party supports Single Logout if their metadata includes the `<SingleLogoutService>` element in their metadata.

就这么简单!

Spring Security 的登出支持提供了多个配置点。请考虑以下使用场景:

启动时的预期

当使用这些属性时,除了登录之外,SAML 2.0 服务提供者将自动配置自身,以便通过使用 RP 发起或 AP 发起的登出方式,利用 <saml2:LogoutRequest><saml2:LogoutResponse> 来促进登出。

它通过确定性的启动过程来实现这一点:

  1. 查询身份服务器元数据端点以获取 <SingleLogoutService> 元素。

  2. 扫描元数据并缓存所有公钥签名验证密钥。

  3. 准备相应的端点。

这个过程的一个结果是,身份服务器必须处于运行状态并接收请求,服务提供者才能成功启动。

如果身份服务器在服务提供者查询时宕机(在适当的超时设置下),那么启动将会失败。

运行时预期

根据上述配置,任何已登录用户都可以向您的应用发送 POST /logout 请求以执行 RP 发起的 SLO。您的应用随后将执行以下操作:

  1. 登出用户并使会话失效。

  2. 生成一个 <saml2:LogoutRequest> 并 POST 到关联声明方的 SLO 端点。

  3. 然后,如果声明方用 <saml2:LogoutResponse> 响应,应用将验证它并重定向到配置的成功端点。

此外,当声明方向 /logout/saml2/slo 发送 <saml2:LogoutRequest> 时,您的应用可以参与 AP 发起的登出。发生这种情况时,您的应用将执行以下操作:

  1. 验证 <saml2:LogoutRequest>

  2. 登出用户并使会话失效。

  3. 生成一个 <saml2:LogoutResponse> 并 POST 回声明方的 SLO 端点。

无 Boot 的最小配置

除了使用 Boot 属性,您还可以通过直接发布 bean 来实现相同的结果,如下所示:

  • Java

  • Kotlin

@Configuration
public class SecurityConfig {
    @Value("${private.key}") RSAPrivateKey key;
    @Value("${public.certificate}") X509Certificate certificate;

    @Bean
    RelyingPartyRegistrationRepository registrations() {
        Saml2X509Credential credential = Saml2X509Credential.signing(key, certificate);
        RelyingPartyRegistration registration = RelyingPartyRegistrations
                .fromMetadataLocation("https://ap.example.org/metadata") (1)
                .registrationId("metadata")
                .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo") (2)
                .signingX509Credentials((signing) -> signing.add(credential)) (3)
                .build();
        return new InMemoryRelyingPartyRegistrationRepository(registration);
    }

    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests((authorize) -> authorize
                .anyRequest().authenticated()
            )
            .saml2Login(withDefaults())
            .saml2Logout(withDefaults()); (4)

        return http.build();
    }
}
@Configuration
class SecurityConfig(@Value("${private.key}") val key: RSAPrivateKey,
        @Value("${public.certificate}") val certificate: X509Certificate) {

    @Bean
    fun registrations(): RelyingPartyRegistrationRepository {
        val credential = Saml2X509Credential.signing(key, certificate)
        val registration = RelyingPartyRegistrations
                .fromMetadataLocation("https://ap.example.org/metadata") (1)
                .registrationId("metadata")
                .singleLogoutServiceLocation("{baseUrl}/logout/saml2/slo") (2)
                .signingX509Credentials({ signing: List<Saml2X509Credential> -> signing.add(credential) }) (3)
                .build()
        return InMemoryRelyingPartyRegistrationRepository(registration)
    }

    @Bean
    fun web(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                anyRequest = authenticated
            }
            saml2Login {

            }
            saml2Logout { (4)

            }
        }

        return http.build()
    }
}
1 - IDP 的元数据 URI,它将向您的应用指示其对 SLO 的支持。
2 - 您的应用中的 SLO 端点。
3 - 用于签署 <saml2:LogoutRequest><saml2:LogoutResponse> 的签名凭证,您也可以将其添加到多个依赖方
4 - 其次,表明您的应用希望使用 SAML SLO 来登出最终用户。
添加 saml2Logout 为您的整个服务提供者增加了登出功能。由于这是一项可选功能,您需要为每个单独的 RelyingPartyRegistration 启用它。您可以通过如上所示设置 RelyingPartyRegistration.Builder#singleLogoutServiceLocation 属性来实现这一点。

SAML 2.0 登出工作原理

接下来,让我们看看 Spring Security 在基于 Servlet 的应用中(就像我们刚刚看到的)支持 SAML 2.0 登出所使用的架构组件。

对于 RP 发起的登出:

number 1 Spring Security 执行其登出流程,调用其 LogoutHandler 以使会话失效并执行其他清理工作。然后它调用 Saml2RelyingPartyInitiatedLogoutSuccessHandler

number 2 登出成功处理器使用 Saml2LogoutRequestResolver 的一个实例来创建、签名和序列化一个 <saml2:LogoutRequest>。它使用与当前 Saml2AuthenticatedPrincipal 关联的 RelyingPartyRegistration 中的密钥和配置。然后,它将 <saml2:LogoutRequest> 通过重定向的方式 POST 到声明方的 SLO 端点。

浏览器将控制权交给声明方。如果声明方重定向回来(它可能不会),则应用继续执行步骤 number 3

number 3 Saml2LogoutResponseFilter 使用其 Saml2LogoutResponseValidator 反序列化、验证和处理 <saml2:LogoutResponse>

number 4 如果有效,则它通过重定向到 /login?logout 或任何已配置的地址来完成本地登出流程。如果无效,则响应 400。

对于 AP 发起的登出:

number 1 Saml2LogoutRequestFilter 使用其 Saml2LogoutRequestValidator 反序列化、验证和处理 <saml2:LogoutRequest>

number 2 如果有效,则过滤器调用配置的 LogoutHandler,使会话失效并执行其他清理工作。

number 3 它使用 Saml2LogoutResponseResolver 来创建、签名和序列化一个 <saml2:LogoutResponse>。它使用从端点或 <saml2:LogoutRequest> 内容派生的 RelyingPartyRegistration 中的密钥和配置。然后,它将 <saml2:LogoutResponse> 通过重定向的方式 POST 到声明方的 SLO 端点。

浏览器将控制权交给声明方。

number 4 如果无效,则它响应 400

配置登出端点

可以通过不同的端点触发三种行为:

  • RP 发起的登出,它允许已认证用户发送 POST 请求,并通过向声明方发送 <saml2:LogoutRequest> 来触发登出过程。

  • AP 发起的登出,它允许声明方向应用发送 <saml2:LogoutRequest>

  • AP 登出响应,它允许声明方响应 RP 发起的 <saml2:LogoutRequest>,发送一个 <saml2:LogoutResponse>

第一种行为在主体类型为 Saml2AuthenticatedPrincipal 时,通过执行正常的 POST /logout 来触发。

第二种行为是通过 POST 一个由声明方签名的 SAMLRequest/logout/saml2/slo 端点来触发。

第三种行为是通过 POST 一个由声明方签名的 SAMLResponse/logout/saml2/slo 端点来触发。

由于用户已经登录或原始登出请求已知,registrationId 已知。因此,默认情况下 {registrationId} 不是这些 URL 的一部分。

这个 URL 可以在 DSL 中定制。

例如,如果您正在将现有的依赖方迁移到 Spring Security,您的声明方可能已经指向 GET /SLOService.saml2。为了减少声明方的配置变更,您可以在 DSL 中配置过滤器,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutRequest((request) -> request.logoutUrl("/SLOService.saml2"))
        .logoutResponse((response) -> response.logoutUrl("/SLOService.saml2"))
    );
http {
    saml2Logout {
        logoutRequest {
            logoutUrl = "/SLOService.saml2"
        }
        logoutResponse {
            logoutUrl = "/SLOService.saml2"
        }
    }
}

您还应该在您的 RelyingPartyRegistration 中配置这些端点。

此外,您还可以自定义在本地触发登出的端点,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2.logoutUrl("/saml2/logout"));
http {
    saml2Logout {
        logoutUrl = "/saml2/logout"
    }
}

分离本地登出和 SAML 2.0 登出

在某些情况下,您可能希望暴露一个端点用于本地登出,另一个用于 RP 发起的 SLO。与其他登出机制一样,您可以注册多个,只要它们有不同的端点即可。

因此,例如,您可以在 DSL 中配置,如下所示:

  • Java

  • Kotlin

http
    .logout((logout) -> logout.logoutUrl("/logout"))
    .saml2Logout((saml2) -> saml2.logoutUrl("/saml2/logout"));
http {
    logout {
        logoutUrl = "/logout"
    }
    saml2Logout {
        logoutUrl = "/saml2/logout"
    }
}

现在,如果客户端发送 POST /logout,会话将被清除,但不会向声明方发送 <saml2:LogoutRequest>。但是,如果客户端发送 POST /saml2/logout,则应用将正常启动 SAML 2.0 SLO。

自定义 <saml2:LogoutRequest> 解析

通常需要在 <saml2:LogoutRequest> 中设置 Spring Security 默认值之外的其他值。

默认情况下,Spring Security 将发出一个 <saml2:LogoutRequest> 并提供:

  • Destination 属性 - 来自 RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation

  • ID 属性 - 一个 GUID。

  • <Issuer> 元素 - 来自 RelyingPartyRegistration#getEntityId

  • <NameID> 元素 - 来自 Authentication#getName

要添加其他值,您可以使用委托模式,如下所示:

  • Java

  • Kotlin

@Bean
Saml2LogoutRequestResolver logoutRequestResolver(RelyingPartyRegistrationRepository registrations) {
	OpenSaml4LogoutRequestResolver logoutRequestResolver =
			new OpenSaml4LogoutRequestResolver(registrations);
	logoutRequestResolver.setParametersConsumer((parameters) -> {
		String name = ((Saml2AuthenticatedPrincipal) parameters.getAuthentication().getPrincipal()).getFirstAttribute("CustomAttribute");
		String format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
		LogoutRequest logoutRequest = parameters.getLogoutRequest();
		NameID nameId = logoutRequest.getNameID();
		nameId.setValue(name);
		nameId.setFormat(format);
	});
	return logoutRequestResolver;
}
@Bean
open fun logoutRequestResolver(registrations:RelyingPartyRegistrationRepository?): Saml2LogoutRequestResolver {
    val logoutRequestResolver = OpenSaml4LogoutRequestResolver(registrations)
    logoutRequestResolver.setParametersConsumer { parameters: LogoutRequestParameters ->
        val name: String = (parameters.getAuthentication().getPrincipal() as Saml2AuthenticatedPrincipal).getFirstAttribute("CustomAttribute")
        val format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
        val logoutRequest: LogoutRequest = parameters.getLogoutRequest()
        val nameId: NameID = logoutRequest.getNameID()
        nameId.setValue(name)
        nameId.setFormat(format)
    }
    return logoutRequestResolver
}

然后,您可以在 DSL 中提供您的自定义 Saml2LogoutRequestResolver,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutRequest((request) -> request
            .logoutRequestResolver(this.logoutRequestResolver)
        )
    );
http {
    saml2Logout {
        logoutRequest {
            logoutRequestResolver = this.logoutRequestResolver
        }
    }
}

自定义 <saml2:LogoutResponse> 解析

通常需要在 <saml2:LogoutResponse> 中设置 Spring Security 默认值之外的其他值。

默认情况下,Spring Security 将发出一个 <saml2:LogoutResponse> 并提供:

  • Destination 属性 - 来自 RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation

  • ID 属性 - 一个 GUID。

  • <Issuer> 元素 - 来自 RelyingPartyRegistration#getEntityId

  • <Status> 元素 - SUCCESS

要添加其他值,您可以使用委托模式,如下所示:

  • Java

  • Kotlin

@Bean
public Saml2LogoutResponseResolver logoutResponseResolver(RelyingPartyRegistrationRepository registrations) {
	OpenSaml4LogoutResponseResolver logoutRequestResolver =
			new OpenSaml4LogoutResponseResolver(registrations);
	logoutRequestResolver.setParametersConsumer((parameters) -> {
		if (checkOtherPrevailingConditions(parameters.getRequest())) {
			parameters.getLogoutRequest().getStatus().getStatusCode().setCode(StatusCode.PARTIAL_LOGOUT);
		}
	});
	return logoutRequestResolver;
}
@Bean
open fun logoutResponseResolver(registrations: RelyingPartyRegistrationRepository?): Saml2LogoutResponseResolver {
    val logoutRequestResolver = OpenSaml4LogoutResponseResolver(registrations)
    logoutRequestResolver.setParametersConsumer { LogoutResponseParameters parameters ->
        if (checkOtherPrevailingConditions(parameters.getRequest())) {
            parameters.getLogoutRequest().getStatus().getStatusCode().setCode(StatusCode.PARTIAL_LOGOUT)
        }
    }
    return logoutRequestResolver
}

然后,您可以在 DSL 中提供您的自定义 Saml2LogoutResponseResolver,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutRequest((request) -> request
            .logoutRequestResolver(this.logoutRequestResolver)
        )
    );
http {
    saml2Logout {
        logoutRequest {
            logoutRequestResolver = this.logoutRequestResolver
        }
    }
}

自定义 <saml2:LogoutRequest> 验证

要自定义验证,您可以实现自己的 Saml2LogoutRequestValidator。目前,验证是最小的,因此您可能可以先委托给默认的 Saml2LogoutRequestValidator,如下所示:

  • Java

  • Kotlin

@Component
public class MyOpenSamlLogoutRequestValidator implements Saml2LogoutRequestValidator {
	private final Saml2LogoutRequestValidator delegate = new OpenSamlLogoutRequestValidator();

	@Override
    public Saml2LogoutRequestValidator logout(Saml2LogoutRequestValidatorParameters parameters) {
		 // verify signature, issuer, destination, and principal name
		Saml2LogoutValidatorResult result = delegate.authenticate(authentication);

		LogoutRequest logoutRequest = // ... parse using OpenSAML
        // perform custom validation
    }
}
@Component
open class MyOpenSamlLogoutRequestValidator: Saml2LogoutRequestValidator {
	private val delegate = OpenSamlLogoutRequestValidator()

	@Override
    fun logout(parameters: Saml2LogoutRequestValidatorParameters): Saml2LogoutRequestValidator {
		 // verify signature, issuer, destination, and principal name
		val result = delegate.authenticate(authentication)

		val logoutRequest: LogoutRequest = // ... parse using OpenSAML
        // perform custom validation
    }
}

然后,您可以在 DSL 中提供您的自定义 Saml2LogoutRequestValidator,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutRequest((request) -> request
            .logoutRequestValidator(myOpenSamlLogoutRequestValidator)
        )
    );
http {
    saml2Logout {
        logoutRequest {
            logoutRequestValidator = myOpenSamlLogoutRequestValidator
        }
    }
}

自定义 <saml2:LogoutResponse> 验证

要自定义验证,您可以实现自己的 Saml2LogoutResponseValidator。目前,验证是最小的,因此您可能可以先委托给默认的 Saml2LogoutResponseValidator,如下所示:

  • Java

  • Kotlin

@Component
public class MyOpenSamlLogoutResponseValidator implements Saml2LogoutResponseValidator {
	private final Saml2LogoutResponseValidator delegate = new OpenSamlLogoutResponseValidator();

	@Override
    public Saml2LogoutValidatorResult logout(Saml2LogoutResponseValidatorParameters parameters) {
		// verify signature, issuer, destination, and status
		Saml2LogoutValidatorResult result = delegate.authenticate(parameters);

		LogoutResponse logoutResponse = // ... parse using OpenSAML
        // perform custom validation
    }
}
@Component
open class MyOpenSamlLogoutResponseValidator: Saml2LogoutResponseValidator {
	private val delegate = OpenSaml4LogoutResponseValidator()

	@Override
    fun logout(parameters: Saml2LogoutResponseValidatorParameters): Saml2LogoutResponseValidator {
		// verify signature, issuer, destination, and status
		val result = delegate.authenticate(authentication)

		val logoutResponse: LogoutResponse = // ... parse using OpenSAML
        // perform custom validation
    }
}

然后,您可以在 DSL 中提供您的自定义 Saml2LogoutResponseValidator,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutResponse((response) -> response
            .logoutResponseAuthenticator(myOpenSamlLogoutResponseAuthenticator)
        )
    );
http {
    saml2Logout {
        logoutResponse {
            logoutResponseValidator = myOpenSamlLogoutResponseValidator
        }
    }
}

自定义 <saml2:LogoutRequest> 存储

当您的应用发送 <saml2:LogoutRequest> 时,该值会存储在会话中,以便可以验证 <saml2:LogoutResponse> 中的 RelayState 参数和 InResponseTo 属性。

如果您想将登出请求存储在会话之外的其他位置,您可以在 DSL 中提供您的自定义实现,如下所示:

  • Java

  • Kotlin

http
    .saml2Logout((saml2) -> saml2
        .logoutRequest((request) -> request
            .logoutRequestRepository(myCustomLogoutRequestRepository)
        )
    );
http {
    saml2Logout {
        logoutRequest {
            logoutRequestRepository = myCustomLogoutRequestRepository
        }
    }
}

更多登出相关参考