From 14ad55ba1364331a7bc629563688e319490e2bd5 Mon Sep 17 00:00:00 2001 From: Marcel May Date: Fri, 3 Apr 2020 16:22:53 +0200 Subject: [PATCH] Support for POP3 SASL Plain (fixes #301) --- .../icegreen/greenmail/pop3/Pop3Handler.java | 28 ++-- .../icegreen/greenmail/pop3/Pop3State.java | 3 +- .../greenmail/pop3/commands/AuthCommand.java | 139 ++++++++++++++++++ .../greenmail/pop3/commands/CapaCommand.java | 1 + .../greenmail/pop3/commands/Pop3Command.java | 4 + .../pop3/commands/Pop3CommandRegistry.java | 1 + .../greenmail/smtp/commands/AuthCommand.java | 31 ++-- .../icegreen/greenmail/util/EncodingUtil.java | 11 ++ .../test/commands/POP3CommandTest.java | 78 ++++++++++ 9 files changed, 261 insertions(+), 35 deletions(-) create mode 100644 greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/AuthCommand.java create mode 100644 greenmail-core/src/test/java/com/icegreen/greenmail/test/commands/POP3CommandTest.java diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3Handler.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3Handler.java index 9eac10dd6a..d1a11cc24e 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3Handler.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3Handler.java @@ -6,20 +6,21 @@ */ package com.icegreen.greenmail.pop3; +import java.io.IOException; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.StringTokenizer; import com.icegreen.greenmail.pop3.commands.Pop3Command; import com.icegreen.greenmail.pop3.commands.Pop3CommandRegistry; import com.icegreen.greenmail.server.BuildInfo; import com.icegreen.greenmail.server.ProtocolHandler; import com.icegreen.greenmail.user.UserManager; - -import java.io.IOException; -import java.net.Socket; -import java.net.SocketTimeoutException; -import java.util.StringTokenizer; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Pop3Handler implements ProtocolHandler { + protected final Logger log = LoggerFactory.getLogger(getClass()); Pop3CommandRegistry registry; Pop3Connection conn; UserManager manager; @@ -52,12 +53,16 @@ public void run() { conn.close(); } catch (SocketTimeoutException ste) { conn.println("421 Service shutting down and closing transmission channel"); - } catch (Exception e) { + if (!quitting) { + log.error("Can not handle POP3 connection", e); + throw new IllegalStateException("Can not handle POP3 connection", e); + } } finally { try { socket.close(); } catch (IOException ioe) { + // Nothing } } @@ -77,20 +82,17 @@ void handleCommand() return; } - String commandName = new StringTokenizer(currentLine, " ").nextToken() - .toUpperCase(); + String commandName = new StringTokenizer(currentLine, " ").nextToken().toUpperCase(); Pop3Command command = registry.getCommand(commandName); if (command == null) { conn.println("-ERR Command not recognized"); - return; } if (!command.isValidForState(state)) { conn.println("-ERR Command not valid for this state"); - return; } @@ -99,12 +101,12 @@ void handleCommand() @Override public void close() { - quitting = true; + quitting = true; try { if (socket != null && !socket.isClosed()) { socket.close(); } - } catch(IOException ignored) { + } catch (IOException ignored) { //empty } } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3State.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3State.java index 00e9cb8cde..89cb3c54e4 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3State.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/Pop3State.java @@ -34,7 +34,7 @@ public GreenMailUser getUser() { public GreenMailUser getUser(String username) throws UserException { GreenMailUser user = manager.getUser(username); if (null == user) { - throw new NoSuchUserException(username + " doesn't exist"); + throw new NoSuchUserException("User <" + username + "> doesn't exist"); } return user; } @@ -57,7 +57,6 @@ public void authenticate(String pass) } public MailFolder getFolder() { - return inbox; } } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/AuthCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/AuthCommand.java new file mode 100644 index 0000000000..ad8aadae71 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/AuthCommand.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2014 Wael Chatila / Icegreen Technologies. All Rights Reserved. + * This software is released under the Apache license 2.0 + * This file has been used and modified. + * Original file can be found on http://foedus.sourceforge.net + */ +package com.icegreen.greenmail.pop3.commands; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import com.icegreen.greenmail.pop3.Pop3Connection; +import com.icegreen.greenmail.pop3.Pop3State; +import com.icegreen.greenmail.store.FolderException; +import com.icegreen.greenmail.user.GreenMailUser; +import com.icegreen.greenmail.user.UserException; +import com.icegreen.greenmail.util.EncodingUtil; +import com.sun.mail.util.BASE64DecoderStream; + +/** + * SASL : PLAIN + *

+ * https://tools.ietf.org/html/rfc5034 + *

+ * AUTH mechanism [initial-response] + * mechanism PLAIN : See https://tools.ietf.org/html/rfc4616 + */ +public class AuthCommand + extends Pop3Command { + + public static final String CONTINUATION = "+ "; + + @Override + public boolean isValidForState(Pop3State state) { + + return !state.isAuthenticated(); + } + + public enum Pop3SaslAuthMechanism { + PLAIN; + + /** + * WS separated list of supported auth mechanism. + * + * @return a list of supported auth mechanism. + */ + static String list() { + StringBuilder buf = new StringBuilder(); + for (Pop3SaslAuthMechanism mechanism : values()) { + if (buf.length() > 0) { + buf.append(' '); + } + buf.append(mechanism.name()); + } + return buf.toString(); + } + } + + @Override + public void execute(Pop3Connection conn, Pop3State state, String cmd) { + if (state.isAuthenticated()) { + conn.println("-ERR Already authenticated"); + return; + } + + String[] args = cmd.split(" "); + if (args.length < 2) { + conn.println("-ERR Required syntax: AUTH mechanism [initial-response]"); + return; + } + + String mechanism = args[1]; + if (Pop3SaslAuthMechanism.PLAIN.name().equalsIgnoreCase(mechanism)) { + authPlain(conn, state, args); + } else { + conn.println("-ERR Required syntax: AUTH mechanism <" + mechanism + + "> not supported, expected one of " + Arrays.toString(Pop3SaslAuthMechanism.values())); + } + } + + private void authPlain(Pop3Connection conn, Pop3State state, String[] args) { + // https://tools.ietf.org/html/rfc4616 + String initialResponse; + if (args.length == 2 || args.length == 3 && "=".equals(args[2])) { // Continuation? + conn.println(CONTINUATION); + try { + initialResponse = conn.readLine(); + } catch (IOException e) { + conn.println("-ERR Invalid syntax, expected continuation with iniital-response"); + return; + } + } else if (args.length == 3) { + initialResponse = args[2]; + } else { + conn.println("-ERR Invalid syntax, expected initial-response : AUTH PLAIN [initial-response]"); + return; + } + + // authorization-id\0authentication-id\0passwd + final BASE64DecoderStream stream = new BASE64DecoderStream( + new ByteArrayInputStream(initialResponse.getBytes(StandardCharsets.UTF_8))); + readTillNullChar(stream); // authorizationId Not used + String authenticationId = readTillNullChar(stream); + + GreenMailUser user; + try { + user = state.getUser(authenticationId); + state.setUser(user); + } catch (UserException e) { + log.error("Can not get user <" + authenticationId + ">", e); + conn.println("-ERR Authentication failed: " + e.getMessage() /* GreenMail is just a test server */); + return; + } + + try { + state.authenticate(readTillNullChar(stream)); + conn.println("+OK"); + } catch (UserException e) { + log.error("Can not authenticate using user <" + user.getLogin() + ">", e); + conn.println("-ERR Authentication failed: " + e.getMessage()); + } catch (FolderException e) { + log.error("Can not authenticate using user " + user + ", internal error", e); + conn.println("-ERR Authentication failed, internal error: " + e.getMessage()); + } + } + + + @Deprecated // Remove once JDK baseline is 1.8 + private String readTillNullChar(BASE64DecoderStream stream) { + try { + return EncodingUtil.readTillNullChar(stream); + } catch (IOException e) { + log.error("Can not decode", e); + return null; + } + } +} \ No newline at end of file diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/CapaCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/CapaCommand.java index c4d9bff5fa..a6ee207e4d 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/CapaCommand.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/CapaCommand.java @@ -37,6 +37,7 @@ public void execute(Pop3Connection conn, Pop3State state, String cmd) { // We don't support any additional capabilities conn.println("+OK"); conn.println("UIDL"); + conn.println("SASL "+ AuthCommand.Pop3SaslAuthMechanism.list()); conn.println("."); } } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3Command.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3Command.java index a361c06e48..c998ba3a18 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3Command.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3Command.java @@ -8,9 +8,13 @@ import com.icegreen.greenmail.pop3.Pop3Connection; import com.icegreen.greenmail.pop3.Pop3State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public abstract class Pop3Command { + protected final Logger log = LoggerFactory.getLogger(getClass()); + public abstract boolean isValidForState(Pop3State state); public abstract void execute(Pop3Connection conn, Pop3State state, diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3CommandRegistry.java b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3CommandRegistry.java index 9ccd3a93ee..2176dd4441 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3CommandRegistry.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/pop3/commands/Pop3CommandRegistry.java @@ -27,6 +27,7 @@ public class Pop3CommandRegistry { commands.put("NOOP", new NoopCommand()); commands.put("RSET", new RsetCommand()); commands.put("CAPA", new CapaCommand()); + commands.put("AUTH", new AuthCommand()); } public Pop3Command getCommand(String name) { 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 ef9cccd81c..74a575c545 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 @@ -75,8 +75,6 @@ public void execute(SmtpConnection conn, SmtpState state, } private void authPlain(SmtpConnection conn, SmtpManager manager, String[] commandParts) throws IOException { - AuthMechanism authMechanism = AuthMechanism.PLAIN; - // Continuation? String initialResponse; if (commandParts.length == 2) { @@ -86,7 +84,7 @@ private void authPlain(SmtpConnection conn, SmtpManager manager, String[] comman initialResponse = commandParts[2]; } - if (authenticate(manager.getUserManager(), authMechanism, initialResponse)) { + if (authenticate(manager.getUserManager(), initialResponse)) { conn.setAuthenticated(true); conn.send(AUTH_SUCCEDED); } else { @@ -114,27 +112,20 @@ private void authLogin(SmtpConnection conn, SmtpManager manager, String commandL } } - private boolean authenticate(UserManager userManager, AuthMechanism authMechanism, String value) { - if (AuthMechanism.PLAIN == authMechanism) { - // authorization-id\0authentication-id\0passwd - final BASE64DecoderStream stream = new BASE64DecoderStream( - new ByteArrayInputStream(value.getBytes(StandardCharsets.US_ASCII))); - readTillNullChar(stream); // authorizationId Not used - String authenticationId = readTillNullChar(stream); - String passwd = readTillNullChar(stream); - return userManager.test(authenticationId, passwd); - } - return false; + private boolean authenticate(UserManager userManager, String value) { + // authorization-id\0authentication-id\0passwd + final BASE64DecoderStream stream = new BASE64DecoderStream( + new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8))); + readTillNullChar(stream); // authorizationId Not used + String authenticationId = readTillNullChar(stream); + String passwd = readTillNullChar(stream); + return userManager.test(authenticationId, passwd); } @Deprecated // Remove once JDK baseline is 1.8 private String readTillNullChar(BASE64DecoderStream stream) { try { - StringBuilder buf = new StringBuilder(); - for (int chr = stream.read(); chr != '\0' && chr > 0; chr = stream.read()) { - buf.append((char) chr); - } - return buf.toString(); + return EncodingUtil.readTillNullChar(stream); } catch (IOException e) { log.error("Can not decode", e); return null; @@ -144,7 +135,7 @@ private String readTillNullChar(BASE64DecoderStream stream) { private static String getValuesWsSeparated() { StringBuilder buf = new StringBuilder(); for (AuthMechanism mechanism : AuthMechanism.values()) { - if(buf.length()>0) { + if (buf.length() > 0) { buf.append(' '); } buf.append(mechanism); diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/util/EncodingUtil.java b/greenmail-core/src/main/java/com/icegreen/greenmail/util/EncodingUtil.java index 0c76f99896..52b7a5dc78 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/util/EncodingUtil.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/util/EncodingUtil.java @@ -8,6 +8,8 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +import com.sun.mail.util.BASE64DecoderStream; + /** * Helper for handling encodings. */ @@ -60,4 +62,13 @@ public static String toString(InputStream is, Charset charset) { public static String decodeBase64(String encoded) { return new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8); } + + @Deprecated // Remove once JDK baseline is 1.8 + public static String readTillNullChar(BASE64DecoderStream stream) throws IOException { + StringBuilder buf = new StringBuilder(); + for (int chr = stream.read(); chr != '\u0000' && chr > 0; chr = stream.read()) { + buf.append((char) chr); + } + return buf.toString(); + } } diff --git a/greenmail-core/src/test/java/com/icegreen/greenmail/test/commands/POP3CommandTest.java b/greenmail-core/src/test/java/com/icegreen/greenmail/test/commands/POP3CommandTest.java new file mode 100644 index 0000000000..5a3f0122f1 --- /dev/null +++ b/greenmail-core/src/test/java/com/icegreen/greenmail/test/commands/POP3CommandTest.java @@ -0,0 +1,78 @@ +package com.icegreen.greenmail.test.commands; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.net.Socket; +import javax.mail.MessagingException; + +import com.icegreen.greenmail.junit.GreenMailRule; +import com.icegreen.greenmail.pop3.commands.AuthCommand; +import com.icegreen.greenmail.user.UserException; +import com.icegreen.greenmail.util.ServerSetupTest; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class POP3CommandTest { + private static final String CRLF = "\r\n"; + + @Rule + public final GreenMailRule greenMail = new GreenMailRule(ServerSetupTest.POP3); + + private int port; + private String hostAddress; + + @Before + public void setUp() { + hostAddress = greenMail.getPop3().getBindTo(); + port = greenMail.getPop3().getPort(); + } + + @Test + public void authPlain() throws IOException, MessagingException, UserException { + try (Socket socket = new Socket(hostAddress, port)) { + assertThat(socket.isConnected(), is(equalTo(true))); + PrintStream printStream = new PrintStream(socket.getOutputStream()); + final BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + + // No such user + assertThat(reader.readLine(), is(startsWith("+OK POP3 GreenMail Server v"))); + printStream.print("AUTH PLAIN dGVzdAB0ZXN0AHRlc3RwYXNz" + CRLF /* test / test / testpass */); + assertThat(reader.readLine(), is(equalTo("-ERR Authentication failed: User doesn't exist"))); + + greenMail.getManagers().getUserManager().createUser("test@localhost", "test", "testpass"); + + // Invalid pwd + printStream.print("AUTH PLAIN dGVzdAB0ZXN0AHRlc3RwY" + CRLF /* test / test / */); + assertThat(reader.readLine(), is(equalTo("-ERR Authentication failed: Invalid password"))); + + // Successful auth + printStream.print("AUTH PLAIN dGVzdAB0ZXN0AHRlc3RwYXNz" + CRLF /* test / test / */); + assertThat(reader.readLine(), is(equalTo("+OK"))); + } + } + + @Test + public void authPlainWithContinuation() throws IOException, UserException { + try (Socket socket = new Socket(hostAddress, port)) { + assertThat(socket.isConnected(), is(equalTo(true))); + PrintStream printStream = new PrintStream(socket.getOutputStream()); + final BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + greenMail.getManagers().getUserManager().createUser("test@localhost", "test", "testpass"); + + assertThat(reader.readLine(), is(startsWith("+OK POP3 GreenMail Server v"))); + printStream.print("AUTH PLAIN" + CRLF /* test / test / testpass */); + assertThat(reader.readLine(), is(equalTo(AuthCommand.CONTINUATION))); + printStream.print("dGVzdAB0ZXN0AHRlc3RwYXNz" + CRLF /* test / test / */); + assertThat(reader.readLine(), is(equalTo("+OK"))); + } + } + +}