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
-
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>
多租戶
當存在多種驗證承載令牌的策略時,資源伺服器被認為是多租戶的,這些策略由某個租戶識別符號進行鍵控。
例如,您的資源伺服器可能接受來自兩個不同授權伺服器的承載令牌。或者,您的授權伺服器可能代表多個頒發者。
在每種情況下,都需要完成兩件事,並且您選擇如何完成它們會帶來權衡
-
解析租戶
-
傳播租戶
透過宣告解析租戶
區分租戶的一種方法是根據頒發者宣告。由於頒發者宣告隨簽名的 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 只有在傳送帶有相應頒發者的第一個請求時才會被例項化。這使得應用程式啟動獨立於這些授權伺服器的啟動和可用性。
動態租戶
當然,您可能不希望每次新增新租戶時都重新啟動應用程式。在這種情況下,您可以使用 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 {
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
authenticationManagerResolver = customAuthenticationManagerResolver
}
}
在這種情況下,您使用一種策略來構造 JwtIssuerAuthenticationManagerResolver,該策略根據頒發者獲取 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 宣告選擇要使用的金鑰選擇器。
| 要使用此方法,請確保授權伺服器配置為將宣告集作為令牌簽名的一部分。否則,您無法保證頒發者未被惡意行為者更改。 |
接下來,我們可以構造一個 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 宣告以外的方式解析租戶,那麼您需要確保以相同的方式處理您的下游資源伺服器。例如,如果您透過子域解析它,您可能需要使用相同的子域來處理下游資源伺服器。
但是,如果您透過承載令牌中的宣告解析它,請繼續閱讀以瞭解 Spring Security 對承載令牌傳播的支援。