From 400ced9b3972bb03dd8ccdf2b7245ef544c28924 Mon Sep 17 00:00:00 2001 From: Andrei Isvoran Date: Tue, 5 Sep 2023 15:03:28 +0200 Subject: [PATCH] Red-6903 - Implement email service in tenant user management service --- build.gradle.kts | 6 +- .../external/SMTPConfigurationResource.java | 9 +- .../external/SMTPConfigurationController.java | 65 +++++-- .../model/SMTPResponse.java | 22 +++ .../service/EmailService.java | 169 ++++++++++++++++++ .../service/RealmService.java | 7 + .../tests/SMTPConfigurationTest.java | 43 +++-- 7 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/knecon/fforesight/tenantusermanagement/model/SMTPResponse.java create mode 100644 src/main/java/com/knecon/fforesight/tenantusermanagement/service/EmailService.java diff --git a/build.gradle.kts b/build.gradle.kts index 3706ac3..48739aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { implementation("commons-validator:commons-validator:1.7") implementation("org.springframework.boot:spring-boot-configuration-processor") implementation("com.iqser.red.commons:storage-commons:2.43.0") + implementation("jakarta.mail:jakarta.mail-api:2.1.2") + implementation("org.eclipse.angus:angus-mail:2.0.2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.cloud:spring-cloud-starter-openfeign") testImplementation("org.projectlombok:lombok") @@ -111,8 +113,8 @@ dependencies { testImplementation("org.springframework.amqp:spring-rabbit-test") testImplementation("org.testcontainers:postgresql:1.18.3") testImplementation("com.github.dasniko:testcontainers-keycloak:2.5.0") - testImplementation("org.testcontainers:testcontainers:1.18.3") - testImplementation("org.testcontainers:junit-jupiter:1.18.3") + testImplementation("org.testcontainers:testcontainers:1.19.0") + testImplementation("org.testcontainers:junit-jupiter:1.19.0") testAnnotationProcessor("org.projectlombok:lombok") } diff --git a/src/main/java/com/knecon/fforesight/tenantusermanagement/api/external/SMTPConfigurationResource.java b/src/main/java/com/knecon/fforesight/tenantusermanagement/api/external/SMTPConfigurationResource.java index 6667002..af6c2be 100644 --- a/src/main/java/com/knecon/fforesight/tenantusermanagement/api/external/SMTPConfigurationResource.java +++ b/src/main/java/com/knecon/fforesight/tenantusermanagement/api/external/SMTPConfigurationResource.java @@ -6,11 +6,12 @@ import org.springframework.web.bind.annotation.DeleteMapping; 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.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import com.knecon.fforesight.tenantusermanagement.model.SMTPConfiguration; +import com.knecon.fforesight.tenantusermanagement.model.SMTPResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -22,7 +23,6 @@ public interface SMTPConfigurationResource { String TEST_PATH = "/test"; - @ResponseBody @ResponseStatus(value = HttpStatus.OK) @GetMapping(value = SMTP_PATH, produces = MediaType.APPLICATION_JSON_VALUE) @@ -38,11 +38,12 @@ public interface SMTPConfigurationResource { void updateSMTPConfiguration(@RequestBody SMTPConfiguration smtpConfigurationModel); + @ResponseBody @ResponseStatus(value = HttpStatus.OK) - @PostMapping(value = SMTP_PATH + TEST_PATH, consumes = MediaType.APPLICATION_JSON_VALUE) + @PostMapping(value = SMTP_PATH + TEST_PATH, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "Test SMTP Settings to KeyCloak") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "SMTP Configuration is valid."), @ApiResponse(responseCode = "400", description = "SMTP test failed.")}) - void testSMTPConfiguration(@RequestBody SMTPConfiguration smtpConfigurationModel); + SMTPResponse testSMTPConfiguration(@RequestBody SMTPConfiguration smtpConfigurationModel); @ResponseStatus(value = HttpStatus.NO_CONTENT) diff --git a/src/main/java/com/knecon/fforesight/tenantusermanagement/controller/external/SMTPConfigurationController.java b/src/main/java/com/knecon/fforesight/tenantusermanagement/controller/external/SMTPConfigurationController.java index 47a4df0..1f547c8 100644 --- a/src/main/java/com/knecon/fforesight/tenantusermanagement/controller/external/SMTPConfigurationController.java +++ b/src/main/java/com/knecon/fforesight/tenantusermanagement/controller/external/SMTPConfigurationController.java @@ -6,8 +6,10 @@ import static com.knecon.fforesight.tenantusermanagement.permissions.UserManagem import java.util.HashMap; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,6 +18,8 @@ import com.knecon.fforesight.tenantcommons.TenantContext; import com.knecon.fforesight.tenantusermanagement.api.external.PublicResource; import com.knecon.fforesight.tenantusermanagement.api.external.SMTPConfigurationResource; import com.knecon.fforesight.tenantusermanagement.model.SMTPConfiguration; +import com.knecon.fforesight.tenantusermanagement.model.SMTPResponse; +import com.knecon.fforesight.tenantusermanagement.service.EmailService; import com.knecon.fforesight.tenantusermanagement.service.RealmService; import lombok.RequiredArgsConstructor; @@ -28,9 +32,11 @@ import lombok.extern.slf4j.Slf4j; public class SMTPConfigurationController implements SMTPConfigurationResource, PublicResource { private final static String SMTP_PASSWORD_KEY = "FFORESIGHT_SMTP_PASSWORD"; + private final static String DEFAULT_PASSWORD = "**********"; private final RealmService realmService; private final ObjectMapper objectMapper; private final EncryptionDecryptionService encryptionDecryptionService; + private final EmailService emailService; @Override @@ -58,6 +64,36 @@ public class SMTPConfigurationController implements SMTPConfigurationResource, P realmService.realm(TenantContext.getTenantId()).update(realmRepresentation); } + @SneakyThrows + @Override + @PreAuthorize("hasAuthority('" + WRITE_SMTP_CONFIGURATION + "')") + public SMTPResponse testSMTPConfiguration(@RequestBody SMTPConfiguration smtpConfiguration) { + + String targetEmail = realmService.getEmail(realmService.realm(TenantContext.getTenantId())); + + if (StringUtils.isEmpty(targetEmail)) { + // will send e-mail to self in case targetEmail is not set + targetEmail = smtpConfiguration.getFrom(); + } + + updatePassword(smtpConfiguration); + smtpConfiguration.setPassword(encryptionDecryptionService.decrypt(smtpConfiguration.getPassword())); + + SMTPResponse smtpResponse = emailService.send(convertSMTPConfigurationModelToMap(smtpConfiguration), targetEmail, "Redaction Test message", "This is a test message"); + log.info("Test SMTP Configuration status: {}, reason: {}", smtpResponse.getStatusCode(), smtpResponse.getReasonPhrase()); + return smtpResponse; + } + + @Override + @PreAuthorize("hasAuthority('" + WRITE_SMTP_CONFIGURATION + "')") + public void clearSMTPConfiguration() { + // also update in KC + var realmRepresentation = realmService.realm(TenantContext.getTenantId()).toRepresentation(); + realmRepresentation.setSmtpServer(new HashMap<>()); + realmRepresentation.getAttributesOrEmpty().remove(SMTP_PASSWORD_KEY); + realmService.realm(TenantContext.getTenantId()).update(realmRepresentation); + + } private Map convertSMTPConfigurationModelToMap(SMTPConfiguration smtpConfigurationModel) { @@ -73,26 +109,19 @@ public class SMTPConfigurationController implements SMTPConfigurationResource, P return stringPropertiesMap; } + private void updatePassword(SMTPConfiguration smtpConfiguration) { - @SneakyThrows - @Override - @PreAuthorize("hasAuthority('" + WRITE_SMTP_CONFIGURATION + "')") - public void testSMTPConfiguration(@RequestBody SMTPConfiguration smtpConfigurationModel) { - - var propertiesMap = convertSMTPConfigurationModelToMap(smtpConfigurationModel); - realmService.realm(TenantContext.getTenantId()).testSMTPConnection(propertiesMap); + if (DEFAULT_PASSWORD.equals(smtpConfiguration.getPassword())) { + try { + var password = realmService.realm(TenantContext.getTenantId()).toRepresentation().getAttributesOrEmpty().get(SMTP_PASSWORD_KEY); + smtpConfiguration.setPassword(password); + } catch (Exception e) { + log.info("No current SMTP Config exists", e); + } + } else { + smtpConfiguration.setPassword(encryptionDecryptionService.encrypt(smtpConfiguration.getPassword())); + } } - @Override - @PreAuthorize("hasAuthority('" + WRITE_SMTP_CONFIGURATION + "')") - public void clearSMTPConfiguration() { - // also update in KC - var realmRepresentation = realmService.realm(TenantContext.getTenantId()).toRepresentation(); - realmRepresentation.setSmtpServer(new HashMap<>()); - realmRepresentation.getAttributesOrEmpty().remove(SMTP_PASSWORD_KEY); - realmService.realm(TenantContext.getTenantId()).update(realmRepresentation); - - } - } diff --git a/src/main/java/com/knecon/fforesight/tenantusermanagement/model/SMTPResponse.java b/src/main/java/com/knecon/fforesight/tenantusermanagement/model/SMTPResponse.java new file mode 100644 index 0000000..f539c91 --- /dev/null +++ b/src/main/java/com/knecon/fforesight/tenantusermanagement/model/SMTPResponse.java @@ -0,0 +1,22 @@ +package com.knecon.fforesight.tenantusermanagement.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Object containing a simplified version of the SMTP test connection response.") +public class SMTPResponse { + + @Schema(description = "Parameter containing status code of the response.") + private int statusCode; + @Schema(description = "Parameter containing the reason phrase of the response.") + private String reasonPhrase; + +} + diff --git a/src/main/java/com/knecon/fforesight/tenantusermanagement/service/EmailService.java b/src/main/java/com/knecon/fforesight/tenantusermanagement/service/EmailService.java new file mode 100644 index 0000000..445ecec --- /dev/null +++ b/src/main/java/com/knecon/fforesight/tenantusermanagement/service/EmailService.java @@ -0,0 +1,169 @@ +package com.knecon.fforesight.tenantusermanagement.service; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; +import java.util.Properties; + +import javax.net.ssl.SSLContext; +import javax.ws.rs.BadRequestException; + +import org.springframework.stereotype.Service; + +import com.knecon.fforesight.tenantusermanagement.model.SMTPResponse; + +import jakarta.mail.Address; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.Transport; +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import jakarta.mail.internet.MimeUtility; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + + public SMTPResponse send(Map config, String address, String subject, String textBody) { + + Transport transport = null; + try { + + Properties props = new Properties(); + + if (config.containsKey("host")) { + props.setProperty("mail.smtp.host", config.get("host")); + } + + boolean auth = "true".equals(config.get("auth")); + boolean ssl = "true".equals(config.get("ssl")); + boolean starttls = "true".equals(config.get("starttls")); + + if (config.containsKey("port") && config.get("port") != null) { + props.setProperty("mail.smtp.port", config.get("port")); + } + + if (auth) { + props.setProperty("mail.smtp.auth", "true"); + } + + if (ssl) { + props.setProperty("mail.smtp.ssl.enable", "true"); + } + + if (starttls) { + props.setProperty("mail.smtp.starttls.enable", "true"); + } + + if (ssl || starttls) { + props.put("mail.smtp.ssl.protocols", getSupportedSslProtocols()); + } + + props.setProperty("mail.smtp.timeout", "10000"); + props.setProperty("mail.smtp.connectiontimeout", "10000"); + + String from = config.get("from"); + String fromDisplayName = config.get("fromDisplayName"); + String replyTo = config.get("replyTo"); + String replyToDisplayName = config.get("replyToDisplayName"); + String envelopeFrom = config.get("envelopeFrom"); + + Session session = Session.getInstance(props); + + Multipart multipart = new MimeMultipart("alternative"); + + MimeBodyPart textPart = new MimeBodyPart(); + textPart.setText(textBody, "UTF-8"); + multipart.addBodyPart(textPart); + + Message msg = new MimeMessage(session); + msg.setFrom(toInternetAddress(from, fromDisplayName)); + + msg.setReplyTo(new Address[]{toInternetAddress(from, fromDisplayName)}); + + if (isNotBlank(replyTo)) { + msg.setReplyTo(new Address[]{toInternetAddress(replyTo, replyToDisplayName)}); + } + + if (isNotBlank(envelopeFrom)) { + props.setProperty("mail.smtp.from", envelopeFrom); + } + + msg.setHeader("To", address); + msg.setSubject(MimeUtility.encodeText(subject, StandardCharsets.UTF_8.name(), null)); + msg.setContent(multipart); + msg.saveChanges(); + msg.setSentDate(new Date()); + + transport = session.getTransport("smtp"); + + if (auth) { + transport.connect(config.get("user"), config.get("password")); + } else { + transport.connect(); + } + + transport.sendMessage(msg, new InternetAddress[]{new InternetAddress(address)}); + + return SMTPResponse.builder() + .statusCode(200) + .build(); + } catch (Exception e) { + return SMTPResponse.builder() + .statusCode(400) + .reasonPhrase(e.getMessage()) + .build(); + } finally { + if (transport != null) { + try { + transport.close(); + } catch (MessagingException e) { + log.warn("Failed to close transport", e); + } + } + } + } + + protected InternetAddress toInternetAddress(String email, String displayName) throws UnsupportedEncodingException, AddressException, BadRequestException { + + if (email == null || "".equals(email.trim())) { + throw new BadRequestException("Please provide a valid address"); + } + + if (displayName == null || "".equals(displayName.trim())) { + return new InternetAddress(email); + } + + return new InternetAddress(email, displayName, "utf-8"); + } + + + private String getSupportedSslProtocols() { + + try { + String[] protocols = SSLContext.getDefault().getSupportedSSLParameters().getProtocols(); + if (protocols != null) { + return String.join(" ", protocols); + } + } catch (Exception e) { + log.warn("Failed to get list of supported SSL protocols", e); + } + return null; + } + + public boolean isNotBlank(String str) { + + return str != null && !"".equals(str.trim()); + } + +} diff --git a/src/main/java/com/knecon/fforesight/tenantusermanagement/service/RealmService.java b/src/main/java/com/knecon/fforesight/tenantusermanagement/service/RealmService.java index d1381d2..bc006e5 100644 --- a/src/main/java/com/knecon/fforesight/tenantusermanagement/service/RealmService.java +++ b/src/main/java/com/knecon/fforesight/tenantusermanagement/service/RealmService.java @@ -1,9 +1,11 @@ package com.knecon.fforesight.tenantusermanagement.service; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.account.UserRepresentation; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import com.knecon.fforesight.keycloakcommons.security.KeycloakSecurity; import com.knecon.fforesight.tenantcommons.model.AuthDetails; import com.knecon.fforesight.tenantusermanagement.properties.TenantUserManagementProperties; @@ -25,6 +27,11 @@ public class RealmService { return keycloak.getAdminClient().realm(tenantId); } + public String getEmail(RealmResource resource) { + var user = resource.users().list().stream().filter(userRepresentation -> userRepresentation.getUsername().equals("admin")).findFirst(); + return user.isPresent() ? user.get().getEmail() : ""; + } + public AuthDetails getOpenIdConnectDetails(String tenantId) { diff --git a/src/test/java/com/knecon/fforesight/tests/SMTPConfigurationTest.java b/src/test/java/com/knecon/fforesight/tests/SMTPConfigurationTest.java index 1cf095f..d852531 100644 --- a/src/test/java/com/knecon/fforesight/tests/SMTPConfigurationTest.java +++ b/src/test/java/com/knecon/fforesight/tests/SMTPConfigurationTest.java @@ -22,20 +22,12 @@ public class SMTPConfigurationTest extends AbstractTenantUserManagementIntegrati TenantContext.setTenantId(AbstractTenantUserManagementIntegrationTest.TEST_TENANT_ID); try { - var currentSMTPConfiguration = smtpConfigurationClient.getCurrentSMTPConfiguration(); + smtpConfigurationClient.getCurrentSMTPConfiguration(); } catch (Exception e) { System.out.println(e.getMessage()); } - SMTPConfiguration newConfig = new SMTPConfiguration(); - newConfig.setAuth(true); - newConfig.setFrom("from@knecon.com"); - newConfig.setHost("test.knecon.com"); - newConfig.setPassword("secret"); - newConfig.setUser("user"); - newConfig.setStarttls(true); - newConfig.setSsl(false); - smtpConfigurationClient.updateSMTPConfiguration(newConfig); + smtpConfigurationClient.updateSMTPConfiguration(provideTestSMTPConfiguration()); var currentSMTPConfiguration = smtpConfigurationClient.getCurrentSMTPConfiguration(); assertThat(currentSMTPConfiguration.getPassword()).matches("\\**"); @@ -50,4 +42,35 @@ public class SMTPConfigurationTest extends AbstractTenantUserManagementIntegrati } + @Test + public void testSMTPConnection() { + + TenantContext.setTenantId(AbstractTenantUserManagementIntegrationTest.TEST_TENANT_ID); + + SMTPConfiguration smtpConfiguration = provideTestSMTPConfiguration(); + + var response = smtpConfigurationClient.testSMTPConfiguration(smtpConfiguration); + + // Fails because we are not using a smtp config + assertThat(response.getStatusCode()).isEqualTo(400); + assertThat(response.getReasonPhrase()).isEqualTo("Couldn't connect to host, port: test.knecon.com, 25; timeout 10000"); + + TenantContext.clear(); + } + + private SMTPConfiguration provideTestSMTPConfiguration() { + + SMTPConfiguration smtpConfiguration = new SMTPConfiguration(); + smtpConfiguration.setAuth(true); + smtpConfiguration.setFrom("from@knecon.com"); + smtpConfiguration.setHost("test.knecon.com"); + smtpConfiguration.setPassword("secret"); + smtpConfiguration.setUser("user"); + smtpConfiguration.setStarttls(true); + smtpConfiguration.setSsl(false); + + return smtpConfiguration; + } + + }