From 6abf159102859be6441e7daec57bc766ab4ae9db Mon Sep 17 00:00:00 2001 From: Andre Wachsmuth Date: Tue, 29 Mar 2022 08:55:10 +0200 Subject: [PATCH] Implements #449 Make authentication details available in MovingMessage * Implements #449: Stores the authentication details in the moving message so that it is available in e.g. MessageDeliveryHandler * Since different authentication methods have different details, there's a AuthenticationState base interface with PlainAuthenticationState (with an additional authorizationId) and LoginAuthenticationState POJOs for the currently supported AUTH mechanisms LOGIN and AUTH. Other AUTH mechanisms may or may not have a username and/or password. * What I don't fully understand is why the SMTPState needs to be cleared so often. It's also cleared by the MailCommand, which removes the authentication details from the MovingMessage. I modfied the MailCommand so that it clears the SMTPState, but transfers the authentication details from the old moving message to the new one. We could also store the authentication details in the SMTPState as well, but that feels a bit redundant. * When I run the tests locally, the DummySSLServerSocketFactoryTest#testLoadKeyStoreViaSystemProperty fails, but that's because I don't have a certificate with an `amazonrootca1` in my system root key store (there is, e.g. a `debian:amazon_root_ca_1.pem` certificate and when I change the test to use that, it passes) --- .gitignore | 2 +- .../greenmail/mail/MovingMessage.java | 71 +++++++++++++++ .../icegreen/greenmail/smtp/SmtpState.java | 11 +++ .../smtp/auth/AuthenticationState.java | 14 +++ .../smtp/auth/LoginAuthenticationState.java | 45 ++++++++++ .../smtp/auth/PlainAuthenticationState.java | 63 ++++++++++++++ .../smtp/auth/UsernameAuthentication.java | 10 +++ .../greenmail/smtp/commands/AuthCommand.java | 35 +++++--- .../greenmail/smtp/commands/MailCommand.java | 4 +- .../greenmail/util/GreenMailUtil.java | 15 ++++ .../ExampleFindUserByAuthLoginTest.java | 87 +++++++++++++++++++ 11 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java create mode 100644 greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java create mode 100644 greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java create mode 100644 greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java create mode 100644 greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java diff --git a/.gitignore b/.gitignore index 90143f669..e20008c97 100644 --- a/.gitignore +++ b/.gitignore @@ -224,7 +224,7 @@ pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties - +dependency-reduced-pom.xml ### Gradle ### .gradle diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java b/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java index 87f375ac5..dca7e200e 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java @@ -10,39 +10,110 @@ import java.util.LinkedList; import java.util.List; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.LoginAuthenticationState; +import com.icegreen.greenmail.smtp.auth.PlainAuthenticationState; + /** * Contains information for delivering a mime email. */ public class MovingMessage { + private AuthenticationState authenticationState; private MailAddress returnPath; private final List toAddresses = new LinkedList<>(); private MimeMessage message; + + /** + * Retrieves the state object with the data used for authentication. Currently + * {@link PlainAuthenticationState PLAIN} and {@link LoginAuthenticationState LOGIN} + * authentication is supported. You can use this, for example, to retrieve the username + * or password that was sent by the client. + * + * Note that this will return {@code null} when no authentication was performed or needed. + * + * @return The state used by the AUTH command, if any. + */ + public AuthenticationState getAuthenticationState() { + return authenticationState; + } + + /** + * Retrieves the state object with the data used for authentication. Currently + * {@link PlainAuthenticationState PLAIN} and {@link LoginAuthenticationState LOGIN} + * authentication is supported. You can use this, for example, to retrieve the username + * or password that was sent by the client. + * + * Note that this will return {@code null} when no authentication was performed or needed. + * + * @return The state used by the AUTH command, if any. + */ + public void setAuthenticationState(AuthenticationState authenticationState) { + this.authenticationState = authenticationState; + } + /** + * Retrieves the addresses from which the email was sent. Note that these are + * the {@code RCPT TO} addresses from the SMTP envelope, not the {@code TO} + * addresses from the mail header. + * @return The address to which the mail is directed. + */ public List getToAddresses() { return toAddresses; } + /** + * Retrieves the contents of the mail message, including all mail headers and the body. + * @return The message that was sent. + */ public MimeMessage getMessage() { return message; } + /** + * Retrieves the address from which the email was sent. Note that this is the + * {@code MAIL FROM} address from the SMTP envelope, not the {@code FROM} + * address(es) from the mail header. + * @return The address from which the email was sent. + */ public MailAddress getReturnPath() { return returnPath; } + /** + * Sets or overwrites the address from which the email was sent. Note that this is + * the {@code MAIL FROM} address from the SMTP envelope, not the {@code FROM} + * address(es) from the mail header. + * @param fromAddress The address from which the email was sent. + */ public void setReturnPath(MailAddress fromAddress) { this.returnPath = fromAddress; } + /** + * Adds an address from which the email was sent. Note that these are the {@code RCPT TO} + * addresses from the SMTP envelope, not the {@code TO} addresses from the mail header. + * @return The address to which the mail is directed. + */ public void addRecipient(MailAddress s) { toAddresses.add(s); } + /** + * Removes an address from the list of addresses from which the email was sent. Note + * that these are the {@code RCPT TO} addresses from the SMTP envelope, not the {@code TO} + * addresses from the mail header. + * @return An address to remove form the addresses to which the mail is directed. + */ public void removeRecipient(MailAddress s) { toAddresses.remove(s); } + /** + * Sets or overwrites the contents of the mail message, including all mail headers + * and the body. + * @param message The message that was sent. + */ public void setMimeMessage(MimeMessage message) { this.message = message; } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java index d0f9a06a6..6a6c63da9 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java @@ -7,6 +7,7 @@ package com.icegreen.greenmail.smtp; import com.icegreen.greenmail.mail.MovingMessage; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; public class SmtpState { MovingMessage currentMessage; @@ -25,4 +26,14 @@ public MovingMessage getMessage() { public void clearMessage() { currentMessage = new MovingMessage(); } + + /** + * To destroy a half-constructed message, but preserves the + * {@link MovingMessage#getAuthenticationState()}. + */ + public void clearMessagePreservingAuthenticationState() { + AuthenticationState authState = currentMessage != null ? currentMessage.getAuthenticationState() : null; + currentMessage = new MovingMessage(); + currentMessage.setAuthenticationState(authState); + } } \ No newline at end of file diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java new file mode 100644 index 000000000..1557a6d22 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java @@ -0,0 +1,14 @@ +package com.icegreen.greenmail.smtp.auth; + +/** + * Base interface for the state used by various authentication methods. The data contained + * in the state depends on the authentication method that was used. The state can be + * mutable, such as when the authentication method requires multiple exchanges between the + * client and the server. + */ +public interface AuthenticationState { + /** + * @return The type of the used authentication mechanism, e.g. {@code PLAIN} or {@code LOGIN}. + */ + String getType(); +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java new file mode 100644 index 000000000..06b4e7ba9 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java @@ -0,0 +1,45 @@ +package com.icegreen.greenmail.smtp.auth; + +import com.icegreen.greenmail.smtp.commands.AuthCommand.AuthMechanism; + +/** + * Details from the {@link AuthMechanism#LOGIN} authorization mechanism, when + * that mechanism was used for authentication. + */ +public class LoginAuthenticationState implements AuthenticationState, UsernameAuthentication { + private final String username; + + private final String password; + + /** + * @param username The username from the AUTH command. + * @param password The password from the AUTH command. + */ + public LoginAuthenticationState(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public String getType() { + return AuthMechanism.LOGIN.name(); + } + + /** + * Retrieves the username that was used for {@code PLAIN} or {@code LOGIN} authentication. + * Note that this will return {@code null} when no authentication was performed or needed. + * @return The username from the AUTH command. + */ + public String getUsername() { + return username; + } + + /** + * Retrieves the password that was used for {@code PLAIN} or {@code LOGIN} authentication. + * Note that this will return {@code null} when no authentication was performed or needed. + * @return The password from the AUTH command. + */ + public String getPassword() { + return password; + } +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java new file mode 100644 index 000000000..d971e1c1a --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java @@ -0,0 +1,63 @@ +package com.icegreen.greenmail.smtp.auth; + +import com.icegreen.greenmail.smtp.commands.AuthCommand.AuthMechanism; +import com.icegreen.greenmail.util.SaslMessage; + +/** + * Details from the {@link AuthMechanism#PLAIN} authorization mechanism, when + * that mechanism was used for authentication. + */ +public class PlainAuthenticationState implements AuthenticationState, UsernameAuthentication { + private final String authorizationId; + private final String authenticationId; + private final String password; + + /** + * @param saslMessage The parsed message sent by the client with the {@code AUTH} command. + */ + public PlainAuthenticationState(SaslMessage saslMessage) { + this(saslMessage.getAuthzid(), saslMessage.getAuthcid(), saslMessage.getPasswd()); + } + + @Override + public String getType() { + return AuthMechanism.PLAIN.name(); + } + + /** + * @param authorizationId The authorization ID sent by the client with the {@code AUTH} command. + * @param authenticationId The authentication ID sent by the client with the {@code AUTH} command. + * @param password The password sent by the client with the {@code AUTH} command. + */ + public PlainAuthenticationState(String authorizationId, String authenticationId, String password) { + this.authorizationId = authorizationId; + this.authenticationId = authenticationId; + this.password = password; + } + + /** + * @return The authorization ID sent by the client with the {@code AUTH} command. + */ + public String getAuthorizationId() { + return authorizationId; + } + + /** + * @return The authentication ID sent by the client with the {@code AUTH} command. + */ + public String getAuthenticationId() { + return authenticationId; + } + + /** + * @return password The password sent by the client with the {@code AUTH} command. + */ + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return authenticationId; + } +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java new file mode 100644 index 000000000..182ff16c1 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java @@ -0,0 +1,10 @@ +package com.icegreen.greenmail.smtp.auth; + +/** + * Base interface for authentication methods that provide or make use of + * a plain text username to identify a user, such as the {@code PLAIN} or + * {@code LOGIN} authentication mechanisms. + */ +public interface UsernameAuthentication { + String getUsername(); +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java index 4a067b793..043002787 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java @@ -9,6 +9,9 @@ import com.icegreen.greenmail.smtp.SmtpConnection; import com.icegreen.greenmail.smtp.SmtpManager; import com.icegreen.greenmail.smtp.SmtpState; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.LoginAuthenticationState; +import com.icegreen.greenmail.smtp.auth.PlainAuthenticationState; import com.icegreen.greenmail.user.UserManager; import com.icegreen.greenmail.util.EncodingUtil; import com.icegreen.greenmail.util.SaslMessage; @@ -62,16 +65,16 @@ public void execute(SmtpConnection conn, SmtpState state, // Check auth mechanism final String authMechanismValue = commandParts[1]; if (AuthMechanism.LOGIN.name().equalsIgnoreCase(authMechanismValue)) { - authLogin(conn, manager, commandLine, commandParts, authMechanismValue); + authLogin(conn, state, manager, commandLine, commandParts, authMechanismValue); } else if (AuthMechanism.PLAIN.name().equalsIgnoreCase(authMechanismValue)) { - authPlain(conn, manager, commandParts); + authPlain(conn, state, manager, commandParts); } else { conn.send(SMTP_SYNTAX_ERROR + " : Unsupported auth mechanism " + authMechanismValue + ". Only auth mechanism <" + Arrays.toString(AuthMechanism.values()) + "> supported."); } } - private void authPlain(SmtpConnection conn, SmtpManager manager, String[] commandParts) throws IOException { + private void authPlain(SmtpConnection conn, SmtpState state, SmtpManager manager, String[] commandParts) throws IOException { // Continuation? String initialResponse; if (commandParts.length == 2) { @@ -80,8 +83,12 @@ private void authPlain(SmtpConnection conn, SmtpManager manager, String[] comman } else { initialResponse = commandParts[2]; } + + SaslMessage saslMessage = parseInitialResponse(initialResponse); + AuthenticationState authenticationContext = new PlainAuthenticationState(saslMessage); + state.getMessage().setAuthenticationState(authenticationContext); - if (authenticate(manager.getUserManager(), EncodingUtil.decodeBase64(initialResponse))) { + if (authenticate(manager.getUserManager(), saslMessage)) { conn.setAuthenticated(true); conn.send(AUTH_SUCCEDED); } else { @@ -89,8 +96,8 @@ private void authPlain(SmtpConnection conn, SmtpManager manager, String[] comman } } - private void authLogin(SmtpConnection conn, SmtpManager manager, String commandLine, String[] commandParts, - String authMechanismValue) throws IOException { + private void authLogin(SmtpConnection conn, SmtpState state, SmtpManager manager, String commandLine, + String[] commandParts, String authMechanismValue) throws IOException { // https://www.samlogic.net/articles/smtp-commands-reference-auth.htm if (commandParts.length != 2) { conn.send(SMTP_SYNTAX_ERROR + " : Unsupported auth mechanism " + authMechanismValue + @@ -100,8 +107,12 @@ private void authLogin(SmtpConnection conn, SmtpManager manager, String commandL String username = conn.readLine(); conn.send(SMTP_SERVER_CONTINUATION + "UGFzc3dvcmQ6"); // "Password:" String pwd = conn.readLine(); + String plainUsername = EncodingUtil.decodeBase64(username); + String plainPwd = EncodingUtil.decodeBase64(pwd); + AuthenticationState authenticationContext = new LoginAuthenticationState(plainUsername, plainPwd); + state.getMessage().setAuthenticationState(authenticationContext); - if (manager.getUserManager().test(EncodingUtil.decodeBase64(username), EncodingUtil.decodeBase64(pwd))) { + if (manager.getUserManager().test(plainUsername, plainPwd)) { conn.setAuthenticated(true); conn.send(AUTH_SUCCEDED); } else { @@ -110,11 +121,15 @@ private void authLogin(SmtpConnection conn, SmtpManager manager, String commandL } } - private boolean authenticate(UserManager userManager, String value) { - // authorization-id\0authentication-id\0passwd - final SaslMessage saslMessage = SaslMessage.parse(value); + private boolean authenticate(UserManager userManager, SaslMessage saslMessage) { return userManager.test(saslMessage.getAuthcid(), saslMessage.getPasswd()); } + + private SaslMessage parseInitialResponse(String initialReponse) { + String value = EncodingUtil.decodeBase64(initialReponse); + // authorization-id\0authentication-id\0passwd + return SaslMessage.parse(value); + } private static String getValuesWsSeparated() { StringBuilder buf = new StringBuilder(); diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java index 519149cc1..f26a04246 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java @@ -45,11 +45,11 @@ public void execute(SmtpConnection conn, SmtpState state, conn.send(err); return; } - state.clearMessage(); + state.clearMessagePreservingAuthenticationState(); state.getMessage().setReturnPath(fromAddr); conn.send("250 OK"); } else { - state.clearMessage(); + state.clearMessagePreservingAuthenticationState(); state.getMessage(); conn.send("250 OK"); } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java b/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java index dd5fa8426..a3ef9e340 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java @@ -266,6 +266,21 @@ public static void sendMimeMessage(MimeMessage mimeMessage) { } } + /** + * Send the message using the JavaMail session defined in the message + * + * @param mimeMessage Message to send + * @param username Username for authentication. + * @param password Password for authentication. + */ + public static void sendMimeMessage(MimeMessage mimeMessage, String username, String password) { + try { + Transport.send(mimeMessage, username, password); + } catch (MessagingException e) { + throw new IllegalStateException("Can not send message " + mimeMessage, e); + } + } + /** * Send the message with the given attributes and the given body using the specified SMTP settings * diff --git a/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java b/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java new file mode 100644 index 000000000..5d1d2c1ba --- /dev/null +++ b/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java @@ -0,0 +1,87 @@ +package com.icegreen.greenmail.examples; + +import com.icegreen.greenmail.junit.GreenMailRule; +import com.icegreen.greenmail.mail.MailAddress; +import com.icegreen.greenmail.mail.MovingMessage; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.UsernameAuthentication; +import com.icegreen.greenmail.store.FolderException; +import com.icegreen.greenmail.store.MailFolder; +import com.icegreen.greenmail.user.GreenMailUser; +import com.icegreen.greenmail.user.MessageDeliveryHandler; +import com.icegreen.greenmail.user.NoSuchUserException; +import com.icegreen.greenmail.user.UserException; +import com.icegreen.greenmail.user.UserManager; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetupTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.mail.Message.RecipientType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +/** + * By default, GreenMail delivers messages to the user based on the email address + * to which the mail was sent. + *

