编辑此页面

使用 OpenID Connect (OIDC) 多租户

本指南演示了您的 OpenID Connect (OIDC) 应用程序如何支持多租户,从而从单个应用程序为多个租户提供服务。这些租户可以是同一 OIDC 提供程序中的不同领域或安全域,甚至是不同的 OIDC 提供程序。

当从同一应用程序为多个客户提供服务时(例如在 SaaS 环境中),每个客户都作为一个独立的租户运行。通过启用应用程序的多租户支持,您可以支持每个租户的不同身份验证策略,甚至可以针对不同的 OIDC 提供程序(如 Keycloak 和 Google)进行身份验证。

要使用 Bearer Token Authorization 授权租户,请参阅OpenID Connect (OIDC) Bearer token authentication指南。

要使用 OIDC 授权码流对租户进行身份验证和授权,请阅读用于保护 Web 应用程序的 OpenID Connect 授权码流机制指南。

另请参阅OpenID Connect (OIDC) 配置属性参考指南。

先决条件

要完成本指南,您需要

  • 大约 15 分钟

  • 一个 IDE

  • 已安装 JDK 17+ 并正确配置了 JAVA_HOME

  • Apache Maven 3.9.9

  • 一个正常工作的容器运行时(Docker 或 Podman

  • 如果您想使用它,可以选择 Quarkus CLI

  • 如果您想构建本机可执行文件(或者如果您使用本机容器构建,则为 Docker),可以选择安装 Mandrel 或 GraalVM 并进行适当的配置

  • jq 工具

架构

在此示例中,我们构建一个非常简单的应用程序,该应用程序支持两种资源方法

  • /{tenant}

此资源返回从 OIDC 提供程序颁发的关于经过身份验证的用户和当前租户的 ID 令牌中获得的信息。

  • /{tenant}/bearer

此资源返回从 OIDC 提供程序颁发的关于经过身份验证的用户和当前租户的 Access Token 中获得的信息。

解决方案

为了透彻理解,我们建议您按照即将发布的循序渐进说明构建应用程序。

或者,如果您希望从完整的示例开始,请克隆 Git 存储库:git clone https://github.com/quarkusio/quarkus-quickstarts.git,或下载存档

解决方案位于security-openid-connect-multi-tenancy-quickstart directory中。

创建 Maven 项目

首先,我们需要一个新项目。使用以下命令创建一个新项目

CLI
quarkus create app org.acme:security-openid-connect-multi-tenancy-quickstart \
    --extension='oidc,rest-jackson' \
    --no-code
cd security-openid-connect-multi-tenancy-quickstart

要创建 Gradle 项目,请添加 --gradle--gradle-kotlin-dsl 选项。

有关如何安装和使用 Quarkus CLI 的更多信息,请参阅 Quarkus CLI 指南。

Maven
mvn io.quarkus.platform:quarkus-maven-plugin:3.24.4:create \
    -DprojectGroupId=org.acme \
    -DprojectArtifactId=security-openid-connect-multi-tenancy-quickstart \
    -Dextensions='oidc,rest-jackson' \
    -DnoCode
cd security-openid-connect-multi-tenancy-quickstart

要创建 Gradle 项目,请添加 -DbuildTool=gradle-DbuildTool=gradle-kotlin-dsl 选项。

对于 Windows 用户

  • 如果使用 cmd,(不要使用反斜杠 \ 并将所有内容放在同一行上)

  • 如果使用 Powershell,请将 -D 参数包含在双引号中,例如 "-DprojectArtifactId=security-openid-connect-multi-tenancy-quickstart"

如果您已经配置了 Quarkus 项目,请通过在项目基本目录中运行以下命令,将 oidc 扩展添加到您的项目中

CLI
quarkus extension add oidc
Maven
./mvnw quarkus:add-extension -Dextensions='oidc'
Gradle
./gradlew addExtension --extensions='oidc'

这会将以下内容添加到您的构建文件中

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-oidc</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-oidc")

编写应用程序

首先实现 /{tenant} 端点。从下面的源代码中可以看到,它只是一个常规的 Jakarta REST 资源

package org.acme.quickstart.oidc;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;

import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.oidc.IdToken;

@Path("/{tenant}")
public class HomeResource {
    /**
     * Injection point for the ID Token issued by the OIDC provider.
     */
    @Inject
    @IdToken
    JsonWebToken idToken;

    /**
     * Injection point for the Access Token issued by the OIDC provider.
     */
    @Inject
    JsonWebToken accessToken;

    /**
     * Returns the ID Token info.
     * This endpoint exists only for demonstration purposes.
     * Do not expose this token in a real application.
     *
     * @return ID Token info
     */
    @GET
    @Produces("text/html")
    public String getIdTokenInfo() {
        StringBuilder response = new StringBuilder().append("<html>")
                .append("<body>");

        response.append("<h2>Welcome, ").append(this.idToken.getClaim("email").toString()).append("</h2>\n");
        response.append("<h3>You are accessing the application within tenant <b>").append(idToken.getIssuer()).append(" boundaries</b></h3>");

        return response.append("</body>").append("</html>").toString();
    }

    /**
     * Returns the Access Token info.
     * This endpoint exists only for demonstration purposes.
     * Do not expose this token in a real application.
     *
     * @return Access Token info
     */
    @GET
    @Produces("text/html")
    @Path("bearer")
    public String getAccessTokenInfo() {
        StringBuilder response = new StringBuilder().append("<html>")
                .append("<body>");

        response.append("<h2>Welcome, ").append(this.accessToken.getClaim("email").toString()).append("</h2>\n");
        response.append("<h3>You are accessing the application within tenant <b>").append(accessToken.getIssuer()).append(" boundaries</b></h3>");

        return response.append("</body>").append("</html>").toString();
    }
}

要从传入请求中解析租户并将其映射到 application.properties 中的特定 quarkus-oidc 租户配置,请为 io.quarkus.oidc.TenantConfigResolver 接口创建一个实现,该接口可以动态解析租户配置

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.config.ConfigProvider;

import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String path = context.request().path();

        if (path.startsWith("/tenant-a")) {
           String keycloakUrl = ConfigProvider.getConfig().getValue("keycloak.url", String.class);

            OidcTenantConfig config = OidcTenantConfig
                    .authServerUrl(keycloakUrl + "/realms/tenant-a")
                    .tenantId("tenant-a")
                    .clientId("multi-tenant-client")
                    .credentials("secret")
                    .applicationType(ApplicationType.HYBRID)
                    .build();
            return Uni.createFrom().item(config);
        } else {
            // resolve to default tenant config
            return Uni.createFrom().nullItem();
        }
    }
}

