编辑此页面

用于保护 Web 应用程序的 OpenID Connect 授权码流机制

要保护您的 Web 应用程序,您可以使用 Quarkus OIDC 扩展提供的行业标准 OpenID Connect (OIDC) 授权码流程机制。

OIDC 授权码流程机制概述

Quarkus OpenID Connect (OIDC) 扩展可以通过使用 OIDC 兼容的授权服务器(如 Keycloak)支持的 OIDC 授权码流程机制来保护应用程序的 HTTP 端点。

授权码流程机制通过将用户重定向到 OIDC 提供商(如 Keycloak)进行登录来验证您的 Web 应用程序的用户。身份验证后,OIDC 提供商会将用户重定向回应用程序,并提供一个授权码,以确认身份验证成功。然后,应用程序会使用此代码与 OIDC 提供商交换 ID 令牌(代表已身份验证的用户)、访问令牌和刷新令牌,以授权用户访问应用程序。

下图概述了 Quarkus 中的授权码流程机制。

Authorization Code Flow
图 1. Quarkus 中的授权码流程机制
  1. Quarkus 用户请求访问 Quarkus web-app 应用程序。

  2. Quarkus web-app 将用户重定向到授权端点,即 OIDC 提供商进行身份验证。

  3. OIDC 提供商将用户重定向到登录和身份验证提示。

  4. 在提示中,用户输入其用户凭据。

  5. OIDC 提供商会验证用户输入的凭据,如果成功,则会 issuing 一个授权码,并将用户重定向回 Quarkus web-app,并在代码中包含授权码作为查询参数。

  6. Quarkus web-app 使用此授权码与 OIDC 提供商交换 ID、访问和刷新令牌。

授权码流程已完成,Quarkus web-app 使用 issuing 的令牌来访问有关用户的信息,并授予该用户相关的基于角色的授权。 issuing 的令牌如下:

  • ID 令牌:Quarkus web-app 应用程序使用 ID 令牌中的用户信息来安全地登录已身份验证的用户,并为 Web 应用程序提供基于角色的访问。

  • 访问令牌:Quarkus web-app 可能会使用访问令牌来访问 UserInfo API,以获取有关已身份验证用户的更多信息,或将其传播到另一个端点。

  • 刷新令牌:(可选)如果 ID 令牌和访问令牌过期,Quarkus web-app 可以使用刷新令牌获取新的 ID 令牌和访问令牌。

另请参阅 OIDC 配置属性 参考指南。

要了解如何使用 OIDC 授权码流程机制保护 Web 应用程序,请参阅 使用 OIDC 授权码流保护 Web 应用程序

如果您想使用 OIDC Bearer 令牌身份验证来保护服务应用程序,请参阅 OIDC Bearer 令牌身份验证

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

使用授权码流程机制

配置 Quarkus 以支持授权码流程

要启用授权码流程身份验证,必须将 quarkus.oidc.application-type 属性设置为 web-app。通常,当您的 Quarkus 应用程序是提供 HTML 页面并需要 OIDC 单点登录时,Quarkus OIDC web-app 应用程序类型必须设置为此值。对于 Quarkus OIDC web-app 应用程序,授权码流程被定义为对用户进行身份验证的首选方法。当您的应用程序同时提供 HTML 页面和 REST API,并需要授权码流程身份验证和 Bearer 访问令牌身份验证时,可以将 quarkus.oidc.application-type 属性设置为 hybrid。在这种情况下,仅当未设置包含 Bearer 访问令牌的 Authorization 请求标头时,才会触发授权码流程。

配置对 OIDC 提供商端点的访问

OIDC web-app 应用程序需要 OIDC 提供商的授权、令牌、JsonWebKey (JWK) 集合以及可能的 UserInfo、内省和会话结束(RP 发起的注销)端点的 URL。

按照惯例,它们是通过将 /.well-known/openid-configuration 路径添加到配置的 quarkus.oidc.auth-server-url 来发现的。

或者,如果发现端点不可用,或者您希望减少发现端点的往返次数,则可以禁用端点发现并配置相对路径值。例如:

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Authorization endpoint: https://:8180/realms/quarkus/protocol/openid-connect/auth
quarkus.oidc.authorization-path=/protocol/openid-connect/auth
# 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/token/introspect
quarkus.oidc.introspection-path=/protocol/openid-connect/token/introspect
# End-session endpoint: https://:8180/realms/quarkus/protocol/openid-connect/logout
quarkus.oidc.end-session-path=/protocol/openid-connect/logout

某些 OIDC 提供商支持元数据发现,但不会返回授权码流程完成或支持应用程序功能(例如用户注销)所需的所有端点 URL 值。为了解决此限制,您可以本地配置缺失的端点 URL 值,如下面的示例所示:

# Metadata is auto-discovered but it does not return an end-session endpoint URL

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

# Configure the end-session URL locally.
# It can be an absolute or relative (to 'quarkus.oidc.auth-server-url') address
quarkus.oidc.end-session-path=logout

您可以使用相同的配置来覆盖发现的端点 URL,如果该 URL 对本地 Quarkus 端点不起作用,并且需要更具体的值。例如,支持全局和应用程序特定会话结束端点的提供商会返回一个全局会话结束 URL,如 https://:8180/oidcprovider/account/global-logout。此 URL 将注销用户当前登录的所有应用程序。但是,如果要求当前应用程序仅注销特定应用程序的用户,则可以通过设置 quarkus.oidc.end-session-path=logout 参数来覆盖全局会话结束 URL。

OIDC 提供商客户端身份验证

OIDC 提供商通常要求应用程序在与 OIDC 端点交互时被识别和进行身份验证。Quarkus OIDC,特别是 quarkus.oidc.runtime.OidcProviderClient 类,在必须将授权码交换为 ID、访问和刷新令牌,或者必须刷新或内省 ID 和访问令牌时,会向 OIDC 提供商进行身份验证。

通常,在应用程序注册到 OIDC 提供商时会为给定的应用程序定义客户端 ID 和客户端密钥。所有 OIDC 客户端身份验证选项均受支持。例如:

client_secret_basic 示例
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.secret=mysecret

或者:

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.client-secret.value=mysecret

以下示例显示了从 凭据提供商检索的密钥:

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

# This is a key which will be used to retrieve a secret from the map of credentials returned from CredentialsProvider
quarkus.oidc.credentials.client-secret.provider.key=mysecret-key
# This is the keyring provided to the CredentialsProvider when looking up the secret, set only if required by the CredentialsProvider implementation
quarkus.oidc.credentials.client-secret.provider.keyring-name=oidc
# Set it only if more than one CredentialsProvider can be registered
quarkus.oidc.credentials.client-secret.provider.name=oidc-credentials-provider
client_secret_post 示例
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.client-secret.value=mysecret
quarkus.oidc.credentials.client-secret.method=post
client_secret_jwt 示例,其中签名算法为 HS256:
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow
client_secret_jwt 示例,其中密钥从 凭据提供商检索:
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app

# This is a key which will be used to retrieve a secret from the map of credentials returned from CredentialsProvider
quarkus.oidc.credentials.jwt.secret-provider.key=mysecret-key
# This is the keyring provided to the CredentialsProvider when looking up the secret, set only if required by the CredentialsProvider implementation
quarkus.oidc.credentials.client-secret.provider.keyring-name=oidc
# Set it only if more than one CredentialsProvider can be registered
quarkus.oidc.credentials.jwt.secret-provider.name=oidc-credentials-provider

private_key_jwt 示例,其中 PEM 密钥内联在 application.properties 中,签名算法为 RS256

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.key=Base64-encoded private key representation

private_key_jwt 示例,其中 PEM 密钥文件和签名算法为 RS256:

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.key-file=privateKey.pem
private_key_jwt 示例,其中密钥库文件和签名算法为 RS256:
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.key-store-file=keystore.pkcs12
quarkus.oidc.credentials.jwt.key-store-password=mypassword
quarkus.oidc.credentials.jwt.key-password=mykeypassword

# Private key alias inside the keystore
quarkus.oidc.credentials.jwt.key-id=mykeyAlias

使用 client_secret_jwtprivate_key_jwt 身份验证方法可确保客户端密钥不会发送到 OIDC 提供商,从而避免了密钥被“中间人”攻击截获的风险。

示例:如何使用 JWT Bearer 令牌对客户端进行身份验证
quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.source=bearer (1)
quarkus.oidc.credentials.jwt.token-path=/var/run/secrets/tokens (2)
1 使用 JWT Bearer 令牌对 OIDC 提供商客户端进行身份验证,请参阅 使用 JWT 进行客户端身份验证 部分以获取更多信息。
2 指向 JWT Bearer 令牌的路径。Quarkus 会从文件系统中加载新令牌,并在令牌过期时重新加载它。

其他 JWT 身份验证选项

如果使用 client_secret_jwtprivate_key_jwt 或 Apple post_jwt 身份验证方法,则可以自定义 JWT 签名算法、密钥标识符、受众、主题和颁发者。例如:

# private_key_jwt client authentication

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus/
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.jwt.key-file=privateKey.pem

# This is a token key identifier 'kid' header - set it if your OIDC provider requires it:
# Note if the key is represented in a JSON Web Key (JWK) format with a `kid` property, then
# using 'quarkus.oidc.credentials.jwt.token-key-id' is not necessary.
quarkus.oidc.credentials.jwt.token-key-id=mykey

# Use RS512 signature algorithm instead of the default RS256
quarkus.oidc.credentials.jwt.signature-algorithm=RS512

# The token endpoint URL is the default audience value, use the base address URL instead:
quarkus.oidc.credentials.jwt.audience=${quarkus.oidc-client.auth-server-url}

# custom subject instead of the client id:
quarkus.oidc.credentials.jwt.subject=custom-subject

# custom issuer instead of the client id:
quarkus.oidc.credentials.jwt.issuer=custom-issuer

Apple POST JWT

Apple OIDC 提供商使用 client_secret_post 方法,其中密钥是使用 private_key_jwt 身份验证方法生成的 JWT,但具有 Apple 帐户特定的颁发者和主题声明。

在 Quarkus Security 中,quarkus-oidc 支持非标准的 client_secret_post_jwt 身份验证方法,您可以按如下方式配置:

# Apple provider configuration sets a 'client_secret_post_jwt' authentication method
quarkus.oidc.provider=apple

