Spring Security—OAuth 2.0 资源服务器的多租户

一、同时支持JWT和Opaque Token

在某些情况下,你可能需要访问两种令牌。例如,你可能支持一个以上的租户,其中一个租户发出JWT,另一个发出 opaque token。

如果这个决定必须在请求时做出,那么你可以使用 AuthenticationManagerResolver 来实现它,就像这样。

  • Java

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

|---------------------------------------------------|
| useJwt(HttpServletRequest) 的实现很可能取决于自定义的请求,如路径。 |

然后在DSL中指定这个 AuthenticationManagerResolver

Authentication Manager Resolver

  • Java

    http
    .authorizeHttpRequests(authorize -> authorize
    .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
    .authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
    );

二、多租户

当有多种验证 bearer token 的策略时,一个资源服务器被认为是多租户的,其关键是一些租户标识符。

例如,你的资源服务器可能接受来自两个不同授权服务器的 bearer token。或者,你的授权服务器可以代表多个发行者。

在每一种情况下,都有两件事需要做,以及与你选择如何做有关的权衡。

  1. 解析租户。

  2. 传递租户。

1、通过 Claim 解析租户

区分租户的一个方法是通过 issuer claim。由于 issuer claim 伴随着签名的JWTs,这可以通过 JwtIssuerAuthenticationManagerResolver 来完成,像这样。

Multi-tenancy Tenant by JWT Claim

  • Java

    JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
    ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

    http
    .authorizeHttpRequests(authorize -> authorize
    .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
    .authenticationManagerResolver(authenticationManagerResolver)
    );

这很好,因为发行者端点的加载是延迟的。事实上,相应的 JwtAuthenticationProvider 只有在发送第一个请求的时候才会被实例化。这使得应用程序的启动与这些授权服务器的启动和可用性无关。

2.1、动态租户

当然,你可能不想在每次添加新租户时都重启应用程序。在这种情况下,你可以用一个 AuthenticationManager 实例库来配置 JwtIssuerAuthenticationManagerResolver,你可以在运行时编辑它,就像这样。

  • Java

    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)
    );

在这种情况下,你构建 JwtIssuerAuthenticationManagerResolver,其策略是获取给定发行者的 AuthenticationManager。这种方法允许我们在运行时从资源库(在片段中显示为 Map)中添加和删除元素。

|----------------------------------------------------------------------------------|
| 简单地采用任何发行者并从中构建一个 AuthenticationManager 是不安全的。发行者应该是代码可以从可信的来源(如允许的发行者列表)中验证的。 |

2.2 只对 Claim 进行一次解析

你可能已经注意到,这种策略虽然简单,但也有代价,那就是JWT被 AuthenticationManagerResolver 解析了一次,然后又被 JwtDecoder 在后来的请求中再次解析。

这种额外的解析可以通过使用 Nimbus 的 JWTClaimsSetAwareJWSKeySelector 直接配置 JwtDecoder 来缓解。

  • Java

    @Component
    public class TenantJWSKeySelector
    implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {

    复制代码
      private final TenantRepository tenants; 
      private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); 
    
      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)) 
      	        .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)); 
      	} catch (Exception ex) {
      		throw new IllegalArgumentException(ex);
      	}
      }

    }

|------------------------------------------------------------------------|
| 一个假设的租户信息来源 |
| JWKKeySelector 的缓存,以租户标识符(ID)为key。 |
| 查询租户比简单地计算JWK Set端点更安全---​查询作为一个允许租户的列表 |
| 通过从JWK Set端点回来的key类型创建一个 JWSKeySelector --- 这里的延迟查找意味着你不需要在启动时配置所有租户 |

上述密钥选择器是许多密钥选择器的组合。它根据JWT中的 iss claim 来选择使用哪个key选择器。

|--------------------------------------------------------------------------|
| 要使用这种方法,请确保授权服务器被配置为包括 claim 集作为令牌签名的一部分。如果不这样做,你就不能保证 issuer 没有被坏行为者改变。 |

接下来,我们可以构建一个 JWTProcessor

  • Java

    @Bean
    JWTProcessor jwtProcessor(JWTClaimSetJWSKeySelector keySelector) {
    ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
    new DefaultJWTProcessor();
    jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
    return jwtProcessor;
    }

正如你已经看到的,将租户意识下移到这个层次的代价是更多的配置。我们只是多了一点。

接下来,我们仍然要确保你正在验证 issuer。但是,由于每个JWT的 issuer 可能是不同的,那么你也需要一个租户感知的验证器。

  • Java

    @Component
    public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
    private final TenantRepository tenants;
    private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();

    复制代码
      public TenantJwtIssuerValidator(TenantRepository tenants) {
      	this.tenants = tenants;
      }
    
      @Override
      public OAuth2TokenValidatorResult validate(Jwt token) {
      	return this.validators.computeIfAbsent(toTenant(token), this::fromTenant)
      			.validate(token);
      }
    
      private String toTenant(Jwt jwt) {
      	return jwt.getIssuer();
      }
    
      private JwtIssuerValidator fromTenant(String tenant) {
      	return Optional.ofNullable(this.tenants.findById(tenant))
      	        .map(t -> t.getAttribute("issuer"))
      			.map(JwtIssuerValidator::new)
      			.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
      }

    }

现在我们有了一个租户识别处理器和一个租户识别验证器,我们可以继续创建我们的 JwtDecoder

  • Java

    @Bean
    JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
    NimbusJwtDecoder decoder = new NimbusJwtDecoder(processor);
    OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
    (JwtValidators.createDefault(), jwtValidator);
    decoder.setJwtValidator(validator);
    return decoder;
    }

我们已经完成了关于解析租户的讨论。

如果你选择通过JWT请求以外的方式来解析租户,那么你需要确保你以同样的方式来解决你的下游资源服务器。例如,如果你是通过子域进行解析,你可能需要使用相同的子域来解决下游资源服务器。

相关推荐
Q一件事19 小时前
结构方程相关
python·算法·机器学习
wanhengidc19 小时前
云手机有哪些辅助功能?
运维·服务器·网络·游戏·智能手机·生活
iPadiPhone19 小时前
Spring Boot 核心注解全维度解析与面试复盘
java·spring boot·后端·spring·面试
herinspace19 小时前
管家婆iShop如何调整商品成本?
服务器·数据库·学习·电脑·excel
A.A呐19 小时前
【QT第四章】QT窗口
服务器·数据库·qt
SunnyDays101119 小时前
使用 Python 轻松操控 Excel 网格线:隐藏、显示与自定义颜色
开发语言·python·excel
BubbleCodes19 小时前
使用Conda和pip创建Python环境
python·conda·pip
杜子不疼.19 小时前
Spring Cloud 熔断降级详解:用 “保险丝“ 类比,Sentinel 实战教程
人工智能·spring·spring cloud·sentinel
ruiang19 小时前
开源模型应用落地-工具使用篇-Spring AI-高阶用法(九)
人工智能·spring·开源
我不听你讲话19 小时前
Nginx核心功能
linux·服务器·python