OAuth 2.0 Resource Server Multi-tenancy

Supporting both JWT and Opaque Token

在某些情况下,您可能需要访问两种令牌。例如,您可能支持不止一个租户,其中一个租户发布 JWT,另一个发布不透明令牌。

In some cases, you may have a need to access both kinds of tokens. For example, you may support more than one tenant where one tenant issues JWTs and the other issues opaque tokens.

如果必须在请求时做出此决定,那么可以通过使用 AuthenticationManagerResolver 来实现,如下所示:

If this decision must be made at request-time, then you can use an AuthenticationManagerResolver to achieve it, like so:

  • Java

  • Kotlin

@Bean
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver
        (JwtDecoder jwtDecoder, OpaqueTokenIntrospector opaqueTokenIntrospector) {
    AuthenticationManager jwt = new ProviderManager(new JwtAuthenticationProvider(jwtDecoder));
    AuthenticationManager opaqueToken = new ProviderManager(
            new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));
    return (request) -> useJwt(request) ? jwt : opaqueToken;
}
@Bean
fun tokenAuthenticationManagerResolver
        (jwtDecoder: JwtDecoder, opaqueTokenIntrospector: OpaqueTokenIntrospector):
        AuthenticationManagerResolver<HttpServletRequest> {
    val jwt = ProviderManager(JwtAuthenticationProvider(jwtDecoder))
    val opaqueToken = ProviderManager(OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));

    return AuthenticationManagerResolver { request ->
        if (useJwt(request)) {
            jwt
        } else {
            opaqueToken
        }
    }
}

useJwt(HttpServletRequest) 的实现可能会依赖于自定义请求资料,例如路径。

The implementation of useJwt(HttpServletRequest) will likely depend on custom request material like the path.

然后在 DSL 中指定此 AuthenticationManagerResolver

And then specify this AuthenticationManagerResolver in the DSL:

Authentication Manager Resolver
  • Java

  • Kotlin

  • Xml

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
    );
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = tokenAuthenticationManagerResolver()
    }
}
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
</http>

Multi-tenancy

当验证 bearer token 有多种策略(以某种租户标识符为键)时,资源服务器被认为是多租户。

A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier.

例如,您的资源服务器可能接受来自两个不同授权服务器的 bearer token。或者,您的授权服务器可能表示多个颁发者。

For example, your resource server may accept bearer tokens from two different authorization servers. Or, your authorization server may represent a multiplicity of issuers.

在每种情况下,都需要做两件事,并权衡执行方式。

In each case, there are two things that need to be done and trade-offs associated with how you choose to do them:

  1. Resolve the tenant

  2. Propagate the tenant

Resolving the Tenant By Claim

区分租户的一种方法是通过 issuers 声明。由于 issuers 声明附带签名 JWT,因此可以使用 JwtIssuerAuthenticationManagerResolver 完成此操作,如下所示:

One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the JwtIssuerAuthenticationManagerResolver, like so:

Multi-tenancy Tenant by JWT Claim
  • Java

  • Kotlin

  • Xml

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
    .fromTrustedIssuers("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );
val customAuthenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
    .fromTrustedIssuers("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo")
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = customAuthenticationManagerResolver
    }
}
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="authenticationManagerResolver"/>
</http>

<bean id="authenticationManagerResolver"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver">
    <constructor-arg>
        <list>
            <value>https://idp.example.org/issuerOne</value>
            <value>https://idp.example.org/issuerTwo</value>
        </list>
    </constructor-arg>
</bean>

这很好,因为 issuers 端点是延迟加载的。实际上,仅在发送具有对应 issuers 的第一个请求时才会实例化相应的 JwtAuthenticationProvider。这允许应用程序启动独立于授权服务器的启动和可用性。

This is nice because the issuer endpoints are loaded lazily. In fact, the corresponding JwtAuthenticationProvider is instantiated only when the first request with the corresponding issuer is sent. This allows for an application startup that is independent from those authorization servers being up and available.

Dynamic Tenants

当然,您可能不希望每次添加新租户时都重新启动应用程序。在这种情况下,您可以使用 AuthenticationManager 实例的存储库配置 JwtIssuerAuthenticationManagerResolver,您可以在运行时编辑它,如下所示:

Of course, you may not want to restart the application each time a new tenant is added. In this case, you can configure the JwtIssuerAuthenticationManagerResolver with a repository of AuthenticationManager instances, which you can edit at runtime, like so:

  • Java

  • Kotlin