quarkus.oidc.client-id=${apple.client-id}
quarkus.oidc.credentials.jwt.key-file=ecPrivateKey.pem
quarkus.oidc.credentials.jwt.token-key-id=${apple.key-id}
# Apple provider configuration sets ES256 signature algorithm

quarkus.oidc.credentials.jwt.subject=${apple.subject}
quarkus.oidc.credentials.jwt.issuer=${apple.issuer}

Mutual TLS (mTLS)

某些 OIDC 提供商可能要求在 mTLS 身份验证过程中对客户端进行身份验证。

以下示例显示了如何配置 quarkus-oidc 以支持 mTLS

quarkus.oidc.tls.tls-configuration-name=oidc

# configure hostname verification if necessary
#quarkus.tls.oidc.hostname-verification-algorithm=NONE

# Keystore configuration
quarkus.tls.oidc.key-store.p12.path=client-keystore.p12
quarkus.tls.oidc.key-store.p12.password=${key-store-password}

# Add more keystore properties if needed:
#quarkus.tls.oidc.key-store.p12.alias=keyAlias
#quarkus.tls.oidc.key-store.p12.alias-password=keyAliasPassword

# Truststore configuration
quarkus.tls.oidc.trust-store.p12.path=client-truststore.p12
quarkus.tls.oidc.trust-store.p12.password=${trust-store-password}
# Add more truststore properties if needed:
#quarkus.tls.oidc.trust-store.p12.alias=certAlias

POST 查询

某些提供商,例如 Strava OAuth2 提供商,要求将客户端凭据作为 HTTP POST 查询参数发送。

quarkus.oidc.provider=strava
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.client-secret.value=mysecret
quarkus.oidc.credentials.client-secret.method=query

内省端点身份验证

某些 OIDC 提供商要求使用 Basic 身份验证并使用与 client_idclient_secret 不同的凭据来对其内省端点进行身份验证。如果您先前已配置安全身份验证以支持 client_secret_basicclient_secret_post 客户端身份验证方法(如 OIDC 提供商客户端身份验证 部分所述),则可能需要应用其他配置,如下所示:

如果必须内省令牌并且需要端点特定的内省身份验证机制,则可以按如下方式配置 quarkus-oidc

quarkus.oidc.introspection-credentials.name=introspection-user-name
quarkus.oidc.introspection-credentials.secret=introspection-user-secret

OIDC 请求过滤器

您可以通过注册一个或多个 OidcRequestFilter 实现来过滤 Quarkus 向 OIDC 提供商发出的 OIDC 请求,这些实现可以更新或添加新的请求标头,还可以记录请求。

例如

package io.quarkus.it.keycloak;

import io.quarkus.oidc.OidcConfigurationMetadata;
import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.common.OidcRequestContext;
import io.quarkus.oidc.common.OidcRequestFilter;

@ApplicationScoped
@Unremovable
public class OidcTokenRequestCustomizer implements OidcRequestFilter {
    @Override
    public void filter(OidcRequestContext requestContext) {
        OidcConfigurationMetadata metadata = requestContext.contextProperties().get(OidcConfigurationMetadata.class.getName()); (1)
        // Metadata URI is absolute, request URI value is relative
        if (metadata.getTokenUri().endsWith(requestContext.request().uri())) { (2)
            requestContext.request().putHeader("TokenGrantDigest", calculateDigest(requestContext.requestBody().toString()));
        }
    }
    private String calculateDigest(String bodyString) {
        // Apply the required digest algorithm to the body string
    }
}
1 获取 OidcConfigurationMetadata,其中包含所有支持的 OIDC 端点地址。
2 使用 OidcConfigurationMetadata 仅过滤到 OIDC 令牌端点的请求。

或者,您可以使用 @OidcEndpoint 注解将此过滤器仅应用于 OIDC 发现端点的响应:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.common.OidcEndpoint;
import io.quarkus.oidc.common.OidcEndpoint.Type;
import io.quarkus.oidc.common.OidcRequestContext;
import io.quarkus.oidc.common.OidcRequestFilter;

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

    @Override
    public void filter(OidcRequestContext requestContext) {
        requestContext.request().putHeader("Discovery", "OK");
    }
}
1 将此过滤器限制为仅针对 OIDC 发现端点的请求。

OidcRequestContextProperties 可用于访问请求属性。目前,您可以使用 tenand_id 键访问 OIDC 租户 ID,使用 grant_type 键访问 OIDC 提供商用于获取令牌的授予类型。当向令牌端点发出请求时,grant_type 只能设置为 authorization_coderefresh_token 授予类型。在所有其他情况下,它是 null

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.TOKEN) (1)
public class TokenEndpointResponseFilter implements OidcResponseFilter {

    @Override
    public void filter(OidcResponseContext rc) {
        String contentType = rc.responseHeaders().get("Content-Type"); (2)
        if (contentType.equals("application/json")
                && OidcConstants.AUTHORIZATION_CODE.equals(rc.requestProperties().get(OidcConstants.GRANT_TYPE)) (3)
                && "code-flow-user-info-cached-in-idtoken".equals(rc.requestProperties().get(OidcUtils.TENANT_ID_ATTRIBUTE)) (3)
                && rc.responseBody().toJsonObject().containsKey("id_token")) { (4)
            Log.debug("Authorization code completed for tenant 'code-flow-user-info-cached-in-idtoken'");
        }
    }
}
1 将此过滤器限制为仅针对 OIDC 令牌端点的请求。
2 检查响应 Content-Type 标头。
3 使用 OidcRequestContextProperties 请求属性仅检查 code-flow-user-info-cached-in-idtoken 租户的 authorization_code 令牌授予响应。
4 确认响应 JSON 包含 id_token 属性。

重定向到 OIDC 提供商和从 OIDC 提供商重定向

当用户被重定向到 OIDC 提供商进行身份验证时,重定向 URL 包含一个 redirect_uri 查询参数,该参数指示提供商在身份验证完成后应将用户重定向到何处。在我们的例子中,这就是 Quarkus 应用程序。

Quarkus 默认将此参数设置为当前应用程序请求 URL。例如,如果用户尝试访问位于 https://:8080/service/1 的 Quarkus 服务端点,则 redirect_uri 参数将设置为 https://:8080/service/1。类似地,如果请求 URL 为 https://:8080/service/2,则 redirect_uri 参数将设置为 https://:8080/service/2

某些 OIDC 提供商要求 redirect_uri 对于给定的应用程序具有相同的值,例如,对于所有重定向 URL,https://:8080/service/callback。在这种情况下,必须设置 quarkus.oidc.authentication.redirect-path 属性。例如,quarkus.oidc.authentication.redirect-path=/service/callback,Quarkus 将 redirect_uri 参数设置为绝对 URL,如 https://:8080/service/callback,无论当前请求 URL 如何,该 URL 都将相同。