在前面的实现中,租户是从请求路径解析的。如果无法推断出租户,则返回 null 以指示应使用默认租户配置。

tenant-a 应用程序类型为 hybrid;如果提供了 HTTP bearer 令牌,它可以接受。否则,当需要身份验证时,它会启动授权码流。

配置应用程序

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.credentials.secret=secret
quarkus.oidc.application-type=web-app

# Tenant A configuration is created dynamically in CustomTenantConfigResolver

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

第一个配置是默认租户配置,当无法从请求中推断出租户时应使用该配置。请注意,%prod 配置文件前缀与 quarkus.oidc.auth-server-url 一起使用,以支持使用 Dev Services For Keycloak 测试多租户应用程序。此配置使用 Keycloak 实例对用户进行身份验证。

TenantConfigResolver 提供的第二个配置在传入请求映射到 tenant-a 租户时使用。

两种配置都映射到同一个 Keycloak 服务器实例,同时使用不同的 realms

或者,您可以直接在 application.properties 中配置租户 tenant-a

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.credentials.secret=secret
quarkus.oidc.application-type=web-app

# Tenant A configuration
quarkus.oidc.tenant-a.auth-server-url=https://:8180/realms/tenant-a
quarkus.oidc.tenant-a.client-id=multi-tenant-client
quarkus.oidc.tenant-a.credentials.secret=secret
quarkus.oidc.tenant-a.application-type=web-app

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

在这种情况下,还可以使用自定义 TenantConfigResolver 来解析它

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String path = context.request().path();
        String[] parts = path.split("/");

        if (parts.length == 0) {
            //Resolve to default tenant configuration
            return null;
        }

        return parts[1];
    }
}

您可以在配置文件中定义多个租户。为了在从 TenantResolver 实现中解析租户时正确映射它们,请确保每个租户都有一个唯一的别名。

但是,使用静态租户解析(包括在 application.properties 中配置租户并使用 TenantResolver 解析它们)不适用于使用 Dev Services for Keycloak 测试端点,因为它不知道如何将请求映射到各个租户,并且无法动态提供租户特定的 quarkus.oidc.<tenant-id>.auth-server-url 值。因此,在 application.properties 中使用带有租户特定 URL 的 %prod 前缀在测试和开发模式下都无法正常工作。

当当前租户表示 OIDC web-app 应用程序时,在自定义租户解析器被调用用于完成代码身份验证流的所有请求以及已经过身份验证的请求(当已经存在租户特定的状态或会话 cookie 时),当前的 io.vertx.ext.web.RoutingContext 包含一个 tenant-id 属性。因此,当使用多个 OIDC 提供程序时,如果 RoutingContext 没有设置 tenant-id 属性,则只需要一个特定于路径的检查来解析租户 ID,例如

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String tenantId = context.get("tenant-id");
        if (tenantId != null) {
            return tenantId;
        } else {
            // Initial login request
            String path = context.request().path();
            String[] parts = path.split("/");

            if (parts.length == 0) {
                //Resolve to default tenant configuration
                return null;
            }
            return parts[1];
        }
    }
}

