【问题标题】:How to implement multi-tenancy in new Spring Authorization server如何在新的 Spring 授权服务器中实现多租户
【发布时间】:2022-08-04 11:23:38
【问题描述】:

授权服务器链接:https://github.com/spring-projects/spring-authorization-server

这个项目几乎拥有 OAuth 和身份提供者方面的一切。 我的问题是,如何在Identity provider 级别实现多租户。

我知道一般有多种方法可以实现多租户。

我感兴趣的场景是这样的:

  1. 一个组织为多个租户提供服务。
  2. 每个租户都关联一个单独的数据库(包括用户数据的数据隔离)
  3. 当用户访问dedicated Front-end app(per tenant) 并从Identity provider 协商访问令牌时
  4. Identity provider 然后识别租户(基于标头/域名)并生成access token 和tenant_id
  5. 这个access token然后被传递给下游服务,实习生可以提取tenant_id并决定数据源

    我对上述所有步骤都有一个大致的了解,但我不确定第 4 点。

    我不确定如何在身份提供者上为不同的租户配置不同的数据源?如何在Token中添加tenant_id?

    问题链接:https://github.com/spring-projects/spring-authorization-server/issues/663#issue-1182431313

  • 这是一个非常具有挑战性的问题。我有一些想法,但需要进行大量研究才能找到一个好的解决方案。您是否有一个更狭窄的问题可以让您朝着正确的方向前进?
  • 我明白那个。我有几个想法,我正在尝试这些(目前看起来很乱)。让我们希望 Spring 框架在不久的将来能够提供“有意见的”开箱即用的多租户
  • 好吧,它目前不在路线图上。您可能想打开一个问题。但是,我认为它不会很快成为首要任务。

标签: spring-boot spring-security oauth-2.0 multi-tenant


【解决方案1】:

这与 Spring auth Server 无关,但与我们可以考虑的方法有关 #4

我记得上次我们实施类似的方法时,我们有以下选项

  1. 要让用户拥有唯一的电子邮件地址,从而使用全局数据库对用户进行身份验证并发布身份验证,请设置租户上下文。
  2. 如果用户在超过 1 个租户中操作,则发布身份验证后,我们可以显示用户有权访问的租户列表,这可以设置租户上下文,然后继续使用应用程序。

    更多详情可以阅读here

【讨论】:

  • 这很棒。但是,如果租户也想为用户提供隔离怎么办。因为一个用户只能拥有一个租户。
  • @ArfatBinkileb,在这种情况下,我们通过租户代码或 URL 解析租户标识,并使用解析的租户信息指向具有 TenantId 列的单独数据库或共享数据库。这涉及您的Resource Server 与Authorization Server 之间的通信,以帮助通过名称或 URL 识别租户。
【解决方案2】:

这确实是一个很好的问题,我真的很想知道如何在新的授权服务器中以适当的方式做到这一点。在 Spring Resource Server 中有一个关于多租户的部分。我成功地做到了。

至于新的 Spring Authorization Server 多租户问题。我还为密码和客户端凭据授予类型完成了此操作。

但请注意,虽然它可以工作,但这是多么完美。我不知道,因为我只是为了学习目的而这样做的。这只是一个样本。当我为授权代码授予类型执行此操作时,我也会将其发布在我的 github 上。

我假设主数据库和租户数据库配置已经完成。我不能在这里提供整个代码,因为它有很多代码。我只会提供相关的sn-ps。但这只是示例

@Configuration
@Import({MasterDatabaseConfiguration.class, TenantDatabaseConfiguration.class})
public class DatabaseConfiguration {
    
}

我使用了单独的数据库。我所做的我在 AuthorizationServerConfiguration 中使用了类似以下的内容。

@Import({OAuth2RegisteredClientConfiguration.class})
public class AuthorizationServerConfiguration {
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
        ....
        http.addFilterBefore(new TenantFilter(), OAuth2AuthorizationRequestRedirectFilter.class);
    
        SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();

        addCustomOAuth2ResourceOwnerPasswordAuthenticationProvider(http);
        return securityFilterChain;
    }
}

这是我的 TenantFilter 代码

public class TenantFilter extends OncePerRequestFilter {

    private static final Logger LOGGER = LogManager.getLogger();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
        String requestUrl = request.getRequestURL().toString();
    
        if (!requestUrl.endsWith("/oauth2/jwks")) {
            String tenantDatabaseName = request.getParameter("tenantDatabaseName");
            if(StringUtils.hasText(tenantDatabaseName)) {
                LOGGER.info("tenantDatabaseName request parameter is found");
                TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
            } else {
                LOGGER.info("No tenantDatabaseName request parameter is found");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.getWriter().write("{'error': 'No tenant request parameter supplied'}");
                response.getWriter().flush();
                return;
            }
        }
    
