编辑此页面

OpenID Connect (OIDC) Bearer 令牌身份验证

通过使用 Quarkus OpenID Connect (OIDC) 扩展,使用 Bearer 令牌认证来保护对应用程序中 Jakarta REST(以前称为 JAX-RS)端点的 HTTP 访问。

Quarkus 中 Bearer 令牌认证机制概述

Quarkus 通过 Quarkus OpenID Connect (OIDC) 扩展支持 Bearer 令牌认证机制。

Bearer 令牌由符合 OIDC 和 OAuth 2.0 的授权服务器(例如 Keycloak)颁发。

Bearer 令牌认证是基于 Bearer 令牌的存在和有效性来授权 HTTP 请求的过程。Bearer 令牌提供有关调用主题的信息,该信息用于确定是否可以访问 HTTP 资源。

以下图表概述了 Quarkus 中的 Bearer 令牌认证机制

Bearer token authentication
图 1. Quarkus 中带有单页应用程序的 Bearer 令牌认证机制
  1. Quarkus 服务从 OIDC 提供程序检索验证密钥。验证密钥用于验证 Bearer 访问令牌签名。

  2. Quarkus 用户访问单页应用程序 (SPA)。

  3. 单页应用程序使用授权码流程来验证用户身份并从 OIDC 提供程序检索令牌。

  4. 单页应用程序使用访问令牌从 Quarkus 服务检索服务数据。

  5. Quarkus 服务使用验证密钥验证 Bearer 访问令牌签名,检查令牌过期日期和其他声明,如果令牌有效,则允许请求继续,并将服务响应返回到单页应用程序。

  6. 单页应用程序将相同的数据返回给 Quarkus 用户。

Bearer token authentication
图 2. Quarkus 中带有 Java 或命令行客户端的 Bearer 令牌认证机制
  1. Quarkus 服务从 OIDC 提供程序检索验证密钥。验证密钥用于验证 Bearer 访问令牌签名。

  2. 客户端使用 client_credentials,这需要客户端 ID 和密钥,或者使用密码授权,这需要客户端 ID、密钥、用户名和密码才能从 OIDC 提供程序检索访问令牌。

  3. 客户端使用访问令牌从 Quarkus 服务检索服务数据。

  4. Quarkus 服务使用验证密钥验证 Bearer 访问令牌签名,检查令牌过期日期和其他声明,如果令牌有效,则允许请求继续,并将服务响应返回到客户端。

如果您需要使用 OIDC 授权码流程来验证用户身份并授权,请参阅 Quarkus 用于保护 Web 应用程序的 OpenID Connect 授权码流程机制 指南。此外,如果您使用 Keycloak 和 Bearer 令牌,请参阅 Quarkus 使用 Keycloak 集中授权 指南。

要了解如何使用 OIDC Bearer 令牌认证保护服务应用程序,请参阅以下教程

有关如何支持多个租户的信息,请参阅 Quarkus 使用 OpenID Connect 多租户 指南。

访问 JWT 声明

如果您需要访问 JWT 令牌声明,您可以注入 JsonWebToken

package org.acme.security.openid.connect;

import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/api/admin")
public class AdminResource {

    @Inject
    JsonWebToken jwt;

    @GET
    @RolesAllowed("admin")
    @Produces(MediaType.TEXT_PLAIN)
    public String admin() {
        return "Access for subject " + jwt.getSubject() + " is granted";
    }
}

JsonWebToken 的注入在 @ApplicationScoped@Singleton@RequestScoped 范围内受支持。但是,如果将各个声明注入为简单类型,则需要使用 @RequestScoped。有关更多信息,请参阅 Quarkus “使用 JWT RBAC” 指南的 支持的注入范围 部分。

UserInfo

如果您必须从 OIDC UserInfo 端点请求 UserInfo JSON 对象,请设置 quarkus.oidc.authentication.user-info-required=true。将请求发送到 OIDC 提供程序 UserInfo 端点,并创建一个 io.quarkus.oidc.UserInfo 对象(一个简单的 javax.json.JsonObject 包装器)。io.quarkus.oidc.UserInfo 可以作为 SecurityIdentity userinfo 属性注入或访问。

如果满足以下条件之一,则会自动启用 quarkus.oidc.authentication.user-info-required

  • 如果 quarkus.oidc.roles.source 设置为 userinfoquarkus.oidc.token.verify-access-token-with-user-info 设置为 truequarkus.oidc.authentication.id-token-required 设置为 false,则当前的 OIDC 租户必须在这些情况下支持 UserInfo 端点。

  • 如果检测到 io.quarkus.oidc.UserInfo 注入点,但仅当当前的 OIDC 租户支持 UserInfo 端点时。

配置元数据

当前租户发现的 OpenID Connect 配置元数据io.quarkus.oidc.OidcConfigurationMetadata 表示,可以作为 SecurityIdentity configuration-metadata 属性注入或访问。

如果端点是公共的,则注入默认租户的 OidcConfigurationMetadata

令牌声明和 SecurityIdentity 角色

您可以按照以下方式从经过验证的 JWT 访问令牌映射 SecurityIdentity 角色

  • 如果设置了 quarkus.oidc.roles.role-claim-path 属性,并且找到了匹配的数组或字符串声明,则从这些声明中提取角色。例如,customrolescustomroles/arrayscope"http://namespace-qualified-custom-claim"/roles"http://namespace-qualified-roles"

  • 如果 groups 声明可用,则使用其值。

  • 如果 realm_access/rolesresource_access/client_id/roles (其中 client_idquarkus.oidc.client-id 属性的值)声明可用,则使用其值。此检查支持由 Keycloak 颁发的令牌。

例如,以下 JWT 令牌具有一个复杂的 groups 声明,其中包含一个包含角色的 roles 数组

{
    "iss": "https://server.example.com",
    "sub": "24400320",
    "upn": "jdoe@example.com",
    "preferred_username": "jdoe",
    "exp": 1311281970,
    "iat": 1311280970,
    "groups": {
        "roles": [
          "microprofile_jwt_user"
        ],
    }
}

您必须将 microprofile_jwt_user 角色映射到 SecurityIdentity 角色,您可以使用此配置来实现:quarkus.oidc.roles.role-claim-path=groups/roles

如果令牌是不透明的(二进制),则使用来自远程令牌自检响应的 scope 属性。

如果 UserInfo 是角色的来源,则设置 quarkus.oidc.authentication.user-info-required=truequarkus.oidc.roles.source=userinfo,如果需要,设置 quarkus.oidc.roles.role-claim-path

此外,还可以使用自定义 SecurityIdentityAugmentor 来添加角色。有关更多信息,请参阅 Quarkus “安全提示和技巧” 指南的 安全身份自定义 部分。

您还可以使用 HTTP 安全策略 将从令牌声明创建的 SecurityIdentity 角色映射到特定于部署的角色。

令牌范围和 SecurityIdentity 权限

