WildFly 和 EAP 的 Keycloak SAML Galleon 功能包

使用 Keycloak SAML Galleon 功能包保护 WildFly 和 EAP 中的应用程序

SAML 适配器作为 Galleon 功能包发布,适用于 Wildfly 29 或更高版本。有关此主题的更多详细信息,请参阅 WildFly 文档JBoss EAP 8 GA 也提供了相同的选项。

有关如何将 Keycloak 与运行在最新 Wildfly/EAP 上的 JakartaEE 应用程序集成的示例,请查看 Keycloak 快速入门 GitHub 仓库 中的 servlet-saml-service-provider Jakarta 文件夹。

安装

功能包的提供是使用 wildfly-maven-pluginwildfly-jar-maven-plugineap-maven-plugin 分别完成的。

使用 wildfly maven 插件的示例

<plugin>
    <groupId>org.wildfly.plugins</groupId>
    <artifactId>wildfly-maven-plugin</artifactId>
    <version>5.0.0.Final</version>
    <configuration>
        <feature-packs>
            <feature-pack>
                <location>wildfly@maven(org.jboss.universe:community-universe)#32.0.1.Final</location>
            </feature-pack>
            <feature-pack>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-saml-adapter-galleon-pack</artifactId>
                <version>26.0.5</version>
            </feature-pack>
        </feature-packs>
        <layers>
            <layer>core-server</layer>
            <layer>web-server</layer>
            <layer>jaxrs-server</layer>
            <layer>datasources-web-server</layer>
            <layer>webservices</layer>
            <layer>keycloak-saml</layer>
            <layer>keycloak-client-saml</layer>
            <layer>keycloak-client-saml-ejb</layer>
        </layers>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>package</goal>
            </goals>
        </execution>
    </executions>
</plugin>

使用 wildfly jar maven 插件的示例

<plugin>
    <groupId>org.wildfly.plugins</groupId>
    <artifactId>wildfly-jar-maven-plugin</artifactId>
    <version>11.0.2.Final</version>
    <configuration>
        <feature-packs>
            <feature-pack>
                <location>wildfly@maven(org.jboss.universe:community-universe)#32.0.1.Final</location>
            </feature-pack>
            <feature-pack>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-saml-adapter-galleon-pack</artifactId>
                <version>26.0.5</version>
            </feature-pack>
        </feature-packs>
        <layers>
            <layer>core-server</layer>
            <layer>web-server</layer>
            <layer>jaxrs-server</layer>
            <layer>datasources-web-server</layer>
            <layer>webservices</layer>
            <layer>keycloak-saml</layer>
            <layer>keycloak-client-saml</layer>
            <layer>keycloak-client-saml-ejb</layer>
        </layers>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>package</goal>
            </goals>
        </execution>
    </executions>
</plugin>

使用 EAP maven 插件的示例

<plugin>
    <groupId>org.jboss.eap.plugins</groupId>
    <artifactId>eap-maven-plugin</artifactId>
    <version>1.0.0.Final-redhat-00014</version>
    <configuration>
        <channels>
            <channel>
                <manifest>
                    <groupId>org.jboss.eap.channels</groupId>
                    <artifactId>eap-8.0</artifactId>
                </manifest>
            </channel>
        </channels>
        <feature-packs>
            <feature-pack>
                <location>org.keycloak:keycloak-saml-adapter-galleon-pack</location>
            </feature-pack>
        </feature-packs>
        <layers>
            <layer>core-server</layer>
            <layer>web-server</layer>
            <layer>jaxrs-server</layer>
            <layer>datasources-web-server</layer>
            <layer>webservices</layer>
            <layer>keycloak-saml</layer>
            <layer>keycloak-client-saml</layer>
            <layer>keycloak-client-saml-ejb</layer>
        </layers>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>package</goal>
            </goals>
        </execution>
    </executions>
</plugin>

配置

SAML 客户端适配器由一个 XML 文件 /WEB-INF/keycloak-saml.xml 配置,该文件放置在 WAR 部署内部。配置可能如下所示

