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
}
}
}
|
The implementation of |
然后在 DSL 中指定此 AuthenticationManagerResolver
:
And then specify this AuthenticationManagerResolver
in the DSL:
-
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:
-
Resolve the tenant
-
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:
-
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.
简单获取任何颁发者并从中构造 |
It would be unsafe to simply take any issuer and construct an |
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.