SAML 2.0 登录概述

我们首先探讨 SAML 2.0 Relying Party 认证在 Spring Security 中的工作原理。首先,我们看到,与 OAuth 2.0 登录类似,Spring Security 会将用户引导到第三方进行认证。这是通过一系列重定向完成的。

saml2webssoauthenticationrequestfilter
图 1. 重定向到 Asserting Party 进行认证

数字 1 首先,用户对 /private 资源发出未经认证的请求,该请求未被授权。

数字 2 Spring Security 的 AuthorizationFilter 通过抛出 AccessDeniedException 表明未经认证的请求被 *拒绝*(Denied)。

数字 3 由于用户缺乏授权,ExceptionTranslationFilter 启动*开始认证*(Start Authentication)。配置的 AuthenticationEntryPointLoginUrlAuthenticationEntryPoint 的一个实例,它重定向到生成 <saml2:AuthnRequest> 的端点 Saml2WebSsoAuthenticationRequestFilter。或者,如果您配置了多个 asserting party,它首先会重定向到一个选择页面。

数字 4 接下来,Saml2WebSsoAuthenticationRequestFilter 使用其配置的Saml2AuthenticationRequestFactory 创建、签名、序列化和编码一个 <saml2:AuthnRequest>

数字 5 然后浏览器接收此 <saml2:AuthnRequest> 并将其发送给 asserting party。asserting party 尝试认证用户。如果成功,它会将一个 <saml2:Response> 返回给浏览器。

数字 6 然后浏览器将 <saml2:Response> 通过 POST 方法发送到 assertion consumer service 端点。

下图展示了 Spring Security 如何认证一个 <saml2:Response>

saml2webssoauthenticationfilter
图 2. 认证一个 <saml2:Response>

此图基于我们的 SecurityFilterChain 图。

数字 1 当浏览器将 <saml2:Response> 提交到应用程序时,它会委托给 Saml2WebSsoAuthenticationFilter。此过滤器会调用其配置的 AuthenticationConverter,通过从 HttpServletRequest 中提取响应来创建一个 Saml2AuthenticationToken。此转换器还会解析 RelyingPartyRegistration 并将其提供给 Saml2AuthenticationToken

数字 2 接下来,过滤器将令牌传递给其配置的 AuthenticationManager。默认情况下,它使用 OpenSamlAuthenticationProvider

数字 3 如果认证失败,则为*失败*(Failure)。

数字 4 如果认证成功,则为*成功*(Success)。

  • Authentication 设置到 SecurityContextHolder 上。

  • Saml2WebSsoAuthenticationFilter 调用 FilterChain#doFilter(request,response) 以继续执行应用程序的其余逻辑。

最低依赖

SAML 2.0 service provider 支持位于 spring-security-saml2-service-provider 中。它基于 OpenSAML 库构建,因此,您还必须在构建配置中包含 Shibboleth Maven 仓库。请查看此链接,了解为何需要单独仓库的更多详细信息。

  • Maven

  • Gradle

<repositories>
    <!-- ... -->
    <repository>
        <id>shibboleth-releases</id>
        <url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>
    </repository>
</repositories>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-saml2-service-provider</artifactId>
</dependency>
repositories {
    // ...
    maven { url "https://build.shibboleth.net/nexus/content/repositories/releases/" }
}
dependencies {
    // ...
    implementation 'org.springframework.security:spring-security-saml2-service-provider'
}

最低配置

使用 Spring Boot 时,将应用程序配置为 service provider 包括两个基本步骤:
. 包含所需的依赖。
. 指明必要的 asserting party 元数据。

此外,此配置预设您已在 asserting party 中注册了 relying party。

指定 Identity Provider 元数据

在 Spring Boot 应用中,要指定 identity provider 的元数据,请创建类似如下的配置

spring:
  security:
    saml2:
      relyingparty:
        registration:
          adfs:
            identityprovider:
              entity-id: https://idp.example.com/issuer
              verification.credentials:
                - certificate-location: "classpath:idp.crt"
              singlesignon.url: https://idp.example.com/issuer/sso
              singlesignon.sign-request: false

其中

就这样!

Identity Provider 与 Asserting Party 是同义词,Service Provider 与 Relying Party 也是同义词。它们通常分别缩写为 AP 和 RP。

运行时期望