<keycloak-saml-adapter xmlns="urn:keycloak:saml:adapter"
                       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xsi:schemaLocation="urn:keycloak:saml:adapter {saml_adapter_xsd_urn}">
    <SP entityID="http://127.0.0.1:8081/sales-post-sig/"
        sslPolicy="EXTERNAL"
        nameIDPolicyFormat="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
        logoutPage="/logout.jsp"
        forceAuthentication="false"
        isPassive="false"
        turnOffChangeSessionIdOnLogin="false"
        autodetectBearerOnly="false">
        <Keys>
            <Key signing="true" >
                <KeyStore resource="/WEB-INF/keystore.jks" password="store123">
                    <PrivateKey alias="http://127.0.0.1:8080/sales-post-sig/" password="test123"/>
                    <Certificate alias="http://127.0.0.1:8080/sales-post-sig/"/>
                </KeyStore>
            </Key>
        </Keys>
        <PrincipalNameMapping policy="FROM_NAME_ID"/>
        <RoleIdentifiers>
            <Attribute name="Role"/>
        </RoleIdentifiers>
        <RoleMappingsProvider id="properties-based-role-mapper">
            <Property name="properties.resource.location" value="/WEB-INF/role-mappings.properties"/>
        </RoleMappingsProvider>
        <IDP entityID="idp"
             signaturesRequired="true">
        <SingleSignOnService requestBinding="POST"
                             bindingUrl="http://127.0.0.1:8081/realms/demo/protocol/saml"
                    />

            <SingleLogoutService
                    requestBinding="POST"
                    responseBinding="POST"
                    postBindingUrl="http://127.0.0.1:8081/realms/demo/protocol/saml"
                    redirectBindingUrl="http://127.0.0.1:8081/realms/demo/protocol/saml"
                    />
            <Keys>
                <Key signing="true">
                    <KeyStore resource="/WEB-INF/keystore.jks" password="store123">
                        <Certificate alias="demo"/>
                    </KeyStore>
                </Key>
            </Keys>
        </IDP>
     </SP>
</keycloak-saml-adapter>

您可以使用 ${…​} 作为系统属性替换。例如 ${jboss.server.config.dir}。要获取有关 XML 配置文件中不同元素的详细信息,请参阅 Keycloak SAML Galleon 功能包详细配置

保护 WAR

本节介绍如何通过在 WAR 包中添加配置和编辑文件来直接保护 WAR。

创建 keycloak-saml.xml 并将其放在 WAR 的 WEB-INF 目录中后,您必须在 web.xml 中将 auth-method 设置为 KEYCLOAK-SAML。您还必须使用标准 servlet 安全性在您的 URL 上指定基于角色的约束。以下是一个 web.xml 文件示例

<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">

	<module-name>customer-portal</module-name>

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Admins</web-resource-name>
            <url-pattern>/admin/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>admin</role-name>
        </auth-constraint>
        <user-data-constraint>
            <transport-guarantee>CONFIDENTIAL</transport-guarantee>
        </user-data-constraint>
    </security-constraint>
    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Customers</web-resource-name>
            <url-pattern>/customers/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>user</role-name>
        </auth-constraint>
        <user-data-constraint>
            <transport-guarantee>CONFIDENTIAL</transport-guarantee>
        </user-data-constraint>
    </security-constraint>

    <login-config>
        <auth-method>KEYCLOAK-SAML</auth-method>
        <realm-name>this is ignored currently</realm-name>
    </login-config>

    <security-role>
        <role-name>admin</role-name>
    </security-role>
    <security-role>
        <role-name>user</role-name>
    </security-role>
</web-app>

所有标准 servlet 设置,除了 auth-method 设置。

使用 Keycloak SAML 子系统保护 WAR

您不必打开 WAR 就可以使用 Keycloak 来保护它。或者,您可以通过 Keycloak SAML 适配器子系统从外部保护它。虽然您不必将 KEYCLOAK-SAML 指定为 auth-method,但您仍然需要在 web.xml 中定义 security-constraints。但是,您不必创建 WEB-INF/keycloak-saml.xml 文件。此元数据将在您的服务器的 domain.xmlstandalone.xml 子系统配置部分中的 XML 内定义。

<extensions>
  <extension module="org.keycloak.keycloak-saml-adapter-subsystem"/>
</extensions>

<profile>
  <subsystem xmlns="urn:jboss:domain:keycloak-saml:1.1">
    <secure-deployment name="WAR MODULE NAME.war">
      <SP entityID="APPLICATION URL">
        ...
      </SP>
    </secure-deployment>
  </subsystem>
</profile>

secure-deploymentname 属性标识您要保护的 WAR。它的值是在 web.xml 中定义的 module-name,后面附加了 .war。其余配置使用与 通用适配器配置 中定义的 keycloak-saml.xml 配置相同的 XML 语法。