如果设置了 quarkus.oidc.authentication.redirect-path,但您需要在用户被重定向回唯一的 callback URL(例如 https://:8080/service/callback)后恢复原始请求 URL,则将 quarkus.oidc.authentication.restore-path-after-redirect 属性设置为 true。这将恢复请求 URL,例如 https://:8080/service/1

自定义身份验证请求

默认情况下,当用户被重定向到 OIDC 提供商进行身份验证时,只有 response_type(设置为 code)、scope(设置为 openid)、client_idredirect_uristate 属性会作为 HTTP 查询参数传递给 OIDC 提供商的授权端点。

您可以使用 quarkus.oidc.authentication.extra-params 添加更多属性。例如,某些 OIDC 提供商可能会选择将授权码作为重定向 URI 的片段的一部分返回,这会破坏身份验证过程。以下示例显示了如何解决此问题:

quarkus.oidc.authentication.extra-params.response_mode=query

另请参阅 OIDC 重定向过滤器 部分,其中解释了如何使用自定义 OidcRedirectFilter 来自定义 OIDC 重定向,包括 OIDC 授权端点的重定向。

自定义身份验证错误响应

当用户被重定向到 OIDC 授权端点进行身份验证,并在必要时授权 Quarkus 应用程序时,此重定向请求可能会失败,例如,当重定向 URI 中包含无效的 scope 时。在这种情况下,提供商会将用户重定向回 Quarkus,并使用 errorerror_description 参数而不是预期的 code 参数。

例如,当重定向到提供商时包含无效的 scope 或其他无效参数时,可能会发生这种情况。

在这种情况下,默认情况下会返回 HTTP 401 错误。但是,您可以请求调用自定义公共错误端点以返回更友好的 HTML 错误页面。为此,请设置 quarkus.oidc.authentication.error-path 属性,如下面的示例所示:

quarkus.oidc.authentication.error-path=/error

确保属性以斜杠 (/) 字符开头,并且路径相对于当前端点的基本 URI。例如,如果将其设置为 '/error' 并且当前请求 URI 为 https://:8080/callback?error=invalid_scope,则最终重定向将到 https://:8080/error?error=invalid_scope

为防止用户被重定向到此页面重新进行身份验证,请确保此错误端点是公共资源。

OIDC 重定向过滤器

您可以注册一个或多个 io.quarkus.oidc.OidcRedirectFilter 实现来过滤 OIDC 重定向到 OIDC 授权和注销端点,还可以过滤到自定义错误和会话过期页面的本地重定向。自定义 OidcRedirectFilter 可以添加其他查询参数、响应标头和设置新 cookie。

例如,以下简单的自定义 OidcRedirectFilter 会为 Quarkus OIDC 可以执行的所有重定向请求添加一个额外的查询参数和一个自定义响应标头:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.OidcRedirectFilter;

@ApplicationScoped
@Unremovable
public class GlobalOidcRedirectFilter implements OidcRedirectFilter {

    @Override
    public void filter(OidcRedirectContext context) {
        if (context.redirectUri().contains("/session-expired-page")) {
            context.additionalQueryParams().add("redirect-filtered", "true,"); (1)
            context.routingContext().response().putHeader("Redirect-Filtered", "true"); (2)
        }
    }

}
1 添加一个额外的查询参数。请注意,查询名称和值会由 Quarkus OIDC 进行 URL 编码,在这种情况下,会向重定向 URI 添加一个 redirect-filtered=true%20C 查询参数。
2 添加自定义 HTTP 响应标头。

另请参阅 自定义身份验证请求 部分,了解如何为 OIDC 授权点配置其他查询参数。

用于本地错误和会话过期页面的自定义 OidcRedirectFilter 还可以创建安全 cookie 以帮助生成这些页面。

例如,假设您需要将当前会话已过期的用户重定向到一个可在 https://:8080/session-expired-page 访问的自定义会话过期页面。以下自定义 OidcRedirectFilter 使用 OIDC 租户客户端密钥使用自定义 session_expired cookie 加密用户名:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.jwt.Claims;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.AuthorizationCodeTokens;
import io.quarkus.oidc.OidcRedirectFilter;
import io.quarkus.oidc.Redirect;
import io.quarkus.oidc.Redirect.Location;
import io.quarkus.oidc.TenantFeature;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.jwt.build.Jwt;

@ApplicationScoped
@Unremovable
@TenantFeature("tenant-refresh")
@Redirect(Location.SESSION_EXPIRED_PAGE) (1)
public class SessionExpiredOidcRedirectFilter implements OidcRedirectFilter {

    @Override
    public void filter(OidcRedirectContext context) {

        if (context.redirectUri().contains("/session-expired-page")) {
        AuthorizationCodeTokens tokens = context.routingContext().get(AuthorizationCodeTokens.class.getName()); (2)
        String userName = OidcUtils.decodeJwtContent(tokens.getIdToken()).getString(Claims.preferred_username.name()); (3)
        String jwe = Jwt.preferredUserName(userName).jwe()
                .encryptWithSecret(context.oidcTenantConfig().credentials.secret.get()); (4)
        OidcUtils.createCookie(context.routingContext(), context.oidcTenantConfig(), "session_expired",
                jwe + "|" + context.oidcTenantConfig().tenantId.get(), 10); (5)
     }
    }
}
1 确保此重定向过滤器仅在重定向到会话过期页面时调用。
2 将与已过期会话关联的 AuthorizationCodeTokens 令牌作为 RoutingContext 属性进行访问。
3 解码 ID 令牌声明并获取用户名。
4 使用当前 OIDC 租户的客户端密钥加密的 JWT 令牌中保存用户名。
5 创建一个有效期为 5 秒的自定义 session_expired cookie,该 cookie 将使用 "|" 分隔符连接加密的令牌和租户 ID。在自定义 cookie 中记录租户 ID 有助于在多租户 OIDC 设置中生成正确的会话过期页面。

接下来,一个生成会话过期页面的公共 JAX-RS 资源可以使用此 cookie 来创建为该用户和相应的 OIDC 租户量身定制的页面,例如:

package io.quarkus.it.keycloak;

import jakarta.inject.Inject;
import jakarta.ws.rs.CookieParam;
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.oidc.OidcTenantConfig;
import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.oidc.runtime.TenantConfigBean;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import io.vertx.ext.web.RoutingContext;

@Path("/session-expired-page")
public class SessionExpiredResource {
    @Inject
    RoutingContext context;

    @Inject
    TenantConfigBean tenantConfig; (1)

    @GET
    public String sessionExpired(@CookieParam("session_expired") String sessionExpired) throws Exception {
        // Cookie format: jwt|<tenant id>

        String[] pair = sessionExpired.split("\\|"); (2)
        OidcTenantConfig oidcConfig = tenantConfig.getStaticTenantsConfig().get(pair[1]).getOidcTenantConfig(); (3)
        JsonWebToken jwt = new DefaultJWTParser().decrypt(pair[0], oidcConfig.credentials.secret.get()); (4)
        OidcUtils.removeCookie(context, oidcConfig, "session_expired"); (5)
        return jwt.getClaim(Claims.preferred_username) + ", your session has expired. "
                + "Please login again at https://:8081/" + oidcConfig.tenantId.get(); (6)
    }
}
1 注入 TenantConfigBean,可用于访问所有当前 OIDC 租户配置。
2 将自定义 cookie 值拆分为 2 部分,第一部分是加密的令牌,最后一部分是租户 ID。
3 获取 OIDC 租户配置。
4 使用 OIDC 租户的客户端密钥解密 cookie 值。
5 删除自定义 cookie。
6 使用解密令牌中的用户名和租户 ID 来生成会话过期页面响应。

访问授权数据

您可以通过多种方式访问有关授权的信息。

访问 ID 和访问令牌

OIDC 代码身份验证机制在授权码流程中获取三个令牌:ID 令牌、访问令牌和刷新令牌。

ID 令牌始终是 JWT 令牌,代表具有 JWT 声明的用户身份验证。您可以使用它来获取 issuing 的 OIDC 端点、用户名和其他称为声明的信息。您可以通过注入具有 IdToken 限定符的 JsonWebToken 来访问 ID 令牌声明:

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.oidc.IdToken;
import io.quarkus.security.Authenticated;

@Path("/web-app")
@Authenticated
public class ProtectedResource {

    @Inject
    @IdToken
    JsonWebToken idToken;

    @GET
    public String getUserName() {
        return idToken.getName();
    }
}

OIDC web-app 应用程序通常使用访问令牌以当前登录用户的名义访问其他端点。您可以按如下方式访问原始访问令牌:

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.security.Authenticated;

@Path("/web-app")
@Authenticated
public class ProtectedResource {

    @Inject
    JsonWebToken accessToken;

    // or
    // @Inject
    // AccessTokenCredential accessTokenCredential;

    @GET
    public String getReservationOnBehalfOfUser() {
        String rawAccessToken = accessToken.getRawToken();
        //or
        //String rawAccessToken = accessTokenCredential.getToken();

        // Use the raw access token to access a remote endpoint.
        // For example, use RestClient to set this token as a `Bearer` scheme value of the HTTP `Authorization` header:
        // `Authorization: Bearer rawAccessToken`.
        return getReservationfromRemoteEndpoint(rawAccesstoken);
    }
}

当 ID 令牌作为 JsonWebToken 注入时,其验证会自动启用,此外还有强制性的 ID 令牌验证。如果确实需要,您可以使用 quarkus.oidc.authentication.verify-access-token=false 禁用此代码流访问令牌验证。

如果颁发给 Quarkus web-app 应用程序的访问令牌是不透明的(二进制)且无法解析为 JsonWebToken,或者应用程序需要其内部内容,则会使用 AccessTokenCredential

JsonWebTokenAccessTokenCredential 的注入在 @RequestScoped@ApplicationScoped 上下文中都受支持。

Quarkus OIDC 使用刷新令牌来刷新当前 ID 令牌和访问令牌,这是其 会话管理过程的一部分。

用户信息

如果 ID 令牌未提供有关当前已身份验证用户的信息,您可以从 UserInfo 端点获取更多信息。设置 quarkus.oidc.authentication.user-info-required=true 属性以从 OIDC UserInfo 端点请求 UserInfo JSON 对象。

通过使用授权码授予响应返回的访问令牌,向 OIDC 提供商 UserInfo 端点发出请求,并创建一个 io.quarkus.oidc.UserInfo(一个简单的 jakarta.json.JsonObject 包装器)对象。io.quarkus.oidc.UserInfo 可以被注入或作为 SecurityIdentity userinfo 属性进行访问。

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

  • 如果 quarkus.oidc.roles.source 设置为 userinfo,或者 quarkus.oidc.token.verify-access-token-with-user-info 设置为 true,或者 quarkus.oidc.authentication.id-token-required 设置为 false,则当前 OIDC 租户必须支持 UserInfo 端点。

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

访问 OIDC 配置信息

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

如果端点是公开的,则注入默认租户的 OiscConfigurationMetadata

映射令牌声明和 SecurityIdentity 角色

角色如何从已验证的令牌映射到 SecurityIdentity 角色与 Bearer 令牌 的映射方式相同。唯一的区别是默认情况下使用 ID 令牌作为角色的来源。

如果您使用 Keycloak,请为 ID 令牌设置 microprofile-jwt 客户端范围,以便 ID 令牌包含 groups 声明。有关更多信息,请参阅 Keycloak 服务器管理指南

但是,根据您的 OIDC 提供商,角色可能存储在访问令牌或用户信息中。

如果访问令牌包含角色,并且该访问令牌不打算传播到下游端点,则设置 quarkus.oidc.roles.source=accesstoken

如果 UserInfo 是角色的来源,则设置 quarkus.oidc.roles.source=userinfo,如果需要,还可以设置 quarkus.oidc.roles.role-claim-path

此外,您还可以使用自定义 SecurityIdentityAugmentor 来添加角色。有关更多信息,请参阅 SecurityIdentity 自定义。您还可以使用 HTTP 安全策略将从令牌声明创建的 SecurityIdentity 角色映射到部署特定的角色。

确保令牌和身份验证数据的有效性

身份验证过程的核心部分是确保信任链和信息的有效性。这是通过确保令牌可信来完成的。

令牌验证和内省

OIDC 授权码流程令牌的验证过程遵循 Bearer 令牌身份验证令牌验证和内省逻辑。有关更多信息,请参阅“Quarkus OpenID Connect (OIDC) Bearer 令牌身份验证”指南的 令牌验证和内省 部分。

对于 Quarkus web-app 应用程序,默认情况下仅验证 IdToken,因为访问令牌不用于访问当前的 Quarkus web-app 端点,而是用于传播到期望此访问令牌的服务。如果您期望访问令牌包含访问当前 Quarkus 端点所需角色(quarkus.oidc.roles.source=accesstoken),则也会验证访问令牌。

令牌内省和 UserInfo 缓存

除非期望代码流访问令牌是角色的来源,否则它们不会被内省。但是,它们将用于获取 UserInfo。如果需要令牌内省、UserInfo 或两者兼有,将会有一次或两次远程调用。有关使用默认令牌缓存或注册自定义缓存实现的信息,请参阅 令牌内省和 UserInfo 缓存

有关使用默认令牌缓存或注册自定义缓存实现的信息,请参阅“Quarkus OpenID Connect (OIDC) Bearer 令牌身份验证”指南的 令牌内省和 UserInfo 缓存 部分。

JSON Web 令牌声明验证

有关声明验证的信息,包括 iss(颁发者)声明,请参阅 JSON Web 令牌声明验证 部分。它适用于 ID 令牌,也适用于 JWT 格式的访问令牌(如果 web-app 应用程序已请求访问令牌验证)。

Jose4j 验证器

您可以注册自定义 Jose4j Validator 来自定义 JWT 声明验证过程。有关更多信息,请参阅 Jose4j 部分 Jose4j

Proof Key for Code Exchange (PKCE)

Proof Key for Code Exchange (PKCE) 可最大程度地降低授权码拦截的风险。

虽然 PKCE 对公共 OIDC 客户端(如浏览器中运行的 SPA 脚本)至关重要,但它也可以为 Quarkus OIDC web-app 应用程序提供额外保护。使用 PKCE,Quarkus OIDC web-app 应用程序充当机密 OIDC 客户端,可以安全地存储客户端密钥并使用它来交换代码以获取令牌。

您可以通过 quarkus.oidc.authentication.pkce-required 属性和一个 32 个字符的密钥来为您的 OIDC web-app 端点启用 PKCE,该密钥用于加密状态 cookie 中的 PKCE 代码验证器,如下面的示例所示:

quarkus.oidc.authentication.pkce-required=true
quarkus.oidc.authentication.state-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU

如果您已经有一个 32 个字符的客户端密钥,则无需设置 quarkus.oidc.authentication.pkce-secret 属性,除非您更喜欢使用不同的密钥。如果未配置,或者在客户端密钥小于 16 个字符时无法回退到客户端密钥,则会自动生成此密钥。

密钥用于加密随机生成的 PKCE code_verifier,同时将用户通过 code_challenge 查询参数重定向到 OIDC 提供商进行身份验证。当用户被重定向回 Quarkus 并与 code、客户端密钥和其他参数一起发送到令牌端点以完成代码交换时,code_verifier 会被解密。如果 code_verifierSHA256 摘要与身份验证请求期间提供的 code_challenge 不匹配,则提供商将失败代码交换。

处理和控制身份验证的生命周期

身份验证的另一个重要要求是确保会话所基于的数据是最新的,而无需用户为每个请求进行身份验证。有时也需要显式触发注销事件。使用以下要点来为保护 Quarkus 应用程序找到正确的平衡:

Cookies

OIDC 适配器使用 cookie 来维护会话、代码流程和注销后状态。此状态是控制身份验证数据生命周期的关键元素。

使用 quarkus.oidc.authentication.cookie-path 属性可以确保在访问具有重叠或不同根目录的受保护资源时,相同的 cookie 可见。例如:

  • /index.html/web-app/service

  • /web-app/service1/web-app/service2

  • /web-app1/service/web-app2/service

默认情况下,quarkus.oidc.authentication.cookie-path 设置为 /,但您可以根据需要将其更改为更具体的路径,例如 /web-app

要动态设置 cookie 路径,请配置 quarkus.oidc.authentication.cookie-path-header 属性。例如,要使用 X-Forwarded-Prefix HTTP 标头的值动态设置 cookie 路径,请将属性配置为 quarkus.oidc.authentication.cookie-path-header=X-Forwarded-Prefix

如果设置了 quarkus.oidc.authentication.cookie-path-header,但在当前请求中没有可用的配置的 HTTP 标头,则会检查 quarkus.oidc.authentication.cookie-path

如果您的应用程序部署在多个域上,请设置 quarkus.oidc.authentication.cookie-domain 属性,以便会话 cookie 对所有受保护的 Quarkus 服务可见。例如,如果您在以下两个域上部署了 Quarkus 服务,则必须将 quarkus.oidc.authentication.cookie-domain 属性设置为 company.net

  • https://whatever.wherever.company.net/

  • https://another.address.company.net/

状态 cookie

状态 cookie 用于支持授权码流程的完成。当授权码流程启动时,Quarkus 会创建一个状态 cookie 和一个匹配的 state 查询参数,然后将用户重定向到 OIDC 提供商。当用户被重定向回 Quarkus 以完成授权码流程时,Quarkus 期望请求 URI 必须包含 state 查询参数,并且它必须与当前状态 cookie 值匹配。

默认状态 cookie 年龄为 5 分钟,您可以使用 quarkus.oidc.authentication.state-cookie-age Duration 属性进行更改。

Quarkus 每次启动新的授权码流程时都会创建一个唯一的状态 cookie 名称,以支持多标签页身份验证。代表同一用户的许多并发身份验证请求可能会导致创建大量状态 cookie。如果您不想允许用户使用多个浏览器标签页进行身份验证,则建议使用 quarkus.oidc.authentication.allow-multiple-code-flows=false 禁用它。它还确保为每次新的用户身份验证创建相同的状态 cookie 名称。

会话 cookie 和默认 TokenStateManager

OIDC CodeAuthenticationMechanism 使用默认的 io.quarkus.oidc.TokenStateManager 接口实现,将授权码或刷新授予响应中返回的 ID、访问和刷新令牌存储在加密的会话 cookie 中。

这使得 Quarkus OIDC 端点完全无状态,并且建议遵循此策略以获得最佳的可伸缩性结果。

有关将令牌存储在数据库或其他服务器端存储解决方案中的信息,请参阅本指南的 数据库 TokenStateManager 部分。如果您偏好并有充分的理由将令牌状态存储在服务器上,则此方法是合适的。

有关令牌存储的替代方法,请参阅 会话 cookie 和自定义 TokenStateManager 部分。这对于那些寻求自定义令牌状态管理解决方案的人来说是理想的,特别是当标准服务器端存储不符合您的特定要求时。

您可以配置默认的 TokenStateManager 以避免将访问令牌保存在会话 cookie 中,并且仅保留 ID 和刷新令牌或仅保留单个 ID 令牌。

仅当端点需要执行以下操作时才需要访问令牌:

  • 检索 UserInfo

  • 使用此访问令牌访问下游服务

  • 使用与访问令牌关联的角色,这些角色默认情况下会被检查

在这种情况下,使用 quarkus.oidc.token-state-manager.strategy 属性按如下方式配置令牌状态策略:

到…​ 将属性设置为…​

仅保留 ID 和刷新令牌

quarkus.oidc.token-state-manager.strategy=id-refresh-tokens

仅保留 ID 令牌

quarkus.oidc.token-state-manager.strategy=id-token

如果选择的会话 cookie 策略组合了令牌,并生成了大于 4KB 的会话 cookie 值,则某些浏览器可能无法处理如此大的 cookie。当 ID、访问和刷新令牌是 JWT 令牌,并且所选策略是 keep-all-tokens 或 ID 和刷新令牌(策略为 id-refresh-token)时,可能会发生这种情况。为了解决此问题,您可以将 quarkus.oidc.token-state-manager.split-tokens=true 设置为为每个令牌创建一个唯一的会话令牌。另一种解决方案是将令牌保存在数据库中。有关更多信息,请参阅 数据库 TokenStateManager

默认的 TokenStateManager 会在将令牌存储在会话 cookie 中之前对其进行加密。以下示例显示了如何配置它以拆分令牌并对其进行加密:

quarkus.oidc.auth-server-url=https://:8180/realms/quarkus
quarkus.oidc.client-id=quarkus-app
quarkus.oidc.credentials.secret=secret
quarkus.oidc.application-type=web-app
quarkus.oidc.token-state-manager.split-tokens=true
quarkus.oidc.token-state-manager.encryption-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU

令牌加密密钥必须至少为 32 个字符。如果未配置此密钥,则 quarkus.oidc.credentials.secretquarkus.oidc.credentials.jwt.secret 将被哈希以创建加密密钥。

如果 Quarkus 通过使用以下任一身份验证方法向 OIDC 提供商进行身份验证,请配置 quarkus.oidc.token-state-manager.encryption-secret 属性:

  • mTLS

  • private_key_jwt,其中使用私有的 RSA 或 EC 密钥对 JWT 令牌进行签名

否则,将生成随机密钥,这在 Quarkus 应用程序在云中运行时可能会有问题,因为有多个 pod 管理请求。

您可以通过将 quarkus.oidc.token-state-manager.encryption-required=false 设置为禁用会话 cookie 中的令牌加密。

会话 cookie 和自定义 TokenStateManager

如果您想自定义令牌与会话 cookie 的关联方式,请注册一个自定义的 io.quarkus.oidc.TokenStateManager 实现作为 @ApplicationScoped CDI Bean。

例如,您可能希望将令牌保留在缓存集群中,而仅将一个密钥存储在会话 cookie 中。请注意,如果您需要在多个微服务节点之间共享令牌,此方法可能会带来一些挑战。

这是一个简单的例子

package io.quarkus.oidc.test;

import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Alternative;
import jakarta.inject.Inject;

import io.quarkus.oidc.AuthorizationCodeTokens;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenStateManager;
import io.quarkus.oidc.runtime.DefaultTokenStateManager;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
@Alternative
@Priority(1)
public class CustomTokenStateManager implements TokenStateManager {

    @Inject
    DefaultTokenStateManager tokenStateManager;

    @Override
    public Uni<String> createTokenState(RoutingContext routingContext, OidcTenantConfig oidcConfig,
            AuthorizationCodeTokens sessionContent, OidcRequestContext<String> requestContext) {
        return tokenStateManager.createTokenState(routingContext, oidcConfig, sessionContent, requestContext)
                .map(t -> (t + "|custom"));
    }

    @Override
    public Uni<AuthorizationCodeTokens> getTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig,
            String tokenState, OidcRequestContext<AuthorizationCodeTokens> requestContext) {
        if (!tokenState.endsWith("|custom")) {
            throw new IllegalStateException();
        }
        String defaultState = tokenState.substring(0, tokenState.length() - 7);
        return tokenStateManager.getTokens(routingContext, oidcConfig, defaultState, requestContext);
    }

    @Override
    public Uni<Void> deleteTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState,
            OidcRequestContext<Void> requestContext) {
        if (!tokenState.endsWith("|custom")) {
            throw new IllegalStateException();
        }
        String defaultState = tokenState.substring(0, tokenState.length() - 7);
        return tokenStateManager.deleteTokens(routingContext, oidcConfig, defaultState, requestContext);
    }
}

