# SAML
pac4j 允许你使用 SAML v2.0 协议登录任何 SAML 认证提供者。
它已经与各种 SAML 2 提供者进行了测试:Okta、testshib.org,CAS SAML2 IdP,Shibboleth v3.4 等。
# 1)依赖
你需要使用以下模块:pac4j-saml (弃用,OpenSAML v4, JDK 11)或 pac4j-saml-opensamlv5(OpenSAML v5, JDK 17)。
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-saml</artifactId>
    <version>${pac4j.version}</version>
</dependency>
 2
3
4
5
# 2)基本配置
SAML2Client (opens new window) 必须用于使用 SAML 2 认证提供者登录。
首先,如果你没有密钥库,则需要为所有签名和加密操作生成密钥库(keystore):
keytool -genkeypair -alias pac4j-demo -keypass pac4j-demo-passwd -keystore samlKeystore.jks -storepass pac4j-demo-passwd -keyalg RSA -keysize 2048 -validity 3650
 或者,你也可以让 pac4j 为你创建密钥库。如果密钥库资源不存在且可写,pac4j 将尝试生成密钥库并在其中生成相关密钥对。
然后,必须定义 SAML2Configuration (opens new window):
SAML2Configuration cfg = new SAML2Configuration(new ClassPathResource("samlKeystore.jks"),
        "pac4j-demo-passwd",
        "pac4j-demo-passwd",
        new ClassPathResource("testshib-providers.xml"));
 2
3
4
第一个参数(keystoreResource)是定义为用作 Spring 资源的 keystore:
org.springframework.core.io.FileSystemResource类用作磁盘文件org.springframework.core.io.ClassPathResource类用作 classpath 文件org.springframework.core.io.UrlResource类用作 URL
第二个参数(keywordPassword)是密钥库生成的 -storepass 选项的值,而第三个参数(privateKeyPassword)是 -keypass 选项值。
第四个参数(identityProviderMetadataResource)应该指向 IdP 元数据,假设你可以使用与密钥库相同的定义。
或者,你也可以使用“前缀机制”来定义 Resource:
SAML2Configuration cfg = new SAML2Configuration("resource:samlKeystore.jks",
    "pac4j-demo-passwd",
    "pac4j-demo-passwd",
    "resource:testshib-providers.xml");
 2
3
4
有以下可用的前缀:
resource:或者classpath:前缀,创建一个ClassPathResource组件http:或者https:前缀,创建一个UrlResource组件file:有前缀或没有前缀时,都会创建一个FileSystemResource组件
或者你甚至可以使用空构造函数和适当的 setter:
setKeystoreResource、setKeystoreResourceFilepath、setKeystoreResourceClasspath、setKeystoreResourceUrl或者setKeystorePath方法用于定义 keystoresetKeystorePassword方法用于定义 keystore 密码setPrivateKeyPassword方法用于定义 keystore 私有密码setIdentityProviderMetadataResource、setIdentityProviderMetadataResourceFilepath、setIdentityProviderMetadataResourceClasspath、setIdentityProviderMetadataResourceUrl或者setIdentityProviderMetadataPath方法用于定义认证提供者元数据
最后,你需要根据前面的配置声明 SAML2Client:
SAML2Client client = new SAML2Client(cfg);
 成功认证后,返回 SAML2Profile (opens new window)。
SAML2Client 配置 ReplayCache,以防止重放攻击。此 ReplayCache 必须在认证之间保持状态。因此,必须使用 SAML2Client 的单个实例。如果这不可能,则可以重写 initSAMLReplayCache 方法以创建自定义 ReplayCacheProvider。
注意:使用后,SAML2Client 必须使用 destroy 方法调用显式销毁。底层实现证明了这一步骤的重要性。FilesystemMetadataResolver 使用守护线程监视元数据文件的更改。在不销毁 SAML2Client 的情况下,该线程将继续运行,因此存在线程泄漏问题的风险。
# 3)扩展配置
通过在 SAML2Configuration 级别设置适当的 AttributeConverter(默认情况下为 SimpleSAMLAttributeConverter),可以控制 SAML 属性从SAML 认证响应转换为 pac4j 属性的方式:
cfg.setSamlAttributeConverter(new ComplexTypeSAML2AttributeConverter());
 你可以控制认证请求的各个方面,例如强制和/或被动认证:
cfg.setForceAuth(true);
cfg.setPassive(true);
 2