正如之前配置的,应用程序会处理任何包含 SAMLResponse 参数的 POST /login/saml2/sso/{registrationId} 请求

POST /login/saml2/sso/adfs HTTP/1.1

SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZ...

有两种方法可以促使您的 asserting party 生成 SAMLResponse

  • 您可以导航到您的 asserting party。它很可能为每个注册的 relying party 提供了某种链接或按钮,您可以点击它们来发送 SAMLResponse

  • 您可以导航到应用程序中的受保护页面 — 例如 localhost:8080。然后您的应用程序会重定向到配置的 asserting party,asserting party 再发送 SAMLResponse

接下来,可以考虑跳转到

SAML 2.0 登录如何与 OpenSAML 集成

Spring Security 的 SAML 2.0 支持有几个设计目标

  • 依赖一个库来执行 SAML 2.0 操作和处理领域对象。为此,Spring Security 使用 OpenSAML。

  • 确保在使用 Spring Security 的 SAML 支持时,不强制要求此库。为此,Spring Security 在契约中使用 OpenSAML 的任何接口或类都保持封装。这使得您可以将 OpenSAML 替换为其他库或 OpenSAML 的不受支持版本。

作为这两个目标的自然结果,Spring Security 的 SAML API 相对于其他模块来说相当小巧。取而代之的是,诸如 OpenSamlAuthenticationRequestFactoryOpenSamlAuthenticationProvider 等类会暴露 Converter 实现,用于定制认证过程中的各个步骤。

例如,一旦您的应用程序接收到 SAMLResponse 并将其委托给 Saml2WebSsoAuthenticationFilter,该过滤器会将其委托给 OpenSamlAuthenticationProvider

认证一个 OpenSAML Response

opensamlauthenticationprovider

数字 1 Saml2WebSsoAuthenticationFilter 构建 Saml2AuthenticationToken 并调用 AuthenticationManager

数字 2 AuthenticationManager 调用 OpenSAML 认证提供者。

数字 3 认证提供者将响应反序列化为 OpenSAML Response 并检查其签名。如果签名无效,认证失败。

数字 4 然后提供者解密任何 EncryptedAssertion 元素。如果任何解密失败,认证失败。

数字 5 接下来,提供者验证响应的 IssuerDestination 值。如果它们与 RelyingPartyRegistration 中的值不匹配,认证失败。

数字 6 之后,提供者验证每个 Assertion 的签名。如果任何签名无效,认证失败。此外,如果响应和断言都没有签名,认证也会失败。响应或所有断言都必须有签名。

数字 7 然后,提供者解密任何 EncryptedIDEncryptedAttribute 元素。如果任何解密失败,认证失败。

数字 8 接下来,提供者验证每个断言的 ExpiresAtNotBefore 时间戳,<Subject> 和任何 <AudienceRestriction> 条件。如果任何验证失败,认证失败。

数字 9 之后,提供者获取第一个断言的 AttributeStatement 并将其映射到 Map<String, List<Object>>。它还会授予 ROLE_USER 授权。

数字 10 最后,它获取第一个断言中的 NameID、属性的 Map 以及 GrantedAuthority,并构建一个 Saml2AuthenticatedPrincipal。然后,它将该 principal 和授权放入一个 Saml2Authentication 中。

生成的 Authentication#getPrincipal 是一个 Spring Security Saml2AuthenticatedPrincipal 对象,而 Authentication#getName 映射到第一个断言的 NameID 元素。Saml2AuthenticatedPrincipal#getRelyingPartyRegistrationId 保存着关联的 RelyingPartyRegistration 的标识符

定制 OpenSAML 配置

任何同时使用 Spring Security 和 OpenSAML 的类都应该在类的开头静态初始化 OpenSamlInitializationService

  • Java

  • Kotlin

static {
	OpenSamlInitializationService.initialize();
}
companion object {
    init {
        OpenSamlInitializationService.initialize()
    }
}

这会替换 OpenSAML 的 InitializationService#initialize 方法。

偶尔,定制 OpenSAML 如何构建、编组和解组 SAML 对象可能会很有价值。在这种情况下,您可能希望调用 OpenSamlInitializationService#requireInitialize(Consumer),它可以让您访问 OpenSAML 的 XMLObjectProviderFactory