private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
	JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
	        (JwtDecoders.fromIssuerLocation(issuer));
	authenticationManagers.put(issuer, authenticationProvider::authenticate);
}

// ...

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
        new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );
private fun addManager(authenticationManagers: MutableMap<String, AuthenticationManager>, issuer: String) {
    val authenticationProvider = JwtAuthenticationProvider(JwtDecoders.fromIssuerLocation(issuer))
    authenticationManagers[issuer] = AuthenticationManager {
        authentication: Authentication? -> authenticationProvider.authenticate(authentication)
    }
}

// ...

val customAuthenticationManagerResolver: JwtIssuerAuthenticationManagerResolver =
    JwtIssuerAuthenticationManagerResolver(authenticationManagers::get)
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = customAuthenticationManagerResolver
    }
}

在这种情况下,你要使用一种策略构建 JwtIssuerAuthenticationManagerResolver,以便给定发行人获取 AuthenticationManager。这种方法允许我们添加和从存储库中移除元素(在代码片段中显示为 Map),而且是在运行时。

In this case, you construct JwtIssuerAuthenticationManagerResolver with a strategy for obtaining the AuthenticationManager given the issuer. This approach allows us to add and remove elements from the repository (shown as a Map in the snippet) at runtime.

简单获取任何颁发者并从中构造 AuthenticationManager 并不安全。颁发者应该是代码可以从受信任来源(例如允许的颁发者列表)验证的一个。

It would be unsafe to simply take any issuer and construct an AuthenticationManager from it. The issuer should be one that the code can verify from a trusted source like a list of allowed issuers.

Parsing the Claim Only Once

您可能已经观察到,尽管此策略很简单,但其权衡是 JWT 由 AuthenticationManagerResolver 解析一次,然后在请求中随后由 JwtDecoder 再次解析。

You may have observed that this strategy, while simple, comes with the trade-off that the JWT is parsed once by the AuthenticationManagerResolver and then again by the JwtDecoder later on in the request.

可以通过直接使用 Nimbus 中的 JWTClaimsSetAwareJWSKeySelector 配置 JwtDecoder 来缓解这种额外的解析:

This extra parsing can be alleviated by configuring the JwtDecoder directly with a JWTClaimsSetAwareJWSKeySelector from Nimbus:

  • Java

  • Kotlin

@Component
public class TenantJWSKeySelector
    implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {

	private final TenantRepository tenants; 1
	private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); 2

	public TenantJWSKeySelector(TenantRepository tenants) {
		this.tenants = tenants;
	}

	@Override
	public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext)
			throws KeySourceException {
		return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
				.selectJWSKeys(jwsHeader, securityContext);
	}

	private String toTenant(JWTClaimsSet claimSet) {
		return (String) claimSet.getClaim("iss");
	}

	private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
		return Optional.ofNullable(this.tenants.findById(tenant)) 3
		        .map(t -> t.getAttrbute("jwks_uri"))
				.map(this::fromUri)
				.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
	}

	private JWSKeySelector<SecurityContext> fromUri(String uri) {
		try {
			return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); 4
		} catch (Exception ex) {
			throw new IllegalArgumentException(ex);
		}
	}
}
@Component
class TenantJWSKeySelector(tenants: TenantRepository) : JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
    private val tenants: TenantRepository 1
    private val selectors: MutableMap<String, JWSKeySelector<SecurityContext>> = ConcurrentHashMap() 2

    init {
        this.tenants = tenants
    }

    fun selectKeys(jwsHeader: JWSHeader?, jwtClaimsSet: JWTClaimsSet, securityContext: SecurityContext): List<Key?> {
        return selectors.computeIfAbsent(toTenant(jwtClaimsSet)) { tenant: String -> fromTenant(tenant) }
                .selectJWSKeys(jwsHeader, securityContext)
    }

    private fun toTenant(claimSet: JWTClaimsSet): String {
        return claimSet.getClaim("iss") as String
    }

    private fun fromTenant(tenant: String): JWSKeySelector<SecurityContext> {
        return Optional.ofNullable(this.tenants.findById(tenant)) 3
                .map { t -> t.getAttrbute("jwks_uri") }
                .map { uri: String -> fromUri(uri) }
                .orElseThrow { IllegalArgumentException("unknown tenant") }
    }

    private fun fromUri(uri: String): JWSKeySelector<SecurityContext?> {
        return try {
            JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(URL(uri)) 4
        } catch (ex: Exception) {
            throw IllegalArgumentException(ex)
        }
    }
}
1 A hypothetical source for tenant information
2 A cache for `JWKKeySelector`s, keyed by tenant identifier
3 Looking up the tenant is more secure than simply calculating the JWK Set endpoint on the fly - the lookup acts as a list of allowed tenants
4 Create a JWSKeySelector via the types of keys that come back from the JWK Set endpoint - the lazy lookup here means that you don’t need to configure all tenants at startup

