Ohhnews

分类导航

$ cd ..
foojay原文

基于 pac4j 与 Apereo CAS 实现 OpenID Federation

#openid federation#pac4j#apereo cas#身份认证#java

OpenID Connect(OIDC)已成为现代应用登录的首选协议,并逐步接管了许多过去由 SAML 或基于 OAuth 构建的自定义认证所处理的用例。OIDC 在 OAuth 2.0 之上增加了标准化的身份层:OAuth 提供委托授权,而 OIDC 还会告诉应用是谁登录了。

该生态系统已经发展成为一个规范家族,涵盖许多需求:从提供方发现、动态客户端注册,到会话管理与注销。但在这幅图景中仍缺少一块:一种能在整个联邦中建立信任的标准方式,而无需在每个身份提供方处逐一接入每个应用。这正是 OpenID Federation 要解决的问题。

在典型的 OIDC 设置中,你会在 OpenID 提供方(OP)(即 OIDC 服务器)处注册 依赖方(RP)(即客户端应用)。你会配置其重定向 URI,并获取 client_id,通常还会获取 client_secret。对每个应用和每个提供方重复这一过程,管理工作很快就会累积起来。

OpenID Federation 引入了一个受信任的第三方:信任锚(TA)。RP 和 OP 通过签名的实体声明以及以共同信任锚为根的信任链来建立信任。这使它们能够获取并验证彼此的元数据,注册可以自动完成,也可以通过显式的联邦注册交互完成。在下面的设置中,我们使用密钥和 private_key_jwt 客户端认证,而不是在 OP 处配置共享的客户端密钥。信任锚在参与方之间建立信任;OP 仍然负责对用户进行认证。

pac4j 是一个开源 Java 安全库,用于在多种 Web 框架中实现认证与授权。我是 Jérôme LELEU,它的创建者和维护者。自 6.4.0 版本起,pac4j 已支持以 RP 身份使用 OpenID Federation;据我所知,它是首个支持该协议的 Java 安全库。

在将这个示例带到 Foojay 之前,我在 pac4j 博客的三篇文章中探讨了该协议:

  • pac4j 与 Connect2id,第 1 和第 2 部分。 第一篇使用 pac4j 作为 RP、Connect2id 作为 OP 以及一个模拟信任锚,建立了一个可运行的设置。第二篇进一步介绍了登录交互、信任链缓存,以及自动客户端注册与显式客户端注册之间的差异,包括提供方返回的凭据。
  • pac4j、Connect2id 和 CAS。 第三篇用 Apereo CAS 服务器替换了模拟信任锚,同时保留 Connect2id 作为 OP,以展示不同实现之间的互操作性。你无需阅读这些文章即可跟随本教程:我们将在这里配置全部三个参与方。

pac4j 早已是 Apereo CAS 中的一等公民:CAS 在其 OAuth/OIDC 支持中使用它来实现委托认证、令牌认证和安全性。在 OpenID Federation 流程中一起测试它们是很自然的下一步。随着 CAS 8.1 增加对联邦 OP 角色的支持,我们现在可以使用 pac4j 作为 RP,并让两个 CAS 服务器分别作为 OP 和信任锚。让我们把这个设置付诸实践。

1)三个参与方

我们的设置包含三个组件:

  • 一个客户端,在 OIDC 中称为依赖方(RP),我们使用 pac4j
  • 一个服务器,在 OIDC 中称为 OpenID 提供方(OP),我们使用 CAS 服务器
  • 一个信任锚(简称 TA),我们使用 CAS 服务器。

尽管 OP 和 TA 都使用 CAS 服务器,但我们不能使用单个 CAS 节点:我们有两个独立的 CAS 服务器节点,每个节点支持一种独占角色。

我们使用以下本地 URL:

  • OP 运行在 http://localhost:8080/cas
  • RP 运行在 http://localhost:8081
  • TA 运行在 http://localhost:8082/cas。

2)CAS 通用配置

对于 CAS 服务器节点,我们使用这个基础 Maven overlay:https://github.com/casinthecloud/cas-overlay-demo。

下面的示例对 CAS 节点使用 CAS 8.1.0-RC1 和 Java 25,对 RP 使用 spring-webmvc-pac4j 8.0.3 和 pac4j-oidc 6.5.6。这些是本演练使用的最低版本。你还需要 Maven 和三个独立的工作目录,每个参与方一个。

我们的两个 CAS 服务器都有类似的 pom.xml 文件:

$ xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.casinthecloud</groupId>
    <artifactId>cas-overlay-demo</artifactId>
    <version>8.1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <dependencies>
        <dependency>
            <groupId>org.apereo.cas</groupId>
            <artifactId>cas-server-webapp${tomcat.properties}</artifactId>
            <version>${cas.version}</version>
            <type>war</type>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.apereo.cas</groupId>
            <artifactId>cas-server-support-oidc-federation</artifactId>
            <version>${cas.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.3.1</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                    <recompressZippedFiles>false</recompressZippedFiles>
                    <archive>
                        <compress>false</compress>
                        <manifestFile>${project.build.directory}/war/work/org.apereo.cas/cas-server-webapp${tomcat.properties}/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                    <overlays>
                        <overlay>
                            <groupId>org.apereo.cas</groupId>
                            <artifactId>cas-server-webapp${tomcat.properties}</artifactId>
                        </overlay>
                    </overlays>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>4.2.0-M1</version>
                <configuration>
                    <mainClass>org.apereo.cas.web.CasWebApplication</mainClass>
                    <excludes>
                        <exclude>
                            <groupId>org.apereo.cas</groupId>
                            <artifactId>cas-server-webapp-tomcat</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <finalName>cas</finalName>
    </build>

    <properties>
        <cas.version>8.1.0-RC1</cas.version>
        <java.version>25</java.version>
        <tomcat.properties>-tomcat</tomcat.properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

不过,与 TA 相比,OP 还多一个 CAS 依赖:

$ xml
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-support-oidc</artifactId>
    <version>${cas.version}</version>
</dependency>

并且每个服务器特有的配置会有所不同。

对于 pac4j 应用,我们使用这个简单示例:https://github.com/pac4j/simple-spring-boot-pac4j-demos/tree/oidc/src/main/java/org/pac4j/demos。

3)将 CAS 服务器设置为 OP

在 OP 节点的 application.yml 文件中,我们将 CAS 服务器设置为充当 OpenID 提供方:

$ config
server.ssl.enabled: false
server.port: 8080

cas.tgc.secure: false
cas.tgc.same-site-policy: Lax

cas.tgc.crypto.enabled: false
cas.webflow.crypto.enabled: false

cas.server.name: http://localhost:${server.port}
cas.server.prefix: ${cas.server.name}/cas
cas.host.name: casop

cas.authn.oidc.jwks.file-system.jwks-file: file:./metadata/oidc.jwks
cas.authn.oidc.federation.role: OPENID_PROVIDER
cas.authn.oidc.federation.jwks-file: file:./metadata/federation.jwks
cas.authn.oidc.federation.authority-hints:
  - http://localhost:8082/cas/oidc
cas.authn.oidc.core.issuer: ${cas.server.prefix}/oidc

配置相当简单:我们让 CAS 服务器运行在 http://localhost:8080/cas(不使用 SSL,策略为 Lax,不启用 cookie/webflow 加密,仅用于开发)。

对于 OIDC,我们定义其 OIDC 基础 URL(issuer)及其 JWKS(./metadata/oidc.jwks)。

而对于联邦部分,我们设置 OPENID_PROVIDER 角色、专用 JWKS(./metadata/federation.jwks)以及信任锚:

$ config
cas.authn.oidc.federation.authority-hints:
  - http://localhost:8082/cas/oidc

4)将 pac4j 应用设置为 RP

在 pac4j 生态中,Spring Boot 是最流行的 Web 技术栈,因此我们在这个简单示例中使用 spring-webmvc-pac4j 实现:https://github.com/pac4j/simple-spring-boot-pac4j-demos/tree/oidc/src/main/java/org/pac4j/demos:

  • SpringBootDemo 类运行 Spring Boot 示例
  • SecurityConfig 类定义安全配置(OIDC + URL 保护)
  • Application 类是一个简单控制器,包含两个 URL:一个是公开的,另一个是受保护的。

a)依赖

我们需要以下依赖:

$ xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>spring-webmvc-pac4j</artifactId>
    <version>8.0.3</version>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-oidc</artifactId>
    <version>6.5.6</version>
</dependency>

b)属性

借助 application.properties 文件,我们让它运行在 8081 端口:

$ properties
server.port=8081
app.base-url=http://localhost:8081

c)SecurityConfig 类

我们更新 SecurityConfig 类,以更改联邦相关配置:

$ java
package org.pac4j.demos;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
import org.pac4j.core.config.Config;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.pac4j.oidc.config.method.PrivateKeyJwtClientAuthnMethodConfig;
import org.pac4j.oidc.federation.config.OidcTrustAnchorProperties;
import org.pac4j.springframework.config.Pac4jSecurityConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

import java.util.List;

@Configuration
public class SecurityConfig extends Pac4jSecurityConfig {

    @Value("${app.base-url:http://localhost:8080}")
    private String baseUri;

