简体   繁体   中英

How to implement multi-tenancy in new Spring Authorization server

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

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

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM