Building a multitenant web application with Spring Boot
Lars Willemsens
Posted on March 20, 2023
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 {
Static assets should fall outside of our security requirements, so we'll add matcher rules such as :
antMatcher(HttpMethod.GET, "/js/**")).permitAll()
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
}
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;
}
}
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 callchain.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);
}
}
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)
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";
}
(@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 {
}
... 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();
}
}
... and activate it:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new TenantResolver());
}
}
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";
}
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;
}
}
On any of my Thymeleaf pages, I can now show the tenant:
<span th:text="${tenant}"></span>
... or test on it:
<span th:if="${tenant != null}"></span>
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
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");
}
}
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)
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:
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 "";
}
}
}
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 customTenantDetails
interface (similar to Spring Security'sUserDetails
). This interface could havegetTenant
andgetTenantId
methods. TheTenantDetails
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 theStyleController
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.
Posted on March 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.