跳到主要内容

SAML 2.0 登录概述

QWen Max 中英对照 SAML2 Log In Overview SAML 2.0 Login Overview

我们首先来检查 SAML 2.0 依赖方认证在 Spring Security 中是如何工作的。首先,我们看到,就像 OAuth 2.0 登录 一样,Spring Security 会将用户重定向到第三方进行认证。它通过一系列的重定向来实现这一点:

saml2webssoauthenticationrequestfilter

图 1. 重定向到声明方认证

备注

1 首先,用户向 /private 资源发出未经身份验证的请求,这是未被授权的。

2 Spring Security 的 AuthorizationFilter 通过抛出 AccessDeniedException 异常来表示未认证的请求被拒绝

3 由于用户缺乏授权,ExceptionTranslationFilter 将启动Start Authentication。配置的 AuthenticationEntryPointLoginUrlAuthenticationEntryPoint 的一个实例,它会重定向到生成 <saml2:AuthnRequest> 的端点,即 Saml2WebSsoAuthenticationRequestFilter。或者,如果您配置了多个断言方,则它会首先重定向到一个选择页面。

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

5 然后浏览器将此 <saml2:AuthnRequest> 呈现给声明方。声明方尝试对用户进行身份验证。如果成功,它会将一个 <saml2:Response> 返回给浏览器。

6 浏览器然后将 <saml2:Response> POST 到断言消费者服务端点。

下图展示了 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

  • 认证 设置在 SecurityContextHolder 上。

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

最小依赖

SAML 2.0 服务提供者支持位于 spring-security-saml2-service-provider 中。它基于 OpenSAML 库构建,因此,你还必须在构建配置中包含 Shibboleth Maven 仓库。有关为什么需要单独的仓库的更多详细信息,请查看 此链接

<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>
xml

最小配置

当使用 Spring Boot 时,将应用程序配置为服务提供者包括两个基本步骤:1. 包含所需的依赖项。2. 指明必要的断言方元数据。

备注

此外,此配置假定您已经将依赖方注册到您的断言方

指定身份提供商元数据

在 Spring Boot 应用程序中,要指定身份提供者的元数据,请创建类似于以下的配置:

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
yml

where:

  • [idp.example.com/issuer](https://idp.example.com/issuer) 是身份提供者发布的 SAML 响应中 Issuer 属性的值。

  • classpath:idp.crt 是用于验证 SAML 响应的身份提供者的证书在类路径上的位置。

  • [idp.example.com/issuer/sso](https://idp.example.com/issuer/sso) 是身份提供者期望接收 AuthnRequest 实例的端点。

  • adfs你选择的一个任意标识符

就这样!

备注

身份提供商和声明方是同义词,服务提供商和依赖方也是同义词。它们分别经常被缩写为 AP 和 RP。

运行时预期

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

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

SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZ...
http

有两种方法可以促使你的断言方生成 SAMLResponse

  • 您可以导航到您的断言方。它可能为每个已注册的依赖方提供某种链接或按钮,您可以点击该链接或按钮来发送 SAMLResponse

  • 您可以导航到应用程序中的受保护页面 — 例如,localhost:8080。然后,您的应用程序会重定向到配置的断言方,该断言方随后会发送 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。然后,它将该主体和权限放入 Saml2Authentication 中。

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

自定义 OpenSAML 配置

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

static {
OpenSamlInitializationService.initialize();
}
java

这取代了 OpenSAML 的 InitializationService#initialize

有时,自定义 OpenSAML 构建、编组和解组 SAML 对象的方式可能会很有价值。在这种情况下,您可能希望调用 OpenSamlInitializationService#requireInitialize(Consumer),这将使您能够访问 OpenSAML 的 XMLObjectProviderFactory

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

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);
});
}
java

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

覆盖或替换启动自动配置

Spring Boot 为依赖方生成两个 @Bean 对象。

第一个是 SecurityFilterChain,它将应用程序配置为依赖方。当包含 spring-security-saml2-service-provider 时,SecurityFilterChain 看起来像这样:

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

如果应用程序没有暴露 SecurityFilterChain bean,Spring Boot 会暴露上述默认的 SecurityFilterChain

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

@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();
}
}
java

上述示例要求任何以 /messages/ 开头的 URL 都需要 USER 角色。

第二个由 Spring Boot 创建的 @BeanRelyingPartyRegistrationRepository,它表示断言方和依赖方的元数据。这包括诸如依赖方在向断言方请求身份验证时应使用的 SSO 端点的位置等信息。

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

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

@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
RelyingPartyRegistration registration = RelyingPartyRegistrations
.fromMetadataLocation(assertingPartyMetadataLocation)
.registrationId("example")
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
java
备注

registrationId 是一个由你选择的任意值,用于区分不同的注册。

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

@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);
}
java
备注

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

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

@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();
}
}
java
备注

依赖方可以通过在 RelyingPartyRegistrationRepository 中注册多个依赖方来实现多租户。

如果你希望你的元数据能够定期刷新,你可以像下面这样用 CachingRelyingPartyRegistrationRepository 包装你的仓库:

@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;
}
}
java

通过这种方式,RelyingPartyRegistration 集合将根据缓存的淘汰计划进行刷新。

RelyingPartyRegistration

RelyingPartyRegistration 实例表示依赖方和声明方的元数据之间的链接。