此外,还可以基于 RedirectionActionBuilder#ATTRIBUTE_FORCE_AUTHN 和 RedirectionActionBuilder#ATTRIBUTE_PASSIVE 定义的存在 HTTP 中的属性,来控制每个请求强制/被动认证请求。
你可以通过 setAuthnRequestBindingType 方法定义认证请求的绑定类型,也可以通过 setSpLogoutRequestBindingType 方式定义 SP 注销请求的绑定类别:
cfg.setAuthnRequestBindingType(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
// or cfg.setAuthnRequestBindingType(SAMLConstants.SAML2_POST_BINDING_URI);
// or cfg.setAuthnRequestBindingType(SAMLConstants.SAML2_POST_SIMPLE_SIGN_BINDING_URI);
 2
3
你可以通过 setResponseBindingType 方法定义认证响应的绑定类型(默认为 POST):
cfg.setResponseBindingType(SAMLConstants.SAML2_POST_BINDING_URI);
// or cfg.setResponseBindingType(SAMLConstants.SAML2_ARTIFACT_BINDING_URI);
 2
请注意,SP 元数据将定义 IdP 注销请求的POST绑定。
一旦你在认证提供者(IdP)上有了认证的 web 会话(session),通常它不会再次提示你输入凭据,并且会自动为你生成新的断言。默认情况下,SAML 客户端将在一小时内接受基于先前认证的断言。如果要更改此行为,请设置 maximumAuthenticationLifetime参数:
// lifetime in seconds
client.setMaximumAuthenticationLifetime(600);
 2
默认情况下,应用程序(服务提供者,SP)的实体 ID 将等同于回调 URL。但你可以使用 serviceProviderEntityId 参数强制自己的实体 ID:
// custom SP entity ID
cfg.setServiceProviderEntityId("http://localhost:8080/callback?extraParameter");
 2
根据 SAML 规范,如果 SP 实体的格式为 nameid-format:entity,则认证请求不能包含 NameQualifier。然而,一些 IdP 要求提供该信息。你可以通过 useNameQualifier 参数在请求中强制使用 NameQualifier:
// force NameQualifier in the authn request
cfg.setUseNameQualifier(true);
 2
SAML 规范建议响应应该为 Destination 属性设置一个值。出于安全原因,pac4j 将检查其存在。如果需要,可以更改此行为:
// force support of missing `Destination` attribute
cfg.setResponseDestinationAttributeMandatory(false);
 2
要允许发送到认证提供者的认证请求指定属性消耗索引,请执行以下操作:
cfg.setAttributeConsumingServiceIndex(1);
 要允许发送到认证提供者的认证请求指定断言消费者服务索引,请执行以下操作:
cfg.setAssertionConsumerServiceIndex(1);
 这也将指导 pac4j 从该索引指示的元数据中选择 ACS URL。
要为初始认证请求配置支持的算法和摘要方法,请通过配置对象指定支持的内容:
cfg.setBlackListedSignatureSigningAlgorithms(...);
cfg.setSignatureAlgorithms(...);
cfg.setSignatureReferenceDigestMethods(...);
cfg.setSignatureCanonicalizationAlgorithm(...);
 2
3
4
SAML 客户端总是要求直接或通过包含断言的响应对断言进行签名。当需要独立于响应处理断言时,可以使用以下命令直接请求对其进行签名:
cfg.setWantsAssertionsSigned(true);
 你还可能希望使用以下方式启用认证请求的签名:
cfg.setAuthnRequestSigned(true);
 最终结果将基于 IdP 元数据和上述配置确定。IdP 元数据的选择将始终有利于 pac4j 配置,因此如果你需要完全依赖 pac4j,则需要修改元数据。
你有两种方式生成 SP 元数据:
- 要么以编程形式通过 
SAML2Client:String spMetadata = client.getServiceProviderMetadataResolver().getMetadata(); - 要么定义合适的配置:
cfg.setServiceProviderMetadata(new FileSystemResource("/tmp/sp-metadata.xml")); 
此外,可以使用 Java 的 ServiceLoader API 来指定 SAML 配置。为了能被发现,你必须创建一个提供者配置文件:META-INF/services/org.pac4j.saml.util.ConfigurationManager。文件的内容是 SPI 实现的完全限定类名:com.example.impl.MyConfigurationManager。
# 3.1)元数据和密钥库配置
对于服务提供商元数据生成,pac4j 提供了以下组件:
SAML2FileSystemMetadataGenerator:这是在文件系统上生成元数据的默认选项。SAML2HttpUrlMetadataGenerator:能够使用响应GET/POST方法的 REST API 解析/获取及存储元数据。这主要使用 OpenSAML 的HTTPMetadataResolver组件来处理。元数据内容总是由 pac4j 生成,然后传递给 API 进行存储。API 只需检索或存储元数据即可。无需其他额外处理/工作。GET操作将返回元数据,POST操作将在请求主体中提供元数据。
对于服务提供者密钥库生成,pac4j 提供了以下组件:
SAML2FileSystemKeystoreGenerator:这是在文件系统上生成密钥库的默认选项。SAML2HttpUrlKeystoreGenerator:能够使用响应GET/POST方法的 REST API 解析/获取及存储密钥库。元数据内容始终由 pac4j 生成,然后作为 base64 编码字符串传递给 API 进行存储。API 只需检索或存储密钥库。当获取中时 pac4j 会将其密钥库解码回来,再次构建它并继续处理。GET操作将返回密钥库,POST操作将在请求体中提供密钥库。
请注意这些:
- 实际上,
SAML2HttpUrlMetadataGenerator很可能与SAML2HttpUrlKeystoreGenerator一起使用。然而,考虑到组件分离存在的灵活性,这不是严格必要的。 - 联系 URL 端点是通过 
SAML2HttpClientBuilder完成的,它是基于 OpenSAML v4 进行了微调。构造器可以支持基本认证和其他形式的端点访问安全性。 
SAML2HttpClientBuilder httpClient = new SAML2HttpClientBuilder();
httpClient.setConnectionTimeout(Duration.ofSeconds(1));
httpClient.setSocketTimeout(Duration.ofSeconds(1));
final SAML2Configuration config = new SAML2Configuration();
config.setHttpClient(httpClient.build());
config.setKeystoreResourceUrl("http://localhost:8081/keystore");
config.setServiceProviderMetadataResource(
    new FileUrlResource(new URL("http://localhost:8081/saml")));
// Other Stuff...
config.init();
return config;
 2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.2)认证提供者元数据解析
