This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit c849032d66f465bb7e17c4520d93a69529705e98 Author: Quan Tran <[email protected]> AuthorDate: Tue Jun 30 09:39:31 2026 +0700 JAMES-4210 Add SMTP LOGIN SASL mechanism Introduce an SMTP-only LOGIN SASL mechanism and factory. The mechanism owns the username/password challenge flow and delegates final credential validation to the configured PLAIN mechanism. --- .../protocols/api/sasl/SaslMechanismNames.java | 1 + .../smtp/core/esmtp/LoginSaslMechanism.java | 128 ++++++++++++++++ .../core/esmtp/LoginSaslMechanismFactory.java} | 25 +++- .../smtp/core/esmtp/LoginSaslMechanismTest.java | 164 +++++++++++++++++++++ 4 files changed, 312 insertions(+), 6 deletions(-) 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 index 70e76d0503..20aad26648 100644 --- 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 @@ -20,6 +20,7 @@ package org.apache.james.protocols.api.sasl; public final class SaslMechanismNames { + public static final String LOGIN = "LOGIN"; public static final String PLAIN = "PLAIN"; public static final String OAUTHBEARER = "OAUTHBEARER"; public static final String XOAUTH2 = "XOAUTH2"; diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java new file mode 100644 index 0000000000..7e91a08458 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanism.java @@ -0,0 +1,128 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +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; + +/** + * SMTP-only AUTH LOGIN framing backed by the configured PLAIN mechanism. + */ +public class LoginSaslMechanism implements SaslMechanism { + private static final byte[] USERNAME_PROMPT = "Username:".getBytes(StandardCharsets.US_ASCII); + private static final byte[] PASSWORD_PROMPT = "Password:".getBytes(StandardCharsets.US_ASCII); + + private final SaslMechanism plainMechanism; + + LoginSaslMechanism(SaslMechanism plainMechanism) { + this.plainMechanism = plainMechanism; + } + + static Optional<SaslExchange> delegatedExchange(SaslExchange exchange) { + if (exchange instanceof LoginSaslExchange loginSaslExchange) { + return loginSaslExchange.plainExchange; + } + return Optional.empty(); + } + + SaslMechanism plainMechanism() { + return plainMechanism; + } + + @Override + public String name() { + return SaslMechanismNames.LOGIN; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return plainMechanism.isAvailableOnTransport(channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + return new LoginSaslExchange(request.initialResponse(), authenticator); + } + + private class LoginSaslExchange implements SaslExchange { + private final Optional<byte[]> initialResponse; + private final SaslAuthenticator authenticator; + private Optional<String> username; + private Optional<SaslExchange> plainExchange; + + private LoginSaslExchange(Optional<byte[]> initialResponse, SaslAuthenticator authenticator) { + this.initialResponse = initialResponse; + this.authenticator = authenticator; + this.username = Optional.empty(); + this.plainExchange = Optional.empty(); + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::recordUsernameThenChallengePassword) + .orElseGet(() -> new SaslStep.Challenge(Optional.of(USERNAME_PROMPT))); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return username + .map(username -> authenticate(username, clientResponse)) + .orElseGet(() -> recordUsernameThenChallengePassword(clientResponse)); + } + + @Override + public void close() { + plainExchange.ifPresent(SaslExchange::close); + } + + private SaslStep recordUsernameThenChallengePassword(byte[] clientResponse) { + username = Optional.of(new String(clientResponse, StandardCharsets.UTF_8)); + return new SaslStep.Challenge(Optional.of(PASSWORD_PROMPT)); + } + + private SaslStep authenticate(String username, byte[] password) { + SaslInitialRequest plainRequest = new SaslInitialRequest(SaslMechanismNames.PLAIN, + Optional.of(plainInitialResponse(username, password))); + SaslExchange exchange = startPlainExchange(plainRequest); + plainExchange = Optional.of(exchange); + return exchange.firstStep(); + } + + private SaslExchange startPlainExchange(SaslInitialRequest plainRequest) { + return plainMechanism.start(plainRequest, authenticator); + } + + private byte[] plainInitialResponse(String username, byte[] password) { + byte[] usernameBytes = username.getBytes(StandardCharsets.UTF_8); + byte[] response = new byte[usernameBytes.length + password.length + 2]; + System.arraycopy(usernameBytes, 0, response, 1, usernameBytes.length); + System.arraycopy(password, 0, response, usernameBytes.length + 2, password.length); + return response; + } + } +} diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java similarity index 54% copy from protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java copy to protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java index 70e76d0503..17a140b912 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/sasl/SaslMechanismNames.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismFactory.java @@ -17,13 +17,26 @@ * under the License. * ****************************************************************/ -package org.apache.james.protocols.api.sasl; +package org.apache.james.protocols.smtp.core.esmtp; -public final class SaslMechanismNames { - public static final String PLAIN = "PLAIN"; - public static final String OAUTHBEARER = "OAUTHBEARER"; - public static final String XOAUTH2 = "XOAUTH2"; +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; - private SaslMechanismNames() { +/** + * SMTP AUTH LOGIN framing backed by a configured PLAIN mechanism factory. + */ +public class LoginSaslMechanismFactory implements SaslMechanismFactory { + private final SaslMechanismFactory plainSaslMechanismFactory; + + public LoginSaslMechanismFactory(SaslMechanismFactory plainSaslMechanismFactory) { + this.plainSaslMechanismFactory = plainSaslMechanismFactory; + } + + @Override + public SaslMechanism create(HierarchicalConfiguration<ImmutableNode> serverConfiguration) throws ConfigurationException { + return new LoginSaslMechanism(plainSaslMechanismFactory.create(serverConfiguration)); } } diff --git a/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java new file mode 100644 index 0000000000..0aaef7a211 --- /dev/null +++ b/protocols/smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/LoginSaslMechanismTest.java @@ -0,0 +1,164 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +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.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class LoginSaslMechanismTest { + private static final Username USERNAME = Username.of("[email protected]"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USERNAME, USERNAME); + private static final SaslAuthenticator UNUSED_AUTHENTICATOR = new SaslAuthenticator() { + @Override + public SaslAuthenticationResult authenticatePassword(Username authenticationId, Optional<Username> authorizationId, String password) { + throw new UnsupportedOperationException(); + } + + @Override + public SaslAuthenticationResult authorize(SaslIdentity identity) { + throw new UnsupportedOperationException(); + } + }; + + private final RecordingPlainMechanism plainMechanism = new RecordingPlainMechanism(); + private final LoginSaslMechanism testee = new LoginSaslMechanism(plainMechanism); + + @Test + void nameShouldBeLogin() { + assertThat(testee.name()).isEqualTo(SaslMechanismNames.LOGIN); + } + + @Test + void isAvailableOnTransportShouldDelegateToPlainMechanism() { + plainMechanism.availableOnEncryptedTransportOnly = true; + + assertThat(testee.isAvailableOnTransport(false)).isFalse(); + assertThat(testee.isAvailableOnTransport(true)).isTrue(); + } + + @Test + void firstStepShouldAskForUsernameWhenInitialResponseIsAbsent() { + SaslExchange exchange = testee.start(new SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), UNUSED_AUTHENTICATOR); + + SaslStep step = exchange.firstStep(); + + assertThat(((SaslStep.Challenge) step).payload()) + .hasValueSatisfying(payload -> assertThat(payload).containsExactly(bytes("Username:"))); + } + + @Test + void shouldAskForPasswordAfterUsernameResponse() { + SaslExchange exchange = testee.start(new SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), UNUSED_AUTHENTICATOR); + exchange.firstStep(); + + SaslStep step = exchange.onResponse(bytes("[email protected]")); + + assertThat(((SaslStep.Challenge) step).payload()) + .hasValueSatisfying(payload -> assertThat(payload).containsExactly(bytes("Password:"))); + } + + @Test + void firstStepShouldAskForPasswordWhenInitialResponseContainsUsername() { + SaslExchange exchange = testee.start(new SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.of(bytes("[email protected]"))), UNUSED_AUTHENTICATOR); + + SaslStep step = exchange.firstStep(); + + assertThat(((SaslStep.Challenge) step).payload()) + .hasValueSatisfying(payload -> assertThat(payload).containsExactly(bytes("Password:"))); + } + + @Test + void shouldDelegateUsernameAndPasswordToPlainMechanism() { + SaslExchange exchange = testee.start(new SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.empty()), UNUSED_AUTHENTICATOR); + exchange.firstStep(); + exchange.onResponse(bytes("[email protected]")); + + SaslStep step = exchange.onResponse(bytes("secret")); + + assertThat(((SaslStep.Success) step).identity()).isEqualTo(IDENTITY); + assertThat(plainMechanism.initialResponse) + .containsExactly(bytes("\[email protected]\0secret")); + } + + @Test + void closeShouldCloseDelegatedPlainExchange() { + SaslExchange exchange = testee.start(new SaslInitialRequest(SaslMechanismNames.LOGIN, Optional.of(bytes("[email protected]"))), UNUSED_AUTHENTICATOR); + exchange.firstStep(); + exchange.onResponse(bytes("secret")); + + exchange.close(); + + assertThat(plainMechanism.exchangeClosed).isTrue(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static class RecordingPlainMechanism implements SaslMechanism { + private boolean availableOnEncryptedTransportOnly; + private boolean exchangeClosed; + private byte[] initialResponse; + + @Override + public String name() { + return SaslMechanismNames.PLAIN; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return !availableOnEncryptedTransportOnly || channelEncrypted; + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + initialResponse = request.initialResponse().orElseThrow().clone(); + return new SaslExchange() { + @Override + public SaslStep firstStep() { + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + exchangeClosed = true; + } + }; + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