配置示例

<subsystem xmlns="urn:jboss:domain:keycloak-saml:1.1">
  <secure-deployment name="saml-post-encryption.war">
    <SP entityID="http://127.0.0.1:8080/sales-post-enc/"
        sslPolicy="EXTERNAL"
        nameIDPolicyFormat="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
        logoutPage="/logout.jsp"
        forceAuthentication="false">
      <Keys>
        <Key signing="true" encryption="true">
          <KeyStore resource="/WEB-INF/keystore.jks" password="store123">
            <PrivateKey alias="http://127.0.0.1:8080/sales-post-enc/" password="test123"/>
            <Certificate alias="http://127.0.0.1:8080/sales-post-enc/"/>
          </KeyStore>
        </Key>
      </Keys>
      <PrincipalNameMapping policy="FROM_NAME_ID"/>
      <RoleIdentifiers>
        <Attribute name="Role"/>
      </RoleIdentifiers>
      <IDP entityID="idp">
        <SingleSignOnService signRequest="true"
            validateResponseSignature="true"
            requestBinding="POST"
            bindingUrl="http://127.0.0.1:8080/realms/saml-demo/protocol/saml"/>

        <SingleLogoutService
            validateRequestSignature="true"
            validateResponseSignature="true"
            signRequest="true"
            signResponse="true"
            requestBinding="POST"
            responseBinding="POST"
            postBindingUrl="http://127.0.0.1:8080/realms/saml-demo/protocol/saml"
            redirectBindingUrl="http://127.0.0.1:8080/realms/saml-demo/protocol/saml"/>
        <Keys>
          <Key signing="true" >
            <KeyStore resource="/WEB-INF/keystore.jks" password="store123">
              <Certificate alias="saml-demo"/>
            </KeyStore>
          </Key>
        </Keys>
      </IDP>
    </SP>
   </secure-deployment>
</subsystem>

为 JSESSIONID cookie 设置 SameSite 值

浏览器计划将 cookie 的 SameSite 属性的默认值设置为 Lax。此设置意味着只有当请求来自同一域时,cookie 才会发送到应用程序。此行为会影响 SAML POST 绑定,这可能会导致其无法正常工作。为了保持 SAML 适配器的完整功能,我们建议您将 JSESSIONID cookie 的 SameSite 值设置为 None,该 cookie 由您的容器创建。否则,可能会导致每次向 Keycloak 发出请求时重置容器的会话。

为了避免将 SameSite 属性设置为 None,请考虑切换到 REDIRECT 绑定(如果可接受),或者切换到 OIDC 协议,在这种协议中不需要此解决方法。

要为 Wildfly/EAP 中的 JSESSIONID cookie 设置 SameSite 值为 None,请将包含以下内容的文件 undertow-handlers.conf 添加到应用程序的 WEB-INF 目录中。

samesite-cookie(mode=None, cookie-pattern=JSESSIONID)

Wildfly 从 19.1.0 版本开始支持此配置。

向身份提供者注册

对于每个基于 servlet 的适配器,您为断言使用者服务 URL 和单点登录服务注册的端点必须是您的 servlet 应用程序的基 URL,并在其后面附加 /saml,即 https://example.com/contextPath/saml

注销

有多种方法可以从 Web 应用程序注销。对于 Jakarta EE servlet 容器,您可以调用 HttpServletRequest.logout()。对于任何其他浏览器应用程序,您可以将浏览器指向 Web 应用程序的任何具有安全约束的 URL,并传递查询参数 GLO,即 http://myapp?GLO=true。如果您使用浏览器与 SSO 会话关联,这将使您注销。

集群环境中的注销

在内部,SAML 适配器存储 SAML 会话索引、主体名称(如果已知)和 HTTP 会话 ID 之间的映射。此映射可以在 JBoss 应用程序服务器系列(WildFly 10/11、EAP 6/7)中跨集群维护,以用于可分发应用程序。作为先决条件,HTTP 会话需要在集群中分发(即应用程序在应用程序的 web.xml 中使用 <distributable/> 标签标记)。

要启用此功能,请将以下部分添加到 /WEB_INF/web.xml 文件中

<context-param>
    <param-name>keycloak.sessionIdMapperUpdater.classes</param-name>
    <param-value>org.keycloak.adapters.saml.wildfly.infinispan.InfinispanSessionCacheIdMapperUpdater</param-value>
</context-param>