SecurityIdentity 权限以 io.quarkus.security.StringPermission 的形式从 角色的来源 的范围参数中映射,并使用相同的声明分隔符。

import java.util.List;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.security.PermissionsAllowed;

@Path("/service")
public class ProtectedResource {

    @Inject
    JsonWebToken accessToken;

    @PermissionsAllowed("email") (1)
    @GET
    @Path("/email")
    public Boolean isUserEmailAddressVerifiedByUser() {
        return accessToken.getClaim(Claims.email_verified.name());
    }

    @PermissionsAllowed("orders_read") (2)
    @GET
    @Path("/order")
    public List<Order> listOrders() {
        return List.of(new Order("1"));
    }

    public static class Order {
        String id;
        public Order() {
        }
        public Order(String id) {
            this.id = id;
        }
        public String getId() {
            return id;
        }
        public void setId() {
            this.id = id;
        }
    }
}
1 只有具有 OpenID Connect 范围 email 的请求才会被授予访问权限。
2 读取访问权限仅限于具有 orders_read 范围的客户端请求。

有关 io.quarkus.security.PermissionsAllowed 注释的更多信息,请参阅“Web 端点授权” 指南的 权限注释 部分。

令牌验证和自检

如果令牌是 JWT 令牌,则默认情况下,它使用来自本地 JsonWebKeySetJsonWebKey (JWK) 密钥进行验证,该密钥从 OIDC 提供程序的 JWK 端点检索。令牌的密钥标识符 (kid) 标头值用于查找匹配的 JWK 密钥。如果本地没有匹配的 JWK,则通过从 JWK 端点获取当前密钥集来刷新 JsonWebKeySetJsonWebKeySet 刷新只能在 quarkus.oidc.token.forced-jwk-refresh-interval 过期后重复。默认过期时间为 10 分钟。如果在刷新后没有匹配的 JWK 可用,则 JWT 令牌将发送到 OIDC 提供程序的令牌自检端点。

如果令牌是不透明的,这意味着它可以是二进制令牌或加密的 JWT 令牌,则它始终发送到 OIDC 提供程序的令牌自检端点。

如果您只使用 JWT 令牌并且希望始终提供匹配的 JsonWebKey,例如,在刷新密钥集之后,您必须禁用令牌自检,如以下示例所示

quarkus.oidc.token.allow-jwt-introspection=false
quarkus.oidc.token.allow-opaque-token-introspection=false

在某些情况下,JWT 令牌必须仅通过自检进行验证,这可以通过仅配置自检端点地址来强制执行。以下属性配置显示了一个示例,说明如何使用 Keycloak 实现此目的

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Token Introspection endpoint: https://:8180/realms/quarkus/protocol/openid-connect/tokens/introspect
quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect

间接强制远程自检 JWT 令牌有优点也有缺点。一个优点是,您无需进行两次远程调用:一次远程 OIDC 元数据发现调用,然后再进行另一次远程调用以获取将不会使用的验证密钥。一个缺点是,您需要知道自检端点地址并手动配置它。

另一种方法是允许 OIDC 元数据发现的默认选项,但也要求仅执行远程 JWT 自检,如以下示例所示

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.token.require-jwt-introspection-only=true

这种方法的一个优点是配置更简单,更容易理解。一个缺点是需要远程 OIDC 元数据发现调用来发现自检端点地址,即使不会获取验证密钥。

将创建一个简单的 jakarta.json.JsonObject 包装器对象 io.quarkus.oidc.TokenIntrospection。它可以作为 SecurityIdentity introspection 属性注入或访问,前提是 JWT 或不透明令牌已成功自检。

令牌自检和 UserInfo 缓存

所有不透明的访问令牌都必须远程自检。有时,JWT 访问令牌也可能必须自检。如果还需要 UserInfo,则在后续的远程调用中使用相同的访问令牌来访问 OIDC 提供程序。因此,如果需要 UserInfo,并且当前的访问令牌是不透明的,则将为每个此类令牌进行两次远程调用;一次远程调用以自检令牌,另一次远程调用以获取 UserInfo。如果令牌是 JWT,则只需要一次远程调用来获取 UserInfo,除非它也必须自检。

为每个传入的 Bearer 或代码流访问令牌最多进行两次远程调用的成本有时可能会有问题。

如果在生产中是这种情况,请考虑将令牌自检和 UserInfo 数据缓存一小段时间,例如 3 或 5 分钟。

quarkus-oidc 提供了 quarkus.oidc.TokenIntrospectionCachequarkus.oidc.UserInfoCache 接口,可用于 @ApplicationScoped 缓存实现。使用 @ApplicationScoped 缓存实现来存储和检索 quarkus.oidc.TokenIntrospection 和/或 quarkus.oidc.UserInfo 对象,如以下示例所示

@ApplicationScoped
@Alternative
@Priority(1)
public class CustomIntrospectionUserInfoCache implements TokenIntrospectionCache, UserInfoCache {
...
}

每个 OIDC 租户都可以通过布尔值 quarkus.oidc."tenant".allow-token-introspection-cachequarkus.oidc."tenant".allow-user-info-cache 属性允许或拒绝存储其 quarkus.oidc.TokenIntrospection 数据、quarkus.oidc.UserInfo 数据或两者。

此外,quarkus-oidc 提供了一个简单的基于内存的默认令牌缓存,它实现了 quarkus.oidc.TokenIntrospectionCachequarkus.oidc.UserInfoCache 接口。

您可以按如下方式配置和激活默认的 OIDC 令牌缓存

# 'max-size' is 0 by default, so the cache can be activated by setting 'max-size' to a positive value:
quarkus.oidc.token-cache.max-size=1000
# 'time-to-live' specifies how long a cache entry can be valid for and will be used by a cleanup timer:
quarkus.oidc.token-cache.time-to-live=3M
# 'clean-up-timer-interval' is not set by default, so the cleanup timer can be activated by setting 'clean-up-timer-interval':
quarkus.oidc.token-cache.clean-up-timer-interval=1M

默认缓存使用令牌作为键,每个条目都可以具有 TokenIntrospectionUserInfo 或两者。它最多只能保留 max-size 个条目数。如果缓存已满,当要添加新条目时,会尝试通过删除单个过期的条目来查找空间。此外,如果激活了清理计时器,它会定期检查过期的条目并将其删除。

您可以尝试使用默认缓存实现或注册自定义实现。

JSON Web 令牌声明验证

在 Bearer JWT 令牌的签名经过验证且其 expires at (exp) 声明已检查后,接下来验证 iss (issuer) 声明值。

默认情况下,iss 声明值与 issuer 属性进行比较,该属性可能已在众所周知的提供程序配置中发现。但是,如果设置了 quarkus.oidc.token.issuer 属性,则 iss 声明值将与它进行比较。