如果未注册自定义 TenantResolver,这就是 Quarkus OIDC 解析静态自定义租户的方式。

类似的技术可以与 TenantConfigResolver 一起使用,其中上下文中提供的 tenant-id 可以返回已经使用先前请求准备好的 OidcTenantConfig

如果您还使用 Hibernate ORM 多租户或带有 Panache 多租户的 MongoDB,并且两个租户 ID 相同,则可以使用带有 tenant-idRoutingContext 属性获取租户 ID。您可以在此处找到更多信息

启动和配置 Keycloak 服务器

要启动 Keycloak 服务器,您可以使用 Docker 并运行以下命令

docker run --name keycloak -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin -p 8180:8080 quay.io/keycloak/keycloak:{keycloak.version} start-dev

其中 keycloak.version 设置为 26.0.7 或更高版本。

localhost:8180 访问您的 Keycloak 服务器。

admin 用户身份登录以访问 Keycloak 管理控制台。用户名和密码均为 admin

现在,导入两个租户的领域

有关更多信息,请参阅 Keycloak 文档中有关如何创建新领域的信息。

运行和使用应用程序

在开发人员模式下运行

要在开发模式下运行微服务,请使用

CLI
quarkus dev
Maven
./mvnw quarkus:dev
Gradle
./gradlew --console=plain quarkusDev

在 JVM 模式下运行

在开发模式下探索完应用程序后,您可以将其作为标准的 Java 应用程序运行。

首先,编译它

CLI
quarkus build
Maven
./mvnw install
Gradle
./gradlew build

然后运行它

java -jar target/quarkus-app/quarkus-run.jar

在本机模式下运行

此演示也可以编译为本机代码;无需修改。

这意味着您不再需要在生产环境中安装 JVM,因为运行时技术包含在生成的二进制文件中,并且经过优化以最小的资源运行。

编译需要更长的时间,因此默认情况下会关闭此步骤;让我们通过启用本机构建再次构建

CLI
quarkus build --native
Maven
./mvnw install -Dnative
Gradle
./gradlew build -Dquarkus.native.enabled=true

稍等片刻后,您可以直接运行此二进制文件

./target/security-openid-connect-multi-tenancy-quickstart-runner

测试应用程序

使用 Dev Services for Keycloak

建议使用 Dev Services for Keycloak 对 Keycloak 进行集成测试。Dev Services for Keycloak 启动并初始化一个测试容器:它导入配置的领域并为 CustomTenantResolver 设置一个基本的 Keycloak URL,以计算特定于领域的 URL。

首先,添加以下依赖项

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-keycloak-server</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.htmlunit</groupId>
    <artifactId>htmlunit</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-keycloak-server")
testImplementation("io.rest-assured:rest-assured")
testImplementation("org.htmlunit:htmlunit")

quarkus-test-keycloak-server 提供了一个实用程序类 io.quarkus.test.keycloak.client.KeycloakTestClient,用于获取特定于领域的访问令牌,您可以将其与 RestAssured 一起用于测试期望 bearer 访问令牌的 /{tenant}/bearer 端点。HtmlUnit 测试 /{tenant} 端点和授权码流。

接下来,配置所需的领域

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.application-type=web-app

# Tenant A configuration is created dynamically in CustomTenantConfigResolver

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

quarkus.keycloak.devservices.realm-path=default-tenant-realm.json,tenant-a-realm.json

最后,编写您的测试,该测试在 JVM 模式下运行

package org.acme.quickstart.oidc;

import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;

import org.htmlunit.SilentCssErrorHandler;
import org.htmlunit.WebClient;
import org.htmlunit.html.HtmlForm;
import org.htmlunit.html.HtmlPage;
import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.client.KeycloakTestClient;
import io.restassured.RestAssured;

@QuarkusTest
public class CodeFlowTest {

    KeycloakTestClient keycloakClient = new KeycloakTestClient();

    @Test
    public void testLogInDefaultTenant() throws IOException {
        try (final WebClient webClient = createWebClient()) {
            HtmlPage page = webClient.getPage("https://:8081/default");

            assertEquals("Sign in to quarkus", page.getTitleText());

            HtmlForm loginForm = page.getForms().get(0);

            loginForm.getInputByName("username").setValueAttribute("alice");
            loginForm.getInputByName("password").setValueAttribute("alice");

            page = loginForm.getButtonByName("login").click();

            assertTrue(page.asNormalizedText().contains("tenant"));
        }
    }

