DEV Community

Cover image for Building a multitenant web application with Spring Boot
Lars Willemsens
Lars Willemsens

Posted on • Updated on

Building a multitenant web application with Spring Boot

Let's create a web application to serve multiple different clients or to host a range of sub-sites and platforms.

Spring Boot will be our companion on this trip with JPA/Hibernate taking care of storage and persistence.

Tenants must be disconnected from each other, and users should be able to register to and contribute to the different tenants' websites transparently. In short, users should be oblivious to the fact that the tenants' web applications are part of the one single Spring application.

What this article will be focusing on:

  • The web application experience. We want to ensure a maintainable application across different tenants' URLs, and security must be kept in check. Each tenant should have their own (sub)domain.
  • We're using a single database and shared schema. It allows us to reason about tenants on a design level and simplifies data source access. There are many articles and online sources that tackle the challenge of handling multiple data sources.

Our POC will use subdomains of localhost such as tenant1.localhost and tenant2.localhost to mimic the actual experience.
When I register an account at tenant1.localhost, I won't (yet) have an account at tenant2.localhost. In fact, as a user, I should have no clue that both tenants are even served by the same Spring application. I should be able to create an account at tenant2 using the same email address as for tenant1.

The application itself will have "posts" on it. For example, you can think of them as blog posts or news posts.

  • Unauthenticated users should be able to see all posts.
  • Authenticated users should be able to contribute by adding new posts.
  • Tenant administrators should be able to both add and delete posts.

There will be a global administrator role as well. There are no plans to implement anything meaningful for the global administrator in this POC. It could be expanded by adding features to create new tenants or upgrading users to tenant administrators.

Technically, we'll use Spring Web with MVC/Thymeleaf and a REST API. I've set up the project using Gradle, Spring Boot 3 (Spring 6), and PostgreSQL.

The end result of this journey can be found here.

Spring Security

Spring Security will provide a foundation for authentication and authorization. Let's tag a @Configuration class with @EnableWebSecurity and provide a bean of type SecurityFilterChain:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Enter fullscreen mode Exit fullscreen mode

Static assets should fall outside of our security requirements, so we'll add matcher rules such as :

antMatcher(HttpMethod.GET, "/js/**")).permitAll()
Enter fullscreen mode Exit fullscreen mode

I'm assuming a basic knowledge of Spring Security. So far, nothing special yet.

Tenant?... what tenant?

All HTTP traffic to tenant1.localhost, tenant2.localhost, and localhost will arrive at our Spring application (assuming port 8080). We need a way to distinguish between them and OncePerRequestFilter will be our tool of choice. I'll create one and call it TenantFilter. This request filter will grab the tenant from the URL and store it so that we can check it later when further processing the request (i.e., in a controller).
Each web request will be handled by a separate thread, so an excellent place to store this little piece of data is Java's ThreadLocal. (In the future, this should even be compatible with Project Loom's virtual threads.)

One of only a few places in a Spring application where static can be used meaningfully:

public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
    private static final ThreadLocal<Long> currentTenantId = new ThreadLocal<>();

    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    public static void setCurrentTenant(String tenant) {
        currentTenant.set(tenant);
    }

    // More getters/setters
}
Enter fullscreen mode Exit fullscreen mode

The filter will use the context class by calling its setters:

public class TenantFilter extends OncePerRequestFilter {
    private final TenantRepository tenantRepository;

    public TenantFilter(TenantRepository tenantRepository) {
        this.tenantRepository = tenantRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        var tenant = getTenant(request);
        var tenantId = tenantRepository.findBySlug(tenant).map(Tenant::getId).orElse(null);
        if (tenant != null && tenantId == null) {
            response.setStatus(NOT_FOUND.value()); // Attempted access to non-existing tenant
            return;
        }
        TenantContext.setCurrentTenant(tenant);
        TenantContext.setCurrentTenantId(tenantId);
        chain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getRequestURI().startsWith("/webjars/")
                || request.getRequestURI().startsWith("/css/")
                || request.getRequestURI().startsWith("/js/")
                || request.getRequestURI().endsWith(".ico");
    }

    private String getTenant(HttpServletRequest request) {
        var domain = request.getServerName();
        var dotIndex = domain.indexOf(".");

        String tenant = null;
        if (dotIndex != -1) {
            tenant = domain.substring(0, dotIndex);
        }

        return tenant;
    }
}
Enter fullscreen mode Exit fullscreen mode

We must override two methods:

  • doFilterInternal: This method's first -essential- responsibility is to determine if we want to continue processing this request. If the answer is yes, then we call chain.doFilter. Not calling the chain means the end of the road for this request. The other task is to get the tenant's code (slug) from the request URL and to store it for later use. A repository is used to retrieve the ID of the tenant (handy for later!).
  • shouldNotFilter: tells Spring when this filter is relevant.