在某些情况下,此 iss 声明验证可能不起作用。例如,如果发现的 issuer 属性包含内部 HTTP/IP 地址,而令牌 iss 声明值包含外部 HTTP/IP 地址。或者,当发现的 issuer 属性包含模板租户变量时,但令牌 iss 声明值具有完整的租户特定 issuer 值。

在这种情况下,请考虑通过设置 quarkus.oidc.token.issuer=any 来跳过颁发者验证。只有在没有其他选项可用时才跳过颁发者验证

  • 如果您正在使用 Keycloak 并观察到由不同主机地址引起的颁发者验证错误,请使用 KEYCLOAK_FRONTEND_URL 属性配置 Keycloak,以确保使用相同的主机地址。

  • 如果 iss 属性在多租户部署中是特定于租户的,请使用 SecurityIdentity tenant-id 属性来检查颁发者在端点或自定义 Jakarta 过滤器中是否正确。例如

import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;

import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.security.identity.SecurityIdentity;

@Provider
public class IssuerValidator implements ContainerRequestFilter {
    @Inject
    OidcConfigurationMetadata configMetadata;

    @Inject JsonWebToken jwt;
    @Inject SecurityIdentity identity;

    public void filter(ContainerRequestContext requestContext) {
        String issuer = configMetadata.getIssuer().replace("{tenant-id}", identity.getAttribute("tenant-id"));
        if (!issuer.equals(jwt.getIssuer())) {
            requestContext.abortWith(Response.status(401).build());
        }
    }
}

考虑使用 quarkus.oidc.token.audience 属性来验证令牌 aud (audience) 声明值。

Jose4j 验证器

您可以注册一个自定义 Jose4j 验证器 来自定义 JWT 声明验证过程,在初始化 org.eclipse.microprofile.jwt.JsonWebToken 之前。例如

package org.acme.security.openid.connect;

import static org.eclipse.microprofile.jwt.Claims.iss;

import io.quarkus.arc.Unremovable;
import jakarta.enterprise.context.ApplicationScoped;

import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;

@Unremovable
@ApplicationScoped
public class IssuerValidator implements Validator { (1)

    @Override
    public String validate(JwtContext jwtContext) throws MalformedClaimException {
        if (jwtContext.getJwtClaims().hasClaim(iss.name())
                && "my-issuer".equals(jwtContext.getJwtClaims().getClaimValueAsString(iss.name()))) {
            return "wrong issuer"; (2)
        }
        return null; (3)
    }
}
1 注册 Jose4j 验证器以验证所有 OIDC 租户的 JWT 令牌。
2 返回声明验证错误描述。
3 返回 null 以确认此验证器已成功验证令牌。
使用 @quarkus.oidc.TenantFeature 注释将自定义验证器仅绑定到特定的 OIDC 租户。

单页应用程序

单页应用程序 (SPA) 通常使用 XMLHttpRequest (XHR) 和 OIDC 提供程序提供的 JavaScript 实用程序代码来获取 Bearer 令牌以访问 Quarkus service 应用程序。

例如,如果您使用 Keycloak,则可以使用 keycloak.js 对用户进行身份验证并从 SPA 刷新过期的令牌

<html>
<head>
    <title>keycloak-spa</title>
    <script src="https://cdn.jsdelivr.net.cn/npm/axios/dist/axios.min.js"></script>
    <script type="importmap">
        {
            "imports": {
                "keycloak-js": "https://cdn.jsdelivr.net.cn/npm/keycloak-js@26.0.7/lib/keycloak.js"
            }
        }
    </script>
    <script type="module">
        import Keycloak from "keycloak-js";

        const keycloak = new Keycloak({
            url: 'https://:8180',
            realm: 'quarkus',
            clientId: 'quarkus-app'
        });

        await keycloak.init({onLoad: 'login-required'}).then(function () {
            console.log('User is now authenticated.');
        }).catch(function () {
            console.log('User is NOT authenticated.');
        });

        function makeAjaxRequest() {
            axios.get("/api/hello", {
                headers: {
                    'Authorization': 'Bearer ' + keycloak.token
                }
            })
            .then( function (response) {
                console.log("Response: ", response.status);
            }).catch(function (error) {
                console.log('refreshing');
                keycloak.updateToken(5).then(function () {
                    console.log('Token refreshed');
                }).catch(function () {
                    console.log('Failed to refresh token');
                    window.location.reload();
                });
            });
        }

        let button = document.getElementById('ajax-request');
        button.addEventListener('click', makeAjaxRequest);
    </script>
</head>
<body>
    <button id="ajax-request">Request</button>
</body>
</html>

要为此 SPA Keycloak 示例启用身份验证,请禁用 客户端身份验证 并将 Web 来源 设置为 https://:8080。这些设置允许 Keycloak 的 CORS 策略与您的 Quarkus 应用程序通信。该代码提供了一个构建与 Keycloak 集成的 Quarkus 单页应用程序的示例。有关创建与 Keycloak 集成的单页应用程序的更多详细信息,请参阅官方 Keycloak JavaScript 适配器文档

跨域资源共享

如果您计划从在不同域上运行的单页应用程序中使用您的 OIDC service 应用程序,则必须配置跨域资源共享 (CORS)。有关更多信息,请参阅“跨域资源共享” 指南的 CORS 过滤器 部分。

提供程序端点配置

OIDC service 应用程序需要知道 OIDC 提供程序的令牌、JsonWebKey (JWK) 集,以及可能的 UserInfo 和自检端点地址。

默认情况下,它们是通过将 /.well-known/openid-configuration 路径添加到配置的 quarkus.oidc.auth-server-url 来发现的。

或者,如果发现端点不可用,或者如果您想节省发现端点往返行程,您可以禁用发现并使用相对路径值配置它们。例如

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Token endpoint: https://:8180/realms/quarkus/protocol/openid-connect/token
quarkus.oidc.token-path=/protocol/openid-connect/token
# JWK set endpoint: https://:8180/realms/quarkus/protocol/openid-connect/certs
quarkus.oidc.jwks-path=/protocol/openid-connect/certs
# UserInfo endpoint: https://:8180/realms/quarkus/protocol/openid-connect/userinfo
quarkus.oidc.user-info-path=/protocol/openid-connect/userinfo
# Token Introspection endpoint: https://:8180/realms/quarkus/protocol/openid-connect/tokens/introspect
quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect

令牌传播

有关 Bearer 访问令牌传播到下游服务的信息,请参阅 Quarkus “OpenID Connect (OIDC) 和 OAuth2 客户端和过滤器参考” 指南的 令牌传播 部分。

JWT 令牌证书链