    @Test
    public void testLogInTenantAWebApp() throws IOException {
        try (final WebClient webClient = createWebClient()) {
            HtmlPage page = webClient.getPage("https://:8081/tenant-a");

            assertEquals("Sign in to tenant-a", page.getTitleText());

            HtmlForm loginForm = page.getForms().get(0);

            loginForm.getInputByName("username").setValueAttribute("alice");
            loginForm.getInputByName("password").setValueAttribute("alice");

            page = loginForm.getButtonByName("login").click();

            assertTrue(page.asNormalizedText().contains("alice@tenant-a.org"));
        }
    }

    @Test
    public void testLogInTenantABearerToken() throws IOException {
        RestAssured.given().auth().oauth2(getAccessToken()).when()
            .get("/tenant-a/bearer").then().body(containsString("alice@tenant-a.org"));
    }

    private String getAccessToken() {
        return keycloakClient.getRealmAccessToken("tenant-a", "alice", "alice", "multi-tenant-client", "secret");
    }

    private WebClient createWebClient() {
        WebClient webClient = new WebClient();
        webClient.setCssErrorHandler(new SilentCssErrorHandler());
        return webClient;
    }
}

在本机模式下

package org.acme.quickstart.oidc;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
public class CodeFlowIT extends CodeFlowTest {
}

有关如何初始化和配置它的更多信息,请参阅 Dev Services for Keycloak

使用浏览器

要测试应用程序,请打开您的浏览器并访问以下 URL

如果一切正常,您将被重定向到 Keycloak 服务器进行身份验证。请注意,请求的路径定义了一个 default 租户,我们没有在配置文件中映射它。在这种情况下,使用默认配置。

要对应用程序进行身份验证,请在 Keycloak 登录页面中输入以下凭据

  • 用户名:alice

  • 密码:alice

单击登录按钮后,您将被重定向回应用程序。

如果您现在尝试在以下 URL 访问应用程序

您将再次被重定向到 Keycloak 登录页面。但是,这一次,您将使用不同的领域进行身份验证。

在这两种情况下,如果用户成功通过身份验证,则登录页面都会显示用户的姓名和电子邮件。虽然 alice 在两个租户中都存在,但应用程序会将它们视为不同领域中的不同用户。

租户解析

租户解析顺序

OIDC 租户按以下顺序解析

  1. 如果禁用了主动身份验证,则首先检查 io.quarkus.oidc.Tenant 注释。

  2. 使用自定义 TenantConfigResolver 的动态租户解析。

  3. 使用以下选项之一的静态租户解析:自定义 TenantResolver、配置的租户路径以及默认为最后一个请求路径段作为租户 ID。

最后,如果在上述步骤之后未解析租户 ID,则选择默认的 OIDC 租户。

有关更多信息,请参见以下部分

此外,对于 OIDC web-app 应用程序,状态和会话 cookie 还会提供一个关于在授权码流启动时使用上述选项之一解析的租户的提示。有关更多信息,请参见 OIDC web-app 应用程序的租户解析 部分。

使用注释解析

您可以使用 io.quarkus.oidc.Tenant 注释来解析租户标识符,作为使用 io.quarkus.oidc.TenantResolver 的替代方法。

必须禁用主动 HTTP 身份验证 (quarkus.http.auth.proactive=false) 才能使其工作。有关更多信息,请参见主动身份验证指南。

假设您的应用程序支持两个 OIDC 租户,即 hr 租户和默认租户,则所有带有 @Tenant("hr") 的资源方法和类都使用由 quarkus.oidc.hr.auth-server-url 配置的 OIDC 提供程序进行身份验证。相反,所有其他类和方法仍然使用默认的 OIDC 提供程序进行身份验证。

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import io.quarkus.oidc.Tenant;
import io.quarkus.security.Authenticated;

@Authenticated
@Path("/api/hello")
public class HelloResource {

    @Tenant("hr") (1)
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String sayHello() {
        return "Hello!";
    }
}
1 io.quarkus.oidc.Tenant 注释必须放置在资源类或资源方法上。

在上面的示例中,sayHello 端点的身份验证是通过 @Authenticated 注释强制执行的。

或者,如果您使用HTTP 安全策略来保护端点,那么,为了使 @Tenant 注释生效,您必须延迟此策略的权限检查,如以下示例所示

quarkus.http.auth.permission.authenticated.paths=/api/hello
quarkus.http.auth.permission.authenticated.methods=GET
quarkus.http.auth.permission.authenticated.policy=authenticated
quarkus.http.auth.permission.authenticated.applies-to=JAXRS (1)
1 告诉 Quarkus 在使用 @Tenant 注释选择租户后运行 HTTP 权限检查。

io.quarkus.oidc.Tenant 注释可用于为 WebSockets Next 服务器端点选择租户。该注释必须放置在端点类上,因为 SecurityIdentity 是在 HTTP 连接升级为 WebSocket 连接之前创建的。有关 HTTP 升级安全性的更多信息,请参见 Quarkus "WebSockets Next 参考" 指南的 安全 HTTP 升级 部分。

