Skip to content

Commit

Permalink
Merge pull request #496 from gessnerfl/feature/489_tls_support
Browse files Browse the repository at this point in the history
closes #489;  simplified  TLS support added to fake smtp server. automated test added, documentation update
  • Loading branch information
gessnerfl authored Jun 22, 2024
2 parents 2820c86 + 0674f33 commit 42bbde2
Show file tree
Hide file tree
Showing 13 changed files with 4,134 additions and 1,592 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fakesmtp:
#Default: no limit
maxMessageSize: 10MB

#Configure if TLS is required to connect to the SMTP server. Defaults to false
#Configure if TLS is required to connect to the SMTP server. Defaults to false. See TLS section below
requireTLS: false

#When set to true emails will be forwarded to a configured target email system. Therefore
Expand All @@ -112,6 +112,22 @@ fakesmtp:
#Password of the client to be authenticated
password: mysecretpassword
```
### TLS
Optionally TLS can be activated. To configure TLS support, a trust store needs to be provided
containing the TLS certificate used by the FakeSMTP Server.
```yaml
fakesmtp:
# true when TLS is mandatory otherwise TLS is optional
requireTLS: true
#configuration of the truststore to enable support for TLS.
tlsKeystore:
location: /path/to/truststore.p12
password: changeit
type: PKCS12 # or JKS
```
## Web UI
Expand Down
10 changes: 5 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
plugins {
id "pl.allegro.tech.build.axion-release" version "1.17.0"
id "org.springframework.boot" version "3.2.5"
id "pl.allegro.tech.build.axion-release" version "1.17.2"
id "org.springframework.boot" version "3.3.1"
id "org.sonarqube" version "5.0.0.4638"
id 'com.google.cloud.tools.jib' version '3.4.2'
id 'com.google.cloud.tools.jib' version '3.4.3'
id "com.github.node-gradle.node" version "7.0.2"
}

Expand Down Expand Up @@ -46,15 +46,15 @@ dependencies {
implementation('org.flywaydb:flyway-core')
implementation("org.glassfish.jaxb:jaxb-runtime:4.0.5")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation('org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0')
implementation('org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0')

runtimeOnly("com.h2database:h2:2.2.224")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.ow2.asm:asm:9.7")
testImplementation("org.apache.commons:commons-lang3:3.14.0")
testImplementation("org.hamcrest:hamcrest-core:2.2")
testImplementation("org.hamcrest:hamcrest:2.2")
}

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,23 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.StringUtils;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.security.*;
import java.security.cert.CertificateException;
import java.util.List;

@Profile("default")
@Configuration
public class BaseSmtpServerConfig implements SmtpServerConfig {

private final BuildProperties buildProperties;
private final ResourceLoader resourceLoader;
private final FakeSmtpConfigurationProperties fakeSmtpConfigurationProperties;
private final List<MessageListener> messageListeners;
private final BasicUsernamePasswordValidator basicUsernamePasswordValidator;
Expand All @@ -29,13 +37,15 @@ public class BaseSmtpServerConfig implements SmtpServerConfig {

@Autowired
public BaseSmtpServerConfig(BuildProperties buildProperties,
ResourceLoader resourceLoader,
FakeSmtpConfigurationProperties fakeSmtpConfigurationProperties,
List<MessageListener> messageListeners,
BasicUsernamePasswordValidator basicUsernamePasswordValidator,
CommandHandler commandHandler,
@Value("${spring.threads.virtual.enabled:false}") boolean virtualThreadsEnabled,
Logger logger) {
this.buildProperties = buildProperties;
this.resourceLoader = resourceLoader;
this.fakeSmtpConfigurationProperties = fakeSmtpConfigurationProperties;
this.messageListeners = messageListeners;
this.basicUsernamePasswordValidator = basicUsernamePasswordValidator;
Expand All @@ -56,8 +66,38 @@ public SmtpServer smtpServer() {
if (fakeSmtpConfigurationProperties.getMaxMessageSize() != null){
smtpServer.setMaxMessageSizeInBytes(fakeSmtpConfigurationProperties.getMaxMessageSize().toBytes());
}
if(fakeSmtpConfigurationProperties.isRequireTLS() && fakeSmtpConfigurationProperties.getTlsKeystore() == null){
throw new IllegalArgumentException("SMTP server TLS keystore configuration is missing");
}
smtpServer.setRequireTLS(fakeSmtpConfigurationProperties.isRequireTLS());
smtpServer.setEnableTLS(fakeSmtpConfigurationProperties.isRequireTLS());
smtpServer.setEnableTLS(fakeSmtpConfigurationProperties.isRequireTLS() || fakeSmtpConfigurationProperties.getTlsKeystore() != null);

var tlsKeystoreConfig = fakeSmtpConfigurationProperties.getTlsKeystore();
if (tlsKeystoreConfig != null) {
logger.info("Setup TLS keystore of SMTP server");
var keyStoreFileStream = resourceLoader.getResource(tlsKeystoreConfig.getLocation());
var keyStorePassphrase =tlsKeystoreConfig.getPassword().toCharArray();
try {
var keyStore = KeyStore.getInstance(tlsKeystoreConfig.getType().name());
keyStore.load(keyStoreFileStream.getInputStream(), keyStorePassphrase);

var kmf = KeyManagerFactory.getInstance("SunX509");
kmf.init(keyStore, keyStorePassphrase);

var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null);

var sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

smtpServer.setSslContext(sslContext);
logger.info("Setup of TLS keystore of SMTP server completed");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
UnrecoverableKeyException | KeyManagementException e) {
throw new IllegalStateException("Failed to setup TLS keystore of SMTP server");
}
}

return smtpServer;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.gessnerfl.fakesmtp.config;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

Expand All @@ -25,6 +27,8 @@ public class FakeSmtpConfigurationProperties {

private DataSize maxMessageSize;
private boolean requireTLS = false;
@Valid
private KeyStore tlsKeystore;
private boolean forwardEmails = false;

@NotNull
Expand Down Expand Up @@ -94,6 +98,14 @@ public void setRequireTLS(boolean requireTLS) {
this.requireTLS = requireTLS;
}

public KeyStore getTlsKeystore() {
return tlsKeystore;
}

public void setTlsKeystore(KeyStore tlsKeystore) {
this.tlsKeystore = tlsKeystore;
}

public boolean isForwardEmails() {
return forwardEmails;
}
Expand Down Expand Up @@ -139,4 +151,41 @@ public void setMaxNumberEmails(Integer maxNumberEmails) {
this.maxNumberEmails = maxNumberEmails;
}
}

public enum KeyStoreType {
PKCS12, JKS
}

public static class KeyStore {
@NotEmpty
private String location;
@NotEmpty
private String password;
@NotNull
private KeyStoreType type = KeyStoreType.JKS;

public @NotEmpty String getLocation() {
return location;
}

public void setLocation(@NotEmpty String location) {
this.location = location;
}

public @NotEmpty String getPassword() {
return password;
}

public void setPassword(@NotEmpty String password) {
this.password = password;
}

public @NotNull KeyStoreType getType() {
return type;
}

public void setType(@NotNull KeyStoreType type) {
this.type = type;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Repository
public interface EmailRepository extends JpaRepository<Email, Long>, JpaSpecificationExecutor<Email> {

Expand All @@ -16,4 +18,8 @@ public interface EmailRepository extends JpaRepository<Email, Long>, JpaSpecific
@Query(value = "DELETE email o WHERE o.id IN ( SELECT i.id FROM email i ORDER BY i.received_on DESC OFFSET ?1)", nativeQuery = true)
int deleteEmailsExceedingDateRetentionLimit(int maxNumber);

@Transactional
@Query
List<Email> findBySubject(String subject);

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import java.net.Socket;
import java.net.UnknownHostException;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

import de.gessnerfl.fakesmtp.smtp.command.CommandHandler;
import jakarta.annotation.PostConstruct;
Expand Down Expand Up @@ -67,6 +67,7 @@ public class BaseSmtpServer implements SmtpServer {
* If true, TLS is enabled
*/
private boolean enableTLS = false;
private SSLContext sslContext;

/**
* If true, a TLS handshake is required; ignored if enableTLS=false
Expand Down Expand Up @@ -252,12 +253,14 @@ private ServerSocket createServerSocket() throws IOException {
* @throws IOException when creating the socket failed
*/
public SSLSocket createSSLSocket(final Socket socket) throws IOException {
final SSLSocketFactory sf = (SSLSocketFactory) SSLSocketFactory.getDefault();
final InetSocketAddress remoteAddress = (InetSocketAddress) socket.getRemoteSocketAddress();
final SSLSocket s = (SSLSocket) sf.createSocket(socket, remoteAddress.getHostName(), socket.getPort(), true);
s.setUseClientMode(false);
s.setEnabledCipherSuites(s.getSupportedCipherSuites());
return s;
final var sf = sslContext.getSocketFactory();
final var remoteAddress = (InetSocketAddress) socket.getRemoteSocketAddress();
final var tlsSocket = (SSLSocket) sf.createSocket(socket, remoteAddress.getHostName(), socket.getPort(), true);
tlsSocket.setUseClientMode(false);
tlsSocket.setEnabledProtocols(tlsSocket.getSupportedProtocols());
tlsSocket.setEnabledCipherSuites(tlsSocket.getSupportedCipherSuites());
tlsSocket.setNeedClientAuth(false);
return tlsSocket;
}

public String getDisplayableLocalSocketAddress() {
Expand Down Expand Up @@ -314,6 +317,10 @@ public boolean getEnableTLS() {
return enableTLS;
}

public void setSslContext(SSLContext sslContext) {
this.sslContext = sslContext;
}

public boolean getRequireTLS() {
return this.requireTLS;
}
Expand Down
59 changes: 59 additions & 0 deletions src/test/java/de/gessnerfl/fakesmtp/TLSIntegrationTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package de.gessnerfl.fakesmtp;

import de.gessnerfl.fakesmtp.repository.EmailRepository;
import de.gessnerfl.fakesmtp.smtp.server.SmtpServer;
import jakarta.transaction.Transactional;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.jupiter.api.Assertions.assertEquals;

@DirtiesContext
@Transactional
@ExtendWith(SpringExtension.class)
@SpringBootTest
@ActiveProfiles({"integrationtest_with_tls_required", "integrationtest", "default"})
class TLSIntegrationTest {

@Autowired
private EmailRepository emailRepository;
@Autowired
private SmtpServer smtpServer;

@Test
void shouldSendMailViaTLS() {
var mailSender = new JavaMailSenderImpl();
mailSender.setHost("localhost");
mailSender.setPort(smtpServer.getPort());

var props = mailSender.getJavaMailProperties();
props.setProperty("mail.transport.protocol", "smtp");
props.setProperty("mail.smtp.auth", "false");
props.setProperty("mail.smtp.starttls.enable", "true");
props.setProperty("mail.smtp.ssl.protocols", "TLSv1.2");
props.setProperty("mail.smtp.ssl.trust", "*");
props.setProperty("mail.debug", "false");

var uniqueRandomName = "Test-Mail-" + RandomStringUtils.randomAlphanumeric(24);
var message = new SimpleMailMessage();
message.setTo("receiver@example.com");
message.setFrom("sender@example.com");
message.setSubject(uniqueRandomName);
message.setText("This is the test mail");
mailSender.send(message);

var mails = emailRepository.findBySubject(uniqueRandomName);
assertThat(mails, hasSize(1));
assertEquals(uniqueRandomName, mails.get(0).getSubject());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import de.gessnerfl.fakesmtp.smtp.AuthenticationHandlerFactory;
import de.gessnerfl.fakesmtp.smtp.auth.EasyAuthenticationHandlerFactory;
import org.springframework.boot.info.BuildProperties;
import org.springframework.core.io.ResourceLoader;

import java.net.InetAddress;
import java.util.Collections;
Expand All @@ -34,6 +35,8 @@ class BaseSmtpServerConfigTest {
@Mock
private BasicUsernamePasswordValidator basicUsernamePasswordValidator;
@Mock
private ResourceLoader resourceLoader;
@Mock
private BaseSmtpServer smtpServer;
@Mock
private Logger logger;
Expand All @@ -43,7 +46,7 @@ class BaseSmtpServerConfigTest {
@BeforeEach
public void init() {
MockitoAnnotations.openMocks(this);
sut = spy(new BaseSmtpServerConfig(buildProperties, fakeSmtpConfigurationProperties, Collections.singletonList(baseMessageListener), basicUsernamePasswordValidator, commandHandler, true, logger));
sut = spy(new BaseSmtpServerConfig(buildProperties, resourceLoader, fakeSmtpConfigurationProperties, Collections.singletonList(baseMessageListener), basicUsernamePasswordValidator, commandHandler, true, logger));
when(sut.createBaseSmtpServerFor(any(MessageListenerAdapter.class), any(SessionIdFactory.class))).thenReturn(smtpServer);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ fakesmtp:
blockedRecipientAddresses:
- blocked@example.com
requireTLS: true
tls-keystore:
location: classpath:tls-keystore.p12
password: changeit
type: PKCS12
Loading

0 comments on commit 42bbde2

Please sign in to comment.