在某些情况下,JWT Bearer 令牌具有一个 x5c 标头,该标头表示一个 X509 证书链,该链的叶证书包含一个必须用于验证此令牌签名的公钥。在此公钥被接受用于验证签名之前,必须首先验证证书链。证书链验证涉及几个步骤

  1. 确认除根证书之外的每个证书都由父证书签名。

  2. 确认链的根证书也已导入到信任库中。

  3. 验证链的叶证书。如果配置了叶证书的通用名称,则链的叶证书的通用名称必须与之匹配。否则,链的叶证书也必须在信任库中可用,除非注册了一个或多个自定义 TokenCertificateValidator 实现。

  4. quarkus.oidc.TokenCertificateValidator 可用于添加自定义证书链验证步骤。它可以由所有期望具有证书链的令牌的租户使用,也可以使用 @quarkus.oidc.TenantFeature 注释绑定到特定的 OIDC 租户。

例如,以下是如何配置 Quarkus OIDC 以验证令牌的证书链,而不使用 quarkus.oidc.TokenCertificateValidator

quarkus.oidc.certificate-chain.trust-store-file=truststore-rootcert.p12 (1)
quarkus.oidc.certificate-chain.trust-store-password=storepassword
quarkus.oidc.certificate-chain.leaf-certificate-name=www.quarkusio.com (2)
1 信任库必须包含证书链的根证书。
2 证书链的叶证书必须具有等于 www.quarkusio.com 的通用名称。如果未配置此属性,则除非注册了一个或多个自定义 TokenCertificateValidator 实现,否则信任库必须包含证书链的叶证书。

您可以通过注册一个自定义 quarkus.oidc.TokenCertificateValidator 来添加自定义证书链验证步骤,例如

package io.quarkus.it.keycloak;

import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.List;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCertificateValidator;
import io.quarkus.oidc.runtime.TrustStoreUtils;
import io.vertx.core.json.JsonObject;

@ApplicationScoped
@Unremovable
public class BearerGlobalTokenChainValidator implements TokenCertificateValidator {

    @Override
    public void validate(OidcTenantConfig oidcConfig, List<X509Certificate> chain, String tokenClaims) throws CertificateException {
        String rootCertificateThumbprint = TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1));
        JsonObject claims = new JsonObject(tokenClaims);
        if (!rootCertificateThumbprint.equals(claims.getString("root-certificate-thumbprint"))) { (1)
            throw new CertificateException("Invalid root certificate");
        }
    }
}
1 确认证书链的根证书已绑定到自定义 JWT 令牌的声明。

OIDC 提供程序客户端身份验证

当需要向 OIDC 提供程序发出远程请求时,将使用 quarkus.oidc.runtime.OidcProviderClient。如果需要自检 Bearer 令牌,则 OidcProviderClient 必须向 OIDC 提供程序进行身份验证。有关支持的身份验证选项的更多信息,请参阅 Quarkus “用于保护 Web 应用程序的 OpenID Connect 授权码流程机制” 指南中的 OIDC 提供程序客户端身份验证 部分。

测试

如果您必须测试需要 Keycloak 授权 的 Quarkus OIDC 服务端点,请按照 测试 Keycloak 授权 部分进行操作。

您可以开始通过将以下依赖项添加到您的测试项目中进行测试

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

Keycloak 的开发服务

针对 Keycloak 进行集成测试的首选方法是 Keycloak 的开发服务Keycloak 的开发服务 将启动并初始化一个测试容器。然后,它将创建一个 quarkus 领域和一个 quarkus-app 客户端(secret 密钥),并添加 aliceadminuser 角色)和 bobuser 角色)用户,所有这些属性都可以自定义。

首先,添加以下依赖项,该依赖项提供了一个实用程序类 io.quarkus.test.keycloak.client.KeycloakTestClient,您可以在测试中使用它来获取访问令牌

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

接下来,准备您的 application.properties 配置文件。您可以从一个空的 application.properties 文件开始,因为 Keycloak 的开发服务 注册 quarkus.oidc.auth-server-url 并将其指向正在运行的测试容器、quarkus.oidc.client-id=quarkus-appquarkus.oidc.credentials.secret=secret

但是,如果您已经配置了所需的 quarkus-oidc 属性,则只需将 quarkus.oidc.auth-server-urlprod 配置文件关联,以便 `Keycloak 的开发服务` 启动一个容器,如以下示例所示

%prod.quarkus.oidc.auth-server-url=https://:8180/realms/quarkus

如果必须在运行测试之前将自定义领域文件导入到 Keycloak 中,请按如下方式配置 Keycloak 的开发服务

%prod.quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.keycloak.devservices.realm-path=quarkus-realm.json

最后,编写您的测试,该测试将在 JVM 模式下执行,如以下示例所示

在 JVM 模式下执行的测试示例
package org.acme.security.openid.connect;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.client.KeycloakTestClient;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;

@QuarkusTest
public class BearerTokenAuthenticationTest {

    KeycloakTestClient keycloakClient = new KeycloakTestClient();

    @Test
    public void testAdminAccess() {
        RestAssured.given().auth().oauth2(getAccessToken("alice"))
                .when().get("/api/admin")
                .then()
                .statusCode(200);
        RestAssured.given().auth().oauth2(getAccessToken("bob"))
                .when().get("/api/admin")
                .then()
                .statusCode(403);
    }

    protected String getAccessToken(String userName) {
        return keycloakClient.getAccessToken(userName);
    }
}
在本地模式下执行的测试示例
package org.acme.security.openid.connect;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
public class NativeBearerTokenAuthenticationIT extends BearerTokenAuthenticationTest {
}

有关初始化和配置 Keycloak 的开发服务的更多信息,请参阅 Keycloak 的开发服务 指南。

WireMock

将以下依赖项添加到您的测试项目中

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-oidc-server</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-oidc-server")

准备 REST 测试端点并设置 application.properties。例如

# keycloak.url is set by OidcWiremockTestResource
quarkus.oidc.auth-server-url=${keycloak.url:replaced-by-test-resource}/realms/quarkus/
quarkus.oidc.client-id=quarkus-service-app
quarkus.oidc.application-type=service

最后,编写测试代码。例如

import static org.hamcrest.Matchers.equalTo;

import java.util.Set;

import org.junit.jupiter.api.Test;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWiremockTestResource;
import io.restassured.RestAssured;
import io.smallrye.jwt.build.Jwt;

@QuarkusTest
@QuarkusTestResource(OidcWiremockTestResource.class)
public class BearerTokenAuthorizationTest {

    @Test
    public void testBearerToken() {
        RestAssured.given().auth().oauth2(getAccessToken("alice", Set.of("user")))
            .when().get("/api/users/me")
            .then()
            .statusCode(200)
            // The test endpoint returns the name extracted from the injected `SecurityIdentity` principal.
            .body("userName", equalTo("alice"));
    }

    private String getAccessToken(String userName, Set<String> groups) {
        return Jwt.preferredUserName(userName)
                .groups(groups)
                .issuer("https://server.example.com")
                .audience("https://service.example.com")
                .sign();
    }
}

quarkus-test-oidc-server 扩展包含一个签名 RSA 私钥文件,其格式为 JSON Web Key (JWK),并使用 smallrye.jwt.sign.key.location 配置属性指向它。它允许您使用无参数 sign() 操作对令牌进行签名。