动态租户配置解析

如果您需要对您要支持的不同租户进行更动态的配置,并且不想在配置文件中添加多个条目,则可以使用 io.quarkus.oidc.TenantConfigResolver

此接口允许您在运行时动态创建租户配置

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import java.util.function.Supplier;

import io.smallrye.mutiny.Uni;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantConfigResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String path = context.request().path();
        String[] parts = path.split("/");

        if (parts.length == 0) {
            //Resolve to default tenant configuration
            return null;
        }

        if ("tenant-c".equals(parts[1])) {
            // Do 'return requestContext.runBlocking(createTenantConfig());'
            // if a blocking call is required to create a tenant config,
            return Uni.createFrom().item(createTenantConfig());
        }

        //Resolve to default tenant configuration
        return null;
    }

    private Supplier<OidcTenantConfig> createTenantConfig() {
        final OidcTenantConfig config = OidcTenantConfig
                .authServerUrl("https://:8180/realms/tenant-c")
                .tenantId("tenant-c")
                .clientId("multi-tenant-client")
                .credentials("my-secret")
                .build();

        // Any other setting supported by the quarkus-oidc extension

        return () -> config;
    }
}

此方法返回的 OidcTenantConfig 与用于从 application.properties 解析 oidc 命名空间配置的 OidcTenantConfig 相同。您可以使用 quarkus-oidc 扩展支持的任何设置来填充它。

如果动态租户解析器返回 null,则接下来会尝试静态租户配置解析

更新已解析的动态租户配置

可能需要更新已解析的租户配置。例如,在注册的 OIDC 应用程序中更新客户端密码后,可能必须更新客户端密码。

要更新配置,请使用 OidcTenantConfigBuilder 创建 OidcTenantConfig 的新实例,并在返回它之前根据需要进行修改

package org.acme;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.TenantConfigBean;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Inject
    TenantConfigBean tenantConfigBean;

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {

        // Check request path or request context `tenant-id` property to determine the tenant id.

        var currentTenantConfig = tenantConfigBean.getDynamicTenant("some-dynamic-tenant-id").getOidcTenantConfig(); (1)
        if (currentTenantConfig != null
            && "name".equals(currentTenantConfig.token().principalClaim().get())) { (2)
            // This is an original configuration, update it now:
            OidcTenantConfig updatedConfig = OidcTenantConfig.builder(currentTenantConfig) (3)
                                .token().principalClaim("email").end()
                                .build();

            return Uni.createFrom().item(updatedConfig);
        }
        // create an initial configuration for the tenant
        OidcTenantConfig config = OidcTenantConfig.builder() (4)
                                .token().principalClaim("name").end()
                                // set other properties
                                .build();
        return Uni.createFrom().item(config);
    }
}
1 使用 io.quarkus.oidc.runtime.TenantConfigBean 获取已解析的租户配置。或者,您可以使用缓存在您的解析器中的租户 OidcTenantConfig
2 您可能希望检查此配置是否已经更新,以避免由于多重重定向而导致的多余更新。
3 使用已解析的配置创建构建器并根据需要进行更新。
4 如果尚不存在配置,则创建初始配置。

这就是您要更新已解析的动态租户配置所要做的全部操作,无需重新连接到提供程序。

如果需要重新连接,例如,可能更改了 UserInfo 端点地址以便租户重新发现它,那么只需将 RoutingContext replace-tenant-configuration-context 属性设置为 true

@Override
public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
    OidcTenantConfig updatedConfig = OidcTenantConfig.builder(currentTenantConfig)
                                    .token().principalClaim("email").end()
                                    .build();
    context.put("replace-tenant-configuration-context", "true"); (1)

    return Uni.createFrom().item(updatedConfig);
}
1 替换已解析的租户配置并重新连接到提供程序

最后,如果您决定在现有 OIDC 会话仍然处于活动状态时更新已解析的配置,则可能需要删除会话 cookie 并重新验证用户身份,以符合最新的租户配置要求。如果需要,请将 RoutingContext remove-session-cookie 属性设置为 true

@Override
public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
    OidcTenantConfig updatedConfig = OidcTenantConfig.builder(currentTenantConfig)
                                    .token().principalClaim("email").end()
                                    .build();
    context.put("remove-session-cookie", "true"); (1)

    return Uni.createFrom().item(updatedConfig);
}
1 更新租户配置,删除会话 cookie 并触发用户重新身份验证。如果可能,请首先让用户注销,而不是在租户解析时触发重新身份验证。

静态租户配置解析

当您在 application.properties 文件中设置多个租户配置时,您只需要指定如何解析租户标识符。要配置租户标识符的解析,请使用以下选项之一

将按列出的顺序尝试这些租户解析选项,直到租户 ID 被解析。如果租户 ID 保持未解析 (null),则选择默认(未命名)的租户配置。

