OAuth 2.0 Resource Server Multi-tenancy
Supporting both JWT and Opaque Token
在某些情况下,您可能需要访问两种令牌。例如,您可能支持不止一个租户,其中一个租户发布 JWT,另一个发布不透明令牌。
如果必须在请求时做出此决定,那么可以通过使用 AuthenticationManagerResolver
来实现,如下所示:
-
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
}
}
}
|
然后在 DSL 中指定此 AuthenticationManagerResolver
:
-
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 有多种策略(以某种租户标识符为键)时,资源服务器被认为是多租户。
例如,您的资源服务器可能接受来自两个不同授权服务器的 bearer token。或者,您的授权服务器可能表示多个颁发者。
在每种情况下,都需要做两件事,并权衡执行方式。
-
Resolve the tenant
-
Propagate the tenant
Resolving the Tenant By Claim
区分租户的一种方法是通过 issuers 声明。由于 issuers 声明附带签名 JWT,因此可以使用 JwtIssuerAuthenticationManagerResolver
完成此操作,如下所示:
-
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
。这允许应用程序启动独立于授权服务器的启动和可用性。
Dynamic Tenants
当然,您可能不希望每次添加新租户时都重新启动应用程序。在这种情况下,您可以使用 AuthenticationManager
实例的存储库配置 JwtIssuerAuthenticationManagerResolver
,您可以在运行时编辑它,如下所示:
-
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
),而且是在运行时。
简单获取任何颁发者并从中构造 |
Parsing the Claim Only Once
您可能已经观察到,尽管此策略很简单,但其权衡是 JWT 由 AuthenticationManagerResolver
解析一次,然后在请求中随后由 JwtDecoder
再次解析。
可以通过直接使用 Nimbus 中的 JWTClaimsSetAwareJWSKeySelector
配置 JwtDecoder
来缓解这种额外的解析:
-
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 | 可能的租户信息源 |
2 | JWKKeySelector 的一个缓存,按租户标识符归 |
3 | 查找租户比实时计算 JWK 集合端点更加安全 - 查找充当已允许租户的列表 |
4 | 通过从 JWK 集终结点返回的密钥类型来创建 JWSKeySelector - 这里的惰性查找意味着无需在启动时配置所有租户 |
以上键选择器是由多个键选择器组成的。它根据 JWT 中的 iss
声明选择要使用的键选择器。
要使用此方法,请确保授权服务器配置为将声明集包含在令牌签名中。如果没有这一点,你就无法保证颁发者没有被恶意行为者更改。 |
接下来,我们可以构建一个 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
}
正如您已看到的,将租户感知下沉到这一层级的权衡在于更多配置。我们只需再进行一些配置。
接下来,我们仍希望确保您在验证发件人。但是,由于每个 JWT 的发件人可能不同,因此您还需要一个基于租户的验证器:
-
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
:
-
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
}
我们已完成对租户解析的讨论。
如果您选择按 JWT 声明之外的内容解析租户,则需要确保在相同的方式中解决下游资源服务器。例如,如果您按子域解析,则您可能需要使用相同子域解决下游资源服务器。
但是,如果您通过持有者令牌中声明的内容解决它,请继续阅读以了解 Spring Security’s support for bearer token propagation。