如何在新的Spring授权服务器中实现多租户

11

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

这个项目在OAuth和身份提供者方面几乎应有尽有。

我的问题是,如何实现身份提供者级别的多租户。

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

我感兴趣的场景是:

  1. 一个组织向多个租户提供服务。
  2. 每个租户都与单独的数据库相关联(包括用户数据的数据隔离)
  3. 当用户访问专用前端应用程序(每个租户)并从身份提供者协商访问令牌时
  4. 身份提供者然后识别租户(基于标头/域名)并生成带有tenant_id访问令牌
  5. 访问令牌然后传递给下游服务,它可以提取tenant_id并决定数据源

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

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

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

该问题为与Spring授权服务器相关的问题,具体内容可点击上述链接查看。

1
这是一个相当具有挑战性的问题。我有一些想法,但需要进行大量的研究才能得出好的解决方案。你是否有更具体的问题作为起点,这样可以帮助你朝着正确的方向前进? - Steve Riesenberg
我明白了。我脑海中有几个想法,正在尝试中(目前看起来有些混乱)。希望未来Spring框架能够提供“有见解的”多租户开箱即用的功能。 - Arfat Binkileb
目前它不在路线图上。您可能需要开启一个问题。然而我并不认为它是即将成为首要任务的事情。 - Steve Riesenberg
2个回答

1
这是一个非常好的问题,我真的想知道如何在新的授权服务器中以正确的方式实现它。在Spring资源服务器中,有一个关于多租户的部分。我已经成功地完成了它。
就新的Spring授权服务器多租户而言,我也为密码和客户端凭证授予类型完成了它。
但请注意,虽然它正在工作,但它有多完美我不知道,因为我只是为了学习而这样做。这只是一个示例。当我为授权码授予类型完成它时,我也会将其发布在我的github上。
我假设主数据库和租户数据库配置已完成。我无法在此处提供整个代码,因为它是大量的代码。我只会提供相关片段。但这里只是样本。
@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();
    }
}

现在,由于主数据库和租户数据库已经有了配置。在这些配置中,我们还检查TenantDBContextHolder类是否包含值。因为当请求令牌时,我们会检查请求并将其设置在TenantDBContextHolder中。因此,基于这个线程本地变量,正确的数据库被连接并向正确的数据库发放令牌。然后,在令牌自定义器中,您可以使用以下内容:

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

现在有两个Spring类,你可以从token中获取租户标识符。

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

    private String toTenantDatabaseName(JWTClaimsSet claimSet) {
    
        String issuerURL = (String) claimSet.getClaim("iss");
        String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
    
        return tenantDatabaseName;
    }

    private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
        TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenant);
        if (tenantDataSource == null) {
            throw new IllegalArgumentException("unknown tenant");
        } 
    
        JWSKeySelector<SecurityContext> jwtKeySelector = fromUri(jwkSetUri);
        return jwtKeySelector;
    }

    private JWSKeySelector<SecurityContext> fromUri(String uri) {
        try {
            return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); 
        } catch (Exception ex) {
            throw new IllegalArgumentException(ex);
        }
    }
}

现在让我们来谈谈授权码授权类型流程。在这种情况下,我也获得了租户标识符。但是当它将我重定向到登录页面时,我失去了租户标识符,因为我认为它会从授权码请求创建一个新的登录页面请求。无论如何,我不确定,因为我必须查看授权代码流程的代码以确定实际操作。所以当它将我重定向到登录页面时,我的租户标识符就丢失了。
但是,在密码授权类型和客户端凭据授权类型的情况下,没有重定向,因此我可以在后续阶段中获取租户标识符,并成功将其放入我的令牌声明中。
然后,在资源服务器上,我获取发行者网址。从发行者网址获取租户标识符。验证它。并连接到资源服务器上的租户数据库。
我如何测试它。我使用了Spring客户端。您可以自定义授权码流程的请求。密码和客户端凭据以包括自定义参数。
谢谢。
------------------ 解决多租户授权码登录问题 ------------------
我也解决了这个问题。实际上,我在我的安全配置中所做的是使用以下配置。
public class SecurityConfiguration {

    .....

    @Bean(name = "authenticationManager")
    public AuthenticationManager authenticationManager(AuthenticationManagerBuilder builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getObject();
        return authenticationManager;
    }        

