From 32977078178c66fcc3675780c5e77a3fbd4cbeae Mon Sep 17 00:00:00 2001 From: Janos Haber Date: Mon, 27 Feb 2023 12:20:57 +0100 Subject: [PATCH] FINERACT-1831 - Encrypted tenant passwords - [x] Encrypt tenant passwords - [x] Update integration tests - [x] Check application database use encrypted passwords - [x] One time migration for the existing password --- .../core/config/FineractProperties.java | 4 + .../FineractPlatformTenantConnection.java | 116 ++---------------- .../DataSourcePerTenantServiceFactory.java | 44 +++---- .../database/DatabasePasswordEncryptor.java | 73 +++++++++++ .../migration/TenantDataSourceFactory.java | 12 +- .../TenantDatabaseUpgradeService.java | 4 + .../TenantPasswordEncryptionTask.java | 94 ++++++++++++++ .../core/service/tenant/TenantMapper.java | 7 +- .../security/service/PasswordEncryptor.java | 26 ++++ .../security/utils/EncryptionUtil.java | 97 +++++++++++++++ .../src/main/resources/application.properties | 2 + .../tenant-store/changelog-tenant-store.xml | 1 + ...0007_encrypt_existing_tenant_passwords.xml | 34 +++++ .../apache/fineract/TestConfiguration.java | 12 +- ...DataSourcePerTenantServiceFactoryTest.java | 29 ++++- .../core/LiquibaseStepDefinitions.java | 9 +- .../resources/application-test.properties | 2 + 17 files changed, 419 insertions(+), 147 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DatabasePasswordEncryptor.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantPasswordEncryptionTask.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PasswordEncryptor.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/utils/EncryptionUtil.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0007_encrypt_existing_tenant_passwords.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index e84249351..c9c88fb61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -67,6 +67,9 @@ public class FineractProperties { private String identifier; private String name; private String description; + private String masterPassword; + private String encryption; + } @Getter @@ -244,4 +247,5 @@ public class FineractProperties { private boolean regexWhitelistEnabled; private List regexWhitelist; } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/FineractPlatformTenantConnection.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/FineractPlatformTenantConnection.java index 6f2dee679..02181a759 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/FineractPlatformTenantConnection.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/domain/FineractPlatformTenantConnection.java @@ -21,11 +21,13 @@ package org.apache.fineract.infrastructure.core.domain; import java.io.Serializable; import java.sql.Connection; import javax.sql.DataSource; +import lombok.Getter; import org.apache.commons.lang3.StringUtils; /** * Holds Tenant's DB server connection connection details. */ +@Getter public class FineractPlatformTenantConnection implements Serializable { private final Long connectionId; @@ -56,6 +58,8 @@ public class FineractPlatformTenantConnection implements Serializable { private final int minEvictableIdleTimeMillis; private final boolean testOnBorrow; + private final String masterPasswordHash; + public FineractPlatformTenantConnection(final Long connectionId, final String schemaName, String schemaServer, final String schemaServerPort, final String schemaConnectionParameters, final String schemaUsername, final String schemaPassword, final boolean autoUpdateEnabled, final int initialSize, final long validationInterval, @@ -63,7 +67,8 @@ public class FineractPlatformTenantConnection implements Serializable { final int abandonWhenPercentageFull, final int maxActive, final int minIdle, final int maxIdle, final int suspectTimeout, final int timeBetweenEvictionRunsMillis, final int minEvictableIdleTimeMillis, final boolean tesOnBorrow, final String readOnlySchemaServer, final String readOnlySchemaServerPort, final String readOnlySchemaName, - final String readOnlySchemaUsername, final String readOnlySchemaPassword, final String readOnlySchemaConnectionParameters) { + final String readOnlySchemaUsername, final String readOnlySchemaPassword, final String readOnlySchemaConnectionParameters, + final String masterPasswordHash) { this.connectionId = connectionId; this.schemaName = schemaName; @@ -92,114 +97,7 @@ public class FineractPlatformTenantConnection implements Serializable { this.readOnlySchemaUsername = readOnlySchemaUsername; this.readOnlySchemaPassword = readOnlySchemaPassword; this.readOnlySchemaConnectionParameters = readOnlySchemaConnectionParameters; - } - - public String getSchemaServer() { - return this.schemaServer; - } - - public String getSchemaServerPort() { - return this.schemaServerPort; - } - - public String getSchemaConnectionParameters() { - return this.schemaConnectionParameters; - } - - public String getSchemaUsername() { - return this.schemaUsername; - } - - public String getSchemaPassword() { - return this.schemaPassword; - } - - public boolean isAutoUpdateEnabled() { - return this.autoUpdateEnabled; - } - - public int getInitialSize() { - return this.initialSize; - } - - public long getValidationInterval() { - return this.validationInterval; - } - - public boolean isRemoveAbandoned() { - return this.removeAbandoned; - } - - public int getRemoveAbandonedTimeout() { - return this.removeAbandonedTimeout; - } - - public boolean isLogAbandoned() { - return this.logAbandoned; - } - - public int getAbandonWhenPercentageFull() { - return this.abandonWhenPercentageFull; - } - - public int getMaxActive() { - return this.maxActive; - } - - public int getMinIdle() { - return this.minIdle; - } - - public int getMaxIdle() { - return this.maxIdle; - } - - public int getSuspectTimeout() { - return this.suspectTimeout; - } - - public int getTimeBetweenEvictionRunsMillis() { - return this.timeBetweenEvictionRunsMillis; - } - - public int getMinEvictableIdleTimeMillis() { - return this.minEvictableIdleTimeMillis; - } - - public boolean isTestOnBorrow() { - return testOnBorrow; - } - - public Long getConnectionId() { - return connectionId; - } - - public String getSchemaName() { - return schemaName; - } - - public String getReadOnlySchemaServer() { - return readOnlySchemaServer; - } - - public String getReadOnlySchemaServerPort() { - return readOnlySchemaServerPort; - } - - public String getReadOnlySchemaName() { - return readOnlySchemaName; - } - - public String getReadOnlySchemaUsername() { - return readOnlySchemaUsername; - } - - public String getReadOnlySchemaPassword() { - return readOnlySchemaPassword; - } - - public String getReadOnlySchemaConnectionParameters() { - return readOnlySchemaConnectionParameters; + this.masterPasswordHash = masterPasswordHash; } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DataSourcePerTenantServiceFactory.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DataSourcePerTenantServiceFactory.java index 7ce120335..e3e6bfc1b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DataSourcePerTenantServiceFactory.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DataSourcePerTenantServiceFactory.java @@ -24,9 +24,9 @@ import static org.apache.fineract.infrastructure.core.domain.FineractPlatformTen import com.zaxxer.hikari.HikariConfig; import javax.sql.DataSource; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenantConnection; -import org.apache.fineract.infrastructure.security.constants.TenantConstants; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; @@ -47,16 +47,24 @@ public class DataSourcePerTenantServiceFactory { private final DataSource tenantDataSource; private final HikariDataSourceFactory hikariDataSourceFactory; + private final DatabasePasswordEncryptor databasePasswordEncryptor; + public DataSourcePerTenantServiceFactory(@Qualifier("hikariTenantDataSource") DataSource tenantDataSource, HikariConfig hikariConfig, - FineractProperties fineractProperties, ApplicationContext context, HikariDataSourceFactory hikariDataSourceFactory) { + FineractProperties fineractProperties, ApplicationContext context, HikariDataSourceFactory hikariDataSourceFactory, + DatabasePasswordEncryptor databasePasswordEncryptor) { this.hikariConfig = hikariConfig; this.fineractProperties = fineractProperties; this.context = context; this.tenantDataSource = tenantDataSource; this.hikariDataSourceFactory = hikariDataSourceFactory; + this.databasePasswordEncryptor = databasePasswordEncryptor; } public DataSource createNewDataSourceFor(final FineractPlatformTenantConnection tenantConnection) { + if (!databasePasswordEncryptor.isMasterPasswordHashValid(tenantConnection.getMasterPasswordHash())) { + throw new IllegalArgumentException( + "Invalid master password on tenant connection %d.".formatted(tenantConnection.getConnectionId())); + } String protocol = toProtocol(tenantDataSource); // Default properties for Writing String schemaServer = tenantConnection.getSchemaServer(); @@ -67,18 +75,13 @@ public class DataSourcePerTenantServiceFactory { String schemaConnectionParameters = tenantConnection.getSchemaConnectionParameters(); // Properties to ReadOnly case if (fineractProperties.getMode().isReadOnlyMode()) { - schemaServer = getPropertyValue(tenantConnection.getReadOnlySchemaServer(), TenantConstants.PROPERTY_RO_SCHEMA_SERVER_NAME, - schemaServer); - schemaPort = getPropertyValue(tenantConnection.getReadOnlySchemaServerPort(), TenantConstants.PROPERTY_RO_SCHEMA_SERVER_PORT, - schemaPort); - schemaName = getPropertyValue(tenantConnection.getReadOnlySchemaName(), TenantConstants.PROPERTY_RO_SCHEMA_SCHEMA_NAME, - schemaName); - schemaUsername = getPropertyValue(tenantConnection.getReadOnlySchemaUsername(), TenantConstants.PROPERTY_RO_SCHEMA_USERNAME, - schemaUsername); - schemaPassword = getPropertyValue(tenantConnection.getReadOnlySchemaPassword(), TenantConstants.PROPERTY_RO_SCHEMA_PASSWORD, - schemaPassword); - schemaConnectionParameters = getPropertyValue(tenantConnection.getReadOnlySchemaConnectionParameters(), - TenantConstants.PROPERTY_RO_SCHEMA_CONNECTION_PARAMETERS, schemaConnectionParameters); + schemaServer = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaServer(), schemaServer); + schemaPort = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaServerPort(), schemaPort); + schemaName = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaName(), schemaName); + schemaUsername = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaUsername(), schemaUsername); + schemaPassword = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaPassword(), schemaPassword); + schemaConnectionParameters = StringUtils.defaultIfBlank(tenantConnection.getReadOnlySchemaConnectionParameters(), + schemaConnectionParameters); } String jdbcUrl = toJdbcUrl(protocol, schemaServer, schemaPort, schemaName, schemaConnectionParameters); log.debug("{}", jdbcUrl); @@ -88,7 +91,7 @@ public class DataSourcePerTenantServiceFactory { config.setJdbcUrl(jdbcUrl); config.setPoolName(schemaName + "_pool"); config.setUsername(schemaUsername); - config.setPassword(schemaPassword); + config.setPassword(databasePasswordEncryptor.decrypt(schemaPassword)); config.setMinimumIdle(tenantConnection.getInitialSize()); config.setMaximumPoolSize(tenantConnection.getMaxActive()); config.setValidationTimeout(tenantConnection.getValidationInterval()); @@ -108,15 +111,4 @@ public class DataSourcePerTenantServiceFactory { return hikariDataSourceFactory.create(config); } - private String getPropertyValue(final String baseValue, final String propertyName, final String defaultValue) { - // If the property already has set, return It - if (null != baseValue) { - return baseValue; - } - if (context == null) { - return defaultValue; - } - return context.getEnvironment().getProperty(propertyName, defaultValue); - } - } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DatabasePasswordEncryptor.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DatabasePasswordEncryptor.java new file mode 100644 index 000000000..033a13669 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/database/DatabasePasswordEncryptor.java @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.service.database; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.security.service.PasswordEncryptor; +import org.apache.fineract.infrastructure.security.utils.EncryptionUtil; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DatabasePasswordEncryptor implements PasswordEncryptor { + + public static final String DEFAULT_ENCRYPTION = "AES/CBC/PKCS5Padding"; + + public static final String DEFAULT_MASTER_PASSWORD = "fineract"; + + private final FineractProperties fineractProperties; + + @Override + public String encrypt(String plainPassword) { + String masterPassword = Optional.ofNullable(fineractProperties.getTenant()) + .map(FineractProperties.FineractTenantProperties::getMasterPassword).orElse(DEFAULT_MASTER_PASSWORD); + String encryption = Optional.ofNullable(fineractProperties.getTenant()) + .map(FineractProperties.FineractTenantProperties::getEncryption).orElse(DEFAULT_ENCRYPTION); + return EncryptionUtil.encryptToBase64(encryption, masterPassword, plainPassword); + } + + @Override + public String decrypt(String encryptedPassword) { + String masterPassword = Optional.ofNullable(fineractProperties.getTenant()) + .map(FineractProperties.FineractTenantProperties::getMasterPassword).orElse(DEFAULT_MASTER_PASSWORD); + String encryption = Optional.ofNullable(fineractProperties.getTenant()) + .map(FineractProperties.FineractTenantProperties::getEncryption).orElse(DEFAULT_ENCRYPTION); + return EncryptionUtil.decryptFromBase64(encryption, masterPassword, encryptedPassword); + } + + public String getMasterPasswordHash() { + String masterPassword = Optional.ofNullable(fineractProperties) // + .map(FineractProperties::getTenant) // + .map(FineractProperties.FineractTenantProperties::getMasterPassword) // + .orElse(DEFAULT_MASTER_PASSWORD); + return BCrypt.hashpw(masterPassword.getBytes(StandardCharsets.UTF_8), BCrypt.gensalt()); + } + + public boolean isMasterPasswordHashValid(String hashed) { + String masterPassword = Optional.ofNullable(fineractProperties) // + .map(FineractProperties::getTenant) // + .map(FineractProperties.FineractTenantProperties::getMasterPassword) // + .orElse(DEFAULT_MASTER_PASSWORD); + return BCrypt.checkpw(masterPassword, hashed); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDataSourceFactory.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDataSourceFactory.java index 11c3f6aec..17e66ed02 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDataSourceFactory.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDataSourceFactory.java @@ -25,6 +25,7 @@ import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenantConnection; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -38,9 +39,13 @@ public class TenantDataSourceFactory { private final HikariDataSource tenantDataSource; + private final DatabasePasswordEncryptor databasePasswordEncryptor; + @Autowired - public TenantDataSourceFactory(@Qualifier("hikariTenantDataSource") HikariDataSource tenantDataSource) { + public TenantDataSourceFactory(@Qualifier("hikariTenantDataSource") HikariDataSource tenantDataSource, + DatabasePasswordEncryptor databasePasswordEncryptor) { this.tenantDataSource = tenantDataSource; + this.databasePasswordEncryptor = databasePasswordEncryptor; } public DataSource create(FineractPlatformTenant tenant) { @@ -53,8 +58,11 @@ public class TenantDataSourceFactory { dataSource.setConnectionTestQuery(tenantDataSource.getConnectionTestQuery()); FineractPlatformTenantConnection tenantConnection = tenant.getConnection(); + if (!databasePasswordEncryptor.isMasterPasswordHashValid(tenantConnection.getMasterPasswordHash())) { + throw new IllegalArgumentException("Invalid master password"); + } dataSource.setUsername(tenantConnection.getSchemaUsername()); - dataSource.setPassword(tenantConnection.getSchemaPassword()); + dataSource.setPassword(databasePasswordEncryptor.decrypt(tenantConnection.getSchemaPassword())); String protocol = toProtocol(tenantDataSource); String tenantJdbcUrl = toJdbcUrl(protocol, tenantConnection.getSchemaServer(), tenantConnection.getSchemaServerPort(), tenantConnection.getSchemaName(), tenantConnection.getSchemaConnectionParameters()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java index aee562ba2..623c49532 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantDatabaseUpgradeService.java @@ -24,6 +24,7 @@ import java.util.Arrays; import java.util.List; import java.util.function.Function; import javax.sql.DataSource; +import liquibase.change.custom.CustomTaskChange; import liquibase.exception.LiquibaseException; import liquibase.integration.spring.SpringLiquibase; import lombok.RequiredArgsConstructor; @@ -59,6 +60,9 @@ public class TenantDatabaseUpgradeService implements InitializingBean { private final TenantDataSourceFactory tenantDataSourceFactory; private final Environment environment; + // DO NOT REMOVE! Required for liquibase custom task initialization + private final List customTaskChangesForDependencyInjection; + @Override public void afterPropertiesSet() throws Exception { if (notLiquibaseOnlyMode()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantPasswordEncryptionTask.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantPasswordEncryptionTask.java new file mode 100644 index 000000000..e8d693fd8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/migration/TenantPasswordEncryptionTask.java @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.service.migration; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.sql.ResultSet; +import java.sql.Statement; +import liquibase.change.custom.CustomTaskChange; +import liquibase.database.Database; +import liquibase.database.jvm.JdbcConnection; +import liquibase.exception.CustomChangeException; +import liquibase.exception.SetupException; +import liquibase.exception.ValidationErrors; +import liquibase.resource.ResourceAccessor; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TenantPasswordEncryptionTask implements CustomTaskChange, ApplicationContextAware { + + private static DatabasePasswordEncryptor databasePasswordEncryptor; + + @Override + public void execute(Database database) throws CustomChangeException { + JdbcConnection dbConn = (JdbcConnection) database.getConnection(); // autocommit is false + try (Statement selectStatement = dbConn.createStatement(); Statement updateStatement = dbConn.createStatement()) { + + try (ResultSet rs = selectStatement.executeQuery("SELECT id, schema_password FROM tenant_server_connections")) { + while (rs.next()) { + String id = rs.getString("id"); + String schemaPassword = rs.getString("schema_password"); + String encryptedPassword = TenantPasswordEncryptionTask.databasePasswordEncryptor.encrypt(schemaPassword); + + String updateSql = String.format( + "update tenant_server_connections set schema_password = '%s', master_password_hash = '%s' where id = %s", + encryptedPassword, TenantPasswordEncryptionTask.databasePasswordEncryptor.getMasterPasswordHash(), id); + updateStatement.execute(updateSql); + } + } + + } catch (Exception e) { + throw new CustomChangeException(e); + } + } + + @Override + public String getConfirmationMessage() { + return null; + } + + @Override + public void setUp() throws SetupException { + // Not required + } + + @Override + public void setFileOpener(ResourceAccessor resourceAccessor) { + // Not required + } + + @Override + public ValidationErrors validate(Database database) { + return null; + } + + @Override + @SuppressWarnings("static-access") + @SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + TenantPasswordEncryptionTask.databasePasswordEncryptor = applicationContext.getBean(DatabasePasswordEncryptor.class); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/tenant/TenantMapper.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/tenant/TenantMapper.java index 979639d0b..bb17ca50a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/tenant/TenantMapper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/tenant/TenantMapper.java @@ -40,8 +40,8 @@ public final class TenantMapper implements RowMapper { + " ts.readonly_schema_server as readOnlySchemaServer, " + " ts.readonly_schema_server_port as readOnlySchemaServerPort, " + " ts.readonly_schema_name as readOnlySchemaName, " + " ts.readonly_schema_username as readOnlySchemaUsername, " + " ts.readonly_schema_password as readOnlySchemaPassword, " - + " ts.readonly_schema_connection_parameters as readOnlySchemaConnectionParameters " - + " from tenants t left join tenant_server_connections ts "; + + " ts.readonly_schema_connection_parameters as readOnlySchemaConnectionParameters, " + + " ts.master_password_hash as masterPasswordHash " + " from tenants t left join tenant_server_connections ts "; private final StringBuilder sqlBuilder = new StringBuilder(TENANT_SERVER_CONNECTION_BUILDER); public TenantMapper(boolean isReport) { @@ -98,11 +98,12 @@ public final class TenantMapper implements RowMapper { final int suspectTimeout = rs.getInt("poolSuspectTimeout"); final int timeBetweenEvictionRunsMillis = rs.getInt("poolTimeBetweenEvictionRunsMillis"); final int minEvictableIdleTimeMillis = rs.getInt("poolMinEvictableIdleTimeMillis"); + final String masterPasswordHash = rs.getString("masterPasswordHash"); return new FineractPlatformTenantConnection(connectionId, schemaName, schemaServer, schemaServerPort, schemaConnectionParameters, schemaUsername, schemaPassword, autoUpdateEnabled, initialSize, validationInterval, removeAbandoned, removeAbandonedTimeout, logAbandoned, abandonWhenPercentageFull, maxActive, minIdle, maxIdle, suspectTimeout, timeBetweenEvictionRunsMillis, minEvictableIdleTimeMillis, testOnBorrow, readOnlySchemaServer, readOnlySchemaServerPort, readOnlySchemaName, - readOnlySchemaUsername, readOnlySchemaPassword, readOnlySchemaConnectionParameters); + readOnlySchemaUsername, readOnlySchemaPassword, readOnlySchemaConnectionParameters, masterPasswordHash); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PasswordEncryptor.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PasswordEncryptor.java new file mode 100644 index 000000000..64ad4fc94 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PasswordEncryptor.java @@ -0,0 +1,26 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.service; + +public interface PasswordEncryptor { + + String encrypt(String plainPassword); + + String decrypt(String encryptedPassword); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/utils/EncryptionUtil.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/utils/EncryptionUtil.java new file mode 100644 index 000000000..003c60941 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/utils/EncryptionUtil.java @@ -0,0 +1,97 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.utils; + +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import javax.validation.constraints.NotNull; + +public final class EncryptionUtil { + + private static SecureRandom random = new SecureRandom(); + + private EncryptionUtil() {} + + public static String encryptToBase64(@NotNull String cipherType, @NotNull String masterPassword, @NotNull String data) { + try { + Cipher cipher = Cipher.getInstance(cipherType); + byte[] salt = new byte[16]; + byte[] ivBytes = new byte[16]; + random.nextBytes(salt); + random.nextBytes(ivBytes); + + IvParameterSpec iv = new IvParameterSpec(ivBytes); + + SecretKeySpec keySpec = generateKeySpec(masterPassword, salt); + + cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); + byte[] encValue = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + byte[] finalCiphertext = new byte[encValue.length + 2 * 16]; + + System.arraycopy(ivBytes, 0, finalCiphertext, 0, 16); + System.arraycopy(salt, 0, finalCiphertext, 16, 16); + System.arraycopy(encValue, 0, finalCiphertext, 32, encValue.length); + return Base64.getEncoder().encodeToString(finalCiphertext); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to encrypt data. Please check if the master password and algorithm is correct.", e); + } + } + + public static String decryptFromBase64(@NotNull String cipherType, @NotNull String masterPassword, @NotNull String encryptedData) { + try { + Cipher cipher = Cipher.getInstance(cipherType); + byte[] rawData = Base64.getDecoder().decode(encryptedData); + byte[] ivBytes = new byte[16]; + byte[] salt = new byte[16]; + byte[] ciphertext = new byte[rawData.length - 32]; + + System.arraycopy(rawData, 0, ivBytes, 0, 16); + System.arraycopy(rawData, 16, salt, 0, 16); + System.arraycopy(rawData, 32, ciphertext, 0, rawData.length - 32); + + SecretKeySpec keySpec = generateKeySpec(masterPassword, salt); + + IvParameterSpec iv = new IvParameterSpec(ivBytes); + + cipher.init(Cipher.DECRYPT_MODE, keySpec, iv); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to decrypt data. Please check if the master password and algorithm is correct.", e); + } + } + + @NotNull + private static SecretKeySpec generateKeySpec(String masterPassword, byte[] salt) + throws NoSuchAlgorithmException, InvalidKeySpecException { + KeySpec spec = new PBEKeySpec(masterPassword.toCharArray(), salt, 65536, 256); // AES-256 + SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] key = f.generateSecret(spec).getEncoded(); + return new SecretKeySpec(key, "AES"); + } + +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 7c37d32b5..11677c9df 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -34,6 +34,8 @@ fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} fineract.tenant.name=${FINERACT_DEFAULT_TENANTDB_NAME:fineract_default} fineract.tenant.description=${FINERACT_DEFAULT_TENANTDB_DESCRIPTION:Default Demo Tenant} +fineract.tenant.master-password=${FINERACT_DEFAULT_TENANTDB_MASTER_PASSWORD:fineract} +fineract.tenant.encrytion=${FINERACT_DEFAULT_TENANTDB_ENCRYPTION:"AES/CBC/PKCS5Padding"} fineract.mode.read-enabled=${FINERACT_MODE_READ_ENABLED:true} fineract.mode.write-enabled=${FINERACT_MODE_WRITE_ENABLED:true} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index 402892a9a..3cc73b305 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -26,4 +26,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0007_encrypt_existing_tenant_passwords.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0007_encrypt_existing_tenant_passwords.xml new file mode 100644 index 000000000..b6136db31 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0007_encrypt_existing_tenant_passwords.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java index 98a6ddad1..67a8e4f91 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java +++ b/fineract-provider/src/test/java/org/apache/fineract/TestConfiguration.java @@ -22,10 +22,13 @@ import static org.mockito.Mockito.RETURNS_MOCKS; import static org.mockito.Mockito.mock; import com.zaxxer.hikari.HikariDataSource; +import java.util.List; import javax.sql.DataSource; +import liquibase.change.custom.CustomTaskChange; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.service.database.DatabaseIndependentQueryService; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibaseFactory; import org.apache.fineract.infrastructure.core.service.migration.TenantDataSourceFactory; import org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseStateVerifier; @@ -83,8 +86,8 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; public class TestConfiguration { @Bean - public TenantDataSourceFactory tenantDataSourceFactory() { - return new TenantDataSourceFactory(null) { + public TenantDataSourceFactory tenantDataSourceFactory(DatabasePasswordEncryptor databasePasswordEncryptor) { + return new TenantDataSourceFactory(null, databasePasswordEncryptor) { @Override public DataSource create(FineractPlatformTenant tenant) { @@ -151,9 +154,10 @@ public class TestConfiguration { public TenantDatabaseUpgradeService tenantDatabaseUpgradeService(TenantDetailsService tenantDetailsService, HikariDataSource tenantDataSource, TenantDatabaseStateVerifier tenantDatabaseStateVerifier, ExtendedSpringLiquibaseFactory liquibaseFactory, TenantDataSourceFactory tenantDataSourceFactory, - FineractProperties fineractProperties, Environment environment) { + FineractProperties fineractProperties, Environment environment, + List customTaskChangesForDependencyInjection) { return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier, - liquibaseFactory, tenantDataSourceFactory, environment); + liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection); } /** diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/DataSourcePerTenantServiceFactoryTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/DataSourcePerTenantServiceFactoryTest.java index 20d8df67c..b099cd397 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/DataSourcePerTenantServiceFactoryTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/DataSourcePerTenantServiceFactoryTest.java @@ -38,7 +38,9 @@ import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenantConnection; import org.apache.fineract.infrastructure.core.service.database.DataSourcePerTenantServiceFactory; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; import org.apache.fineract.infrastructure.core.service.database.HikariDataSourceFactory; +import org.apache.fineract.infrastructure.security.utils.EncryptionUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -49,6 +51,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.security.crypto.bcrypt.BCrypt; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -81,6 +84,10 @@ public class DataSourcePerTenantServiceFactoryTest { public static final String MASTER_DB_CONN_TEST_QUERY = "SELECT 1"; public static final boolean MASTER_DB_AUTO_COMMIT_ENABLED = true; + public static final String MASTER_MASTER_PASSWORD = "fineract"; + + public static final String MASTER_ENCRYPTION = "AES/CBC/PKCS5Padding"; + @Mock private FineractProperties fineractProperties; @@ -102,6 +109,9 @@ public class DataSourcePerTenantServiceFactoryTest { @Captor private ArgumentCaptor hikariConfigCaptor; + @Mock + private DatabasePasswordEncryptor databasePasswordEncryptor; + @InjectMocks private DataSourcePerTenantServiceFactory underTest; @@ -119,15 +129,19 @@ public class DataSourcePerTenantServiceFactoryTest { given(tenantConnection.getSchemaServerPort()).willReturn(MASTER_DB_SERVER_PORT); given(tenantConnection.getSchemaName()).willReturn(MASTER_DB_SCHEMA_NAME); given(tenantConnection.getSchemaUsername()).willReturn(MASTER_DB_USERNAME); - given(tenantConnection.getSchemaPassword()).willReturn(MASTER_DB_PASSWORD); + given(tenantConnection.getSchemaPassword()) + .willReturn(EncryptionUtil.encryptToBase64(MASTER_ENCRYPTION, MASTER_MASTER_PASSWORD, MASTER_DB_PASSWORD)); given(tenantConnection.getSchemaConnectionParameters()).willReturn(MASTER_DB_CONN_PARAMS); given(tenantConnection.getReadOnlySchemaServer()).willReturn(READONLY_DB_SERVER); given(tenantConnection.getReadOnlySchemaServerPort()).willReturn(READONLY_DB_SERVER_PORT); given(tenantConnection.getReadOnlySchemaName()).willReturn(READONLY_DB_SCHEMA_NAME); given(tenantConnection.getReadOnlySchemaUsername()).willReturn(READONLY_DB_USERNAME); - given(tenantConnection.getReadOnlySchemaPassword()).willReturn(READONLY_DB_PASSWORD); + given(tenantConnection.getReadOnlySchemaPassword()) + .willReturn(EncryptionUtil.encryptToBase64(MASTER_ENCRYPTION, MASTER_MASTER_PASSWORD, READONLY_DB_PASSWORD)); given(tenantConnection.getReadOnlySchemaConnectionParameters()).willReturn(READONLY_DB_CONN_PARAMS); + String hashedMasterPassword = BCrypt.hashpw("master-password", BCrypt.gensalt()); + given(tenantConnection.getMasterPasswordHash()).willReturn(hashedMasterPassword); given(defaultTenant.getConnection()).willReturn(tenantConnection); given(tenantConnection.getInitialSize()).willReturn(MASTER_DB_INITIAL_SIZE); @@ -141,6 +155,17 @@ public class DataSourcePerTenantServiceFactoryTest { given(tenantHikariConfig.isAutoCommit()).willReturn(MASTER_DB_AUTO_COMMIT_ENABLED); given(hikariDataSourceFactory.create(any())).willReturn(mock(HikariDataSource.class)); + FineractProperties.FineractTenantProperties tenantPropertiesMock = mock(FineractProperties.FineractTenantProperties.class); + given(tenantPropertiesMock.getEncryption()).willReturn(MASTER_ENCRYPTION); + given(tenantPropertiesMock.getMasterPassword()).willReturn(MASTER_MASTER_PASSWORD); + given(fineractProperties.getTenant()).willReturn(tenantPropertiesMock); + + given(databasePasswordEncryptor.isMasterPasswordHashValid(any())).willReturn(true); + given(databasePasswordEncryptor.getMasterPasswordHash()).willReturn(hashedMasterPassword); + given(databasePasswordEncryptor.decrypt(any())).will( + answer -> EncryptionUtil.decryptFromBase64(MASTER_ENCRYPTION, MASTER_MASTER_PASSWORD, answer.getArgument(0, String.class))); + given(databasePasswordEncryptor.encrypt(any())).will( + answer -> EncryptionUtil.encryptToBase64(MASTER_ENCRYPTION, MASTER_MASTER_PASSWORD, answer.getArgument(0, String.class))); } @Test diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java index 27b64e415..8608a964c 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/core/LiquibaseStepDefinitions.java @@ -29,16 +29,19 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import io.cucumber.java8.En; +import java.util.Arrays; import java.util.List; import javax.sql.DataSource; import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibase; import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibaseFactory; import org.apache.fineract.infrastructure.core.service.migration.SchemaUpgradeNeededException; import org.apache.fineract.infrastructure.core.service.migration.TenantDataSourceFactory; import org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseStateVerifier; import org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService; +import org.apache.fineract.infrastructure.core.service.migration.TenantPasswordEncryptionTask; import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; @@ -65,6 +68,8 @@ public class LiquibaseStepDefinitions implements En { private DataSource defaultTenantDataSource; private Environment environment; + private DatabasePasswordEncryptor databasePasswordEncryptor; + public LiquibaseStepDefinitions() { Given("Liquibase is disabled with a default tenant", () -> { initializeLiquibase(false); @@ -169,6 +174,8 @@ public class LiquibaseStepDefinitions implements En { defaultTenantDataSource = mock(DataSource.class); + TenantPasswordEncryptionTask tenantPasswordEncryptor = mock(TenantPasswordEncryptionTask.class); + given(databaseStateVerifier.isLiquibaseDisabled()).willReturn(!liquibaseEnabled); given(liquibaseFactory.create(tenantStoreDataSource, "tenant_store_db", "initial_switch")).willReturn(initialTenantStoreLiquibase); given(liquibaseFactory.create(tenantStoreDataSource, "tenant_store_db")).willReturn(tenantStoreLiquibase); @@ -180,6 +187,6 @@ public class LiquibaseStepDefinitions implements En { given(liquibaseFactory.create(defaultTenantDataSource, "tenant_db", "custom_changelog")).willReturn(customChangeLogLiquibase); tenantDatabaseUpgradeService = new TenantDatabaseUpgradeService(tenantDetailsService, tenantStoreDataSource, fineractProperties, - databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment); + databaseStateVerifier, liquibaseFactory, tenantDataSourceFactory, environment, Arrays.asList(tenantPasswordEncryptor)); } } diff --git a/fineract-provider/src/test/resources/application-test.properties b/fineract-provider/src/test/resources/application-test.properties index 3e2f22432..f33d80bea 100644 --- a/fineract-provider/src/test/resources/application-test.properties +++ b/fineract-provider/src/test/resources/application-test.properties @@ -32,6 +32,8 @@ fineract.tenant.timezone=Asia/Kolkata fineract.tenant.identifier=default fineract.tenant.name=fineract_default fineract.tenant.description=Default Demo Tenant +fineract.tenant.master-password=fineract +fineract.tenant.encrytion="AES/CBC/PKCS5Padding" fineract.mode.read-enabled=true fineract.mode.write-enabled=true