认证提供者元数据的解析也可以被控制和覆盖,如下所示:
final SAML2MetadataResolver resolver = new CustomIdentityProviderSAML2MetadataResolver();
final SAML2Configuration config = new SAML2Configuration();
config.setIdentityProviderMetadataResolver(resolver);
 2
3
# 4)注销
SAML 支持处理注销请求/响应的 HTTP-POST 和 HTTP 重定向绑定(以及传入注销请求的 SOAP绑定)。
SAML2Client 可以参与中央注销并向 IdP 发送注销请求。此请求的绑定由 spLogoutRequestBindingType 属性控制,可以使用 SAML2Configuration 的 spLogoutRequestSigned 属性对请求进行签名。
配置文件和会话的清理由 DefaultLogoutHandler 管理。你可以通过实现 LogoutHandler 接口来使用自己的注销处理程序,并在 SAML 配置中定义它。默认情况下,在 SP 启动注销的最后一步,用户将看到一个空白页面。可以使用 SAML2Configuration 的 postLogoutURL 属性自定义默认 pac4j 行为。
当 IdP 调用时,SAML pac4j 应用程序基于注销处理程序删除用户配置文件,并返回一个注销响应,该响应具有由 spLogoutResponseBindingType 属性(在 SAML2Configuration 中)定义的绑定。
# 5)认证属性
此客户端将填充以下认证属性:
- IdP 的 entityID(
getAuthenticationAttribute("issuerId")或SAML2Profile.getIssuerId()) - IdP 的 认证断言方法(
getAuthenticationAttribute("authnContext")或SAML2Profile.getAuthnContexts()) - NotBefore SAML 条件 (
getAuthenticationAttribute("notBefore")或SAML2Profile.getNotBefore()) - NotOnOrAfter SAML 条件 (
getAuthenticationAttribute("notOnOrAfter")或SAML2Profile.getNotOnOrAfter()) - 会话索引
 
