diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index 65ab5254fb8..5028910546c 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -498,6 +498,9 @@ include::partial$configure/smtp-limitation-hook.adoc[] Declarative authentication. +`ConfigurationAuthHook` is deprecated. Existing handler-chain registrations remain supported through the SMTP SASL +adapter, while new authentication logic should use a dedicated SASL mechanism factory. + It is possible to open and configure on a dedicated port (eg port 26) to accept application traffic in parallel of user traffic. Authentication is then done on the supplied configuration. @@ -529,4 +532,4 @@ user accounts for those applications) -.... \ No newline at end of file +.... diff --git a/docs/modules/servers/partials/customization/smtp-hooks.adoc b/docs/modules/servers/partials/customization/smtp-hooks.adoc index 8b27377659a..207fec5b05d 100644 --- a/docs/modules/servers/partials/customization/smtp-hooks.adoc +++ b/docs/modules/servers/partials/customization/smtp-hooks.adoc @@ -5,7 +5,8 @@ enqueued in the MailQueue, and before any mail processing takes place. The following interfaces allows interacting with the following commands: - * *AuthHook*: Implement this interfaces to hook in the AUTH Command. + * *AuthHook*: Legacy hook for the AUTH Command. Prefer implementing a SASL mechanism factory for new authentication + logic, or a `SaslAuthResultHook` when only post-authentication side effects are needed. .... HookResult doAuth(SMTPSession session, Username username, String password); @@ -52,6 +53,9 @@ The following interfaces allows interacting with the following commands: Register you hooks using xref:{pages-path}/configure/smtp.adoc[*smtpserver.xml*] handlerchain property. +For AUTH, `AuthHook` is deprecated. Existing handler-chain password hooks remain supported through the SMTP SASL +adapter while keeping `AuthCmdHandler` on the SASL exchange path. + == Writing additional SMTP commands What to do if the Hook API is not enough for you ? diff --git a/examples/custom-imap/README.md b/examples/custom-imap/README.md index 7d0eee03256..750ce1b7bee 100644 --- a/examples/custom-imap/README.md +++ b/examples/custom-imap/README.md @@ -14,6 +14,26 @@ Sample configure file: [imapserver.xml](./sample-configuration/imapserver.xml) Note that when `imapPackages` is not provided, James will implicit use `org.apache.James.modules.protocols.DefaultImapPackage` +# Creating your own IMAP SASL mechanisms + +This example also demonstrates how to add a custom IMAP SASL mechanism. +The `EXAMPLE-TOKEN` mechanism is declared through `auth.saslMechanisms`, +while `auth.exampleToken` is a custom configuration block owned by the extension: + +```xml + + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory + + secret-token + bob@domain.tld + + +``` + +`auth.saslMechanisms` lists SASL mechanism factory classes. Built-in factories +can use simple names, while custom factories use their fully qualified class name. +The factory reads that server's `auth.exampleToken` block. + ## Running the example Build the project: @@ -56,4 +76,59 @@ a02 OK LOGIN completed. A03 PING * PONG A03 OK PING completed. +A04 LOGOUT +``` + +Test the custom SASL mechanism: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 CAPABILITY +* CAPABILITY IMAP4rev1 AUTH=PLAIN SASL-IR AUTH=EXAMPLE-TOKEN PING +A01 OK CAPABILITY completed. +A02 AUTHENTICATE EXAMPLE-TOKEN c2VjcmV0LXRva2Vu +A02 OK AUTHENTICATE completed. +A03 PING +* PONG +A03 OK PING completed. +``` + +The custom SASL mechanism also supports a continuation when the client does not send +the initial response in the `AUTHENTICATE` command. The continuation payload is +base64-encoded by IMAP, so `R28gYWhlYWQ` decodes to `Go ahead`: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 AUTHENTICATE EXAMPLE-TOKEN ++ R28gYWhlYWQ +c2VjcmV0LXRva2Vu +A01 OK AUTHENTICATE completed. +A02 PING +* PONG +A02 OK PING completed. +``` + +The mechanism can also return final server data on success. The client acknowledges +that final data with an empty line before James sends the tagged `OK`: + +```bash +telnet localhost 143 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +* OK JAMES IMAP4rev1 Server james.local is ready. +A01 AUTHENTICATE EXAMPLE-TOKEN ++ R28gYWhlYWQ +c2VjcmV0LXRva2VuOnNlcnZlci1kYXRh ++ VG9rZW4gYWNjZXB0ZWQ= + +A01 OK AUTHENTICATE completed. ``` diff --git a/examples/custom-imap/pom.xml b/examples/custom-imap/pom.xml index bbdfc87b089..a4665f87dac 100644 --- a/examples/custom-imap/pom.xml +++ b/examples/custom-imap/pom.xml @@ -67,6 +67,12 @@ ${james.baseVersion} provided + + ${james.protocols.groupId} + protocols-api + ${james.baseVersion} + provided + com.google.inject guice diff --git a/examples/custom-imap/sample-configuration/imapserver.xml b/examples/custom-imap/sample-configuration/imapserver.xml index 3333910586a..f1df338c0df 100644 --- a/examples/custom-imap/sample-configuration/imapserver.xml +++ b/examples/custom-imap/sample-configuration/imapserver.xml @@ -33,6 +33,13 @@ under the License. 0 false false + + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java new file mode 100644 index 00000000000..6bf0c219cc6 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.core.Username; + +public record ExampleTokenSaslConfiguration(String expectedToken, Username authorizedUser) { + private static final String EXPECTED_TOKEN_PROPERTY = "auth.exampleToken.expectedToken"; + private static final String AUTHORIZED_USER_PROPERTY = "auth.exampleToken.authorizedUser"; + + public static ExampleTokenSaslConfiguration from(HierarchicalConfiguration configuration) throws ConfigurationException { + if (!configuration.containsKey(EXPECTED_TOKEN_PROPERTY)) { + throw new ConfigurationException(EXPECTED_TOKEN_PROPERTY + " is mandatory"); + } + if (!configuration.containsKey(AUTHORIZED_USER_PROPERTY)) { + throw new ConfigurationException(AUTHORIZED_USER_PROPERTY + " is mandatory"); + } + + return new ExampleTokenSaslConfiguration( + configuration.getString(EXPECTED_TOKEN_PROPERTY), + Username.of(configuration.getString(AUTHORIZED_USER_PROPERTY))); + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java new file mode 100644 index 00000000000..6a8943a01b5 --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java @@ -0,0 +1,99 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; + +public class ExampleTokenSaslMechanism implements SaslMechanism { + public static final String NAME = "EXAMPLE-TOKEN"; + public static final String CONTINUATION_PROMPT = "Go ahead"; + public static final String SUCCESS_DATA_TOKEN_SUFFIX = ":server-data"; + public static final String SUCCESS_DATA = "Token accepted"; + + private final ExampleTokenSaslConfiguration configuration; + + public ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public String name() { + return NAME; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + Optional initialResponse = request.initialResponse(); + return new ExampleTokenSaslExchange(initialResponse, configuration); + } + + private static class ExampleTokenSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final ExampleTokenSaslConfiguration configuration; + + private ExampleTokenSaslExchange(Optional initialResponse, ExampleTokenSaslConfiguration configuration) { + this.initialResponse = initialResponse; + this.configuration = configuration; + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.of(CONTINUATION_PROMPT + .getBytes(StandardCharsets.UTF_8)))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + String token = new String(clientResponse, StandardCharsets.UTF_8); + if (configuration.expectedToken().equals(token)) { + return success(Optional.empty()); + } + // allow client to request server to return data on success message, which may be used by Kerberos auth + if ((configuration.expectedToken() + SUCCESS_DATA_TOKEN_SUFFIX).equals(token)) { + return success(Optional.of(SUCCESS_DATA.getBytes(StandardCharsets.UTF_8))); + } + return new SaslStep.Failure(SaslFailure.authenticationFailed(Optional.empty(), Optional.of(configuration.authorizedUser()), + "EXAMPLE-TOKEN authentication failed.")); + } + + private SaslStep success(Optional serverData) { + return new SaslStep.Success(new SaslIdentity(configuration.authorizedUser(), configuration.authorizedUser()), serverData); + } + } +} diff --git a/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java new file mode 100644 index 00000000000..57895b789de --- /dev/null +++ b/examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +public class ExampleTokenSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + return new ExampleTokenSaslMechanism(ExampleTokenSaslConfiguration.from(serverConfiguration)); + } +} diff --git a/examples/custom-imap/src/main/resources/imapserver.xml b/examples/custom-imap/src/main/resources/imapserver.xml index 9f5ec0e98b9..3ffc60b05a6 100644 --- a/examples/custom-imap/src/main/resources/imapserver.xml +++ b/examples/custom-imap/src/main/resources/imapserver.xml @@ -36,6 +36,13 @@ under the License. pong.response=customImapParameter prop.b=anotherValue false + + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages @@ -55,7 +62,14 @@ under the License. pong.response=bad prop.b=baad false + + PlainSaslMechanismFactory,org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanismFactory + + secret-token + bob@domain.tld + + org.apache.james.modules.protocols.DefaultImapPackage org.apache.james.examples.imap.PingImapPackages - \ No newline at end of file + diff --git a/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java new file mode 100644 index 00000000000..41fb770b2ca --- /dev/null +++ b/examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java @@ -0,0 +1,209 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.examples.imap; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.apache.james.jmap.JMAPTestingConstants.BOB; +import static org.apache.james.jmap.JMAPTestingConstants.BOB_PASSWORD; +import static org.apache.james.jmap.JMAPTestingConstants.DOMAIN; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.function.Predicate; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.MemoryJamesConfiguration; +import org.apache.james.MemoryJamesServerMain; +import org.apache.james.examples.imap.sasl.ExampleTokenSaslMechanism; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.TestIMAPClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class CustomSaslMechanismTest { + private static class ClientConnection implements AutoCloseable { + private final Socket socket; + private final BufferedReader reader; + private final BufferedWriter writer; + + private ClientConnection(String host, int port) throws IOException { + socket = new Socket(); + socket.connect(new InetSocketAddress(host, port)); + socket.setSoTimeout(5_000); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)); + writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII)); + } + + private void writeLine(String line) throws IOException { + writer.write(line); + writer.write("\r\n"); + writer.flush(); + } + + private String readUntil(Predicate condition) throws IOException { + StringBuilder response = new StringBuilder(); + while (true) { + String line = reader.readLine(); + if (line == null) { + throw new EOFException("Connection closed while waiting for IMAP response"); + } + response.append(line).append("\n"); + if (condition.test(line)) { + return response.toString(); + } + } + } + + @Override + public void close() throws IOException { + socket.close(); + } + } + + private static final String EXPECTED_TOKEN = "secret-token"; + private static final String LOCALHOST_IP = "127.0.0.1"; + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + MemoryJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(MemoryJamesServerMain::createServer) + .build(); + + @BeforeEach + void setup(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(BOB.asString(), BOB_PASSWORD); + } + + @Test + void imapServerShouldAdvertiseCustomSaslMechanism(GuiceJamesServer server) throws IOException { + assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server)) + .sendCommand("CAPABILITY")) + .contains("AUTH=PLAIN", "AUTH=EXAMPLE-TOKEN"); + } + + @Test + void imapServerShouldAuthenticateCustomSaslMechanismUsingOwnConfiguration(GuiceJamesServer server) throws IOException { + TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); + + assertThat(client.sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode(EXPECTED_TOKEN))) + .contains("OK AUTHENTICATE completed."); + assertThat(client.sendCommand("PING")) + .contains("PONG"); + } + + @Test + void imapServerShouldAuthenticateCustomSaslMechanismUsingContinuation(GuiceJamesServer server) throws IOException { + try (ClientConnection client = clientConnection(server)) { + client.readUntil(line -> line.startsWith("* OK")); + + client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN"); + assertThat(client.readUntil(line -> line.startsWith("+"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + client.writeLine(encode(EXPECTED_TOKEN)); + String authenticationResponse = client.readUntil(line -> line.startsWith("A01")); + assertThat(authenticationResponse) + .contains("OK AUTHENTICATE completed.") + .doesNotContain("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + client.writeLine("A02 PING"); + assertThat(client.readUntil(line -> line.startsWith("A02"))) + .contains("PONG"); + } + } + + @Test + void imapServerShouldAuthenticateCustomSaslMechanismReturningServerDataOnSuccess(GuiceJamesServer server) throws IOException { + try (ClientConnection client = clientConnection(server)) { + // GIVEN a custom SASL exchange started without SASL-IR + client.readUntil(line -> line.startsWith("* OK")); + client.writeLine("A01 AUTHENTICATE EXAMPLE-TOKEN"); + assertThat(client.readUntil(line -> line.startsWith("+"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.CONTINUATION_PROMPT)); + + // WHEN the mechanism succeeds with final server data, as GSSAPI/Kerberos-like SASL mechanisms may require + client.writeLine(encode(EXPECTED_TOKEN + ExampleTokenSaslMechanism.SUCCESS_DATA_TOKEN_SUFFIX)); + assertThat(client.readUntil(line -> line.startsWith("+") || line.startsWith("A01"))) + .contains("+ " + encode(ExampleTokenSaslMechanism.SUCCESS_DATA)); + + // THEN the client acknowledges the final server data before IMAP completes authentication + client.writeLine(""); + assertThat(client.readUntil(line -> line.startsWith("A01"))) + .contains("OK AUTHENTICATE completed."); + + // THEN the authenticated IMAP session remains usable + client.writeLine("A02 PING"); + assertThat(client.readUntil(line -> line.startsWith("A02"))) + .contains("PONG"); + } + } + + @Test + void plainSaslAuthenticationShouldStillWork(GuiceJamesServer server) throws IOException { + TestIMAPClient client = new TestIMAPClient().connect("127.0.0.1", imapPort(server)); + + assertThat(client.sendCommand("AUTHENTICATE PLAIN " + encodePlainInitialResponse())) + .contains("OK AUTHENTICATE completed."); + assertThat(client.sendCommand("PING")) + .contains("PONG"); + } + + @Test + void imapServerShouldRejectInvalidCustomSaslToken(GuiceJamesServer server) throws IOException { + assertThat(new TestIMAPClient().connect("127.0.0.1", imapPort(server)) + .sendCommand("AUTHENTICATE EXAMPLE-TOKEN " + encode("invalid-token"))) + .contains("NO AUTHENTICATE failed."); + } + + private int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + private ClientConnection clientConnection(GuiceJamesServer server) throws IOException { + return new ClientConnection(LOCALHOST_IP, imapPort(server)); + } + + private String encode(String token) { + return Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); + } + + private String encodePlainInitialResponse() { + return encode(BOB.asString() + "\0" + BOB.asString() + "\0" + BOB_PASSWORD); + } +} diff --git a/examples/custom-smtp-command/src/main/java/org/apache/james/examples/MyCmdHandlerLoader.java b/examples/custom-smtp-command/src/main/java/org/apache/james/examples/MyCmdHandlerLoader.java index 7b224ee9a67..eaa636127d7 100644 --- a/examples/custom-smtp-command/src/main/java/org/apache/james/examples/MyCmdHandlerLoader.java +++ b/examples/custom-smtp-command/src/main/java/org/apache/james/examples/MyCmdHandlerLoader.java @@ -48,7 +48,6 @@ import org.apache.james.smtpserver.JamesWelcomeMessageHandler; import org.apache.james.smtpserver.SendMailHandler; import org.apache.james.smtpserver.SenderAuthIdentifyVerificationHook; -import org.apache.james.smtpserver.UsersRepositoryAuthHook; /** * This class copies CoreCmdHandlerLoader adding support for MYNOOP command @@ -71,7 +70,6 @@ public class MyCmdHandlerLoader implements HandlersPackage { RsetCmdHandler.class.getName(), VrfyCmdHandler.class.getName(), MailSizeEsmtpExtension.class.getName(), - UsersRepositoryAuthHook.class.getName(), AuthRequiredToRelayRcptHook.class.getName(), SenderAuthIdentifyVerificationHook.class.getName(), PostmasterAbuseRcptHook.class.getName(), diff --git a/pom.xml b/pom.xml index a8abd2fef04..f033892b0d6 100644 --- a/pom.xml +++ b/pom.xml @@ -2151,6 +2151,11 @@ protocols-pop3 ${project.version} + + ${james.protocols.groupId} + protocols-sasl + ${project.version} + ${james.protocols.groupId} protocols-smtp diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java index 1623998c954..be557f1cac5 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java @@ -58,41 +58,43 @@ public String getAssociatedUser() { } public static Optional parse(String initialResponse) { - Optional decodeResult = decodeBase64(initialResponse); + return decodeBase64(initialResponse) + .flatMap(OIDCSASLParser::parseDecoded); + } - if (decodeResult.isPresent()) { - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - String decodeValueWithoutDanglingPart = decodeResult.filter(value -> value.startsWith("n,")) - .map(value -> value.substring(2)) - .orElse(decodeResult.get()); + public static Optional parseDecoded(String decodedInitialResponse) { + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + String decodeValueWithoutDanglingPart = Optional.of(decodedInitialResponse) + .filter(value -> value.startsWith("n,")) + .map(value -> value.substring(2)) + .orElse(decodedInitialResponse); - StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); - String tokenPart = null; - String userPart = null; - int tokenPartCounter = 0; - int userPartCounter = 0; + StringTokenizer stringTokenizer = new StringTokenizer(decodeValueWithoutDanglingPart, String.valueOf(SASL_SEPARATOR)); + String tokenPart = null; + String userPart = null; + int tokenPartCounter = 0; + int userPartCounter = 0; - while (stringTokenizer.hasMoreTokens()) { - String stringToken = stringTokenizer.nextToken(); - if (stringToken.startsWith(TOKEN_PART_PREFIX)) { - tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); - tokenPartCounter++; - } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { - userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); - userPartCounter++; - } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { - userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); - // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. - if (userPart.endsWith(",")) { - userPart = userPart.substring(0, userPart.length() - 1); - } - userPartCounter++; + while (stringTokenizer.hasMoreTokens()) { + String stringToken = stringTokenizer.nextToken(); + if (stringToken.startsWith(TOKEN_PART_PREFIX)) { + tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); + tokenPartCounter++; + } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { + userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); + userPartCounter++; + } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { + userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + if (userPart.endsWith(",")) { + userPart = userPart.substring(0, userPart.length() - 1); } + userPartCounter++; } + } - if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { - return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); - } + if (tokenPart != null && userPart != null && tokenPartCounter == 1 && userPartCounter == 1) { + return Optional.of(new OIDCInitialResponse(userPart, tokenPart)); } return Optional.empty(); } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java new file mode 100644 index 00000000000..f3c2c10a690 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +/** + * Result of protocol-neutral James authentication or authorization performed for a SASL mechanism. + */ +public sealed interface SaslAuthenticationResult permits SaslAuthenticationResult.Success, SaslAuthenticationResult.Failure { + + record Success(SaslIdentity identity) implements SaslAuthenticationResult { + } + + record Failure(SaslFailure failure) implements SaslAuthenticationResult { + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java new file mode 100644 index 00000000000..18b21d78108 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; + +/** + * Protocol-neutral authentication service available to SASL mechanisms. + */ +public interface SaslAuthenticator { + /** + * Verifies password credentials and, when present, validates that the authenticated user may act as the + * requested authorization identity. + */ + SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional authorizationId, + String password); + + /** + * Validates an already-authenticated identity, typically for token or Kerberos mechanisms that verified + * credentials inside the SASL exchange. + */ + SaslAuthenticationResult authorize(SaslIdentity identity); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java new file mode 100644 index 00000000000..49921941d21 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java @@ -0,0 +1,45 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +/** + * Stateful SASL authentication exchange. + */ +public interface SaslExchange extends AutoCloseable { + /** + * Starts the exchange and returns the first server step. + */ + SaslStep firstStep(); + + /** + * Continues the exchange with a decoded client response. + */ + SaslStep onResponse(byte[] clientResponse); + + /** + * Aborts the exchange after a client cancellation or protocol-level failure, and releases associated resources. + */ + default void abort() { + close(); + } + + @Override + void close(); +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java new file mode 100644 index 00000000000..798214d0740 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +import org.apache.james.core.Username; + +/** + * Protocol-neutral SASL failure with enough metadata for protocol-specific response mapping and audit logging. + */ +public record SaslFailure(Type type, + Optional authenticationId, + Optional authorizationId, + String reason, + Optional cause) { + public enum Type { + MALFORMED, + INVALID_CREDENTIALS, + AUTHENTICATION_FAILED, + USER_DOES_NOT_EXIST, + DELEGATION_FORBIDDEN, + SERVER_ERROR + } + + public static SaslFailure malformed(String reason) { + return new SaslFailure(Type.MALFORMED, Optional.empty(), Optional.empty(), reason, Optional.empty()); + } + + public static SaslFailure invalidCredentials(Username authenticationId, Optional authorizationId, String reason) { + return new SaslFailure(Type.INVALID_CREDENTIALS, Optional.of(authenticationId), authorizationId, reason, Optional.empty()); + } + + public static SaslFailure authenticationFailed(Optional authenticationId, Optional authorizationId, String reason) { + return new SaslFailure(Type.AUTHENTICATION_FAILED, authenticationId, authorizationId, reason, Optional.empty()); + } + + public static SaslFailure userDoesNotExist(Username authenticationId, Username authorizationId, String reason) { + return new SaslFailure(Type.USER_DOES_NOT_EXIST, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty()); + } + + public static SaslFailure delegationForbidden(Username authenticationId, Username authorizationId, String reason) { + return new SaslFailure(Type.DELEGATION_FORBIDDEN, Optional.of(authenticationId), Optional.of(authorizationId), reason, Optional.empty()); + } + + public static SaslFailure serverError(Optional authenticationId, Optional authorizationId, String reason, Throwable cause) { + return new SaslFailure(Type.SERVER_ERROR, authenticationId, authorizationId, reason, Optional.ofNullable(cause)); + } + + public static SaslFailure serverError(Optional authenticationId, Optional authorizationId, String reason) { + return new SaslFailure(Type.SERVER_ERROR, authenticationId, authorizationId, reason, Optional.empty()); + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java new file mode 100644 index 00000000000..7038651fcd5 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import org.apache.james.core.Username; + +/** + * SASL authentication and authorization identities. + * + * @param authenticationId identity proven by the SASL mechanism + * @param authorizationId identity requested for protocol access + */ +public record SaslIdentity(Username authenticationId, Username authorizationId) { +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java new file mode 100644 index 00000000000..4e245eebd18 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +/** + * Protocol-neutral initial SASL request. + * + * @param mechanismName requested SASL mechanism name + * @param initialResponse decoded initial client response, when supplied by the client + */ +public record SaslInitialRequest(String mechanismName, Optional initialResponse) { + public SaslInitialRequest(String mechanismName, Optional initialResponse) { + this.mechanismName = mechanismName; + this.initialResponse = initialResponse.map(byte[]::clone); + } + + /** + * Returns a defensive copy of the decoded initial client response. + */ + public Optional initialResponse() { + return initialResponse.map(byte[]::clone); + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java new file mode 100644 index 00000000000..3be6de623bd --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +/** + * Protocol-neutral SASL mechanism. + */ +public interface SaslMechanism { + /** + * Returns the SASL mechanism name advertised to clients. + */ + String name(); + + /** + * Starts a new SASL exchange for one client authentication attempt. + */ + SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator); + + /** + * Whether this mechanism may be used over the current transport. + * + * @param channelEncrypted whether the underlying transport is encrypted, for example with TLS. + */ + default boolean isAvailableOnTransport(boolean channelEncrypted) { + return true; + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java new file mode 100644 index 00000000000..77fb9e5e709 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; + +/** + * Creates a SASL mechanism for one server configuration block. + */ +public interface SaslMechanismFactory { + SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException; +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java new file mode 100644 index 00000000000..70e76d05039 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java @@ -0,0 +1,29 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +public final class SaslMechanismNames { + public static final String PLAIN = "PLAIN"; + public static final String OAUTHBEARER = "OAUTHBEARER"; + public static final String XOAUTH2 = "XOAUTH2"; + + private SaslMechanismNames() { + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java new file mode 100644 index 00000000000..37608f30d5a --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import java.util.Optional; + +/** + * Server step produced by a SASL exchange. + */ +public sealed interface SaslStep permits SaslStep.Challenge, SaslStep.Success, SaslStep.Failure { + /** + * Server challenge to send back to the client. + */ + record Challenge(Optional payload) implements SaslStep { + public Challenge(Optional payload) { + this.payload = payload.map(byte[]::clone); + } + + /** + * Returns a defensive copy of the decoded challenge payload. + */ + public Optional payload() { + return payload.map(byte[]::clone); + } + } + + /** + * Successful SASL exchange result. + */ + record Success(SaslIdentity identity, Optional serverData) implements SaslStep { + public Success(SaslIdentity identity, Optional serverData) { + this.identity = identity; + this.serverData = serverData.map(byte[]::clone); + } + + /** + * Returns a defensive copy of the decoded final server data. + */ + public Optional serverData() { + return serverData.map(byte[]::clone); + } + } + + /** + * Failed SASL exchange result. + */ + record Failure(SaslFailure failure) implements SaslStep { + } +} diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java new file mode 100644 index 00000000000..2b4054284a2 --- /dev/null +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java @@ -0,0 +1,249 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.api.sasl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.junit.jupiter.api.Test; + +/** + * Validates the shared SASL SPI shape with fake mechanisms before wiring real protocol mechanisms to it. + */ +class SaslMechanismContractTest { + private static final Username AUTHENTICATION_ID = Username.of("authentication@example.com"); + private static final Username AUTHORIZATION_ID = Username.of("authorization@example.com"); + private static final SaslIdentity SAME_USER_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID); + private static final SaslIdentity DELEGATED_IDENTITY = new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID); + private static final SaslAuthenticator NOOP_AUTHENTICATOR = new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + + /** + * Models one-step mechanisms that can immediately succeed or fail on the first server step. + */ + private static class FixedStepMechanism implements SaslMechanism { + private final SaslStep firstStep; + + private FixedStepMechanism(SaslStep firstStep) { + this.firstStep = firstStep; + } + + @Override + public String name() { + return "FIXED"; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new FixedStepExchange(firstStep); + } + } + + private static class FixedStepExchange implements SaslExchange { + private final SaslStep firstStep; + private boolean aborted; + private boolean closed; + + private FixedStepExchange(SaslStep firstStep) { + this.firstStep = firstStep; + } + + @Override + public SaslStep firstStep() { + return firstStep; + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return firstStep; + } + + @Override + public void abort() { + aborted = true; + } + + @Override + public void close() { + closed = true; + } + } + + /** + * Models challenge/response mechanisms where state must survive between client lines. + */ + private static class TwoStepMechanism implements SaslMechanism { + @Override + public String name() { + return "TWO_STEP"; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new TwoStepExchange(); + } + } + + private static class TwoStepExchange implements SaslExchange { + private boolean challenged; + + @Override + public SaslStep firstStep() { + challenged = true; + return new SaslStep.Challenge(Optional.of(bytes("continue"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + if (!challenged) { + return new SaslStep.Failure(SaslFailure.malformed("response received before challenge")); + } + if (new String(clientResponse, StandardCharsets.UTF_8).equals("accepted")) { + return new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty()); + } + return new SaslStep.Failure(SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected")); + } + + @Override + public void abort() { + } + + @Override + public void close() { + } + } + + @Test + void oneStepMechanismShouldReturnSuccess() { + // GIVEN a one-step mechanism configured to immediately succeed + SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.empty()); + SaslExchange exchange = new FixedStepMechanism(success).start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); + + // WHEN the exchange starts + SaslStep firstStep = exchange.firstStep(); + + // THEN the mechanism can complete without a client continuation + assertThat(firstStep).isEqualTo(success); + } + + @Test + void oneStepMechanismShouldReturnFailure() { + // GIVEN a one-step mechanism configured to immediately fail + SaslStep.Failure failure = new SaslStep.Failure(SaslFailure.malformed("failure")); + SaslExchange exchange = new FixedStepMechanism(failure).start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); + + // WHEN the exchange starts + SaslStep firstStep = exchange.firstStep(); + + // THEN the mechanism can fail without a client continuation + assertThat(firstStep).isEqualTo(failure); + } + + @Test + void multiStepMechanismShouldKeepStateAcrossResponses() { + // GIVEN a mechanism that requires one challenge before accepting a response + SaslExchange exchange = new TwoStepMechanism().start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); + + // WHEN the server sends a challenge and later receives the expected client response + SaslStep firstStep = exchange.firstStep(); + SaslStep success = exchange.onResponse(bytes("accepted")); + + // THEN the exchange keeps enough state to complete after the continuation + assertThat(firstStep).isInstanceOf(SaslStep.Challenge.class); + assertThat(((SaslStep.Success) success).identity()).isEqualTo(SAME_USER_IDENTITY); + } + + @Test + void successStepShouldPreserveDelegatedIdentity() { + // GIVEN a self-authenticating mechanism returning a delegated identity + SaslExchange exchange = new FixedStepMechanism(new SaslStep.Success(DELEGATED_IDENTITY, Optional.empty())) + .start(initialRequest(Optional.empty()), NOOP_AUTHENTICATOR); + + // WHEN the exchange starts + SaslStep firstStep = exchange.firstStep(); + + // THEN the identity keeps both authentication and authorization users + assertThat(((SaslStep.Success) firstStep).identity()).isEqualTo(DELEGATED_IDENTITY); + } + + @Test + void initialRequestShouldDefensivelyCopyInitialResponse() { + // GIVEN an initial response backed by a mutable byte array + byte[] initialResponse = bytes("initial"); + SaslInitialRequest request = initialRequest(Optional.of(initialResponse)); + + // WHEN the caller mutates the original array + initialResponse[0] = 'I'; + + // THEN the request keeps the original payload + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void saslStepsShouldDefensivelyCopyPayloads() { + // GIVEN challenge and success steps backed by mutable byte arrays + byte[] challengePayload = bytes("challenge"); + byte[] serverData = bytes("server"); + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(challengePayload)); + SaslStep.Success success = new SaslStep.Success(SAME_USER_IDENTITY, Optional.of(serverData)); + + // WHEN the caller mutates the original arrays + challengePayload[0] = 'C'; + serverData[0] = 'S'; + + // THEN the SASL steps keep their original payloads + assertThat(challenge.payload()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("challenge"))); + assertThat(success.serverData()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("server"))); + } + + @Test + void exchangeShouldExposeAbortAndCloseLifecycle() { + // GIVEN an active exchange + FixedStepExchange exchange = new FixedStepExchange(new SaslStep.Failure(SaslFailure.malformed("failure"))); + + // WHEN the protocol aborts and then closes it + exchange.abort(); + exchange.close(); + + // THEN mechanisms can observe both lifecycle events + assertThat(exchange.aborted).isTrue(); + assertThat(exchange.closed).isTrue(); + } + + private static SaslInitialRequest initialRequest(Optional initialResponse) { + return new SaslInitialRequest("TEST", initialResponse); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/imap/pom.xml b/protocols/imap/pom.xml index 1d4d6642d07..e1ec4819fb4 100644 --- a/protocols/imap/pom.xml +++ b/protocols/imap/pom.xml @@ -91,6 +91,10 @@ ${james.protocols.groupId} protocols-api + + ${james.protocols.groupId} + protocols-sasl + com.beetstra.jutf7 jutf7 diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java index ebdce2fdbaf..791e8ce69a5 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/api/process/ImapSession.java @@ -31,7 +31,6 @@ import org.apache.commons.text.RandomStringGenerator; import org.apache.james.core.Username; import org.apache.james.imap.api.ImapSessionState; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.protocols.api.CommandDetectionSession; import org.apache.james.util.MDCBuilder; @@ -247,19 +246,6 @@ default boolean backpressureNeeded(Runnable restoreBackpressure) { */ void popLineHandler(); - /** - * Return true if SSL is required when Authenticating - */ - boolean isSSLRequired(); - - /** - * Return true if the login / authentication via plain username / password is - * enabled - */ - boolean isPlainAuthEnabled(); - - boolean supportsOAuth(); - default void withMDC(Runnable runnable) { try (Closeable c = mdc().build()) { runnable.run(); @@ -280,8 +266,6 @@ default MDCBuilder mdc() { */ InetSocketAddress getRemoteAddress(); - Optional oidcSaslConfiguration(); - default void setMailboxSession(MailboxSession mailboxSession) { setAttribute(MAILBOX_SESSION_ATTRIBUTE_SESSION_KEY, mailboxSession); } @@ -296,14 +280,6 @@ default Username getUserName() { .orElse(null); } - default boolean isPlainAuthDisallowed() { - return !isPlainAuthEnabled() || isAuthenticatingNonEncryptedWhenRequiredSSL(); - } - - default boolean isAuthenticatingNonEncryptedWhenRequiredSSL() { - return isSSLRequired() && !isTLSActive(); - } - void schedule(Runnable runnable, Duration waitDelay); } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java index a4dab679c8b..3e77db72e68 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/AuthenticateResponseEncoder.java @@ -30,6 +30,10 @@ public Class acceptableMessages() { @Override public void encode(AuthenticateResponse message, ImapResponseComposer composer) throws IOException { - composer.continuationResponse(); + if (message.getResponse().filter(response -> !response.isEmpty()).isPresent()) { + composer.continuationResponse(message.getResponse().get()); + } else { + composer.continuationResponse(); + } } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java index d73af074e1b..cd7ad586589 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/encode/FakeImapSession.java @@ -34,7 +34,6 @@ import org.apache.james.imap.api.process.ImapLineHandler; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.api.process.SelectedMailbox; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.util.concurrent.NamedThreadFactory; import reactor.core.publisher.Mono; @@ -166,31 +165,11 @@ public void popLineHandler() { } - @Override - public boolean isSSLRequired() { - return false; - } - - @Override - public boolean isPlainAuthEnabled() { - return true; - } - - @Override - public boolean supportsOAuth() { - return false; - } - @Override public InetSocketAddress getRemoteAddress() { return null; } - @Override - public Optional oidcSaslConfiguration() { - return Optional.empty(); - } - @Override public boolean isTLSActive() { return false; diff --git a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java index 345fc9e5f4d..dcf4355e345 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java @@ -19,8 +19,22 @@ package org.apache.james.imap.message.response; +import java.util.Optional; + import org.apache.james.imap.api.message.response.ImapResponseMessage; -public class AuthenticateResponse implements ImapResponseMessage{ +public class AuthenticateResponse implements ImapResponseMessage { + private final Optional response; + + public AuthenticateResponse() { + this.response = Optional.empty(); + } + + public AuthenticateResponse(String response) { + this.response = Optional.of(response); + } + public Optional getResponse() { + return response; + } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java index 8786a6ec941..cad4359952f 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AbstractAuthProcessor.java @@ -27,8 +27,6 @@ import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.main.PathConverter; -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.DefaultMailboxes; import org.apache.james.mailbox.MailboxManager; @@ -41,12 +39,13 @@ import org.apache.james.mailbox.model.MailboxConstants; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslStep; import org.apache.james.util.AuditTrail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import reactor.core.publisher.Mono; @@ -80,54 +79,6 @@ public void configure(ImapConfiguration imapConfiguration) { this.imapConfiguration = imapConfiguration; } - protected void doPasswordAuth(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) { - Preconditions.checkArgument(!authenticationAttempt.isDelegation()); - - if (authenticationAttempt.getAuthenticationId() == null || authenticationAttempt.getPassword() == null) { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), Optional.empty(), - "Malformed authentication command." - ); - } else { - try { - final MailboxSession mailboxSession = getMailboxManager().authenticate( - authenticationAttempt.getAuthenticationId(), - authenticationAttempt.getPassword() - ).withoutDelegation(); - authSuccess(session, mailboxSession, request, responder, "Password authentication succeeded."); - } catch (BadCredentialsException e) { - authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, - Optional.of(authenticationAttempt.getAuthenticationId()), - Optional.empty(), - "Password authentication failed because of bad credentials." - ); - } catch (MailboxException e) { - // This is probably not a user error, so we do not increase the failure count or add the - // event to the audit log. - LOGGER.error("Authentication failed", e); - no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); - } - } - } - - protected void doPasswordAuthWithDelegation(AuthenticationAttempt authenticationAttempt, ImapSession session, ImapRequest request, Responder responder) { - Preconditions.checkArgument(authenticationAttempt.isDelegation()); - Username otherUser = authenticationAttempt.getDelegateUserName().orElseThrow(); - - Username givenUser = authenticationAttempt.getAuthenticationId(); - if (givenUser == null) { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, - Optional.empty(), Optional.of(otherUser), "Malformed authentication command."); - } else { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(givenUser, authenticationAttempt.getPassword()) - .as(otherUser), - session, - request, responder, - givenUser, otherUser); - } - } - protected Authorizator withAdminUsers() { return (userId, otherUserId) -> { if (imapConfiguration.getAdminUsers().contains(userId.asString())) { @@ -140,8 +91,14 @@ protected Authorizator withAdminUsers() { protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier, ImapSession session, ImapRequest request, Responder responder, Username authenticateUser, Username delegatorUser) { + doAuth(mailboxSessionSupplier, session, request, responder, authenticateUser, delegatorUser, "Authentication with delegation succeeded."); + } + + protected void doAuth(MailboxSessionAuthWithDelegationSupplier mailboxSessionSupplier, + ImapSession session, ImapRequest request, Responder responder, + Username authenticateUser, Username delegatorUser, String successLog) { try { - authSuccess(session, mailboxSessionSupplier.get(), request, responder, "Authentication with delegation succeeded."); + authSuccess(session, mailboxSessionSupplier.get(), request, responder, successLog); } catch (BadCredentialsException e) { authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, Optional.of(authenticateUser), Optional.of(delegatorUser), "Password authentication with delegation failed because of bad credentials."); @@ -150,37 +107,58 @@ protected void doAuthWithDelegation(MailboxSessionAuthWithDelegationSupplier mai Optional.of(delegatorUser), "Delegation target user does not exist."); } catch (ForbiddenDelegationException e) { authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN, Optional.of(authenticateUser), - Optional.of(delegatorUser), "Requested delegation is forbidden."); + Optional.of(delegatorUser), "Requested delegation is forbidden."); } catch (MailboxException e) { - // This is probably not a user error, so we do not increase the failure count or add the - // event to the audit log. LOGGER.info("Authentication failed", e); no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); } } - protected void doOAuth(OIDCSASLParser.OIDCInitialResponse oidcInitialResponse, OidcSASLConfiguration oidcSASLConfiguration, - ImapSession session, ImapRequest request, Responder responder) { - new OidcJwtTokenVerifier(oidcSASLConfiguration).validateToken(oidcInitialResponse.getToken()) - .ifPresentOrElse(authenticatedUser -> { - Username associatedUser = Username.of(oidcInitialResponse.getAssociatedUser()); - if (!associatedUser.equals(authenticatedUser)) { - doAuthWithDelegation(() -> getMailboxManager() - .withExtraAuthorizator(withAdminUsers()) - .authenticate(authenticatedUser) - .as(associatedUser), - session, request, responder, authenticatedUser, associatedUser); - } else { - authSuccess(session, getMailboxManager().createSystemSession(authenticatedUser), request, responder, - "OAuth authentication succeeded." - ); - } - }, () -> { - authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.of(Username.of(oidcInitialResponse.getAssociatedUser())), - "OAuth authentication failed." - ); - }); + protected void handleSaslStep(SaslStep step, ImapSession session, ImapRequest request, Responder responder, String successLog) { + switch (step) { + case SaslStep.Success success -> handleSaslSuccess(success, session, request, responder, successLog); + case SaslStep.Failure failure -> handleSaslFailure(failure.failure(), session, request, responder); + case SaslStep.Challenge ignored -> throw new IllegalStateException("Challenge SASL step cannot be applied as authentication result"); + } + } + + protected void handleSaslSuccess(SaslStep.Success success, ImapSession session, ImapRequest request, Responder responder, String successLog) { + SaslIdentity identity = success.identity(); + if (!identity.authenticationId().equals(identity.authorizationId())) { + doAuthWithDelegation(() -> getMailboxManager() + .withExtraAuthorizator(withAdminUsers()) + .authenticate(identity.authenticationId()) + .as(identity.authorizationId()), + session, request, responder, identity.authenticationId(), identity.authorizationId()); + return; + } + + doAuth(() -> getMailboxManager() + .authenticate(identity.authenticationId()) + .withoutDelegation(), + session, request, responder, identity.authenticationId(), identity.authorizationId(), successLog); + } + + protected void handleSaslFailure(SaslFailure failure, ImapSession session, ImapRequest request, Responder responder) { + switch (failure.type()) { + case MALFORMED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case AUTHENTICATION_FAILED -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case INVALID_CREDENTIALS -> authFailure(session, request, responder, HumanReadableText.INVALID_CREDENTIALS, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case USER_DOES_NOT_EXIST -> authFailure(session, request, responder, HumanReadableText.USER_DOES_NOT_EXIST, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case DELEGATION_FORBIDDEN -> authFailure(session, request, responder, HumanReadableText.DELEGATION_FORBIDDEN, + failure.authenticationId(), failure.authorizationId(), failure.reason()); + case SERVER_ERROR -> { + failure.cause() + .ifPresentOrElse( + cause -> LOGGER.error("Authentication failed: {}", failure.reason(), cause), + () -> LOGGER.error("Authentication failed: {}", failure.reason())); + no(request, responder, HumanReadableText.GENERIC_FAILURE_DURING_PROCESSING); + } + } } protected void provisionInbox(ImapSession session, MailboxManager mailboxManager, MailboxSession mailboxSession) throws MailboxException { @@ -233,15 +211,7 @@ protected void manageFailureCount(ImapSession session, ImapRequest request, Resp } } - protected static AuthenticationAttempt delegation(Username authorizeId, Username authenticationId, String password) { - return new AuthenticationAttempt(Optional.of(authorizeId), authenticationId, password); - } - - protected static AuthenticationAttempt noDelegation(Username authenticationId, String password) { - return new AuthenticationAttempt(Optional.empty(), authenticationId, password); - } - - protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String log) { + protected void authSuccess(ImapSession session, MailboxSession mailboxSession, ImapRequest request, Responder responder, String successLog) { session.authenticated(); session.setMailboxSession(mailboxSession); try { @@ -260,12 +230,13 @@ protected void authSuccess(ImapSession session, MailboxSession mailboxSession, I if (assumedUser.isPresent()) { entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString())); } - entry.log(log); + entry.log(successLog); okComplete(request, responder); session.stopDetectingCommandInjection(); } - protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username, Optional assumedUser, String log) { + protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional username, + Optional assumedUser, String failureReason) { AuditTrail.Entry entry = AuditTrail.entry() .username(() -> username.map(name -> name.asString()).orElse(null)) .sessionId(() -> session.sessionId().asString()) @@ -275,35 +246,7 @@ protected void authFailure(ImapSession session, ImapRequest request, Responder r if (assumedUser.isPresent()) { entry = entry.parameters(() -> ImmutableMap.of("delegatorUser", assumedUser.get().asString())); } - entry.log(log); + entry.log(failureReason); manageFailureCount(session, request, responder, failed); } - - protected static class AuthenticationAttempt { - private final Optional delegateUserName; - private final Username authenticationId; - private final String password; - - public AuthenticationAttempt(Optional delegateUserName, Username authenticationId, String password) { - this.delegateUserName = delegateUserName; - this.authenticationId = authenticationId; - this.password = password; - } - - public boolean isDelegation() { - return delegateUserName.isPresent() && !delegateUserName.get().equals(authenticationId); - } - - public Optional getDelegateUserName() { - return delegateUserName; - } - - public Username getAuthenticationId() { - return authenticationId; - } - - public String getPassword() { - return password; - } - } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java index cfae43c6f37..8a9d87fbd30 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/AuthenticateProcessor.java @@ -19,36 +19,36 @@ package org.apache.james.imap.processor; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; import java.util.List; +import java.util.Locale; import java.util.Optional; -import java.util.stream.Collectors; import jakarta.inject.Inject; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.core.Username; import org.apache.james.imap.api.display.HumanReadableText; import org.apache.james.imap.api.message.Capability; -import org.apache.james.imap.api.message.request.ImapRequest; import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.imap.main.PathConverter; import org.apache.james.imap.message.request.AuthenticateRequest; import org.apache.james.imap.message.request.IRAuthenticateRequest; import org.apache.james.imap.message.response.AuthenticateResponse; +import org.apache.james.imap.processor.sasl.ImapSaslBridge; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; -import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; import org.apache.james.util.MDCBuilder; import org.apache.james.util.ReactorUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import reactor.core.publisher.Mono; @@ -59,17 +59,21 @@ public class AuthenticateProcessor extends AbstractAuthProcessor implements CapabilityImplementingProcessor { public static final String AUTH_PLAIN = "AUTH=PLAIN"; public static final Capability AUTH_PLAIN_CAPABILITY = Capability.of(AUTH_PLAIN); - private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class); - private static final String AUTH_TYPE_PLAIN = "PLAIN"; - private static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER"; - private static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2"; - private static final List OAUTH_CAPABILITIES = ImmutableList.of(Capability.of("AUTH=" + AUTH_TYPE_OAUTHBEARER), Capability.of("AUTH=" + AUTH_TYPE_XOAUTH2)); public static final Capability SASL_CAPABILITY = Capability.of("SASL-IR"); + private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticateProcessor.class); + + private final ImapSaslBridge saslBridge; + private final JamesSaslAuthenticator jamesSaslAuthenticator; + private ImmutableList saslMechanisms; @Inject public AuthenticateProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, - MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { + MetricFactory metricFactory, PathConverter.Factory pathConverterFactory, + JamesSaslAuthenticator jamesSaslAuthenticator) { super(AuthenticateRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); + this.saslBridge = new ImapSaslBridge(); + this.jamesSaslAuthenticator = jamesSaslAuthenticator; + this.saslMechanisms = ImmutableList.of(); } @Override @@ -79,127 +83,40 @@ public List> acceptableClasses() { @Override protected void processRequest(AuthenticateRequest request, ImapSession session, final Responder responder) { - final String authType = request.getAuthType(); - - if (authType.equalsIgnoreCase(AUTH_TYPE_PLAIN)) { - // See if AUTH=PLAIN is allowed. See IMAP-304 - if (session.isPlainAuthDisallowed()) { - LOGGER.warn("Plain authentication rejected because it is disabled or not allowed over insecure channel"); - no(request, responder, HumanReadableText.DISABLED_LOGIN); - } else { - if (request instanceof IRAuthenticateRequest) { - IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request; - parseAndDoPlainAuth(irRequest.getInitialClientResponse(), session, request, responder); - } else { - session.executeSafely(() -> { - responder.respond(new AuthenticateResponse()); - responder.flush(); - session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> { - parseAndDoPlainAuth(extractInitialClientResponse(data), requestSession, request, responder); - // remove the handler now - requestSession.popLineHandler(); - responder.flush(); - }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then()); - }); - } - } - } else if (authType.equalsIgnoreCase(AUTH_TYPE_OAUTHBEARER) || authType.equalsIgnoreCase(AUTH_TYPE_XOAUTH2)) { - if (!session.supportsOAuth()) { - LOGGER.warn("OAuth authentication rejected because it is disabled"); - no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); - } else { - if (request instanceof IRAuthenticateRequest) { - IRAuthenticateRequest irRequest = (IRAuthenticateRequest) request; - parseAndDoOAuth(irRequest.getInitialClientResponse(), session, request, responder); - } else { - session.executeSafely(() -> { - responder.respond(new AuthenticateResponse()); - responder.flush(); - session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> { - parseAndDoOAuth(extractInitialClientResponse(data), requestSession, request, responder); - requestSession.popLineHandler(); - responder.flush(); - }).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER).then()); - }); - } - } - } else { - LOGGER.debug("Unsupported authentication mechanism '{}'", authType); + Optional mechanism = findMechanism(request.getAuthType()); + + if (mechanism.isEmpty()) { + LOGGER.debug("Unsupported authentication mechanism '{}'", request.getAuthType()); no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); + return; } - } - /** - * Parse the initial client response and start plain authentication. - */ - protected void parseAndDoPlainAuth(String initialClientResponse, ImapSession session, ImapRequest request, Responder responder) { - AuthenticationAttempt authenticationAttempt = parseDelegationAttempt(initialClientResponse); - if (authenticationAttempt.isDelegation()) { - doPasswordAuthWithDelegation(authenticationAttempt, session, request, responder); - } else { - doPasswordAuth(authenticationAttempt, session, request, responder); + if (!isAvailable(mechanism.get(), session)) { + rejectUnavailable(request, responder, mechanism.get()); + return; } - } - - /** - * Parse the initial client response and start oauth authentication. - */ - protected void parseAndDoOAuth(String initialResponse, ImapSession session, ImapRequest request, Responder responder) { - OIDCSASLParser.parse(initialResponse) - .flatMap(oidcInitialResponseValue -> session.oidcSaslConfiguration().map(configure -> Pair.of(oidcInitialResponseValue, configure))) - .ifPresentOrElse(pair -> doOAuth(pair.getLeft(), pair.getRight(), session, request, responder), - () -> authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), - Optional.empty(), "Malformed authentication command.")); - } - private AuthenticationAttempt parseDelegationAttempt(String initialClientResponse) { try { - String userpass = new String(Base64.getDecoder().decode(initialClientResponse)); - List tokens = Arrays.stream(userpass.split("\0")) - .filter(token -> !token.isBlank()) - .collect(Collectors.toList()); - Preconditions.checkArgument(tokens.size() == 2 || tokens.size() == 3); - if (tokens.size() == 2) { - // If we got here, this is what happened. RFC 2595 - // says that "the client may leave the authorization - // identity empty to indicate that it is the same as - // the authentication identity." As noted above, - // that would be represented as a decoded string of - // the form: "\0authenticate-id\0password". The - // first call to nextToken will skip the empty - // authorize-id, and give us the authenticate-id, - // which we would store as the authorize-id. The - // second call will give us the password, which we - // think is the authenticate-id (user). Then when - // we ask for the password, there are no more - // elements, leading to the exception we just - // caught. So we need to move the user to the - // password, and the authorize_id to the user. - return noDelegation(Username.of(tokens.get(0)), tokens.get(1)); - } else { - return delegation(Username.of(tokens.get(0)), Username.of(tokens.get(1)), tokens.get(2)); - } - } catch (Exception e) { - // Ignored - this exception in parsing will be dealt - // with in the if clause below + SaslInitialRequest initialRequest = saslBridge.initialRequest(request.getAuthType(), initialClientResponse(request)); + SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers()); + SaslExchange exchange = mechanism.get().start(initialRequest, authenticator); + handleFirstStep(exchange, firstStep(exchange), session, request, responder); + } catch (IllegalArgumentException e) { LOGGER.info("Invalid syntax in AUTHENTICATE initial client response", e); - return noDelegation(null, null); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); } } @Override public List getImplementedCapabilities(ImapSession session) { - List caps = new ArrayList<>(); - // Only ounce AUTH=PLAIN if the session does allow plain auth or TLS is active. - // See IMAP-304 - if (!session.isPlainAuthDisallowed()) { - caps.add(AUTH_PLAIN_CAPABILITY); - } + List caps = saslMechanisms.stream() + .filter(mechanism -> isAvailable(mechanism, session)) + .map(mechanism -> Capability.of("AUTH=" + mechanism.name())) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + // Support for SASL-IR. See RFC4959 caps.add(SASL_CAPABILITY); - if (session.supportsOAuth()) { - caps.addAll(OAUTH_CAPABILITIES); - } return ImmutableList.copyOf(caps); } @@ -210,9 +127,235 @@ protected MDCBuilder mdc(AuthenticateRequest request) { .addToContext("authType", request.getAuthType()); } - private static String extractInitialClientResponse(byte[] data) { - // cut of the CRLF - return new String(data, 0, data.length - 2, StandardCharsets.US_ASCII); + public void configureSaslMechanisms(ImmutableList saslMechanisms) { + this.saslMechanisms = saslMechanisms; + } + + private Optional initialClientResponse(AuthenticateRequest request) { + if (request instanceof IRAuthenticateRequest irAuthenticateRequest) { + return Optional.of(irAuthenticateRequest.getInitialClientResponse()); + } + return Optional.empty(); + } + + private SaslStep firstStep(SaslExchange exchange) { + try { + return exchange.firstStep(); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private Optional findMechanism(String mechanismName) { + String normalizedName = mechanismName.toUpperCase(Locale.US); + return saslMechanisms.stream() + .filter(mechanism -> mechanism.name().toUpperCase(Locale.US).equals(normalizedName)) + .findFirst(); + } + + private boolean isAvailable(SaslMechanism mechanism, ImapSession session) { + return mechanism.isAvailableOnTransport(session.isTLSActive()); + } + + private void rejectUnavailable(AuthenticateRequest request, Responder responder, SaslMechanism mechanism) { + LOGGER.warn("{} authentication rejected because it is not allowed over current transport", mechanism.name()); + if (SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) { + no(request, responder, HumanReadableText.DISABLED_LOGIN); + return; + } + no(request, responder, HumanReadableText.UNSUPPORTED_AUTHENTICATION_MECHANISM); + } + + private void handleFirstStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { + if (step instanceof SaslStep.Challenge challenge) { + handleInitialChallenge(exchange, challenge, session, request, responder); + return; + } + if (step instanceof SaslStep.Success success && success.serverData().isPresent()) { + handleSuccessWithServerData(exchange, success, session, request, responder); + return; + } + handleTerminalStep(exchange, step, session, request, responder); + } + + private void handleInitialChallenge(SaslExchange exchange, SaslStep.Challenge challenge, + ImapSession session, AuthenticateRequest request, Responder responder) { + pushContinuationHandlerAndRespond(exchange, challenge, session, request, responder); + } + + private void pushContinuationHandlerAndRespond(SaslExchange exchange, SaslStep.Challenge challenge, + ImapSession session, AuthenticateRequest request, Responder responder) { + pushContinuationHandler(exchange, session, request, responder); + respondActiveContinuation(exchange, session, () -> + responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge)))); + } + + private void pushContinuationHandler(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder) { + try { + session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleContinuationLine(exchange, requestSession, request, responder, data)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + .then()); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private void handleContinuationLine(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { + if (isAbort(exchange, session, data)) { + abortActiveContinuation(exchange, session); + no(request, responder, HumanReadableText.AUTHENTICATION_FAILED); + responder.flush(); + return; + } + + nextStep(exchange, session, request, responder, data) + .ifPresent(step -> handleContinuationStep(exchange, step, session, request, responder)); + } + + private Optional nextStep(SaslExchange exchange, ImapSession session, AuthenticateRequest request, Responder responder, byte[] data) { + try { + return Optional.of(saslBridge.onClientResponse(exchange, data)); + } catch (IllegalArgumentException e) { + LOGGER.info("Invalid syntax in AUTHENTICATE client response", e); + closeActiveContinuation(exchange, session); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); + responder.flush(); + return Optional.empty(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } } + private void handleContinuationStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { + if (step instanceof SaslStep.Challenge challenge) { + try { + responder.respond(new AuthenticateResponse(saslBridge.continuation(challenge))); + responder.flush(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + return; + } + + popActiveContinuation(exchange, session); + if (step instanceof SaslStep.Success success && success.serverData().isPresent()) { + handleSuccessWithServerData(exchange, success, session, request, responder); + return; + } + + handleTerminalStep(exchange, step, session, request, responder); + responder.flush(); + } + + private void respondActiveContinuation(SaslExchange exchange, ImapSession session, Runnable runnable) { + try { + runnable.run(); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private boolean isAbort(SaslExchange exchange, ImapSession session, byte[] data) { + try { + return saslBridge.isAbort(data); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private boolean isEmptyClientResponse(SaslExchange exchange, ImapSession session, byte[] data) { + try { + return saslBridge.isEmptyClientResponse(data); + } catch (RuntimeException e) { + closeActiveContinuation(exchange, session); + throw e; + } + } + + private void closeActiveContinuation(SaslExchange exchange, ImapSession session) { + try { + session.popLineHandler(); + } finally { + saslBridge.close(exchange); + } + } + + private void abortActiveContinuation(SaslExchange exchange, ImapSession session) { + try { + session.popLineHandler(); + } finally { + saslBridge.abort(exchange); + } + } + + private void popActiveContinuation(SaslExchange exchange, ImapSession session) { + try { + session.popLineHandler(); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private void handleSuccessWithServerData(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder) { + pushSuccessDataAcknowledgementHandler(exchange, success, session, request, responder); + respondActiveContinuation(exchange, session, () -> { + responder.respond(new AuthenticateResponse(saslBridge.successData(success))); + responder.flush(); + }); + } + + private void pushSuccessDataAcknowledgementHandler(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder) { + try { + session.pushLineHandler((requestSession, data) -> Mono.fromRunnable(() -> handleSuccessDataAcknowledgement(exchange, success, requestSession, request, responder, data)) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + .then()); + } catch (RuntimeException e) { + saslBridge.close(exchange); + throw e; + } + } + + private void handleSuccessDataAcknowledgement(SaslExchange exchange, SaslStep.Success success, ImapSession session, + AuthenticateRequest request, Responder responder, byte[] data) { + if (isAbort(exchange, session, data)) { + abortActiveContinuation(exchange, session); + no(request, responder, HumanReadableText.AUTHENTICATION_FAILED); + responder.flush(); + return; + } + if (!isEmptyClientResponse(exchange, session, data)) { + closeActiveContinuation(exchange, session); + authFailure(session, request, responder, HumanReadableText.AUTHENTICATION_FAILED, Optional.empty(), + Optional.empty(), "Malformed authentication command."); + responder.flush(); + return; + } + + popActiveContinuation(exchange, session); + handleTerminalStep(exchange, success, session, request, responder); + responder.flush(); + } + + private void handleTerminalStep(SaslExchange exchange, SaslStep step, ImapSession session, AuthenticateRequest request, Responder responder) { + try { + handleSaslStep(step, session, request, responder, successLog(request)); + } finally { + saslBridge.close(exchange); + } + } + + private String successLog(AuthenticateRequest request) { + String authType = request.getAuthType().toUpperCase(Locale.US); + return authType + " authentication succeeded."; + } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java index 794954c85d9..be56964b79c 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/DefaultProcessor.java @@ -19,6 +19,8 @@ package org.apache.james.imap.processor; +import static org.apache.james.protocols.sasl.JamesSaslAuthenticator.jamesSaslAuthenticator; + import java.util.Map; import java.util.stream.Stream; @@ -40,6 +42,9 @@ import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -59,16 +64,56 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess MailboxCounterCorrector mailboxCounterCorrector, MetricFactory metricFactory, FetchProcessor.LocalCacheConfiguration localCacheConfiguration) { + return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager, + statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector, + metricFactory, localCacheConfiguration, ImmutableList.of(new PlainSaslMechanism())); + } + + public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor, + MailboxManager mailboxManager, + EventBus eventBus, + SubscriptionManager subscriptionManager, + StatusResponseFactory statusResponseFactory, + MailboxTyper mailboxTyper, + QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, + MailboxCounterCorrector mailboxCounterCorrector, + MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + ImmutableList defaultSaslMechanisms) { + return createDefaultProcessor(chainEndProcessor, mailboxManager, eventBus, subscriptionManager, + statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, mailboxCounterCorrector, + metricFactory, localCacheConfiguration, defaultSaslMechanisms, jamesSaslAuthenticator(mailboxManager)); + } + + public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcessor, + MailboxManager mailboxManager, + EventBus eventBus, + SubscriptionManager subscriptionManager, + StatusResponseFactory statusResponseFactory, + MailboxTyper mailboxTyper, + QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, + MailboxCounterCorrector mailboxCounterCorrector, + MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + ImmutableList defaultSaslMechanisms, + JamesSaslAuthenticator saslAuthenticator) { PathConverter.Factory pathConverterFactory = PathConverter.Factory.DEFAULT; ImmutableList.Builder builder = ImmutableList.builder(); CapabilityProcessor capabilityProcessor = new CapabilityProcessor(mailboxManager, statusResponseFactory, metricFactory); + LoginProcessor loginProcessor = new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator); + loginProcessor.configureSaslMechanisms(defaultSaslMechanisms); + AuthenticateProcessor authenticateProcessor = new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory, saslAuthenticator); + authenticateProcessor.configureSaslMechanisms(defaultSaslMechanisms); + builder.add(new SystemMessageProcessor()); builder.add(new LogoutProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(capabilityProcessor); builder.add(new IdProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(new CheckProcessor(mailboxManager, statusResponseFactory, metricFactory)); - builder.add(new LoginProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); + builder.add(loginProcessor); builder.add(new RenameProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new DeleteProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new CreateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); @@ -76,7 +121,7 @@ public static ImapProcessor createDefaultProcessor(ImapProcessor chainEndProcess builder.add(new UnsubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new SubscribeProcessor(mailboxManager, subscriptionManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new CopyProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); - builder.add(new AuthenticateProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); + builder.add(authenticateProcessor); builder.add(new ExpungeProcessor(mailboxManager, statusResponseFactory, metricFactory)); builder.add(new ReplaceProcessor(mailboxManager, statusResponseFactory, metricFactory, pathConverterFactory)); builder.add(new ExamineProcessor(mailboxManager, eventBus, statusResponseFactory, metricFactory, pathConverterFactory, mailboxCounterCorrector)); diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java index 551bac70cf2..b3318e9b891 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/EnableProcessor.java @@ -52,15 +52,15 @@ public class EnableProcessor extends AbstractMailboxProcessor implements CapabilityImplementingProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(EnableProcessor.class); - private static final List capabilities = new ArrayList<>(); public static final String ENABLED_CAPABILITIES = "ENABLED_CAPABILITIES"; private static final List CAPS = ImmutableList.of(SUPPORTS_ENABLE); - private final CapabilityProcessor capabilityProcessor; + private final List capabilities = new ArrayList<>(); + private CapabilityProcessor capabilityProcessor; public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, List capabilities, MetricFactory metricFactory, CapabilityProcessor capabilityProcessor) { this(mailboxManager, factory, metricFactory, capabilityProcessor); - EnableProcessor.capabilities.addAll(capabilities); + this.capabilities.addAll(capabilities); } @Inject @@ -70,6 +70,12 @@ public EnableProcessor(MailboxManager mailboxManager, StatusResponseFactory fact this.capabilityProcessor = capabilityProcessor; } + /** + * Use the capability processor from the same IMAP suite. + */ + public void configureCapabilityProcessor(CapabilityProcessor capabilityProcessor) { + this.capabilityProcessor = capabilityProcessor; + } @Override protected Mono processRequestReactive(EnableRequest request, ImapSession session, Responder responder) { diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java index c7c2a845b33..2f2ba5d168d 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.List; +import java.util.Optional; import jakarta.inject.Inject; @@ -32,6 +33,11 @@ import org.apache.james.imap.message.request.LoginRequest; import org.apache.james.mailbox.MailboxManager; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import org.apache.james.util.MDCBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,9 +51,15 @@ public class LoginProcessor extends AbstractAuthProcessor implemen private static final List LOGINDISABLED_CAPS = ImmutableList.of(Capability.of("LOGINDISABLED")); private static final Logger LOGGER = LoggerFactory.getLogger(LoginProcessor.class); + private final JamesSaslAuthenticator jamesSaslAuthenticator; + private Optional plainSaslMechanism; + @Inject - public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory) { + public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory factory, MetricFactory metricFactory, PathConverter.Factory pathConverterFactory, + JamesSaslAuthenticator jamesSaslAuthenticator) { super(LoginRequest.class, mailboxManager, factory, metricFactory, pathConverterFactory); + this.jamesSaslAuthenticator = jamesSaslAuthenticator; + this.plainSaslMechanism = Optional.empty(); } /** @@ -55,12 +67,16 @@ public LoginProcessor(MailboxManager mailboxManager, StatusResponseFactory facto */ @Override protected void processRequest(LoginRequest request, ImapSession session, Responder responder) { + Optional plainSaslMechanism = availablePlainSaslMechanism(session); + // check if the login is allowed with LOGIN command. See IMAP-304 - if (session.isPlainAuthDisallowed()) { - LOGGER.warn("Login rejected because it is disabled or not allowed over insecure channel"); + if (plainSaslMechanism.isEmpty()) { + LOGGER.warn("Login rejected because PLAIN SASL mechanism is disabled"); no(request, responder, HumanReadableText.DISABLED_LOGIN); } else { - doPasswordAuth(noDelegation(request.getUserid(), request.getPassword()), session, request, responder); + SaslAuthenticator authenticator = jamesSaslAuthenticator.withExtraAuthorizator(withAdminUsers()); + handleSaslStep(plainSaslMechanism.orElseThrow().authenticate(request.getUserid(), request.getPassword(), authenticator), + session, request, responder, "Password authentication succeeded."); } } @@ -68,12 +84,25 @@ protected void processRequest(LoginRequest request, ImapSession session, Respond public List getImplementedCapabilities(ImapSession session) { // Announce LOGINDISABLED if plain auth / login is deactivated and the session is not using // TLS. See IMAP-304 - if (session.isPlainAuthDisallowed()) { + if (availablePlainSaslMechanism(session).isEmpty()) { return LOGINDISABLED_CAPS; } return Collections.emptyList(); } + public void configureSaslMechanisms(ImmutableList saslMechanisms) { + this.plainSaslMechanism = saslMechanisms.stream() + .filter(mechanism -> SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) + .filter(PlainSaslMechanism.class::isInstance) + .map(PlainSaslMechanism.class::cast) + .findFirst(); + } + + private Optional availablePlainSaslMechanism(ImapSession session) { + return plainSaslMechanism + .filter(mechanism -> mechanism.isAvailableOnTransport(session.isTLSActive())); + } + @Override protected MDCBuilder mdc(LoginRequest request) { return MDCBuilder.create() diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java index 31415b99cf8..6edd5724db2 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/main/DefaultImapProcessorFactory.java @@ -19,6 +19,9 @@ package org.apache.james.imap.processor.main; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.events.EventBus; import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.DefaultMailboxTyper; @@ -34,6 +37,17 @@ import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; public class DefaultImapProcessorFactory { @@ -59,6 +73,7 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo } public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + JamesSaslAuthenticator saslAuthenticator, EventBus eventBus, SubscriptionManager subscriptionManager, MailboxTyper mailboxTyper, QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, MetricFactory metricFactory) { @@ -69,7 +84,60 @@ public static ImapProcessor createXListSupportingProcessor(MailboxManager mailbo return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, MailboxCounterCorrector.DEFAULT, metricFactory, - FetchProcessor.LocalCacheConfiguration.DEFAULT); + FetchProcessor.LocalCacheConfiguration.DEFAULT, ImmutableList.of(new PlainSaslMechanism()), saslAuthenticator); + } + + public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + EventBus eventBus, SubscriptionManager subscriptionManager, + MailboxTyper mailboxTyper, QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + + StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory(); + UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory); + + return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, + eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, + MailboxCounterCorrector.DEFAULT, metricFactory, + localCacheConfiguration, defaultSaslMechanisms(serverConfiguration)); + } + + public static ImapProcessor createXListSupportingProcessor(MailboxManager mailboxManager, + EventBus eventBus, SubscriptionManager subscriptionManager, + MailboxTyper mailboxTyper, QuotaManager quotaManager, + QuotaRootResolver quotaRootResolver, MetricFactory metricFactory, + FetchProcessor.LocalCacheConfiguration localCacheConfiguration, + HierarchicalConfiguration serverConfiguration, + JamesSaslAuthenticator saslAuthenticator) throws ConfigurationException { + + StatusResponseFactory statusResponseFactory = new UnpooledStatusResponseFactory(); + UnknownRequestProcessor unknownRequestImapProcessor = new UnknownRequestProcessor(statusResponseFactory); + + return DefaultProcessor.createDefaultProcessor(unknownRequestImapProcessor, mailboxManager, + eventBus, subscriptionManager, statusResponseFactory, mailboxTyper, quotaManager, quotaRootResolver, + MailboxCounterCorrector.DEFAULT, metricFactory, + localCacheConfiguration, defaultSaslMechanisms(serverConfiguration), saslAuthenticator); + } + + private static ImmutableList defaultSaslMechanisms(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + ImmutableList factories = BuiltInSaslMechanismFactories.enabledForServer( + ImmutableList.of( + new PlainSaslMechanismFactory(), + new OauthBearerSaslMechanismFactory(), + new XOauth2SaslMechanismFactory()), + serverConfiguration); + + try { + return factories.stream() + .map(Throwing.function(factory -> factory.create(serverConfiguration))) + .collect(ImmutableList.toImmutableList()); + } catch (RuntimeException e) { + if (e.getCause() instanceof ConfigurationException configurationException) { + throw configurationException; + } + throw e; + } } } diff --git a/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java new file mode 100644 index 00000000000..7e5a8217fe9 --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java @@ -0,0 +1,104 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; + +public class ImapSaslBridge { + /** + * Converts an IMAP AUTHENTICATE request into a protocol-neutral SASL initial request. + */ + public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { + return new SaslInitialRequest(mechanismName, initialClientResponse.map(this::decodeInitialClientResponse)); + } + + /** + * Encodes a SASL challenge payload as an IMAP continuation payload. + */ + public String continuation(SaslStep.Challenge challenge) { + return challenge.payload() + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + } + + /** + * Encodes final SASL server data as an IMAP continuation payload. + */ + public String successData(SaslStep.Success success) { + return success.serverData() + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + } + + /** + * Decodes an IMAP client continuation line and forwards it to the SASL exchange. + */ + public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { + return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); + } + + public boolean isAbort(byte[] line) { + return "*".equals(stripTrailingCrlf(line)); + } + + /** + * Detects the empty IMAP client response used to acknowledge final SASL server data. + */ + public boolean isEmptyClientResponse(byte[] line) { + return stripTrailingCrlf(line).isEmpty(); + } + + /** + * Aborts an active SASL exchange. + */ + public void abort(SaslExchange exchange) { + exchange.abort(); + } + + /** + * Closes an active SASL exchange. + */ + public void close(SaslExchange exchange) { + exchange.close(); + } + + private byte[] decodeBase64(String value) { + return Base64.getDecoder().decode(value); + } + + private byte[] decodeInitialClientResponse(String value) { + if (value.equals("=")) { + return new byte[0]; + } + return decodeBase64(value); + } + + private String stripTrailingCrlf(byte[] line) { + String value = new String(line, StandardCharsets.US_ASCII); + return StringUtils.stripEnd(value, "\r\n"); + } +} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java new file mode 100644 index 00000000000..1d66db34375 --- /dev/null +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java @@ -0,0 +1,277 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor; + +import static org.apache.james.imap.ImapFixture.TAG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.imap.api.message.response.ImapResponseMessage; +import org.apache.james.imap.api.process.ImapLineHandler; +import org.apache.james.imap.api.process.ImapProcessor; +import org.apache.james.imap.encode.FakeImapSession; +import org.apache.james.imap.main.PathConverter; +import org.apache.james.imap.message.request.AuthenticateRequest; +import org.apache.james.imap.message.response.UnpooledStatusResponseFactory; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Mono; + +class AuthenticateProcessorTest { + private static final String BROKEN_MECHANISM = "BROKEN"; + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private static class TestSaslMechanism implements SaslMechanism { + private final SaslExchange exchange; + + private TestSaslMechanism(SaslExchange exchange) { + this.exchange = exchange; + } + + @Override + public String name() { + return BROKEN_MECHANISM; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return exchange; + } + } + + private static class RecordingLineHandlerImapSession extends FakeImapSession { + private ImapLineHandler lineHandler; + private boolean throwOnPop; + private int popCount; + + @Override + public void pushLineHandler(ImapLineHandler lineHandler) { + this.lineHandler = lineHandler; + } + + @Override + public void popLineHandler() { + popCount++; + if (throwOnPop) { + throw new IllegalStateException("boom"); + } + lineHandler = null; + } + } + + private static class ThrowingResponder implements ImapProcessor.Responder { + @Override + public void respond(ImapResponseMessage message) { + throw new IllegalStateException("boom"); + } + + @Override + public void flush() { + } + } + + private static class ThrowingFirstStepExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + throw new IllegalArgumentException("boom"); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new UnsupportedOperationException(); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private static class ThrowingContinuationExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new IllegalStateException("boom"); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private static class SuccessDataExchange implements SaslExchange { + private boolean closed; + + @Override + public SaslStep firstStep() { + return new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new UnsupportedOperationException(); + } + + @Override + public void abort() { + } + + @Override + public void close() { + closed = true; + } + } + + private final AuthenticateProcessor testee = new AuthenticateProcessor( + mock(MailboxManager.class), + new UnpooledStatusResponseFactory(), + new RecordingMetricFactory(), + PathConverter.Factory.DEFAULT, + new JamesSaslAuthenticator(mock(Authenticator.class), mock(Authorizator.class))); + + @Test + void processRequestShouldCloseExchangeWhenFirstStepThrows() { + // GIVEN a mechanism whose exchange fails while computing the first SASL step + ThrowingFirstStepExchange exchange = new ThrowingFirstStepExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the malformed exchange + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), mock(ImapProcessor.Responder.class)); + + // THEN it closes the exchange even though the terminal step handling was not reached + assertThat(exchange.closed).isTrue(); + } + + @Test + void processRequestShouldCloseExchangeWhenInitialChallengeWriteThrows() { + // GIVEN a first challenge that fails while being written to the client + ThrowingContinuationExchange exchange = new ThrowingContinuationExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the initial challenge + assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder())) + .isInstanceOf(IllegalStateException.class); + + // THEN it closes the exchange even though no continuation handler was installed + assertThat(exchange.closed).isTrue(); + } + + @Test + void processRequestShouldCloseExchangeWhenInitialSuccessDataWriteThrows() { + // GIVEN a first successful SASL step with final server data that fails while being written to the client + SuccessDataExchange exchange = new SuccessDataExchange(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + + // WHEN the processor handles the initial success data + assertThatThrownBy(() -> testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), new FakeImapSession(), new ThrowingResponder())) + .isInstanceOf(IllegalStateException.class); + + // THEN it closes the exchange because terminal handling did not own it yet + assertThat(exchange.closed).isTrue(); + } + + @Test + void continuationShouldCloseExchangeWhenOnResponseThrows() { + // GIVEN an active continuation whose mechanism fails unexpectedly while processing the client response + ThrowingContinuationExchange exchange = new ThrowingContinuationExchange(); + RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class)); + ImapLineHandler lineHandler = session.lineHandler; + assertThat(lineHandler).isNotNull(); + + // WHEN the continuation line is processed + assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, imapLine("response"))).block()) + .isInstanceOf(IllegalStateException.class); + + // THEN the active line handler is removed and the exchange is closed before rethrowing + assertThat(session.popCount).isEqualTo(1); + assertThat(exchange.closed).isTrue(); + } + + @Test + void successDataAcknowledgementShouldCloseExchangeWhenPopLineHandlerThrows() { + // GIVEN an active final server-data acknowledgement handler + SuccessDataExchange exchange = new SuccessDataExchange(); + RecordingLineHandlerImapSession session = new RecordingLineHandlerImapSession(); + testee.configureSaslMechanisms(ImmutableList.of(new TestSaslMechanism(exchange))); + testee.processRequest(new AuthenticateRequest(BROKEN_MECHANISM, TAG), session, mock(ImapProcessor.Responder.class)); + ImapLineHandler lineHandler = session.lineHandler; + assertThat(lineHandler).isNotNull(); + session.throwOnPop = true; + + // WHEN the acknowledgement line reaches a failing line-handler cleanup + assertThatThrownBy(() -> Mono.from(lineHandler.onLine(session, emptyLine())).block()) + .isInstanceOf(IllegalStateException.class); + + // THEN the exchange is still closed before the failure is rethrown + assertThat(session.popCount).isEqualTo(1); + assertThat(exchange.closed).isTrue(); + } + + private static byte[] imapLine(String value) { + return (Base64.getEncoder().encodeToString(bytes(value)) + "\r\n").getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] emptyLine() { + return "\r\n".getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java new file mode 100644 index 00000000000..63053ee11c3 --- /dev/null +++ b/protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java @@ -0,0 +1,240 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.imap.processor.sasl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class ImapSaslBridgeTest { + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final ImapSaslBridge testee = new ImapSaslBridge(); + + private static class RecordingExchange implements SaslExchange { + protected final List lifecycleEvents; + private byte[] lastClientResponse; + + private RecordingExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + lastClientResponse = clientResponse.clone(); + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + + private static class RecordingAbortExchange extends RecordingExchange { + @Override + public void abort() { + lifecycleEvents.add("abort"); + close(); + } + } + + private static class ThrowingAbortExchange implements SaslExchange { + private final List lifecycleEvents; + + private ThrowingAbortExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public void abort() { + lifecycleEvents.add("abort"); + close(); + throw new IllegalStateException("boom"); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + + @Test + void initialRequestShouldDecodeInitialClientResponse() { + String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); + + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); + + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + } + + @Test + void continuationShouldBase64EncodeChallengePayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("challenge"))); + + String continuation = testee.continuation(challenge); + + assertThat(continuation).isEqualTo(Base64.getEncoder().encodeToString(bytes("challenge"))); + } + + @Test + void continuationShouldReturnEmptyStringWhenChallengeHasNoPayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.empty()); + + String continuation = testee.continuation(challenge); + + assertThat(continuation).isEmpty(); + } + + @Test + void successDataShouldBase64EncodePayload() { + SaslStep.Success success = new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))); + + String successData = testee.successData(success); + + assertThat(successData).isEqualTo(Base64.getEncoder().encodeToString(bytes("server-data"))); + } + + @Test + void isEmptyClientResponseShouldDetectEmptyLine() { + assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isEmptyClientResponseShouldRejectNonEmptyLine() { + assertThat(testee.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse(); + } + + @Test + void onClientResponseShouldDecodeLineAndContinueExchange() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldDecodeLineWithLfOnly() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void isAbortShouldDetectImapSaslAbortLine() { + assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldDetectImapSaslAbortLineWithLfOnly() { + assertThat(testee.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldRejectRegularClientResponse() { + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + assertThat(testee.isAbort(line)).isFalse(); + } + + @Test + void abortShouldCloseExchangeByDefault() { + RecordingExchange exchange = new RecordingExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + @Test + void abortShouldUseExchangeSpecificAbortWhenOverridden() { + RecordingAbortExchange exchange = new RecordingAbortExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + + @Test + void abortShouldPropagateExchangeSpecificAbortFailure() { + ThrowingAbortExchange exchange = new ThrowingAbortExchange(); + + assertThatThrownBy(() -> testee.abort(exchange)) + .isInstanceOf(IllegalStateException.class); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + + @Test + void closeShouldCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.close(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/lmtp/src/test/java/org/apache/james/protocols/lmtp/LMTPConfigurationImpl.java b/protocols/lmtp/src/test/java/org/apache/james/protocols/lmtp/LMTPConfigurationImpl.java index c81959b0679..59a01adbf04 100644 --- a/protocols/lmtp/src/test/java/org/apache/james/protocols/lmtp/LMTPConfigurationImpl.java +++ b/protocols/lmtp/src/test/java/org/apache/james/protocols/lmtp/LMTPConfigurationImpl.java @@ -18,10 +18,6 @@ ****************************************************************/ package org.apache.james.protocols.lmtp; -import java.util.Optional; - -import org.apache.james.jwt.OidcSASLConfiguration; - public class LMTPConfigurationImpl extends LMTPConfiguration { private long maxMessageSize = 0; @@ -44,14 +40,4 @@ public long getMaxMessageSize() { public void setMaxMessageSize(long maxMessageSize) { this.maxMessageSize = maxMessageSize; } - - @Override - public boolean isPlainAuthEnabled() { - return false; - } - - @Override - public Optional saslConfiguration() { - return Optional.empty(); - } } diff --git a/protocols/pom.xml b/protocols/pom.xml index e3745907832..a643b050a31 100644 --- a/protocols/pom.xml +++ b/protocols/pom.xml @@ -42,6 +42,7 @@ managesieve netty pop3 + sasl smtp diff --git a/protocols/sasl/pom.xml b/protocols/sasl/pom.xml new file mode 100644 index 00000000000..ddb94437af0 --- /dev/null +++ b/protocols/sasl/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + org.apache.james.protocols + protocols + 3.10.0-SNAPSHOT + ../pom.xml + + + protocols-sasl + jar + + Apache James :: Protocols :: SASL implementations + + + + ${james.groupId} + apache-james-mailbox-api + + + ${james.groupId} + james-server-jwt + + + ${james.groupId} + testing-base + test + + + ${james.protocols.groupId} + protocols-api + + + com.google.guava + guava + + + jakarta.inject + jakarta.inject-api + + + org.apache.commons + commons-configuration2 + + + diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java new file mode 100644 index 00000000000..859895f0304 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +import com.google.common.collect.ImmutableList; + +public final class BuiltInSaslMechanismFactories { + public static ImmutableList enabledForServer(ImmutableList defaultFactories, + HierarchicalConfiguration serverConfiguration) { + return defaultFactories.stream() + .filter(factory -> isEnabledByDefault(factory, serverConfiguration)) + .collect(ImmutableList.toImmutableList()); + } + + private static boolean isEnabledByDefault(SaslMechanismFactory factory, + HierarchicalConfiguration serverConfiguration) { + if (factory instanceof OidcSaslMechanismFactory) { + return !serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty(); + } + return true; + } + + private BuiltInSaslMechanismFactories() { + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java new file mode 100644 index 00000000000..b253153bde9 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java @@ -0,0 +1,118 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.exception.BadCredentialsException; +import org.apache.james.mailbox.exception.ForbiddenDelegationException; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.exception.UserDoesNotExistException; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; + +public class JamesSaslAuthenticator implements SaslAuthenticator { + public static JamesSaslAuthenticator jamesSaslAuthenticator(MailboxManager mailboxManager) { + Authenticator authenticator = (username, password) -> { + try { + return Optional.of(mailboxManager.authenticate(username, password.toString()).withoutDelegation().getUser()); + } catch (BadCredentialsException e) { + return Optional.empty(); + } + }; + Authorizator authorizator = (username, otherUsername) -> { + try { + mailboxManager.authenticate(username).as(otherUsername); + return Authorizator.AuthorizationState.ALLOWED; + } catch (UserDoesNotExistException e) { + return Authorizator.AuthorizationState.UNKNOWN_USER; + } catch (ForbiddenDelegationException | BadCredentialsException e) { + return Authorizator.AuthorizationState.FORBIDDEN; + } + }; + return new JamesSaslAuthenticator(authenticator, authorizator); + } + + private final Authenticator authenticator; + private final Authorizator authorizator; + + @Inject + public JamesSaslAuthenticator(Authenticator authenticator, Authorizator authorizator) { + this.authenticator = authenticator; + this.authorizator = authorizator; + } + + public JamesSaslAuthenticator withExtraAuthorizator(Authorizator extraAuthorizator) { + return new JamesSaslAuthenticator(authenticator, Authorizator.combine(authorizator, extraAuthorizator)); + } + + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, + Optional authorizationId, + String password) { + try { + Optional authenticatedUser = authenticator.isAuthentic(authenticationId, password); + if (authenticatedUser.isEmpty()) { + return failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, + "Password authentication failed because of bad credentials.")); + } + Username targetUser = authorizationId.orElse(authenticatedUser.get()); + return authorize(new SaslIdentity(authenticatedUser.get(), targetUser)); + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(authenticationId), authorizationId, "Authentication failed.", e)); + } + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + if (identity.authenticationId().equals(identity.authorizationId())) { + return success(identity); + } + + try { + return switch (authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId())) { + case ALLOWED -> success(identity); + case UNKNOWN_USER -> failure(SaslFailure.userDoesNotExist(identity.authenticationId(), identity.authorizationId(), + "Delegation target user does not exist.")); + case FORBIDDEN -> failure(SaslFailure.delegationForbidden(identity.authenticationId(), identity.authorizationId(), + "Requested delegation is forbidden.")); + }; + } catch (MailboxException e) { + return failure(SaslFailure.serverError(Optional.of(identity.authenticationId()), Optional.of(identity.authorizationId()), + "Authentication failed.", e)); + } + } + + private SaslAuthenticationResult success(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + + private SaslAuthenticationResult failure(SaslFailure failure) { + return new SaslAuthenticationResult.Failure(failure); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java new file mode 100644 index 00000000000..9af3d36a910 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; + +public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + OidcSASLConfiguration oidcConfiguration = parseConfiguration(serverConfiguration); + return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, new OidcJwtTokenVerifier(oidcConfiguration), + requiresSsl(serverConfiguration), invalidTokenResponse(oidcConfiguration)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java new file mode 100644 index 00000000000..60385804249 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java @@ -0,0 +1,55 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +abstract class OidcSaslMechanismFactory implements SaslMechanismFactory { + protected boolean requiresSsl(HierarchicalConfiguration serverConfiguration) { + return serverConfiguration.getBoolean("auth.requireSSL", false); + } + + protected byte[] invalidTokenResponse(OidcSASLConfiguration configuration) { + return String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", + configuration.getScope(), + configuration.getOidcConfigurationURL().toString()) + .getBytes(UTF_8); + } + + protected OidcSASLConfiguration parseConfiguration(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + if (serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty()) { + throw new ConfigurationException("OAuth SASL mechanisms require an auth.oidc configuration"); + } + try { + return OidcSASLConfiguration.parse(serverConfiguration.configurationAt("auth.oidc")); + } catch (MalformedURLException | URISyntaxException | NullPointerException e) { + throw new ConfigurationException("Failed to retrieve oauth component", e); + } + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java new file mode 100644 index 00000000000..7964454d433 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java @@ -0,0 +1,55 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; + +public class PlainSaslMechanismFactory implements SaslMechanismFactory { + private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true; + private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true; + + private final boolean requireSslDefault; + + public PlainSaslMechanismFactory() { + this(PLAIN_AUTH_DISALLOWED_DEFAULT); + } + + public PlainSaslMechanismFactory(boolean requireSslDefault) { + this.requireSslDefault = requireSslDefault; + } + + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new PlainSaslMechanism(plainAuthEnabled(serverConfiguration), requiresSsl(serverConfiguration)); + } + + protected boolean plainAuthEnabled(HierarchicalConfiguration serverConfiguration) { + return serverConfiguration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT); + } + + protected boolean requiresSsl(HierarchicalConfiguration serverConfiguration) { + return serverConfiguration.getBoolean("auth.requireSSL", + serverConfiguration.getBoolean("plainAuthDisallowed", requireSslDefault)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java new file mode 100644 index 00000000000..f852f3fe235 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.sasl.oidc.OAuthSaslMechanism; + +public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + OidcSASLConfiguration oidcConfiguration = parseConfiguration(serverConfiguration); + return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, new OidcJwtTokenVerifier(oidcConfiguration), + requiresSsl(serverConfiguration), invalidTokenResponse(oidcConfiguration)); + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java new file mode 100644 index 00000000000..d9bc1366da6 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java @@ -0,0 +1,130 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.oidc; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; + +/** + * OIDC bearer-token SASL mechanism. OAUTHBEARER and XOAUTH2 share the same exchange and only differ by + * their advertised name, so a single implementation is parameterized with the mechanism name. + */ +public class OAuthSaslMechanism implements SaslMechanism { + private final String name; + private final OidcJwtTokenVerifier verifier; + private final boolean requiresSsl; + private final byte[] invalidTokenResponse; + + public OAuthSaslMechanism(String name, OidcJwtTokenVerifier verifier, boolean requiresSsl) { + this(name, verifier, requiresSsl, new byte[0]); + } + + public OAuthSaslMechanism(String name, OidcJwtTokenVerifier verifier, boolean requiresSsl, byte[] invalidTokenResponse) { + this.name = name; + this.verifier = verifier; + this.requiresSsl = requiresSsl; + this.invalidTokenResponse = invalidTokenResponse.clone(); + } + + @Override + public String name() { + return name; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return !requiresSsl || channelEncrypted; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new OAuthSaslExchange(request.initialResponse(), authenticator); + } + + private class OAuthSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final SaslAuthenticator authenticator; + private Optional pendingFailure; + + private OAuthSaslExchange(Optional initialResponse, SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + this.authenticator = authenticator; + this.pendingFailure = Optional.empty(); + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + if (pendingFailure.isPresent()) { + SaslFailure failure = pendingFailure.orElseThrow(); + pendingFailure = Optional.empty(); + return new SaslStep.Failure(failure); + } + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return OIDCSASLParser.parseDecoded(new String(clientResponse, StandardCharsets.US_ASCII)) + .map(response -> { + Username authorizationId = Username.of(response.getAssociatedUser()); + return verifier.validateToken(response.getToken()) + .map(authenticationId -> authorize(authenticationId, authorizationId)) + .orElseGet(() -> invalidToken(authorizationId)); + }) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private SaslStep invalidToken(Username authorizationId) { + pendingFailure = Optional.of(SaslFailure.authenticationFailed( + Optional.empty(), Optional.of(authorizationId), "OAuth authentication failed.")); + return new SaslStep.Challenge(Optional.of(invalidTokenResponse.clone())); + } + + private SaslStep authorize(Username authenticationId, Username authorizationId) { + SaslAuthenticationResult result = authenticator.authorize(new SaslIdentity(authenticationId, authorizationId)); + return switch (result) { + case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty()); + case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure()); + }; + } + } +} diff --git a/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java new file mode 100644 index 00000000000..641171f9e59 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java @@ -0,0 +1,153 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.plain; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; + +import com.google.common.collect.ImmutableList; + +public class PlainSaslMechanism implements SaslMechanism { + public static final String NAME = SaslMechanismNames.PLAIN; + + protected record PlainCredentials(Optional authorizationId, Username authenticationId, String password) { + } + + protected static PlainCredentials credentials(Optional authorizationId, Username authenticationId, String password) { + return new PlainCredentials(authorizationId, authenticationId, password); + } + + private final boolean enabled; + private final boolean requiresSsl; + + public PlainSaslMechanism() { + this(true, false); + } + + public PlainSaslMechanism(boolean enabled, boolean requiresSsl) { + this.enabled = enabled; + this.requiresSsl = requiresSsl; + } + + @Override + public String name() { + return NAME; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return enabled && (!requiresSsl || channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new PlainSaslExchange(request.initialResponse(), this::parse, authenticator); + } + + /** + * Verifies cleartext credentials directly for protocols whose command already exposes username/password, + * for example IMAP LOGIN. + */ + public SaslStep authenticate(Username authenticationId, String password, SaslAuthenticator authenticator) { + return verify(credentials(Optional.empty(), authenticationId, password), authenticator); + } + + private static class PlainSaslExchange implements SaslExchange { + private final Optional initialResponse; + private final Function> credentialsParser; + private final SaslAuthenticator authenticator; + + private PlainSaslExchange(Optional initialResponse, + Function> credentialsParser, + SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + this.credentialsParser = credentialsParser; + this.authenticator = authenticator; + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + private SaslStep authenticate(byte[] clientResponse) { + return credentialsParser.apply(clientResponse) + .map(credentials -> verify(credentials, authenticator)) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + } + + protected static SaslStep verify(PlainCredentials credentials, SaslAuthenticator authenticator) { + SaslAuthenticationResult result = authenticator.authenticatePassword( + credentials.authenticationId(), credentials.authorizationId(), credentials.password()); + return switch (result) { + case SaslAuthenticationResult.Success success -> new SaslStep.Success(success.identity(), Optional.empty()); + case SaslAuthenticationResult.Failure failure -> new SaslStep.Failure(failure.failure()); + }; + } + + protected Optional parse(byte[] clientResponse) { + ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) + .collect(ImmutableList.toImmutableList()); + + // Preserve legacy SMTP compatibility: some clients send a trailing NUL after the password. + if (tokens.size() == 4 && tokens.get(3).isEmpty()) { + return credentials(tokens.subList(0, 3)); + } + return credentials(tokens); + } + + private Optional credentials(List tokens) { + if (tokens.size() == 2) { + return Optional.of(credentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1))); + } + if (tokens.size() == 3) { + Optional authorizationId = Optional.of(tokens.get(0)) + .filter(value -> !value.isEmpty()) + .map(Username::of); + return Optional.of(credentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2))); + } + return Optional.empty(); + } +} diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java new file mode 100644 index 00000000000..97c89b143c0 --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java @@ -0,0 +1,193 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.oidc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class OidcSaslMechanismTest { + private static final Username USER = Username.of("user@example.com"); + private static final Username TOKEN_SUBJECT = Username.of("token-subject@example.com"); + private static final String TOKEN = "token"; + private static final byte[] INVALID_TOKEN_RESPONSE = bytes("{\"status\":\"invalid_token\"}"); + + @Test + void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { + // GIVEN a decoded OAUTHBEARER initial response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = mechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it returns the authorized identity directly to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty())); + } + + @Test + void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() { + // GIVEN a decoded XOAUTH2 initial response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.XOAUTH2, + Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + + // WHEN the mechanism consumes and validates the response + SaslStep step = mechanism(SaslMechanismNames.XOAUTH2, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it exposes the same authorized identity shape + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(TOKEN_SUBJECT, USER), Optional.empty())); + } + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN an OIDC SASL exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = mechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldFailMalformedResponse() { + // GIVEN a malformed OIDC SASL response + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("invalid"))); + + // WHEN the mechanism consumes the response + SaslStep step = mechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + + // THEN it fails before any token validation side effect + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + @Test + void shouldReturnInvalidTokenChallengeThenFailWhenTokenIsRejected() { + // GIVEN an OIDC SASL response with an invalid bearer token + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + SaslExchange exchange = mechanism(SaslMechanismNames.OAUTHBEARER, rejectingToken()).start(request, authorizing()); + + // WHEN token validation rejects the token + SaslStep firstStep = exchange.firstStep(); + SaslStep secondStep = exchange.onResponse(new byte[0]); + + // THEN the mechanism owns the OIDC error challenge before returning the typed authentication failure + assertThat(firstStep).isInstanceOfSatisfying(SaslStep.Challenge.class, + challenge -> assertThat(challenge.payload()).hasValueSatisfying(payload -> assertThat(payload).containsExactly(INVALID_TOKEN_RESPONSE))); + assertThat(secondStep).isEqualTo(new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.of(USER), "OAuth authentication failed."))); + } + + @Test + void shouldReturnAuthorizationFailure() { + // GIVEN a valid token but a James authorization rule rejecting the requested identity + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, + Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); + SaslFailure failure = SaslFailure.delegationForbidden(TOKEN_SUBJECT, USER, "forbidden"); + + // WHEN authorization rejects the identity + SaslStep step = mechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + + // THEN the failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + @Test + void shouldBeUnavailableOnClearTransportWhenSslIsRequired() { + OAuthSaslMechanism mechanism = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken(), true); + + assertThat(mechanism.isAvailableOnTransport(false)).isFalse(); + assertThat(mechanism.isAvailableOnTransport(true)).isTrue(); + } + + private static OAuthSaslMechanism mechanism(String mechanismName, OidcJwtTokenVerifier verifier) { + return new OAuthSaslMechanism(mechanismName, verifier, false, INVALID_TOKEN_RESPONSE); + } + + private static OidcJwtTokenVerifier verifyingToken() { + return new TestOidcJwtTokenVerifier(Optional.of(TOKEN_SUBJECT)); + } + + private static OidcJwtTokenVerifier rejectingToken() { + return new TestOidcJwtTokenVerifier(Optional.empty()); + } + + private static SaslAuthenticator authorizing() { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejectingAuthorization(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "unused")); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Failure(failure); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static class TestOidcJwtTokenVerifier extends OidcJwtTokenVerifier { + private final Optional validateTokenResult; + + private TestOidcJwtTokenVerifier(Optional validateTokenResult) { + super(null); + this.validateTokenResult = validateTokenResult; + } + + @Override + public Optional validateToken(String token) { + return validateTokenResult; + } + } +} diff --git a/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java new file mode 100644 index 00000000000..066c47168b3 --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java @@ -0,0 +1,194 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl.plain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class PlainSaslMechanismTest { + private static final Username AUTHENTICATION_ID = Username.of("user@example.com"); + private static final Username AUTHORIZATION_ID = Username.of("delegated@example.com"); + private static final String PASSWORD = "secret"; + + private final PlainSaslMechanism testee = new PlainSaslMechanism(); + + @Test + void shouldBeAvailableOnClearTransportByDefault() { + assertThat(testee.isAvailableOnTransport(false)).isTrue(); + } + + @Test + void shouldNotBeAvailableOnClearTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(false)).isFalse(); + } + + @Test + void shouldBeAvailableOnEncryptedTransportWhenSslIsRequired() { + assertThat(new PlainSaslMechanism(true, true).isAvailableOnTransport(true)).isTrue(); + } + + @Test + void shouldNotBeAvailableWhenDisabled() { + assertThat(new PlainSaslMechanism(false, false).isAvailableOnTransport(true)).isFalse(); + } + + @Test + void shouldChallengeWhenNoInitialResponse() { + // GIVEN a PLAIN exchange without SASL-IR + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + + // WHEN the mechanism starts + SaslStep firstStep = testee.start(request, authenticating()).firstStep(); + + // THEN the server asks for one client response + assertThat(firstStep).isEqualTo(new SaslStep.Challenge(Optional.empty())); + } + + @Test + void shouldAuthenticateInitialResponseWithoutDelegation() { + // GIVEN a valid PLAIN initial response without an authorization identity + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the initial response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it authenticates through the shared SASL authenticator and returns the authenticated identity + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldAuthenticateContinuationResponseWithDelegation() { + // GIVEN a PLAIN exchange waiting for the client response + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, Optional.empty()); + SaslExchange exchange = testee.start(request, authenticating()); + + // WHEN the client sends a response with an authorization identity + SaslStep step = exchange.onResponse(bytes(AUTHORIZATION_ID.asString() + "\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD)); + + // THEN both identities are preserved after mechanism-owned authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHORIZATION_ID), Optional.empty())); + } + + @Test + void shouldAcceptTwoPartResponseWithoutAuthorizationIdentity() { + // GIVEN a PLAIN response encoded as authcid/password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes(AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it treats the response as non-delegated authentication + assertThat(step).isEqualTo(new SaslStep.Success(new SaslIdentity(AUTHENTICATION_ID, AUTHENTICATION_ID), Optional.empty())); + } + + @Test + void shouldPassPasswordUnmodifiedToAuthenticator() { + // GIVEN a PLAIN response whose password is made of whitespace only + AtomicReference capturedPassword = new AtomicReference<>(); + String whitespacePassword = " "; + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + whitespacePassword))); + + // WHEN the mechanism consumes the response + testee.start(request, authenticating(capturedPassword)).firstStep(); + + // THEN the password is kept unchanged instead of being filtered as blank + assertThat(capturedPassword).hasValue(whitespacePassword); + } + + @Test + void shouldReturnAuthenticatorFailure() { + // GIVEN a valid PLAIN response but an authenticator rejecting the password + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("\0" + AUTHENTICATION_ID.asString() + "\0" + PASSWORD))); + SaslFailure failure = SaslFailure.invalidCredentials(AUTHENTICATION_ID, Optional.empty(), "rejected"); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, rejecting(failure)).firstStep(); + + // THEN the typed failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + @Test + void shouldFailMalformedResponse() { + // GIVEN a PLAIN response without the expected separators + SaslInitialRequest request = new SaslInitialRequest(PlainSaslMechanism.NAME, + Optional.of(bytes("missing-separators"))); + + // WHEN the mechanism consumes the response + SaslStep step = testee.start(request, authenticating()).firstStep(); + + // THEN it fails before calling protocol-neutral authentication + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private static SaslAuthenticator authenticating() { + return authenticating(new AtomicReference<>()); + } + + private static SaslAuthenticator authenticating(AtomicReference capturedPassword) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + capturedPassword.set(password); + return new SaslAuthenticationResult.Success(new SaslIdentity(authenticationId, authorizationId.orElse(authenticationId))); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static SaslAuthenticator rejecting(SaslFailure failure) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + return new SaslAuthenticationResult.Failure(failure); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + return new SaslAuthenticationResult.Success(identity); + } + }; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java index a0eb940633e..32ae29440f1 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfiguration.java @@ -22,10 +22,8 @@ package org.apache.james.protocols.smtp; import java.util.Locale; -import java.util.Optional; import java.util.Set; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.api.ProtocolConfiguration; import com.google.common.collect.ImmutableSet; @@ -97,10 +95,6 @@ public static SenderVerificationMode parse(String value) { */ boolean useAddressBracketsEnforcement(); - boolean isPlainAuthEnabled(); - - Optional saslConfiguration(); - default Set disabledFeatures() { return ImmutableSet.of(); } diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java index 3890210d72c..fe082f47236 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPConfigurationImpl.java @@ -20,9 +20,6 @@ package org.apache.james.protocols.smtp; -import java.util.Optional; - -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.api.ProtocolConfigurationImpl; /** @@ -86,13 +83,4 @@ public void setUseAddressBracketsEnforcement(boolean bracketsEnforcement) { this.bracketsEnforcement = bracketsEnforcement; } - @Override - public boolean isPlainAuthEnabled() { - return true; - } - - @Override - public Optional saslConfiguration() { - return Optional.empty(); - } } diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java index 098d1a97bd1..e17fdc847a5 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPProtocolHandlerChain.java @@ -19,13 +19,11 @@ package org.apache.james.protocols.smtp; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import org.apache.james.metrics.api.MetricFactory; import org.apache.james.protocols.api.handler.CommandDispatcher; import org.apache.james.protocols.api.handler.CommandHandlerResultLogger; -import org.apache.james.protocols.api.handler.ExtensibleHandler; import org.apache.james.protocols.api.handler.ProtocolHandler; import org.apache.james.protocols.api.handler.ProtocolHandlerChain; import org.apache.james.protocols.api.handler.ProtocolHandlerChainImpl; @@ -49,7 +47,6 @@ import org.apache.james.protocols.smtp.core.esmtp.EhloCmdHandler; import org.apache.james.protocols.smtp.core.esmtp.MailSizeEsmtpExtension; import org.apache.james.protocols.smtp.core.esmtp.StartTlsCmdHandler; -import org.apache.james.protocols.smtp.hook.AuthHook; import org.apache.james.protocols.smtp.hook.Hook; /** @@ -87,6 +84,7 @@ public SMTPProtocolHandlerChain(MetricFactory metricFactory, Hook... hooks) thro protected List initDefaultHandlers() { List defaultHandlers = new ArrayList<>(); defaultHandlers.add(new CommandDispatcher()); + defaultHandlers.add(new AuthCmdHandler()); defaultHandlers.add(new ExpnCmdHandler()); defaultHandlers.add(new EhloCmdHandler(metricFactory)); defaultHandlers.add(new HeloCmdHandler(metricFactory)); @@ -109,45 +107,4 @@ protected List initDefaultHandlers() { return defaultHandlers; } - private synchronized boolean checkForAuth(ProtocolHandler handler) { - if (isReadyOnly()) { - throw new UnsupportedOperationException("Read-Only"); - } - if (handler instanceof AuthHook) { - // check if we need to add the AuthCmdHandler - List handlers = getHandlers(ExtensibleHandler.class); - for (ExtensibleHandler h: handlers) { - if (h.getMarkerInterfaces().contains(AuthHook.class)) { - return true; - } - } - if (!add(new AuthCmdHandler())) { - return false; - } - } - return true; - } - - @Override - public boolean add(ProtocolHandler handler) { - checkForAuth(handler); - return super.add(handler); - } - - @Override - public boolean addAll(Collection c) { - return c.stream().allMatch(this::checkForAuth) && super.addAll(c); - } - - @Override - public boolean addAll(int index, Collection c) { - return c.stream().allMatch(this::checkForAuth) && super.addAll(index, c); - } - - @Override - public void add(int index, ProtocolHandler element) { - checkForAuth(element); - super.add(index, element); - } - } diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java index 960bab9085e..dfdefa0f7ee 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java @@ -91,8 +91,6 @@ default boolean isUnAuthorized(int priority) { int getRcptCount(); - boolean supportsOAuth(); - long currentMessageSize(); void setCurrentMessageSize(long increment); diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java index b5b35ac5c92..1d13efdb647 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSessionImpl.java @@ -86,11 +86,6 @@ public int getRcptCount() { .orElse(0); } - @Override - public boolean supportsOAuth() { - return getConfiguration().saslConfiguration().isPresent() && isAuthAnnounced(); - } - @Override public boolean isAuthAnnounced() { return getConfiguration().isAuthAnnounced(getRemoteAddress().getAddress().getHostAddress(), isTLSStarted()); diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java index 95055945cd4..e17d6a5bf58 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandler.java @@ -21,24 +21,28 @@ package org.apache.james.protocols.smtp.core.esmtp; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Optional; -import java.util.function.Function; +import java.util.stream.Stream; -import org.apache.commons.lang3.StringUtils; import org.apache.james.core.Username; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.api.Request; import org.apache.james.protocols.api.Response; import org.apache.james.protocols.api.handler.CommandHandler; import org.apache.james.protocols.api.handler.ExtensibleHandler; import org.apache.james.protocols.api.handler.LineHandler; import org.apache.james.protocols.api.handler.WiringException; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; import org.apache.james.protocols.smtp.SMTPResponse; import org.apache.james.protocols.smtp.SMTPRetCode; import org.apache.james.protocols.smtp.SMTPSession; @@ -46,15 +50,13 @@ import org.apache.james.protocols.smtp.hook.AuthHook; import org.apache.james.protocols.smtp.hook.HookResult; import org.apache.james.protocols.smtp.hook.HookResultHook; -import org.apache.james.protocols.smtp.hook.HookReturnCode; import org.apache.james.protocols.smtp.hook.MailParametersHook; +import org.apache.james.protocols.smtp.hook.SaslAuthResultHook; import org.apache.james.util.AuditTrail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; -import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -63,10 +65,7 @@ /** * handles AUTH command * - * Note: we could extend this to use java5 sasl standard libraries and provide client - * support against a server implemented via non-james specific hooks. - * This would allow us to reuse hooks between imap4/pop3/smtp and eventually different - * system (simple pluggabilty against external authentication services). + * Authentication is delegated to configured SASL mechanisms. */ public class AuthCmdHandler implements CommandHandler, EhloExtension, ExtensibleHandler, MailParametersHook { @@ -80,11 +79,14 @@ public class AuthCmdHandler private static final Response ALREADY_AUTH = new SMTPResponse(SMTPRetCode.BAD_SEQUENCE, DSNStatus.getStatus(DSNStatus.PERMANENT,DSNStatus.DELIVERY_OTHER) + " User has previously authenticated. " + " Further authentication is not required!").immutable(); private static final Response SYNTAX_ERROR = new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, DSNStatus.getStatus(DSNStatus.PERMANENT,DSNStatus.DELIVERY_INVALID_ARG) + " Usage: AUTH (authentication type) ").immutable(); - private static final Response AUTH_READY_PLAIN = new SMTPResponse(SMTPRetCode.AUTH_READY, "OK. Continue authentication").immutable(); private static final Response AUTH_READY_USERNAME_LOGIN = new SMTPResponse(SMTPRetCode.AUTH_READY, "VXNlcm5hbWU6").immutable(); // base64 encoded "Username:" private static final Response AUTH_READY_PASSWORD_LOGIN = new SMTPResponse(SMTPRetCode.AUTH_READY, "UGFzc3dvcmQ6").immutable(); // base64 encoded "Password: + private static final Response AUTH_SUCCEEDED = new SMTPResponse(SMTPRetCode.AUTH_OK, "Authentication Successful").immutable(); private static final Response AUTH_FAILED = new SMTPResponse(SMTPRetCode.AUTH_FAILED, "Authentication Failed").immutable(); private static final Response UNKNOWN_AUTH_TYPE = new SMTPResponse(SMTPRetCode.PARAMETER_NOT_IMPLEMENTED, "Unrecognized Authentication Type").immutable(); + private static final Response SERVER_ERROR = new SMTPResponse(SMTPRetCode.LOCAL_ERROR, "Unable to process request").immutable(); + + private static final SmtpSaslBridge SASL_BRIDGE = new SmtpSaslBridge(); private abstract static class AbstractSMTPLineHandler implements LineHandler { @@ -122,18 +124,18 @@ private Response handleCommand(SMTPSession session, String line) { */ protected static final String AUTH_TYPE_LOGIN = "LOGIN"; - /** - * The text string for the SMTP AUTH type OAUTHBEARER. - */ - protected static final String AUTH_TYPE_OAUTHBEARER = "OAUTHBEARER"; - protected static final String AUTH_TYPE_XOAUTH2 = "XOAUTH2"; - - /** - * The AuthHooks - */ - private List hooks; - - private List rHooks; + private ImmutableList saslMechanisms = ImmutableList.of(); + private ImmutableList effectiveSaslMechanisms = ImmutableList.of(); + private Optional saslAuthenticator = Optional.empty(); + private ImmutableList authHooks = ImmutableList.of(); + private ImmutableList hookResultHooks = ImmutableList.of(); + private ImmutableList saslAuthResultHooks = ImmutableList.of(); + + public void configureSaslMechanisms(ImmutableList saslMechanisms, SaslAuthenticator saslAuthenticator) { + this.saslMechanisms = saslMechanisms; + this.saslAuthenticator = Optional.of(saslAuthenticator); + updateEffectiveSaslMechanisms(); + } /** * handles AUTH command @@ -165,356 +167,273 @@ private Response doAUTH(SMTPSession session, String argument) { argument = argument.substring(0,argument.indexOf(" ")); } String authType = argument.toUpperCase(Locale.US); - if (authType.equals(AUTH_TYPE_PLAIN) && session.getConfiguration().isPlainAuthEnabled()) { - return handlePlainContinuation(session, initialResponse); - } else if (authType.equals(AUTH_TYPE_LOGIN) && session.getConfiguration().isPlainAuthEnabled()) { - return handleLoginAuthContinuation(session, initialResponse); - } else if ((authType.equals(AUTH_TYPE_OAUTHBEARER) || authType.equals(AUTH_TYPE_XOAUTH2)) && session.supportsOAuth()) { - return handleOauth2Continuation(session, initialResponse); - } else { - return doUnknownAuth(authType); - } + return handleSaslAuthentication(session, authType, Optional.ofNullable(initialResponse).map(String::trim)); } } - private Response handlePlainContinuation(SMTPSession session, String initialResponse) { - return Optional.ofNullable(initialResponse) - .map(String::trim) - .map(userpass -> doPlainAuth(session, userpass)) - .orElseGet(() -> { - session.pushLineHandler(new AbstractSMTPLineHandler() { - @Override - protected Response onCommand(SMTPSession session, String l) { - return doPlainAuth(session, l); - } - }); - return AUTH_READY_USERNAME_LOGIN; - }); - } + private Response handleSaslAuthentication(SMTPSession session, String authType, Optional initialResponse) { + if (authType.equals(AUTH_TYPE_LOGIN)) { + return handleLoginFraming(session, initialResponse); + } - private Response handleLoginAuthContinuation(SMTPSession session, String initialResponse) { - return Optional.ofNullable(initialResponse) - .map(String::trim) - .map(user -> doLoginAuthPass(session, user)) - .orElseGet(() -> { - session.pushLineHandler(new AbstractSMTPLineHandler() { - @Override - protected Response onCommand(SMTPSession session, String l) { - return doLoginAuthPass(session, l); - } - }); - return AUTH_READY_USERNAME_LOGIN; - }); + Optional maybeMechanism = findAvailableMechanism(session, authType); + if (maybeMechanism.isEmpty()) { + return doUnknownAuth(authType); + } + + SaslMechanism mechanism = maybeMechanism.get(); + SaslExchange exchange; + try { + SaslInitialRequest request = SASL_BRIDGE.initialRequest(authType, initialResponse); + exchange = startExchange(session, mechanism, request); + return handleFirstSaslStep(session, authType, exchange); + } catch (IllegalArgumentException e) { + LOGGER.info("Could not decode parameters for AUTH {}", authType, e); + return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH " + authType); + } } - private Response handleOauth2Continuation(SMTPSession session, String initialResponse) { - return Optional.ofNullable(initialResponse) - .map(token -> doOauth2Authentication(session, token)) - .orElseGet(() -> { - session.pushLineHandler(new AbstractSMTPLineHandler() { - @Override - protected Response onCommand(SMTPSession session, String l) { - Response response = doOauth2Authentication(session, l); - session.popLineHandler(); - return response; - } - }); - return new SMTPResponse(SMTPRetCode.AUTH_READY, ""); - }); + private Response handleFirstSaslStep(SMTPSession session, String authType, SaslExchange exchange) { + try { + SaslStep step = exchange.firstStep(); + return handleSaslStep(session, authType, exchange, step); + } catch (RuntimeException e) { + SASL_BRIDGE.close(exchange); + throw e; + } } - private Response doOauth2Authentication(SMTPSession session, String initialResponseString) { - return session.getConfiguration().saslConfiguration() - .map(oidcSASLConfiguration -> hooks.stream() - .flatMap(hook -> Optional.ofNullable(executeHook(session, hook, - hook2 -> hook2.doSasl(session, oidcSASLConfiguration, initialResponseString))).stream()) - .filter(response -> !SMTPRetCode.AUTH_FAILED.equals(response.getRetCode())) - .findFirst() - .orElseGet(() -> failSasl(oidcSASLConfiguration, session))) - .orElseGet(() -> doUnknownAuth(AUTH_TYPE_OAUTHBEARER)); + private Response handleSaslStep(SMTPSession session, String authType, SaslExchange exchange, SaslStep step) { + return switch (step) { + case SaslStep.Challenge challenge -> { + session.pushLineHandler(saslLineHandler(authType, exchange)); + yield SASL_BRIDGE.challenge(challenge); + } + case SaslStep.Success success -> handleSaslSuccess(session, authType, exchange, success); + case SaslStep.Failure failure -> handleSaslFailure(session, authType, exchange, failure.failure()); + }; } - private Response failSasl(OidcSASLConfiguration saslConfiguration, SMTPSession session) { - String rawResponse = String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", - saslConfiguration.getScope(), - saslConfiguration.getOidcConfigurationURL().toString()); + private Response handleSaslContinuation(SMTPSession session, String authType, SaslExchange exchange, String line) { + try { + SaslStep step = SASL_BRIDGE.onClientResponse(exchange, line.getBytes(session.getCharset())); + return switch (step) { + case SaslStep.Challenge challenge -> SASL_BRIDGE.challenge(challenge); + case SaslStep.Success success -> { + session.popLineHandler(); + yield handleSaslSuccess(session, authType, exchange, success); + } + case SaslStep.Failure failure -> { + session.popLineHandler(); + yield handleSaslFailure(session, authType, exchange, failure.failure()); + } + }; + } catch (IllegalArgumentException e) { + LOGGER.info("Could not decode parameters for AUTH {}", authType, e); + session.popLineHandler(); + SASL_BRIDGE.close(exchange); + return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH " + authType); + } catch (RuntimeException e) { + session.popLineHandler(); + SASL_BRIDGE.close(exchange); + throw e; + } + } - session.pushLineHandler(new AbstractSMTPLineHandler() { - @Override - protected Response onCommand(SMTPSession session, String l) { + private LineHandler saslLineHandler(String authType, SaslExchange exchange) { + return (session, line) -> { + if (SASL_BRIDGE.isAbort(line)) { session.popLineHandler(); - - return AUTH_FAILED; + SASL_BRIDGE.abort(exchange); + return AUTH_ABORTED; } - }); - return new SMTPResponse("334", Base64.getEncoder().encodeToString(rawResponse.getBytes())); + return handleSaslContinuation(session, authType, exchange, new String(line, session.getCharset())); + }; } - /** - * Carries out the Plain AUTH SASL exchange. - * - * According to RFC 2595 the client must send: [authorize-id] \0 authenticate-id \0 password. - * - * >>> AUTH PLAIN dGVzdAB0ZXN0QHdpei5leGFtcGxlLmNvbQB0RXN0NDI= - * Decoded: test\000test@wiz.example.com\000tEst42 - * - * >>> AUTH PLAIN dGVzdAB0ZXN0AHRFc3Q0Mg== - * Decoded: test\000test\000tEst42 - * - * @param session SMTP session object - * @param line the initial response line passed in with the AUTH command - */ - private Response doPlainAuth(SMTPSession session, String line) { + private Response handleSaslSuccess(SMTPSession session, String authType, SaslExchange exchange, SaslStep.Success success) { + if (success.serverData().isPresent()) { + session.pushLineHandler(successDataAcknowledgementLineHandler(authType, exchange, success)); + return SASL_BRIDGE.successData(success); + } try { + return applySaslSuccess(session, authType, exchange, success); + } finally { + SASL_BRIDGE.close(exchange); + } + } - AuthValues authValues = - Optional.ofNullable(decodeBase64(line)) - .flatMap(AuthCmdHandler::parseAuthValues) - .orElseThrow(() -> new IllegalArgumentException("Can't parse line as authentication values")); - - Response response; + private LineHandler successDataAcknowledgementLineHandler(String authType, SaslExchange exchange, SaslStep.Success success) { + return (session, line) -> handleSaslSuccessDataAcknowledgement(session, authType, exchange, success, new String(line, session.getCharset())); + } - if (authValues.password.isEmpty()) { - response = doDelegation(session, authValues.username); - } else { - response = doAuthTest(session, Optional.of(authValues.username), authValues.password, AUTH_TYPE_PLAIN); + private Response handleSaslSuccessDataAcknowledgement(SMTPSession session, String authType, SaslExchange exchange, + SaslStep.Success success, String line) { + session.popLineHandler(); + boolean aborted = false; + try { + byte[] bytes = line.getBytes(session.getCharset()); + if (SASL_BRIDGE.isAbort(bytes)) { + aborted = true; + SASL_BRIDGE.abort(exchange); + return AUTH_ABORTED; + } + if (!SASL_BRIDGE.isEmptyClientResponse(bytes)) { + return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH " + authType); + } + return applySaslSuccess(session, authType, exchange, success); + } finally { + if (!aborted) { + SASL_BRIDGE.close(exchange); } - - session.popLineHandler(); - return response; - } catch (Exception e) { - LOGGER.info("Could not decode parameters for AUTH PLAIN", e); - return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH PLAIN"); } } - @VisibleForTesting - static Optional parseAuthValues(String input) { - - List parts = Splitter.on('\0').splitToStream(input).filter(token -> !token.isBlank()).toList(); - - return switch (parts.size()) { - case 1 -> Optional.of(new AuthValues(Username.of(parts.get(0)), Optional.empty())); - // If we got here, this is what happened. RFC 2595 - // says that "the client may leave the authorization - // identity empty to indicate that it is the same as - // the authentication identity." As noted above, - // that would be represented as a decoded string of - // the form: "\0authenticate-id\0password". The - // first call to nextToken will skip the empty - // authorize-id, and give us the authenticate-id, - // which we would store as the authorize-id. The - // second call will give us the password, which we - // think is the authenticate-id (user). Then when - // we ask for the password, there are no more - // elements, leading to the exception we just - // caught. So we need to move the user to the - // password, and the authorize_id to the user. - case 2 -> Optional.of(new AuthValues(Username.of(parts.get(0)), Optional.of(parts.get(1)))); - case 3 -> Optional.of(new AuthValues(Username.of(parts.get(1)), Optional.of(parts.get(2)))); - default -> Optional.empty(); - }; + private Response applySaslSuccess(SMTPSession session, String authType, SaslExchange exchange, SaslStep.Success success) { + Username username = success.identity().authorizationId(); + session.setUsername(username); + session.setRelayingAllowed(true); + saslAuthResultHooks.forEach(hook -> hook.onSuccess(session, authType, success.identity())); + + AUTHENTICATION_DEDICATED_LOGGER.debug("AUTH method {} succeeded", authType); + + AuditTrail.entry() + .username(username::asString) + .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress())) + .sessionId(session::getSessionID) + .protocol("SMTP") + .action("AUTH") + .parameters(() -> ImmutableMap.of("authType", authType)) + .log("SMTP Authentication succeeded."); + + return AuthHookSaslMechanism.terminalResponse(exchange) + .orElse(AUTH_SUCCEEDED); } - record AuthValues(Username username, Optional password) { + private Response handleSaslFailure(SMTPSession session, String authType, SaslExchange exchange, SaslFailure failure) { + try { + saslAuthResultHooks.forEach(hook -> hook.onFailure(session, authType, failure)); + + failure.authenticationId().ifPresent(username -> AuditTrail.entry() + .username(username::asString) + .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress())) + .protocol("SMTP") + .action("AUTH") + .parameters(() -> ImmutableMap.of("authType", authType)) + .log("SMTP Authentication failed.")); + + return AuthHookSaslMechanism.terminalResponse(exchange) + .orElseGet(() -> switch (failure.type()) { + case MALFORMED -> new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH " + authType); + case SERVER_ERROR -> SERVER_ERROR; + case INVALID_CREDENTIALS, AUTHENTICATION_FAILED, USER_DOES_NOT_EXIST, DELEGATION_FORBIDDEN -> AUTH_FAILED; + }); + } finally { + SASL_BRIDGE.close(exchange); + } } - private String decodeBase64(String line) { - if (line != null) { - String lineWithoutTrailingCrLf = StringUtils.replace(line, "\r\n", ""); - return new String(Base64.getDecoder().decode(lineWithoutTrailingCrLf), StandardCharsets.UTF_8); + private Response handleLoginFraming(SMTPSession session, Optional initialResponse) { + Optional plain = findAvailableMechanism(session, SaslMechanismNames.PLAIN); + if (plain.isEmpty()) { + return doUnknownAuth(AUTH_TYPE_LOGIN); } - return null; + + return initialResponse + .map(user -> promptLoginPasswordThenDelegateToPlain(session, plain.get(), user)) + .orElseGet(() -> { + session.pushLineHandler(new AbstractSMTPLineHandler() { + @Override + protected Response onCommand(SMTPSession session, String line) { + session.popLineHandler(); + return promptLoginPasswordThenDelegateToPlain(session, plain.get(), line); + } + }); + return AUTH_READY_USERNAME_LOGIN; + }); } - /** - * Carries out the Login AUTH SASL exchange. - * - * @param session SMTP session object - * @param user the user passed in with the AUTH command - */ - private Response doLoginAuthPass(SMTPSession session, String user) { - session.popLineHandler(); + private Response promptLoginPasswordThenDelegateToPlain(SMTPSession session, SaslMechanism plain, String encodedUsername) { session.pushLineHandler(new AbstractSMTPLineHandler() { @Override - protected Response onCommand(SMTPSession session, String l) { - return doLoginAuthPassCheck(session, asUsername(user), l); + protected Response onCommand(SMTPSession session, String encodedPassword) { + session.popLineHandler(); + return delegateLoginCredentialsToPlain(session, plain, decodeLoginUsername(encodedUsername), encodedPassword); } }); return AUTH_READY_PASSWORD_LOGIN; } - private Optional asUsername(String user) { - try { - return Optional.of(Username.of(decodeBase64(user))); - } catch (Exception e) { - LOGGER.info("Failed parsing base64 username {}", user, e); - return Optional.empty(); + private Response delegateLoginCredentialsToPlain(SMTPSession session, SaslMechanism plain, Optional username, String encodedPassword) { + Optional password = decodeLoginPassword(username, encodedPassword); + if (username.isEmpty() || password.isEmpty()) { + return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS, "Could not decode parameters for AUTH " + AUTH_TYPE_LOGIN); } + SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.PLAIN, + Optional.of(toPlainInitialResponse(username.get(), password.get()))); + SaslExchange exchange = startExchange(session, plain, request); + return handleFirstSaslStep(session, AUTH_TYPE_LOGIN, exchange); } - private Response doLoginAuthPassCheck(SMTPSession session, Optional username, String pass) { - session.popLineHandler(); - // Authenticate user - return doAuthTest(session, username, sanitizePassword(username, pass), "LOGIN"); + private byte[] toPlainInitialResponse(Username username, String password) { + return ("\0" + username.asString() + "\0" + password).getBytes(StandardCharsets.UTF_8); } - private Optional sanitizePassword(Optional username, String pass) { + private Optional decodeLoginUsername(String response) { try { - return Optional.of(decodeBase64(pass)); - } catch (Exception e) { - LOGGER.info("Failed parsing base64 password for user {}", username, e); - // Ignored - this parse error will be - // addressed in the if clause below + return Optional.of(Username.of(decodeSaslLoginResponse(response))); + } catch (IllegalArgumentException e) { + LOGGER.info("Could not decode LOGIN username", e); return Optional.empty(); } } - protected Response doDelegation(SMTPSession session, Username username) { - List hooks = Optional.ofNullable(getHooks()) - .orElse(List.of()); - - for (AuthHook rawHook : hooks) { - rawHook.doDelegation(session, username); - Response res = executeHook(session, rawHook, hook -> rawHook.doDelegation(session, username)); - - if (res != null) { - if (SMTPRetCode.AUTH_FAILED.equals(res.getRetCode())) { - LOGGER.warn("{} was not authorized to connect as {}", session.getUsername(), username); - } else if (SMTPRetCode.AUTH_OK.equals(res.getRetCode())) { - LOGGER.info("{} was authorized to connect as {}", session.getUsername(), username); - } - return res; - } + private Optional decodeLoginPassword(Optional username, String response) { + try { + return Optional.of(decodeSaslLoginResponse(response)); + } catch (IllegalArgumentException e) { + LOGGER.info("Could not decode LOGIN password for user {}", username, e); + return Optional.empty(); } + } - LOGGER.info("DELEGATE failed from {}@{}", username, session.getRemoteAddress().getAddress().getHostAddress()); - return AUTH_FAILED; + private String decodeSaslLoginResponse(String response) { + return new String(Base64.getDecoder().decode(response.replace("\r\n", "")), StandardCharsets.UTF_8); } - protected Response doAuthTest(SMTPSession session, Optional username, Optional pass, String authType) { - if (username.isEmpty() || pass.isEmpty()) { - return new SMTPResponse(SMTPRetCode.SYNTAX_ERROR_ARGUMENTS,"Could not decode parameters for AUTH " + authType); - } + private Optional findAvailableMechanism(SMTPSession session, String authType) { + return effectiveSaslMechanisms + .stream() + .filter(mechanism -> mechanism.name().equalsIgnoreCase(authType)) + .filter(mechanism -> mechanism.isAvailableOnTransport(session.isTLSStarted())) + .findFirst(); + } - List hooks = getHooks(); - - if (hooks != null) { - for (AuthHook rawHook : hooks) { - Response res = executeHook(session, rawHook, hook -> hook.doAuth(session, username.get(), pass.get())); - - if (res != null) { - if (SMTPRetCode.AUTH_FAILED.equals(res.getRetCode())) { - AUTHENTICATION_DEDICATED_LOGGER.info("AUTH method {} failed", authType); - } else if (SMTPRetCode.AUTH_OK.equals(res.getRetCode())) { - // TODO: Make this string a more useful debug message - AUTHENTICATION_DEDICATED_LOGGER.debug("AUTH method {} succeeded", authType); - - AuditTrail.entry() - .username(username.get()::asString) - .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress())) - .sessionId(session::getSessionID) - .protocol("SMTP") - .action("AUTH") - .parameters(() -> ImmutableMap.of("authType", authType)) - .log("SMTP Authentication succeeded."); - } - return res; - } - } + private SaslExchange startExchange(SMTPSession session, SaslMechanism mechanism, SaslInitialRequest request) { + if (mechanism instanceof AuthHookSaslMechanism authHookSaslMechanism) { + return authHookSaslMechanism.start(request, session); } - - AUTHENTICATION_DEDICATED_LOGGER.info("AUTH method {} failed from {}@{}", authType, username, session.getRemoteAddress().getAddress().getHostAddress()); - - AuditTrail.entry() - .username(username.get()::asString) - .remoteIP(() -> Optional.ofNullable(session.getRemoteAddress())) - .protocol("SMTP") - .action("AUTH") - .parameters(() -> ImmutableMap.of("authType", authType)) - .log("SMTP Authentication failed."); - - return AUTH_FAILED; + return mechanism.start(request, saslAuthenticator()); } - private Response executeHook(SMTPSession session, AuthHook rawHook, Function tc) { - LOGGER.debug("executing hook {}", rawHook); - - long start = System.currentTimeMillis(); - HookResult hRes = tc.apply(rawHook); - long executionTime = System.currentTimeMillis() - start; - - HookResult finalHookResult = Optional.ofNullable(rHooks) - .orElse(ImmutableList.of()).stream() - .peek(rHook -> LOGGER.debug("executing hook {}", rHook)) - .reduce(hRes, (a, b) -> b.onHookResult(session, a, executionTime, rawHook), (a, b) -> { - throw new UnsupportedOperationException(); - }); - - return calcDefaultSMTPResponse(finalHookResult); + private SaslAuthenticator saslAuthenticator() { + return saslAuthenticator + .orElseThrow(() -> new IllegalStateException("SASL authenticator is not configured")); } - /** - * Calculate the SMTPResponse for the given result - * - * @param result the HookResult which should converted to SMTPResponse - * @return the calculated SMTPResponse for the given HookReslut - */ - protected Response calcDefaultSMTPResponse(HookResult result) { - if (result != null) { - HookReturnCode returnCode = result.getResult(); - - String smtpReturnCode = Optional.ofNullable(result.getSmtpRetCode()) - .or(() -> retrieveDefaultSmtpReturnCode(returnCode)) - .orElse(null); - - String smtpDescription = Optional.ofNullable(result.getSmtpDescription()) - .or(() -> retrieveDefaultSmtpDescription(returnCode)) - .orElse(null); - - if (HookReturnCode.Action.ACTIVE_ACTIONS.contains(returnCode.getAction())) { - SMTPResponse response = new SMTPResponse(smtpReturnCode, smtpDescription); - - if (returnCode.isDisconnected()) { - response.setEndSession(true); - } - return response; - } else if (returnCode.isDisconnected()) { - return Response.DISCONNECT; - } - } - return null; - + private void updateEffectiveSaslMechanisms() { + this.effectiveSaslMechanisms = saslMechanisms.stream() + .map(this::adaptPlainMechanismForLegacyAuthHooks) + .collect(ImmutableList.toImmutableList()); } - private Optional retrieveDefaultSmtpDescription(HookReturnCode returnCode) { - switch (returnCode.getAction()) { - case DENY: - return Optional.of("Authentication Failed"); - case DENYSOFT: - return Optional.of("Temporary problem. Please try again later"); - case OK: - return Optional.of("Authentication Succesfull"); - case DECLINED: - case NONE: - break; - } - return Optional.empty(); - } - - private Optional retrieveDefaultSmtpReturnCode(HookReturnCode returnCode) { - switch (returnCode.getAction()) { - case DENY: - return Optional.of(SMTPRetCode.AUTH_FAILED); - case DENYSOFT: - return Optional.of(SMTPRetCode.LOCAL_ERROR); - case OK: - return Optional.of(SMTPRetCode.AUTH_OK); - case DECLINED: - case NONE: - break; + private SaslMechanism adaptPlainMechanismForLegacyAuthHooks(SaslMechanism mechanism) { + if (!authHooks.isEmpty() && mechanism.name().equalsIgnoreCase(SaslMechanismNames.PLAIN)) { + // Legacy AuthHooks own PLAIN authentication: replace, rather than append to, the configured mechanism + // so a declined hook remains a terminal authentication failure as it was before SASL modularization. + return new AuthHookSaslMechanism(mechanism, authHooks, hookResultHooks); } - return Optional.empty(); + return mechanism; } /** @@ -535,15 +454,7 @@ public Collection getImplCommands() { @Override public List getImplementedEsmtpFeatures(SMTPSession session) { if (session.isAuthAnnounced()) { - ImmutableList.Builder authTypesBuilder = ImmutableList.builder(); - if (session.getConfiguration().isPlainAuthEnabled()) { - authTypesBuilder.add(AUTH_TYPE_LOGIN, AUTH_TYPE_PLAIN); - } - if (session.getConfiguration().saslConfiguration().isPresent()) { - authTypesBuilder.add(AUTH_TYPE_OAUTHBEARER); - authTypesBuilder.add(AUTH_TYPE_XOAUTH2); - } - ImmutableList authTypes = authTypesBuilder.build(); + ImmutableList authTypes = saslAuthTypes(session); if (authTypes.isEmpty()) { return Collections.emptyList(); } @@ -553,37 +464,36 @@ public List getImplementedEsmtpFeatures(SMTPSession session) { return Collections.emptyList(); } + private ImmutableList saslAuthTypes(SMTPSession session) { + return effectiveSaslMechanisms + .stream() + .filter(mechanism -> mechanism.isAvailableOnTransport(session.isTLSStarted())) + .flatMap(mechanism -> { + if (mechanism.name().equalsIgnoreCase(SaslMechanismNames.PLAIN)) { + return Stream.of(AUTH_TYPE_LOGIN, AUTH_TYPE_PLAIN); + } + return Stream.of(mechanism.name()); + }) + .distinct() + .collect(ImmutableList.toImmutableList()); + } + @Override public List> getMarkerInterfaces() { - List> classes = new ArrayList<>(2); - classes.add(AuthHook.class); - classes.add(HookResultHook.class); - return classes; + return ImmutableList.of(AuthHook.class, HookResultHook.class, SaslAuthResultHook.class); } - @Override @SuppressWarnings("unchecked") public void wireExtensions(Class interfaceName, List extension) throws WiringException { if (AuthHook.class.equals(interfaceName)) { - this.hooks = (List) extension; - // If no AuthHook is configured then we revert to the default LocalUsersRespository check - if (hooks == null || hooks.isEmpty()) { - throw new WiringException("AuthCmdHandler used without AuthHooks"); - } + this.authHooks = ImmutableList.copyOf((List) extension); } else if (HookResultHook.class.equals(interfaceName)) { - this.rHooks = (List) extension; + this.hookResultHooks = ImmutableList.copyOf((List) extension); + } else if (SaslAuthResultHook.class.equals(interfaceName)) { + this.saslAuthResultHooks = ImmutableList.copyOf((List) extension); } - } - - - /** - * Return a list which holds all hooks for the cmdHandler - * - * @return list containing all hooks for the cmd handler - */ - protected List getHooks() { - return hooks; + updateEffectiveSaslMechanisms(); } @Override diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java new file mode 100644 index 00000000000..0c75392b882 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java @@ -0,0 +1,270 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPResponse; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.hook.AuthHook; +import org.apache.james.protocols.smtp.hook.HookResult; +import org.apache.james.protocols.smtp.hook.HookResultHook; +import org.apache.james.protocols.smtp.hook.HookReturnCode; + +import com.google.common.collect.ImmutableList; + +/** + * Legacy AuthHook adapter exposed as a standard PLAIN SASL mechanism. + */ +class AuthHookSaslMechanism implements SaslMechanism { + static Optional terminalResponse(SaslExchange exchange) { + if (exchange instanceof AuthHookSaslMechanism.Exchange authHookExchange) { + return authHookExchange.terminalResponse(); + } + return Optional.empty(); + } + + private final SaslMechanism plainMechanism; + private final ImmutableList authHooks; + private final ImmutableList hookResultHooks; + + AuthHookSaslMechanism(SaslMechanism plainMechanism, List authHooks, List hookResultHooks) { + this.plainMechanism = plainMechanism; + this.authHooks = ImmutableList.copyOf(authHooks); + this.hookResultHooks = ImmutableList.copyOf(hookResultHooks); + } + + @Override + public String name() { + return SaslMechanismNames.PLAIN; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return plainMechanism.isAvailableOnTransport(channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + throw new IllegalStateException("Legacy SMTP AuthHook adapter requires an SMTP session"); + } + + Exchange start(SaslInitialRequest request, SMTPSession session) { + return new Exchange(request.initialResponse(), session); + } + + final class Exchange implements SaslExchange { + private final Optional initialResponse; + private final SMTPSession session; + private Optional terminalResponse; + + private Exchange(Optional initialResponse, SMTPSession session) { + this.initialResponse = initialResponse; + this.session = session; + this.terminalResponse = Optional.empty(); + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + Optional terminalResponse() { + return terminalResponse; + } + + private SaslStep authenticate(byte[] clientResponse) { + return parse(clientResponse) + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private SaslStep authenticate(PlainCredentials credentials) { + if (credentials.password().isEmpty()) { + return delegateWithHooks(credentials); + } + return authenticateWithHooks(credentials); + } + + private SaslStep authenticateWithHooks(PlainCredentials credentials) { + return authHooks.stream() + .map(hook -> executeHook(hook, hook2 -> hook2.doAuth(session, credentials.authenticationId(), credentials.password()), credentials)) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> invalidCredentials(credentials)); + } + + private SaslStep delegateWithHooks(PlainCredentials credentials) { + return authHooks.stream() + .map(hook -> executeHook(hook, hook2 -> hook2.doDelegation(session, credentials.authenticationId()), credentials)) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> invalidCredentials(credentials)); + } + + private Optional executeHook(AuthHook hook, Function operation, PlainCredentials credentials) { + long start = System.currentTimeMillis(); + HookResult hookResult = operation.apply(hook); + long executionTime = System.currentTimeMillis() - start; + HookResult decoratedHookResult = hookResultHooks.stream() + .reduce(hookResult, + (result, hookResultHook) -> hookResultHook.onHookResult(session, result, executionTime, hook), + (left, right) -> { + throw new UnsupportedOperationException(); + }); + + return toSaslStep(decoratedHookResult, credentials); + } + + private Optional toSaslStep(HookResult hookResult, PlainCredentials credentials) { + if (hookResult == null) { + return Optional.empty(); + } + HookReturnCode.Action action = hookResult.getResult().getAction(); + Optional response = toSmtpResponse(hookResult); + if (response.isEmpty()) { + return Optional.empty(); + } + terminalResponse = response; + return switch (action) { + case OK -> { + Username authorizedUser = Optional.ofNullable(session.getUsername()) + .or(() -> credentials.authorizationId()) + .orElse(credentials.authenticationId()); + yield Optional.of(new SaslStep.Success( + new SaslIdentity(credentials.authenticationId(), authorizedUser), + Optional.empty())); + } + case DENY -> Optional.of(new SaslStep.Failure(SaslFailure.invalidCredentials( + credentials.authenticationId(), + credentials.authorizationId(), + failureReason(hookResult, "Invalid credentials")))); + case DENYSOFT, DECLINED, NONE -> Optional.of(new SaslStep.Failure(SaslFailure.serverError( + Optional.of(credentials.authenticationId()), + credentials.authorizationId(), + failureReason(hookResult, "Authentication failed")))); + }; + } + + private Optional toSmtpResponse(HookResult hookResult) { + HookReturnCode returnCode = hookResult.getResult(); + if (!HookReturnCode.Action.ACTIVE_ACTIONS.contains(returnCode.getAction())) { + return returnCode.isDisconnected() ? Optional.of(Response.DISCONNECT) : Optional.empty(); + } + + String smtpReturnCode = Optional.ofNullable(hookResult.getSmtpRetCode()) + .orElseGet(() -> defaultSmtpReturnCode(returnCode.getAction())); + String smtpDescription = Optional.ofNullable(hookResult.getSmtpDescription()) + .orElseGet(() -> defaultSmtpDescription(returnCode.getAction())); + SMTPResponse response = new SMTPResponse(smtpReturnCode, smtpDescription); + if (returnCode.isDisconnected() && returnCode.getAction() != HookReturnCode.Action.OK) { + response.setEndSession(true); + } + return Optional.of(response); + } + + private String defaultSmtpReturnCode(HookReturnCode.Action action) { + return switch (action) { + case OK -> SMTPRetCode.AUTH_OK; + case DENY -> SMTPRetCode.AUTH_FAILED; + case DENYSOFT -> SMTPRetCode.LOCAL_ERROR; + case DECLINED, NONE -> throw new IllegalArgumentException("No SMTP response for declined AuthHook result"); + }; + } + + private String defaultSmtpDescription(HookReturnCode.Action action) { + return switch (action) { + case OK -> "Authentication Successful"; + case DENY -> "Authentication Failed"; + case DENYSOFT -> "Temporary problem. Please try again later"; + case DECLINED, NONE -> throw new IllegalArgumentException("No SMTP response for declined AuthHook result"); + }; + } + + private SaslStep invalidCredentials(PlainCredentials credentials) { + return new SaslStep.Failure(SaslFailure.invalidCredentials( + credentials.authenticationId(), + credentials.authorizationId(), + "Invalid credentials")); + } + + private String failureReason(HookResult hookResult, String defaultReason) { + return Optional.ofNullable(hookResult.getSmtpDescription()) + .orElse(defaultReason); + } + + } + + private Optional parse(byte[] clientResponse) { + ImmutableList tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) + .collect(ImmutableList.toImmutableList()); + + if (tokens.size() == 4 && tokens.get(3).isEmpty()) { + return credentials(tokens.subList(0, 3)); + } + return credentials(tokens); + } + + private Optional credentials(List tokens) { + try { + if (tokens.size() == 2) { + return Optional.of(new PlainCredentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1))); + } + if (tokens.size() == 3) { + Optional authorizationId = Optional.of(tokens.get(0)) + .filter(value -> !value.isEmpty()) + .map(Username::of); + return Optional.of(new PlainCredentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2))); + } + return Optional.empty(); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private record PlainCredentials(Optional authorizationId, Username authenticationId, String password) { + } +} diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java new file mode 100644 index 00000000000..67a46827671 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java @@ -0,0 +1,116 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPResponse; +import org.apache.james.protocols.smtp.SMTPRetCode; + +public class SmtpSaslBridge { + /** + * Converts an SMTP AUTH command into a protocol-neutral SASL initial request. + */ + public SaslInitialRequest initialRequest(String mechanismName, Optional initialClientResponse) { + return new SaslInitialRequest(mechanismName, initialClientResponse.map(this::decodeInitialClientResponse)); + } + + /** + * Encodes a SASL challenge as an SMTP 334 response. + */ + public Response challenge(SaslStep.Challenge challenge) { + return new SMTPResponse(SMTPRetCode.AUTH_READY, encode(challenge.payload())).immutable(); + } + + /** + * Encodes final SASL server data as an SMTP 334 response. + * + * Per RFC 4422, when a mechanism has additional success data and the protocol + * outcome has no dedicated field for it, the server sends it as a challenge, + * waits for an empty client response, then returns the successful outcome. + */ + public Response successData(SaslStep.Success success) { + return new SMTPResponse(SMTPRetCode.AUTH_READY, encode(success.serverData())).immutable(); + } + + /** + * Decodes an SMTP client continuation line and forwards it to the SASL exchange. + */ + public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { + return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); + } + + /** + * Detects SMTP SASL client cancellation. + */ + public boolean isAbort(byte[] line) { + return "*".equals(stripTrailingCrlf(line)); + } + + /** + * Detects the empty SMTP client response used to acknowledge final SASL server data. + */ + public boolean isEmptyClientResponse(byte[] line) { + return stripTrailingCrlf(line).isEmpty(); + } + + /** + * Aborts an active SASL exchange. + */ + public void abort(SaslExchange exchange) { + exchange.abort(); + } + + /** + * Closes an active SASL exchange. + */ + public void close(SaslExchange exchange) { + exchange.close(); + } + + private byte[] decodeBase64(String value) { + return Base64.getDecoder().decode(value); + } + + private byte[] decodeInitialClientResponse(String value) { + if (value.equals("=")) { + return new byte[0]; + } + return decodeBase64(value); + } + + private String encode(Optional payload) { + return payload + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + } + + private String stripTrailingCrlf(byte[] line) { + String value = new String(line, StandardCharsets.US_ASCII); + return StringUtils.stripEnd(value, "\r\n"); + } +} diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java index 3a7234356c4..af5d5b01d07 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java @@ -23,8 +23,12 @@ import org.apache.james.protocols.smtp.SMTPSession; /** - * Implement this interfaces to hook in the AUTH Command + * Legacy SMTP authentication hook. + * + * @deprecated Implement {@code SaslMechanismFactory} for authentication, or {@link SaslAuthResultHook} + * for post-authentication side effects. */ +@Deprecated public interface AuthHook extends Hook { /** @@ -37,7 +41,9 @@ public interface AuthHook extends Hook { */ HookResult doAuth(SMTPSession session, Username username, String password); - HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse); + default HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse) { + return HookResult.DECLINED; + } default HookResult doDelegation(SMTPSession session, Username target) { return HookResult.DECLINED; diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java new file mode 100644 index 00000000000..caaf729433d --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java @@ -0,0 +1,37 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.hook; + +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.smtp.SMTPSession; + +/** + * Decorates terminal SMTP SASL authentication results without participating in credential validation. + *