    @Bean
    @DependsOn(value = {"authenticationManager"})
    public TenantUsernamePasswordAuthenticationFilter tenantAuthenticationFilter(AuthenticationManagerBuilder builder) throws Exception {
        TenantUsernamePasswordAuthenticationFilter filter = new TenantUsernamePasswordAuthenticationFilter(); 
    
filter.setAuthenticationManager(authenticationManager(builder));
        filter.setAuthenticationDetailsSource(new TenantWebAuthenticationDetailsSource());
        //filter.setAuthenticationFailureHandler(failureHandler());
        return filter;
    }

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        FederatedIdentityConfigurer federatedIdentityConfigurer = new FederatedIdentityConfigurer().oauth2UserHandler(new UserRepositoryOAuth2UserHandler());
    
        AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); 
http.addFilterBefore(tenantAuthenticationFilter(authenticationManagerBuilder), UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests(authorizeRequests -> authorizeRequests.requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
            .antMatchers("/resources/**", "/static/**", "/webjars/**").permitAll()
            .antMatchers("/login").permitAll()
            .anyRequest().authenticated()
        )
        ......
        .apply(federatedIdentityConfigurer);

        return http.build();
    }

实际上,授权码的问题在于你首先被重定向到登录页面。成功登录后,您会看到同意页面。但是当您来到同意页面时,您会失去租户参数。
原因是Spring内部类OAuth2AuthorizationEndpointFilter拦截了Authorization Code的请求。它检查用户是否已经通过身份验证。如果用户尚未通过身份验证,则显示登录页面。成功登录后,它会检查是否需要同意。如果需要,则使用仅三个参数的重定向URI。这是Spring的内部代码。
private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response,
        OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication,
        OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthentication) throws IOException {

    ....
    if (hasConsentUri()) {
        String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request))
                .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
                .queryParam(OAuth2ParameterNames.CLIENT_ID, clientId)
                .queryParam(OAuth2ParameterNames.STATE, state)
                .toUriString();
        this.redirectStrategy.sendRedirect(request, response, redirectUri);
    } else {
        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Displaying generated consent screen");
        }
        DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state);
    }
}

请看上面的方法是私有的,我找不到任何可以自定义它的方法。也许有,但我没有找到。无论如何,现在您的同意控制器已被调用。但没有租户标识符。你拿不到它。并且在同意之后,没有办法连接到基于标识符的租户数据库。
所以第一步是在登录页面添加租户标识符。然后在登录后,您应该有这个租户标识符,以便可以在同意页面上设置它。之后当您提交同意表单时,这个参数将会存在。
顺便说一下,我之前做过这件事,可能我错过了什么,但这就是我做的。
现在怎样在登录页面获取您的参数。我使用以下方法解决。首先,我创建了一个常量,因为我需要多次访问该名称。
public interface Constant {
    String TENANT_DATABASE_NAME = "tenantDatabaseName";
}

创建以下类:
public class RedirectModel {

    @NotBlank
    private String tenantDatabaseName;

    public void setTenantDatabaseName(String tenantDatabaseName) {
        this.tenantDatabaseName = tenantDatabaseName;
    }

    public String getTenantDatabaseName() {
        return tenantDatabaseName;
    }
}

然后在我的登录控制器中,我使用以下代码获取它:
@Controller
public class LoginController {
    @GetMapping("/login")
    public String login(@Valid @ModelAttribute RedirectModel redirectModel,  Model model, BindingResult result) {
        if (!result.hasErrors()) {
            String tenantDatabaseName = redirectModel.getTenantDatabaseName();
            String currentDb = TenantDBContextHolder.getCurrentDb();
            LOGGER.info("Current database is {}", currentDb);
            LOGGER.info("Putting {} as tenant database name in model. So it can be set as a hidden form element ", tenantDatabaseName);
            model.addAttribute(Constant.TENANT_DATABASE_NAME, tenantDatabaseName);
        }
        return "login";
    }
}

这是我在登录页面通过请求获得租户标识符的第一步。

现在,我在我的安全配置中使用了TenantUsernamePasswordAuthenticationFilter。以下是过滤器:

public class TenantUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

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

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {

        String tenantDatabaseName = obtainTenantDatabaseName(request);
        LOGGER.info("tenantDatabaseName is {}", tenantDatabaseName);
        LOGGER.info("Setting {} as tenant database name in thread local context.", tenantDatabaseName);
        TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
        return super.attemptAuthentication(request, response);
    }

    private String obtainTenantDatabaseName(HttpServletRequest request) {
        return request.getParameter(Constant.TENANT_DATABASE_NAME);
    }
}

