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


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

1.1. Quarkus 中的 Bearer 令牌身份验证机制概述

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

bearer 令牌由 OIDC 和 OAuth 2.0 兼容授权服务器发布,如 Keycloak

bearer 令牌身份验证是根据 bearer 令牌存在和有效授权 HTTP 请求的过程。bearer 令牌提供有关调用主题的信息,用于确定是否可以访问 HTTP 资源。

下图显示了 Quarkus 中的 Bearer 令牌身份验证机制:

图 1.1. 带有单页应用程序的 Quarkus 中的 bearer 令牌身份验证机制

bearer 令牌身份验证
  1. Quarkus 服务从 OIDC 供应商检索验证密钥。验证密钥用于验证 bearer 访问令牌签名。
  2. Quarkus 用户访问单页应用程序(SPA)。
  3. 单页应用程序使用授权代码流来验证用户并从 OIDC 供应商检索令牌。
  4. 单页应用使用访问令牌从 Quarkus 服务检索服务数据。
  5. Quarkus 服务使用验证密钥验证 bearer 访问令牌签名,检查令牌到期日期和其他声明,允许请求在令牌有效时继续,并将服务响应返回到单页应用。
  6. 单页应用程序将同一数据返回到 Quarkus 用户。

图 1.2. 使用 Java 或命令行客户端的 Quarkus 中的 bearer 令牌身份验证机制

bearer 令牌身份验证
  1. Quarkus 服务从 OIDC 供应商检索验证密钥。验证密钥用于验证 bearer 访问令牌签名。
  2. 客户端使用 client_credentials,它需要客户端 ID 和 secret 或密码授权,这需要客户端 ID、secret、用户名和密码从 OIDC 供应商检索访问令牌。
  3. 客户端使用访问令牌从 Quarkus 服务检索服务数据。
  4. Quarkus 服务使用验证密钥验证 bearer 访问令牌签名,检查令牌到期日期和其他声明,允许请求在令牌有效时继续,并将服务响应返回给客户端。

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

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

有关如何支持多个租户的详情,请参考使用 OpenID Connect Multi-Tenancy 的 Quarkus。

1.1.1. 访问 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";
    }
}

@ApplicationScoped@Singleton@RequestScoped 范围支持 JsonWebToken 注入。但是,如果单个声明被注入为简单类型,则需要使用 @RequestScoped。如需更多信息,请参阅 Quarkus "Using JWT RBAC" 指南中的 支持的注入范围 部分。

1.1.2. UserInfo

如果您必须从 OIDC UserInfo 端点请求 UserInfo JSON 对象,请设置 quarkus.oidc.authentication.user-info-required=true。请求发送到 OIDC 提供程序 UserInfo 端点,并且创建 io.quarkus.oidc.UserInfo (一个简单的 javax.json.JsonObject wrapper)对象。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 端点时才检测到。

1.1.3. 配置元数据

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

如果端点是 public,则默认租户的 OidcConfigurationMetadata 会被注入。

1.1.4. 令牌声明和安全身份角色

您可以从验证的 JWT 访问令牌映射 SecurityIdentity 角色,如下所示:

  • 如果设置了 quarkus.oidc.roles.role-claim-path 属性,并找到匹配的数组或字符串声明,则从这些声明中提取角色。例如,Customroles ,custom roles/array,scope,"http://namespace-qualified-custom-claim"/roles,"http://namespace-qualified-roles"
  • 如果有一个 声明可用,则使用其值。
  • 如果 realm_access/rolesresource_access/client_id/roles (其中 client_idquarkus.oidc.client-id 属性的值)声明可用,则使用其值。此检查支持 Keycloak 发布的令牌。

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

