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
This commit is contained in:
Janos Haber
2023-03-02 22:24:31 +01:00
committed by Arnold Gálovics
parent f1c89ac914
commit 3297707817
17 changed files with 419 additions and 147 deletions
@@ -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<String> regexWhitelist;
}
}
@@ -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
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
@@ -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<CustomTaskChange> customTaskChangesForDependencyInjection;
@Override
public void afterPropertiesSet() throws Exception {
if (notLiquibaseOnlyMode()) {
@@ -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);
}
}
@@ -40,8 +40,8 @@ public final class TenantMapper implements RowMapper<FineractPlatformTenant> {
+ " 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<FineractPlatformTenant> {
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);
}
}
@@ -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);
}
@@ -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");
}
}
@@ -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}
@@ -26,4 +26,5 @@
<include file="parts/0004_readonly_database_connection.xml" relativeToChangelogFile="true"/>
<include file="parts/0005_jdbc_connection_string.xml" relativeToChangelogFile="true"/>
<include file="parts/0006_drop_retry_parameter_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0007_encrypt_existing_tenant_passwords.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1" context="tenant_store_db">
<modifyDataType tableName="tenant_server_connections" columnName="schema_password" newDataType="varchar(255)"/>
<addColumn tableName="tenant_server_connections">
<column name="master_password_hash" type="varchar(255)"></column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="2" context="tenant_store_db">
<customChange class="org.apache.fineract.infrastructure.core.service.migration.TenantPasswordEncryptionTask"/>
</changeSet>
</databaseChangeLog>
@@ -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<CustomTaskChange> customTaskChangesForDependencyInjection) {
return new TenantDatabaseUpgradeService(tenantDetailsService, tenantDataSource, fineractProperties, tenantDatabaseStateVerifier,
liquibaseFactory, tenantDataSourceFactory, environment);
liquibaseFactory, tenantDataSourceFactory, environment, customTaskChangesForDependencyInjection);
}
/**
@@ -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<HikariConfig> 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
@@ -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));
}
}
@@ -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