以上键选择器是由多个键选择器组成的。它根据 JWT 中的 iss 声明选择要使用的键选择器。

The above key selector is a composition of many key selectors. It chooses which key selector to use based on the iss claim in the JWT.

要使用此方法,请确保授权服务器配置为将声明集包含在令牌签名中。如果没有这一点,你就无法保证颁发者没有被恶意行为者更改。

To use this approach, make sure that the authorization server is configured to include the claim set as part of the token’s signature. Without this, you have no guarantee that the issuer hasn’t been altered by a bad actor.

接下来,我们可以构建一个 JWTProcessor

Next, we can construct a JWTProcessor:

  • Java

  • Kotlin

@Bean
JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
	ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor();
	jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
	return jwtProcessor;
}
@Bean
fun jwtProcessor(keySelector: JWTClaimsSetAwareJWSKeySelector<SecurityContext>): JWTProcessor<SecurityContext> {
    val jwtProcessor = DefaultJWTProcessor<SecurityContext>()
    jwtProcessor.jwtClaimsSetAwareJWSKeySelector = keySelector
    return jwtProcessor
}

正如您已看到的,将租户感知下沉到这一层级的权衡在于更多配置。我们只需再进行一些配置。

As you are already seeing, the trade-off for moving tenant-awareness down to this level is more configuration. We have just a bit more.

接下来,我们仍希望确保您在验证发件人。但是,由于每个 JWT 的发件人可能不同,因此您还需要一个基于租户的验证器:

Next, we still want to make sure you are validating the issuer. But, since the issuer may be different per JWT, then you’ll need a tenant-aware validator, too:

  • Java

  • Kotlin

@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
    private final TenantRepository tenants;

    private final OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid",
            "https://tools.ietf.org/html/rfc6750#section-3.1");

    public TenantJwtIssuerValidator(TenantRepository tenants) {
        this.tenants = tenants;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt token) {
        if(this.tenants.findById(token.getIssuer()) != null) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(this.error);
    }
}
@Component
class TenantJwtIssuerValidator(private val tenants: TenantRepository) : OAuth2TokenValidator<Jwt> {
    private val error: OAuth2Error = OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid",
            "https://tools.ietf.org/html/rfc6750#section-3.1")

    override fun validate(token: Jwt): OAuth2TokenValidatorResult {
        return if (tenants.findById(token.issuer) != null)
            OAuth2TokenValidatorResult.success() else OAuth2TokenValidatorResult.failure(error)
    }
}

现在我们有了 tenant-aware 处理器和 tenant-aware 验证器,我们可以继续创建我们的 JwtDecoder

Now that we have a tenant-aware processor and a tenant-aware validator, we can proceed with creating our JwtDecoder:

  • Java

  • Kotlin

@Bean
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
	NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
	OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
			(JwtValidators.createDefault(), jwtValidator);
	decoder.setJwtValidator(validator);
	return decoder;
}
@Bean
fun jwtDecoder(jwtProcessor: JWTProcessor<SecurityContext>?, jwtValidator: OAuth2TokenValidator<Jwt>?): JwtDecoder {
    val decoder = NimbusJwtDecoder(jwtProcessor)
    val validator: OAuth2TokenValidator<Jwt> = DelegatingOAuth2TokenValidator(JwtValidators.createDefault(), jwtValidator)
    decoder.setJwtValidator(validator)
    return decoder
}

我们已完成对租户解析的讨论。

We’ve finished talking about resolving the tenant.

如果您选择按 JWT 声明之外的内容解析租户,则需要确保在相同的方式中解决下游资源服务器。例如,如果您按子域解析,则您可能需要使用相同子域解决下游资源服务器。

If you’ve chosen to resolve the tenant by something other than a JWT claim, then you’ll need to make sure you address your downstream resource servers in the same way. For example, if you are resolving it by subdomain, you may need to address the downstream resource server using the same subdomain.

但是,如果您通过持有者令牌中声明的内容解决它,请继续阅读以了解 Spring Security’s support for bearer token propagation

However, if you resolve it by a claim in the bearer token, read on to learn about Spring Security’s support for bearer token propagation.