{
    "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 " Security tips and tricks" 指南中的 安全身份自定义 部分。

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

1.1.5. 令牌范围和 SecurityIdentity 权限

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

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 范围 电子邮件 的请求才会被授予访问权限。
2
读访问权限仅限于具有 orders_read 范围的客户端请求。

有关 io.quarkus.security.PermissionsAllowed 注解的更多信息,请参阅"Authorization of web endpoint"指南中的 Permission 注解 部分。

1.1.6. 令牌验证和内省

如果令牌是 JWT 令牌,默认情况下,它通过来自本地 JsonWebKeySet 中的 JsonWebKey (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=http://localhost:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Token Introspection endpoint: http://localhost: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=http://localhost:8180/realms/quarkus
quarkus.oidc.token.require-jwt-introspection-only=true

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

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

1.1.7. 令牌内省和 UserInfo 缓存

所有不透明访问令牌都必须远程内省。有时,还必须内省 JWT 访问令牌。如果同时需要 UserInfo,则会在对 OIDC 提供程序的后续远程调用中使用相同的访问令牌。因此,如果需要 UserInfo,并且当前访问令牌不透明,会为每个此类令牌进行两个远程调用;一个远程调用来内省令牌,另一个用于获取 UserInfo。如果令牌是 JWT,则只需要对 get 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.TokenIntrospection 数据、quarkus.oidc.UserInfo 数据,或使用布尔值 quarkus.oidc."tenant".allow-token-introspection-cachequarkus.oidc."tenant".allow-user-info-cache 属性的存储。

另外,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 或两者。它只会保留最大大小的条目数。如果在添加新条目时缓存已满,则会尝试通过删除单个过期条目来查找空格。另外,如果激活,清理计时器会定期检查过期的条目并删除它们。

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

1.1.8. JSON Web 令牌声明验证

在验证 bearer JWT 令牌签名 并在 (exp)声明被检查后,将验证 iss (issuer)声明值。

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

在某些情况下,这是声明 验证可能无法正常工作。例如,如果发现的 issuer 属性包含内部 HTTP/IP 地址,而 令牌是 声明值包含外部 HTTP/IP 地址。或者,当发现的 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)声明值。

1.1.9. Jose4j Validator

您可以注册自定义 Jose4j Validator,以自定义 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 Validator,以验证所有 OIDC 租户的 JWT 令牌。
2
返回声明验证错误描述。
3
返回 null 以确认此 Validator 已成功验证令牌。
提示

使用 @quarkus.oidc.TenantFeature 注解将自定义 Validator 绑定到特定的 OIDC 租户。

1.1.10. 跨原始资源共享

如果您计划从在不同域上运行的单页应用中使用 OIDC 服务 应用程序,您必须配置跨原始资源共享(CORS)。如需更多信息,请参阅"Cross-origin 资源共享"指南中的 CORS 过滤器 部分。

1.1.11. 供应商端点配置

OIDC 服务 应用需要知道 OIDC 供应商的令牌、JsonWebKey (JWK)设置,以及可能的 UserInfo 和内省端点地址。

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

或者,如果发现端点不可用,或者要在发现端点往返中保存,您可以禁用发现并使用相对路径值进行配置。例如:

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

1.1.12. 令牌传播

有关 bearer 访问令牌传播到下游服务的详情,请参考 Quarkus "OpenID Connect (OIDC)和 OAuth2 客户端和过滤器参考" 指南中的 Token propagation 部分。

1.1.13. 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
truststore 必须包含证书链的 root 证书。
2
证书链的叶证书必须具有等于 www.quarkusio.com 的通用名称。如果没有配置此属性,则 truststore 必须包含证书链的叶证书,除非注册了一个或多个自定义 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 令牌声明。

1.1.14. OIDC 供应商客户端身份验证

当需要对 OIDC 供应商的远程请求时,使用 quarkus.oidc.runtime.OidcProviderClient。如果需要内省 Bearer 令牌,则 OidcProviderClient 必须向 OIDC 提供程序进行身份验证。有关支持的验证选项的更多信息,请参阅 Quarkus "OpenID Connect authorization code flow mechanism for protect web application" 指南中的 OIDC provider client authentication 部分。

1.1.15. 测试

注意

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

您可以通过在测试项目中添加以下依赖项开始测试:

  • 使用 Maven:

    <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>
  • 使用 Gradle:

    testImplementation("io.rest-assured:rest-assured")
    testImplementation("io.quarkus:quarkus-junit5")

1.1.15.1. WireMock

在您的测试项目中添加以下依赖项:

  • 使用 Maven:

    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-test-oidc-server</artifactId>
        <scope>test</scope>
    </dependency>
  • 使用 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 扩展包含 JSON Web 密钥(JWK)格式的签名 RSA 私钥文件,并使用 smallrye.jwt.sign.key.location 配置属性指向它。它允许您使用 no-argument sign () 操作为令牌签名。

使用 OidcWiremockTestResource 测试 quarkus-oidc 服务 应用程序提供了最佳覆盖,因为即使通信通道针对 WireMock HTTP stub 进行了测试。如果您需要使用 WireMock stubs 运行测试,它还没有被 OidcWiremockTestResource 支持,您可以将 WireMockServer 实例注入测试类,如下例所示:

注意

OidcWiremockTestResource 无法针对 Docker 容器使用 @QuarkusIntegrationTest,因为运行测试的 JVM 中运行 WireMock 服务器,这无法从运行 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"));
    }
}