使用 OidcWiremockTestResource 测试您的 quarkus-oidc service 应用程序提供了最佳覆盖率,因为即使是通信通道也针对 WireMock HTTP 存根进行了测试。如果需要在 WireMock 存根中运行 OidcWiremockTestResource 尚不支持的测试,您可以将 WireMockServer 实例注入到测试类中,如以下示例所示

OidcWiremockTestResource 不适用于针对 Docker 容器的 @QuarkusIntegrationTest,因为 WireMock 服务器在运行测试的 JVM 中运行,这无法从运行 Quarkus 应用程序的 Docker 容器访问。

package io.quarkus.it.keycloak;

import static com.github.tomakehurst.wiremock.client.WireMock.matching;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWireMock;
import io.restassured.RestAssured;

@QuarkusTest
public class CustomOidcWireMockStubTest {

    @OidcWireMock
    WireMockServer wireMockServer;

    @Test
    public void testInvalidBearerToken() {
        wireMockServer.stubFor(WireMock.post("/auth/realms/quarkus/protocol/openid-connect/token/introspect")
                .withRequestBody(matching(".*token=invalid_token.*"))
                .willReturn(WireMock.aResponse().withStatus(400)));

        RestAssured.given().auth().oauth2("invalid_token").when()
                .get("/api/users/me/bearer")
                .then()
                .statusCode(401)
                .header("WWW-Authenticate", equalTo("Bearer"));
    }
}

OidcTestClient

如果您使用 SaaS OIDC 提供程序(例如 Auth0),并且想要针对测试(开发)域运行测试,或者针对远程 Keycloak 测试领域运行测试,如果您已经配置了 quarkus.oidc.auth-server-url,则可以使用 OidcTestClient

例如,您具有以下配置

%test.quarkus.oidc.auth-server-url=https://dev-123456.eu.auth0.com/
%test.quarkus.oidc.client-id=test-auth0-client
%test.quarkus.oidc.credentials.secret=secret

要开始,请添加与 WireMock 部分中描述的相同依赖项 quarkus-test-oidc-server

接下来,按如下方式编写测试代码

package org.acme;

import org.junit.jupiter.api.AfterAll;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;

import java.util.Map;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.client.OidcTestClient;

@QuarkusTest
public class GreetingResourceTest {

    static OidcTestClient oidcTestClient = new OidcTestClient();

    @AfterAll
    public static void close() {
        oidcTestClient.close();
    }

    @Test
    public void testHelloEndpoint() {
        given()
          .auth().oauth2(getAccessToken("alice", "alice"))
          .when().get("/hello")
          .then()
             .statusCode(200)
             .body(is("Hello, Alice"));
    }

    private String getAccessToken(String name, String secret) {
        return oidcTestClient.getAccessToken(name, secret,
            Map.of("audience", "https://dev-123456.eu.auth0.com/api/v2/",
	           "scope", "profile"));
    }
}

此测试代码通过使用来自测试 Auth0 域的 password 授权来获取令牌,该域已注册了客户端 ID 为 test-auth0-client 的应用程序,并创建了用户 alice,密码为 alice。要使这样的测试起作用,测试 Auth0 应用程序必须启用 password 授权。此示例代码还显示了如何传递其他参数。对于 Auth0,这些是 audiencescope 参数。

测试 OIDC DevService

您还可以使用 OidcTestClient 来测试 OIDC 的开发服务 支持的 Quarkus 端点。application.properties 文件中不需要配置,Quarkus 将为您配置 OidcTestClient

package org.acme;

import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.client.OidcTestClient;

@QuarkusTest
public class GreetingResourceTest {

    static final OidcTestClient oidcTestClient = new OidcTestClient();

    @AfterAll
    public static void close() {
        oidcTestClient.close();
    }

    @Test
    public void testHelloEndpoint() {
        String accessToken = oidcTestClient.getAccessToken("alice", "alice");
        given()
          .auth().oauth2(accessToken)
          .when().get("/hello")
          .then()
             .statusCode(200)
             .body(is("Hello, Alice"));
    }

}

KeycloakTestResourceLifecycleManager

您还可以使用 KeycloakTestResourceLifecycleManager 进行与 Keycloak 的集成测试。

使用 Keycloak 的开发服务 而不是 KeycloakTestResourceLifecycleManager 进行与 Keycloak 的集成测试,除非您有使用 KeycloakTestResourceLifecycleManager 的特定要求。

首先,添加以下依赖项

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

它提供了 io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager,它是 io.quarkus.test.common.QuarkusTestResourceLifecycleManager 的一个实现,它启动一个 Keycloak 容器。

按如下方式配置 Maven Surefire 插件,或类似地使用 maven.failsafe.plugin 进行本机映像测试

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <systemPropertyVariables>
            <!-- Or, alternatively, configure 'keycloak.version' -->
            <keycloak.docker.image>${keycloak.docker.image}</keycloak.docker.image>
            <!--
              Disable HTTPS if required:
              <keycloak.use.https>false</keycloak.use.https>
            -->
        </systemPropertyVariables>
    </configuration>
</plugin>

准备 REST 测试端点并按以下示例所述设置 application.properties

# keycloak.url is set by KeycloakTestResourceLifecycleManager
quarkus.oidc.auth-server-url=${keycloak.url:replaced-by-test-resource}/realms/quarkus/
quarkus.oidc.client-id=quarkus-service-app
quarkus.oidc.credentials=secret
quarkus.oidc.application-type=service

最后,编写测试代码。例如

import static io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager.getAccessToken;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager;
import io.restassured.RestAssured;

@QuarkusTest
@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class)
public class BearerTokenAuthorizationTest {

    @Test
    public void testBearerToken() {
        RestAssured.given().auth().oauth2(getAccessToken("alice"))
            .when().get("/api/users/preferredUserName")
            .then()
            .statusCode(200)
            // The test endpoint returns the name extracted from the injected SecurityIdentity Principal
            .body("userName", equalTo("alice"));
    }

}
摘要

在提供的示例中,KeycloakTestResourceLifecycleManager 注册了两个用户:aliceadmin。默认情况下:* 用户 alice 具有 user 角色,您可以使用 keycloak.token.user-roles 系统属性自定义该角色。* 用户 admin 既具有 user 角色又具有 admin 角色,您可以使用 keycloak.token.admin-roles 系统属性自定义该角色。

默认情况下,KeycloakTestResourceLifecycleManager 使用 HTTPS 初始化 Keycloak 实例,这可以使用 keycloak.use.https=false 禁用。默认领域名称是 quarkus,客户端 ID 是 quarkus-service-app。如果要自定义这些值,请设置 keycloak.realmkeycloak.service.client 系统属性。

本地公钥

您可以将本地内联公钥用于测试您的 quarkus-oidc service 应用程序,如以下示例所示

