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