OAuth 2.0 资源服务器多租户
同时支持 JWT 和不透明Tokens
在某些情况下,您可能需要同时访问这两种类型的Tokens。 例如,您可能支持多个租户,其中一个租户颁发 JWT,而另一个租户颁发不透明Tokens。
如果此决策必须在请求时做出,则可以使用 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
}
}
}
useJwt(HttpServletRequest) 的实现很可能依赖于自定义的请求内容,例如路径。 |
然后在 DSL 中指定这个 AuthenticationManagerResolver:
-
Java
-
Kotlin
-
Xml
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2
.authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
);
http {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
authenticationManagerResolver = tokenAuthenticationManagerResolver()
}
}
<http>
<oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
</http>
Multi-tenancy
当存在多种用于验证持有者Tokens(bearer token)的策略,并且这些策略通过某个租户标识符进行区分时,该资源服务器被视为多租户的。
例如,您的资源服务器可能会接受来自两个不同授权服务器的持有者Tokens。 或者,您的授权服务器可能代表多个颁发者。
在每种情况下,都有两件事情需要完成,而你选择如何完成它们会带来相应的权衡:
-
解析租户
-
传播租户
通过声明解析租户
区分租户的一种方法是通过 issuer(签发者)声明。由于 issuer 声明会随已签名的 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 {
authorizeHttpRequests {
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>
这是很好的,因为发行人端点是懒加载的。
实际上,对应的 JwtAuthenticationProvider 只在首次发送对应发行人的请求时才被实例化。
这使得应用程序启动与那些授权服务器是否运行和可用无关。
动态租户
当然,您可能不希望每次添加新租户时都重启应用程序。
在这种情况下,您可以使用一个 JwtIssuerAuthenticationManagerResolver 实例的存储库来配置 AuthenticationManager,该存储库可在运行时进行修改,如下所示:
-
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 {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
authenticationManagerResolver = customAuthenticationManagerResolver
}
}
在这种情况下,您使用一种策略来构造 JwtIssuerAuthenticationManagerResolver,该策略用于根据颁发者(issuer)获取 AuthenticationManager。
这种方法允许我们在运行时向存储库(在代码片段中显示为 Map)中添加或移除元素。
简单地接受任意颁发者并据此构造一个AuthenticationManager是不安全的。
该颁发者应当是代码能够从可信来源(例如允许的颁发者列表)验证的一个。 |
仅解析一次声明
您可能已经注意到,这种策略虽然简单,但存在一个权衡: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 | 一个用于JWSKeySelector的缓存,以租户标识符作为键 |
| 3 | 查找租户比简单地动态计算 JWK Set 端点更加安全——该查找操作相当于一个允许的租户列表。 |
| 4 | 通过 JWK Set 端点返回的密钥类型创建一个 JWSKeySelector —— 此处的延迟查找意味着你无需在启动时配置所有租户 |
上述密钥选择器是由多个密钥选择器组合而成的。
它根据 JWT 中的 iss 声明来决定使用哪个密钥选择器。
| 要使用这种方法,请确保授权服务器已配置为将声明集(claim set)作为Tokens签名的一部分包含在内。 如果没有这一点,你就无法保证该Tokens的签发者(issuer)未被恶意攻击者篡改。 |
接下来,我们可以构造一个 JWTProcessor:
-
Java
-
Kotlin
@Bean
JWTProcessor jwtProcessor(JWTClaimsSetAwareJWSKeySelector 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
}
正如你已经看到的那样,将租户感知能力下移到这一层所带来的权衡是需要更多的配置。 我们只需要再多一点点配置。
接下来,我们仍然希望确保你对签发者(issuer)进行了验证。 但由于每个 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)
}
}
现在我们已经拥有了一个租户感知处理器和一个租户感知验证器,我们可以继续创建我们的 JwtDecoder:
-
Java
-
Kotlin
@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;
}
@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 声明以外的其他方式解析租户(tenant),那么你需要确保以相同的方式访问下游资源服务器。 例如,如果你是通过子域名来解析租户的,那么你可能需要使用相同的子域名来访问下游资源服务器。
然而,如果你通过持有者Tokens(bearer token)中的声明来解析它,请继续阅读以了解Spring Security 对持有者Tokens传播的支持。