    @Bean
    public Config config() {
        final var config = new OidcConfiguration();
        config.setAllowUnsignedIdTokens(true);

        final var rpJwks = config.getRpJwks();
        rpJwks.setJwksPath("file:./metadata/rpjwks.jwks");
        rpJwks.setKid("defaultjwks26");
        config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
        final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
        config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

        config.setRequestObjectSigningAlgorithm(JWSAlgorithm.RS256);

        final var federation = config.getFederation();

        federation.setTargetOp("http://localhost:8080/cas/oidc");
        final var trust = new OidcTrustAnchorProperties();
        trust.setIssuer("http://localhost:8082/cas/oidc");
        trust.setJwksPath("classpath:trustanchor.jwks");
        federation.getTrustAnchors().add(trust);

        federation.getJwks().setJwksPath("file:./metadata/oidcfede.jwks");
        federation.getJwks().setKid("mykeyoidcfede26");
        federation.setContactName("RP with CAS");
        federation.setContactEmails(List.of("[email protected]"));

        federation.setEntityId("http://localhost:8081");

        return new Config(baseUri + "/callback", new OidcClient(config));
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        addSecurity(registry, "OidcClient").addPathPatterns("/protected/**");
    }
}

RP 配置将 OIDC 客户端密钥与联邦信任设置结合在一起。

该示例保留了本地演示中的 setAllowUnsignedIdTokens(true)。OpenID Federation 并不要求这样做。对于生产环境的提供方,请移除它并要求使用签名的 ID 令牌。本演练中的 HTTP URL 以及禁用的 CAS cookie/webflow 加密也仅用于本地开发。

我们为 RP 定义一个全局 JWKS,它也用于 private_key_jwt 认证方法:

$ java
    final var rpJwks = config.getRpJwks();
    rpJwks.setJwksPath("file:./metadata/rpjwks.jwks");
    rpJwks.setKid("defaultjwks26");
    config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
    final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
    config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

对于联邦,我们使用:

$ java
    config.setRequestObjectSigningAlgorithm(JWSAlgorithm.RS256);

    final var federation = config.getFederation();

    federation.setTargetOp("http://localhost:8080/cas/oidc");
    final var trust = new OidcTrustAnchorProperties();
    trust.setIssuer("http://localhost:8082/cas/oidc");
    trust.setJwksPath("classpath:trustanchor.jwks");
    federation.getTrustAnchors().add(trust);

目标 OP 当然是充当 OP 的 CAS 服务器。

对于信任锚,则是另一个 CAS 节点(端口 8082),我们从 http://localhost:8082/cas/oidc/.well-known/openid-federation 获取其 JWKS,并将其保存到 trustanchor.jwks 文件中。

示例:

$ cat
{
    "keys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "19f7aaaa-c4d3-4c26-a26e-3ce4b8ab1beb",
            "n": "uXgnb...SEdSw"
        }
    ]
}

我们还有一个专用于联邦的特定 JWKS 配置(显示在 .well-known/openid-federation 端点上):

$ java
    federation.getJwks().setJwksPath("file:./metadata/oidcfede.jwks");
    federation.getJwks().setKid("mykeyoidcfede26");
    federation.setContactName("RP with CAS");
    federation.setContactEmails(List.of("[email protected]"));

    federation.setEntityId("http://localhost:8081");

d)Application 类

我们还需要更新 Application 类,为 OpenID 联邦端点添加映射:

$ java
@Controller
public class Application {

    @Autowired
    private Config config;

    @RequestMapping(value = "/.well-known/openid-federation", produces = DefaultEntityConfigurationGenerator.CONTENT_TYPE)
    @ResponseBody
    public String oidcFederation() throws HttpAction {
        final var oidcClient = (OidcClient) config.getClients().findClient("OidcClient").get();
        return oidcClient.getConfiguration().getFederation().getEntityConfigurationGenerator().generateEntityStatement();
    }

    ...
}
```## 5) 将 CAS 服务器设置为信任锚

这次我们在 `application.yml` 文件中将 CAS 服务器设置为信任锚:

```yaml
server.ssl.enabled: false
server.port: 8082

cas.tgc.secure: false
cas.tgc.same-site-policy: Lax

cas.tgc.crypto.enabled: false
cas.webflow.crypto.enabled: false

cas.server.name: http://localhost:${server.port}
cas.server.prefix: ${cas.server.name}/cas
cas.host.name: casta

cas.authn.oidc.federation.role: TRUST_ANCHOR
cas.authn.oidc.federation.jwks-file: file:./metadata/trustanchor.jwks
cas.authn.oidc.core.issuer: ${cas.server.prefix}/oidc
cas.authn.oidc.federation.subordinate-directory: ./subordinates

该配置与另一个 CAS 节点相近,区别在于我们定义了 TRUST_ANCHOR 角色,以及用于定义其下级实体的文件目录。