有关默认 TokenStateManager 将令牌存储在加密会话 cookie 中的信息,请参阅 会话 cookie 和默认 TokenStateManager

有关自定义 Quarkus TokenStateManager 实现将令牌存储在数据库中的信息,请参阅 数据库 TokenStateManager

数据库 TokenStateManager

如果您更喜欢遵循有状态令牌存储策略,则可以使用 Quarkus 提供的自定义 TokenStateManager,让您的应用程序将令牌存储在数据库中,而不是像 会话 cookie 和默认 TokenStateManager 部分所述的那样存储在加密的会话 cookie 中。

要使用此功能,请将以下扩展添加到您的项目中:

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

此扩展将用基于数据库的 io.quarkus.oidc.TokenStateManager 替换默认的 io.quarkus.oidc.TokenStateManager

OIDC 数据库令牌状态管理器在底层使用 Reactive SQL 客户端,以避免阻塞,因为身份验证可能发生在 IO 线程上。

根据您的数据库,包含并配置恰好一个 Reactive SQL 客户端。支持以下 Reactive SQL 客户端:

  • Reactive Microsoft SQL 客户端

  • Reactive MySQL 客户端

  • Reactive PostgreSQL 客户端

  • Reactive Oracle 客户端

  • Reactive DB2 客户端