# 6)ADFS 细节
你必须遵循这些规则才能成功使用 Microsoft ADFS 2.0/3.0 进行认证。
# a)最大认证时间
pac4j 的默认最长时间设置为 1 小时,而 ADFS 将其设置为 8 小时。因此,ADFS 可能会发送一个断言,该断言在 ADFS 侧仍然有效,但在 pac4j 侧被评估为无效。
你可以看到以下错误信息: org.pac4j.saml.exceptions.SAMLException: Authentication issue instant is too old or in the future
如何使值相等有两种可能性:
- 在“信任属性”对话框中更改ADFS管理控制台中的值
 - 使用 
setMaximumAuthenticationLifetime方法更改 pac4j 侧的值。 
# b)Java 加密扩展(JCE)无限强度管辖权策略文件
您必须将 Java 加密扩展(JCE)无限强度管辖权策略文件(Java Cryptography Extension(JCE)Unlimited Strength Jurisdiction Policy Files)安装到运行 pac4j 的 JRE/JDK 中。如果不这样做,你可能会遇到以下错误:
ERROR [org.opensaml.xml.encryption.Decrypter] - <Error decrypting the encrypted data element>
org.apache.xml.security.encryption.XMLEncryptionException: Illegal key size
ERROR [org.opensaml.xml.encryption.Decrypter] - <Failed to decrypt EncryptedData using either EncryptedData KeyInfoCredentialResolver or EncryptedKeyResolver + EncryptedKey KeyInfoCredentialResolver>
ERROR [org.opensaml.saml2.encryption.Decrypter] - <SAML Decrypter encountered an error decrypting element content>
 2
3
4
Java 加密扩展(JCE)无限强度管辖权策略文件可以从 Oracle’s Java 下载站点下载。
# c)禁用 urn:oasis:names:tc:SAML:2.0:nameid-format:entity 格式的名字限定符
 ADFS 3.0 在使用 urn:oasis:names:tc:SAML:2.0:nameid-format:entity 时不接受 NameQualifier。因此,SAML2Configuration 中的参数 useNameQualifier 必须设置为默认值 false。
# 与各种 IdP 集成
# SimpleSAMPphp
SimpleSAMLphp 是一个常用的 IdP。要将 PAC4J 与 SimpleSAMLphp 集成,请从以下步骤开始。让我们假设一个标准的 simpleSAMLphp 安装。
# DemoConfigFactory.java
final SAML2Configuration cfg = new SAML2Configuration("resource:samlKeystore.jks",
 "pac4j-demo-passwd",
 "pac4j-demo-passwd",
 "resource:idp-metadata.xml"); //the id-metadata.xml contains IdP metadata, you will have to create this
 cfg.setMaximumAuthenticationLifetime(3600);
 cfg.setServiceProviderEntityId("test.pac4j"); //the entityId of you client (the SP), you will usualy change this
 cfg.setServiceProviderMetadataPath(new File("sp-metadata.xml").getAbsolutePath()); //the metadata of the SP, no changes required usually
 final SAML2Client saml2Client = new SAML2Client(cfg);
 2
3
4
5
6
7
8
# SimpleSAMLphp 配置
请注意,pac4j 需要绑定 urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST 用于单点登录和单点登出服务,而 simpleSAMLphp 默认安装情况下仅使用 urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect。需要将绑定添加到托管的 metadata/saml20-idp-hosted.php 文件:
'SingleSignOnServiceBinding' => array('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'),
'SingleLogoutServiceBinding' => array('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'),
 2
还需要将 SP 的 EntityID 注册到文件 metadata/saml20-sp-remote.php。
$metadata['test.pac4j'] = array(
 'AssertionConsumerService' => 'http://localhost:8080/callback?client_name=SAML2Client',
...
 2
3
# 元数据
SimpleSAMLphp 在 http://idp-domain/simplesamlphp/saml2/idp/metadata.php?output=xhtml 上暴露了它的 IdP 元数据。你可以在 <md:EntitiesDescriptor ... 标签中包装这个文件,以生成 idp-metadata.xml 文件。
<?xml version="1.0"?>
<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
    <md:EntityDescriptor entityID="http://idp-domain/simplesamlphp/saml2/idp/metadata.php">
      <md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
        <md:KeyDescriptor use="signing">
          <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:X509Data>
              <ds:X509Certificate>MII...</ds:X509Certificate>
            </ds:X509Data>
          </ds:KeyInfo>
        </md:KeyDescriptor>
        <md:KeyDescriptor use="encryption">
          <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:X509Data>
              <ds:X509Certificate>MII...</ds:X509Certificate>
            </ds:X509Data>
          </ds:KeyInfo>
        </md:KeyDescriptor>
        <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://idp-domain/simplesamlphp/saml2/idp/SingleLogoutService.php"/>
        <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://idp-domain/simplesamlphp/saml2/idp/SingleLogoutService.php"/>
        <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
        <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://idp-domain/simplesamlphp/saml2/idp/SSOService.php"/>
        <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://idp-domain/simplesamlphp/saml2/idp/SSOService.php"/>
      </md:IDPSSODescriptor>
    </md:EntityDescriptor>
</md:EntitiesDescriptor>
 2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 7)自定义 OpenSAML 启动