如果部署的会话缓存名为 deployment-cache,则用于 SAML 映射的缓存将命名为 deployment-cache.ssoCache。缓存的名称可以通过上下文参数 keycloak.sessionIdMapperUpdater.infinispan.cacheName 覆盖。包含缓存的缓存容器将与包含部署会话缓存的容器相同,但可以通过上下文参数 keycloak.sessionIdMapperUpdater.infinispan.containerName 覆盖。

默认情况下,SAML 映射缓存的配置将从会话缓存派生。配置可以手动在服务器的缓存配置部分中覆盖,与其他缓存相同。

目前,为了提供可靠的服务,建议对 SAML 会话缓存使用复制缓存。使用分布式缓存可能会导致 SAML 注销请求到达没有访问 SAML 会话索引到 HTTP 会话映射的节点,这会导致注销失败。

跨站点场景中的注销

处理跨越多个数据中心的会话需要特殊处理。想象一下以下场景

  1. 登录请求在数据中心 1 中的集群内处理。

  2. 管理员对特定 SAML 会话发出注销请求,该请求到达数据中心 2。

数据中心 2 必须注销数据中心 1(以及共享 HTTP 会话的所有其他数据中心)中存在的所有会话。

为了解决这种情况,上面描述的 SAML 会话缓存需要不仅在各个集群内复制,还需要在所有数据中心之间复制,例如 通过独立的 Infinispan/JDG 服务器

  1. 必须将缓存添加到独立的 Infinispan/JDG 服务器。

  2. 必须将上一项中的缓存作为远程存储添加到相应的 SAML 会话缓存。

一旦在部署期间发现 SAML 会话缓存中存在远程存储,就会监视其更改,并相应地更新本地 SAML 会话缓存。

获取断言属性

在成功进行 SAML 登录后,您的应用程序代码可能希望获取与 SAML 断言一起传递的属性值。HttpServletRequest.getUserPrincipal() 返回一个 Principal 对象,您可以将其类型转换为 Keycloak 特定类,称为 org.keycloak.adapters.saml.SamlPrincipal。此对象允许您查看原始断言,并且还具有用于查找属性值的便捷函数。

package org.keycloak.adapters.saml;

public class SamlPrincipal implements Serializable, Principal {
    /**
     * Get full saml assertion
     *
     * @return
     */
    public AssertionType getAssertion() {
       ...
    }

    /**
     * Get SAML subject sent in assertion
     *
     * @return
     */
    public String getSamlSubject() {
        ...
    }

    /**
     * Subject nameID format
     *
     * @return
     */
    public String getNameIDFormat() {
        ...
    }

    @Override
    public String getName() {
        ...
    }

    /**
     * Convenience function that gets Attribute value by attribute name
     *
     * @param name
     * @return
     */
    public List<String> getAttributes(String name) {
        ...

    }

    /**
     * Convenience function that gets Attribute value by attribute friendly name
     *
     * @param friendlyName
     * @return
     */
    public List<String> getFriendlyAttributes(String friendlyName) {
        ...
    }

    /**
     * Convenience function that gets first  value of an attribute by attribute name
     *
     * @param name
     * @return
     */
    public String getAttribute(String name) {
        ...
    }

    /**
     * Convenience function that gets first  value of an attribute by attribute name
     *
     *
     * @param friendlyName
     * @return
     */
    public String getFriendlyAttribute(String friendlyName) {
        ...
    }

    /**
     * Get set of all assertion attribute names
     *
     * @return
     */
    public Set<String> getAttributeNames() {
        ...
    }

    /**
     * Get set of all assertion friendly attribute names
     *
     * @return
     */
    public Set<String> getFriendlyNames() {
        ...
    }
}

错误处理

Keycloak 为基于 servlet 的客户端适配器提供了一些错误处理功能。当身份验证中遇到错误时,客户端适配器将调用 HttpServletResponse.sendError()。您可以在 web.xml 文件中设置一个 error-page 来处理您想要的错误。客户端适配器可以抛出 400、401、403 和 500 错误。

<error-page>
    <error-code>403</error-code>
    <location>/ErrorHandler</location>
</error-page>

客户端适配器还设置了一个 HttpServletRequest 属性,您可以检索该属性。属性名称为 org.keycloak.adapters.spi.AuthenticationError。将此对象类型转换为:org.keycloak.adapters.saml.SamlAuthenticationError。此类可以准确地告诉您发生了什么。如果未设置此属性,则适配器不会对错误代码负责。