We only want to support tenants that exist in our database as well as null (=no tenant). That's why we've added a 404 check.

UserDetailsService

The first place where we will read this ThreadLocal information is from a custom UserDetailsService. The method to override here is loadUserByUsername:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        var tenant = TenantContext.getCurrentTenant();

        if (tenant != null) {
            return loadUser(email, tenant);
        } else {
            return loadGeneralAdmin(email);
        }
    }

    private UserDetails loadUser(String email, String tenant) {
        var user =
                userRepository.findUser(email, tenant)
                        .orElseThrow(
                                () -> new UsernameNotFoundException(
                                        "'" + email + "' / '" + tenant +
                                                "' was not found."));

        var auths = new ArrayList<GrantedAuthority>();
        auths.add(new SimpleGrantedAuthority(user.getRole().getRoleName()));
        return new CustomUserDetails(user.getEmail(), user.getPassword(), user.getId(),
                user.getTenant().getId(), auths);
    }

    private UserDetails loadGeneralAdmin(String email) {
        var admin = userRepository.findGeneralAdmin(email).orElseThrow(
                () -> new UsernameNotFoundException(
                        "'" + email + "' was not found as a general admin."));
        var auths = new ArrayList<GrantedAuthority>();
        auths.add(new SimpleGrantedAuthority(ADMINISTRATOR.getRoleName()));
        return new CustomUserDetails(admin.getEmail(), admin.getPassword(), admin.getId(), null,
                auths);
    }
}
Enter fullscreen mode Exit fullscreen mode

If the tenant is null, we know the user is accessing localhost without a subdomain. This part of our application will be used to host global administration pages.
If the tenant is not null, then we must select a user filtering on both email and tenant.
FYI, userRepository.findGeneralAdmin filters by email and tenant equal to null.
I've created a CustomUserDetails class to extend Spring's User. It contains the user ID and tenant ID as well. This will come in handy later.

Enabling the filter

We can activate the request filter from within WebSecurityConfig:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // More config here...
            .addFilterBefore(new TenantFilter(tenantRepository), UsernamePasswordAuthenticationFilter.class)
Enter fullscreen mode Exit fullscreen mode

The before-part is essential here. UserDetailsService will read from TenantContext, so the filter must kick in before authentication is done.

A controller Example

Whenever someone creates a new post, the application needs to know two things:

  • Who is the user (author) of this new post? Easy: we use @AuthenticationPrincipal CustomUserDetails user.
  • Which tenant is this new post supposed to be a part of? Also easy: we call TenantContext.getCurrentTenantId().

Like so:

@PostMapping("add_post")
@PreAuthorize("isAuthenticated() && !hasRole('ADMINISTRATOR')")
public String addPost(@AuthenticationPrincipal CustomUserDetails user,
                      @Valid NewPostViewModel postVm) {
    var tenantId = TenantContext.getCurrentTenantId();
    postService.addPost(user.getUserId(), tenantId, postVm.getText());
    return "redirect:/posts";
}
Enter fullscreen mode Exit fullscreen mode

(@PreAuthorize is part of Spring Method Security)

However, TenantContext.getCurrentTenantId is way too much typing, and I'm way too lazy so...
We create a custom annotation:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantId {
}
Enter fullscreen mode Exit fullscreen mode

... a resolver:

public class TenantResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(TenantId.class) != null &&
                        parameter.getParameterType().getTypeName().equals("long");
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        return TenantContext.getCurrentTenantId();
    }
}
Enter fullscreen mode Exit fullscreen mode

... and activate it:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new TenantResolver());
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can do this(!):

@PostMapping("add_post")
@PreAuthorize("isAuthenticated() && !hasRole('ADMINISTRATOR')")
public String addPost(@TenantId long tenantId,
                      @AuthenticationPrincipal CustomUserDetails user,
                      @Valid NewPostViewModel postVm) {
    postService.addPost(user.getUserId(), tenantId, postVm.getText());
    return "redirect:/posts";
}
Enter fullscreen mode Exit fullscreen mode

I've added support for @Tenant String tenant parameters as well. Very convenient because @ControllerAdvice can be used to add the tenant to the MVC model for all controllers.

@ControllerAdvice
public class GlobalControllerAdvice {
    private static final Logger LOGGER = Logger.getLogger(GlobalControllerAdvice.class.getName());

    @ModelAttribute("tenant")
    public String populateTenantName(@Tenant String tenant) {
        return tenant;
    }
}
Enter fullscreen mode Exit fullscreen mode

On any of my Thymeleaf pages, I can now show the tenant:

<span th:text="${tenant}"></span>
Enter fullscreen mode Exit fullscreen mode

... or test on it:

