OAuth 2.0 资源服务器多租户

同时支持 JWT 和不透明Tokens

在某些情况下,您可能需要同时访问这两种类型的Tokens。 例如,您可能支持多个租户,其中一个租户颁发 JWT,而另一个租户颁发不透明Tokens。spring-doc.cadn.net.cn

如果此决策必须在请求时做出,则可以使用 AuthenticationManagerResolver 来实现,如下所示:spring-doc.cadn.net.cn

@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 中指定这个 AuthenticationManagerResolverspring-doc.cadn.net.cn

认证管理器解析器
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)的策略,并且这些策略通过某个租户标识符进行区分时,该资源服务器被视为多租户的。spring-doc.cadn.net.cn

例如,您的资源服务器可能会接受来自两个不同授权服务器的持有者Tokens。 或者,您的授权服务器可能代表多个颁发者。spring-doc.cadn.net.cn

在每种情况下,都有两件事情需要完成,而你选择如何完成它们会带来相应的权衡:spring-doc.cadn.net.cn

通过声明解析租户

区分租户的一种方法是通过 issuer(签发者)声明。由于 issuer 声明会随已签名的 JWT 一起提供,因此可以使用 JwtIssuerAuthenticationManagerResolver 来实现,如下所示:spring-doc.cadn.net.cn

基于 JWT 声明的多租户
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 只在首次发送对应发行人的请求时才被实例化。 这使得应用程序启动与那些授权服务器是否运行和可用无关。spring-doc.cadn.net.cn

动态租户

当然,您可能不希望每次添加新租户时都重启应用程序。 在这种情况下,您可以使用一个 JwtIssuerAuthenticationManagerResolver 实例的存储库来配置 AuthenticationManager,该存储库可在运行时进行修改,如下所示:spring-doc.cadn.net.cn

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)中添加或移除元素。spring-doc.cadn.net.cn

简单地接受任意颁发者并据此构造一个AuthenticationManager是不安全的。 该颁发者应当是代码能够从可信来源(例如允许的颁发者列表)验证的一个。

仅解析一次声明

您可能已经注意到,这种策略虽然简单,但存在一个权衡:JWT 会被 AuthenticationManagerResolver 解析一次,然后在请求的后续阶段再次被 JwtDecoder 解析。spring-doc.cadn.net.cn

通过直接使用 Nimbus 的 JWTClaimsSetAwareJWSKeySelector 配置 JwtDecoder,可以减轻这种额外的解析工作:spring-doc.cadn.net.cn

@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 声明来决定使用哪个密钥选择器。spring-doc.cadn.net.cn

要使用这种方法,请确保授权服务器已配置为将声明集(claim set)作为Tokens签名的一部分包含在内。 如果没有这一点,你就无法保证该Tokens的签发者(issuer)未被恶意攻击者篡改。

接下来,我们可以构造一个 JWTProcessorspring-doc.cadn.net.cn

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

正如你已经看到的那样,将租户感知能力下移到这一层所带来的权衡是需要更多的配置。 我们只需要再多一点点配置。spring-doc.cadn.net.cn

接下来,我们仍然希望确保你对签发者(issuer)进行了验证。 但由于每个 JWT 的签发者可能不同,因此你也需要一个支持租户感知的验证器:spring-doc.cadn.net.cn

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

现在我们已经拥有了一个租户感知处理器和一个租户感知验证器,我们可以继续创建我们的 JwtDecoderspring-doc.cadn.net.cn

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

我们已经完成了关于解析租户的讨论。spring-doc.cadn.net.cn

如果你选择通过 JWT 声明以外的其他方式解析租户(tenant),那么你需要确保以相同的方式访问下游资源服务器。 例如,如果你是通过子域名来解析租户的,那么你可能需要使用相同的子域名来访问下游资源服务器。spring-doc.cadn.net.cn

然而,如果你通过持有者Tokens(bearer token)中的声明来解析它,请继续阅读以了解Spring Security 对持有者Tokens传播的支持spring-doc.cadn.net.cn