RelyingPartyRegistration 中,你可以提供依赖方元数据,如其 Issuer 值、期望接收 SAML 响应的地址以及用于签名或解密负载的任何凭证。

此外,你还可以提供断言方元数据,如其 Issuer 值、期望接收 AuthnRequests 的位置,以及任何用于依赖方验证或加密负载的公开凭证。

以下 RelyingPartyRegistration 是大多数设置所需的最低要求:

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

请注意,您也可以从任意的 InputStream 源创建一个 RelyingPartyRegistration。一个这样的例子是当元数据存储在数据库中时:

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

更复杂的设置也是可能的:

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();
java
提示

顶级元数据方法是关于依赖方的详细信息。AssertingPartyMetadata中的方法是关于声明方的详细信息。

备注

期望接收 SAML 响应的依赖方的位置是断言消费者服务位置。

默认的依赖方 entityId{baseUrl}/saml2/service-provider-metadata/{registrationId}。在配置认证方以使其了解您的依赖方时,需要使用此值。

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

URI 模式

你可能注意到了前面示例中的 {baseUrl}{registrationId} 占位符。

这些对于生成 URI 很有用。因此,依赖方的 entityIdassertionConsumerServiceLocation 支持以下占位符:

  • baseUrl - 部署应用程序的方案、主机和端口

  • registrationId - 此依赖方的注册 ID

  • baseScheme - 部署应用程序的方案

  • baseHost - 部署应用程序的主机

  • basePort - 部署应用程序的端口

例如,前面定义的 assertionConsumerServiceLocation 是:

/my-login-endpoint/{registrationId}

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

/my-login-endpoint/adfs

前面显示的 entityId 定义为:

{baseUrl}/{registrationId}

在已部署的应用程序中,这相当于:

https://rp.example.com/adfs

常见的 URI 模式如下:

  • /saml2/authenticate/{registrationId} - 该端点基于该 RelyingPartyRegistration 的配置生成一个 <saml2:AuthnRequest>,并将其发送给断言方

  • /login/saml2/sso/ - 该端点验证断言方的 <saml2:Response>;如果需要,RelyingPartyRegistration 会从先前认证的状态或响应的发行者中查找;还支持 /login/saml2/sso/{registrationId}

  • /logout/saml2/sso - 该端点处理 <saml2:LogoutRequest> 和 <saml2:LogoutResponse> 负载;如果需要,RelyingPartyRegistration 会从先前认证的状态或请求的发行者中查找;还支持 /logout/saml2/slo/{registrationId}

  • /saml2/metadata - 一组 RelyingPartyRegistration依赖方元数据;还支持 /saml2/metadata/{registrationId}/saml2/service-provider-metadata/{registrationId} 用于特定的 RelyingPartyRegistration

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

凭据

在前面显示的示例中,你也可能注意到了所使用的凭证。

通常,依赖方使用相同的密钥来签名负载以及解密它们。或者,它也可以使用相同的密钥来验证负载以及加密它们。

正因为如此,Spring Security 提供了 Saml2X509Credential,这是一个特定于 SAML 的凭证,简化了为不同用例配置相同密钥的过程。

你至少需要有一个来自声明方的证书,以便可以验证声明方的签名响应。

要构建一个可用于验证断言方的断言的 Saml2X509Credential,你可以加载文件并使用 CertificateFactory

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

假设声明方还要对声明进行加密。在这种情况下,依赖方需要一个私钥来解密加密的值。

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

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);
}
java
提示

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

重复的依赖方配置

当应用程序使用多个声明方时,RelyingPartyRegistration 实例之间会有一些重复的配置:

  • 依赖方的 entityId

  • assertionConsumerServiceLocation

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

这种设置可能让某些身份提供商的凭证比其他身份提供商更容易轮换。

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

首先,在 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: ...
yaml

其次,在数据库中,你不必复制 RelyingPartyRegistration 的模型。

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

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);
}
java

从请求中解析 RelyingPartyRegistration

如前所见,Spring Security 通过在 URI 路径中查找注册 id 来解析 RelyingPartyRegistration

根据用例的不同,还会采用许多其他策略来推导出一个。例如:

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

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

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

当需要调整时,你可以转向针对自定义这些端点的特定组件:

  • 对于 SAML 响应,自定义 AuthenticationConverter

  • 对于注销请求,自定义 Saml2LogoutRequestValidatorParametersResolver

  • 对于元数据,自定义 Saml2MetadataResponseResolver

联合登录

一种常见的 SAML 2.0 配置是一个身份提供商具有多个断言方。在这种情况下,身份提供商的元数据端点会返回多个 <md:IDPSSODescriptor> 元素。

这些多个声明方可以在对 RelyingPartyRegistrations 的一次调用中访问,如下所示:

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());
java

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

在许多联合场景中,所有声明方共享服务提供者配置。鉴于Spring Security默认会在服务提供者元数据中包含registrationId,另一个步骤是更改相应的URI以排除registrationId,您可以在上面的示例中看到这一点,其中entityIdassertionConsumerServiceLocation已经配置为静态端点。

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

使用 Spring Security SAML 扩展 URI

如果您正在从 Spring Security SAML 扩展迁移,那么将应用程序配置为使用 SAML 扩展的 URI 默认值可能会有一些好处。

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