OAuth 2.0 リソースサーバーマルチテナンシー
JWT と Opaque トークンの両方をサポート
場合によっては、両方の種類のトークンにアクセスする必要があります。例: 1 つのテナントが JWT を発行し、他のテナントが Opaque トークンを発行する複数のテナントをサポートできます。
リクエスト時にこの決定を行う必要がある場合は、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 {
authorizeRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
authenticationManagerResolver = tokenAuthenticationManagerResolver()
}
}
<http>
<oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
</http>
マルチテナンシー
リソースサーバーは、何らかのテナント ID をキーとするベアラートークンを検証するための複数の戦略がある場合、マルチテナントと見なされます。
例: リソースサーバーは、2 つの異なる認可サーバーからベアラートークンを受け入れる場合があります。または、認可サーバーが複数の発行者を表している場合があります。
いずれの場合も、実行する必要がある 2 つの事柄と、それらの選択方法に関連するトレードオフがあります。
テナントを解決する
テナントを伝播する
クレームによるテナントの解決
テナントを区別する 1 つの方法は、発行者の主張によるものです。発行者の主張には署名された 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>
発行者エンドポイントが遅延ロードされるため、これは素晴らしいことです。実際、対応する 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 {
authorizeRequests {
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
authenticationManagerResolver = customAuthenticationManagerResolver
}
}
この場合、発行者から AuthenticationManager
を取得する方法で JwtIssuerAuthenticationManagerResolver
を作成します。このアプローチにより、実行時にリポジトリ(スニペットで Map
として表示)に要素を追加および削除できます。
発行者を単純に取り、それから AuthenticationManager を構築するのは危険です。発行者は、許可された発行者のリストのように、コードが信頼できるソースから検証できるものである必要があります。 |
クレームの解析は 1 回のみ
この戦略は単純ですが、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 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 のベアラートークン伝播のサポートについて学習してください。