使用 TenantResolver 解析

以下 application.properties 示例显示了如何使用 TenantResolver 方法解析名为 ab 的两个租户的租户标识符

# Tenant 'a' configuration
quarkus.oidc.a.auth-server-url=https://:8180/realms/quarkus-a
quarkus.oidc.a.client-id=client-a
quarkus.oidc.a.credentials.secret=client-a-secret

# Tenant 'b' configuration
quarkus.oidc.b.auth-server-url=https://:8180/realms/quarkus-b
quarkus.oidc.b.client-id=client-b
quarkus.oidc.b.credentials.secret=client-b-secret

您可以从 io.quarkus.oidc.TenantResolver 返回 ab 的租户 ID

import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;

public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String path = context.request().path();
        if (path.endsWith("a")) {
            return "a";
        } else if (path.endsWith("b")) {
            return "b";
        } else {
            // default tenant
            return null;
        }
    }
}

在此示例中,最后一个请求路径段的值是一个租户 ID,但如果需要,您可以实现更复杂的租户标识符解析逻辑。

配置租户路径

您可以使用 quarkus.oidc.tenant-paths 配置属性来解析租户标识符,作为使用 io.quarkus.oidc.TenantResolver 的替代方法。以下是如何为先前示例中使用的 HelloResource 资源的 sayHello 端点选择 hr 租户