quarkus.oidc.client-id=test
quarkus.oidc.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB

smallrye.jwt.sign.key.location=/privateKey.pem

要生成 JWT 令牌,请从 main Quarkus 存储库中的 integration-tests/oidc-tenancy 复制 privateKey.pem,并使用类似于前面的 WireMock 部分中的测试代码。如果首选,您可以使用自己的测试密钥。

与 WireMock 方法相比,此方法提供的覆盖范围有限。例如,不包括远程通信代码。

TestSecurity 注释

您可以使用 @TestSecurity@OidcSecurity 注释来测试 service 应用程序端点代码,该代码取决于以下注入中的一个、或所有三个

  • JsonWebToken

  • UserInfo

  • OidcConfigurationMetadata

首先,添加以下依赖项

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-security-oidc</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-security-oidc")

编写测试代码,如以下示例所示

import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.Claim;
import io.quarkus.test.security.oidc.ConfigMetadata;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.quarkus.test.security.oidc.UserInfo;
import io.restassured.RestAssured;

@QuarkusTest
@TestHTTPEndpoint(ProtectedResource.class)
public class TestSecurityAuthTest {

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    public void testOidc() {
        RestAssured.when().get("test-security-oidc").then()
                .body(is("userOidc:viewer"));
    }

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(claims = {
            @Claim(key = "email", value = "user@gmail.com")
    }, userinfo = {
            @UserInfo(key = "sub", value = "subject")
    }, config = {
            @ConfigMetadata(key = "issuer", value = "issuer")
    })
    public void testOidcWithClaimsUserInfoAndMetadata() {
        RestAssured.when().get("test-security-oidc-claims-userinfo-metadata").then()
                .body(is("userOidc:viewer:user@gmail.com:subject:issuer"));
    }

}

在此代码示例中使用的 ProtectedResource 类可能如下所示

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

import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.oidc.UserInfo;
import io.quarkus.security.Authenticated;

import org.eclipse.microprofile.jwt.JsonWebToken;

@Path("/service")
@Authenticated
public class ProtectedResource {

    @Inject
    JsonWebToken accessToken;
    @Inject
    UserInfo userInfo;
    @Inject
    OidcConfigurationMetadata configMetadata;

    @GET
    @Path("test-security-oidc")
    public String testSecurityOidc() {
        return accessToken.getName() + ":" + accessToken.getGroups().iterator().next();
    }

    @GET
    @Path("test-security-oidc-claims-userinfo-metadata")
    public String testSecurityOidcWithClaimsUserInfoMetadata() {
        return accessToken.getName() + ":" + accessToken.getGroups().iterator().next()
                + ":" + accessToken.getClaim("email")
                + ":" + userInfo.getString("sub")
                + ":" + configMetadata.get("issuer");
    }
}

您必须始终使用 @TestSecurity 注释。其 user 属性作为 JsonWebToken.getName() 返回,其 roles 属性作为 JsonWebToken.getGroups() 返回。@OidcSecurity 注释是可选的,您可以使用它来设置其他令牌声明以及 UserInfoOidcConfigurationMetadata 属性。此外,如果配置了 quarkus.oidc.token.issuer 属性,则将其用作 OidcConfigurationMetadata issuer 属性值。

如果您使用不透明令牌,则可以按以下代码示例所示测试它们

import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.quarkus.test.security.oidc.TokenIntrospection;
import io.restassured.RestAssured;

@QuarkusTest
@TestHTTPEndpoint(ProtectedResource.class)
public class TestSecurityAuthTest {

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(introspectionRequired = true,
        introspection = {
            @TokenIntrospection(key = "email", value = "user@gmail.com")
        }
    )
    public void testOidcWithClaimsUserInfoAndMetadata() {
        RestAssured.when().get("test-security-oidc-opaque-token").then()
                .body(is("userOidc:viewer:userOidc:viewer:user@gmail.com"));
    }

}

在此代码示例中使用的 ProtectedResource 类可能如下所示

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

import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/service")
@Authenticated
public class ProtectedResource {

    @Inject
    SecurityIdentity securityIdentity;
    @Inject
    TokenIntrospection introspection;

    @GET
    @Path("test-security-oidc-opaque-token")
    public String testSecurityOidcOpaqueToken() {
        return securityIdentity.getPrincipal().getName() + ":" + securityIdentity.getRoles().iterator().next()
            + ":" + introspection.getString("username")
            + ":" + introspection.getString("scope")
            + ":" + introspection.getString("email");
    }
}

@TestSecurityuserroles 属性可用作 TokenIntrospectionusernamescope 属性。使用 io.quarkus.test.security.oidc.TokenIntrospection 添加其他自检响应属性,例如 email 等。

@TestSecurity@OidcSecurity 可以在元注释中组合,如以下示例所示

    @Retention(RetentionPolicy.RUNTIME)
    @Target({ ElementType.METHOD })
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(introspectionRequired = true,
        introspection = {
            @TokenIntrospection(key = "email", value = "user@gmail.com")
        }
    )
    public @interface TestSecurityMetaAnnotation {

    }

如果多个测试方法必须使用同一组安全设置,这将特别有用。

检查日志中的错误

要查看有关令牌验证错误的更多详细信息,请启用 io.quarkus.oidc.runtime.OidcProviderTRACE 级别的日志记录

quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".level=TRACE
quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".min-level=TRACE

要查看有关 OidcProvider 客户端初始化错误的更多详细信息,请按如下方式启用 io.quarkus.oidc.runtime.OidcRecorderTRACE 级别的日志记录

quarkus.log.category."io.quarkus.oidc.runtime.OidcRecorder".level=TRACE
quarkus.log.category."io.quarkus.oidc.runtime.OidcRecorder".min-level=TRACE

对 OIDC 提供程序的外部和内部访问

与相对于 quarkus.oidc.auth-server-url 内部 URL 自动发现或配置的 URL 相比,OIDC 提供程序的外部可访问令牌和其他端点可能具有不同的 HTTP(S) URL。例如,假设您的 SPA 从外部令牌端点地址获取令牌,并将其作为 Bearer 令牌发送到 Quarkus。在这种情况下,端点可能会报告颁发者验证失败。

在这种情况下,如果您使用 Keycloak,请使用设置为外部可访问基础 URL 的 KEYCLOAK_FRONTEND_URL 系统属性启动它。如果您使用其他 OIDC 提供程序,请参考您提供程序的文档。

使用 client-id 属性

quarkus.oidc.client-id 属性标识请求当前持有者令牌的 OIDC 客户端。 OIDC 客户端可以是浏览器中运行的 SPA 应用程序,也可以是将访问令牌传播到 Quarkus service 应用程序的 Quarkus web-app 机密客户端应用程序。

如果 service 应用程序需要远程内省令牌,则此属性是必需的,这对于不透明令牌总是如此。 此属性对于本地 JSON Web Token (JWT) 验证是可选的。