        filterChain.doFilter(request, response);
    
    }

    public static String getFullURL(HttpServletRequest request) {
        StringBuilder requestURL = new StringBuilder(request.getRequestURL().toString());
        String queryString = request.getQueryString();

        if (queryString == null) {
            return requestURL.toString();
        } else {
            return requestURL.append('?').append(queryString).toString();
        }
    }
}

这是 TenantDBContextHolder 类

public class TenantDBContextHolder {

    private static final ThreadLocal<String> TENANT_DB_CONTEXT_HOLDER = new ThreadLocal<>();

    public static void setCurrentDb(String dbType) {
        TENANT_DB_CONTEXT_HOLDER.set(dbType);
    }

    public static String getCurrentDb() {
        return TENANT_DB_CONTEXT_HOLDER.get();
    }

    public static void clear() {
        TENANT_DB_CONTEXT_HOLDER.remove();
    }
}

然后在令牌定制器中。您可以使用以下内容

public class UsernamePasswordAuthenticationTokenJwtCustomizerHandler extends AbstractJwtCustomizerHandler {

    ....
    @Override
    protected void customizeJwt(JwtEncodingContext jwtEncodingContext) {
        ....
        String tenantDatabaseName = TenantDBContextHolder.getCurrentDb();
        if (StringUtils.hasText(tenantDatabaseName)) {
            URL issuerURL = jwtClaimSetBuilder.build().getIssuer();
            String issuer = issuerURL + "/" + tenantDatabaseName;
            jwtClaimSetBuilder.claim(JwtClaimNames.ISS, issuer);
        }
    
        jwtClaimSetBuilder.claims(claims ->
            userAttributes.entrySet().stream()
            .forEach(entry -> claims.put(entry.getKey(), entry.getValue()))
        );
    }
}

现在我假设资源服务器也配置为多租户。这是链接Spring Security Resource Server Multitenancy。基本上你必须为多租户配置两个bean,如下所示

public class OAuth2ResourceServerConfiguration {
    ....
    @Bean
    public JWTProcessor<SecurityContext> jwtProcessor(JWTClaimsSetAwareJWSKeySelector<SecurityContext> keySelector) {
        ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
        jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(keySelector);
        return jwtProcessor;
    }

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

现在春季开两节课。您可以从中获取令牌中的租户标识符。

@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {

    private final TenantDataSourceRepository tenantDataSourceRepository;
    private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();
    ....
    @Override
    public OAuth2TokenValidatorResult validate(Jwt token) {
        String issuerURL = toTenant(token);
        JwtIssuerValidator jwtIssuerValidator = validators.computeIfAbsent(issuerURL, this::fromTenant);
        OAuth2TokenValidatorResult oauth2TokenValidatorResult = jwtIssuerValidator.validate(token);
    
        String tenantDatabaseName = JwtService.getTenantDatabaseName(token);
        TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
    
        return oauth2TokenValidatorResult;
    }

    private String toTenant(Jwt jwt) {
        return jwt.getIssuer().toString();
    }

    private JwtIssuerValidator fromTenant(String tenant) {
        String issuerURL = tenant;
        String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
    
        TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenantDatabaseName);
        if (tenantDataSource == null) {
            throw new IllegalArgumentException("unknown tenant");
        }
    
        JwtIssuerValidator jwtIssuerValidator = new JwtIssuerValidator(issuerURL);
        return jwtIssuerValidator;
    }
}

相似地

@Component
public class TenantJWSKeySelector implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
    ....
    @Override
    public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext) throws KeySourceException {
        String tenant = toTenantDatabaseName(jwtClaimsSet);
    
        JWSKeySelector<SecurityContext> jwtKeySelector = selectors.computeIfAbsent(tenant, this::fromTenant);
        List<? extends Key> jwsKeys = jwtKeySelector.selectJWSKeys(jwsHeader, securityContext);
        return jwsKeys;
    }
}

现在授权代码授权类型授权类型流程怎么样。在这种情况下,我也得到了租户标识符。但是当它将我重定向到登录页面时,我丢失了租户标识符,因为我认为它会根据授权代码请求为登录页面创建一个新请求。无论如何,我不确定,因为我必须查看授权代码流的代码它实际上在做什么。因此,当我将我重定向到登录页面时,我的租户标识符正在丢失。

但是在密码授予类型和客户端凭据授予类型的情况下,没有重定向,因此我在稍后阶段获得租户标识符,并且可以成功使用它来放入我的令牌声明中。

然后在资源服务器上我得到颁发者 url。从颁发者 url 获取租户标识符。验证它。它连接到资源服务器上的租户数据库。

我是如何测试的。我用的是spring客户端。您可以自定义授权码流的请求。包含自定义参数的密码和客户端凭据。

谢谢。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2016-05-10
    • 2014-09-08
    • 2020-04-26
    • 2020-11-21
    • 1970-01-01
    • 2020-07-23
    • 1970-01-01
    • 2018-12-07
    相关资源
    最近更新 更多