<span th:if="${tenant != null}"></span>
Enter fullscreen mode Exit fullscreen mode

Check the full source here.

Cross-tenant security breaches

The web application works fine at this stage, but unfortunately, it's not fully secure yet. Once a user authenticates, she'll be handed a cookie called "JSESSIONID" that could appear as if it's tenant-specific, but it isn't. When a request is made using this cookie, Spring Security will check the validity of this cookie without taking tenants into account. In fact, Spring Security doesn't know what a tenant is.

Let's imagine I've authenticated against tenant2, where I'm a tenant administrator. I've been given a cookie (yum!) that I will now use to make hand-crafted requests against tenant1:

DELETE http://tenant1.localhost:8080/api/posts/1
Cookie: JSESSIONID=DF07D6D7C7CB9652830ABB3E108F20C7
Enter fullscreen mode Exit fullscreen mode

Without any additional checks, I'll be able to delete the posts of the other tenants. (CSRF has been left out of the equation in the example)

More filters

This TenantAuthorizationFilter compares the tenant of the request (taken from the URL) against the tenant of the user (taken from CustomUserDetails).

public class TenantAuthorizationFilter extends OncePerRequestFilter {
    private static final Logger LOGGER =
            Logger.getLogger(TenantAuthorizationFilter.class.getName());

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        var tenantId = TenantContext.getCurrentTenantId();
        var authentication = SecurityContextHolder.getContext().getAuthentication();
        var user = authentication == null ? null : (CustomUserDetails) authentication.getPrincipal();
        var userTenantId = user == null ? null : user.getTenantId();

        if (user == null || Objects.equals(tenantId, userTenantId)) {
            chain.doFilter(request, response);
        } else {
            LOGGER.warning("Attempted cross-tenant access.");
            response.setStatus(FORBIDDEN.value());
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getRequestURI().startsWith("/webjars/")
                || request.getRequestURI().startsWith("/css/")
                || request.getRequestURI().startsWith("/js/")
                || request.getRequestURI().endsWith(".ico");
    }
}
Enter fullscreen mode Exit fullscreen mode

When should this filter kick in?
Indeed, after authentication has happened (because CustomUserDetails must be created first):

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // More config here...
            .addFilterBefore(new TenantFilter(tenantRepository), UsernamePasswordAuthenticationFilter.class)
            .addFilterAfter(new TenantAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
Enter fullscreen mode Exit fullscreen mode

Speaking of cookies, my favorites are dinosaur coo--
I mean, cookies are domain-specific inside of a browser. If you have a cookie for tenant1.localhost, the browser will not use it when making requests against tenant2.localhost. That's great because tenants' websites will feel separated and disconnected, as they should be.
That also means that the problem highlighted above only applies to hand-crafted HTTP requests (Postman, .http file, ...).

Schematically, the situation that we've got ourselves into now looks like this:
Image description

Styling and customization

A quick and easy way to give tenants their own customizable style would be to do something like this:

@RestController
@RequestMapping("/style")
public class StyleController {
    @GetMapping(value = "tenantStyle.css", produces = {"text/css"})
    public String getTenantStyle(@Tenant String tenant) {
        if (tenant == null) {
            return "body { background-color: #fcd2d2; }";
        } else if (tenant.equals("tenant1")) {
            return "body { background-color: #ebebfc; }";
        } else if (tenant.equals("tenant2")) {
            return "body { background-color: #edfceb; }";
        } else {
            return "";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Obviously, this is a very hard-coded approach. Configurable colors could be stored in the database and retrieved from a service here. A CssBuilder could be invented to do the heavy lifting, and caching could be added since the CSS won't change much.

Possible improvements and additions

  • The @Tenant parameter annotation could be made compatible with a custom TenantDetails interface (similar to Spring Security's UserDetails). This interface could have getTenant and getTenantId methods. The TenantDetails class could be extended with a tenant name, logo (file path), etc.
  • Error handling is quite basic right now. There's no form validation and error reporting. Redirecting after login success/failure has not been configured.
  • Thymeleaf code could be cleaned up by moving the general administration navbar and pages into separate fragments and/or include files.
  • For cleaner controller code, Method Security Meta-Annotations such as @AdminOnly and @TenantUserOnly can be created.
  • Spring Caching can be used to cache the lookup of the tenant ID (inside TenantFilter) and to cache calls to the StyleController because they trigger a lot! @Cacheable is incredible.

Summary

As you know, all source code is here.
All multitenant specifics are covered above, but I recommend pulling the repository for a more complete picture. Do make sure to read the README.md for instructions on setting up the database. All you need is docker and a JVM.

I hope this writeup has given you the information you were looking for! Any comments are appreciated! Definitely let me know if you have any suggestions or recommendations.

Top comments (0)