如果您的应用程序已经使用 Hibernate ORM 和其中一个 JDBC 驱动程序扩展,则不需要切换到使用 Reactive SQL 客户端。

例如,您已经有一个使用 Hibernate ORM 扩展和 PostgreSQL JDBC 驱动程序的应用程序,并且您的数据源配置如下:

quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=quarkus_test
quarkus.datasource.password=quarkus_test
quarkus.datasource.jdbc.url=jdbc:postgresql://:5432/quarkus_test

现在,如果您决定使用 OIDC 数据库令牌状态管理器,则必须添加以下依赖项并设置 reactive 驱动程序 URL:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-oidc-db-token-state-manager</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-reactive-pg-client</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-oidc-db-token-state-manager")
implementation("io.quarkus:quarkus-reactive-pg-client")
quarkus.datasource.reactive.url=postgresql://:5432/quarkus_test

现在,令牌已准备好存储在数据库中。

默认情况下,会为您创建一个用于存储令牌的数据库表,但是,您可以使用 quarkus.oidc.db-token-state-manager.create-database-table-if-not-exists 配置属性禁用此选项。如果您希望 Hibernate ORM 扩展来创建此表,您只需包含一个 Entity,例如:

package org.acme.manager;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Table(name = "oidc_db_token_state_manager") (1)
@Entity
public class OidcDbTokenStateManagerEntity {

    @Id
    String id;

    @Column(name = "id_token", length = 4000) (2)
    String idToken;

    @Column(name = "refresh_token", length = 4000)
    String refreshToken;

    @Column(name = "access_token", length = 4000)
    String accessToken;

    @Column(name = "expires_in")
    Long expiresIn;
}
1 只有在生成数据库模式时,Hibernate ORM 扩展才会为您创建此表。有关更多信息,请参阅 Hibernate ORM 指南。
2 您可以根据令牌的长度选择列长度。

Redis TokenStateManager

有状态令牌存储策略的另一种方法是 Quarkus 提供的自定义 TokenStateManager,让您的应用程序将令牌存储在 Redis 缓存中。如果您决定使用 OIDC Redis Token State Manager,则必须添加以下依赖项:

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

Quarkus 会将令牌存储在默认 Redis 客户端中。如果您倾向于使用不同的 Redis 客户端,可以按如下示例配置:

quarkus.oidc.redis-token-state-manager.redis-client-name=my-redis-client (1)
1 my-redis-client 名称必须对应于通过 quarkus.redis.my-redis-client.* 配置属性指定的 Redis 客户端配置密钥。

请参阅 Quarkus Redis Client 参考 以了解如何配置 Redis 客户端。

注销和过期

身份验证信息过期主要有两种方式:令牌已过期且未续订,或者触发了显式注销操作。

让我们先从显式注销操作开始。

您可以通过 quarkus.oidc.logout.clear-site-data 配置属性为所有注销操作请求设置 Clear-Site-Data 指令。例如:

quarkus.oidc.logout.clear-site-data=cache,cookies

用户发起的注销

用户可以通过向 Quarkus 端点注销路径(通过 quarkus.oidc.logout.path 属性设置)发送请求来请求注销。例如,如果端点地址是 https://application.com/webappquarkus.oidc.logout.path 设置为 /logout,则注销请求必须发送到 https://application.com/webapp/logout

此注销请求将启动 RP 发起的注销。用户将被重定向到 OIDC 提供商进行注销,在那里可能会要求用户确认注销确实是意图的。

注销完成后,如果设置了 quarkus.oidc.logout.post-logout-path 属性,用户将被返回到注销后页面。例如,如果端点地址是 https://application.com/webappquarkus.oidc.logout.post-logout-path 设置为 /signin,则用户将被返回到 https://application.com/webapp/signin。请注意,此 URI 必须在 OIDC 提供商中注册为有效的 post_logout_redirect_uri

如果设置了 quarkus.oidc.logout.post-logout-path,则会创建一个 q_post_logout cookie,并将一个匹配的 state 查询参数添加到注销重定向 URI,OIDC 提供商将在注销完成后返回此 state。建议 Quarkus web-app 应用程序检查 state 查询参数是否与 q_post_logout cookie 的值匹配,例如,这可以在 Jakarta REST 过滤器中完成。

请注意,在使用 OpenID Connect 多租户时,cookie 名称会发生变化。例如,对于 ID 为 tenant_1 的租户,其名称将为 q_post_logout_tenant_1,依此类推。

以下是如何配置 Quarkus 应用程序以启动注销流程的示例:

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

quarkus.oidc.logout.path=/logout
# Logged-out users should be returned to the /welcome.html site which will offer an option to re-login:
quarkus.oidc.logout.post-logout-path=/welcome.html

# Only the authenticated users can initiate a logout:
quarkus.http.auth.permission.authenticated.paths=/logout
quarkus.http.auth.permission.authenticated.policy=authenticated

# All users can see the Welcome page:
quarkus.http.auth.permission.public.paths=/welcome.html
quarkus.http.auth.permission.public.policy=permit

您可能还想将 quarkus.oidc.authentication.cookie-path 设置为所有应用程序资源的通用路径值,在本例中为 /。有关更多信息,请参阅 Cookies 部分。

某些 OIDC 提供商不支持 RP 发起的注销规范,并且不返回 OpenID Connect well-known end_session_endpoint 元数据属性。但是,这对 Quarkus 来说不是问题,因为这些 OIDC 提供商的特定注销机制仅在注销 URL 查询参数的命名方式上有所不同。

根据 RP 发起的注销规范,quarkus.oidc.logout.post-logout-path 属性表示为 post_logout_redirect_uri 查询参数,而该参数不被不支持此规范的提供商识别。

您可以使用 quarkus.oidc.logout.post-logout-url-param 来解决此问题。您还可以使用 quarkus.oidc.logout.extra-params 请求添加更多注销查询参数。例如,以下是如何支持与 Auth0 的注销:

quarkus.oidc.auth-server-url=https://dev-xxx.us.auth0.com
quarkus.oidc.client-id=redacted
quarkus.oidc.credentials.secret=redacted
quarkus.oidc.application-type=web-app

quarkus.oidc.tenant-logout.logout.path=/logout
quarkus.oidc.tenant-logout.logout.post-logout-path=/welcome.html

# Auth0 does not return the `end_session_endpoint` metadata property. Instead, you must configure it:
quarkus.oidc.end-session-path=v2/logout
# Auth0 will not recognize the 'post_logout_redirect_uri' query parameter so ensure it is named as 'returnTo':
quarkus.oidc.logout.post-logout-uri-param=returnTo

# Set more properties if needed.
# For example, if 'client_id' is provided, then a valid logout URI should be set as the Auth0 Application property, without it - as Auth0 Tenant property:
quarkus.oidc.logout.extra-params.client_id=${quarkus.oidc.client-id}

后端通道注销

OIDC 提供商可以通过使用身份验证数据强制注销所有应用程序。这称为后端通道注销。在这种情况下,OIDC 将调用每个应用程序的特定 URL 来触发该注销。

OIDC 提供商使用 后端通道注销来注销当前用户在所有应用程序中的登录状态,绕过用户代理。

您可以按如下方式配置 Quarkus 以支持后端通道注销:

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

quarkus.oidc.logout.backchannel.path=/back-channel-logout

绝对 back-channel logout URL 是通过将 quarkus.oidc.back-channel-logout.path 添加到当前端点 URL 来计算的,例如 https://:8080/back-channel-logout。您需要在 OIDC 提供商的管理控制台中配置此 URL。

如果您的 OIDC 提供商不设置过期声明(expiry claim)在当前注销令牌中,您还需要配置一个令牌年龄属性以使注销令牌验证成功。例如,设置 quarkus.oidc.token.age=10S 以确保从注销令牌的 iat(issued at)时间点开始最多经过 10 秒。

前端通道注销

您可以使用 前端通道注销直接从用户代理(例如浏览器)注销当前用户。它与 后端通道注销类似,但注销步骤由用户代理(如浏览器)执行,而不是由 OIDC 提供商在后台执行。此选项很少使用。

您可以按如下方式配置 Quarkus 以支持前端通道注销:

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

quarkus.oidc.logout.frontchannel.path=/front-channel-logout

此路径将与当前请求的路径进行比较,如果这些路径匹配,则用户将被注销。

本地注销

用户发起的注销将注销用户在 OIDC 提供商的登录状态。如果作为单点登录使用,这可能不是您需要的。例如,如果您的 OIDC 提供商是 Google,您将从 Google 及其服务注销。但是,用户可能只想注销该特定应用程序。另一种用例是当 OIDC 提供商没有注销端点时。

通过使用 OidcSession,您可以支持本地注销,这意味着只清除本地会话 cookie,如下面的示例所示:

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

import io.quarkus.oidc.OidcSession;

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

    @Inject
    OidcSession oidcSession;

    @GET
    @Path("logout")
    public String logout() {
        oidcSession.logout().await().indefinitely();
        return "You are logged out";
    }
}

使用 OidcSession 进行本地注销

io.quarkus.oidc.OidcSession 是当前 IdToken 的包装器,它可以帮助执行 本地注销、检索当前会话的租户标识符,并检查会话何时过期。将来会添加更多有用的方法。

会话管理

默认情况下,注销基于 OIDC 提供商 issuing 的 ID 令牌的过期时间。当 ID 令牌过期时,Quarkus 端点的当前用户会话将失效,用户将被重定向到 OIDC 提供商再次进行身份验证。如果 OIDC 提供商上的会话仍然活动,用户将自动重新进行身份验证,而无需再次提供凭据。