+ * Use this hook for legacy {@link AuthHook} use cases that only need authentication result side effects, + * such as audit events, notifications or metrics. Credential validation should be implemented by a SASL + * mechanism instead. + */ +public interface SaslAuthResultHook extends Hook { + void onSuccess(SMTPSession session, String mechanismName, SaslIdentity identity); + + void onFailure(SMTPSession session, String mechanismName, SaslFailure failure); +} diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java deleted file mode 100644 index edf407331d7..00000000000 --- a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.protocols.smtp.core.esmtp; - -import java.util.Optional; - -import org.apache.james.core.Username; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; - -class AuthCmdHandlerTest { - - @Test - void shouldReturnEmptyWhenEmptyInput() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues("")).isEmpty(); - } - - @Test - void shouldReturnEmptyWhenBlankInput() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues(" \t\n")).isEmpty(); - } - - @Test - void shouldReturnEmptyWhenTwoBlankParts() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues(" \0\t\n")).isEmpty(); - } - - @Test - void shouldReturnEmptyWhenThreeBlankParts() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues(" \0\0 ")).isEmpty(); - } - - @Test - void shouldReturnUsernameWhenSinglePart() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues("bob")) - .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), Optional.empty())); - } - - @Test - void shouldReturnUsernameAndPassWhenTwoParts() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues("bob\0pass")) - .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), Optional.of("pass"))); - } - - @Test - void shouldReturnUsernameAndPassWhenThreeParts() { - Assertions.assertThat(AuthCmdHandler.parseAuthValues("something\0bob\0pass")) - .contains(new AuthCmdHandler.AuthValues(Username.of("bob"), Optional.of("pass"))); - } - - -} \ No newline at end of file diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java new file mode 100644 index 00000000000..0db637f68ad --- /dev/null +++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java @@ -0,0 +1,247 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.junit.jupiter.api.Test; + +class SmtpSaslBridgeTest { + private static final Username USER = Username.of("user@example.com"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final SmtpSaslBridge testee = new SmtpSaslBridge(); + + private static class RecordingExchange implements SaslExchange { + protected final List lifecycleEvents; + private byte[] lastClientResponse; + + private RecordingExchange() { + this.lifecycleEvents = new ArrayList<>(); + } + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.of(bytes("challenge"))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + lastClientResponse = clientResponse.clone(); + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public void close() { + lifecycleEvents.add("close"); + } + } + + private static class RecordingAbortExchange extends RecordingExchange { + @Override + public void abort() { + lifecycleEvents.add("abort"); + close(); + } + } + + @Test + void initialRequestShouldDecodeInitialClientResponse() { + String encodedInitialResponse = Base64.getEncoder().encodeToString(bytes("initial")); + + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encodedInitialResponse)); + + assertThat(request.mechanismName()).isEqualTo("PLAIN"); + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void initialRequestShouldDecodeEqualSignAsEmptyInitialClientResponse() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + } + + @Test + void initialRequestShouldRejectMalformedInitialClientResponse() { + assertThatThrownBy(() -> testee.initialRequest("PLAIN", Optional.of("not-base64!"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void challengeShouldBase64EncodePayloadAsSmtp334Response() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("challenge"))); + + Response response = testee.challenge(challenge); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly("334 " + Base64.getEncoder().encodeToString(bytes("challenge"))); + } + + @Test + void challengeShouldReturnEmptySmtp334ResponseWhenChallengeHasNoPayload() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.empty()); + + Response response = testee.challenge(challenge); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly("334 "); + } + + @Test + void challengeShouldEncodeAuthLoginUsernamePrompt() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("Username:"))); + + Response response = testee.challenge(challenge); + + assertThat(response.getLines()).containsExactly("334 VXNlcm5hbWU6"); + } + + @Test + void challengeShouldEncodeAuthLoginPasswordPrompt() { + SaslStep.Challenge challenge = new SaslStep.Challenge(Optional.of(bytes("Password:"))); + + Response response = testee.challenge(challenge); + + assertThat(response.getLines()).containsExactly("334 UGFzc3dvcmQ6"); + } + + @Test + void onClientResponseShouldDecodeLineAndContinueExchange() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldDecodeLineWithLfOnly() { + RecordingExchange exchange = new RecordingExchange(); + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\n").getBytes(StandardCharsets.US_ASCII); + + SaslStep step = testee.onClientResponse(exchange, line); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldRejectMalformedBase64() { + RecordingExchange exchange = new RecordingExchange(); + + assertThatThrownBy(() -> testee.onClientResponse(exchange, "not-base64!\r\n".getBytes(StandardCharsets.US_ASCII))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void isAbortShouldDetectSmtpSaslAbortLine() { + assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldDetectSmtpSaslAbortLineWithLfOnly() { + assertThat(testee.isAbort("*\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isAbortShouldRejectRegularClientResponse() { + byte[] line = (Base64.getEncoder().encodeToString(bytes("response")) + "\r\n").getBytes(StandardCharsets.US_ASCII); + + assertThat(testee.isAbort(line)).isFalse(); + } + + @Test + void successDataShouldBase64EncodePayloadAsSmtp334Response() { + SaslStep.Success success = new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data"))); + + Response response = testee.successData(success); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly("334 " + Base64.getEncoder().encodeToString(bytes("server-data"))); + } + + @Test + void successDataShouldReturnEmptySmtp334ResponseWhenPayloadIsEmpty() { + SaslStep.Success success = new SaslStep.Success(IDENTITY, Optional.empty()); + + Response response = testee.successData(success); + + assertThat(response.getRetCode()).isEqualTo(SMTPRetCode.AUTH_READY); + assertThat(response.getLines()).containsExactly("334 "); + } + + @Test + void isEmptyClientResponseShouldDetectEmptyLine() { + assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + } + + @Test + void isEmptyClientResponseShouldRejectNonEmptyLine() { + assertThat(testee.isEmptyClientResponse("data\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse(); + } + + @Test + void abortShouldCloseExchangeByDefault() { + RecordingExchange exchange = new RecordingExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + @Test + void abortShouldUseExchangeSpecificAbortWhenOverridden() { + RecordingAbortExchange exchange = new RecordingAbortExchange(); + + testee.abort(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("abort", "close"); + } + + @Test + void closeShouldCloseExchange() { + RecordingExchange exchange = new RecordingExchange(); + + testee.close(exchange); + + assertThat(exchange.lifecycleEvents).containsExactly("close"); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java index bff98d4ce01..8bc8abe5f8f 100644 --- a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java +++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/utils/BaseFakeSMTPSession.java @@ -69,11 +69,6 @@ public int getRcptCount() { throw new UnsupportedOperationException("Unimplemented Stub Method"); } - @Override - public boolean supportsOAuth() { - return false; - } - @Override public String getSessionID() { throw new UnsupportedOperationException("Unimplemented Stub Method"); diff --git a/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java index 7fc3a55792e..8c08ae690a6 100644 --- a/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java +++ b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/Main.java @@ -25,6 +25,7 @@ import org.apache.james.blob.api.MetricableBlobStore; import org.apache.james.blob.objectstorage.aws.S3BlobStoreDAO; import org.apache.james.blob.objectstorage.aws.S3RequestOption; +import org.apache.james.mailbox.Authenticator; import org.apache.james.mailrepository.api.MailRepositoryFactory; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; import org.apache.james.mailrepository.postgres.PostgresMailRepositoryFactory; @@ -109,6 +110,7 @@ public class Main implements JamesServerMain { Multibinder.newSetBinder(binder, PostgresDataDefinition.class) .addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryDataDefinition.MODULE); binder.bind(MailRepositoryUrlStore.class).to(PostgresMailRepositoryUrlStore.class).in(Scopes.SINGLETON); + binder.bind(Authenticator.class).to(UsersRepositoryBackedAuthenticator.class).in(Scopes.SINGLETON); }, new CoreDataModule(), new MemoryDelegationStoreModule(), diff --git a/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java new file mode 100644 index 00000000000..f980601e082 --- /dev/null +++ b/server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.api.UsersRepositoryException; + +class UsersRepositoryBackedAuthenticator implements Authenticator { + private final UsersRepository usersRepository; + + @Inject + UsersRepositoryBackedAuthenticator(UsersRepository usersRepository) { + this.usersRepository = usersRepository; + } + + @Override + public Optional isAuthentic(Username userid, CharSequence passwd) throws MailboxException { + try { + return usersRepository.test(userid, passwd.toString()); + } catch (UsersRepositoryException e) { + throw new MailboxException("Unable to access UsersRepository", e); + } + } +} diff --git a/server/container/guice/common/pom.xml b/server/container/guice/common/pom.xml index c6a56f70283..f8001ee29ee 100644 --- a/server/container/guice/common/pom.xml +++ b/server/container/guice/common/pom.xml @@ -74,6 +74,10 @@ ${james.groupId} james-server-guice-netty + + ${james.groupId} + james-server-guice-utils + ${james.groupId} james-server-mailrepository-memory @@ -126,6 +130,10 @@ testing-base test + + ${james.protocols.groupId} + protocols-api + com.github.stefanbirkner system-lambda diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java index fffe4af299b..63959aecb54 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/CommonServicesModule.java @@ -49,7 +49,7 @@ public CommonServicesModule(Configuration configuration) { this.fileSystem = new FileSystemImpl(configuration.directories()); } - + @Override protected void configure() { install(new ExtensionModule()); diff --git a/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java new file mode 100644 index 00000000000..ad86c882d70 --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import com.google.inject.BindingAnnotation; + +@BindingAnnotation +@Retention(RUNTIME) +public @interface SaslMechanismFactories { +} diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java new file mode 100644 index 00000000000..c34d2e5804b --- /dev/null +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java @@ -0,0 +1,89 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; + +public class GuiceSaslMechanismResolver { + private static final NamingScheme SASL_FACTORY_NAMING_SCHEME = + new NamingScheme.OptionalPackagePrefix(PackageName.of("org.apache.james.protocols.sasl")); + + private final GuiceLoader.InvocationPerformer factoryLoader; + + @Inject + public GuiceSaslMechanismResolver(GuiceLoader guiceLoader) { + this.factoryLoader = guiceLoader.withNamingSheme(SASL_FACTORY_NAMING_SCHEME); + } + + public ImmutableList resolve(Collection configuredFactoryClassNames, + ImmutableList enabledDefaultFactories, + HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + try { + ImmutableList factories = configuredFactoryClassNames.isEmpty() + ? enabledDefaultFactories + : configuredFactoryClassNames.stream() + .map(Throwing.function(this::instantiateFactory)) + .collect(ImmutableList.toImmutableList()); + + return factories.stream() + .map(Throwing.function(factory -> factory.create(serverConfiguration))) + .collect(Collectors.toMap( + mechanism -> normalize(mechanism.name()), + Function.identity(), + (first, second) -> first, + LinkedHashMap::new)) + .values() + .stream() + .collect(ImmutableList.toImmutableList()); + } catch (RuntimeException e) { + if (e.getCause() instanceof ConfigurationException configurationException) { + throw configurationException; + } + throw e; + } + } + + private SaslMechanismFactory instantiateFactory(String className) throws ConfigurationException { + try { + return factoryLoader.instantiate(new ClassName(className)); + } catch (ClassNotFoundException | RuntimeException e) { + throw new ConfigurationException("Can not load SASL mechanism factory " + className, e); + } + } + + private String normalize(String mechanismName) { + return mechanismName.toUpperCase(Locale.US); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java new file mode 100644 index 00000000000..850ed4c1ce1 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.sasl; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.utils.FixedNameSaslMechanism; + +public class TestingDefaultPackageSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism("DEFAULT"); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java new file mode 100644 index 00000000000..c1f59a2eef1 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +public class ConfigurableFakeSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism(serverConfiguration.getString("auth.example.realm")); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java new file mode 100644 index 00000000000..d8607ac7a21 --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +public class ExternalFakeSaslMechanismFactory implements SaslMechanismFactory { + @Override + public SaslMechanism create(HierarchicalConfiguration serverConfiguration) { + return new FixedNameSaslMechanism("EXTERNAL-FAKE"); + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java new file mode 100644 index 00000000000..9b82df2cb2d --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java @@ -0,0 +1,69 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; + +public class FixedNameSaslMechanism implements SaslMechanism { + private final String name; + + public FixedNameSaslMechanism(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new FixedStepExchange(); + } + + private record FixedStepExchange() implements SaslExchange { + @Override + public SaslStep firstStep() { + return failure(); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return failure(); + } + + @Override + public void abort() { + } + + @Override + public void close() { + } + + private SaslStep failure() { + return new SaslStep.Failure(SaslFailure.malformed("not implemented")); + } + } +} diff --git a/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java new file mode 100644 index 00000000000..e3551ee400f --- /dev/null +++ b/server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java @@ -0,0 +1,242 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Module; + +class GuiceSaslMechanismResolverTest { + private static final HierarchicalConfiguration EMPTY_CONFIGURATION = new BaseHierarchicalConfiguration(); + + @Test + void resolveShouldUseEnabledDefaultFactoriesWhenNoFactoryClassIsConfigured() throws Exception { + // GIVEN an absent auth.saslMechanisms configuration and an ordered default factory list + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving mechanisms for this server + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("PLAIN"), factory("OAUTHBEARER")), + EMPTY_CONFIGURATION); + + // THEN defaults are used in their declared order + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("PLAIN", "OAUTHBEARER"); + } + + @Test + void resolveShouldResolveSimpleFactoryNameFromDefaultSaslPackage() throws Exception { + // GIVEN a configured built-in SASL factory simple name + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving it + ImmutableList mechanisms = testee.resolve(ImmutableList.of("TestingDefaultPackageSaslMechanismFactory"), + ImmutableList.of(), + EMPTY_CONFIGURATION); + + // THEN the factory is loaded from org.apache.james.protocols.sasl + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("DEFAULT"); + } + + @Test + void resolveShouldResolveFullyQualifiedFactoryName() throws Exception { + // GIVEN a configured extension factory FQCN + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving it + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + EMPTY_CONFIGURATION); + + // THEN the extension factory is loaded directly + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("EXTERNAL-FAKE"); + } + + @Test + void resolveShouldUseConfiguredFactoriesInsteadOfDefaults() throws Exception { + // GIVEN both defaults and an explicit auth.saslMechanisms configuration + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving mechanisms + ImmutableList mechanisms = testee.resolve(ImmutableList.of(ExternalFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(factory("DEFAULT")), + EMPTY_CONFIGURATION); + + // THEN the configured list replaces defaults + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("EXTERNAL-FAKE"); + } + + @Test + void resolveShouldCreateConfiguredFactoriesFromCurrentServerConfiguration() throws Exception { + // GIVEN two server configurations using the same configured SASL factory + BaseHierarchicalConfiguration firstConfiguration = new BaseHierarchicalConfiguration(); + firstConfiguration.addProperty("auth.example.realm", "FIRST"); + BaseHierarchicalConfiguration secondConfiguration = new BaseHierarchicalConfiguration(); + secondConfiguration.addProperty("auth.example.realm", "SECOND"); + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving the same configured factory for each server + SaslMechanism firstMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + firstConfiguration) + .getFirst(); + SaslMechanism secondMechanism = testee.resolve(ImmutableList.of(ConfigurableFakeSaslMechanismFactory.class.getCanonicalName()), + ImmutableList.of(), + secondConfiguration) + .getFirst(); + + // THEN each mechanism is created from that server's configuration, not from a global singleton + assertThat(firstMechanism.name()).isEqualTo("FIRST"); + assertThat(secondMechanism.name()).isEqualTo("SECOND"); + } + + @Test + void resolveShouldPreserveConfiguredOrderForDistinctMechanisms() throws Exception { + // GIVEN several distinct SASL mechanism factories in a configured order + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving them + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("FIRST"), factory("SECOND"), factory("THIRD")), + EMPTY_CONFIGURATION); + + // THEN the resolved mechanisms keep the configured order + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("FIRST", "SECOND", "THIRD"); + } + + @Test + void resolveShouldDeduplicateMechanismNamesCaseInsensitively() throws Exception { + // GIVEN two factories returning the same SASL mechanism name with different case + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving both factories + ImmutableList mechanisms = testee.resolve(ImmutableList.of(), + ImmutableList.of(factory("DUPLICATE"), factory("duplicate")), + EMPTY_CONFIGURATION); + + // THEN first occurrence wins and order remains stable + assertThat(mechanisms) + .extracting(SaslMechanism::name) + .containsExactly("DUPLICATE"); + } + + @Test + void resolveShouldFailWhenConfiguredFactoryClassDoesNotExist() { + // GIVEN a resolver used for configured SASL mechanism factory entries + GuiceSaslMechanismResolver testee = new GuiceSaslMechanismResolver(new ReflectionGuiceLoader()); + + // WHEN resolving an unknown factory class name + // THEN startup wiring can fail fast with the configured entry in the error + assertThatThrownBy(() -> testee.resolve(ImmutableList.of("MissingSaslMechanismFactory"), + ImmutableList.of(), + EMPTY_CONFIGURATION)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("MissingSaslMechanismFactory"); + } + + private static SaslMechanismFactory factory(String mechanismName) { + return serverConfiguration -> new FixedNameSaslMechanism(mechanismName); + } + + private static class ReflectionGuiceLoader implements GuiceLoader { + @Override + public T instantiate(ClassName className) throws ClassNotFoundException { + return this.withNamingSheme(NamingScheme.IDENTITY).instantiate(className); + } + + @Override + public InvocationPerformer withNamingSheme(NamingScheme namingSheme) { + return new ReflectionInvocationPerformer<>(namingSheme); + } + + @Override + public InvocationPerformer withChildModule(Module childModule) { + return new ReflectionInvocationPerformer<>(NamingScheme.IDENTITY); + } + } + + private static class ReflectionInvocationPerformer implements GuiceLoader.InvocationPerformer { + private final NamingScheme namingScheme; + + private ReflectionInvocationPerformer(NamingScheme namingScheme) { + this.namingScheme = namingScheme; + } + + @Override + public T instantiate(ClassName className) throws ClassNotFoundException { + try { + return locateClass(className).getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new ClassNotFoundException(className.getName(), e); + } + } + + @Override + public Class locateClass(ClassName className) throws ClassNotFoundException { + Optional> locatedClass = namingScheme.toFullyQualifiedClassNames(className) + .map(FullyQualifiedClassName::getName) + .map(this::tryLocateClass) + .flatMap(Optional::stream) + .findFirst(); + return locatedClass.orElseThrow(() -> new ClassNotFoundException(className.getName())); + } + + @Override + public GuiceLoader.InvocationPerformer withChildModule(Module childModule) { + return new ReflectionInvocationPerformer<>(namingScheme); + } + + @Override + public GuiceLoader.InvocationPerformer withNamingSheme(NamingScheme namingSheme) { + return new ReflectionInvocationPerformer<>(namingSheme); + } + + @SuppressWarnings("unchecked") + private Optional> tryLocateClass(String className) { + try { + return Optional.of((Class) Class.forName(className)); + } catch (ClassNotFoundException e) { + return Optional.empty(); + } + } + } +} diff --git a/server/container/guice/protocols/imap/pom.xml b/server/container/guice/protocols/imap/pom.xml index 11a22740b50..dc99029ba77 100644 --- a/server/container/guice/protocols/imap/pom.xml +++ b/server/container/guice/protocols/imap/pom.xml @@ -53,6 +53,10 @@ testing-base test + + ${james.protocols.groupId} + protocols-sasl + com.google.inject guice diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 3c3fc4ee3d1..5733cea9cd6 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -26,6 +26,7 @@ import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.ProtocolConfigurationSanitizer; import org.apache.james.RunArguments; @@ -60,6 +61,7 @@ import org.apache.james.imap.processor.CapabilityProcessor; import org.apache.james.imap.processor.DefaultProcessor; import org.apache.james.imap.processor.EnableProcessor; +import org.apache.james.imap.processor.LoginProcessor; import org.apache.james.imap.processor.NamespaceSupplier; import org.apache.james.imap.processor.PermitEnableCapabilityProcessor; import org.apache.james.imap.processor.SelectProcessor; @@ -72,12 +74,19 @@ import org.apache.james.lifecycle.api.ConfigurationSanitizer; import org.apache.james.metrics.api.GaugeRegistry; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; import org.apache.james.protocols.lib.netty.CertificateReloadable; import org.apache.james.protocols.netty.Encryption; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.utils.ClassName; import org.apache.james.utils.GuiceLoader; import org.apache.james.utils.GuiceProbe; +import org.apache.james.utils.GuiceSaslMechanismResolver; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.KeystoreCreator; @@ -94,7 +103,6 @@ import com.google.inject.multibindings.ProvidesIntoSet; public class IMAPServerModule extends AbstractModule { - private static Stream> asPairStream(AbstractProcessor p) { return p.acceptableClasses() .stream().map(clazz -> Pair.of(clazz, p)); @@ -106,11 +114,13 @@ protected void configure() { bind(UnpooledStatusResponseFactory.class).in(Scopes.SINGLETON); bind(StatusResponseFactory.class).to(UnpooledStatusResponseFactory.class); - bind(CapabilityProcessor.class).in(Scopes.SINGLETON); - bind(AuthenticateProcessor.class).in(Scopes.SINGLETON); + // Keep CapabilityProcessor, AuthenticateProcessor and EnableProcessor unscoped: IMAP suite loading configures + // their SASL mechanisms and capability links from each server configuration. + bind(CapabilityProcessor.class); + bind(AuthenticateProcessor.class); + bind(EnableProcessor.class); bind(SelectProcessor.class).in(Scopes.SINGLETON); bind(StatusProcessor.class).in(Scopes.SINGLETON); - bind(EnableProcessor.class).in(Scopes.SINGLETON); bind(NamespaceSupplier.class).to(NamespaceSupplier.Default.class).in(Scopes.SINGLETON); bind(PathConverter.Factory.class).to(PathConverter.Factory.Default.class).in(Scopes.SINGLETON); bind(MailboxTyper.class).to(DefaultMailboxTyper.class).in(Scopes.SINGLETON); @@ -130,21 +140,35 @@ protected void configure() { @Singleton IMAPServerFactory provideServerFactory(FileSystem fileSystem, GuiceLoader guiceLoader, + GuiceSaslMechanismResolver saslMechanismResolver, + @ImapDefaultSaslMechanismFactories ImmutableList defaultSaslMechanismFactories, StatusResponseFactory statusResponseFactory, MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory, Encryption.Factory encryptionFactory) { - IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); + IMAPServerFactory factory = new IMAPServerFactory(fileSystem, imapSuiteLoader(guiceLoader, saslMechanismResolver, + defaultSaslMechanismFactories, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); factory.setEncryptionFactory(encryptionFactory); return factory; } - DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, StatusResponseFactory statusResponseFactory) { + @Provides + @Singleton + @ImapDefaultSaslMechanismFactories + ImmutableList provideDefaultImapSaslMechanismFactories(PlainSaslMechanismFactory plain, + OauthBearerSaslMechanismFactory oauthBearer, + XOauth2SaslMechanismFactory xoauth2) { + return ImmutableList.of(plain, oauthBearer, xoauth2); + } + + DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader guiceLoader, + ImmutableList saslMechanisms, StatusResponseFactory statusResponseFactory) { ImmutableMap processors = imapPackage.processors() .stream() .map(Throwing.function(guiceLoader::instantiate)) .map(AbstractProcessor.class::cast) + .map(processor -> configureSaslMechanisms(processor, saslMechanisms)) .flatMap(IMAPServerModule::asPairStream) .collect(ImmutableMap.toImmutableMap( Pair::getLeft, @@ -168,6 +192,16 @@ DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceLoader return new DefaultProcessor(processors, new UnknownRequestProcessor(statusResponseFactory)); } + private AbstractProcessor configureSaslMechanisms(AbstractProcessor processor, ImmutableList saslMechanisms) { + if (processor instanceof AuthenticateProcessor authenticateProcessor) { + authenticateProcessor.configureSaslMechanisms(saslMechanisms); + } + if (processor instanceof LoginProcessor loginProcessor) { + loginProcessor.configureSaslMechanisms(saslMechanisms); + } + return processor; + } + private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfiguration configuration) { String[] imapPackages = configuration.getStringArray("imapPackages"); @@ -185,11 +219,39 @@ private ImapPackage retrievePackages(GuiceLoader guiceLoader, HierarchicalConfig return ImapPackage.and(packages); } + private ImmutableList retrieveSaslMechanisms(GuiceSaslMechanismResolver saslMechanismResolver, + ImmutableList defaultSaslMechanismFactories, + HierarchicalConfiguration configuration) throws ConfigurationException { + ImmutableList mechanismFactoryClassNames = retrieveSaslMechanismFactoryClassNames(configuration); + ImmutableList enabledDefaultFactories = + BuiltInSaslMechanismFactories.enabledForServer(defaultSaslMechanismFactories, configuration); + return saslMechanismResolver.resolve(mechanismFactoryClassNames, enabledDefaultFactories, configuration); + } + + ImmutableList retrieveSaslMechanismFactoryClassNames(HierarchicalConfiguration configuration) throws ConfigurationException { + if (!configuration.containsKey("auth.saslMechanisms")) { + return ImmutableList.of(); + } + + ImmutableList mechanismFactoryClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms")) + .flatMap(value -> Arrays.stream(value.split(","))) + .map(String::trim) + .collect(ImmutableList.toImmutableList()); + + if (mechanismFactoryClassNames.isEmpty() || mechanismFactoryClassNames.stream().anyMatch(StringUtils::isBlank)) { + throw new ConfigurationException("auth.saslMechanisms must not be blank when configured"); + } + return mechanismFactoryClassNames; + } + private ThrowingFunction, ImapSuite> imapSuiteLoader(GuiceLoader guiceLoader, + GuiceSaslMechanismResolver saslMechanismResolver, + ImmutableList defaultSaslMechanismFactories, StatusResponseFactory statusResponseFactory) { return configuration -> { ImapPackage imapPackage = retrievePackages(guiceLoader, configuration); - DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, statusResponseFactory); + ImmutableList saslMechanisms = retrieveSaslMechanisms(saslMechanismResolver, defaultSaslMechanismFactories, configuration); + DefaultProcessor processor = provideClassImapProcessors(imapPackage, guiceLoader, saslMechanisms, statusResponseFactory); ImapEncoder encoder = provideImapEncoder(imapPackage, guiceLoader); ImapParserFactory imapParserFactory = provideImapCommandParserFactory(imapPackage, guiceLoader); @@ -231,6 +293,12 @@ FetchProcessor.LocalCacheConfiguration provideFetchLocalCacheConfiguration(Confi } private void configureEnable(EnableProcessor enableProcessor, ImmutableMap processorMap) { + processorMap.values().stream() + .filter(CapabilityProcessor.class::isInstance) + .map(CapabilityProcessor.class::cast) + .findFirst() + .ifPresent(enableProcessor::configureCapabilityProcessor); + processorMap.values().stream() .filter(PermitEnableCapabilityProcessor.class::isInstance) .map(PermitEnableCapabilityProcessor.class::cast) @@ -262,4 +330,4 @@ ConfigurationSanitizer configurationSanitizer(ConfigurationProvider configuratio FileSystem fileSystem, RunArguments runArguments) { return new ProtocolConfigurationSanitizer(configurationProvider, keystoreCreator, fileSystem, runArguments, "imapserver"); } -} \ No newline at end of file +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java new file mode 100644 index 00000000000..6cac7200fd6 --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import com.google.inject.BindingAnnotation; + +@BindingAnnotation +@Retention(RUNTIME) +public @interface ImapDefaultSaslMechanismFactories { +} diff --git a/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java new file mode 100644 index 00000000000..611ec5ceea3 --- /dev/null +++ b/server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java @@ -0,0 +1,106 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class IMAPServerModuleTest { + private final IMAPServerModule testee = new IMAPServerModule(); + + @Test + void provideDefaultImapSaslMechanismFactoriesShouldReturnJamesDefaults() { + // GIVEN no auth.saslMechanisms configuration + // WHEN IMAP provides its default SASL factories + + // THEN existing James IMAP defaults are preserved in order + assertThat(testee.provideDefaultImapSaslMechanismFactories( + new PlainSaslMechanismFactory(), + new OauthBearerSaslMechanismFactory(), + new XOauth2SaslMechanismFactory())) + .map(factory -> factory.getClass().getSimpleName()) + .containsExactly( + PlainSaslMechanismFactory.class.getSimpleName(), + OauthBearerSaslMechanismFactory.class.getSimpleName(), + XOauth2SaslMechanismFactory.class.getSimpleName()); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldReturnEmptyWhenAbsent() throws Exception { + // GIVEN no auth.saslMechanisms configuration. + // The empty configured list lets the resolver use the Guice-provided default factory list. + // Community custom IMAP packages can override that default factory list to avoid breaking changes. + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + + // WHEN auth.saslMechanisms is absent + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); + + // THEN there is no configured override + assertThat(mechanismFactoryClassNames).isEmpty(); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldReturnConfiguredSaslFactoryList() throws Exception { + // GIVEN an explicit server-specific SASL factory list + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", + "PlainSaslMechanismFactory,com.example.CustomSaslMechanismFactory,PlainSaslMechanismFactory"); + + // WHEN IMAP resolves configured factory class names + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); + + // THEN the exact configured order is passed to the resolver + assertThat(mechanismFactoryClassNames) + .containsExactly("PlainSaslMechanismFactory", "com.example.CustomSaslMechanismFactory", "PlainSaslMechanismFactory"); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankConfiguredList() { + // GIVEN auth.saslMechanisms is present but blank + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", " "); + + // WHEN resolving factory class names + // THEN startup fails instead of silently disabling all mechanisms + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) + .isInstanceOf(ConfigurationException.class); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankEntry() { + // GIVEN auth.saslMechanisms contains a blank entry + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanismFactory,,XOauth2SaslMechanismFactory"); + + // WHEN resolving factory class names + // THEN startup fails with an invalid configured list + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) + .isInstanceOf(ConfigurationException.class); + } +} diff --git a/server/container/guice/protocols/smtp/pom.xml b/server/container/guice/protocols/smtp/pom.xml index 101df093ccd..a58b6c5aaa8 100644 --- a/server/container/guice/protocols/smtp/pom.xml +++ b/server/container/guice/protocols/smtp/pom.xml @@ -49,6 +49,10 @@ testing-base test + + ${james.protocols.groupId} + protocols-sasl + com.google.inject guice diff --git a/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SMTPServerModule.java b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SMTPServerModule.java index b44afd9d816..d74147d30be 100644 --- a/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SMTPServerModule.java +++ b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SMTPServerModule.java @@ -19,23 +19,46 @@ package org.apache.james.modules.protocols; +import java.util.Arrays; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.commons.lang3.StringUtils; import org.apache.james.ProtocolConfigurationSanitizer; import org.apache.james.RunArguments; import org.apache.james.core.ConnectionDescriptionSupplier; import org.apache.james.core.Disconnector; +import org.apache.james.dnsservice.api.DNSService; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.lifecycle.api.ConfigurationSanitizer; +import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; +import org.apache.james.protocols.lib.handler.ProtocolHandlerLoader; import org.apache.james.protocols.lib.netty.CertificateReloadable; +import org.apache.james.protocols.netty.Encryption; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.JamesSaslAuthenticator; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.smtpserver.SendMailHandler; +import org.apache.james.smtpserver.netty.SMTPServer.AuthAnnouncementConfiguration; import org.apache.james.smtpserver.netty.SMTPServerFactory; +import org.apache.james.smtpserver.netty.SMTPServerFactory.SmtpSaslMechanismLoader; import org.apache.james.utils.GuiceProbe; +import org.apache.james.utils.GuiceSaslMechanismResolver; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.KeystoreCreator; +import com.google.common.collect.ImmutableList; import com.google.inject.AbstractModule; -import com.google.inject.Scopes; +import com.google.inject.Provides; +import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; @@ -43,7 +66,7 @@ public class SMTPServerModule extends AbstractModule { @Override protected void configure() { install(new JSPFModule()); - bind(SMTPServerFactory.class).in(Scopes.SINGLETON); + bind(SaslAuthenticator.class).to(JamesSaslAuthenticator.class); Multibinder.newSetBinder(binder(), GuiceProbe.class).addBinding().to(SmtpGuiceProbe.class); @@ -52,6 +75,61 @@ protected void configure() { Multibinder.newSetBinder(binder(), ConnectionDescriptionSupplier.class).addBinding().to(SMTPServerFactory.class); } + @Provides + @Singleton + SMTPServerFactory provideSmtpServerFactory(DNSService dns, + ProtocolHandlerLoader protocolHandlerLoader, + FileSystem fileSystem, + MetricFactory metricFactory, + SmtpSaslMechanismLoader saslMechanismLoader, + SaslAuthenticator saslAuthenticator, + Encryption.Factory encryptionFactory) { + SMTPServerFactory smtpServerFactory = new SMTPServerFactory(dns, protocolHandlerLoader, fileSystem, + metricFactory, saslMechanismLoader, saslAuthenticator); + smtpServerFactory.setEncryptionFactory(encryptionFactory); + return smtpServerFactory; + } + + @Provides + @Singleton + @SmtpDefaultSaslMechanismFactories + ImmutableList provideDefaultSmtpSaslMechanismFactories(OauthBearerSaslMechanismFactory oauthBearer, + XOauth2SaslMechanismFactory xoauth2) { + return ImmutableList.of(new PlainSaslMechanismFactory(AuthAnnouncementConfiguration.REQUIRE_SSL_DEFAULT), oauthBearer, xoauth2); + } + + @Provides + @Singleton + SmtpSaslMechanismLoader provideSmtpSaslMechanismLoader(GuiceSaslMechanismResolver saslMechanismResolver, + @SmtpDefaultSaslMechanismFactories ImmutableList defaultSaslMechanismFactories) { + return configuration -> retrieveSaslMechanisms(saslMechanismResolver, defaultSaslMechanismFactories, configuration); + } + + private ImmutableList retrieveSaslMechanisms(GuiceSaslMechanismResolver saslMechanismResolver, + ImmutableList defaultSaslMechanismFactories, + HierarchicalConfiguration configuration) throws ConfigurationException { + ImmutableList mechanismFactoryClassNames = retrieveSaslMechanismFactoryClassNames(configuration); + ImmutableList enabledDefaultFactories = + BuiltInSaslMechanismFactories.enabledForServer(defaultSaslMechanismFactories, configuration); + return saslMechanismResolver.resolve(mechanismFactoryClassNames, enabledDefaultFactories, configuration); + } + + ImmutableList retrieveSaslMechanismFactoryClassNames(HierarchicalConfiguration configuration) throws ConfigurationException { + if (!configuration.containsKey("auth.saslMechanisms")) { + return ImmutableList.of(); + } + + ImmutableList mechanismFactoryClassNames = Arrays.stream(configuration.getStringArray("auth.saslMechanisms")) + .flatMap(value -> Arrays.stream(value.split(","))) + .map(String::trim) + .collect(ImmutableList.toImmutableList()); + + if (mechanismFactoryClassNames.isEmpty() || mechanismFactoryClassNames.stream().anyMatch(StringUtils::isBlank)) { + throw new ConfigurationException("auth.saslMechanisms must not be blank when configured"); + } + return mechanismFactoryClassNames; + } + @ProvidesIntoSet InitializationOperation configureSmtp(ConfigurationProvider configurationProvider, SMTPServerFactory smtpServerFactory, diff --git a/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpDefaultSaslMechanismFactories.java b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpDefaultSaslMechanismFactories.java new file mode 100644 index 00000000000..a6cea4fd32b --- /dev/null +++ b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpDefaultSaslMechanismFactories.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import com.google.inject.BindingAnnotation; + +@BindingAnnotation +@Retention(RUNTIME) +public @interface SmtpDefaultSaslMechanismFactories { +} diff --git a/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpGuiceProbe.java b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpGuiceProbe.java index 9c67ced130f..e96153afbc5 100644 --- a/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpGuiceProbe.java +++ b/server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpGuiceProbe.java @@ -24,6 +24,7 @@ import java.util.function.Function; import java.util.function.Predicate; +import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer; @@ -56,6 +57,12 @@ private SmtpGuiceProbe(SMTPServerFactory smtpServerFactory) { this.smtpServerFactory = smtpServerFactory; } + @PreDestroy + void destroy() { + // SMTPServerFactory is provided through a factory method; dispose it explicitly on Guice shutdown. + smtpServerFactory.destroy(); + } + public Port getSmtpPort() { return getPort(server -> true); } diff --git a/server/container/guice/protocols/smtp/src/test/java/org/apache/james/modules/protocols/SMTPServerModuleTest.java b/server/container/guice/protocols/smtp/src/test/java/org/apache/james/modules/protocols/SMTPServerModuleTest.java new file mode 100644 index 00000000000..f88e3d3f0dc --- /dev/null +++ b/server/container/guice/protocols/smtp/src/test/java/org/apache/james/modules/protocols/SMTPServerModuleTest.java @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.protocols; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class SMTPServerModuleTest { + private final SMTPServerModule testee = new SMTPServerModule(); + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldReturnEmptyWhenAbsent() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); + + assertThat(mechanismFactoryClassNames).isEmpty(); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldReturnConfiguredSaslFactoryList() throws Exception { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", + "PlainSaslMechanismFactory,com.example.CustomSaslMechanismFactory,PlainSaslMechanismFactory"); + + ImmutableList mechanismFactoryClassNames = testee.retrieveSaslMechanismFactoryClassNames(configuration); + + assertThat(mechanismFactoryClassNames) + .containsExactly("PlainSaslMechanismFactory", "com.example.CustomSaslMechanismFactory", "PlainSaslMechanismFactory"); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankConfiguredList() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", " "); + + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) + .isInstanceOf(ConfigurationException.class); + } + + @Test + void retrieveSaslMechanismFactoryClassNamesShouldRejectBlankEntry() { + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("auth.saslMechanisms", "PlainSaslMechanismFactory,,XOauth2SaslMechanismFactory"); + + assertThatThrownBy(() -> testee.retrieveSaslMechanismFactoryClassNames(configuration)) + .isInstanceOf(ConfigurationException.class); + } +} diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java index d0b43c0ff08..9d4cdc492fc 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java @@ -21,9 +21,7 @@ import static org.apache.james.imapserver.netty.HAProxyMessageHandler.PROXY_INFO; import java.net.InetSocketAddress; -import java.net.MalformedURLException; import java.net.SocketAddress; -import java.net.URISyntaxException; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -50,7 +48,6 @@ import org.apache.james.imap.api.process.SelectedMailbox; import org.apache.james.imap.decode.ImapDecoder; import org.apache.james.imap.encode.ImapEncoder; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.metrics.api.GaugeRegistry; @@ -94,64 +91,6 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC private static final Logger LOG = LoggerFactory.getLogger(IMAPServer.class); public static final AttributeKey CONNECTION_DATE = AttributeKey.newInstance("connectionDate"); - public static class AuthenticationConfiguration { - private static final boolean PLAIN_AUTH_DISALLOWED_DEFAULT = true; - private static final boolean PLAIN_AUTH_ENABLED_DEFAULT = true; - private static final String OIDC_PATH = "auth.oidc"; - - public static AuthenticationConfiguration parse(HierarchicalConfiguration configuration) throws ConfigurationException { - boolean isRequireSSL = configuration.getBoolean("auth.requireSSL", fallback(configuration)); - boolean isPlainAuthEnabled = configuration.getBoolean("auth.plainAuthEnabled", PLAIN_AUTH_ENABLED_DEFAULT); - - if (configuration.immutableConfigurationsAt(OIDC_PATH).isEmpty()) { - return new AuthenticationConfiguration( - isRequireSSL, - isPlainAuthEnabled); - } else { - try { - return new AuthenticationConfiguration( - isRequireSSL, - isPlainAuthEnabled, - OidcSASLConfiguration.parse(configuration.configurationAt(OIDC_PATH))); - } catch (MalformedURLException | NullPointerException | URISyntaxException exception) { - throw new ConfigurationException("Failed to retrieve oauth component", exception); - } - } - } - - private static boolean fallback(HierarchicalConfiguration configuration) { - return configuration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT); - } - - private final boolean isSSLRequired; - private final boolean plainAuthEnabled; - private final Optional oidcSASLConfiguration; - - public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled) { - this.isSSLRequired = isSSLRequired; - this.plainAuthEnabled = plainAuthEnabled; - this.oidcSASLConfiguration = Optional.empty(); - } - - public AuthenticationConfiguration(boolean isSSLRequired, boolean plainAuthEnabled, OidcSASLConfiguration oidcSASLConfiguration) { - this.isSSLRequired = isSSLRequired; - this.plainAuthEnabled = plainAuthEnabled; - this.oidcSASLConfiguration = Optional.of(oidcSASLConfiguration); - } - - public boolean isSSLRequired() { - return isSSLRequired; - } - - public boolean isPlainAuthEnabled() { - return plainAuthEnabled; - } - - public Optional getOidcSASLConfiguration() { - return oidcSASLConfiguration; - } - } - private static final String SOFTWARE_TYPE = "JAMES " + VERSION + " Server "; private static final String DEFAULT_TIME_UNIT = "SECONDS"; private static final String CAPABILITY_SEPARATOR = "|"; @@ -174,7 +113,6 @@ public Optional getOidcSASLConfiguration() { private int inMemorySizeLimit; private int timeout; private int literalSizeLimit; - private AuthenticationConfiguration authenticationConfiguration; private Optional trafficShaping = Optional.empty(); private Optional connectionLimitUpstreamHandler = Optional.empty(); private Optional connectionPerIpLimitUpstreamHandler = Optional.empty(); @@ -212,7 +150,6 @@ public void doConfigure(HierarchicalConfiguration configuration) if (timeout < DEFAULT_TIMEOUT) { throw new ConfigurationException("Minimum timeout of 30 minutes required. See rfc2060 5.4 for details"); } - authenticationConfiguration = AuthenticationConfiguration.parse(configuration); connectionLimitUpstreamHandler = ConnectionLimitUpstreamHandler.forCount(connectionLimit); connectionPerIpLimitUpstreamHandler = ConnectionPerIpLimitUpstreamHandler.forCount(connPerIP); ignoreIDLEUponProcessing = configuration.getBoolean("ignoreIDLEUponProcessing", true); @@ -368,7 +305,6 @@ protected ChannelInboundHandlerAdapter createCoreHandler() { .processor(processor) .encoder(encoder) .compress(compress) - .authenticationConfiguration(authenticationConfiguration) .connectionChecks(connectionChecks) .secure(secure) .imapMetrics(imapMetrics) diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java index e4435a7786f..23d953c64ec 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java @@ -19,7 +19,6 @@ package org.apache.james.imapserver.netty; import static org.apache.james.imap.api.process.ImapSession.MDC_KEY; -import static org.apache.james.imapserver.netty.IMAPServer.AuthenticationConfiguration; import java.io.Closeable; import java.io.IOException; @@ -88,7 +87,6 @@ public static class ImapChannelUpstreamHandlerBuilder { private boolean compress; private ImapProcessor processor; private ImapEncoder encoder; - private IMAPServer.AuthenticationConfiguration authenticationConfiguration; private ImapMetrics imapMetrics; private boolean ignoreIDLEUponProcessing; private Duration heartbeatInterval; @@ -127,11 +125,6 @@ public ImapChannelUpstreamHandlerBuilder encoder(ImapEncoder encoder) { return this; } - public ImapChannelUpstreamHandlerBuilder authenticationConfiguration(IMAPServer.AuthenticationConfiguration authenticationConfiguration) { - this.authenticationConfiguration = authenticationConfiguration; - return this; - } - public ImapChannelUpstreamHandlerBuilder connectionChecks(Set connectionChecks) { this.connectionChecks = connectionChecks; return this; @@ -163,7 +156,7 @@ public ImapChannelUpstreamHandlerBuilder imapChannelGroup(ChannelGroup imapChann } public ImapChannelUpstreamHandler build() { - return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, authenticationConfiguration, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup); + return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired, imapChannelGroup); } } @@ -182,7 +175,6 @@ public static ImapChannelUpstreamHandlerBuilder builder() { private final ImapProcessor processor; private final ImapEncoder encoder; private final ImapHeartbeatHandler heartbeatHandler; - private final AuthenticationConfiguration authenticationConfiguration; private final Metric imapConnectionsMetric; private final Metric imapCommandsMetric; private final boolean ignoreIDLEUponProcessing; @@ -192,15 +184,13 @@ public static ImapChannelUpstreamHandlerBuilder builder() { private final ChannelGroup imapChannelGroup; public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEncoder encoder, boolean compress, - Encryption secure, ImapMetrics imapMetrics, AuthenticationConfiguration authenticationConfiguration, - boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler, + Encryption secure, ImapMetrics imapMetrics, boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler, Set connectionChecks, boolean proxyRequired, ChannelGroup imapChannelGroup) { this.hello = hello; this.processor = processor; this.encoder = encoder; this.secure = secure; this.compress = compress; - this.authenticationConfiguration = authenticationConfiguration; this.imapConnectionsMetric = imapMetrics.getConnectionsMetric(); this.imapCommandsMetric = imapMetrics.getCommandsMetric(); this.ignoreIDLEUponProcessing = ignoreIDLEUponProcessing; @@ -215,9 +205,7 @@ public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEnc public void channelActive(ChannelHandlerContext ctx) { imapChannelGroup.add(ctx.channel()); SessionId sessionId = SessionId.generate(); - ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, authenticationConfiguration.isSSLRequired(), - authenticationConfiguration.isPlainAuthEnabled(), sessionId, - authenticationConfiguration.getOidcSASLConfiguration()); + ImapSession imapsession = new NettyImapSession(ctx.channel(), secure, compress, sessionId); ctx.channel().attr(IMAP_SESSION_ATTRIBUTE_KEY).set(imapsession); ctx.channel().attr(REQUEST_COUNTER).set(new AtomicLong()); ctx.channel().attr(LINEARIZER_ATTRIBUTE_KEY).set(new ImapLinerarizer()); diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java index 9a10733e645..866ea6d579b 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/NettyImapSession.java @@ -39,7 +39,6 @@ import org.apache.james.imap.api.process.SelectedMailbox; import org.apache.james.imap.encode.ImapResponseWriter; import org.apache.james.imap.message.Literal; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.mailbox.MailboxSession; import org.apache.james.protocols.netty.Encryption; import org.apache.james.protocols.netty.LineHandlerAware; @@ -61,28 +60,19 @@ public class NettyImapSession implements ImapSession, NettyConstants { private final Encryption secure; private final boolean compress; private final Channel channel; - private final boolean requiredSSL; - private final boolean plainAuthEnabled; private final SessionId sessionId; - private final boolean supportsOAuth; - private final Optional oidcSASLConfiguration; private volatile ImapSessionState state = ImapSessionState.NON_AUTHENTICATED; private final AtomicReference selectedMailbox = new AtomicReference<>(); private volatile boolean needsCommandInjectionDetection; private volatile MailboxSession mailboxSession = null; - public NettyImapSession(Channel channel, Encryption secure, boolean compress, boolean requiredSSL, boolean plainAuthEnabled, SessionId sessionId, - Optional oidcSASLConfiguration) { + public NettyImapSession(Channel channel, Encryption secure, boolean compress, SessionId sessionId) { this.channel = channel; this.secure = secure; this.compress = compress; - this.requiredSSL = requiredSSL; - this.plainAuthEnabled = plainAuthEnabled; this.sessionId = sessionId; this.needsCommandInjectionDetection = true; - this.oidcSASLConfiguration = oidcSASLConfiguration; - this.supportsOAuth = oidcSASLConfiguration.isPresent(); } @Override @@ -311,31 +301,11 @@ public void popLineHandler() { handler.popLineHandler(); } - @Override - public boolean isSSLRequired() { - return requiredSSL; - } - - @Override - public boolean isPlainAuthEnabled() { - return plainAuthEnabled; - } - - @Override - public boolean supportsOAuth() { - return supportsOAuth; - } - @Override public InetSocketAddress getRemoteAddress() { return (InetSocketAddress) channel.remoteAddress(); } - @Override - public Optional oidcSaslConfiguration() { - return oidcSASLConfiguration; - } - @Override public boolean isTLSActive() { return channel.pipeline().get(SSL_HANDLER) != null; diff --git a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml index 117f0baaa83..2d3e4e51846 100644 --- a/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml +++ b/server/protocols/protocols-imap4/src/main/resources/META-INF/spring/imapserver-context.xml @@ -29,17 +29,21 @@ - - - - - - - - + + + + + + + + + + + + diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java index 7eb0eb22cc9..a4c6471f4c7 100644 --- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/AbstractIMAPServerTest.java @@ -120,7 +120,8 @@ protected IMAPServer createImapServer(HierarchicalConfiguration c memoryIntegrationResources.getQuotaManager(), memoryIntegrationResources.getQuotaRootResolver(), metricFactory, - localCacheConfiguration), + localCacheConfiguration, + config), new ImapMetrics(metricFactory), new NoopGaugeRegistry(), connectionChecks); diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerOidcTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerOidcTest.java index 033a335c895..c2b60a369a6 100644 --- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerOidcTest.java +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerOidcTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.tree.ImmutableNode; @@ -46,6 +47,12 @@ @SuppressWarnings("checkstyle:membername") class IMAPServerOidcTest extends AbstractIMAPServerTest { + private static final String OIDC_CONFIGURATION_URL = "https://example.com/jwks"; + private static final String OIDC_SCOPE = "email"; + private static final String FAIL_RESPONSE_TOKEN = Base64.getEncoder().encodeToString( + String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", OIDC_SCOPE, OIDC_CONFIGURATION_URL) + .getBytes(StandardCharsets.UTF_8)); + String JWKS_URI_PATH = "/jwks"; String INTROSPECT_TOKEN_URI_PATH = "/introspect"; String USERINFO_URI_PATH = "/userinfo"; @@ -80,8 +87,8 @@ void authSetup() throws Exception { HierarchicalConfiguration config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("oauth.xml")); config.addProperty("auth.oidc.jwksURL", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), JWKS_URI_PATH)); config.addProperty("auth.oidc.claim", OidcTokenFixture.CLAIM); - config.addProperty("auth.oidc.oidcConfigurationURL", "https://example.com/jwks"); - config.addProperty("auth.oidc.scope", "email"); + config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_CONFIGURATION_URL); + config.addProperty("auth.oidc.scope", OIDC_SCOPE); imapServer = createImapServer(config, integrationResources, FetchProcessor.LocalCacheConfiguration.DEFAULT); port = imapServer.getListenAddresses().get(0).getPort(); @@ -208,6 +215,11 @@ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception { String oauthBearer = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); IMAPSClient client = imapsClient(port); client.sendCommand("AUTHENTICATE OAUTHBEARER " + oauthBearer); + assertThat(client.getReplyString()).contains("+ " + FAIL_RESPONSE_TOKEN); + + // RFC 7628 section 3.2.3: after an OAuth failure error challenge, the client must send a dummy response + // so the server can fail the SASL negotiation. + client.sendCommand("AQ=="); assertThat(client.getReplyString()).contains("NO AUTHENTICATE failed."); } diff --git a/server/protocols/protocols-lmtp/src/main/java/org/apache/james/lmtpserver/netty/LMTPServer.java b/server/protocols/protocols-lmtp/src/main/java/org/apache/james/lmtpserver/netty/LMTPServer.java index 492b3e520ee..568e077fd9e 100644 --- a/server/protocols/protocols-lmtp/src/main/java/org/apache/james/lmtpserver/netty/LMTPServer.java +++ b/server/protocols/protocols-lmtp/src/main/java/org/apache/james/lmtpserver/netty/LMTPServer.java @@ -18,12 +18,9 @@ ****************************************************************/ package org.apache.james.lmtpserver.netty; -import java.util.Optional; - import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.lmtpserver.CoreCmdHandlerLoader; import org.apache.james.lmtpserver.jmx.JMXHandlersLoader; import org.apache.james.protocols.api.ProtocolSession; @@ -121,16 +118,6 @@ public long getMaxMessageSize() { public String getSMTPGreeting() { return LMTPServer.this.lmtpGreeting; } - - @Override - public boolean isPlainAuthEnabled() { - return false; - } - - @Override - public Optional saslConfiguration() { - return Optional.empty(); - } } @Override diff --git a/server/protocols/protocols-smtp/pom.xml b/server/protocols/protocols-smtp/pom.xml index 5351f7b5ac6..d8d790af5d4 100644 --- a/server/protocols/protocols-smtp/pom.xml +++ b/server/protocols/protocols-smtp/pom.xml @@ -142,6 +142,10 @@ ${james.protocols.groupId} protocols-netty + + ${james.protocols.groupId} + protocols-sasl + ${james.protocols.groupId} diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java index f68527afc1e..22e575ddff0 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java @@ -44,9 +44,10 @@ /** * Declarative authentication. * - * This is helpful for things like service accounts, used by other applications (and it is not desirable to create - * user accounts for those applications) + * @deprecated Prefer implementing a SASL mechanism factory. Existing handler-chain registrations + * are adapted by the SMTP AUTH handler during migration. */ +@Deprecated public class ConfigurationAuthHook implements AuthHook { private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationAuthHook.class); diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java index c0a6c8b9ea3..87d5e0e4927 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java @@ -61,7 +61,6 @@ public class CoreCmdHandlerLoader implements HandlersPackage { RsetCmdHandler.class.getName(), VrfyCmdHandler.class.getName(), MailSizeEsmtpExtension.class.getName(), - UsersRepositoryAuthHook.class.getName(), AuthRequiredToRelayRcptHook.class.getName(), SenderAuthIdentifyVerificationHook.class.getName(), AuthRequiredHook.class.getName(), diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java index d60b0dafccb..28ffdfcdf6e 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java @@ -23,11 +23,6 @@ import jakarta.inject.Inject; import org.apache.james.core.Username; -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.jwt.OidcSASLConfiguration; -import org.apache.james.mailbox.Authorizator; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.protocols.api.OIDCSASLParser; import org.apache.james.protocols.smtp.SMTPSession; import org.apache.james.protocols.smtp.hook.AuthHook; import org.apache.james.protocols.smtp.hook.HookResult; @@ -38,19 +33,19 @@ import org.slf4j.LoggerFactory; /** - * This Auth hook can be used to authenticate against the james user repository + * Legacy SMTP AuthHook backed by the James users repository. + * + * @deprecated Default SMTP authentication is now handled by PLAIN SASL and {@code SaslAuthenticator}. */ +@Deprecated public class UsersRepositoryAuthHook implements AuthHook { private static final Logger LOGGER = LoggerFactory.getLogger(UsersRepositoryAuthHook.class); private final UsersRepository users; - private final Authorizator authorizator; @Inject - public UsersRepositoryAuthHook(UsersRepository users, - Authorizator authorizator) { + public UsersRepositoryAuthHook(UsersRepository users) { this.users = users; - this.authorizator = authorizator; } @Override @@ -70,46 +65,4 @@ public HookResult doAuth(SMTPSession session, Username username, String password } return HookResult.DECLINED; } - - @Override - public HookResult doSasl(SMTPSession session, OidcSASLConfiguration configuration, String initialResponse) { - return OIDCSASLParser.parse(initialResponse) - .flatMap(oidcInitialResponseValue -> new OidcJwtTokenVerifier(configuration).validateToken(oidcInitialResponseValue.getToken()) - .map(authenticatedUser -> { - Username associatedUser = Username.of(oidcInitialResponseValue.getAssociatedUser()); - if (!associatedUser.equals(authenticatedUser)) { - return doAuthWithDelegation(session, authenticatedUser, associatedUser); - } else { - return saslSuccess(session, authenticatedUser); - } - }) - ) - .orElse(HookResult.DECLINED); - } - - private HookResult doAuthWithDelegation(SMTPSession session, Username authenticatedUser, Username associatedUser) { - try { - if (Authorizator.AuthorizationState.ALLOWED.equals(authorizator.user(authenticatedUser).canLoginAs(associatedUser))) { - return saslSuccess(session, associatedUser); - } - } catch (MailboxException e) { - LOGGER.info("Unable to authorization", e); - } - return HookResult.DECLINED; - } - - private HookResult saslSuccess(SMTPSession session, Username username) { - try { - users.assertValid(username); - session.setUsername(username); - session.setRelayingAllowed(true); - return HookResult.builder() - .hookReturnCode(HookReturnCode.ok()) - .smtpDescription("Authentication successful.") - .build(); - } catch (UsersRepositoryException e) { - LOGGER.warn("Invalid username", e); - return HookResult.DECLINED; - } - } } diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServer.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServer.java index e509ac384c7..b60abaa58cb 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServer.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServer.java @@ -24,9 +24,7 @@ import static org.apache.james.smtpserver.netty.SMTPServer.AuthenticationAnnounceMode.NEVER; import java.net.InetSocketAddress; -import java.net.MalformedURLException; import java.net.SocketAddress; -import java.net.URISyntaxException; import java.util.Locale; import java.util.Optional; import java.util.Set; @@ -44,9 +42,10 @@ import org.apache.james.core.Username; import org.apache.james.dnsservice.api.DNSService; import org.apache.james.dnsservice.library.netmatcher.NetMatcher; -import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.api.ProtocolSession; import org.apache.james.protocols.api.ProtocolTransport; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.lib.handler.HandlersPackage; import org.apache.james.protocols.lib.netty.AbstractProtocolAsyncServer; import org.apache.james.protocols.netty.AbstractChannelPipelineFactory; @@ -55,6 +54,7 @@ import org.apache.james.protocols.smtp.SMTPConfiguration; import org.apache.james.protocols.smtp.SMTPProtocol; import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.core.esmtp.AuthCmdHandler; import org.apache.james.smtpserver.CoreCmdHandlerLoader; import org.apache.james.smtpserver.ExtendedSMTPSession; import org.apache.james.smtpserver.jmx.JMXHandlersLoader; @@ -62,6 +62,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -110,30 +111,15 @@ public static AuthenticationAnnounceMode parse(String authRequiredString) { } } - public static class AuthenticationConfiguration { - private static final String OIDC_PATH = "auth.oidc"; + public static class AuthAnnouncementConfiguration { + public static final boolean REQUIRE_SSL_DEFAULT = false; - public static AuthenticationConfiguration parse(HierarchicalConfiguration configuration) throws ConfigurationException { - return new AuthenticationConfiguration( + public static AuthAnnouncementConfiguration parse(HierarchicalConfiguration configuration) throws ConfigurationException { + return new AuthAnnouncementConfiguration( Optional.ofNullable(configuration.getString("auth.announce", null)) .map(AuthenticationAnnounceMode::parse) .orElseGet(() -> fallbackAuthenticationAnnounceMode(configuration)), - configuration.getBoolean("auth.requireSSL", false), - configuration.getBoolean("auth.plainAuthEnabled", true), - parseSASLConfiguration(configuration)); - } - - private static Optional parseSASLConfiguration(HierarchicalConfiguration configuration) throws ConfigurationException { - boolean haveOidcProperties = configuration.getKeys(OIDC_PATH).hasNext(); - if (haveOidcProperties) { - try { - return Optional.of(OidcSASLConfiguration.parse(configuration.configurationAt(OIDC_PATH))); - } catch (MalformedURLException | URISyntaxException exception) { - throw new ConfigurationException("Failed to retrieve oauth component", exception); - } - } else { - return Optional.empty(); - } + configuration.getBoolean("auth.requireSSL", REQUIRE_SSL_DEFAULT)); } private static AuthenticationAnnounceMode fallbackAuthenticationAnnounceMode(HierarchicalConfiguration configuration) { @@ -142,37 +128,22 @@ private static AuthenticationAnnounceMode fallbackAuthenticationAnnounceMode(Hie private final AuthenticationAnnounceMode authenticationAnnounceMode; private final boolean requireSSL; - private final boolean plainAuthEnabled; - private final Optional saslConfiguration; - public AuthenticationConfiguration(AuthenticationAnnounceMode authenticationAnnounceMode, boolean requireSSL, boolean plainAuthEnabled, Optional saslConfiguration) { + public AuthAnnouncementConfiguration(AuthenticationAnnounceMode authenticationAnnounceMode, boolean requireSSL) { this.authenticationAnnounceMode = authenticationAnnounceMode; this.requireSSL = requireSSL; - this.plainAuthEnabled = plainAuthEnabled; - this.saslConfiguration = saslConfiguration; } public AuthenticationAnnounceMode getAuthenticationAnnounceMode() { return authenticationAnnounceMode; } - public boolean isRequireSSL() { - return requireSSL; - } - - public boolean isPlainAuthEnabled() { - return plainAuthEnabled; - } - - public Optional getSaslConfiguration() { - return saslConfiguration; - } } /** * Whether authentication is required to use this SMTP server. */ - private AuthenticationConfiguration authenticationConfiguration; + private AuthAnnouncementConfiguration authAnnouncementConfiguration; /** * Whether the server needs helo to be send first @@ -203,6 +174,8 @@ public Optional getSaslConfiguration() { private final SmtpMetrics smtpMetrics; private final DefaultChannelGroup smtpChannelGroup; private Set disabledFeatures = ImmutableSet.of(); + private ImmutableList saslMechanisms = ImmutableList.of(); + private Optional saslAuthenticator = Optional.empty(); private boolean addressBracketsEnforcement = true; @@ -216,6 +189,14 @@ public SMTPServer(SmtpMetrics smtpMetrics) { this.smtpChannelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); } + public void setSaslMechanisms(ImmutableList saslMechanisms) { + this.saslMechanisms = saslMechanisms; + } + + public void setSaslAuthenticator(Optional saslAuthenticator) { + this.saslAuthenticator = saslAuthenticator; + } + @Inject public void setDnsService(DNSService dns) { this.dns = dns; @@ -224,6 +205,9 @@ public void setDnsService(DNSService dns) { @Override protected void preInit() throws Exception { super.preInit(); + saslAuthenticator.ifPresent(authenticator -> getProtocolHandlerChain() + .getHandlers(AuthCmdHandler.class) + .forEach(handler -> handler.configureSaslMechanisms(saslMechanisms, authenticator))); if (authorizedAddresses != null) { java.util.StringTokenizer st = new java.util.StringTokenizer(authorizedAddresses, ", ", false); java.util.Collection networks = new java.util.ArrayList<>(); @@ -246,7 +230,7 @@ public ProtocolSession newSession(ProtocolTransport transport) { public void doConfigure(HierarchicalConfiguration configuration) throws ConfigurationException { super.doConfigure(configuration); if (isEnabled()) { - authenticationConfiguration = AuthenticationConfiguration.parse(configuration); + authAnnouncementConfiguration = AuthAnnouncementConfiguration.parse(configuration); authorizedAddresses = configuration.getString("authorizedAddresses", null); @@ -322,19 +306,15 @@ public boolean useAddressBracketsEnforcement() { return SMTPServer.this.addressBracketsEnforcement; } - public boolean isPlainAuthEnabled() { - return authenticationConfiguration.isPlainAuthEnabled(); - } - @Override public boolean isAuthAnnounced(String remoteIP, boolean tlsStarted) { - if (authenticationConfiguration.requireSSL && !tlsStarted) { + if (authAnnouncementConfiguration.requireSSL && !tlsStarted) { return false; } - if (authenticationConfiguration.getAuthenticationAnnounceMode() == ALWAYS) { + if (authAnnouncementConfiguration.getAuthenticationAnnounceMode() == ALWAYS) { return true; } - if (authenticationConfiguration.getAuthenticationAnnounceMode() == NEVER) { + if (authAnnouncementConfiguration.getAuthenticationAnnounceMode() == NEVER) { return false; } return Optional.ofNullable(authorizedNetworks) @@ -356,11 +336,6 @@ public String getSoftwareName() { return "JAMES SMTP Server "; } - @Override - public Optional saslConfiguration() { - return authenticationConfiguration.getSaslConfiguration(); - } - @Override public Set disabledFeatures() { return disabledFeatures; @@ -428,7 +403,7 @@ protected ChannelHandlerFactory createFrameHandlerFactory() { } public AuthenticationAnnounceMode getAuthRequired() { - return authenticationConfiguration.getAuthenticationAnnounceMode(); + return authAnnouncementConfiguration.getAuthenticationAnnounceMode(); } @Override diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServerFactory.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServerFactory.java index 5d4bbc08f4d..c04861508ef 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServerFactory.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/SMTPServerFactory.java @@ -21,12 +21,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Stream; import jakarta.inject.Inject; import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.core.ConnectionDescription; import org.apache.james.core.ConnectionDescriptionSupplier; @@ -35,26 +37,73 @@ import org.apache.james.dnsservice.api.DNSService; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.metrics.api.MetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; import org.apache.james.protocols.lib.handler.ProtocolHandlerLoader; import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer; import org.apache.james.protocols.lib.netty.AbstractServerFactory; import org.apache.james.protocols.netty.Encryption; +import org.apache.james.protocols.sasl.BuiltInSaslMechanismFactories; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.PlainSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; +import org.apache.james.smtpserver.netty.SMTPServer.AuthAnnouncementConfiguration; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; public class SMTPServerFactory extends AbstractServerFactory implements Disconnector, ConnectionDescriptionSupplier { + @FunctionalInterface + public interface SmtpSaslMechanismLoader { + static SmtpSaslMechanismLoader defaultLoader() { + ImmutableList defaultFactories = ImmutableList.of( + new PlainSaslMechanismFactory(AuthAnnouncementConfiguration.REQUIRE_SSL_DEFAULT), + new OauthBearerSaslMechanismFactory(), + new XOauth2SaslMechanismFactory()); + return configuration -> loadBuiltInMechanisms(defaultFactories, configuration); + } + + ImmutableList load(HierarchicalConfiguration configuration) throws ConfigurationException; + } + + private static ImmutableList loadBuiltInMechanisms(ImmutableList defaultFactories, + HierarchicalConfiguration configuration) throws ConfigurationException { + try { + return BuiltInSaslMechanismFactories.enabledForServer(defaultFactories, configuration) + .stream() + .map(Throwing.function(factory -> factory.create(configuration))) + .collect(ImmutableList.toImmutableList()); + } catch (RuntimeException e) { + if (e.getCause() instanceof ConfigurationException configurationException) { + throw configurationException; + } + throw e; + } + } protected final DNSService dns; protected final ProtocolHandlerLoader loader; protected final FileSystem fileSystem; protected final SmtpMetricsImpl smtpMetrics; + protected final SmtpSaslMechanismLoader saslMechanismLoader; + protected final Optional saslAuthenticator; protected Encryption.Factory encryptionFactory; - @Inject public SMTPServerFactory(DNSService dns, ProtocolHandlerLoader loader, FileSystem fileSystem, - MetricFactory metricFactory) { + MetricFactory metricFactory, SmtpSaslMechanismLoader saslMechanismLoader, SaslAuthenticator saslAuthenticator) { + this(dns, loader, fileSystem, metricFactory, saslMechanismLoader, Optional.of(saslAuthenticator)); + } + + private SMTPServerFactory(DNSService dns, ProtocolHandlerLoader loader, FileSystem fileSystem, + MetricFactory metricFactory, SmtpSaslMechanismLoader saslMechanismLoader, + Optional saslAuthenticator) { this.dns = dns; this.loader = loader; this.fileSystem = fileSystem; this.smtpMetrics = new SmtpMetricsImpl(metricFactory); + this.saslMechanismLoader = saslMechanismLoader; + this.saslAuthenticator = saslAuthenticator; } @Inject @@ -78,6 +127,8 @@ protected List createServers(HierarchicalConfig server.setProtocolHandlerLoader(loader); server.setFileSystem(fileSystem); server.setEncryptionFactory(encryptionFactory); + server.setSaslMechanisms(saslMechanismLoader.load(serverConfig)); + server.setSaslAuthenticator(saslAuthenticator); server.configure(serverConfig); servers.add(server); } diff --git a/server/protocols/protocols-smtp/src/main/resources/META-INF/spring/smtpserver-context.xml b/server/protocols/protocols-smtp/src/main/resources/META-INF/spring/smtpserver-context.xml index 68fedd9e1c3..1ac134715bf 100644 --- a/server/protocols/protocols-smtp/src/main/resources/META-INF/spring/smtpserver-context.xml +++ b/server/protocols/protocols-smtp/src/main/resources/META-INF/spring/smtpserver-context.xml @@ -23,6 +23,16 @@ + + + + + + + + + diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java new file mode 100644 index 00000000000..5d8265031ef --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java @@ -0,0 +1,163 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.hook.AuthHook; +import org.apache.james.protocols.smtp.hook.HookResult; +import org.apache.james.protocols.smtp.hook.HookReturnCode; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class AuthHookSaslMechanismTest { + private static final Username USERNAME = Username.of("user@domain.tld"); + + private final SMTPSession session = mock(SMTPSession.class); + + @Test + void shouldDelegatePlainAvailabilityToWrappedMechanism() { + // GIVEN a PLAIN mechanism disabled by the SMTP configuration + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(false, true), + ImmutableList.of(mock(AuthHook.class)), + ImmutableList.of()); + + // WHEN checking whether it is available on either transport + // THEN the legacy adapter preserves the configured policy + assertThat(mechanism.isAvailableOnTransport(false)).isFalse(); + assertThat(mechanism.isAvailableOnTransport(true)).isFalse(); + } + + @Test + void shouldFailWhenAllAuthHooksDecline() { + // GIVEN a legacy hook declining the provided credentials + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(any(SMTPSession.class), eq(USERNAME), eq("password"))).thenReturn(HookResult.DECLINED); + SMTPSession session = mock(SMTPSession.class); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN authenticating through the adapter + SaslStep step = mechanism.start(initialRequest("\0user@domain.tld\0password"), session).firstStep(); + + // THEN the legacy terminal failure is preserved without a fallback login + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.invalidCredentials( + USERNAME, + Optional.empty(), + "Invalid credentials"))); + } + + @Test + void shouldDelegateEmptyPasswordToAuthHooks() { + // GIVEN the legacy empty-password delegation form + AuthHook authHook = mock(AuthHook.class); + when(authHook.doDelegation(eq(session), eq(USERNAME))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.ok()) + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN authenticating through the adapter + SaslStep step = mechanism.start(initialRequest("\0user@domain.tld\0"), session).firstStep(); + + // THEN the legacy hook receives the delegation call + assertThat(step).isEqualTo(new SaslStep.Success( + new SaslIdentity(USERNAME, USERNAME), + Optional.empty())); + verify(authHook).doDelegation(session, USERNAME); + } + + @Test + void shouldPreserveCustomDeniedResponseAndDisconnect() { + // GIVEN a legacy hook denying authentication with a custom response and disconnect policy + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(eq(session), eq(USERNAME), eq("password"))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.DENY)) + .smtpReturnCode("421") + .smtpDescription("Too many authentication attempts") + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN the adapter processes the PLAIN credentials + AuthHookSaslMechanism.Exchange exchange = mechanism.start(initialRequest("\0user@domain.tld\0password"), session); + SaslStep step = exchange.firstStep(); + + // THEN the SASL failure keeps the legacy SMTP response for the protocol driver + assertThat(step).isInstanceOf(SaslStep.Failure.class); + Response response = AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow(); + assertThat(response.getRetCode()).isEqualTo("421"); + assertThat(response.getLines()).containsExactly("421 Too many authentication attempts"); + assertThat(response.isEndSession()).isTrue(); + } + + @Test + void shouldKeepConnectionOpenForSuccessfulAuthHookResponse() { + // GIVEN a legacy hook accepting authentication while requesting the connection to close + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(eq(session), eq(USERNAME), eq("password"))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.OK)) + .smtpReturnCode(SMTPRetCode.AUTH_OK) + .smtpDescription("Authentication successful") + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN the adapter processes the PLAIN credentials + AuthHookSaslMechanism.Exchange exchange = mechanism.start(initialRequest("\0user@domain.tld\0password"), session); + SaslStep step = exchange.firstStep(); + + // THEN SMTP success keeps the connection open + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow().isEndSession()).isFalse(); + } + + private SaslInitialRequest initialRequest(String value) { + return new SaslInitialRequest(SaslMechanismNames.PLAIN, Optional.of(value.getBytes(UTF_8))); + } +} diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java index 7049978ed72..24464bc5966 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AuthAnnounceTest.java @@ -23,6 +23,8 @@ import java.net.InetSocketAddress; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.commons.net.smtp.SMTPClient; import org.apache.james.server.core.configuration.FileConfigurationProvider; import org.assertj.core.api.SoftAssertions; @@ -45,9 +47,7 @@ void tearDown() { @Test void authAnnounceAlwaysShouldAnnounceAuth() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceAlways.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-authAnnounceAlways.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -63,9 +63,7 @@ void authAnnounceAlwaysShouldAnnounceAuth() throws Exception { @Test void authAnnounceSometimeShouldNotAnnounceAuthWhenMatching() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceSometimeMatching.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-authAnnounceSometimeMatching.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -81,9 +79,7 @@ void authAnnounceSometimeShouldNotAnnounceAuthWhenMatching() throws Exception { @Test void plainAuthShouldNotBeAnnouncedWhenDisabled() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-no-plain.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-no-plain.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -99,9 +95,7 @@ void plainAuthShouldNotBeAnnouncedWhenDisabled() throws Exception { @Test void plainAuthShouldFailWhenDisabled() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-no-plain.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-no-plain.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -114,9 +108,7 @@ void plainAuthShouldFailWhenDisabled() throws Exception { @Test void authAnnounceSometimeShouldAnnounceAuthWhenNotMatching() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceSometimeNotMatching.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-authAnnounceSometimeNotMatching.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -132,9 +124,7 @@ void authAnnounceSometimeShouldAnnounceAuthWhenNotMatching() throws Exception { @Test void authAnnounceNeverShouldNotAnnounceAuth() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-authAnnounceNever.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-authAnnounceNever.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -150,9 +140,7 @@ void authAnnounceNeverShouldNotAnnounceAuth() throws Exception { @Test void authShouldNotBeAnnouncedOnPlainChannelsWhenRequireSSL() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-requireSSL.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-requireSSL.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -168,9 +156,7 @@ void authShouldNotBeAnnouncedOnPlainChannelsWhenRequireSSL() throws Exception { @Test void shouldStartWithPreviousConfiguration() throws Exception { - testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( - ClassLoader.getSystemResourceAsStream("smtpserver-noauth.xml"))); - testSystem.smtpServer.init(); + configureAndInit("smtpserver-noauth.xml"); SMTPClient smtpProtocol = new SMTPClient(); InetSocketAddress bindedAddress = testSystem.getBindedAddress(); @@ -184,4 +170,12 @@ void shouldStartWithPreviousConfiguration() throws Exception { .doesNotContain("250-AUTH LOGIN PLAIN"); }); } + + private void configureAndInit(String configurationName) throws Exception { + HierarchicalConfiguration configuration = FileConfigurationProvider.getConfig( + ClassLoader.getSystemResourceAsStream(configurationName)); + testSystem.configureSaslMechanisms(configuration); + testSystem.smtpServer.configure(configuration); + testSystem.smtpServer.init(); + } } diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java index 45bac667831..8ad3db72785 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java @@ -27,6 +27,7 @@ import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Optional; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.tree.ImmutableNode; @@ -37,9 +38,19 @@ import org.apache.james.jwt.OidcTokenFixture; import org.apache.james.mailbox.Authorizator; import org.apache.james.protocols.api.OIDCSASLHelper; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslStep; import org.apache.james.protocols.api.utils.BogusSslContextFactory; import org.apache.james.protocols.api.utils.BogusTrustManagerFactory; import org.apache.james.protocols.lib.mock.ConfigLoader; +import org.apache.james.protocols.sasl.OauthBearerSaslMechanismFactory; +import org.apache.james.protocols.sasl.XOauth2SaslMechanismFactory; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import org.apache.james.util.ClassLoaderUtils; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.AfterEach; @@ -49,6 +60,8 @@ import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; +import com.google.common.collect.ImmutableList; + class SMTPSaslTest { public static final String LOCAL_DOMAIN = "domain.org"; public static final Username USER = Username.of("user@domain.org"); @@ -63,6 +76,8 @@ class SMTPSaslTest { String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", SCOPE, OIDC_URL).getBytes(UTF_8)); public static final String VALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); public static final String INVALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.INVALID_TOKEN); + private static final SaslMechanism ADVERTISED_OAUTHBEARER = new AdvertisedOnlySaslMechanism("OAUTHBEARER"); + private static final SaslMechanism ADVERTISED_XOAUTH2 = new AdvertisedOnlySaslMechanism("XOAUTH2"); private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); @@ -83,17 +98,29 @@ void setUp() throws Exception { config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); - Authorizator authorizator = (userId, otherUserId) -> { + setUpServerWithOidcMechanisms(config); + } + + private void setUpServerWithOidcMechanisms(HierarchicalConfiguration config) throws Exception { + testSystem.setUpWithSaslMechanisms(config, authorizator(), ImmutableList.of( + new OauthBearerSaslMechanismFactory().create(config), + new XOauth2SaslMechanismFactory().create(config))); + addUsers(); + } + + private void addUsers() throws Exception { + testSystem.domainList.addDomain(Domain.of(LOCAL_DOMAIN)); + testSystem.usersRepository.addUser(USER, PASSWORD); + testSystem.usersRepository.addUser(USER2, PASSWORD); + } + + private Authorizator authorizator() { + return (userId, otherUserId) -> { if (userId.equals(USER) && otherUserId.equals(USER2)) { return Authorizator.AuthorizationState.ALLOWED; } return Authorizator.AuthorizationState.FORBIDDEN; }; - - testSystem.setUp(config, authorizator); - testSystem.domainList.addDomain(Domain.of(LOCAL_DOMAIN)); - testSystem.usersRepository.addUser(USER, PASSWORD); - testSystem.usersRepository.addUser(USER2, PASSWORD); } private SMTPSClient initSMTPSClient() throws IOException { @@ -117,7 +144,7 @@ void oauthShouldSuccessWhenValidToken() throws Exception { client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); client.sendCommand("NOOP"); assertThat(client.getReplyString()).contains("250 2.0.0 OK"); @@ -131,7 +158,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuation() throws Exception { assertThat(client.getReplyString()).contains("334"); client.sendCommand(VALID_OAUTHBEARER_TOKEN); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); client.sendCommand("NOOP"); assertThat(client.getReplyString()).contains("250 2.0.0 OK"); @@ -145,7 +172,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuationAndXOauth2() throws Exceptio assertThat(client.getReplyString()).contains("334"); client.sendCommand(OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); client.sendCommand("NOOP"); assertThat(client.getReplyString()).contains("250 2.0.0 OK"); @@ -157,7 +184,7 @@ void oauthShouldSupportXOAUTH2Type() throws Exception { client.sendCommand("AUTH XOAUTH2 " + OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); } @Test @@ -234,8 +261,8 @@ void shouldNotOauthWhenAlreadyAuthenticated() throws Exception { void oauthShouldFailWhenConfigIsNotProvided() throws Exception { testSystem.smtpServer.destroy(); HierarchicalConfiguration config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml")); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + testSystem.setUp(config, authorizator()); + addUsers(); SMTPSClient client = initSMTPSClient(); @@ -273,8 +300,8 @@ void ehloShouldAdvertiseXOAUTH2WhenConfigIsProvided() throws Exception { void ehloShouldNotAdvertiseOAUTHBEARERWhenConfigIsNotProvided() throws Exception { testSystem.smtpServer.destroy(); HierarchicalConfiguration config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml")); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + testSystem.setUp(config, authorizator()); + addUsers(); SMTPSClient client = initSMTPSClient(); client.sendCommand("EHLO localhost"); @@ -290,8 +317,8 @@ void ehloShouldNotAdvertiseOAUTHBEARERWhenConfigIsNotProvided() throws Exception void ehloShouldNotAdvertiseXOAUTH2WhenConfigIsNotProvided() throws Exception { testSystem.smtpServer.destroy(); HierarchicalConfiguration config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-advancedSecurity.xml")); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + testSystem.setUp(config, authorizator()); + addUsers(); SMTPSClient client = initSMTPSClient(); client.sendCommand("EHLO localhost"); @@ -318,8 +345,7 @@ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception { config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH)); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + setUpServerWithOidcMechanisms(config); SMTPSClient client = initSMTPSClient(); @@ -347,14 +373,13 @@ void oauthShouldSuccessWhenIntrospectTokenReturnActiveIsTrue() throws Exception config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), INTROSPECT_TOKEN_URI_PATH)); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + setUpServerWithOidcMechanisms(config); SMTPSClient client = initSMTPSClient(); client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); } @Test @@ -372,8 +397,7 @@ void oauthShouldFailWhenIntrospectTokenServerError() throws Exception { config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); config.addProperty("auth.oidc.introspection.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), invalidURI)); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + setUpServerWithOidcMechanisms(config); SMTPSClient client = initSMTPSClient(); @@ -397,14 +421,13 @@ void oauthShouldSuccessWhenCheckTokenByUserInfoIsPassed() throws Exception { config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); config.addProperty("auth.oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), USERINFO_URI_PATH)); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + setUpServerWithOidcMechanisms(config); SMTPSClient client = initSMTPSClient(); client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); } @Test @@ -421,8 +444,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { config.addProperty("auth.oidc.oidcConfigurationURL", OIDC_URL); config.addProperty("auth.oidc.scope", SCOPE); config.addProperty("auth.oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", authServer.getLocalPort(), USERINFO_URI_PATH)); - testSystem.smtpServer.configure(config); - testSystem.smtpServer.init(); + setUpServerWithOidcMechanisms(config); SMTPSClient client = initSMTPSClient(); @@ -437,9 +459,6 @@ void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse("another@domain.org", OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); - assertThat(client.getReplyString()).contains("334 "); - - client.sendCommand("AQ=="); assertThat(client.getReplyString()).contains("535 Authentication Failed"); } @@ -449,7 +468,7 @@ void oauthShouldImpersonateSuccessWhenDelegated() throws Exception { String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); - assertThat(client.getReplyString()).contains("235 Authentication successful."); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); } @Test @@ -469,4 +488,208 @@ void impersonationShouldWorkWhenDelegated() throws Exception { .as("mail received by mail server") .isNotNull(); } + + @Test + void ehloShouldAdvertisePlainAndLoginWhenPlainMechanismIsConfigured() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("EHLO localhost"); + + assertThat(client.getReplyString()).contains("250-AUTH LOGIN PLAIN"); + } + + @Test + void ehloShouldPreserveConfiguredMechanismOrderAndDeduplicateAdvertisement() throws Exception { + resetWithMechanisms(ImmutableList.of( + ADVERTISED_OAUTHBEARER, + new PlainSaslMechanism(true, false), + ADVERTISED_XOAUTH2, + new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("EHLO localhost"); + + assertThat(client.getReplyString()) + .contains("250-AUTH OAUTHBEARER LOGIN PLAIN XOAUTH2") + .doesNotContain("LOGIN PLAIN XOAUTH2 LOGIN PLAIN"); + } + + @Test + void ehloShouldAdvertiseOnlyConfiguredSaslMechanisms() throws Exception { + resetWithMechanisms(ImmutableList.of(ADVERTISED_XOAUTH2)); + SMTPClient client = connectedClient(); + + client.sendCommand("EHLO localhost"); + + assertThat(client.getReplyString()) + .contains("250-AUTH XOAUTH2") + .doesNotContain("LOGIN") + .doesNotContain("PLAIN") + .doesNotContain("OAUTHBEARER"); + } + + @Test + void authPlainShouldBeRejectedWhenPlainMechanismIsNotConfigured() throws Exception { + resetWithMechanisms(ImmutableList.of(ADVERTISED_XOAUTH2)); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH PLAIN " + plainInitialResponse(SMTPServerTestSystem.BOB.asString(), SMTPServerTestSystem.PASSWORD)); + + assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); + } + + @Test + void authPlainWithInitialResponseShouldSucceedWhenCredentialsAreValid() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH PLAIN " + plainInitialResponse(SMTPServerTestSystem.BOB.asString(), SMTPServerTestSystem.PASSWORD)); + + assertThat(client.getReplyString()).contains("235 Authentication Successful"); + } + + @Test + void authPlainContinuationShouldSucceedWhenCredentialsAreValid() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH PLAIN"); + assertThat(client.getReplyString()).contains("334 "); + client.sendCommand(plainInitialResponse(SMTPServerTestSystem.BOB.asString(), SMTPServerTestSystem.PASSWORD)); + + assertThat(client.getReplyString()).contains("235 Authentication Successful"); + } + + @Test + void authLoginShouldSucceedWhenCredentialsAreValid() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH LOGIN"); + assertThat(client.getReplyString()).contains("334 VXNlcm5hbWU6"); + client.sendCommand(base64(SMTPServerTestSystem.BOB.asString())); + assertThat(client.getReplyString()).contains("334 UGFzc3dvcmQ6"); + client.sendCommand(base64(SMTPServerTestSystem.PASSWORD)); + + assertThat(client.getReplyString()).contains("235 Authentication Successful"); + } + + @Test + void authPlainShouldFailWhenCredentialsAreInvalid() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH PLAIN " + plainInitialResponse(SMTPServerTestSystem.BOB.asString(), "bad-password")); + + assertThat(client.getReplyString()).contains("535 Authentication Failed"); + } + + @Test + void authShouldSendFinalServerDataBeforeSuccess() throws Exception { + resetWithMechanisms(ImmutableList.of(new ServerDataSaslMechanism())); + SMTPClient client = connectedClient(); + + client.sendCommand("AUTH SERVER-DATA"); + assertThat(client.getReplyString()).contains("334 " + base64("server-data")); + + client.sendCommand(""); + assertThat(client.getReplyString()).contains("235 Authentication Successful"); + } + + @Test + void mechanismUnavailableOnClearTransportShouldNotBeAdvertisedAndShouldBeRejected() throws Exception { + resetWithMechanisms(ImmutableList.of(new PlainSaslMechanism(true, true))); + SMTPClient client = connectedClient(); + + client.sendCommand("EHLO localhost"); + assertThat(client.getReplyString()).doesNotContain("250-AUTH LOGIN PLAIN"); + + client.sendCommand("AUTH PLAIN " + plainInitialResponse(SMTPServerTestSystem.BOB.asString(), SMTPServerTestSystem.PASSWORD)); + assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); + } + + private void resetWithMechanisms(ImmutableList saslMechanisms) throws Exception { + testSystem.smtpServer.destroy(); + HierarchicalConfiguration configuration = ConfigLoader.getConfig( + ClassLoaderUtils.getSystemResourceAsSharedStream("smtpserver-authAnnounceAlways.xml")); + testSystem.setUpWithSaslMechanisms(configuration, authorizator(), saslMechanisms); + } + + private SMTPClient connectedClient() throws IOException { + SMTPClient client = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + client.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + return client; + } + + private String plainInitialResponse(String username, String password) { + return base64("\0" + username + "\0" + password); + } + + private String base64(String value) { + return Base64.getEncoder().encodeToString(value.getBytes(UTF_8)); + } + + private static class AdvertisedOnlySaslMechanism implements SaslMechanism { + private final String name; + + private AdvertisedOnlySaslMechanism(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new SaslExchange() { + @Override + public SaslStep firstStep() { + return new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.empty(), "Test-only mechanism")); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.empty(), "Test-only mechanism")); + } + + @Override + public void close() { + } + }; + } + } + + private static class ServerDataSaslMechanism implements SaslMechanism { + @Override + public String name() { + return "SERVER-DATA"; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new SaslExchange() { + @Override + public SaslStep firstStep() { + return new SaslStep.Success( + new SaslIdentity(USER, USER), + Optional.of("server-data".getBytes(UTF_8))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return firstStep(); + } + + @Override + public void close() { + } + }; + } + } } diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java index c7b82cb41c1..71391cf7db8 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTest.java @@ -1363,8 +1363,10 @@ public void testAuthShouldFailedWhenUserPassIsNotBase64Decoded() throws Exceptio smtpProtocol.sendCommand("AUTH PLAIN"); smtpProtocol.sendCommand("canNotDecode"); + // RFC 4954 distinguishes malformed AUTH exchange data from invalid credentials: + // this payload has no valid PLAIN separators, so 501 is more accurate than 535. assertThat(smtpProtocol.getReplyString()) - .contains("535 Authentication Failed"); + .contains("501 Could not decode parameters for AUTH PLAIN"); } @Test diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java index d276912fe2c..4b9ee5a181c 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPServerTestSystem.java @@ -25,6 +25,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneId; +import java.util.Optional; import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.commons.configuration2.HierarchicalConfiguration; @@ -39,6 +40,7 @@ import org.apache.james.domainlist.memory.MemoryDomainList; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailrepository.api.MailRepositoryStore; import org.apache.james.mailrepository.api.Protocol; import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration; @@ -49,9 +51,15 @@ import org.apache.james.metrics.api.Metric; import org.apache.james.metrics.api.MetricFactory; import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.protocols.api.sasl.SaslAuthenticationResult; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslMechanism; import org.apache.james.protocols.api.utils.ProtocolServerUtils; import org.apache.james.protocols.lib.LegacyJavaEncryptionFactory; import org.apache.james.protocols.lib.mock.MockProtocolHandlerLoader; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; import org.apache.james.queue.api.MailQueueFactory; import org.apache.james.queue.api.RawMailQueueItemDecoratorFactory; import org.apache.james.queue.memory.MemoryMailQueueFactory; @@ -67,6 +75,7 @@ import org.apache.james.smtpserver.netty.SMTPServer; import org.apache.james.smtpserver.netty.SmtpMetricsImpl; import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.api.UsersRepositoryException; import org.apache.james.user.memory.MemoryUsersRepository; import com.google.common.collect.ImmutableList; @@ -100,6 +109,23 @@ void setUp(String configuration) throws Exception { void setUp(HierarchicalConfiguration configuration, Authorizator authorizator) throws Exception { preSetUp(authorizator); + configureSaslMechanisms(configuration); + smtpServer.configure(configuration); + smtpServer.init(); + } + + void configureSaslMechanisms(HierarchicalConfiguration configuration) { + smtpServer.setSaslMechanisms(ImmutableList.of(new PlainSaslMechanism( + configuration.getBoolean("auth.plainAuthEnabled", true), + configuration.getBoolean("auth.requireSSL", false)))); + } + + void setUpWithSaslMechanisms(HierarchicalConfiguration configuration, Authorizator authorizator, + ImmutableList saslMechanisms) throws Exception { + preSetUp(authorizator); + + smtpServer.setSaslMechanisms(saslMechanisms); + smtpServer.setSaslAuthenticator(Optional.of(testSaslAuthenticator(authorizator))); smtpServer.configure(configuration); smtpServer.init(); } @@ -116,7 +142,7 @@ void preSetUp(Authorizator authorizator) throws Exception { createMailRepositoryStore(); setUpFakeLoader(authorizator); - setUpSMTPServer(); + setUpSMTPServer(authorizator); } void preSetUp() throws Exception { @@ -141,7 +167,7 @@ protected SMTPServer createSMTPServer(SmtpMetricsImpl smtpMetrics) { return new SMTPServer(smtpMetrics); } - protected void setUpSMTPServer() { + protected void setUpSMTPServer(Authorizator authorizator) { SmtpMetricsImpl smtpMetrics = mock(SmtpMetricsImpl.class); when(smtpMetrics.getCommandsMetric()).thenReturn(mock(Metric.class)); when(smtpMetrics.getConnectionMetric()).thenReturn(mock(Metric.class)); @@ -150,6 +176,8 @@ protected void setUpSMTPServer() { smtpServer.setFileSystem(fileSystem); smtpServer.setEncryptionFactory(new LegacyJavaEncryptionFactory(fileSystem)); smtpServer.setProtocolHandlerLoader(chain); + smtpServer.setSaslMechanisms(ImmutableList.of(new PlainSaslMechanism(true, false))); + smtpServer.setSaslAuthenticator(Optional.of(testSaslAuthenticator(authorizator))); } protected void setUpFakeLoader(Authorizator authorizator) { @@ -178,6 +206,34 @@ protected void setUpFakeLoader(Authorizator authorizator) { .build(); } + private SaslAuthenticator testSaslAuthenticator(Authorizator authorizator) { + return new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional authorizationId, String password) { + try { + return usersRepository.test(authenticationId, password) + .map(authenticatedUser -> authorize(new SaslIdentity(authenticatedUser, authorizationId.orElse(authenticatedUser)))) + .orElseGet(() -> new SaslAuthenticationResult.Failure(SaslFailure.invalidCredentials(authenticationId, authorizationId, "Invalid credentials"))); + } catch (UsersRepositoryException e) { + return new SaslAuthenticationResult.Failure(SaslFailure.serverError(Optional.of(authenticationId), authorizationId, "Authentication failed", e)); + } + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + try { + if (identity.authenticationId().equals(identity.authorizationId()) + || authorizator.user(identity.authenticationId()).canLoginAs(identity.authorizationId()) == Authorizator.AuthorizationState.ALLOWED) { + return new SaslAuthenticationResult.Success(identity); + } + return new SaslAuthenticationResult.Failure(SaslFailure.delegationForbidden(identity.authenticationId(), identity.authorizationId(), "Delegation forbidden")); + } catch (MailboxException e) { + return new SaslAuthenticationResult.Failure(SaslFailure.serverError(Optional.of(identity.authenticationId()), Optional.of(identity.authorizationId()), "Authentication failed", e)); + } + } + }; + } + InetSocketAddress getBindedAddress() { return new ProtocolServerUtils(smtpServer).retrieveBindedAddress(); } diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java index c63833fb9e6..2341714eea2 100644 --- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/memory/MemoryWebAdminServerIntegrationTest.java @@ -22,8 +22,12 @@ import static io.restassured.RestAssured.when; import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import static org.apache.james.jmap.JMAPTestingConstants.LOCALHOST_IP; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.is; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; @@ -40,6 +44,7 @@ class MemoryWebAdminServerIntegrationTest extends WebAdminServerIntegrationTest { private static final String DOMAIN = "domain"; private static final String USERNAME = "bob@" + DOMAIN; + private static final String ADMIN_USERNAME = "admin@" + DOMAIN; private static final String PASSWORD = "password"; @RegisterExtension @@ -79,4 +84,29 @@ void shouldDescribeConnectedImapChannels(GuiceJamesServer server) throws Excepti .body("[0].protocolSpecificInformation.userAgent", is("{name=Thunderbird, version=102.7.1}")) .body("[0].protocolSpecificInformation.requestCount", is("3")); } + + @Test + void shouldDescribeDelegatedImapChannels(GuiceJamesServer server) throws Exception { + int imapPort = server.getProbe(ImapGuiceProbe.class).getImapPort(); + + server.getProbe(DataProbeImpl.class).addUser(USERNAME, PASSWORD); + server.getProbe(DataProbeImpl.class).addUser(ADMIN_USERNAME, PASSWORD); + + String initialClientResponse = Base64.getEncoder() + .encodeToString((USERNAME + "\0" + ADMIN_USERNAME + "\0" + PASSWORD).getBytes(StandardCharsets.US_ASCII)); + + testIMAPClient.connect(LOCALHOST_IP, imapPort); + String authenticateResponse = testIMAPClient.sendCommand("AUTHENTICATE PLAIN " + initialClientResponse); + testIMAPClient.select("INBOX"); + + assertThat(authenticateResponse).contains("OK AUTHENTICATE completed."); + + // loggedInUser should be well recorded for delegation + when() + .get("/servers/channels/" + USERNAME) + .then() + .statusCode(HttpStatus.OK_200) + .body("[0].username", is(USERNAME)) + .body("[0].protocolSpecificInformation.loggedInUser", is(ADMIN_USERNAME)); + } } \ No newline at end of file diff --git a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml index f30afe0b249..c77b813a5c9 100644 --- a/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml +++ b/server/protocols/webadmin-integration-test/memory-webadmin-integration-test/src/test/resources/imapserver.xml @@ -36,6 +36,11 @@ under the License. 0 0 false + + + admin@domain + + false 0 diff --git a/upgrade-instructions.md b/upgrade-instructions.md index 29987f7061e..efa37b07ff2 100644 --- a/upgrade-instructions.md +++ b/upgrade-instructions.md @@ -19,6 +19,24 @@ Change list: - [Adding thread_id column to Cassandra email_query_view_sent_at and email_query_view_received_at tables](#adding-thread_id-column-to-cassandra-email_query_view_sent_at-and-email_query_view_received_at-tables) - [Adding thread_id column to Postgresql email_query_view table](#adding-thread_id-column-to-postgresql-email_query_view-table) - [Lucene mailbox index schema update for collapseThreads support](#lucene-mailbox-index-schema-update-for-collapsethreads-support) + - [JAMES-4210 SMTP AuthHook deprecation](#james-4210-smtp-authhook-deprecation) + +### JAMES-4210 SMTP AuthHook deprecation + +Date: 24/06/2026 + +Concerned products: SMTP servers declaring custom `AuthHook` implementations + +`AuthHook` is deprecated. Existing handler-chain registrations remain usable through the automatically applied +`AuthHookSaslMechanism` compatibility adapter, but new and existing extensions should avoid relying on `AuthHook` +going forward and migrate to dedicated SASL mechanisms. + +Extensions that only decorate authentication results for audit, notification or metrics should migrate to +`SaslAuthResultHook`. + +Normal SMTP user authentication now relies on `PlainSaslMechanism`, not a default `UsersRepositoryAuthHook`. +Deployments that relied on a custom `AuthHook` declining before `UsersRepositoryAuthHook` authenticated normal users +must explicitly declare `UsersRepositoryAuthHook` after their custom hook in the SMTP handler chain. ### Adding metadata column to blob tables