Developing a Multi-Tenancy Application With Spring Security and JWTs
This article guides you through the process of creating a multi-tenancy application following a Software as a Service (SaaS) model, where each client has a dedicated database.
Join the DZone community and get the full member experience.
Join For FreeThis article addresses the need for a robust solution for implementing multi-tenancy in web applications while ensuring security. By adopting a database-per-tenant approach and storing user information within each tenant's database, both multi-tenancy and stringent security measures can be achieved seamlessly using Spring Security.
The main goal of this tutorial is to demonstrate the creation of a multi-tenancy application following a Software as a Service (SaaS) model, where each client has a dedicated database. We'll focus on integrating Spring Security and JWT for authentication and authorization. Whether you're connecting multiple schemas within a single database (e.g., MySQL) or multiple databases (e.g., MySQL, PostgreSQL, or Oracle), this tutorial will guide you through the process.
Mastering Java & Spring Framework Essentials Bundle*
*Affiliate link. See Terms of Use.
What Is Multi-Tenancy?
Multi-tenancy is an architecture in which a single instance of a software application serves multiple customers. Each client is called a tenant. Tenants may be given the ability to customize some parts of the application.
A multi-tenant application is where a tenant (i.e. users in a company) feels that the application has been created and deployed for them. In reality, there are many such tenants, and they too are using the same application but get a feeling that it's built just for them.
- Client requests to login to the system
- The system checks with the master database using client Id
- If it's successful, set the current database to context based on the driver class name
- If this fails, the user gets the message, "unauthorized"
- After successful authentication, the user gets a JWT for the next execution
The whole process executes in the following workflow:
Now let's start developing a multi-tenancy application step-by-step with Spring Security and JWT.
How to Develop a Multi-Tenancy Application With Spring Security and JWT
1. Set up the project
Here are all the technologies that will play a role within our application:
- Java 11
- Spring Boot
- Spring Security
- Spring AOP
- Spring Data JPA
- Hibernate
- JWT
- MySQL, PostgreSQL
- IntliJ
You can set up your project quickly by using https://start.spring.io/.
Following the steps outlined in the Spring site should the resulting project structure:
2. Create a master database and a tenant database
Master Database:
In the master database, we only have one table (tbl_tenant_master
), where all tenant information is stored in the table.
xxxxxxxxxx
create database master_db;
CREATE TABLE `master_db`.`tbl_tenant_master` (
`tenant_client_id` int(10) unsigned NOT NULL,
`db_name` varchar(50) NOT NULL,
`url` varchar(100) NOT NULL,
`user_name` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`driver_class` varchar(100) NOT NULL,
`status` varchar(10) NOT NULL,
PRIMARY KEY (`tenant_client_id`) USING BTREE
) ENGINE=InnoDB;
Tenant Database (1) in MySQL:
Create a table for client login authentication(tbl_user
).
Create another table (tbl_product
) to retrieve data using a JWT (for Authorization checks).
xxxxxxxxxx
create database testdb;
DROP TABLE IF EXISTS `testdb`.`tbl_user`;
CREATE TABLE `testdb`.`tbl_user` (
`user_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`full_name` varchar(100) NOT NULL,
`gender` varchar(10) NOT NULL,
`user_name` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`status` varchar(10) NOT NULL,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB;
DROP TABLE IF EXISTS `testdb`.`tbl_product`;
CREATE TABLE `testdb`.`tbl_product` (
`product_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`product_name` varchar(50) NOT NULL,
`quantity` int(10) unsigned NOT NULL DEFAULT '0',
`size` varchar(3) NOT NULL,
PRIMARY KEY (`product_id`)
) ENGINE=InnoDB;
Tenant Database (2) in PostgreSQL:
Create a table for client login authentication (tbl_user
).
Create another table (tbl_product
) to retrieve data using a JWT (for authorization checks).
xxxxxxxxxx
create database testdb_pgs;
CREATE TABLE public.tbl_user
(
user_id integer NOT NULL,
full_name character varying(100) COLLATE pg_catalog."default" NOT NULL,
gender character varying(10) COLLATE pg_catalog."default" NOT NULL,
user_name character varying(50) COLLATE pg_catalog."default" NOT NULL,
password character varying(100) COLLATE pg_catalog."default" NOT NULL,
status character varying(10) COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT tbl_user_pkey PRIMARY KEY (user_id)
)
CREATE TABLE public.tbl_product
(
product_id integer NOT NULL,
product_name character varying(50) COLLATE pg_catalog."default" NOT NULL,
quantity integer NOT NULL DEFAULT 0,
size character varying(3) COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT tbl_product_pkey PRIMARY KEY (product_id)
)
Database creation and table creation are done!
3. Check the pom.xml
file
Your pom file should look like this:
xxxxxxxxxx
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath></relativePath> <!-- lookup parent from repository -->
</parent>
<groupId>com.amran.dynamic.multitenant</groupId>
<artifactId>dynamicmultitenant</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<name>dynamicmultitenant</name>
<description>Dynamic Multi Tenant project for Spring Boot</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4. Configure the master database or common database
We can configure the master database or common database into our Spring Boot application via the application.yml
file as follows:
xxxxxxxxxx
multitenancy:
mtapp:
master:
datasource:
url: jdbc:mysql://192.168.0.115:3306/master_db?useSSL=false
username: root
password: test
driverClassName: com.mysql.cj.jdbc.Driver
connectionTimeout: 20000
maxPoolSize: 250
idleTimeout: 300000
minIdle: 5
poolName: masterdb-connection-pool
5. Enable Spring security and JWT
WebSecurityConfigurerAdapter
allows users to configure web-based security for a certain selection (in this case all) requests. It allows configuring things that impact our application's security. WebSecurityConfigurerAdapter
is a convenience class that allows customization to both WebSecurity
and HttpSecurity
.
WebSecurityConfig.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
* @author Md. Amran Hossain
*/
prePostEnabled = true) (
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private JwtUserDetailsService jwtUserDetailsService;
private JwtAuthenticationEntryPoint unauthorizedHandler;
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoder());
}
public JwtAuthenticationFilter authenticationTokenFilterBean() throws Exception {
return new JwtAuthenticationFilter();
}
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable().
authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/product/**").authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);
}
// @Bean
// public PasswordEncoder passwordEncoder() {
// PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// return encoder;
// }
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
public FilterRegistrationBean platformCorsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration configAutenticacao = new CorsConfiguration();
configAutenticacao.setAllowCredentials(true);
configAutenticacao.addAllowedOrigin("*");
configAutenticacao.addAllowedHeader("Authorization");
configAutenticacao.addAllowedHeader("Content-Type");
configAutenticacao.addAllowedHeader("Accept");
configAutenticacao.addAllowedMethod("POST");
configAutenticacao.addAllowedMethod("GET");
configAutenticacao.addAllowedMethod("DELETE");
configAutenticacao.addAllowedMethod("PUT");
configAutenticacao.addAllowedMethod("OPTIONS");
configAutenticacao.setMaxAge(3600L);
source.registerCorsConfiguration("/**", configAutenticacao);
FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
bean.setOrder(-110);
return bean;
}
}
The class, OncePerRequestFilter
, is a filter base class that aims to guarantee a single execution per request dispatch on any servlet container. As of Servlet 3.0, a filter may be invoked as part of a REQUEST or ASYNC dispatch that occurs in separate threads.
JwtAuthenticationFilter.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.security;
import com.amran.dynamic.multitenant.constant.JWTConstants;
import com.amran.dynamic.multitenant.mastertenant.config.DBContextHolder;
import com.amran.dynamic.multitenant.mastertenant.entity.MasterTenant;
import com.amran.dynamic.multitenant.mastertenant.service.MasterTenantService;
import com.amran.dynamic.multitenant.util.JwtTokenUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.SignatureException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
/**
* @author Md. Amran Hossain
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtUserDetailsService jwtUserDetailsService;
private JwtTokenUtil jwtTokenUtil;
MasterTenantService masterTenantService;
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
String header = httpServletRequest.getHeader(JWTConstants.HEADER_STRING);
String username = null;
String audience = null; //tenantOrClientId
String authToken = null;
if (header != null && header.startsWith(JWTConstants.TOKEN_PREFIX)) {
authToken = header.replace(JWTConstants.TOKEN_PREFIX,"");
try {
username = jwtTokenUtil.getUsernameFromToken(authToken);
audience = jwtTokenUtil.getAudienceFromToken(authToken);
MasterTenant masterTenant = masterTenantService.findByClientId(Integer.valueOf(audience));
if(null == masterTenant){
logger.error("An error during getting tenant name");
throw new BadCredentialsException("Invalid tenant and user.");
}
DBContextHolder.setCurrentDb(masterTenant.getDbName());
} catch (IllegalArgumentException ex) {
logger.error("An error during getting username from token", ex);
} catch (ExpiredJwtException ex) {
logger.warn("The token is expired and not valid anymore", ex);
} catch(SignatureException ex){
logger.error("Authentication Failed. Username or Password not valid.",ex);
}
} else {
logger.warn("Couldn't find bearer string, will ignore the header");
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = jwtUserDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN")));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
logger.info("authenticated user " + username + ", setting security context");
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
ExceptionTranslationFilter
is used to catch any Spring Security exceptions so that either an HTTP error response can be returned, or an appropriate AuthenticationEntryPoint
can be launched. The AuthenticationEntryPoint
will be called if the user requests a secure HTTP resource, but they are not authenticated.
xxxxxxxxxx
package com.amran.dynamic.multitenant.security;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;
/**
* @author Md. Amran Hossain
*/
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
private static final long serialVersionUID = -7858869558953243875L;
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}
6. Configure the master database
Master Data Source Configuration:
ThreadLocals
is used to maintain some context related to the current thread. For example, when the current transaction is stored in a ThreadLocal
, you don't need to pass it as a parameter through every method call in case someone down the stack needs access to it.
Web applications might store information about the current request and session in a ThreadLocal
, so that the application has easy access to them. ThreadLocals
can be used when implementing custom scopes for injected objects.
ThreadLocals
are one sort of global variables (although slightly less evil because they are restricted to one thread), so you should be careful when using them to avoid unwanted side-effects and memory leaks.
DBContextHolder.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.mastertenant.config;
/**
* @author Md. Amran Hossain
* The context holder implementation is a container that stores the current context as a ThreadLocal reference.
*/
public class DBContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setCurrentDb(String dbType) {
contextHolder.set(dbType);
}
public static String getCurrentDb() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
Create another class, MasterDatabaseConfigProperties.java
. It holds a connection-related parameter, as defined in the application.yml
file.
xxxxxxxxxx
package com.amran.dynamic.multitenant.mastertenant.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author Md. Amran Hossain
*/
"multitenancy.mtapp.master.datasource") (
public class MasterDatabaseConfigProperties {
private String url;
private String username;
private String password;
private String driverClassName;
private long connectionTimeout;
private int maxPoolSize;
private long idleTimeout;
private int minIdle;
private String poolName;
//Initialization of HikariCP.
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("MasterDatabaseConfigProperties [url=");
builder.append(url);
builder.append(", username=");
builder.append(username);
builder.append(", password=");
builder.append(password);
builder.append(", driverClassName=");
builder.append(driverClassName);
builder.append(", connectionTimeout=");
builder.append(connectionTimeout);
builder.append(", maxPoolSize=");
builder.append(maxPoolSize);
builder.append(", idleTimeout=");
builder.append(idleTimeout);
builder.append(", minIdle=");
builder.append(minIdle);
builder.append(", poolName=");
builder.append(poolName);
builder.append("]");
return builder.toString();
}
public String getUrl() {
return url;
}
public MasterDatabaseConfigProperties setUrl(String url) {
this.url = url;
return this;
}
public String getUsername() {
return username;
}
public MasterDatabaseConfigProperties setUsername(String username) {
this.username = username;
return this;
}
public String getPassword() {
return password;
}
public MasterDatabaseConfigProperties setPassword(String password) {
this.password = password;
return this;
}
public String getDriverClassName() {
return driverClassName;
}
public MasterDatabaseConfigProperties setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
return this;
}
public long getConnectionTimeout() {
return connectionTimeout;
}
public MasterDatabaseConfigProperties setConnectionTimeout(long connectionTimeout) {
this.connectionTimeout = connectionTimeout;
return this;
}
public int getMaxPoolSize() {
return maxPoolSize;
}
public MasterDatabaseConfigProperties setMaxPoolSize(int maxPoolSize) {
this.maxPoolSize = maxPoolSize;
return this;
}
public long getIdleTimeout() {
return idleTimeout;
}
public MasterDatabaseConfigProperties setIdleTimeout(long idleTimeout) {
this.idleTimeout = idleTimeout;
return this;
}
public int getMinIdle() {
return minIdle;
}
public MasterDatabaseConfigProperties setMinIdle(int minIdle) {
this.minIdle = minIdle;
return this;
}
public String getPoolName() {
return poolName;
}
public MasterDatabaseConfigProperties setPoolName(String poolName) {
this.poolName = poolName;
return this;
}
}
@EnableTransactionManagement
and <tx:annotation-driven/>
are responsible for registering the necessary Spring components that power annotation-driven transaction management, such as the TransactionInterceptor
and the proxy, or an AspectJ-based advice that weaves the interceptor into the call stack when JdbcFooRepository's @Transactional
methods are invoked.
MasterDatabaseConfig.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.mastertenant.config;
import com.amran.dynamic.multitenant.mastertenant.entity.MasterTenant;
import com.amran.dynamic.multitenant.mastertenant.repository.MasterTenantRepository;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.Properties;
/**
* @author Md. Amran Hossain
*/
basePackages = {"com.amran.dynamic.multitenant.mastertenant.entity", "com.amran.dynamic.multitenant.mastertenant.repository"}, (
entityManagerFactoryRef = "masterEntityManagerFactory",
transactionManagerRef = "masterTransactionManager")
public class MasterDatabaseConfig {
private static final Logger LOG = LoggerFactory.getLogger(MasterDatabaseConfig.class);
private MasterDatabaseConfigProperties masterDbProperties;
//Create Master Data Source using master properties and also configure HikariCP
name = "masterDataSource") (
public DataSource masterDataSource() {
HikariDataSource hikariDataSource = new HikariDataSource();
hikariDataSource.setUsername(masterDbProperties.getUsername());
hikariDataSource.setPassword(masterDbProperties.getPassword());
hikariDataSource.setJdbcUrl(masterDbProperties.getUrl());
hikariDataSource.setDriverClassName(masterDbProperties.getDriverClassName());
hikariDataSource.setPoolName(masterDbProperties.getPoolName());
// HikariCP settings
hikariDataSource.setMaximumPoolSize(masterDbProperties.getMaxPoolSize());
hikariDataSource.setMinimumIdle(masterDbProperties.getMinIdle());
hikariDataSource.setConnectionTimeout(masterDbProperties.getConnectionTimeout());
hikariDataSource.setIdleTimeout(masterDbProperties.getIdleTimeout());
LOG.info("Setup of masterDataSource succeeded.");
return hikariDataSource;
}
name = "masterEntityManagerFactory") (
public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory() {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
// Set the master data source
em.setDataSource(masterDataSource());
// The master tenant entity and repository need to be scanned
em.setPackagesToScan(new String[]{MasterTenant.class.getPackage().getName(), MasterTenantRepository.class.getPackage().getName()});
// Setting a name for the persistence unit as Spring sets it as
// 'default' if not defined
em.setPersistenceUnitName("masterdb-persistence-unit");
// Setting Hibernate as the JPA provider
JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
em.setJpaVendorAdapter(vendorAdapter);
// Set the hibernate properties
em.setJpaProperties(hibernateProperties());
LOG.info("Setup of masterEntityManagerFactory succeeded.");
return em;
}
name = "masterTransactionManager") (
public JpaTransactionManager masterTransactionManager( ("masterEntityManagerFactory") EntityManagerFactory emf) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(emf);
return transactionManager;
}
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
return new PersistenceExceptionTranslationPostProcessor();
}
//Hibernate configuration properties
private Properties hibernateProperties() {
Properties properties = new Properties();
properties.put(org.hibernate.cfg.Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
properties.put(org.hibernate.cfg.Environment.SHOW_SQL, true);
properties.put(org.hibernate.cfg.Environment.FORMAT_SQL, true);
properties.put(org.hibernate.cfg.Environment.HBM2DDL_AUTO, "none");
return properties;
}
}
7. Configure the tenant database
In this section, we'll work to understand multitenancy in Hibernate. There are three approaches to multitenancy in Hibernate:
- Separate Schema — one schema per tenant in the same physical database instance.
- Separate Database — one separate physical database instance per tenant.
- Partitioned (Discriminator) Data — the data for each tenant is partitioned by a discriminator value.
As usual, Hibernate abstracts the complexity around the implementation of each approach.
All we need is to provide an implementation of these two interfaces:
-
MultiTenantConnectionProvider
– provides connections per tenant. -
CurrentTenantIdentifierResolver
– resolves the tenant identifier to use.
MultiTenantConnectionProvider
If Hibernate cannot resolve the tenant identifier to use, it will use the method, getAnyConnection
, to get a connection. Otherwise, it will use the method, getConnection
.
Hibernate provides two implementations of this interface depending on how we define the database connections:
- Using Datasource interface from Java – we would use the DataSourceBasedMultiTenantConnectionProviderImpl implementation
- Using the ConnectionProvider interface from Hibernate – we would use the AbstractMultiTenantConnectionProvider implementation
CurrentTenantIdentifierResolver
Hibernate calls the method, resolveCurrentTenantIdentifier
, to get the tenant identifier. If we want Hibernate to validate that all the existing sessions belong to the same tenant identifier, the method validateExistingCurrentSessions
should return true.
Schema Approach
In this strategy, we'll use different schemas or users in the same physical database instance. This approach should be used when we need the best performance for our application and can sacrifice special database features such as backup per tenant.
Database Approach
The Database multi-tenancy approach uses different physical database instances per tenant. Since each tenant is fully isolated, we should choose this strategy when we need special database features, like backup per tenant more than we need the best performance.
CurrentTenantIdentifierResolverImpl.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.tenant.config;
import com.amran.dynamic.multitenant.mastertenant.config.DBContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
/**
* @author Md. Amran Hossain
*/
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {
private static final String DEFAULT_TENANT_ID = "client_tenant_1";
public String resolveCurrentTenantIdentifier() {
String tenant = DBContextHolder.getCurrentDb();
return StringUtils.isNotBlank(tenant) ? tenant : DEFAULT_TENANT_ID;
}
public boolean validateExistingCurrentSessions() {
return true;
}
}
DataSourceBasedMultiTenantConnectionProviderImpl.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.tenant.config;
import com.amran.dynamic.multitenant.mastertenant.config.DBContextHolder;
import com.amran.dynamic.multitenant.mastertenant.entity.MasterTenant;
import com.amran.dynamic.multitenant.mastertenant.repository.MasterTenantRepository;
import com.amran.dynamic.multitenant.util.DataSourceUtil;
import org.hibernate.engine.jdbc.connections.spi.AbstractDataSourceBasedMultiTenantConnectionProviderImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* @author Md. Amran Hossain
*/
public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {
private static final Logger LOG = LoggerFactory.getLogger(DataSourceBasedMultiTenantConnectionProviderImpl.class);
private static final long serialVersionUID = 1L;
private Map<String, DataSource> dataSourcesMtApp = new TreeMap<>();
private MasterTenantRepository masterTenantRepository;
ApplicationContext applicationContext;
protected DataSource selectAnyDataSource() {
// This method is called more than once. So check if the data source map
// is empty. If it is then rescan master_tenant table for all tenant
if (dataSourcesMtApp.isEmpty()) {
List<MasterTenant> masterTenants = masterTenantRepository.findAll();
LOG.info("selectAnyDataSource() method call...Total tenants:" + masterTenants.size());
for (MasterTenant masterTenant : masterTenants) {
dataSourcesMtApp.put(masterTenant.getDbName(), DataSourceUtil.createAndConfigureDataSource(masterTenant));
}
}
return this.dataSourcesMtApp.values().iterator().next();
}
protected DataSource selectDataSource(String tenantIdentifier) {
// If the requested tenant id is not present check for it in the master
// database 'master_tenant' table
tenantIdentifier = initializeTenantIfLost(tenantIdentifier);
if (!this.dataSourcesMtApp.containsKey(tenantIdentifier)) {
List<MasterTenant> masterTenants = masterTenantRepository.findAll();
LOG.info("selectDataSource() method call...Tenant:" + tenantIdentifier + " Total tenants:" + masterTenants.size());
for (MasterTenant masterTenant : masterTenants) {
dataSourcesMtApp.put(masterTenant.getDbName(), DataSourceUtil.createAndConfigureDataSource(masterTenant));
}
}
//check again if tenant exist in map after rescan master_db, if not, throw UsernameNotFoundException
if (!this.dataSourcesMtApp.containsKey(tenantIdentifier)) {
LOG.warn("Trying to get tenant:" + tenantIdentifier + " which was not found in master db after rescan");
throw new UsernameNotFoundException(String.format("Tenant not found after rescan, " + " tenant=%s", tenantIdentifier));
}
return this.dataSourcesMtApp.get(tenantIdentifier);
}
private String initializeTenantIfLost(String tenantIdentifier) {
if (tenantIdentifier != DBContextHolder.getCurrentDb()) {
tenantIdentifier = DBContextHolder.getCurrentDb();
}
return tenantIdentifier;
}
}
TenantDatabaseConfig.java
xxxxxxxxxx
package com.amran.dynamic.multitenant.tenant.config;
import org.hibernate.MultiTenancyStrategy;
import org.hibernate.cfg.Environment;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* @author Md. Amran Hossain
*/
basePackages = { "com.amran.dynamic.multitenant.tenant.repository", "com.amran.dynamic.multitenant.tenant.entity" }) (
basePackages = {"com.amran.dynamic.multitenant.tenant.repository", "com.amran.dynamic.multitenant.tenant.service" }, (
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager")
public class TenantDatabaseConfig {
name = "tenantJpaVendorAdapter") (
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
name = "tenantTransactionManager") (
public JpaTransactionManager transactionManager( ("tenantEntityManagerFactory") EntityManagerFactory tenantEntityManager) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(tenantEntityManager);
return transactionManager;
}
/**
* The multi tenant connection provider
*
* @return
*/
name = "datasourceBasedMultitenantConnectionProvider") (
name = "masterEntityManagerFactory") (
public MultiTenantConnectionProvider multiTenantConnectionProvider() {
// Autowires the multi connection provider
return new DataSourceBasedMultiTenantConnectionProviderImpl();
}
/**
* The current tenant identifier resolver
*
* @return
*/
name = "currentTenantIdentifierResolver") (
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
return new CurrentTenantIdentifierResolverImpl();
}
/**
* Creates the entity manager factory bean which is required to access the
* JPA functionalities provided by the JPA persistence provider, i.e.
* Hibernate in this case.
*
* @param connectionProvider
* @param tenantResolver
* @return
*/
name = "tenantEntityManagerFactory") (
name = "datasourceBasedMultitenantConnectionProvider") (
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
"datasourceBasedMultitenantConnectionProvider") (
MultiTenantConnectionProvider connectionProvider,
"currentTenantIdentifierResolver") (
CurrentTenantIdentifierResolver tenantResolver) {
LocalContainerEntityManagerFactoryBean emfBean = new LocalContainerEntityManagerFactoryBean();
//All tenant related entities, repositories and service classes must be scanned
emfBean.setPackagesToScan("com.amran.dynamic.multitenant");
emfBean.setJpaVendorAdapter(jpaVendorAdapter());
emfBean.setPersistenceUnitName("tenantdb-persistence-unit");
Map<String, Object> properties = new HashMap<>();
properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, connectionProvider);
properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantResolver);
properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
properties.put(Environment.SHOW_SQL, true);
properties.put(Environment.FORMAT_SQL, true);
properties.put(Environment.HBM2DDL_AUTO, "none");
emfBean.setJpaPropertyMap(properties);
return emfBean;
}
}
It seems like we're almost done. So we should move to the next step in the process.
8. Perform database data checks
Master Database data:- tbl_tenant_master
- tbl_user
- tbl_product
- tbl_user
- tbl_product
9. Test that everything works as we expect using Postman
Target MySQL:
Target PostgreSQL:
Conclusion
This tutorial has provided a comprehensive guide for developing a multi-tenancy application with Spring Security and JWTs. By leveraging a database-per-tenant architecture and securely managing user credentials within each tenant's database, we've ensured both data isolation and robust security measures.
Throughout this tutorial, we've emphasized the importance of maintaining the integrity of each tenant's data while implementing authentication and authorization mechanisms using Spring Security and JWTs. By following the steps outlined here, you're equipped to build scalable and secure multi-tenant applications that adhere to industry standards and best practices.
I hope this tutorial will be helpful for any person or organization.
You can find the source code here: https://github.com/amran-bd/Dynamic-Multi-Tenancy-Using-Java-Spring-Boot-Security-JWT-Rest-API-MySQL-Postgresql-full-example.
Opinions expressed by DZone contributors are their own.
Comments