1.1.16. 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

要启动,请添加相同的依赖项 quarkus-test-oidc-server,如 WireMock 部分所述。

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

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"));
    }
}

此测试代码使用来自 test Auth0 域的 密码 授权获取令牌,该密码使用客户端 id test-auth0-client 注册了应用,并使用密码 alice 创建用户 alice。要使测试正常工作,test Auth0 应用必须启用 密码 授权。这个示例代码还演示了如何传递额外的参数。对于 Auth0,它们是 audiencescope 参数。

1.1.16.1. 用于 Keycloak 的 dev Services

针对 Keycloak 进行集成测试的首选方法是 Keycloak 的 Dev Services用于 Keycloak 的 dev Services 将启动并初始化测试容器。然后,它将创建一个 quarkus realm 和 quarkus-app 客户端(secret secret),并添加 alice (admin user roles)和 bob (用户角色)用户,其中所有这些属性都可以自定义。

首先,添加以下依赖项,它提供实用程序类 io.quarkus.test.keycloak.client.KeycloakTestClient,您可以用于测试获取访问令牌:

  • 使用 Maven:

    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-test-keycloak-server</artifactId>
        <scope>test</scope>
    </dependency>
  • 使用 Gradle:

    testImplementation("io.quarkus:quarkus-test-keycloak-server")

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

但是,如果您已配置了所需的 quarkus-oidc 属性,则您只需要将 quarkus.oidc.auth-server-url 与 'Dev Services for Keycloak' 的 prod 配置集关联,如下例所示:

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

如果在运行测试前必须将自定义域文件导入到 Keycloak 中,请为 Keycloak 配置 Dev Services,如下所示:

%prod.quarkus.oidc.auth-server-url=http://localhost: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 的 Dev 服务的更多信息,请参阅 Dev Services for Keycloak 指南。

1.1.16.2. 本地公钥

您可以使用本地内联公钥来测试 quarkus-oidc 服务 应用程序,如下例所示:

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 令牌,请从 Quarkus 存储库中的 integration-tests/oidc-tenancy 复制 privateKey.pem,并使用与上一 WireMock 部分中类似的测试代码。如果需要,您可以使用自己的测试密钥。

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

1.1.16.3. TestSecurity 注解

您可以使用 @TestSecurity@OidcSecurity 注释来测试 服务 应用程序端点代码(取决于以下注入之一或全部三个):

  • JsonWebToken
  • UserInfo
  • OidcConfigurationMetadata

首先,添加以下依赖项:

  • 使用 Maven:

    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-test-security-oidc</artifactId>
        <scope>test</scope>
    </dependency>
  • 使用 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 来添加额外的内省响应属性,如电子邮件 等等。

提示

@TestSecurity@OidcSecurity 可以合并到 meta-annotation 中,如下例所示:

    @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 {

    }

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

1.1.17. 检查日志中的错误

要查看有关令牌验证错误的更多详细信息,请启用 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

1.1.18. 对 OIDC 供应商的外部和内部访问

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

在这种情况下,如果您使用 Keycloak,使用 KEYCLOAK_FRONTEND_URL 系统属性将其设置为外部可访问的基本 URL。如果使用其他 OIDC 供应商,请参阅您的供应商文档。

1.1.19. 使用 client-id 属性

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

如果服务 应用预期远程内省令牌,则需要此属性,这始终是不透明令牌的情况。此属性是可选的,用于本地 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 或类似属性设置客户端 secret,因为它不会被使用。

注意

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

Red Hat logoGithubRedditYoutubeTwitter

学习

尝试、购买和销售

社区

关于红帽文档

通过我们的产品和服务,以及可以信赖的内容,帮助红帽用户创新并实现他们的目标。

让开源更具包容性

红帽致力于替换我们的代码、文档和 Web 属性中存在问题的语言。欲了解更多详情,请参阅红帽博客.

關於紅帽

我们提供强化的解决方案,使企业能够更轻松地跨平台和环境(从核心数据中心到网络边缘)工作。

© 2024 Red Hat, Inc.