例如,发送未签名的 AuthNRequest 时,您可能希望强制重新认证。在这种情况下,您可以注册自己的 AuthnRequestMarshaller,如下所示

  • Java

  • Kotlin

static {
    OpenSamlInitializationService.requireInitialize(factory -> {
        AuthnRequestMarshaller marshaller = new AuthnRequestMarshaller() {
            @Override
            public Element marshall(XMLObject object, Element element) throws MarshallingException {
                configureAuthnRequest((AuthnRequest) object);
                return super.marshall(object, element);
            }

            public Element marshall(XMLObject object, Document document) throws MarshallingException {
                configureAuthnRequest((AuthnRequest) object);
                return super.marshall(object, document);
            }

            private void configureAuthnRequest(AuthnRequest authnRequest) {
                authnRequest.setForceAuthn(true);
            }
        }

        factory.getMarshallerFactory().registerMarshaller(AuthnRequest.DEFAULT_ELEMENT_NAME, marshaller);
    });
}
companion object {
    init {
        OpenSamlInitializationService.requireInitialize {
            val marshaller = object : AuthnRequestMarshaller() {
                override fun marshall(xmlObject: XMLObject, element: Element): Element {
                    configureAuthnRequest(xmlObject as AuthnRequest)
                    return super.marshall(xmlObject, element)
                }

                override fun marshall(xmlObject: XMLObject, document: Document): Element {
                    configureAuthnRequest(xmlObject as AuthnRequest)
                    return super.marshall(xmlObject, document)
                }

                private fun configureAuthnRequest(authnRequest: AuthnRequest) {
                    authnRequest.isForceAuthn = true
                }
            }
            it.marshallerFactory.registerMarshaller(AuthnRequest.DEFAULT_ELEMENT_NAME, marshaller)
        }
    }
}

requireInitialize 方法每个应用程序实例只能调用一次。

覆盖或替换 Boot 自动配置

Spring Boot 会为 relying party 生成两个 @Bean 对象。

第一个是 SecurityFilterChain,它将应用程序配置为 relying party。当包含 spring-security-saml2-service-provider 时,SecurityFilterChain 看起来像

默认 SAML 2.0 登录配置
  • Java

  • Kotlin

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .saml2Login(withDefaults());
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        authorizeRequests {
            authorize(anyRequest, authenticated)
        }
        saml2Login { }
    }
    return http.build()
}

如果应用程序没有暴露 SecurityFilterChain bean,Spring Boot 会暴露前面的默认 bean。

您可以通过在应用程序中暴露该 bean 来替换它