quarkus.oidc.hr.tenant-paths=/api/hello (1)
quarkus.oidc.a.tenant-paths=/api/* (2)
quarkus.oidc.b.tenant-paths=/*/hello (3)
1 与先前示例中的 quarkus.http.auth.permission.authenticated.paths=/api/hello 配置属性应用相同的路径匹配规则。
2 放置在路径末尾的通配符表示任意数量的路径段。但是,该路径不如 /api/hello 具体,因此 hr 租户将用于保护 sayHello 端点。
3 /*/hello 中的通配符表示恰好一个路径段。但是,通配符不如 api 具体,因此将使用 hr 租户。
路径匹配机制的工作方式与 使用配置授权 中完全相同。

使用请求路径段查找租户 ID

租户标识符的默认解析是基于约定的,因此身份验证请求必须在请求路径的一个路径段中包含租户标识符。

以下 application.properties 示例显示了如何配置名为 googlegithub 的两个租户

# Tenant 'google' configuration
quarkus.oidc.google.provider=google
quarkus.oidc.google.client-id=${google-client-id}
quarkus.oidc.google.credentials.secret=${google-client-secret}
quarkus.oidc.google.authentication.redirect-path=/signed-in

# Tenant 'github' configuration
quarkus.oidc.github.provider=github
quarkus.oidc.github.client-id=${github-client-id}
quarkus.oidc.github.credentials.secret=${github-client-secret}
quarkus.oidc.github.authentication.redirect-path=/signed-in

在提供的示例中,两个租户都配置 OIDC web-app 应用程序以使用授权码流对用户进行身份验证,并且需要身份验证后生成会话 cookie。在 Google 或 GitHub 对当前用户进行身份验证之后,用户将被返回到经过身份验证的用户的 /signed-in 区域,例如 JAX-RS 端点上的安全资源路径。

最后,要完成默认租户解析,请设置以下配置属性

quarkus.http.auth.permission.login.paths=/google,/github
quarkus.http.auth.permission.login.policy=authenticated

如果端点在 https://:8080 上运行,您还可以为用户提供 UI 选项来登录到 https://:8080/googlehttps://:8080/github,而无需添加特定的 /google/github JAX-RS 资源路径。

在完成身份验证后,租户标识符也会记录在会话 cookie 名称中。因此,经过身份验证的用户可以访问安全应用程序区域,而无需在安全 URL 中包含 googlegithub 路径值。

默认解析也可以用于 Bearer 令牌身份验证。但是,它可能不太实用,因为租户标识符必须始终设置为最后一个路径段值。

使用令牌颁发者声明解析租户

支持 Bearer 令牌身份验证的 OIDC 租户可以使用访问令牌的颁发者进行解析。以下条件必须满足才能使基于颁发者的解析工作

  • 访问令牌必须为 JWT 格式,并且包含颁发者 (iss) 令牌声明。

  • 仅考虑应用程序类型为 servicehybrid 的 OIDC 租户。这些租户必须已发现或配置令牌颁发者。

使用 quarkus.oidc.resolve-tenants-with-issuer 属性启用基于颁发者的解析。例如

quarkus.oidc.resolve-tenants-with-issuer=true (1)

quarkus.oidc.tenant-a.auth-server-url=${tenant-a-oidc-provider} (2)
quarkus.oidc.tenant-a.client-id=${tenant-a-client-id}
quarkus.oidc.tenant-a.credentials.secret=${tenant-a-client-secret}

quarkus.oidc.tenant-b.auth-server-url=${tenant-b-oidc-provider} (3)
quarkus.oidc.tenant-b.discover-enabled=false
quarkus.oidc.tenant-b.token.issuer=${tenant-b-oidc-provider}/issuer
quarkus.oidc.tenant-b.jwks-path=/jwks
quarkus.oidc.tenant-b.token-path=/tokens
quarkus.oidc.tenant-b.client-id=${tenant-b-client-id}
quarkus.oidc.tenant-b.credentials.secret=${tenant-b-client-secret}
1 租户 tenant-atenant-b 使用 JWT 访问令牌的颁发者 iss 声明值进行解析。
2 租户 tenant-a 从 OIDC 提供程序的知名配置端点发现 issuer
3 租户 tenant-b 配置 issuer,因为其 OIDC 提供程序不支持发现。

OIDC web-app 应用程序的租户解析

对于 OIDC web-app 应用程序的租户解析,必须在授权码流期间至少完成 3 次,因为 OIDC 租户特定配置会影响以下每个步骤的运行方式。

步骤 1:未经身份验证的用户访问端点并被重定向到 OIDC 提供程序

当未经身份验证的用户访问安全路径时,用户将被重定向到 OIDC 提供程序进行身份验证,并且租户配置用于构建重定向 URI。

静态租户配置解析动态租户配置解析 部分中列出的所有静态和动态租户解析选项都可用于解析租户。

步骤 2:用户被重定向回端点

在提供程序身份验证之后,用户将被重定向回 Quarkus 端点,并且租户配置用于完成授权码流。

静态租户配置解析动态租户配置解析 部分中列出的所有静态和动态租户解析选项都可用于解析租户。在租户解析开始之前,授权码流 state cookie 用于将已解析的租户配置 ID 设置为 RoutingContext tenant-id 属性:自定义动态 TenantConfigResolver 和静态 TenantResolver 租户解析器都可以检查它。

步骤 3:经过身份验证的用户使用会话 cookie 访问安全路径

租户配置确定如何验证和刷新会话 cookie。在租户解析开始之前,授权码流 session cookie 用于将已解析的租户配置 ID 设置为 RoutingContext tenant-id 属性:自定义动态 TenantConfigResolver 和静态 TenantResolver 租户解析器都可以检查它。

例如,以下是自定义 TenantConfigResolver 如何避免创建已解析的租户配置,否则可能需要从数据库或其他远程源进行阻塞读取

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
        if (resolvedTenantId != null) { (1)
            return null;
        }

        String path = context.request().path(); (2)
        if (path.endsWith("tenant-a")) {
            return Uni.createFrom().item(createTenantConfig("tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            return Uni.createFrom().item(createTenantConfig("tenant-b", "client-b", "secret-b"));
        }

        // Default tenant id
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String clientId, String secret) {
        final OidcTenantConfig config = OidcTenantConfig
                .authServerUrl("https://:8180/realms/"  + tenantId)
                .tenantId(tenantId)
                .clientId(clientId)
                .credentials(secret)
                .applicationType(ApplicationType.WEB_APP)
                .build();
        return config;
    }
}
1 如果 Quarkus 已经解析了租户配置,则让 Quarkus 使用已解析的租户配置。
2 检查请求路径以创建租户配置。

默认配置可能如下所示

quarkus.oidc.auth-server-url=https://:8180/realms/default
quarkus.oidc.client-id=client-default
quarkus.oidc.credentials.secret=secret-default
quarkus.oidc.application-type=web-app

前面的示例假定 tenant-atenant-b 和默认租户都用于保护相同的端点路径。换句话说,在使用 tenant-a 配置对用户进行身份验证之后,此用户将无法选择使用 tenant-b 或默认配置进行身份验证,除非此用户注销并且会话 cookie 被清除或过期。

多个 OIDC web-app 租户保护租户特定路径的情况不太常见,也需要格外小心。当多个 OIDC web-app 租户(例如 tenant-atenant-b 和默认租户)用于控制对租户特定路径的访问时,使用一个 OIDC 提供程序进行身份验证的用户必须不能访问需要使用另一个提供程序进行身份验证的路径,否则结果将是不可预测的,最有可能导致意外的身份验证失败。例如,如果 tenant-a 身份验证需要 Keycloak 身份验证,而 tenant-b 身份验证需要 Auth0 身份验证,那么,如果 tenant-a 经过身份验证的用户尝试访问由 tenant-b 配置保护的路径,则会话 cookie 将不会被验证,因为 Auth0 公共验证密钥不能用于验证由 Keycloak 签名的令牌。避免多个 web-app 租户相互冲突的一种简单且推荐的方法是设置租户特定的会话路径,如以下示例所示

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
        if (resolvedTenantId != null) { (1)
            return null;
        }

        String path = context.request().path(); (2)
        if (path.endsWith("tenant-a")) {
            return Uni.createFrom().item(createTenantConfig("tenant-a", "/tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            return Uni.createFrom().item(createTenantConfig("tenant-b", "/tenant-b", "client-b", "secret-b"));
        }

        // Default tenant id
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String cookiePath, String clientId, String secret) {
        final OidcTenantConfig config = OidcTenantConfig
                .authServerUrl("https://:8180/realms/" + tenantId)
                .tenantId(tenantId)
                .clientId(clientId)
                .credentials(secret)
                .applicationType(ApplicationType.WEB_APP)
                .authentication().cookiePath(cookiePath).end()  (3)
                .build();
        return config;
    }
}
1 如果 Quarkus 已经解析了租户配置,则让 Quarkus 使用已解析的租户配置。
2 检查请求路径以创建租户配置。
3 设置租户特定的 cookie 路径,以确保会话 cookie 仅对创建它的租户可见。

应像这样调整默认租户配置

quarkus.oidc.auth-server-url=https://:8180/realms/default
quarkus.oidc.client-id=client-default
quarkus.oidc.credentials.secret=secret-default
quarkus.oidc.authentication.cookie-path=/default
quarkus.oidc.application-type=web-app

不建议并且应避免在多个 OIDC web-app 租户保护租户特定路径时使用相同的会话 cookie 路径,因为它需要自定义解析器更加小心,例如

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {

        String path = context.request().path(); (1)
        if (path.endsWith("tenant-a")) {
            String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
	    if (resolvedTenantId != null) {
	        if ("tenant-a".equals(resolvedTenantId)) { (2)
	            return null;
	        } else {
	           // Require a "tenant-a" authentication
                   context.remove(OidcUtils.TENANT_ID_ATTRIBUTE); (3)
	        }
            }
            return Uni.createFrom().item(createTenantConfig("tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
	    if (resolvedTenantId != null) {
	        if ("tenant-b".equals(resolvedTenantId)) { (2)
	            return null;
	        } else {
	            // Require a "tenant-b" authentication
                   context.remove(OidcUtils.TENANT_ID_ATTRIBUTE); (3)
	        }
            }
            return Uni.createFrom().item(createTenantConfig("tenant-b", "client-b", "secret-b"));
        }

        // Set default tenant id
        context.put(OidcUtils.TENANT_ID_ATTRIBUTE, OidcUtils.DEFAULT_TENANT_ID); (4)
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String clientId, String secret) {
        final OidcTenantConfig config = OidcTenantConfig
            .authServerUrl("https://:8180/realms/"  + tenantId)
            .tenantId(tenantId)
            .clientId(clientId)
            .credentials(secret)
            .applicationType(ApplicationType.WEB_APP)
            .build();
        return config;
    }
}
1 检查请求路径以创建租户配置。
2 如果当前路径需要已解析的租户,则让 Quarkus 使用已解析的租户配置。
3 如果当前路径不需要已解析的租户配置,则删除 tenant-id 属性。
4 对所有其他路径使用默认租户。这等效于删除 tenant-id 属性。

禁用租户配置

如果无法从当前请求中推断出租户,并且需要回退到默认租户配置,则自定义 TenantResolverTenantConfigResolver 实现可能会返回 null

如果您希望自定义解析器始终解析租户,则无需配置默认租户解析。

  • 要关闭默认租户配置,请设置 quarkus.oidc.tenant-enabled=false

当未配置 quarkus.oidc.auth-server-url,但有可用的自定义租户配置或注册了 TenantConfigResolver 时,默认租户配置会自动禁用。

请注意,租户特定的配置也可以禁用,例如:quarkus.oidc.tenant-a.tenant-enabled=false

多个租户的程序化 OIDC 启动

可以像以下示例中一样以编程方式创建静态 OIDC 租户

package io.quarkus.it.oidc;

import io.quarkus.oidc.Oidc;
import io.quarkus.oidc.OidcTenantConfig;
import jakarta.enterprise.event.Observes;

public class OidcStartup {

    void observe(@Observes Oidc oidc) { (1)
        oidc.create(OidcTenantConfig.authServerUrl("https://:8180/realms/tenant-one").tenantId("tenant-one").build()); (2)
        oidc.create(OidcTenantConfig.authServerUrl("https://:8180/realms/tenant-two").tenantId("tenant-two").build()); (3)
    }

}
1 观察 OIDC 事件。
2 创建 OIDC 租户 'tenant-one'。
3 创建 OIDC 租户 'tenant-two'。

上面的代码在编程上等效于 application.properties 文件中的以下配置

quarkus.oidc.tenant-one.auth-server-url=https://:8180/realms/tenant-one
quarkus.oidc.tenant-two.auth-server-url=https://:8180/realms/tenant-two

相关内容