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

发布于 2025-01-15 16:40:00 字数 900 浏览 4 评论 0原文

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

这个项目几乎包含了 OAuth 和身份提供者方面的所有内容。 我的问题是,如何在身份提供商级别实现多租户。

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

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

  1. 一个组织为多个租户提供服务。
  2. 每个租户都与一个单独的数据库关联(包括用户数据的数据隔离)
  3. 当用户访问专用前端应用程序(每个租户)并协商来自身份提供商的访问令牌
  4. < code>身份提供商然后识别租户(基于标头/域名)并使用tenant_id生成访问令牌
  5. 然后此访问令牌被传递到下游服务,其中可以提取tenant_id并决定数据源

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

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

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

Link for Authorization server: https://github.com/spring-projects/spring-authorization-server

This project pretty much has everything in terms of OAuth and Identity provider.
My question is, How to achieve multi-tenancy at the Identity provider level.

I know there are multiple ways to achieve multi-tenancy in general.

The scenario I am interested in is this:

  1. An organization provides services to multiple tenants.
  2. Each tenant is associated with a separate database (Data isolation including user data)
  3. When a user visits dedicated Front-end app(per tenant) and negotiate access tokens from Identity provider
  4. Identity provider then identifies tenant (Based on header/ Domain name) and generates access token with tenant_id
  5. This access token then is passed on to down-stream services, which intern can extract tenant_id and decide the data source

I have a general idea about all the above steps, but I am not sure about point 4.

I am not sure How to configure different data sources for different tenants on the Identity Provider? How to add tenant_id in Token?

Link to the issue: https://github.com/spring-projects/spring-authorization-server/issues/663#issue-1182431313

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(3

祁梦 2025-01-22 16:40:00

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

至于新的 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 资源服务器多租户< /a>.基本上,您必须为多租户配置两个 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 配置两个类。您可以从中获取令牌中的租户标识符。

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

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

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

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

我是如何测试的。我用的是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);
    }
}

,在配置中,我在此过滤器上设置 TenantWebAuthenticationDetailsS​​ource,这里是

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 上。一旦我再次运行它,我将在这里对流程进行更多解释。特别是最后一部分,提交同意后如何在线程局部变量中设置。

之后一切就变得简单了。

希望它会有所帮助。

谢谢

This is really a good question and I really want to know how to do it in new Authorization Server in a proper way. In Spring Resource Server there is a section about Multitenancy. I did it successfully.

As far as new Spring Authorization Server multitenancy concerns. I have also done it for the password and the Client Credentials grant type.

But please note that although it is working but how perfect is this. I don't know because I just did it for learning purpose. It's just a sample. I will also post it on my github when I would do it for the authorization code grant type.

I am assuming that the master and tenant database configuration has been done. I can not provide the whole code here because it's lot of code. I will just provide the relevant snippets. But here is just the sample

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

I used the separate database. What I did I used something like the following in the 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;
    }
}

Here is my TenantFilter code

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

Here is the TenantDBContextHolder class

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

Now as there is already configuration for master and tenant database. In these configurations we also check for the TenantDBContextHolder
class that it contains the value or not. Because when request comes for token then we check the request and set it in TenantDBContextHolder. So base on this thread local variable right database is connected and the token issue to the right database. Then in the token customizer. You can use something like the following

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

Now I am assuming that the Resource Server is also configure for multitenancy. Here is the link Spring Security Resource Server Multitenancy. Basically You have to configure two beans for multitenancy like the following

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

Now two classes for spring. From which you can get the tenant Identifier from your 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;
    }
}

Similarly

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

Now what about authorization code grant type grant type flow. I get the tenant identifier in this case too. But when it redirects me to login page then I lost the tenant identifier because I think it creates a new request for the login page from the authorization code request. Anyways I am not sure about it because I have to look into the code of authorization code flow that what it is actually doing. So my tenant identifier is losing when it redirects me to login page.

But in case of password grant type and client credentials grant type there is no redirection so I get the tenant identifier in later stages and I can successfully use it to put into my token claims.

Then on the resource server I get the issuer url. Get the tenant identifier from the issuer url. Verify it. And it connects to the tenant database on resource server.

How I tested it. I used the spring client. You can customize the request for authorization code flow. Password and client credentials to include the custom parameters.

Thanks.

------------------ Solve the Authorization Code login problem for multitenancy -------------

I solved this issue too. Actually what I did in my security configuration. I used the following configuration

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

Actually the problem was in case of Authorization Code is that you first redirect to login page. After successfully login you see the consent page. But when you comes to consent page then you lost the tenant parameter.

The reason is the spring internal class OAuth2AuthorizationEndpointFilter intercepts the request for Authorization Code. It checks user is authenticated or not. If user is not authenticated then it shows the login page. After successfully login it checks if consent is required. And if required then it makes a redirect uri with just three parameters. Here is the spring internal code

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

See the above method is private and I found no way that I can customize it. May be there is but I didn't find it. Anyways now your consent controller is call. But there is no tenant Identifier. You can't get it. And after consent there is no way that it connects to tenant database base in identifier.

So the first step is to add tenant identifier to login page. And then after login you should have this tenant identifier so you can set it on your consent page. And after that when you submit your consent form then this parameter will be there.

Btw I did it some time ago and may be I miss something but this is what I did.

Now how you get your parameter at login page. I solved it using the following. First I created a constant as I have to access the name from multiple times

public interface Constant {
    String TENANT_DATABASE_NAME = "tenantDatabaseName";
}

Create the following class

public class RedirectModel {

    @NotBlank
    private String tenantDatabaseName;

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

    public String getTenantDatabaseName() {
        return tenantDatabaseName;
    }
}

Then on my Login controller I get it using the following code

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

So this is the first step that I have my tenant identifier in my login page that is send to me by request.

Now the configuration that I used in my Security configuration. You can see that I am using TenantUsernamePasswordAuthenticationFilter. Here is the filer

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

And in the configuration I am setting TenantWebAuthenticationDetailsSource on this filter which is here

public class TenantWebAuthenticationDetailsSource extends WebAuthenticationDetailsSource {

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

Here is the class

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

Now after spring authenticates the user then I have the tenant name in details. Then in the consent controller I use

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

Now I have my tenant identifier on my consent page. After submitting it it's in the request parameter.

There is another class that I used and it was

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

Anyways this is how I solved it. I don't have any such requirement in any of my project but I want to do it using this new server so I just solved it in this way.

Anyways there is lot of code. I tested it using the Spring oauth2 client and it was working. Hopefully I will create some project and upload it on my Github. Once I will run it again then I will put more explanation here of the flow. Specially for the last part that after submitting the consent how it set in the Thread Local variable.

After that everything is straight forward.

Hopefully it will help.

Thanks

讽刺将军 2025-01-22 16:40:00

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

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

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

更多详细信息可以从此处阅读

This is not related to Spring auth Server, but related to approaches that we can think for point # 4

I remember the last time we implemented a similar approach, where we had below options

  1. To have unique email addresses for the users thereby using the global database to authenticate the users and post authentication, set up the tenant context.
  2. In case of users operating in more than 1 tenant, post authentication, we can show the list of tenant's that the user has access to, which enables setting the tenant context and then proceeding with the application usage.

More details can be read from here

夏末染殇 2025-01-22 16:40:00

很高兴分享,这最终通过 https://github 实现.com/spring-projects/spring-authorization-server/issues/663

尚未测试。不要忘记贡献

So happy to share , this has been finally implemented via https://github.com/spring-projects/spring-authorization-server/issues/663

Yet to test it out. Don’t forget to contribute

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文