+ * This example illustrates how you can use a custom message delivery handler to deliver + * messages based on the login that was used to authenticate against the mail server. + */ +public class ExampleFindUserByAuthLoginTest { + public GreenMail greenMail; + + @Test + public void testSend() throws MessagingException, UserException, FolderException { + final UserManager userManager = greenMail.getUserManager(); + // Create a new user + userManager.createUser("from@localhost", "login", "pass"); + + // Set a message delivery handler that find the user and inbox by + // the login that was used. + userManager.setMessageDeliveryHandler(new MessageDeliveryHandler() { + @Override + public GreenMailUser handle(MovingMessage msg, MailAddress mailAddress) + throws MessagingException, UserException { + AuthenticationState authState = msg.getAuthenticationState(); + if (!(authState instanceof UsernameAuthentication)) { + throw new MessagingException("Authentication is required"); + } + String login = ((UsernameAuthentication)authState).getUsername(); + GreenMailUser user = userManager.getUser(login); + if (user == null) { + throw new NoSuchUserException("No user found for login " + login + ", make sure to create the user first"); + } + return user; + } + }); + + // Send a mail with an arbitrary FROM / TO address + MimeMessage message = GreenMailUtil.createTextEmail("john@example.com", "mary@example.com", + "some subject", "some body", ServerSetupTest.SMTP); // --- Place your sending code here instead + GreenMailUtil.sendMimeMessage(message, "login", "pass"); + + // Check that the mail was still sent to the user we created. + GreenMailUser user = greenMail.getUserManager().getUser("login"); + MailFolder inbox = greenMail.getManagers().getImapHostManager().getInbox(user); + assertThat(inbox.getMessages().get(0).getMimeMessage().getSubject()).isEqualTo("some subject"); + assertThat(inbox.getMessages().get(0).getMimeMessage().getRecipients(RecipientType.TO)[0].toString()).isEqualTo("john@example.com"); + assertThat(inbox.getMessages().get(0).getMimeMessage().getFrom()[0].toString()).isEqualTo("mary@example.com"); + } + + @Before + public void setupMail() { + greenMail = new GreenMail(ServerSetupTest.SMTP); + greenMail.start(); + } + + @After + public void tearDownMail() { + greenMail.stop(); + } +}