自定义 SAML 2.0 登录配置
  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/messages/**").hasAuthority("ROLE_USER")
                .anyRequest().authenticated()
            )
            .saml2Login(withDefaults());
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
class MyCustomSecurityConfiguration {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeRequests {
                authorize("/messages/**", hasAuthority("ROLE_USER"))
                authorize(anyRequest, authenticated)
            }
            saml2Login {
            }
        }
        return http.build()
    }
}

前面的示例要求以 /messages/ 开头的任何 URL 都具有 USER 角色。

Spring Boot 创建的第二个 @Bean 是一个 RelyingPartyRegistrationRepository,它表示 asserting party 和 relying party 的元数据。这包括 relying party 在向 asserting party 请求认证时应使用的 SSO 端点位置等信息。

您可以通过发布自己的 RelyingPartyRegistrationRepository bean 来覆盖默认设置。例如,您可以通过访问 asserting party 的元数据端点来查找其配置

Relying Party Registration 仓库
  • Java

  • Kotlin

@Value("${metadata.location}")
String assertingPartyMetadataLocation;

@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
    RelyingPartyRegistration registration = RelyingPartyRegistrations
            .fromMetadataLocation(assertingPartyMetadataLocation)
            .registrationId("example")
            .build();
    return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Value("\${metadata.location}")
var assertingPartyMetadataLocation: String? = null

@Bean
open fun relyingPartyRegistrations(): RelyingPartyRegistrationRepository? {
    val registration = RelyingPartyRegistrations
        .fromMetadataLocation(assertingPartyMetadataLocation)
        .registrationId("example")
        .build()
    return InMemoryRelyingPartyRegistrationRepository(registration)
}
registrationId 是您选择的任意值,用于区分不同的注册。

或者,您可以手动提供每个细节

Relying Party Registration 仓库手动配置
  • Java

  • Kotlin

@Value("${verification.key}")
File verificationKey;

@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
    X509Certificate certificate = X509Support.decodeCertificate(this.verificationKey);
    Saml2X509Credential credential = Saml2X509Credential.verification(certificate);
    RelyingPartyRegistration registration = RelyingPartyRegistration
            .withRegistrationId("example")
            .assertingPartyMetadata(party -> party
                .entityId("https://idp.example.com/issuer")
                .singleSignOnServiceLocation("https://idp.example.com/SSO.saml2")
                .wantAuthnRequestsSigned(false)
                .verificationX509Credentials(c -> c.add(credential))
            )
            .build();
    return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Value("\${verification.key}")
var verificationKey: File? = null

@Bean
open fun relyingPartyRegistrations(): RelyingPartyRegistrationRepository {
    val certificate: X509Certificate? = X509Support.decodeCertificate(verificationKey!!)
    val credential: Saml2X509Credential = Saml2X509Credential.verification(certificate)
    val registration = RelyingPartyRegistration
        .withRegistrationId("example")
        .assertingPartyMetadata { party: AssertingPartyMetadata.Builder ->
            party
                .entityId("https://idp.example.com/issuer")
                .singleSignOnServiceLocation("https://idp.example.com/SSO.saml2")
                .wantAuthnRequestsSigned(false)
                .verificationX509Credentials { c: MutableCollection<Saml2X509Credential?> ->
                    c.add(
                        credential
                    )
                }
        }
        .build()
    return InMemoryRelyingPartyRegistrationRepository(registration)
}

X509Support 是 OpenSAML 类,在前面的代码片段中使用是为了简洁。

或者,您可以使用 DSL 直接配置仓库,这也会覆盖自动配置的 SecurityFilterChain

自定义 Relying Party Registration DSL
  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/messages/**").hasAuthority("ROLE_USER")
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .relyingPartyRegistrationRepository(relyingPartyRegistrations())
            );
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
class MyCustomSecurityConfiguration {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeRequests {
                authorize("/messages/**", hasAuthority("ROLE_USER"))
                authorize(anyRequest, authenticated)
            }
            saml2Login {
                relyingPartyRegistrationRepository = relyingPartyRegistrations()
            }
        }
        return http.build()
    }
}

通过在 RelyingPartyRegistrationRepository 中注册多个 relying party,一个 relying party 可以支持多租户。

如果您希望元数据可以定期刷新,您可以像这样将您的仓库包装在 CachingRelyingPartyRegistrationRepository

缓存 Relying Party Registration 仓库
  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
    @Bean
    public RelyingPartyRegistrationRepository registrations(CacheManager cacheManager) {
		Supplier<IterableRelyingPartyRegistrationRepository> delegate = () ->
            new InMemoryRelyingPartyRegistrationRepository(RelyingPartyRegistrations
                .fromMetadataLocation("https://idp.example.org/ap/metadata")
                .registrationId("ap").build());
		CachingRelyingPartyRegistrationRepository registrations =
            new CachingRelyingPartyRegistrationRepository(delegate);
		registrations.setCache(cacheManager.getCache("my-cache-name"));
        return registrations;
    }
}
@Configuration
@EnableWebSecurity
class MyCustomSecurityConfiguration  {
    @Bean
    fun registrations(cacheManager: CacheManager): RelyingPartyRegistrationRepository {
        val delegate = Supplier<IterableRelyingPartyRegistrationRepository> {
             InMemoryRelyingPartyRegistrationRepository(RelyingPartyRegistrations
                .fromMetadataLocation("https://idp.example.org/ap/metadata")
                .registrationId("ap").build())
        }
        val registrations = CachingRelyingPartyRegistrationRepository(delegate)
        registrations.setCache(cacheManager.getCache("my-cache-name"))
        return registrations
    }
}

通过这种方式,RelyingPartyRegistration 集合将根据缓存的过期策略进行刷新。

RelyingPartyRegistration

一个 RelyingPartyRegistration 实例代表 relying party 和 asserting party 元数据之间的链接。

RelyingPartyRegistration 中,您可以提供 relying party 元数据,例如其 Issuer 值、期望接收 SAML Responses 的位置,以及用于签名或解密有效载荷的任何凭据。

此外,您可以提供 asserting party 元数据,例如其 Issuer 值、期望接收 AuthnRequests 的位置,以及 relying party 用于验证或加密有效载荷的任何公共凭据。

以下 RelyingPartyRegistration 是大多数设置所需的最低配置

  • Java

  • Kotlin

RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
        .fromMetadataLocation("https://ap.example.org/metadata")
        .registrationId("my-id")
        .build();
val relyingPartyRegistration = RelyingPartyRegistrations
    .fromMetadataLocation("https://ap.example.org/metadata")
    .registrationId("my-id")
    .build()

请注意,您还可以从任意 InputStream 源创建 RelyingPartyRegistration。一个示例是元数据存储在数据库中时

String xml = fromDatabase();
try (InputStream source = new ByteArrayInputStream(xml.getBytes())) {
    RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistrations
            .fromMetadata(source)
            .registrationId("my-id")
            .build();
}

也可能存在更复杂的设置

  • Java

  • Kotlin

RelyingPartyRegistration relyingPartyRegistration = RelyingPartyRegistration.withRegistrationId("my-id")
        .entityId("{baseUrl}/{registrationId}")
        .decryptionX509Credentials(c -> c.add(relyingPartyDecryptingCredential()))
        .assertionConsumerServiceLocation("/my-login-endpoint/{registrationId}")
        .assertingPartyMetadata(party -> party
                .entityId("https://ap.example.org")
                .verificationX509Credentials(c -> c.add(assertingPartyVerifyingCredential()))
                .singleSignOnServiceLocation("https://ap.example.org/SSO.saml2")
        )
        .build();
val relyingPartyRegistration =
    RelyingPartyRegistration.withRegistrationId("my-id")
        .entityId("{baseUrl}/{registrationId}")
        .decryptionX509Credentials { c: MutableCollection<Saml2X509Credential?> ->
            c.add(relyingPartyDecryptingCredential())
        }
        .assertionConsumerServiceLocation("/my-login-endpoint/{registrationId}")
        .assertingPartyMetadata { party -> party
                .entityId("https://ap.example.org")
                .verificationX509Credentials { c -> c.add(assertingPartyVerifyingCredential()) }
                .singleSignOnServiceLocation("https://ap.example.org/SSO.saml2")
        }
        .build()

顶层元数据方法是关于 relying party 的详细信息。AssertingPartyMetadata 内部的方法是关于 asserting party 的详细信息。

relying party 期望接收 SAML Responses 的位置是 Assertion Consumer Service Location。

relying party 的 entityId 默认值为 {baseUrl}/saml2/service-provider-metadata/{registrationId}。在配置 asserting party 以了解您的 relying party 时,需要这个值。

assertionConsumerServiceLocation 的默认值为 /login/saml2/sso/{registrationId}。默认情况下,它在过滤器链中映射到 Saml2WebSsoAuthenticationFilter

URI 模式

您可能已经注意到前面示例中的 {baseUrl}{registrationId} 占位符。

这些占位符对于生成 URI 很有用。因此,relying party 的 entityIdassertionConsumerServiceLocation 支持以下占位符

  • baseUrl - 已部署应用程序的方案(scheme)、主机(host)和端口(port)

  • registrationId - 此 relying party 的注册 ID

  • baseScheme - 已部署应用程序的方案(scheme)

  • baseHost - 已部署应用程序的主机(host)

  • basePort - 已部署应用程序的端口(port)

例如,前面定义的 assertionConsumerServiceLocation

/my-login-endpoint/{registrationId}

在已部署的应用程序中,它会转换为

/my-login-endpoint/adfs

前面展示的 entityId 被定义为

{baseUrl}/{registrationId}

在已部署的应用程序中,它会转换为

https://rp.example.com/adfs

主要的 URI 模式如下

  • /saml2/authenticate/{registrationId} - 基于该 RelyingPartyRegistration 的配置生成 <saml2:AuthnRequest> 并将其发送到 asserting party 的端点

  • /login/saml2/sso/ - 认证 asserting party 的 <saml2:Response> 的端点;如果需要,RelyingPartyRegistration 会从之前认证的状态或响应的 issuer 中查找;也支持 /login/saml2/sso/{registrationId}

  • /logout/saml2/sso - 处理 <saml2:LogoutRequest><saml2:LogoutResponse> 有效载荷的端点;如果需要,RelyingPartyRegistration 会从当前登录用户或请求的 issuer 中查找;也支持 /logout/saml2/slo/{registrationId}

  • /saml2/metadata - RelyingPartyRegistration 集合的 relying party 元数据;对于特定的 RelyingPartyRegistration,也支持 /saml2/metadata/{registrationId}/saml2/service-provider-metadata/{registrationId}

由于 registrationIdRelyingPartyRegistration 的主要标识符,在未经认证的场景中,URL 中需要它。如果出于任何原因您希望从 URL 中移除 registrationId,可以指定一个 RelyingPartyRegistrationResolver 来告诉 Spring Security 如何查找 registrationId

凭据

前面展示的示例中,您可能也注意到了使用的凭据。

通常,relying party 使用相同的密钥来签名有效载荷和解密它们。或者,它也可以使用相同的密钥来验证有效载荷和加密它们。

因此,Spring Security 提供了 Saml2X509Credential,这是一种 SAML 专用的凭据,简化了为不同用例配置相同密钥的过程。

至少,您需要拥有 asserting party 的证书,以便验证 asserting party 的签名响应。

要构建可用于验证 asserting party 断言的 Saml2X509Credential,您可以加载文件并使用 CertificateFactory

  • Java

  • Kotlin

Resource resource = new ClassPathResource("ap.crt");
try (InputStream is = resource.getInputStream()) {
    X509Certificate certificate = (X509Certificate)
            CertificateFactory.getInstance("X.509").generateCertificate(is);
    return Saml2X509Credential.verification(certificate);
}
val resource = ClassPathResource("ap.crt")
resource.inputStream.use {
    return Saml2X509Credential.verification(
        CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate?
    )
}

假设 asserting party 也要加密断言。在这种情况下,relying party 需要一个私钥来解密加密的值。

在这种情况下,您需要一个 RSAPrivateKey 以及其对应的 X509Certificate。您可以使用 Spring Security 的 RsaKeyConverters 工具类加载前者,并像之前一样加载后者。

  • Java

  • Kotlin

X509Certificate certificate = relyingPartyDecryptionCertificate();
Resource resource = new ClassPathResource("rp.crt");
try (InputStream is = resource.getInputStream()) {
    RSAPrivateKey rsa = RsaKeyConverters.pkcs8().convert(is);
    return Saml2X509Credential.decryption(rsa, certificate);
}
val certificate: X509Certificate = relyingPartyDecryptionCertificate()
val resource = ClassPathResource("rp.crt")
resource.inputStream.use {
    val rsa: RSAPrivateKey = RsaKeyConverters.pkcs8().convert(it)
    return Saml2X509Credential.decryption(rsa, certificate)
}

当您将这些文件的位置指定为相应的 Spring Boot 属性时,Spring Boot 会为您执行这些转换。

重复的 Relying Party 配置

当一个应用程序使用多个 asserting parties 时,一些配置会在 RelyingPartyRegistration 实例之间重复

  • relying party 的 entityId

  • assertionConsumerServiceLocation

  • 其凭据 — 例如,其签名或解密凭据

这种设置可能使得某些 identity provider 的凭据比其他更容易轮换。

可以通过几种不同的方式来缓解重复问题。

首先,在 YAML 中可以通过引用来缓解

spring:
  security:
    saml2:
      relyingparty:
        okta:
          signing.credentials: &relying-party-credentials
            - private-key-location: classpath:rp.key
              certificate-location: classpath:rp.crt
          identityprovider:
            entity-id: ...
        azure:
          signing.credentials: *relying-party-credentials
          identityprovider:
            entity-id: ...

其次,在数据库中,您无需复制 RelyingPartyRegistration 的模型。

第三,在 Java 中,您可以创建一个自定义配置方法

  • Java

  • Kotlin

private RelyingPartyRegistration.Builder
        addRelyingPartyDetails(RelyingPartyRegistration.Builder builder) {

    Saml2X509Credential signingCredential = ...
    builder.signingX509Credentials(c -> c.addAll(signingCredential));
    // ... other relying party configurations
}

@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
    RelyingPartyRegistration okta = addRelyingPartyDetails(
            RelyingPartyRegistrations
                .fromMetadataLocation(oktaMetadataUrl)
                .registrationId("okta")).build();

    RelyingPartyRegistration azure = addRelyingPartyDetails(
            RelyingPartyRegistrations
                .fromMetadataLocation(oktaMetadataUrl)
                .registrationId("azure")).build();

    return new InMemoryRelyingPartyRegistrationRepository(okta, azure);
}
private fun addRelyingPartyDetails(builder: RelyingPartyRegistration.Builder): RelyingPartyRegistration.Builder {
    val signingCredential: Saml2X509Credential = ...
    builder.signingX509Credentials { c: MutableCollection<Saml2X509Credential?> ->
        c.add(
            signingCredential
        )
    }
    // ... other relying party configurations
}

@Bean
open fun relyingPartyRegistrations(): RelyingPartyRegistrationRepository? {
    val okta = addRelyingPartyDetails(
        RelyingPartyRegistrations
            .fromMetadataLocation(oktaMetadataUrl)
            .registrationId("okta")
    ).build()
    val azure = addRelyingPartyDetails(
        RelyingPartyRegistrations
            .fromMetadataLocation(oktaMetadataUrl)
            .registrationId("azure")
    ).build()
    return InMemoryRelyingPartyRegistrationRepository(okta, azure)
}

从请求中解析 RelyingPartyRegistration

如前所述,Spring Security 通过查找 URI 路径中的 registration id 来解析 RelyingPartyRegistration

根据用例,还采用了许多其他策略来派生它。例如

  • 对于处理 <saml2:Response>RelyingPartyRegistration 从关联的 <saml2:AuthRequest> 或从 <saml2:Response#Issuer> 元素中查找

  • 对于处理 <saml2:LogoutRequest>RelyingPartyRegistration 从当前登录用户或从 <saml2:LogoutRequest#Issuer> 元素中查找

  • 对于发布元数据,RelyingPartyRegistration 会从任何实现了 Iterable<RelyingPartyRegistration> 的仓库中查找

当需要调整时,您可以转向针对定制此功能的各个端点的特定组件

  • 对于 SAML Responses,定制 AuthenticationConverter

  • 对于 Logout Requests,定制 Saml2LogoutRequestValidatorParametersResolver

  • 对于 Metadata,定制 Saml2MetadataResponseResolver

联合登录

SAML 2.0 中一个常见的配置是一个 identity provider 拥有多个 asserting parties。在这种情况下,identity provider 的元数据端点会返回多个 <md:IDPSSODescriptor> 元素。

可以通过一次调用 RelyingPartyRegistrations 来访问这些多个 asserting parties,如下所示

  • Java

  • Kotlin

Collection<RelyingPartyRegistration> registrations = RelyingPartyRegistrations
        .collectionFromMetadataLocation("https://example.org/saml2/idp/metadata.xml")
        .stream().map((builder) -> builder
            .registrationId(UUID.randomUUID().toString())
            .entityId("https://example.org/saml2/sp")
            .build()
        )
        .collect(Collectors.toList());
var registrations: Collection<RelyingPartyRegistration> = RelyingPartyRegistrations
        .collectionFromMetadataLocation("https://example.org/saml2/idp/metadata.xml")
        .stream().map { builder : RelyingPartyRegistration.Builder -> builder
            .registrationId(UUID.randomUUID().toString())
            .entityId("https://example.org/saml2/sp")
            .assertionConsumerServiceLocation("{baseUrl}/login/saml2/sso")
            .build()
        }
        .collect(Collectors.toList())

请注意,由于 registration id 设置为随机值,这将导致某些 SAML 2.0 端点变得不可预测。有几种方法可以解决此问题;让我们关注一种适用于联合特定用例的方法。

在许多联合场景中,所有 asserting parties 共享 service provider 配置。鉴于 Spring Security 默认会在 service provider 元数据中包含 registrationId,另一个步骤是将相应的 URI 更改为排除 registrationId,您可以看到在上面的示例中已经这样做了,其中 entityIdassertionConsumerServiceLocation 配置为静态端点。

您可以在我们的 saml-extension-federation 示例中看到一个完整的示例。

使用 Spring Security SAML 扩展 URI

如果您正在从 Spring Security SAML 扩展迁移,配置应用程序使用 SAML 扩展默认 URI 可能会带来一些好处。

有关此内容的更多信息,请参阅我们的 custom-urls 示例和我们的 saml-extension-federation 示例