在幕后,OpenSAML 使用单一注册表保存其配置(构造器、整理器、解析器等)。虽然 pac4j 附带了此配置的默认值(参阅 org.pac4j.saml.util.configuration$DefaultConfigurationManager),但开发人员重写此配置可能更有用。
Pac4j 使用 Java 服务提供程序查找配置类并启动 OpenSAML 库。它将加载它可以在类路径上找到的 org.pac4j.saml.util.Configuration 的所有实现,并使用具有 javax.annotation.Priority 值的实现。
要使用自定义配置,必须将具有以下内容的 jar 添加到 classpath 中:
- 实现 
org.pac4j.saml.util.Configuration。此实现具有javax.annotation.Priority注解,代表优先级。最小值是最终将使用的配置。默认实现的有效优先级为100。通用提供程序可能应该使用50,而最终用户实现程序应该使用1。例如: 
  @Priority(100)
  public static class DefaultConfigurationManager implements ConfigurationManager {
      @Override
      public void configure() {
          XMLObjectProviderRegistry registry;
          synchronized (ConfigurationService.class) {
              registry = ConfigurationService.get(XMLObjectProviderRegistry.class);
              if (registry == null) {
                  registry = new XMLObjectProviderRegistry();
                  ConfigurationService.register(XMLObjectProviderRegistry.class, registry);
              }
          }
          try {
              InitializationService.initialize();
          } catch (final InitializationException e) {
              throw new RuntimeException("Exception initializing OpenSAML", e);
          }
          ParserPool parserPool = initParserPool();
          registry.setParserPool(parserPool);
      }
      private static ParserPool initParserPool() {
          try {
              BasicParserPool parserPool = new BasicParserPool();
              parserPool.setMaxPoolSize(100);
              parserPool.setCoalescing(true);
              parserPool.setIgnoreComments(true);
              parserPool.setNamespaceAware(true);
              parserPool.setExpandEntityReferences(false);
              parserPool.setXincludeAware(false);
              parserPool.setIgnoreElementContentWhitespace(true);
              final Map<String, Object> builderAttributes = new HashMap<String, Object>();
              parserPool.setBuilderAttributes(builderAttributes);
              final Map<String, Boolean> features = new HashMap<>();
              features.put("http://apache.org/xml/features/disallow-doctype-decl", Boolean.TRUE);
              features.put("http://apache.org/xml/features/validation/schema/normalized-value", Boolean.FALSE);
              features.put("http://javax.xml.XMLConstants/feature/secure-processing", Boolean.TRUE);
              features.put("http://xml.org/sax/features/external-general-entities", Boolean.FALSE);
              features.put("http://xml.org/sax/features/external-parameter-entities", Boolean.FALSE);
              parserPool.setBuilderFeatures(features);
              parserPool.initialize();
              return parserPool;
          } catch (final ComponentInitializationException e) {
              throw new RuntimeException("Exception initializing parserPool", e);
          }
      }
  }
 2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/META-INF/services/org.pac4j.saml.util.ConfigurationManager文件。此文件应具有org.pac4j.saml.util.Configuratio实现的完全限定类名。
更多信息,参阅https://docs.oracle.com/javase/tutorial/ext/basics/spi.html (opens new window)。
# 8)SAML 消息存储
消息存储是 pac4j 用来跟踪正在进行的请求和传入响应的抽象。
# HttpSessionStore
此实现使用 HttpSession 作为底层数据存储。
# HazelcastSAMLMessageStore
此实现使用 Hazelcast 作为底层数据存储。
它要求:
- 附加依赖
 
<dependency>
    <groupId>com.hazelcast</groupId>
    <artifactId>hazelcast</artifactId>
    <version>${hazelcast.version}</version>
    <optional>true</optional>
</dependency>
 2
3
4
5
6
- 将 HazelcastInstance 对象传递给构造函数