在配置中,我正在将TenantWebAuthenticationDetailsSource设置在此过滤器上,如下所示:

public class TenantWebAuthenticationDetailsSource extends WebAuthenticationDetailsSource {

    @Override
    public TenantWebAuthenicationDetails buildDetails(HttpServletRequest context) {
        return new TenantWebAuthenicationDetails(context);
    }
}

这里是类

public class TenantWebAuthenicationDetails extends WebAuthenticationDetails {

    private static final long serialVersionUID = 1L;

    private String tenantDatabaseName; 

    public TenantWebAuthenicationDetails(HttpServletRequest request) {
        super(request);
        this.tenantDatabaseName = request.getParameter(Constant.TENANT_DATABASE_NAME);
}

    public TenantWebAuthenicationDetails(String remoteAddress, String sessionId, String tenantDatabaseName) {
        super(remoteAddress, sessionId);
        this.tenantDatabaseName = tenantDatabaseName;
    }

    public String getTenantDatabaseName() {
        return tenantDatabaseName;
    }
}

现在,在Spring验证用户后,我可以在详细信息中找到租户名称。然后在同意控制器中使用:
@Controller
public class AuthorizationConsentController {
    ....
    @GetMapping(value = "/oauth2/consent")
    public String consent(Authentication authentication, Principal principal, Model model,
        @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
        @RequestParam(OAuth2ParameterNames.SCOPE) String scope,
        @RequestParam(OAuth2ParameterNames.STATE) String state) {
        ......
        String registeredClientName = registeredClient.getClientName();
    Object webAuthenticationDetails = authentication.getDetails();
        if (webAuthenticationDetails instanceof TenantWebAuthenicationDetails) {
            TenantWebAuthenicationDetails tenantAuthenticationDetails = (TenantWebAuthenicationDetails)webAuthenticationDetails;
            String tenantDatabaseName = tenantAuthenticationDetails.getTenantDatabaseName();
            model.addAttribute(Constant.TENANT_DATABASE_NAME, tenantDatabaseName);
        }
    
        model.addAttribute("clientId", clientId);
        .....
        return "consent-customized";
    }
}

现在我的同意页面上有我的租户标识符。提交后,它将出现在请求参数中。

还有另一个我使用的类:

public class TenantLoginUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {

    public TenantLoginUrlAuthenticationEntryPoint(String loginFormUrl) {
        super(loginFormUrl);
    }

    @Override
    protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException exception) {

        String tenantDatabaseNameParamValue = request.getParameter(Constant.TENANT_DATABASE_NAME);
        String redirect = super.determineUrlToUseForThisRequest(request, response, exception);
        String url = UriComponentsBuilder.fromPath(redirect).queryParam(Constant.TENANT_DATABASE_NAME, tenantDatabaseNameParamValue).toUriString();
        return url;
    }
}

无论如何,这是我的解决办法。虽然在我所有的项目中都没有这样的要求,但我想使用这个新服务器来完成它,所以我就用这种方式解决了它。
总之,代码很多。我使用Spring oauth2客户端对其进行了测试,它可以正常工作。希望我能创建一些项目并将其上传到我的Github上。再次运行它后,我会在这里更详细地解释流程,特别是在提交同意后如何设置Thread Local变量。
之后,一切都很直接了当。
希望这能有所帮助。
谢谢。

1

这与Spring auth Server无关,但与我们可以考虑的第4点相关

我记得上一次我们实现了类似的方法,我们有以下选项:

  1. 为用户设置唯一的电子邮件地址,从而使用全局数据库来验证用户,并在身份验证后设置租户上下文。
  2. 对于在多个租户中操作的用户,在身份验证后,我们可以显示用户可以访问的租户列表,这使得设置租户上下文然后继续使用应用程序成为可能。

更多详细信息请阅读此处


这很棒。但是如果租户也希望为用户提供隔离呢?例如,一个用户只能有一个租户。 - Arfat Binkileb
在这种情况下,我们通过租户代码或URL解析租户标识,并使用解析后的租户信息指向具有“TenantId”列的单独数据库或共享数据库。这涉及到您的“资源服务器”与“授权服务器”之间的通信,以帮助从名称或URL中识别租户。@ArfatBinkileb - Saravanan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接