跳到主要内容
版本:7.0.2

OAuth 2.0 资源服务器多租户

DeepSeek V3 中英对照 Multitenancy OAuth 2.0 Resource Server Multi-tenancy

同时支持JWT和不透明令牌

在某些情况下,您可能需要同时访问两种类型的令牌。例如,您可能支持多个租户,其中一个租户颁发JWT,而另一个颁发不透明令牌。

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

@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;
}
备注

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

然后在DSL中指定这个 AuthenticationManagerResolver

http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2
.authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
);

多租户

当资源服务器根据不同的租户标识符,采用多种策略来验证承载令牌时,该资源服务器被视为多租户。

例如,您的资源服务器可能接受来自两个不同授权服务器的承载令牌。或者,您的授权服务器可能代表多个颁发者。

在每种情况下,都有两件事需要完成,并且如何选择完成方式会涉及权衡取舍。

  1. 解析租户

  2. 传播租户

通过声明解析租户

区分租户的一种方式是通过签发者声明。由于签发者声明伴随着已签名的JWT,这可以通过 JwtIssuerAuthenticationManagerResolver 来实现,如下所示:

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

这很好,因为颁发者端点是被延迟加载的。实际上,只有当发送第一个带有相应颁发者的请求时,才会实例化对应的 JwtAuthenticationProvider。这使得应用程序的启动不依赖于这些授权服务器是否已启动并可用。

动态租户

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

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

在这种情况下,你通过一个策略来构建 JwtIssuerAuthenticationManagerResolver,该策略用于根据颁发者获取 AuthenticationManager。这种方法允许我们在运行时向存储库(在代码片段中显示为一个 Map)中添加和移除元素。

备注

简单地接受任意签发者并从中构建 AuthenticationManager 是不安全的。签发者应该是代码能够从可信来源(例如允许的签发者列表)验证的。

仅解析声明一次

你可能已经注意到,这种策略虽然简单,但存在一个权衡:JWT 首先由 AuthenticationManagerResolver 解析一次,然后在请求的后续阶段再由 JwtDecoder 解析一次。

通过直接使用Nimbus的JWTClaimsSetAwareJWSKeySelector配置JwtDecoder,可以减轻这种额外的解析负担:

@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);
}
}
}
  • 一个用于租户信息的假设性来源

  • 一个以租户标识符为键的 JWSKeySelector 缓存

  • 查找租户比动态计算 JWK 集端点更安全——查找操作相当于一个允许租户的列表

  • 通过从 JWK 集端点返回的密钥类型创建 JWSKeySelector——这里的延迟查找意味着您无需在启动时配置所有租户

上述密钥选择器由多个密钥选择器组合而成。它根据JWT中的iss声明来选择使用哪个密钥选择器。

备注

要使用此方法,请确保授权服务器已配置为将声明集包含在令牌的签名中。否则,无法保证签发者未被恶意行为者篡改。

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

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

正如你所见,将租户感知下移到这一层的代价是更多的配置。我们还有一点需要补充。

接下来,我们仍需确保您对签发者进行验证。但由于每个 JWT 的签发者可能不同,因此您还需要一个支持租户感知的验证器:

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

现在我们已经有了一个租户感知处理器和一个租户感知验证器,接下来可以继续创建我们的 JwtDecoder

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

我们已经讲完了如何解决租户问题。

如果你选择通过JWT声明之外的方式来解析租户,那么你需要确保以相同的方式处理下游资源服务器。例如,如果你通过子域名来解析,你可能需要使用相同的子域名来访问下游资源服务器。

然而,如果您通过承载令牌中的声明来解决此问题,请继续阅读以了解 Spring Security 对承载令牌传播的支持