下级实体是 CAS 服务器为其提供信任的实体。必须预先使用它们的元数据和联邦密钥对它们进行定义。

在这里,它们将是 RP(pac4j 客户端,即 Spring Boot 演示应用)和 OP(另一个 CAS 服务器)。

对于 RP(pac4j),我们调用 URL:http://localhost:8081/.well-known/openid-federation。Spring Boot 演示应用会返回一个实体声明,我们可以通过任意 JWT 工具或 jwt.io 网站对其进行解码。

我们使用 metadata 以及 jwks 属性中的 keys(不在 metadata 属性中)来构建下级实体文件 rp.json(放置在 ./subordinates 目录中):

$ cat
{
    "entityId": "http://localhost:8081",
    "metadata": {
        "openid_relying_party": {
            "redirect_uris": [
                "http://localhost:8081/callback?client_name=OidcClient"
            ],
            "application_type": "web",
            "response_types": [
                "code"
            ],
            "grant_types": [
                "authorization_code"
            ],
            "scope": "openid email profile",
            "token_endpoint_auth_method": "private_key_jwt",
            "token_endpoint_auth_signing_alg": "RS256",
            "request_object_signing_alg": "RS256",
            "jwks": {
                "keys": [
                    {
                        "kty": "RSA",
                        "e": "AQAB",
                        "use": "sig",
                        "kid": "defaultjwks26",
                        "n": "v-zf7...G2tyw"
                    }
                ]
            },
            "client_registration_types": [
                "explicit",
                "automatic"
            ],
            "client_name": "RP with CAS",
            "contacts": [
                "[email protected]"
            ]
        }
    },
    "federationKeys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "mykeyoidcfede26",
            "n": "uaov...EpbZQ"
        }
    ]
}

对于 OP(CAS 服务器),我们执行类似操作:调用 URL http://localhost:8080/cas/oidc/.well-known/openid-federation 获取元数据和联邦密钥,并创建 op.json 文件(位于 ./subordinates 目录中)。

$ cat
{
    "entityId": "http://localhost:8080/cas/oidc",
    "metadata": {
        "openid_provider": {
            "DPopSigningAlgValuesSupported": [
                "RS256",
                "RS384",
                "RS512",
                "ES256",
                "ES384",
                "ES512"
            ],
            "request_parameter_supported": true,
            "pushed_authorization_request_endpoint": "http://localhost:8080/cas/oidc/oidcPushAuthorize",
            "introspection_signing_alg_values_supported": [
                "none",
                "RS256",
                "RS384",
                "RS512",
                "PS256",
                "PS384",
                "PS512",
                "ES256",
                "ES384",
                "ES512",
                "HS256",
                "HS384",
                "HS512"
            ],

            ...[TRUNCATED]...

            "registration_endpoint": "http://localhost:8080/cas/oidc/register",
            "request_object_signing_alg_values_supported": [
                "none",
                "RS256",
                "RS384",
                "RS512",
                "PS256",
                "PS384",
                "PS512",
                "ES256",
                "ES384",
                "ES512",
                "HS256",
                "HS384",
                "HS512"
            ],
            "request_object_encryption_alg_values_supported": [
                "RSA1_5",
                "RSA-OAEP",
                "RSA-OAEP-256",
                "A128KW",
                "A192KW",
                "A256KW",
                "A128GCMKW",
                "A192GCMKW",
                "A256GCMKW",
                "ECDH-ES",
                "ECDH-ES+A128KW",
                "ECDH-ES+A192KW",
                "ECDH-ES+A256KW"
            ]
        },
        "federation_entity": {
            "organization_name": "Apereo CAS",
            "contacts": []
        }
    },
    "federationKeys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "0bf6c36e-1cba-41d3-a50e-a11881fd85e7",
            "n": "xoP5Q...3EkRw"
        }
    ]
}

6) 最终测试

使用 mvn clean package 分别打包每个 CAS overlay,然后在各自节点的目录中启动 java -jar target/cas.war。在演示目录中使用 mvn spring-boot:run 启动 Spring Boot RP。让这三个进程分别在独立终端中保持运行。

启动 RP、OP 和 TA 后,我们在浏览器中访问 http://localhost:8081:

[LOADING...]

然后,我们点击“Protected area”链接。

在 CAS 服务器登录页面上,我们使用预定义用户 casuser / Mellon 登录:

[LOADING...]

并确认授权:

[LOADING...]

并且可以正常工作:

[LOADING...]

借助 OpenID Federation 协议和两个 CAS 服务器节点,我们最终登录到了 pac4j 应用。

CAS 可以作为联邦 OP 和信任锚,pac4j 则作为 RP。

改编自 OpenID Federation with pac4j and CAS,原文发布于 pac4j.org。