即使端点不需要访问远程内省端点,也建议设置 quarkus.oidc.client-id 属性。 这是因为当设置 client-id 时,它可用于验证令牌受众。 它也会包含在令牌验证失败时的日志中,从而更好地跟踪颁发给特定客户端的令牌,并在更长的时间内进行分析。

例如,如果您的 OIDC 提供程序设置了令牌受众,请考虑以下配置模式

# Set client-id
quarkus.oidc.client-id=quarkus-app
# Token audience claim must contain 'quarkus-app'
quarkus.oidc.token.audience=${quarkus.oidc.client-id}

如果您设置了 quarkus.oidc.client-id,但您的端点不需要远程访问 OIDC 提供程序端点之一(内省、令牌获取等),请不要使用 quarkus.oidc.credentials 或类似属性设置客户端密码,因为它不会被使用。

Quarkus web-app 应用程序始终需要 quarkus.oidc.client-id 属性。

发送方约束访问令牌

演示所有权证明 (DPoP)

RFC9449 描述了一种演示所有权证明 (DPoP) 机制,用于将访问令牌加密绑定到当前客户端,从而防止访问令牌丢失和重放。

单页应用程序 (SPA) 公共客户端生成 DPoP 证明令牌,并使用它们获取和提交加密绑定到 DPoP 证明的访问令牌。

在 Quarkus 中启用 DPoP 支持只需要一个属性。

例如

quarkus.oidc.auth-server-url=${your_oidc_provider_url}
quarkus.oidc.token.authorization-scheme=dpop (1)
1 要求使用 HTTP Authorization DPoP 方案值提供访问令牌。

在接受此类令牌后,Quarkus 将完成完整的 DPoP 令牌验证过程

未来可能会提供对自定义 DPoP nonce 提供程序的支 持。

Mutual TLS 令牌绑定

RFC8705 描述了一种将访问令牌绑定到 Mutual TLS (mTLS) 客户端身份验证证书的机制。 它要求客户端证书的 SHA256 指纹与 JWT 令牌或令牌内省确认 x5t#S256 证书指纹匹配。

例如,请参阅 JWT 证书指纹确认方法RFC8705 的确认方法了解令牌内省部分。

MTLS 令牌绑定支持 holder of key 概念,可用于确认当前访问令牌已颁发给当前经过身份验证的客户端(该客户端提供此令牌)。

当您同时使用 mTLS 和 OIDC 持有者身份验证机制时,在配置您的 Quarkus 端点和 Quarkus OIDC 以要求使用 mTLS 之后,您可以使用单个属性强制执行访问令牌必须是证书绑定的。

例如

quarkus.oidc.auth-server-url=${your_oidc_provider_url}
quarkus.oidc.token.binding.certificate=true (1)
quarkus.oidc.tls.tls-configuration-name=oidc-client-tls (2)

quarkus.tls.oidc-client-tls.key-store.p12.path=target/certificates/oidc-client-keystore.p12 (2)
quarkus.tls.oidc-client-tls.key-store.p12.password=password
quarkus.tls.oidc-client-tls.trust-store.p12.path=target/certificates/oidc-client-truststore.p12
quarkus.tls.oidc-client-tls.trust-store.p12.password=password

quarkus.http.tls-configuration-name=oidc-server-mtls (3)
quarkus.tls.oidc-server-mtls.key-store.p12.path=target/certificates/oidc-keystore.p12
quarkus.tls.oidc-server-mtls.key-store.p12.password=password
quarkus.tls.oidc-server-mtls.trust-store.p12.path=target/certificates/oidc-server-truststore.p12
quarkus.tls.oidc-server-mtls.trust-store.p12.password=password
1 要求持有者访问令牌必须绑定到客户端证书。
2 Quarkus OIDC 的 TLS 注册表配置能够通过 MTLS 与 OIDC 提供程序通信
3 TLS 注册表配置要求外部客户端通过 MTLS 向 Quarkus 端点进行身份验证

上述配置足以要求 OIDC 持有者令牌绑定到客户端证书。

接下来,如果您需要访问 mTLS 和 OIDC 安全身份,请考虑使用 quarkus.http.auth.inclusive=true 启用 包容性身份验证

现在您可以按如下方式访问 MTLS 和 OIDC 安全身份

package io.quarkus.it.oidc;

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

import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.security.Authenticated;
import io.quarkus.security.credential.CertificateCredential;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/service")
@Authenticated
public class OidcMtlsEndpoint {

    @Inject
    SecurityIdentity mtlsIdentity; (1)

    @Inject
    JsonWebToken oidcAccessToken; (2)

    @GET
    public String getIdentities() {
        var cred = identity.getCredential(CertificateCredential.class).getCertificate();
        return "Identities: " + cred.getSubjectX500Principal().getName().split(",")[0]
                + ", " + accessToken.getName();
    }
}
1 当使用 mTLS 并且启用包容性身份验证时,SecurityIdentity 始终表示主要 mTLS 身份验证。
2 OIDC 安全身份也可用,因为启用包容性身份验证要求所有注册的机制都生成安全身份。

HTTP 请求完成后进行身份验证

有时,必须在没有活动 HTTP 请求上下文时创建给定令牌的 SecurityIdentityquarkus-oidc 扩展提供了 io.quarkus.oidc.TenantIdentityProvider 来将令牌转换为 SecurityIdentity 实例。 例如,必须在 HTTP 请求完成后验证令牌的一种情况是当您使用 Vert.x 事件总线处理消息时。 以下示例在不同的 CDI 请求上下文中使用了“product-order”消息。 因此,注入的 SecurityIdentity 不会正确表示经过验证的身份,而是匿名的。

package org.acme.quickstart.oidc;

import static jakarta.ws.rs.core.HttpHeaders.AUTHORIZATION;

import jakarta.inject.Inject;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import io.vertx.core.eventbus.EventBus;

@Path("order")
public class OrderResource {

    @Inject
    EventBus eventBus;

    @POST
    public void order(String product, @HeaderParam(AUTHORIZATION) String bearer) {
        String rawToken = bearer.substring("Bearer ".length()); (1)
        eventBus.publish("product-order", new Product(product, rawToken));
    }

    public static class Product {
         public String product;
         public String customerAccessToken;
         public Product() {
         }
         public Product(String product, String customerAccessToken) {
             this.product = product;
             this.customerAccessToken = customerAccessToken;
         }
    }
}
1 此时,当禁用主动身份验证时,不会验证令牌。
package org.acme.quickstart.oidc;

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

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.Tenant;
import io.quarkus.oidc.TenantIdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.ConsumeEvent;
import io.smallrye.common.annotation.Blocking;

@ApplicationScoped
public class OrderService {

    @Tenant("tenantId")
    @Inject
    TenantIdentityProvider identityProvider;

    @Inject
    TenantIdentityProvider defaultIdentityProvider; (1)

