diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/pom.xml b/persistence-modules/spring-boot-persistence-mongodb-3/pom.xml index efb988d0a0..b9a47aa703 100644 --- a/persistence-modules/spring-boot-persistence-mongodb-3/pom.xml +++ b/persistence-modules/spring-boot-persistence-mongodb-3/pom.xml @@ -24,6 +24,11 @@ org.springframework.boot spring-boot-starter-data-mongodb + + org.mongodb + mongodb-crypt + ${mongodb-crypt.version} + de.flapdoodle.embed de.flapdoodle.embed.mongo @@ -31,4 +36,8 @@ + + 1.6.1 + + diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/MongoDbCsfleApplication.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/MongoDbCsfleApplication.java new file mode 100644 index 0000000000..fac296a208 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/MongoDbCsfleApplication.java @@ -0,0 +1,12 @@ +package com.baeldung.boot.csfle; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MongoDbCsfleApplication { + + public static void main(String... args) { + SpringApplication.run(MongoDbCsfleApplication.class, args); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/EncryptionConfig.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/EncryptionConfig.java new file mode 100644 index 0000000000..1495822bc0 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/EncryptionConfig.java @@ -0,0 +1,59 @@ +package com.baeldung.boot.csfle.config; + +import org.bson.BsonBinary; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import com.mongodb.client.vault.ClientEncryption; + +@Configuration +public class EncryptionConfig { + + @Value("${com.baeldung.csfle.master-key-path}") + private String masterKeyPath; + + @Value("${com.baeldung.csfle.key-vault.namespace}") + private String keyVaultNamespace; + + @Value("${com.baeldung.csfle.key-vault.alias}") + private String keyVaultAlias; + + @Value("${com.baeldung.csfle.auto-decryption:false}") + private Boolean autoDecryption; + + private ClientEncryption encryption; + + private BsonBinary dataKeyId; + + public void setEncryption(ClientEncryption encryption) { + this.encryption = encryption; + } + + public ClientEncryption getEncryption() { + return encryption; + } + + public void setDataKeyId(BsonBinary dataKeyId) { + this.dataKeyId = dataKeyId; + } + + public BsonBinary getDataKeyId() { + return dataKeyId; + } + + public String getKeyVaultNamespace() { + return keyVaultNamespace; + } + + public String getKeyVaultAlias() { + return keyVaultAlias; + } + + public String getMasterKeyPath() { + return masterKeyPath; + } + + public Boolean getAutoDecryption() { + return autoDecryption; + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/LocalKmsUtils.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/LocalKmsUtils.java new file mode 100644 index 0000000000..e5daf781f0 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/LocalKmsUtils.java @@ -0,0 +1,55 @@ +package com.baeldung.boot.csfle.config; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.Map; + +public class LocalKmsUtils { + + private static final int KEY_SIZE = 96; + + private LocalKmsUtils() { + } + + public static byte[] createMasterKey(String path) throws FileNotFoundException, IOException { + byte[] masterKey = new byte[KEY_SIZE]; + new SecureRandom().nextBytes(masterKey); + + try (FileOutputStream stream = new FileOutputStream(path)) { + stream.write(masterKey); + } + + return masterKey; + } + + public static byte[] readMasterKey(String path) throws FileNotFoundException, IOException { + byte[] masterKey = new byte[KEY_SIZE]; + + try (FileInputStream stream = new FileInputStream(path)) { + stream.read(masterKey, 0, KEY_SIZE); + } + + return masterKey; + } + + public static Map> providersMap(String masterKeyPath) throws FileNotFoundException, IOException { + if (masterKeyPath == null) + throw new IllegalArgumentException("master key path cannot be null"); + + File masterKeyFile = new File(masterKeyPath); + byte[] masterKey = masterKeyFile.isFile() + ? readMasterKey(masterKeyPath) + : createMasterKey(masterKeyPath); + + Map masterKeyMap = new HashMap<>(); + masterKeyMap.put("key", masterKey); + Map> providersMap = new HashMap<>(); + providersMap.put("local", masterKeyMap); + return providersMap; + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/MongoClientConfig.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/MongoClientConfig.java new file mode 100644 index 0000000000..29076f4e61 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/MongoClientConfig.java @@ -0,0 +1,122 @@ +package com.baeldung.boot.csfle.config; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; + +import org.bson.BsonBinary; +import org.bson.BsonDocument; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +import com.baeldung.boot.csfle.config.converter.IntegerConverter; +import com.baeldung.boot.csfle.config.converter.StringConverter; +import com.mongodb.AutoEncryptionSettings; +import com.mongodb.ClientEncryptionSettings; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoClientSettings.Builder; +import com.mongodb.MongoNamespace; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.vault.DataKeyOptions; +import com.mongodb.client.vault.ClientEncryption; +import com.mongodb.client.vault.ClientEncryptions; + +@Configuration +public class MongoClientConfig extends AbstractMongoClientConfiguration { + + @Value("${spring.data.mongodb.uri}") + private String uri; + + @Value("${spring.data.mongodb.database}") + private String db; + + @Autowired + private EncryptionConfig encryptionConfig; + + @Override + protected String getDatabaseName() { + return db; + } + + @Override + public MongoCustomConversions customConversions() { + return new MongoCustomConversions(Arrays.asList(new StringConverter(encryptionConfig), new IntegerConverter(encryptionConfig))); + } + + @Override + public MongoClient mongoClient() { + MongoClient client; + try { + client = MongoClients.create(clientSettings()); + + ClientEncryption encryption = createClientEncryption(); + encryptionConfig.setDataKeyId(createOrRetrieveDataKey(client, encryption)); + + return client; + } catch (IOException e) { + throw new IllegalStateException("unable to create client", e); + } + } + + private BsonBinary createOrRetrieveDataKey(MongoClient client, ClientEncryption encryption) { + MongoNamespace namespace = new MongoNamespace(encryptionConfig.getKeyVaultNamespace()); + MongoCollection keyVault = client.getDatabase(namespace.getDatabaseName()) + .getCollection(namespace.getCollectionName()); + + Bson query = Filters.in("keyAltNames", encryptionConfig.getKeyVaultAlias()); + BsonDocument key = keyVault.withDocumentClass(BsonDocument.class) + .find(query) + .first(); + + if (key == null) { + keyVault.createIndex(Indexes.ascending("keyAltNames"), new IndexOptions().unique(true) + .partialFilterExpression(Filters.exists("keyAltNames"))); + + DataKeyOptions options = new DataKeyOptions(); + options.keyAltNames(Arrays.asList(encryptionConfig.getKeyVaultAlias())); + return encryption.createDataKey("local", options); + } else { + return (BsonBinary) key.get("_id"); + } + } + + private ClientEncryption createClientEncryption() throws FileNotFoundException, IOException { + Map> kmsProviders = LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath()); + + ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings(clientSettings()) + .keyVaultNamespace(encryptionConfig.getKeyVaultNamespace()) + .kmsProviders(kmsProviders) + .build(); + + encryptionConfig.setEncryption(ClientEncryptions.create(encryptionSettings)); + return encryptionConfig.getEncryption(); + } + + private MongoClientSettings clientSettings() throws FileNotFoundException, IOException { + Builder settings = MongoClientSettings.builder() + .applyConnectionString(new ConnectionString(uri)); + + if (encryptionConfig.getAutoDecryption()) { + settings.autoEncryptionSettings(AutoEncryptionSettings.builder() + .keyVaultNamespace(encryptionConfig.getKeyVaultNamespace()) + .kmsProviders(LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath())) + .bypassAutoEncryption(true) + .build()); + } + + return settings.build(); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/IntegerConverter.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/IntegerConverter.java new file mode 100644 index 0000000000..020513ebff --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/IntegerConverter.java @@ -0,0 +1,27 @@ +package com.baeldung.boot.csfle.config.converter; + +import org.bson.BsonBinary; +import org.bson.BsonValue; +import org.bson.types.Binary; +import org.springframework.core.convert.converter.Converter; + +import com.baeldung.boot.csfle.config.EncryptionConfig; + +public class IntegerConverter implements Converter { + + private EncryptionConfig encryptionConfig; + + public IntegerConverter(EncryptionConfig config) { + this.encryptionConfig = config; + } + + @Override + public Integer convert(Binary source) { + BsonBinary bin = new BsonBinary(source.getType(), source.getData()); + BsonValue value = encryptionConfig.getEncryption() + .decrypt(bin); + + return value.asInt32() + .getValue(); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/StringConverter.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/StringConverter.java new file mode 100644 index 0000000000..7f8193ce43 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/config/converter/StringConverter.java @@ -0,0 +1,27 @@ +package com.baeldung.boot.csfle.config.converter; + +import org.bson.BsonBinary; +import org.bson.BsonValue; +import org.bson.types.Binary; +import org.springframework.core.convert.converter.Converter; + +import com.baeldung.boot.csfle.config.EncryptionConfig; + +public class StringConverter implements Converter { + + private EncryptionConfig encryptionConfig; + + public StringConverter(EncryptionConfig config) { + this.encryptionConfig = config; + } + + @Override + public String convert(Binary source) { + BsonBinary bin = new BsonBinary(source.getType(), source.getData()); + BsonValue value = encryptionConfig.getEncryption() + .decrypt(bin); + + return value.asString() + .getValue(); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/Citizen.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/Citizen.java new file mode 100644 index 0000000000..9d6496a17b --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/Citizen.java @@ -0,0 +1,47 @@ +package com.baeldung.boot.csfle.data; + +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("citizens") +public class Citizen { + + private String name; + private String email; + private Integer birthYear; + + public Citizen() { + } + + public Citizen(EncryptedCitizen encryptedCitizen) { + this.name = encryptedCitizen.getName(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Integer getBirthYear() { + return birthYear; + } + + public void setBirthYear(Integer birthYear) { + this.birthYear = birthYear; + } + + @Override + public String toString() { + return "Citizen [name=" + name + ", email=" + email + ", birthYear=" + birthYear + "]"; + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/EncryptedCitizen.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/EncryptedCitizen.java new file mode 100644 index 0000000000..01c9245fbf --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/data/EncryptedCitizen.java @@ -0,0 +1,48 @@ +package com.baeldung.boot.csfle.data; + +import org.bson.BsonBinary; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("citizens") +public class EncryptedCitizen { + + private String name; + private BsonBinary email; + private BsonBinary birthYear; + + public EncryptedCitizen() { + } + + public EncryptedCitizen(Citizen citizen) { + this.name = citizen.getName(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public BsonBinary getEmail() { + return email; + } + + public void setEmail(BsonBinary email) { + this.email = email; + } + + public BsonBinary getBirthYear() { + return birthYear; + } + + public void setBirthYear(BsonBinary birthYear) { + this.birthYear = birthYear; + } + + @Override + public String toString() { + return "Citizen [name=" + name + ", email=" + email + ", birthYear=" + birthYear + "]"; + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/service/CitizenService.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/service/CitizenService.java new file mode 100644 index 0000000000..9cc0753289 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/service/CitizenService.java @@ -0,0 +1,63 @@ +package com.baeldung.boot.csfle.service; + +import java.util.List; + +import org.bson.BsonBinary; +import org.bson.BsonInt32; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +import com.baeldung.boot.csfle.config.EncryptionConfig; +import com.baeldung.boot.csfle.data.Citizen; +import com.baeldung.boot.csfle.data.EncryptedCitizen; +import com.mongodb.client.model.vault.EncryptOptions; + +@Service +public class CitizenService { + + public static final String DETERMINISTIC_ALGORITHM = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"; + public static final String RANDOM_ALGORITHM = "AEAD_AES_256_CBC_HMAC_SHA_512-Random"; + + @Autowired + private MongoTemplate mongo; + + @Autowired + private EncryptionConfig encryptionConfig; + + public EncryptedCitizen save(Citizen citizen) { + EncryptedCitizen encryptedCitizen = new EncryptedCitizen(citizen); + encryptedCitizen.setEmail(encrypt(citizen.getEmail(), DETERMINISTIC_ALGORITHM)); + encryptedCitizen.setBirthYear(encrypt(citizen.getBirthYear(), RANDOM_ALGORITHM)); + + return mongo.save(encryptedCitizen); + } + + public List findAll() { + return mongo.findAll(Citizen.class); + } + + public Citizen findByEmail(String email) { + Query byEmail = new Query(Criteria.where("email") + .is(encrypt(email, DETERMINISTIC_ALGORITHM))); + return mongo.findOne(byEmail, Citizen.class); + } + + public BsonBinary encrypt(Object value, String algorithm) { + if (value == null) + return null; + + BsonValue bsonValue = value instanceof Integer + ? new BsonInt32((Integer) value) + : new BsonString(value.toString()); + + EncryptOptions options = new EncryptOptions(algorithm); + options.keyId(encryptionConfig.getDataKeyId()); + return encryptionConfig.getEncryption() + .encrypt(bsonValue, options); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/web/CitizenController.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/web/CitizenController.java new file mode 100644 index 0000000000..d17435fb5e --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/main/java/com/baeldung/boot/csfle/web/CitizenController.java @@ -0,0 +1,38 @@ +package com.baeldung.boot.csfle.web; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.baeldung.boot.csfle.data.Citizen; +import com.baeldung.boot.csfle.data.EncryptedCitizen; +import com.baeldung.boot.csfle.service.CitizenService; + +@RestController +@RequestMapping("/citizen") +public class CitizenController { + + @Autowired + private CitizenService service; + + @GetMapping + public List get() { + return service.findAll(); + } + + @GetMapping("by") + public Citizen getBy(@RequestParam String email) { + return service.findByEmail(email); + } + + @PostMapping + public EncryptedCitizen post(@RequestBody Citizen citizen) { + return service.save(citizen); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/test/java/com/baeldung/boot/csfle/CitizenServiceLiveTest.java b/persistence-modules/spring-boot-persistence-mongodb-3/src/test/java/com/baeldung/boot/csfle/CitizenServiceLiveTest.java new file mode 100644 index 0000000000..5d0a931bb9 --- /dev/null +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/test/java/com/baeldung/boot/csfle/CitizenServiceLiveTest.java @@ -0,0 +1,73 @@ +package com.baeldung.boot.csfle; + +import static org.junit.jupiter.api.Assertions.*; + +import org.bson.BsonBinary; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import com.baeldung.boot.csfle.data.Citizen; +import com.baeldung.boot.csfle.data.EncryptedCitizen; +import com.baeldung.boot.csfle.service.CitizenService; + +@DirtiesContext +@RunWith(SpringRunner.class) +@TestPropertySource("/embedded.properties") +@SpringBootTest(classes = MongoDbCsfleApplication.class) +public class CitizenServiceLiveTest { + + @Autowired + private MongoTemplate mongo; + + @Autowired + private CitizenService service; + + @Test + public void givenCitizen_whenEncryptingEmail_thenEncryptedCitizenEmailMatches() { + final Citizen citizen = new Citizen(); + citizen.setName("Foo"); + citizen.setEmail("foo@citizen.com"); + + BsonBinary encryptedEmail = service.encrypt(citizen.getEmail(), CitizenService.DETERMINISTIC_ALGORITHM); + + EncryptedCitizen saved = service.save(citizen); + assertEquals(encryptedEmail, saved.getEmail()); + } + + @Test + public void givenRandomEncryptedField_whenFilteringByField_thenDocumentNotFound() { + Citizen john = new Citizen(); + john.setName("Jane Doe"); + john.setEmail("jane.doe@citizen.com"); + john.setBirthYear(1852); + + service.save(john); + + Query byBirthYear = new Query(Criteria.where("birthYear") + .is(service.encrypt(john.getBirthYear(), CitizenService.RANDOM_ALGORITHM))); + Citizen result = mongo.findOne(byBirthYear, Citizen.class); + + assertNull(result); + } + + @Test + public void givenDeterministicallyEncryptedField_whenFilteringByField_thenDocumentFound() { + Citizen jane = new Citizen(); + jane.setName("Jane Doe"); + jane.setEmail("jane.doe@citizen.com"); + jane.setBirthYear(1952); + + service.save(jane); + Citizen result = service.findByEmail(jane.getEmail()); + + assertNotNull(result); + } +} diff --git a/persistence-modules/spring-boot-persistence-mongodb-3/src/test/resources/embedded.properties b/persistence-modules/spring-boot-persistence-mongodb-3/src/test/resources/embedded.properties index a5b5fb9804..5325354e55 100644 --- a/persistence-modules/spring-boot-persistence-mongodb-3/src/test/resources/embedded.properties +++ b/persistence-modules/spring-boot-persistence-mongodb-3/src/test/resources/embedded.properties @@ -1 +1,10 @@ -spring.mongodb.embedded.version=4.4.9 \ No newline at end of file +spring.mongodb.embedded.version=4.4.9 + +spring.data.mongodb.uri=changeit +spring.data.mongodb.database=changeit + +com.baeldung.csfle.kms-provider=local +com.baeldung.csfle.key-vault.namespace=encryption._keyVault +com.baeldung.csfle.key-vault.alias=master.key +com.baeldung.csfle.master-key-path=/tmp/master.key +com.baeldung.csfle.auto-decryption=false