public class SamlAuthenticationError implements AuthenticationError {
    public static enum Reason {
        EXTRACTION_FAILURE,
        INVALID_SIGNATURE,
        ERROR_STATUS
    }

    public Reason getReason() {
        return reason;
    }
    public StatusResponseType getStatus() {
        return status;
    }
}

故障排除

解决问题的最佳方法是打开客户端适配器和 Keycloak 服务器中的 SAML 调试。使用您的日志记录框架,将 org.keycloak.saml 包的日志级别设置为 DEBUG。打开此选项可以查看发送到服务器和从服务器发送的 SAML 请求和响应文档。

多租户

SAML 提供多租户功能,这意味着单个目标应用程序 (WAR) 可以使用多个 Keycloak 领域进行保护。这些领域可以位于同一个 Keycloak 实例上,也可以位于不同的实例上。

为此,应用程序必须具有多个 keycloak-saml.xml 适配器配置文件。

虽然您可以将 WAR 的多个实例与不同的适配器配置文件部署到不同的上下文路径,但这可能很不方便,您可能还希望根据上下文路径以外的其他内容来选择领域。

Keycloak 使创建自定义配置解析器成为可能,因此您可以选择为每个请求使用哪个适配器配置。在 SAML 中,配置仅在登录处理中很重要;用户登录后,会话将被身份验证,因此返回的 keycloak-saml.xml 是否不同并不重要。因此,对于同一个会话返回相同的配置是正确的方法。

要实现此目的,请创建一个 org.keycloak.adapters.saml.SamlConfigResolver 的实现。以下示例使用 Host 标头来定位正确的配置,并从应用程序的 Java 类路径加载配置及其相关元素

package example;

import java.io.InputStream;
import org.keycloak.adapters.saml.SamlConfigResolver;
import org.keycloak.adapters.saml.SamlDeployment;
import org.keycloak.adapters.saml.config.parsers.DeploymentBuilder;
import org.keycloak.adapters.saml.config.parsers.ResourceLoader;
import org.keycloak.adapters.spi.HttpFacade;
import org.keycloak.saml.common.exceptions.ParsingException;

public class SamlMultiTenantResolver implements SamlConfigResolver {

    @Override
    public SamlDeployment resolve(HttpFacade.Request request) {
        String host = request.getHeader("Host");
        String realm = null;
        if (host.contains("tenant1")) {
            realm = "tenant1";
        } else if (host.contains("tenant2")) {
            realm = "tenant2";
        } else {
            throw new IllegalStateException("Not able to guess the keycloak-saml.xml to load");
        }

        InputStream is = getClass().getResourceAsStream("/" + realm + "-keycloak-saml.xml");
        if (is == null) {
            throw new IllegalStateException("Not able to find the file /" + realm + "-keycloak-saml.xml");
        }

        ResourceLoader loader = new ResourceLoader() {
            @Override
            public InputStream getResourceAsStream(String path) {
                return getClass().getResourceAsStream(path);
            }
        };

        try {
            return new DeploymentBuilder().build(is, loader);
        } catch (ParsingException e) {
            throw new IllegalStateException("Cannot load SAML deployment", e);
        }
    }
}

您还必须在 web.xml 中使用 keycloak.config.resolver 上下文参数配置要使用的 SamlConfigResolver 实现

<web-app>
    ...
    <context-param>
        <param-name>keycloak.config.resolver</param-name>
        <param-value>example.SamlMultiTenantResolver</param-value>
    </context-param>
</web-app>

Keycloak 特定的错误

Keycloak 服务器可以在 SAML 响应中向客户端应用程序发送错误,其中可能包含 SAML 状态,例如

<samlp:Status>
  <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder">
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"/>
  </samlp:StatusCode>
  <samlp:StatusMessage>authentication_expired</samlp:StatusMessage>
</samlp:Status>

当用户经过身份验证并具有 SSO 会话,但身份验证会话在当前浏览器选项卡中过期,因此 Keycloak 服务器无法自动对用户进行 SSO 重新身份验证并重定向回客户端,并返回成功响应时,Keycloak 会发送此错误。当客户端应用程序收到此类错误时,理想情况下应立即重试身份验证,并向 Keycloak 服务器发送新的 SAML 请求,该请求通常应始终对用户进行身份验证,因为存在 SSO 会话,并重定向回。如果服务器返回注释掉的状态,SAML 适配器会自动执行此重试。有关详细信息,请参阅 服务器管理指南