通过启用 quarkus.oidc.token.refresh-expired 属性,可以自动延长当前用户会话。如果设置为 true,当当前 ID 令牌过期时,刷新令牌授予将用于刷新 ID 令牌以及访问令牌和刷新令牌。

如果您有一个用于服务应用程序的 单页应用程序,其中您的 OIDC 提供商脚本(如 keycloak.js)正在管理授权码流程,那么该脚本还将控制 SPA 的身份验证会话生命周期。

如果您使用的是 Quarkus OIDC web-app 应用程序,那么 Quarkus OIDC 代码身份验证机制将管理用户会话的生命周期。

要使用刷新令牌,您应该仔细配置会话 cookie 的年龄。会话年龄应长于 ID 令牌的生命周期,并接近或等于刷新令牌的生命周期。

您通过添加当前 ID 令牌的生命周期值以及 quarkus.oidc.authentication.session-age-extensionquarkus.oidc.token.lifespan-grace 属性的值来计算会话年龄。

如果需要,您仅使用 quarkus.oidc.authentication.session-age-extension 属性来显著延长会话生命周期。您仅使用 quarkus.oidc.token.lifespan-grace 属性来考虑一些小的时钟偏差。

当当前已身份验证的用户返回到受保护的 Quarkus 端点,并且与会话 cookie 关联的 ID 令牌已过期时,默认情况下,用户将被自动重定向到 OIDC 授权端点以重新进行身份验证。如果用户与此 OIDC 提供商之间的会话仍然活动,OIDC 提供商可能会再次挑战用户,这可能发生在会话配置的持续时间长于 ID 令牌的持续时间时。

如果 quarkus.oidc.token.refresh-expired 设置为 true,则将使用原始授权码授予响应返回的刷新令牌来刷新已过期的 ID 令牌(以及访问令牌)。在此过程中,此刷新令牌本身也可能被回收(刷新)。结果是创建了新的会话 cookie,并且会话得到了延长。

在用户不活跃的情况下,您可以使用 quarkus.oidc.authentication.session-age-extension 属性来帮助处理已过期的 ID 令牌。如果 ID 令牌过期,则在用户下次请求时可能不会将会话 cookie 返回到 Quarkus 端点,因为 cookie 的生命周期可能已经结束。Quarkus 假定此请求是首次身份验证请求。将 quarkus.oidc.authentication.session-age-extension 设置为对于不太活跃的用户以及根据您的安全策略来说是合理长的。

您可以更进一步,主动刷新即将过期的 ID 令牌或访问令牌。将 quarkus.oidc.token.refresh-token-time-skew 设置为您希望预期的刷新时间。如果在当前用户请求期间,计算得出当前 ID 令牌将在此 quarkus.oidc.token.refresh-token-time-skew 内过期,则会刷新它,并创建新的会话 cookie。此属性应设置为小于 ID 令牌生命周期的值;越接近此生命周期值,ID 令牌刷新的频率就越高。

您可以通过让简单的 JavaScript 函数定期 ping Quarkus 端点来模拟用户活动,从而进一步优化此过程,这可以最大程度地减少用户可能需要重新进行身份验证的时间范围。

当会话无法刷新时,当前已身份验证的用户将被重定向到 OIDC 提供商以重新进行身份验证。但是,在这种情况下,用户体验可能不是理想的,如果用户在先前的成功身份验证后,在尝试访问应用程序页面时突然看到 OIDC 身份验证挑战屏幕。

相反,您可以要求将用户首先重定向到一个公共的、特定于应用程序的会话过期页面。此页面告知用户会话已过期,并建议用户通过单击指向安全应用程序欢迎页面的链接来重新进行身份验证。用户单击链接,Quarkus OIDC 会强制重定向到 OIDC 提供商以重新进行身份验证。如果您想这样做,请使用 quarkus.oidc.authentication.session-expired-page 相对路径属性。

例如,将 quarkus.oidc.authentication.session-expired-page=/session-expired-page 设置为 https://:8080 处可用应用程序,可以确保会话已过期的用户被重定向到 https://:8080/session-expired-page

另请参阅 OIDC 重定向过滤器 部分,其中解释了如何使用自定义 OidcRedirectFilter 来自定义 OIDC 重定向,包括会话过期页面的重定向。

您不能无限期地延长用户会话。当刷新令牌过期时,具有已过期 ID 令牌的返回用户将必须在 OIDC 提供商端点重新进行身份验证。

与 GitHub 和非 OIDC OAuth2 提供商集成

一些知名的提供商,如 GitHub 或 LinkedIn,不是 OpenID Connect 提供商,而是支持 authorization code flow 的 OAuth2 提供商。例如,GitHub OAuth2LinkedIn OAuth2。请记住,OIDC 是建立在 OAuth2 之上的。

OIDC 和 OAuth2 提供商之间的主要区别在于,OIDC 提供商返回代表用户身份验证的 ID Token,以及 OAuth2 提供商返回的标准授权码流程 accessrefresh 令牌。

GitHub 等 OAuth2 提供商不返回 IdToken,用户身份验证是隐式的,并间接由 access 令牌表示。此 access 令牌代表已身份验证的用户授权当前 Quarkus web-app 应用程序以用户身份访问某些数据。

对于 OIDC,您将 ID 令牌验证为身份验证有效性的证明,而在 OAuth2 的情况下,您验证访问令牌。这是通过后续调用需要访问令牌的端点来完成的,该端点通常会返回用户信息。此方法类似于 OIDC UserInfo 方法,Quarkus OIDC 会代表您获取 UserInfo

例如,在使用 GitHub 时,Quarkus 端点可以获取一个 access 令牌,该令牌允许 Quarkus 端点请求当前用户的 GitHub 配置文件。

为了支持与此类 OAuth2 服务器的集成,需要对 quarkus-oidc 进行一些不同的配置,以允许在没有 IdToken 的情况下进行授权码流程响应:quarkus.oidc.authentication.id-token-required=false

即使您将扩展配置为支持没有 IdToken 的授权码流程,也会生成内部 IdToken 以标准化 quarkus-oidc 的操作方式。您使用内部 IdToken 来支持身份验证会话,并避免在每次请求时将用户重定向到 GitHub 等提供商。在这种情况下,IdToken 的年龄设置为授权码流程响应中标准 expires_in 属性的值。您可以使用 quarkus.oidc.authentication.internal-id-token-lifespan 属性来自定义 ID 令牌的年龄。默认 ID 令牌年龄为 5 分钟,您可以在 会话管理 部分所述的方式进一步延长。

这简化了您处理支持多个 OIDC 提供商的应用程序的方式。

下一步是确保返回的访问令牌对于当前的 Quarkus 端点有用且有效。第一种方法是通过配置 quarkus.oidc.introspection-path 来调用 OAuth2 提供商的内省端点(如果提供商提供此类端点)。在这种情况下,您可以使用访问令牌作为角色的来源,使用 quarkus.oidc.roles.source=accesstoken。如果没有内省端点,您可以尝试请求提供商的 UserInfo,因为它至少会验证访问令牌。为此,请指定 quarkus.oidc.token.verify-access-token-with-user-info=true。您还需要将 quarkus.oidc.user-info-path 属性设置为获取用户信息的 URL 端点(或由访问令牌保护的端点)。对于 GitHub,因为它没有内省端点,所以必须请求 UserInfo。

要求 UserInfo 涉及在每次请求时进行远程调用。

因此,UserInfo 会嵌入到内部生成的 IdToken 中,并保存在加密的会话 cookie 中。可以通过 quarkus.oidc.cache-user-info-in-idtoken=false 禁用它。

或者,您可能希望考虑使用默认或自定义 UserInfo 缓存提供程序来缓存 UserInfo。有关更多信息,请参阅“OpenID Connect (OIDC) Bearer 令牌身份验证”指南的 令牌内省和 UserInfo 缓存 部分。

大多数知名的社交 OAuth2 提供商都会强制执行速率限制,因此您很有可能希望缓存 UserInfo。

OAuth2 服务器可能不支持 well-known 配置端点。在这种情况下,您必须禁用发现并手动配置授权、令牌以及内省和 UserInfo 端点路径。

对于 Apple、Facebook、GitHub、Google、Microsoft、Spotify 和 X(原 Twitter)等知名的 OIDC 或 OAuth2 提供商,Quarkus 可以通过 quarkus.oidc.provider 属性显著简化您的应用程序配置。以下是如何在 创建 GitHub OAuth 应用程序后将 quarkus-oidc 与 GitHub 集成的:配置您的 Quarkus 端点如下:

quarkus.oidc.provider=github
quarkus.oidc.client-id=github_app_clientid
quarkus.oidc.credentials.secret=github_app_clientsecret

# user:email scope is requested by default, use 'quarkus.oidc.authentication.scopes' to request different scopes such as `read:user`.
# See https://githubdocs.cn/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps for more information.

# Consider enabling UserInfo Cache
# quarkus.oidc.token-cache.max-size=1000
# quarkus.oidc.token-cache.time-to-live=5M
#
# Or having UserInfo cached inside IdToken itself
# quarkus.oidc.cache-user-info-in-idtoken=true

有关配置其他知名提供商的更多信息,请参阅 OpenID Connect 提供商

对于像这样的端点,这是获取当前已身份验证用户的配置文件并将其作为单独的 UserInfo 属性访问的全部内容:GET https://:8080/github/userinfo

package io.quarkus.it.keycloak;

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

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

@Path("/github")
@Authenticated
public class TokenResource {

    @Inject
    UserInfo userInfo;

    @GET
    @Path("/userinfo")
    @Produces("application/json")
    public String getUserInfo() {
        return userInfo.getUserInfoString();
    }
}

如果您使用 OpenID Connect 多租户支持多个社交提供商,例如 Google(一个返回 IdToken 的 OIDC 提供商)和 GitHub(一个不返回 IdToken 且仅允许访问 UserInfo 的 OAuth2 提供商),那么您可以让您的端点仅使用注入的 SecurityIdentity 来处理 Google 和 GitHub 流程。在这种情况下,需要对 SecurityIdentity 进行简单的增强,其中使用基于 UserInfo 的 principal 替换使用内部生成的 IdToken 创建的 principal,当 GitHub 流程处于活动状态时。

