OAuth 2.0 資源伺服器多租戶

同時支援 JWT 和不透明令牌

在某些情況下,您可能需要訪問這兩種型別的令牌。例如,您可能支援多個租戶,其中一個租戶頒發 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
        }
    }
}
`useJwt(HttpServletRequest)` 的實現可能取決於自定義請求內容,例如路徑。

然後在 DSL 中指定此 `AuthenticationManagerResolver`

Authentication Manager Resolver
  • 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>

多租戶

當存在多種策略用於驗證持有者令牌,且這些策略由某個租戶識別符號確定時,該資源伺服器被視為多租戶的。

例如,您的資源伺服器可能接受來自兩個不同授權伺服器的持有者令牌。或者,您的授權伺服器可能代表多個頒發者。

在每種情況下,都需要做兩件事,並且如何選擇做這兩件事伴隨著權衡:

  1. 解析租戶

  2. 傳播租戶

按 Claim 解析租戶

一種區分租戶的方法是透過頒發者 (issuer) claim。由於頒發者 claim 隨簽名的 JWT 一同提供,因此可以使用 `JwtIssuerAuthenticationManagerResolver` 來完成此操作,如下所示:

按 JWT Claim 實現多租戶
  • 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>

這樣做的好處是頒發者端點是延遲載入的。事實上,相應的 `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 {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = customAuthenticationManagerResolver
    }
}

在這種情況下,您使用一種策略來構造 `JwtIssuerAuthenticationManagerResolver`,該策略根據頒發者獲取 `AuthenticationManager`。這種方法允許我們在執行時新增和移除倉庫(程式碼片段中顯示為 `Map`)中的元素。

簡單地獲取任何頒發者並從中構造 `AuthenticationManager` 是不安全的。頒發者應該是程式碼可以從可信源(例如允許的頒發者列表)驗證的頒發者之一。

僅解析 Claim 一次

您可能已經注意到,這種策略雖然簡單,但也存在權衡: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 Set 端點更安全——查詢充當了允許租戶的列表
4 透過從 JWK Set 端點返回的金鑰型別建立 `JWSKeySelector` ——這裡的延遲查詢意味著您不需要在啟動時配置所有租戶

上述金鑰選擇器是許多金鑰選擇器的組合。它根據 JWT 中的 `iss` claim 選擇使用哪個金鑰選擇器。

要使用這種方法,請確保授權伺服器配置為將 claim set 包含在令牌簽名的一部分中。否則,無法保證頒發者未被惡意行為者更改。

接下來,我們可以構造一個 `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
}

正如您已經看到的,將租戶感知下移到這一層的權衡是需要更多的配置。我們還需要一點點。

接下來,我們仍然希望確保您正在驗證頒發者。但是,由於每個 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 claim 以外的方式解析租戶,那麼您需要確保以相同的方式處理下游資源伺服器。例如,如果您透過子域解析租戶,則可能需要使用相同的子域來處理下游資源伺服器。

但是,如果您透過持有者令牌中的 claim 解析租戶,請繼續閱讀以瞭解 Spring Security 對持有者令牌傳播的支援