From 996f459fa9758cd4b773c7ca30b3a138225bd53e Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 3 Jun 2026 15:17:50 +0700 Subject: [PATCH 01/10] [NO REVIEW] SASL SPI + IMAP adoption cf https://github.com/apache/james-project/pull/3059 --- examples/custom-imap/README.md | 75 ++++ examples/custom-imap/pom.xml | 6 + .../sample-configuration/imapserver.xml | 7 + .../sasl/ExampleTokenSaslConfiguration.java | 43 ++ .../imap/sasl/ExampleTokenSaslMechanism.java | 99 +++++ .../ExampleTokenSaslMechanismFactory.java | 33 ++ .../src/main/resources/imapserver.xml | 16 +- .../imap/CustomSaslMechanismTest.java | 209 ++++++++++ pom.xml | 5 + .../james/protocols/api/OIDCSASLParser.java | 60 +-- .../api/sasl/SaslAuthenticationResult.java | 32 ++ .../protocols/api/sasl/SaslAuthenticator.java | 43 ++ .../protocols/api/sasl/SaslExchange.java | 45 ++ .../james/protocols/api/sasl/SaslFailure.java | 66 +++ .../protocols/api/sasl/SaslIdentity.java | 31 ++ .../api/sasl/SaslInitialRequest.java | 42 ++ .../protocols/api/sasl/SaslMechanism.java | 44 ++ .../api/sasl/SaslMechanismFactory.java | 31 ++ .../api/sasl/SaslMechanismNames.java | 29 ++ .../james/protocols/api/sasl/SaslStep.java | 66 +++ .../api/sasl/SaslMechanismContractTest.java | 249 +++++++++++ protocols/imap/pom.xml | 4 + .../james/imap/api/process/ImapSession.java | 24 -- .../encode/AuthenticateResponseEncoder.java | 6 +- .../james/imap/encode/FakeImapSession.java | 21 - .../response/AuthenticateResponse.java | 16 +- .../imap/processor/AbstractAuthProcessor.java | 179 +++----- .../imap/processor/AuthenticateProcessor.java | 393 ++++++++++++------ .../imap/processor/DefaultProcessor.java | 49 ++- .../james/imap/processor/EnableProcessor.java | 12 +- .../james/imap/processor/LoginProcessor.java | 39 +- .../main/DefaultImapProcessorFactory.java | 70 +++- .../imap/processor/sasl/ImapSaslBridge.java | 104 +++++ .../processor/AuthenticateProcessorTest.java | 277 ++++++++++++ .../processor/sasl/ImapSaslBridgeTest.java | 240 +++++++++++ protocols/pom.xml | 1 + protocols/sasl/pom.xml | 66 +++ .../sasl/BuiltInSaslMechanismFactories.java | 46 ++ .../sasl/JamesSaslAuthenticator.java | 118 ++++++ .../sasl/OauthBearerSaslMechanismFactory.java | 34 ++ .../sasl/OidcSaslMechanismFactory.java | 43 ++ .../sasl/PlainSaslMechanismFactory.java | 45 ++ .../sasl/XOauth2SaslMechanismFactory.java | 34 ++ .../sasl/oidc/OAuthSaslMechanism.java | 105 +++++ .../sasl/plain/PlainSaslMechanism.java | 144 +++++++ .../sasl/oidc/OidcSaslMechanismTest.java | 175 ++++++++ .../sasl/plain/PlainSaslMechanismTest.java | 194 +++++++++ server/container/guice/common/pom.xml | 8 + .../james/modules/CommonServicesModule.java | 2 +- .../james/modules/SaslMechanismFactories.java | 31 ++ .../utils/GuiceSaslMechanismResolver.java | 89 ++++ ...ingDefaultPackageSaslMechanismFactory.java | 33 ++ .../ConfigurableFakeSaslMechanismFactory.java | 32 ++ .../ExternalFakeSaslMechanismFactory.java | 32 ++ .../james/utils/FixedNameSaslMechanism.java | 69 +++ .../utils/GuiceSaslMechanismResolverTest.java | 242 +++++++++++ server/container/guice/protocols/imap/pom.xml | 4 + .../modules/protocols/IMAPServerModule.java | 84 +++- .../ImapDefaultSaslMechanismFactories.java | 31 ++ .../protocols/IMAPServerModuleTest.java | 106 +++++ .../james/imapserver/netty/IMAPServer.java | 64 --- .../netty/ImapChannelUpstreamHandler.java | 18 +- .../imapserver/netty/NettyImapSession.java | 32 +- .../META-INF/spring/imapserver-context.xml | 20 +- .../netty/AbstractIMAPServerTest.java | 3 +- .../MemoryWebAdminServerIntegrationTest.java | 30 ++ .../src/test/resources/imapserver.xml | 5 + 67 files changed, 4146 insertions(+), 459 deletions(-) create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslConfiguration.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanism.java create mode 100644 examples/custom-imap/src/main/java/org/apache/james/examples/imap/sasl/ExampleTokenSaslMechanismFactory.java create mode 100644 examples/custom-imap/src/test/java/org/apache/james/examples/imap/CustomSaslMechanismTest.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticationResult.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslAuthenticator.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslExchange.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslIdentity.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslInitialRequest.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanism.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismFactory.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java create mode 100644 protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslStep.java create mode 100644 protocols/api/src/test/java/org/apache/james/protocols/api/sasl/SaslMechanismContractTest.java create mode 100644 protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java create mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/AuthenticateProcessorTest.java create mode 100644 protocols/imap/src/test/java/org/apache/james/imap/processor/sasl/ImapSaslBridgeTest.java create mode 100644 protocols/sasl/pom.xml create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/BuiltInSaslMechanismFactories.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/JamesSaslAuthenticator.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java create mode 100644 protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java create mode 100644 protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java create mode 100644 protocols/sasl/src/test/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanismTest.java create mode 100644 server/container/guice/common/src/main/java/org/apache/james/modules/SaslMechanismFactories.java create mode 100644 server/container/guice/common/src/main/java/org/apache/james/utils/GuiceSaslMechanismResolver.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/protocols/sasl/TestingDefaultPackageSaslMechanismFactory.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/ConfigurableFakeSaslMechanismFactory.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/ExternalFakeSaslMechanismFactory.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/FixedNameSaslMechanism.java create mode 100644 server/container/guice/common/src/test/java/org/apache/james/utils/GuiceSaslMechanismResolverTest.java create mode 100644 server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ImapDefaultSaslMechanismFactories.java create mode 100644 server/container/guice/protocols/imap/src/test/java/org/apache/james/modules/protocols/IMAPServerModuleTest.java 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/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..385ef2140b6 --- /dev/null +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslFailure.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; + +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)); + } +} 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/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..49de4f2e404 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java @@ -0,0 +1,34 @@ +/**************************************************************** + * 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.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 { + return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, parseVerifier(serverConfiguration)); + } +} 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..3cf55ed170b --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OidcSaslMechanismFactory.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.sasl; + +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.OidcJwtTokenVerifier; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslMechanismFactory; + +abstract class OidcSaslMechanismFactory implements SaslMechanismFactory { + protected OidcJwtTokenVerifier parseVerifier(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + if (serverConfiguration.immutableConfigurationsAt("auth.oidc").isEmpty()) { + throw new ConfigurationException("OAuth SASL mechanisms require an auth.oidc configuration"); + } + try { + return new OidcJwtTokenVerifier(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..9a3537a772f --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/PlainSaslMechanismFactory.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.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; + + @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", PLAIN_AUTH_DISALLOWED_DEFAULT)); + } +} 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..9f9c895b7f3 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/XOauth2SaslMechanismFactory.java @@ -0,0 +1,34 @@ +/**************************************************************** + * 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.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 { + return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, parseVerifier(serverConfiguration)); + } +} 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..cd2106d1f01 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/oidc/OAuthSaslMechanism.java @@ -0,0 +1,105 @@ +/**************************************************************** + * 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; + + public OAuthSaslMechanism(String name, OidcJwtTokenVerifier verifier) { + this.name = name; + this.verifier = verifier; + } + + @Override + public String name() { + return name; + } + + @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 OAuthSaslExchange(Optional initialResponse, SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + 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 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(() -> new SaslStep.Failure(SaslFailure.authenticationFailed( + Optional.empty(), Optional.of(authorizationId), "OAuth authentication failed."))); + }) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + 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..96aa8212c12 --- /dev/null +++ b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/plain/PlainSaslMechanism.java @@ -0,0 +1,144 @@ +/**************************************************************** + * 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.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()); + + 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..ddc1eeae5f3 --- /dev/null +++ b/protocols/sasl/src/test/java/org/apache/james/protocols/sasl/oidc/OidcSaslMechanismTest.java @@ -0,0 +1,175 @@ +/**************************************************************** + * 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.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"; + + @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 = new OAuthSaslMechanism(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 = new OAuthSaslMechanism(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 = new OAuthSaslMechanism(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 = new OAuthSaslMechanism(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 shouldFailWhenTokenIsRejected() { + // 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"))); + + // WHEN token validation rejects the token + SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, rejectingToken()).start(request, authorizing()).firstStep(); + + // THEN the mechanism returns a typed authentication failure + assertThat(step).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 = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + + // THEN the failure is returned to the protocol driver + assertThat(step).isEqualTo(new SaslStep.Failure(failure)); + } + + 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/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/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/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 From af7fa7937bc497aa4c809db905c654995d0203ca Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 17 Jun 2026 15:27:17 +0700 Subject: [PATCH 02/10] JAMES-4210 Add SMTP SASL bridge Introduce an SMTP SASL bridge for decoding initial responses and continuation lines, encoding challenges as SMTP 334 responses, detecting aborts, and supporting final SASL server data through the RFC 4422 additional-challenge flow. --- .../smtp/core/esmtp/SmtpSaslBridge.java | 116 ++++++++ .../smtp/core/esmtp/SmtpSaslBridgeTest.java | 247 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridge.java create mode 100644 protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/SmtpSaslBridgeTest.java 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/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); + } +} From 626520ee6a833b03ee009a8f8abd47451e114683 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 10:53:09 +0700 Subject: [PATCH 03/10] JAMES-4210 Align IMAP OIDC SASL error handling with RFC 7628 Assert the base64 OAuth error challenge and dummy client response required by RFC 7628 before terminal IMAP authentication failure, aligning IMAP behavior with the existing SMTP OIDC error flow. --- .../imapserver/netty/IMAPServerOidcTest.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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."); } From c9c30da8ca3bedf4100b0da7d21f5e9258c07617 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 10:56:46 +0700 Subject: [PATCH 04/10] JAMES-4210 Extend shared SASL mechanisms for SMTP adoption Add shared SASL behavior needed by SMTP: transport availability on factories and mechanisms, OAuth invalid-token challenge flow, server-error convenience failure, and legacy PLAIN trailing-NUL compatibility. --- .../james/protocols/api/sasl/SaslFailure.java | 4 +++ .../sasl/OauthBearerSaslMechanismFactory.java | 6 +++- .../sasl/OidcSaslMechanismFactory.java | 18 ++++++++-- .../sasl/PlainSaslMechanismFactory.java | 12 ++++++- .../sasl/XOauth2SaslMechanismFactory.java | 6 +++- .../sasl/oidc/OAuthSaslMechanism.java | 31 ++++++++++++++-- .../sasl/plain/PlainSaslMechanism.java | 9 +++++ .../sasl/oidc/OidcSaslMechanismTest.java | 36 ++++++++++++++----- 8 files changed, 104 insertions(+), 18 deletions(-) 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 index 385ef2140b6..798214d0740 100644 --- 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 @@ -63,4 +63,8 @@ public static SaslFailure delegationForbidden(Username authenticationId, Usernam 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/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java b/protocols/sasl/src/main/java/org/apache/james/protocols/sasl/OauthBearerSaslMechanismFactory.java index 49de4f2e404..9af3d36a910 100644 --- 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 @@ -22,6 +22,8 @@ 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; @@ -29,6 +31,8 @@ public class OauthBearerSaslMechanismFactory extends OidcSaslMechanismFactory { @Override public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { - return new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, parseVerifier(serverConfiguration)); + 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 index 3cf55ed170b..60385804249 100644 --- 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 @@ -19,23 +19,35 @@ 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.OidcJwtTokenVerifier; import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.api.sasl.SaslMechanismFactory; abstract class OidcSaslMechanismFactory implements SaslMechanismFactory { - protected OidcJwtTokenVerifier parseVerifier(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { + 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 new OidcJwtTokenVerifier(OidcSASLConfiguration.parse(serverConfiguration.configurationAt("auth.oidc"))); + 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 index 9a3537a772f..7964454d433 100644 --- 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 @@ -29,6 +29,16 @@ 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)); @@ -40,6 +50,6 @@ protected boolean plainAuthEnabled(HierarchicalConfiguration serv protected boolean requiresSsl(HierarchicalConfiguration serverConfiguration) { return serverConfiguration.getBoolean("auth.requireSSL", - serverConfiguration.getBoolean("plainAuthDisallowed", PLAIN_AUTH_DISALLOWED_DEFAULT)); + 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 index 9f9c895b7f3..f852f3fe235 100644 --- 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 @@ -22,6 +22,8 @@ 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; @@ -29,6 +31,8 @@ public class XOauth2SaslMechanismFactory extends OidcSaslMechanismFactory { @Override public SaslMechanism create(HierarchicalConfiguration serverConfiguration) throws ConfigurationException { - return new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, parseVerifier(serverConfiguration)); + 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 index cd2106d1f01..d9bc1366da6 100644 --- 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 @@ -41,10 +41,18 @@ 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) { + 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 @@ -52,6 +60,11 @@ 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); @@ -60,10 +73,12 @@ public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenti 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 @@ -75,6 +90,11 @@ public SaslStep firstStep() { @Override public SaslStep onResponse(byte[] clientResponse) { + if (pendingFailure.isPresent()) { + SaslFailure failure = pendingFailure.orElseThrow(); + pendingFailure = Optional.empty(); + return new SaslStep.Failure(failure); + } return authenticate(clientResponse); } @@ -88,12 +108,17 @@ private SaslStep authenticate(byte[] clientResponse) { Username authorizationId = Username.of(response.getAssociatedUser()); return verifier.validateToken(response.getToken()) .map(authenticationId -> authorize(authenticationId, authorizationId)) - .orElseGet(() -> new SaslStep.Failure(SaslFailure.authenticationFailed( - Optional.empty(), Optional.of(authorizationId), "OAuth authentication failed."))); + .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) { 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 index 96aa8212c12..641171f9e59 100644 --- 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 @@ -21,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -130,6 +131,14 @@ 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))); } 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 index ddc1eeae5f3..97c89b143c0 100644 --- 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 @@ -28,6 +28,7 @@ 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; @@ -39,6 +40,7 @@ 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() { @@ -47,7 +49,7 @@ void oauthBearerShouldValidateTokenAndAuthorizeDecodedInitialResponse() { Optional.of(bytes("n,a=" + USER.asString() + ",\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); // WHEN the mechanism consumes and validates the response - SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -60,7 +62,7 @@ void xOauth2ShouldValidateTokenAndAuthorizeDecodedInitialResponse() { Optional.of(bytes("user=" + USER.asString() + "\u0001auth=Bearer " + TOKEN + "\u0001\u0001"))); // WHEN the mechanism consumes and validates the response - SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.XOAUTH2, verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -72,7 +74,7 @@ void shouldChallengeWhenNoInitialResponse() { SaslInitialRequest request = new SaslInitialRequest(SaslMechanismNames.OAUTHBEARER, Optional.empty()); // WHEN the mechanism starts - SaslStep firstStep = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + 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())); @@ -85,23 +87,27 @@ void shouldFailMalformedResponse() { Optional.of(bytes("invalid"))); // WHEN the mechanism consumes the response - SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, authorizing()).firstStep(); + 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 shouldFailWhenTokenIsRejected() { + 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 step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, rejectingToken()).start(request, authorizing()).firstStep(); + SaslStep firstStep = exchange.firstStep(); + SaslStep secondStep = exchange.onResponse(new byte[0]); - // THEN the mechanism returns a typed authentication failure - assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.authenticationFailed( + // 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."))); } @@ -113,12 +119,24 @@ void shouldReturnAuthorizationFailure() { SaslFailure failure = SaslFailure.delegationForbidden(TOKEN_SUBJECT, USER, "forbidden"); // WHEN authorization rejects the identity - SaslStep step = new OAuthSaslMechanism(SaslMechanismNames.OAUTHBEARER, verifyingToken()).start(request, rejectingAuthorization(failure)).firstStep(); + 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)); } From 415c9cb431f2be8e544f85cd6ffe17e65c4fd633 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 11:03:26 +0700 Subject: [PATCH 05/10] JAMES-4210 Drive SMTP AUTH through SASL exchanges Replace embedded SMTP PLAIN/OIDC authentication logic with the shared SaslMechanism exchange flow, including LOGIN framing, final server data acknowledgement, SMTP session authentication state, and SASL-focused regression tests. --- .../protocols/smtp/SMTPConfiguration.java | 6 - .../protocols/smtp/SMTPConfigurationImpl.java | 12 - .../smtp/SMTPProtocolHandlerChain.java | 45 +- .../james/protocols/smtp/SMTPSession.java | 2 - .../james/protocols/smtp/SMTPSessionImpl.java | 5 - .../smtp/core/esmtp/AuthCmdHandler.java | 600 ++++++++---------- .../smtp/core/esmtp/AuthCmdHandlerTest.java | 68 -- .../smtp/utils/BaseFakeSMTPSession.java | 5 - .../james/smtpserver/AuthAnnounceTest.java | 42 +- .../apache/james/smtpserver/SMTPSaslTest.java | 287 ++++++++- .../james/smtpserver/SMTPServerTest.java | 4 +- .../smtpserver/SMTPServerTestSystem.java | 60 +- 12 files changed, 590 insertions(+), 546 deletions(-) delete mode 100644 protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthCmdHandlerTest.java 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/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/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/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(); } From 0e8deb98f2013ea5a27ac5d57fb6908f700d3141 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 11:18:48 +0700 Subject: [PATCH 06/10] JAMES-4210 Add SMTP AuthHook SASL migration adapter Keep legacy SMTP AuthHook registrations usable by wrapping them as a PLAIN SASL mechanism, add post-auth result hook support, deprecate AuthHook, and document migration expectations. --- .../partials/configure/smtp-hooks.adoc | 5 +- .../partials/customization/smtp-hooks.adoc | 6 +- .../core/esmtp/AuthHookSaslMechanism.java | 270 ++++++++++++++++++ .../james/protocols/smtp/hook/AuthHook.java | 10 +- .../smtp/hook/SaslAuthResultHook.java | 37 +++ .../smtpserver/ConfigurationAuthHook.java | 5 +- .../smtpserver/CoreCmdHandlerLoader.java | 1 - .../smtpserver/UsersRepositoryAuthHook.java | 57 +--- .../core/esmtp/AuthHookSaslMechanismTest.java | 163 +++++++++++ upgrade-instructions.md | 18 ++ 10 files changed, 513 insertions(+), 59 deletions(-) create mode 100644 protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java create mode 100644 protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java create mode 100644 server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java 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/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/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/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/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/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 From e341baee9034e177672598b72b58039c2a5477bc Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 13:53:34 +0700 Subject: [PATCH 07/10] JAMES-4210 Resolve SMTP SASL mechanisms per server configuration Wire SMTP Guice support for per-server SASL mechanism resolution, default SMTP SASL factories, probe cleanup, and module tests. --- server/container/guice/protocols/smtp/pom.xml | 4 + .../modules/protocols/SMTPServerModule.java | 82 ++++++++++++++++++- .../SmtpDefaultSaslMechanismFactories.java | 31 +++++++ .../modules/protocols/SmtpGuiceProbe.java | 7 ++ .../protocols/SMTPServerModuleTest.java | 72 ++++++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 server/container/guice/protocols/smtp/src/main/java/org/apache/james/modules/protocols/SmtpDefaultSaslMechanismFactories.java create mode 100644 server/container/guice/protocols/smtp/src/test/java/org/apache/james/modules/protocols/SMTPServerModuleTest.java 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); + } +} From bf3e82e00c19f6780ea37847551995b3884a3b46 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 14:20:21 +0700 Subject: [PATCH 08/10] JAMES-4210 Wire SMTP SASL support for Spring and scaling Pulsar apps Adapt non-Guice and Spring SMTP server creation to provide default SASL mechanisms and authenticator wiring. Add a UsersRepository-backed authenticator for the scaling Pulsar SMTP app. --- .../src/main/java/org/apache/james/Main.java | 2 + .../UsersRepositoryBackedAuthenticator.java | 48 +++++++++++ server/protocols/protocols-smtp/pom.xml | 4 + .../james/smtpserver/netty/SMTPServer.java | 83 +++++++------------ .../smtpserver/netty/SMTPServerFactory.java | 55 +++++++++++- .../META-INF/spring/smtpserver-context.xml | 10 +++ 6 files changed, 146 insertions(+), 56 deletions(-) create mode 100644 server/apps/scaling-pulsar-smtp/src/main/java/org/apache/james/UsersRepositoryBackedAuthenticator.java 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/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/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 @@ + + + + + + + + + From ae66031f71f539d8373bfc66332751f2d4bc3af2 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 14:35:11 +0700 Subject: [PATCH 09/10] JAMES-4210 Remove obsolete SMTP auth config stubs from LMTP Remove LMTP test and server implementations of SMTP authentication configuration methods that no longer exist after SMTP AUTH moved to SASL mechanisms. --- .../protocols/lmtp/LMTPConfigurationImpl.java | 14 -------------- .../apache/james/lmtpserver/netty/LMTPServer.java | 13 ------------- 2 files changed, 27 deletions(-) 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/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 From 275c52ea2f6f31c7d583bd14af96ce22fc84bc08 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jun 2026 14:35:23 +0700 Subject: [PATCH 10/10] JAMES-4210 Align custom SMTP command example with SASL AUTH defaults Stop the custom SMTP command example from explicitly registering UsersRepositoryAuthHook; default SMTP authentication is now handled by the SASL AUTH command path. --- .../main/java/org/apache/james/examples/MyCmdHandlerLoader.java | 2 -- 1 file changed, 2 deletions(-) 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(),