package io.quarkus.it.keycloak;

import java.security.Principal;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.oidc.UserInfo;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.SecurityIdentityAugmentor;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomSecurityIdentityAugmentor implements SecurityIdentityAugmentor {

    @Override
    public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
        RoutingContext routingContext = identity.getAttribute(RoutingContext.class.getName());
        if (routingContext != null && routingContext.normalizedPath().endsWith("/github")) {
	        QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity);
	        UserInfo userInfo = identity.getAttribute("userinfo");
	        builder.setPrincipal(new Principal() {

	            @Override
	            public String getName() {
	                return userInfo.getString("preferred_username");
	            }

	        });
	        identity = builder.build();
        }
        return Uni.createFrom().item(identity);
    }

}

现在,当用户通过 Google 或 GitHub 登录到您的应用程序时,以下代码将有效:

package io.quarkus.it.keycloak;

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

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

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

    @Inject
    SecurityIdentity identity;

    @GET
    @Path("/google")
    @Produces("application/json")
    public String getGoogleUserName() {
        return identity.getPrincipal().getName();
    }

    @GET
    @Path("/github")
    @Produces("application/json")
    public String getGitHubUserName() {
        return identity.getPrincipal().getName();
    }
}

也许一个更简单的替代方法是注入 @IdToken JsonWebTokenUserInfo,并在处理返回 IdToken 的提供商时使用 JsonWebToken,而对于不返回 IdToken 的提供商则使用 UserInfo

您必须确保在 GitHub OAuth 应用程序配置中输入的 callback 路径与您希望用户在成功 GitHub 身份验证和应用程序授权后重定向到的端点路径匹配。在这种情况下,它必须设置为 https://:8080/github/userinfo

监听重要的身份验证事件

您可以注册 @ApplicationScoped Bean 来观察重要的 OIDC 身份验证事件。当用户首次登录、重新进行身份验证或刷新会话时,监听器会收到更新。将来可能会报告更多事件。例如:

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;

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

@ApplicationScoped
public class SecurityEventListener {

    public void event(@Observes SecurityEvent event) {
        String tenantId = event.getSecurityIdentity().getAttribute("tenant-id");
        RoutingContext vertxContext = event.getSecurityIdentity().getAttribute(RoutingContext.class.getName());
        vertxContext.put("listener-message", String.format("event:%s,tenantId:%s", event.getEventType().name(), tenantId));
    }
}
您可以监听其他安全事件,如安全技巧和技巧指南的 观察安全事件 部分所述。

令牌撤销

有时,您可能希望撤销当前授权码流程的访问令牌和/或刷新令牌。您可以使用 quarkus.oidc.OidcProviderClient 来撤销令牌,该客户端提供对 OIDC 提供商的 UserInfo、令牌内省和撤销端点的访问。

例如,当使用 OidcSession 执行本地注销时,您可以使用注入的 OidcProviderClient 来撤销与当前会话关联的访问令牌和刷新令牌:

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

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcProviderClient;
import io.quarkus.oidc.OidcSession;
import io.quarkus.oidc.RefreshToken;

import io.smallrye.mutiny.Uni;

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

    @Inject
    OidcSession oidcSession;

    @Inject
    OidcProviderClient oidcProviderClient;

    @Inject
    AccessTokenCredential accessToken;

    @Inject
    RefreshToken refreshToken;

    @GET
    public Uni<String> logout() {
        return oidcSession.logout() (1)
                   .chain(() -> oidcClient.revokeAccessToken(accessToken.getToken())) (2)
                   .chain(() -> oidcClient.revokeRefreshToken(refreshToken.getToken())) (3)
                   .map((result) -> "You are logged out");
    }
}
1 通过清除会话 cookie 执行本地注销。
2 撤销授权码流程访问令牌。
3 撤销授权码流程刷新令牌。

您也可以在安全事件监听器中撤销令牌。

例如,当您的应用程序支持标准的 用户发起的注销时,您可以捕获注销事件并撤销令牌:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcProviderClient;
import io.quarkus.oidc.RefreshToken;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.ObservesAsync;

@ApplicationScoped
public class SecurityEventListener {

    public CompletionStage<Void> processSecurityEvent(@ObservesAsync SecurityEvent event) {
        if (SecurityEvent.Type.OIDC_LOGOUT_RP_INITIATED == event.getEventType()) { (1)
	    return revokeTokens(event.getSecurityIdentity()).subscribeAsCompletionStage();
	}
	return CompletableFuture.completedFuture(null);
    }
    private Uni<Void> revokeTokens(SecurityIdentity securityIdentity) {
        return Uni.join().all(
                   revokeAccessToken(securityIdentity),
	           revokeRefreshToken(securityIdentity)
               ).andCollectFailures()
                .replaceWithVoid()
                .onFailure().recoverWithUni(t -> logFailure(t));
    }

    private static Uni<Boolean> revokeAccessToken(SecurityIdentity securityIdentity) { (2)
        OidcProviderClient oidcProvider = securityIdentity.getAttribute(OidcProviderClient.class.getName());
        String accessToken = securityIdentity.getCredential(AccessTokenCredential.class).getToken();
        return oidcProvider.revokeAccessToken(accessToken);
    }

    private static Uni<Boolean> revokeRefreshToken(SecurityIdentity securityIdentity) { (3)
        OidcProviderClient oidcProvider = securityIdentity.getAttribute(OidcProviderClient.class.getName());
        String refreshToken = securityIdentity.getCredential(RefreshToken.class).getToken();
        return oidcProvider.revokeRefreshToken(refreshToken);
    }

    private static Uni<Void> logFailure(Throwable t) {
        // Log failure as required
        return Uni.createFrom().voidItem();
    }
}
1 如果观察到 RP 发起的注销事件,则撤销令牌。
2 撤销授权码流程访问令牌。
3 撤销授权码流程刷新令牌。

将令牌传播到下游服务

有关将授权码流程访问令牌传播到下游服务的信息,请参阅“Quarkus OpenID Connect (OIDC) 客户端参考”中的 令牌传播 部分。

集成注意事项

您的 OIDC 安全应用程序集成在可以从单页应用程序调用的环境中。它必须与知名的 OIDC 提供商一起工作,运行在 HTTP 反向代理后面,需要外部和内部访问,等等。

本节将讨论这些注意事项。

单页应用程序

您可以检查在“OpenID Connect (OIDC) Bearer 令牌身份验证”指南的 单页应用程序部分中建议的实现单页应用程序(SPA)的方式是否满足您的要求。

如果您倾向于在 Quarkus Web 应用程序中使用 SPA 和 JavaScript API(如 FetchXMLHttpRequest(XHR)),请注意,OIDC 提供商可能不支持针对用户在 Quarkus 重定向后进行身份验证的授权端点的跨域资源共享(CORS)。如果 Quarkus 应用程序和 OIDC 提供商托管在不同的 HTTP 域、端口或两者之上,这将导致身份验证失败。

在这种情况下,请将 quarkus.oidc.authentication.java-script-auto-redirect 属性设置为 false,这将指示 Quarkus 返回 499 状态码和一个带有 OIDC 值的 WWW-Authenticate 标头。

quarkus.oidc.authentication.java-script-auto-redirect 属性设置为 false 时,浏览器脚本必须设置一个标头来标识当前请求为 JavaScript 请求,以返回 499 状态码。

如果脚本引擎自行设置了引擎特定的请求标头,那么您可以注册一个自定义的 quarkus.oidc.JavaScriptRequestChecker Bean,它将告知 Quarkus 当前请求是否为 JavaScript 请求。例如,如果 JavaScript 引擎设置了 HX-Request: true 这样的标头,那么您可以像这样检查它:

import jakarta.enterprise.context.ApplicationScoped;

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

@ApplicationScoped
public class CustomJavaScriptRequestChecker implements JavaScriptRequestChecker {

    @Override
    public boolean isJavaScriptRequest(RoutingContext context) {
        return "true".equals(context.request().getHeader("HX-Request"));
    }
}

并在 499 状态码的情况下重新加载最后一个请求的页面。

否则,您还必须更新浏览器脚本以将 X-Requested-With 标头设置为 JavaScript 值,并在 499 状态码的情况下重新加载最后一个请求的页面。

例如

Future<void> callQuarkusService() async {
    Map<String, String> headers = Map.fromEntries([MapEntry("X-Requested-With", "JavaScript")]);

    await http
        .get("https://:443/serviceCall")
        .then((response) {
            if (response.statusCode == 499) {
                window.location.assign("https://.com:443/serviceCall");
            }
         });
  }

跨域资源共享

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

调用云提供商服务

Google Cloud

您可以让 Quarkus OIDC web-app 应用程序以当前已启用 OIDC 授权码流程权限的用户身份访问Google Cloud 服务,例如BigQuery

您可以通过使用 Quarkiverse Google Cloud Services 来实现此目的。您只需要像下面的示例那样添加最新版本的服务依赖项:

pom.xml
<dependency>
    <groupId>io.quarkiverse.googlecloudservices</groupId>
    <artifactId>quarkus-google-cloud-bigquery</artifactId>
    <version>${quarkiverse.googlecloudservices.version}</version>
</dependency>
build.gradle
implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-bigquery:${quarkiverse.googlecloudservices.version}")

然后,配置 Google OIDC 属性:

quarkus.oidc.provider=google
quarkus.oidc.client-id={GOOGLE_CLIENT_ID}
quarkus.oidc.credentials.secret={GOOGLE_CLIENT_SECRET}
quarkus.oidc.token.issuer=https://#

在反向代理后面运行 Quarkus 应用程序

如果您的 Quarkus 应用程序运行在反向代理、网关或防火墙后面,OIDC 身份验证机制可能会受到影响,因为 HTTP Host 标头可能会被重置为内部 IP 地址,并且 HTTPS 连接可能会被终止等。例如,授权码流程 redirect_uri 参数可能被设置为内部主机而不是预期的外部主机。

在这种情况下,需要将 Quarkus 配置为识别代理转发的原始标头。有关更多信息,请参阅 在反向代理后面运行 的 Vert.x 文档部分。

例如,如果您的 Quarkus 端点在 Kubernetes Ingress 后面的集群中运行,那么从 OIDC 提供商重定向回此端点可能不起作用,因为计算出的 redirect_uri 参数可能指向内部端点地址。您可以使用以下配置解决此问题,其中 X-ORIGINAL-HOST 由 Kubernetes Ingress 设置,用于表示外部端点地址:

