From 7af516d159b9b3929c6920a47f21f6fa5269e64e Mon Sep 17 00:00:00 2001 From: Hamid Reza Sharifi Date: Wed, 24 Aug 2022 02:03:31 +0430 Subject: [PATCH] Bael 5407: Multitenancy with Spring Data JPA (#12507) * BAEL-5407: add entity, repository, controller, main class * BAEL-5407: add starter-web and postgres dependencies * BAEL-5407: add sample script * BAEL-5407: add tenant definition file * BAEL-5407: add tenant filter * BAEL-5407: add AbstractRoutingDataSource class * BAEL-5407: add TenantContext class * BAEL-5407: add MultitenantConfiguration class * BAEL-5407: replace Object with String * BAEL-5407: improve indentation * BAEL-5407: remove printStackTrace() * BAEL-5407: add security classes * BAEL-5407: add security dependencies * BAEL-5407: get Tenant from JWT token * BAEL-5407: improve indent * BAEL-5407: improve code quality Co-authored-by: h_sharifi --- .../allTenants/tenant_1.properties | 5 ++ .../allTenants/tenant_2.properties | 5 ++ persistence-modules/spring-jpa-2/pom.xml | 21 +++++++ .../multitenant/MultiTenantApplication.java | 14 +++++ .../config/MultitenantConfiguration.java | 53 ++++++++++++++++++ .../config/MultitenantDataSource.java | 12 ++++ .../multitenant/config/TenantContext.java | 14 +++++ .../multitenant/config/TenantFilter.java | 29 ++++++++++ .../controller/EmployeeController.java | 25 +++++++++ .../baeldung/multitenant/domain/Employee.java | 40 +++++++++++++ .../repository/EmployeeRepository.java | 9 +++ .../security/AccountCredentials.java | 23 ++++++++ .../security/AuthenticationFilter.java | 25 +++++++++ .../security/AuthenticationService.java | 56 +++++++++++++++++++ .../multitenant/security/LoginFilter.java | 51 +++++++++++++++++ .../security/SecurityConfiguration.java | 56 +++++++++++++++++++ .../src/main/resources/tenant-schema.sql | 1 + 17 files changed, 439 insertions(+) create mode 100644 persistence-modules/spring-jpa-2/allTenants/tenant_1.properties create mode 100644 persistence-modules/spring-jpa-2/allTenants/tenant_2.properties create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/MultiTenantApplication.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantConfiguration.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantDataSource.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantContext.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantFilter.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/controller/EmployeeController.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/domain/Employee.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/repository/EmployeeRepository.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AccountCredentials.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationFilter.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationService.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/LoginFilter.java create mode 100644 persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/SecurityConfiguration.java create mode 100644 persistence-modules/spring-jpa-2/src/main/resources/tenant-schema.sql diff --git a/persistence-modules/spring-jpa-2/allTenants/tenant_1.properties b/persistence-modules/spring-jpa-2/allTenants/tenant_1.properties new file mode 100644 index 0000000000..141cb8f3d8 --- /dev/null +++ b/persistence-modules/spring-jpa-2/allTenants/tenant_1.properties @@ -0,0 +1,5 @@ +name=tenant_1 +datasource.url=jdbc:postgresql://localhost:5432/tenant1 +datasource.username=postgres +datasource.password=123456 +datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file diff --git a/persistence-modules/spring-jpa-2/allTenants/tenant_2.properties b/persistence-modules/spring-jpa-2/allTenants/tenant_2.properties new file mode 100644 index 0000000000..6069da5822 --- /dev/null +++ b/persistence-modules/spring-jpa-2/allTenants/tenant_2.properties @@ -0,0 +1,5 @@ +name=tenant_2 +datasource.url=jdbc:postgresql://localhost:5432/tenant2 +datasource.username=postgres +datasource.password=123456 +datasource.driver-class-name=org.postgresql.Driver \ No newline at end of file diff --git a/persistence-modules/spring-jpa-2/pom.xml b/persistence-modules/spring-jpa-2/pom.xml index 19f5a8acc2..de42f94856 100644 --- a/persistence-modules/spring-jpa-2/pom.xml +++ b/persistence-modules/spring-jpa-2/pom.xml @@ -21,6 +21,11 @@ spring-boot-starter ${spring-boot.version} + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + org.springframework.boot spring-boot-starter-data-jpa @@ -42,6 +47,16 @@ spring-context ${org.springframework.version} + + org.springframework.boot + spring-boot-starter-security + ${spring-boot.version} + + + io.jsonwebtoken + jjwt + 0.9.1 + org.hibernate @@ -53,6 +68,12 @@ h2 ${h2.version} + + org.postgresql + postgresql + 42.4.0 + runtime + org.apache.tomcat tomcat-dbcp diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/MultiTenantApplication.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/MultiTenantApplication.java new file mode 100644 index 0000000000..236b53f570 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/MultiTenantApplication.java @@ -0,0 +1,14 @@ +package com.baeldung.multitenant; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; + +@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }) +public class MultiTenantApplication { + + public static void main(String[] args) { + SpringApplication.run(MultiTenantApplication.class, args); + } + +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantConfiguration.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantConfiguration.java new file mode 100644 index 0000000000..64d66f5cc9 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantConfiguration.java @@ -0,0 +1,53 @@ +package com.baeldung.multitenant.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +import javax.sql.DataSource; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +@Configuration +public class MultitenantConfiguration { + + @Bean + @ConfigurationProperties(prefix = "tenants") + public DataSource dataSource() { + File[] files = Paths.get("allTenants").toFile().listFiles(); + Map resolvedDataSources = new HashMap<>(); + + for (File propertyFile : files) { + Properties tenantProperties = new Properties(); + DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create(); + + try { + tenantProperties.load(new FileInputStream(propertyFile)); + String tenantId = tenantProperties.getProperty("name"); + + dataSourceBuilder.driverClassName(tenantProperties.getProperty("datasource.driver-class-name")); + dataSourceBuilder.username(tenantProperties.getProperty("datasource.username")); + dataSourceBuilder.password(tenantProperties.getProperty("datasource.password")); + dataSourceBuilder.url(tenantProperties.getProperty("datasource.url")); + resolvedDataSources.put(tenantId, dataSourceBuilder.build()); + } catch (IOException exp) { + throw new RuntimeException("Problem in tenant datasource:" + exp); + } + } + + AbstractRoutingDataSource dataSource = new MultitenantDataSource(); + dataSource.setDefaultTargetDataSource(resolvedDataSources.get("tenant_1")); + dataSource.setTargetDataSources(resolvedDataSources); + + dataSource.afterPropertiesSet(); + return dataSource; + } + +} \ No newline at end of file diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantDataSource.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantDataSource.java new file mode 100644 index 0000000000..d8bf664488 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/MultitenantDataSource.java @@ -0,0 +1,12 @@ +package com.baeldung.multitenant.config; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +public class MultitenantDataSource extends AbstractRoutingDataSource { + + @Override + protected String determineCurrentLookupKey() { + return TenantContext.getCurrentTenant(); + } + +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantContext.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantContext.java new file mode 100644 index 0000000000..30bfba7892 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantContext.java @@ -0,0 +1,14 @@ +package com.baeldung.multitenant.config; + +public class TenantContext { + + private static final ThreadLocal CURRENT_TENANT = new ThreadLocal<>(); + + public static String getCurrentTenant() { + return CURRENT_TENANT.get(); + } + + public static void setCurrentTenant(String tenant) { + CURRENT_TENANT.set(tenant); + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantFilter.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantFilter.java new file mode 100644 index 0000000000..3fbb5026f7 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/config/TenantFilter.java @@ -0,0 +1,29 @@ +package com.baeldung.multitenant.config; + +import com.baeldung.multitenant.security.AuthenticationService; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +@Component +@Order(1) +class TenantFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + + String tenant = AuthenticationService.getTenant((HttpServletRequest) request); + TenantContext.setCurrentTenant(tenant); + + try { + chain.doFilter(request, response); + } finally { + TenantContext.setCurrentTenant(""); + } + + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/controller/EmployeeController.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/controller/EmployeeController.java new file mode 100644 index 0000000000..91b35a2836 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/controller/EmployeeController.java @@ -0,0 +1,25 @@ +package com.baeldung.multitenant.controller; + +import com.baeldung.multitenant.domain.Employee; +import com.baeldung.multitenant.repository.EmployeeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Transactional +public class EmployeeController { + + @Autowired + private EmployeeRepository employeeRepository; + + @PostMapping(path = "/employee") + public ResponseEntity createEmployee() { + Employee newEmployee = new Employee(); + newEmployee.setName("Baeldung"); + employeeRepository.save(newEmployee); + return ResponseEntity.ok(newEmployee); + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/domain/Employee.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/domain/Employee.java new file mode 100644 index 0000000000..883b70ca01 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/domain/Employee.java @@ -0,0 +1,40 @@ +package com.baeldung.multitenant.domain; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; + +@Entity +@Table(name = "employee") +public class Employee { + + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name") + private String name; + + public Employee() { + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/repository/EmployeeRepository.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/repository/EmployeeRepository.java new file mode 100644 index 0000000000..4a965fd51a --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/repository/EmployeeRepository.java @@ -0,0 +1,9 @@ +package com.baeldung.multitenant.repository; + +import com.baeldung.multitenant.domain.Employee; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EmployeeRepository extends JpaRepository { +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AccountCredentials.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AccountCredentials.java new file mode 100644 index 0000000000..cadfcb601a --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AccountCredentials.java @@ -0,0 +1,23 @@ +package com.baeldung.multitenant.security; + +public class AccountCredentials { + + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationFilter.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationFilter.java new file mode 100644 index 0000000000..fa6bcfb5a0 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationFilter.java @@ -0,0 +1,25 @@ +package com.baeldung.multitenant.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +public class AuthenticationFilter extends GenericFilterBean { + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + Authentication authentication = AuthenticationService.getAuthentication((HttpServletRequest) req); + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(req, res); + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationService.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationService.java new file mode 100644 index 0000000000..302058882d --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/AuthenticationService.java @@ -0,0 +1,56 @@ +package com.baeldung.multitenant.security; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Date; + +public class AuthenticationService { + + private static final long EXPIRATIONTIME = 864_000_00; // 1 day in milliseconds + private static final String SIGNINGKEY = "SecretKey"; + private static final String PREFIX = "Bearer"; + + public static void addToken(HttpServletResponse res, String username, String tenant) { + String JwtToken = Jwts.builder().setSubject(username) + .setAudience(tenant) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME)) + .signWith(SignatureAlgorithm.HS512, SIGNINGKEY) + .compact(); + res.addHeader("Authorization", PREFIX + " " + JwtToken); + } + + public static Authentication getAuthentication(HttpServletRequest req) { + String token = req.getHeader("Authorization"); + if (token != null) { + String user = Jwts.parser() + .setSigningKey(SIGNINGKEY) + .parseClaimsJws(token.replace(PREFIX, "")) + .getBody() + .getSubject(); + if (user != null) { + return new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()); + } + } + + return null; + } + + public static String getTenant(HttpServletRequest req) { + String token = req.getHeader("Authorization"); + if (token == null) { + return null; + } + String tenant = Jwts.parser() + .setSigningKey(SIGNINGKEY) + .parseClaimsJws(token.replace(PREFIX, "")) + .getBody() + .getAudience(); + return tenant; + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/LoginFilter.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/LoginFilter.java new file mode 100644 index 0000000000..9f6cbe6e16 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/LoginFilter.java @@ -0,0 +1,51 @@ +package com.baeldung.multitenant.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +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.Collection; +import java.util.Collections; + +public class LoginFilter extends AbstractAuthenticationProcessingFilter { + + public LoginFilter(String url, AuthenticationManager authManager) { + super(new AntPathRequestMatcher(url)); + setAuthenticationManager(authManager); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) + throws AuthenticationException, IOException, ServletException { + + AccountCredentials creds = new ObjectMapper(). + readValue(req.getInputStream(), AccountCredentials.class); + + return getAuthenticationManager().authenticate( + new UsernamePasswordAuthenticationToken(creds.getUsername(), + creds.getPassword(), Collections.emptyList())); + } + + @Override + protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, + FilterChain chain, Authentication auth) throws IOException, ServletException { + + Collection authorities = auth.getAuthorities(); + String tenant = ""; + for (GrantedAuthority gauth : authorities) { + tenant = gauth.getAuthority(); + } + + AuthenticationService.addToken(res, auth.getName(), tenant.substring(5)); + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/SecurityConfiguration.java b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/SecurityConfiguration.java new file mode 100644 index 0000000000..98c6a9c58d --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/java/com/baeldung/multitenant/security/SecurityConfiguration.java @@ -0,0 +1,56 @@ +package com.baeldung.multitenant.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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; + +@Configuration +@EnableWebSecurity +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication() + .passwordEncoder(passwordEncoder()) + .withUser("user") + .password(passwordEncoder().encode("baeldung")) + .roles("tenant_1"); + + auth.inMemoryAuthentication() + .passwordEncoder(passwordEncoder()) + .withUser("admin") + .password(passwordEncoder().encode("baeldung")) + .roles("tenant_2"); + } + + @Bean + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .authorizeRequests() + .antMatchers("/login").permitAll() + .anyRequest().authenticated() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .addFilterBefore(new LoginFilter("/login", authenticationManager()), + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new AuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .csrf().disable() + .headers().frameOptions().disable() + .and() + .httpBasic(); + } +} diff --git a/persistence-modules/spring-jpa-2/src/main/resources/tenant-schema.sql b/persistence-modules/spring-jpa-2/src/main/resources/tenant-schema.sql new file mode 100644 index 0000000000..675a571fe4 --- /dev/null +++ b/persistence-modules/spring-jpa-2/src/main/resources/tenant-schema.sql @@ -0,0 +1 @@ +create table employee (id int8 generated by default as identity, name varchar(255), primary key (id)); \ No newline at end of file