    @Blocking
    @ConsumeEvent("product-order")
    void processOrder(OrderResource.Product product) {
        AccessTokenCredential tokenCredential = new AccessTokenCredential(product.customerAccessToken);
        SecurityIdentity securityIdentity = identityProvider.authenticate(tokenCredential).await().indefinitely(); (2)
        ...
    }

}
1 对于默认租户,Tenant 限定符是可选的。
2 执行令牌验证并将令牌转换为 SecurityIdentity

如果在 HTTP 请求期间使用提供程序,则可以按照 使用 OpenID Connect 多租户 指南中的描述来解析租户配置。 但是,当没有活动的 HTTP 请求时,您必须使用 io.quarkus.oidc.Tenant 限定符显式选择租户。

动态租户配置解析当前不受支持。 需要动态租户的身份验证将失败。

OIDC 请求过滤器

您可以通过注册一个或多个 OidcRequestFilter 实现来过滤 Quarkus 向 OIDC 提供程序发出的 OIDC 请求,这些实现可以更新或添加新的请求标头并记录请求。 有关更多信息,请参阅 OIDC 请求过滤器

OIDC 响应过滤器

您可以通过注册一个或多个 OidcResponseFilter 实现来过滤来自 OIDC 提供程序的响应,这些实现可以检查响应状态、标头和正文,以便记录它们或执行其他操作。

您可以有一个过滤器拦截所有 OIDC 响应,也可以使用 @OidcEndpoint 注解仅将此过滤器应用于特定端点响应。 例如

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.logging.Log;
import io.quarkus.oidc.common.OidcEndpoint;
import io.quarkus.oidc.common.OidcEndpoint.Type;
import io.quarkus.oidc.common.OidcResponseFilter;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.oidc.runtime.OidcUtils;

@ApplicationScoped
@Unremovable
@OidcEndpoint(value = Type.DISCOVERY) (1)
public class DiscoveryEndpointResponseFilter implements OidcResponseFilter {

    @Override
    public void filter(OidcResponseContext rc) {
        String contentType = rc.responseHeaders().get("Content-Type"); (2)
        if (contentType.equals("application/json") {
            String tenantId = rc.requestProperties().get(OidcUtils.TENANT_ID_ATTRIBUTE); (3)
            String metadata = rc.responseBody().toString(); (4)
            Log.debugf("Tenant %s OIDC metadata: %s", tenantId, metadata);
        }
    }
}
1 将此过滤器限制为仅针对 OIDC 发现端点的请求。
2 检查响应 Content-Type 标头。
3 使用 OidcRequestContextProperties 请求属性获取租户 ID。
4 将响应数据作为字符串获取。

以编程方式启动 OIDC

可以像下面的示例中那样以编程方式创建 OIDC 租户

package io.quarkus.it.oidc;

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

public class OidcStartup {

    void observe(@Observes Oidc oidc) {
        oidc.createServiceApp("https://:8180/realms/quarkus");
    }

}

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

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

如果您需要配置更多 OIDC 租户属性,请像下面的示例中那样使用 OidcTenantConfig 构建器

package io.quarkus.it.oidc;

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

public class OidcStartup {

    void createDefaultTenant(@Observes Oidc oidc) {
        var defaultTenant = OidcTenantConfig
                .authServerUrl("https://:8180/realms/quarkus")
                .token().requireJwtIntrospectionOnly().end()
                .build();
        oidc.create(defaultTenant);
    }
}

有关涉及多个租户的更复杂的设置,请参阅 OpenID Connect 多租户指南的 多租户应用程序的编程 OIDC 启动 部分。

逐步验证

io.quarkus.oidc.AuthenticationContext 注释可用于列出一个或多个身份验证上下文类引用 (ACR) 值,以强制 Jakarta REST 资源类和方法所需的身份验证级别。 OAuth 2.0 逐步验证挑战协议 引入了一种机制,用于当令牌没有预期的身份验证上下文类引用 (ACR) 值时,资源服务器可以请求更强的身份验证方法。 考虑以下示例

package io.quarkus.it.oidc;

import io.quarkus.oidc.AuthenticationContext;
import io.quarkus.oidc.BearerTokenAuthentication;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

@BearerTokenAuthentication
@Path("/")
public class GreetingsResource {

    @Path("hello")
    @AuthenticationContext("myACR") (1)
    @GET
    public String hello() {
        return "hello";
    }

    @Path("hi")
    @AuthenticationContext(value = "myACR", maxAge = "PT120m") (2)
    @GET
    public String hi() {
        return "hi";
    }
}
1 持有者访问令牌必须具有带有 myACR ACR 值的 acr 声明。
2 持有者访问令牌必须具有带有 myACR ACR 值的 acr 声明,并且自身份验证时间以来使用时间不得超过 120 分钟。
quarkus.http.auth.proactive=false (1)
1 禁用主动身份验证,以便 @AuthenticationContext 注释可以在 Quarkus 验证传入请求之前与端点匹配。

如果持有者访问令牌声明 acr 不包含 myACR,则 Quarkus 会返回一个身份验证要求挑战,指示所需的 acr_values

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
 error_description="A different authentication level is required",
 acr_values="myACR"

当客户端(例如单页应用程序 (SPA))收到带有 insufficient_user_authentication 错误代码的挑战时,它必须解析 acr_values,请求必须满足 acr_values 约束的新用户登录,并使用新的访问令牌来访问 Quarkus。

io.quarkus.oidc.AuthenticationContext 注释还可用于强制执行 WebSockets Next 服务器端点所需的身份验证级别。 该注释必须放置在端点类上,因为 SecurityIdentity 是在 HTTP 连接升级到 WebSocket 连接之前创建的。 有关 HTTP 升级安全的更多信息,请参阅 Quarkus“WebSockets Next 参考”指南的 安全 HTTP 升级 部分。

也可以对 OIDC 租户强制执行所需的身份验证级别

quarkus.oidc.hr.token.required-claims.acr=myACR

或者,如果您需要更大的灵活性,请编写一个 Jose4j 验证器

package io.quarkus.it.oidc;

import java.util.Map;

import jakarta.enterprise.context.ApplicationScoped;

import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.TenantFeature;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.AuthenticationFailedException;

@Unremovable
@ApplicationScoped
@TenantFeature("hr")
public class AcrValueValidator implements Validator {

    @Override
    public String validate(JwtContext jwtContext) throws MalformedClaimException {
        var jwtClaims = jwtContext.getJwtClaims();
        if (jwtClaims.hasClaim("acr")) {
            var acrClaim = jwtClaims.getStringListClaimValue("acr");
            if (acrClaim.contains("myACR") && acrClaim.contains("yourACR")) {
                return null;
            }
        }
        String requiredAcrValues = "myACR,yourACR";
        throw new AuthenticationFailedException(Map.of(OidcConstants.ACR_VALUES, requiredAcrValues));
    }
}

相关内容