quarkus.http.proxy.proxy-address-forwarding=true
quarkus.http.proxy.allow-forwarded=false
quarkus.http.proxy.enable-forwarded-host=true
quarkus.http.proxy.forwarded-host-header=X-ORIGINAL-HOST

当 Quarkus 应用程序运行在 SSL 终止的反向代理后面时,也可以使用 quarkus.oidc.authentication.force-redirect-https-scheme 属性。

到 OIDC 提供商的外部和内部访问

OIDC 提供商外部可访问的授权、注销和其他端点可能具有与 quarkus.oidc.auth-server-url 内部 URL 自动发现或相对配置的 URL 不同的 HTTP(S) URL。在这种情况下,端点可能会报告颁发者验证失败,并且重定向到外部可访问的 OIDC 提供商端点可能会失败。

如果您使用 Keycloak,则在启动 Keycloak 时设置 KEYCLOAK_FRONTEND_URL 系统属性为外部可访问的基础 URL。如果您使用其他 OIDC 提供商,请查阅您的提供商文档。

OIDC HTTP 客户端重定向

防火墙后面的 OIDC 提供商可能会将 Quarkus OIDC HTTP 客户端的 GET 请求重定向到其某些端点,例如 well-known 配置端点。默认情况下,Quarkus OIDC HTTP 客户端会自动跟随 HTTP 重定向,但出于安全原因会排除重定向请求期间可能设置的 cookie。

如果您愿意,可以使用 quarkus.oidc.follow-redirects=false 禁用它。

当自动跟随重定向被禁用时,如果 Quarkus OIDC HTTP 客户端收到重定向请求,它将尝试仅恢复一次,方法是跟随重定向 URI,但前提是它与原始请求 URI 完全相同,并且在重定向请求期间设置了一个或多个 cookie。

OIDC SAML 身份提供商

如果您的身份提供商不实现 OpenID Connect,而只实现基于 XML 的遗留 SAML2.0 SSO 协议,那么 Quarkus 不能作为 SAML 2.0 适配器使用,就像 quarkus-oidc 用作 OIDC 适配器一样。

但是,许多 OIDC 提供商(如 Keycloak、Okta、Auth0 和 Microsoft ADFS)提供 OIDC 到 SAML 2.0 的桥接。您可以在 OIDC 提供商中创建到 SAML 2.0 提供商的身份提供商连接,并使用 quarkus-oidc 来向此 SAML 2.0 提供商进行用户身份验证,由 OIDC 提供商协调 OIDC 和 SAML 2.0 通信。就 Quarkus 端点而言,它们可以继续使用相同的 Quarkus Security、OIDC API、@Authenticated 等注解,SecurityIdentity 等。

例如,假设 Okta 是您的 SAML 2.0 提供商,Keycloak 是您的 OIDC 提供商。以下是一个典型的序列,说明如何配置 Keycloak 以与 Okta SAML 2.0 提供商进行中介。

首先,在您的 Okta Dashboard/Applications 中创建一个新的 SAML2 集成。

Okta Create SAML Integration

例如,将其命名为 OktaSaml

Okta SAML General Settings

接下来,配置它指向 Keycloak SAML 中介端点。此时,您需要知道 Keycloak 领域(realm)的名称,例如 quarkus,并且假设 Keycloak SAML 中介别名是 saml,请输入端点地址为 https://:8081/realms/quarkus/broker/saml/endpoint。将服务提供商(SP)实体 ID 输入为 https://:8081/realms/quarkus,其中 https://:8081 是 Keycloak 的基础地址,saml 是中介别名。

Okta SAML Configuration

接下来,保存此 SAML 集成并记下其元数据 URL。

Okta SAML Metadata

接下来,向 Keycloak 添加一个 SAML 提供商。

首先,像往常一样,创建一个新的领域或导入现有的领域到 Keycloak。在这种情况下,领域名称必须是 quarkus

现在,在 quarkus 领域属性中,导航到 Identity Providers 并添加一个新的 SAML 提供商。

Keycloak Add SAML Provider

请注意,别名设置为 samlRedirect URIhttps://:8081/realms/quarkus/broker/saml/endpointService provider entity IDhttps://:8081/realms/quarkus - 这些值与您在上一步创建 Okta SAML 集成时输入的值相同。

最后,将 Service entity descriptor 设置为指向您在上一步末尾记下的 Okta SAML 集成元数据 URL。

接下来,如果您愿意,可以通过导航到 Authentication/browser/Identity Provider Redirector config 并将 AliasDefault Identity Provider 属性都设置为 saml 来将此 Keycloak SAML 提供商注册为默认提供商。如果您不将其配置为默认提供商,则在身份验证时,Keycloak 提供 2 个选项:

  • 使用 SAML 提供商进行身份验证

  • 直接使用用户名和密码向 Keycloak 进行身份验证

现在,将 Quarkus OIDC web-app 应用程序配置为指向 Keycloak quarkus 领域,quarkus.oidc.auth-server-url=https://:8180/realms/quarkus。然后,您就可以准备好使用 Keycloak OIDC 和 Okta SAML 2.0 提供商提供的 OIDC 到 SAML 桥接来开始对您的 Quarkus 用户进行 Okta SAML 2.0 提供商的身份验证了。

您可以像配置 Keycloak 一样,将其他 OIDC 提供商配置为提供 SAML 桥接。

测试

对于与外部 OIDC 类服务器进行身份验证,测试通常很棘手。Quarkus 提供了从模拟到本地运行 OIDC 提供商的多种选项。

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

pom.xml
<dependency>
    <groupId>org.htmlunit</groupId>
    <artifactId>htmlunit</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>*</artifactId>
       </exclusion>
    </exclusions>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("org.htmlunit:htmlunit")
testImplementation("io.quarkus:quarkus-junit5")

Keycloak 的开发服务

对于与 Keycloak 的集成测试,请使用 Keycloak 的开发服务。此服务会初始化一个测试容器,创建一个 quarkus 领域,并配置一个具有 secret 秘密的 quarkus-app 客户端。它还将设置两个用户:拥有 adminuser 角色的 alice,以及拥有 user 角色的 bob

首先,准备 application.properties 文件。

如果从空的 application.properties 文件开始,Keycloak 的开发服务 会自动注册以下属性:

  • quarkus.oidc.auth-server-url,它指向正在运行的测试容器。

  • quarkus.oidc.client-id=quarkus-app.

  • quarkus.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

最后,编写测试代码,如 Wiremock 部分所述。唯一的区别是不再需要 @QuarkusTestResource

@QuarkusTest
public class CodeFlowAuthorizationTest {
}

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-web-app
quarkus.oidc.credentials.secret=secret
quarkus.oidc.application-type=web-app

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

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

import org.htmlunit.SilentCssErrorHandler;
import org.htmlunit.WebClient;
import org.htmlunit.html.HtmlForm;
import org.htmlunit.html.HtmlPage;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWiremockTestResource;

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

    @Test
    public void testCodeFlow() throws Exception {
        try (final WebClient webClient = createWebClient()) {
            // the test REST endpoint listens on '/code-flow'
            HtmlPage page = webClient.getPage("https://:8081/code-flow");

            HtmlForm form = page.getForms().get(0);
            // user 'alice' has the 'user' role
            form.getInputByName("username").type("alice");
            form.getInputByName("password").type("alice");

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

            assertEquals("alice", page.getBody().asNormalizedText());
        }
    }

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

OidcWiremockTestResource 识别 aliceadmin 用户。默认情况下,alice 用户仅拥有 user 角色 - 可以使用 quarkus.test.oidc.token.user-roles 系统属性自定义。默认情况下,admin 用户拥有 useradmin 角色 - 可以使用 quarkus.test.oidc.token.admin-roles 系统属性自定义。

此外,OidcWiremockTestResource 将令牌颁发者和受众设置为 https://service.example.com,这可以通过 quarkus.test.oidc.token.issuerquarkus.test.oidc.token.audience 系统属性进行自定义。

OidcWiremockTestResource 可用于模拟所有 OIDC 提供商。

使用 KeycloakTestResourceLifecycleManager

只有当有充分的理由不使用 Keycloak 的开发服务 时,才应为您的测试使用 KeycloakTestResourceLifecycleManager。如果您需要针对 Keycloak 进行集成测试,则鼓励您使用 Keycloak 的开发服务

首先,添加以下依赖项:

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

现在,设置配置并以与 Wiremock 部分描述相同的方式编写测试代码。唯一的区别是 QuarkusTestResource 的名称。

import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager;

@QuarkusTest
@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class)
public class CodeFlowAuthorizationTest {
}

KeycloakTestResourceLifecycleManager 会注册 aliceadmin 用户。默认情况下,alice 用户仅拥有 user 角色 - 可以使用 keycloak.token.user-roles 系统属性进行自定义。默认情况下,admin 用户拥有 useradmin 角色 - 可以使用 keycloak.token.admin-roles 系统属性进行自定义。

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

TestSecurity 注解

您可以使用 @TestSecurity@OidcSecurity 注解来测试 web-app 应用程序的端点代码,这些代码依赖于以下一个或全部四个注入:

  • ID JsonWebToken

  • 访问 JsonWebToken

  • UserInfo

  • OidcConfigurationMetadata

有关更多信息,请参阅 使用 TestingSecurity 和注入的 JsonWebToken

在日志中检查错误

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

quarkus dev 控制台中,输入 j 来更改应用程序的全局日志级别。

以编程方式启动 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.createWebApp("https://:8180/realms/quarkus", "quarkus-app", "mysecret");
    }

}

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

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

如果您需要配置更多的 OIDC 租户属性,请使用 OidcTenantConfig 生成器,如下面的示例所示:

package io.quarkus.it.oidc;

import io.quarkus.oidc.Oidc;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Secret.Method;
import jakarta.enterprise.event.Observes;

public class OidcStartup {

    void createDefaultTenant(@Observes Oidc oidc) {
        var defaultTenant = OidcTenantConfig
                .authServerUrl("https://:8180/realms/quarkus/")
                .clientId("quarkus-app")
                .credentials().clientSecret("mysecret", Method.POST).end()
                .build();
        oidc.create(defaultTenant);
    }
}

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

相关内容