跳到主要内容
版本:7.0.2

SAML 2.0 登录概述

DeepSeek V3 中英对照 SAML2 Log In Overview SAML 2.0 Login Overview

我们首先来探讨 SAML 2.0 依赖方认证在 Spring Security 中的工作原理。与 OAuth 2.0 登录 类似,Spring Security 会将用户引导至第三方进行身份验证。这一过程通过一系列重定向来完成:

saml2webssoauthenticationrequestfilter

图 1. 重定向至断言方身份验证

备注

上图基于我们的 SecurityFilterChainAbstractAuthenticationProcessingFilter 图表构建:

1 首先,用户向 /private 资源发起一个未经身份验证的请求,而该用户对此资源并无访问权限。

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

3 由于用户缺乏授权,ExceptionTranslationFilter 会启动身份验证。配置的 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。默认情况下,它使用 OpenSaml5AuthenticationProvider

3 如果身份验证失败,则 Failure

4 如果认证成功,则显示 成功

  • 认证 被设置在 SecurityContextHolder 上。

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

最小依赖

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

<repositories>
<!-- ... -->
<repository>
<id>shibboleth-releases</id>
<name>Shibboleth Releases Repository</name>
<url>https://build.shibboleth.net/maven/releases/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-saml2-service-provider</artifactId>
</dependency>

最小配置

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

备注

同时,此配置假设您已经在您的断言方注册了依赖方

指定身份提供者元数据

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

spring:
security:
saml2:
relyingparty:
registration:
adfs:
assertingparty:
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

其中:

  • [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您选择的任意标识符

就这样!

备注

身份提供者(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...

有两种方法可以引导你的断言方生成 SAMLResponse

  • 您可以导航至您的断言方。它通常会为每个已注册的依赖方提供某种链接或按钮,点击即可发送 SAMLResponse

  • 您可以导航至应用程序中的受保护页面——例如 [localhost:8080](http://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 相对于其他模块来说相当精简。相反,诸如 OpenSamlXAuthenticationRequestFactoryOpenSamlXAuthenticationProvider 这样的类通过暴露 Converter 实现来定制身份验证过程中的各个步骤。

例如,当您的应用程序收到 SAMLResponse 并委托给 Saml2WebSsoAuthenticationFilter 时,该过滤器会进一步委托给 OpenSamlXAuthenticationProvider

验证 OpenSAML Response

opensamlauthenticationprovider

1 Saml2WebSsoAuthenticationFilter 会构建 Saml2AuthenticationToken 并调用 AuthenticationManager

2 AuthenticationManager 调用 OpenSAML 认证提供程序。

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

4 随后提供者解密所有 EncryptedAssertion 元素。如果任何解密失败,则认证失败。

5 接下来,提供程序会验证响应的 Issuer(颁发者)和 Destination(目标)值。如果它们与 RelyingPartyRegistration(依赖方注册)中的信息不匹配,则认证失败。

6 随后,服务提供者验证每个 Assertion 的签名。如果任何签名无效,则认证失败。此外,如果响应和断言均无签名,认证也会失败。响应或所有断言中必须至少有一方包含签名。

7 随后,提供程序对任何 EncryptedIDEncryptedAttribute 元素进行解密。如果任何解密失败,则认证失败。

8 接下来,提供程序会验证每个断言的 ExpiresAtNotBefore 时间戳、<Subject> 以及任何 <AudienceRestriction> 条件。如果任何验证失败,则身份验证失败。

9 随后,提供程序获取第一个断言的 AttributeStatement,并将其映射到 Map<String, List<Object>>。同时,它授予 FACTOR_SAML_RESPONSEROLE_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();
}

这取代了 OpenSAML 的 InitializationService#initialize

在某些情况下,自定义 OpenSAML 构建、编组(marshalling)和解组(unmarshalling)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);
});
}

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

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

你可以通过暴露应用程序中的 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();
}
}

前面的例子要求任何以 /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);
}
备注

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

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

依赖方可以通过在 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;
}
}

这样,RelyingPartyRegistration 的集合将根据缓存的驱逐计划进行刷新。

RelyingPartyRegistration

一个 RelyingPartyRegistration 实例代表了依赖方与断言方元数据之间的关联。

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

此外,您还可以提供断言方的元数据,如其 Issuer 值、期望接收 AuthnRequests 的地址,以及依赖方用于验证或加密有效载荷的任何公共凭证。

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

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

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

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

顶级元数据方法是关于依赖方的详细信息。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 设计的凭证类,它简化了为不同用例配置相同密钥的过程。

至少,你需要持有来自断言方(asserting party)的证书,以便验证断言方签名的响应。

要构建一个可用于验证断言方声明的 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);
}

假设断言方也打算对断言进行加密。在这种情况下,依赖方需要一把私钥来解密加密后的值。

既然如此,你需要一个 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);
}
提示

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

重复的依赖方配置

当一个应用程序使用多个断言方时,RelyingPartyRegistration 实例之间会存在一些重复配置:

  • 依赖方的 entityId

  • assertionConsumerServiceLocation

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

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

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

首先,在YAML中,可以通过引用(references)来缓解这个问题:

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

其次,在数据库中,您无需复制 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);
}

从请求中解析 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());

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

在许多联邦认证场景中,所有断言方共享服务提供者配置。由于Spring Security默认会在服务提供者元数据中包含registrationId,因此需要额外调整相关URI以排除registrationId。您可以在上述示例中看到,entityIdassertionConsumerServiceLocation已通过静态端点完成配置,这正是该调整的体现。

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

使用 Spring Security SAML 扩展 URI

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

如需了解更多相关信息,请参阅 我们的 custom-urls 示例我们的 saml-extension-federation 示例