diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java index dfdefa0f7ee..a4b540d6321 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/SMTPSession.java @@ -44,6 +44,20 @@ public interface SMTPSession extends ProtocolSession { /** HELO or EHLO */ AttachmentKey CURRENT_HELO_MODE = AttachmentKey.of("CURRENT_HELO_MODE", String.class); AttachmentKey CURRENT_HELO_NAME = AttachmentKey.of("CURRENT_HELO_NAME", String.class); + /** Set when the authenticated account was granted the right to use identities other than its own */ + AttachmentKey ALLOW_USE_OTHER_IDENTITY = AttachmentKey.of("ALLOW_USE_OTHER_IDENTITY", Boolean.class); + + /** + * Whether this session is allowed to use MAIL FROM / From identities other than the one it authenticated with. + * + * Set upon authentication for accounts explicitly granted that right, this bypasses the identity checks + * performed by the sender identity verification hook (see the verifyIdentity setting). + * + * @return true if identity verification is to be bypassed for this session + */ + default boolean allowUseOtherIdentity() { + return getAttachment(ALLOW_USE_OTHER_IDENTITY, State.Connection).orElse(false); + } /** * Returns the service wide configuration diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/AbstractSenderAuthIdentifyVerificationHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/AbstractSenderAuthIdentifyVerificationHook.java index c5937e3548f..e2488925c84 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/AbstractSenderAuthIdentifyVerificationHook.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/AbstractSenderAuthIdentifyVerificationHook.java @@ -88,6 +88,9 @@ protected HookResult doCheck(SMTPSession session, MaybeSender sender) { @Override public HookResult doMail(SMTPSession session, MaybeSender sender) { + if (session.allowUseOtherIdentity()) { + return HookResult.DECLINED; + } return doCheck(session, sender); } @@ -97,6 +100,9 @@ public HookResult doMail(SMTPSession session, MaybeSender sender) { */ @Override public HookResult doRcpt(SMTPSession session, MaybeSender sender, MailAddress rcpt) { + if (session.allowUseOtherIdentity()) { + return HookResult.DECLINED; + } return doCheck(session, sender); } diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java index 22e575ddff0..1d2788418fe 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java @@ -30,6 +30,7 @@ import org.apache.commons.lang3.NotImplementedException; import org.apache.james.core.Username; import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.ProtocolSession; import org.apache.james.protocols.smtp.SMTPSession; import org.apache.james.protocols.smtp.hook.AuthHook; import org.apache.james.protocols.smtp.hook.HookResult; @@ -38,12 +39,16 @@ import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableListMultimap; -import com.google.common.collect.Multimap; /** * Declarative authentication. * + *

Each {@code account} supports an optional {@code allowUseOtherIdentity} flag (defaults to {@code false}). + * When set, the authenticated session bypasses the {@code verifyIdentity} checks and may thus use any + * MAIL FROM / From identity. This is intended for application accounts sending on behalf of end users + * (calendar invitations, notifications...), and should be granted only to accounts whose credentials are + * under the operator control.

+ * * @deprecated Prefer implementing a SASL mechanism factory. Existing handler-chain registrations * are adapted by the SMTP AUTH handler during migration. */ @@ -51,7 +56,13 @@ public class ConfigurationAuthHook implements AuthHook { private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationAuthHook.class); - private Multimap accounts = ImmutableListMultimap.of(); + private record Account(Username username, List passwords, boolean allowUseOtherIdentity) { + boolean matches(Username username, String password) { + return this.username.equals(username) && passwords.stream().anyMatch(password::equals); + } + } + + private List accounts = ImmutableList.of(); @Inject public ConfigurationAuthHook() { @@ -62,40 +73,44 @@ public ConfigurationAuthHook() { public void init(Configuration config) throws ConfigurationException { HierarchicalConfiguration hierarchicalConfiguration = (HierarchicalConfiguration) config; - ImmutableListMultimap.Builder builder = ImmutableListMultimap.builder(); - - for (HierarchicalConfiguration accountNode : hierarchicalConfiguration.configurationAt("accounts") - .configurationsAt("account")) { - String username = accountNode.getString("username"); - if (username != null) { - List passwords = accountNode.getList(String.class, "passwords.password"); - passwords.forEach(pw -> builder.put(Username.of(username), pw)); - } - } - this.accounts = builder.build(); - - LOGGER.info("SMTP authentication enabled from configuration for users: {}", accounts.keySet() + this.accounts = hierarchicalConfiguration.configurationAt("accounts") + .configurationsAt("account") .stream() - .map(Username::asString) + .flatMap(accountNode -> parseAccount(accountNode).stream()) + .collect(ImmutableList.toImmutableList()); + + LOGGER.info("SMTP authentication enabled from configuration for users: {}", accounts.stream() + .map(account -> account.username().asString()) .collect(ImmutableList.toImmutableList())); } + private Optional parseAccount(HierarchicalConfiguration accountNode) { + return Optional.ofNullable(accountNode.getString("username")) + .map(username -> new Account(Username.of(username), + accountNode.getList(String.class, "passwords.password", ImmutableList.of()), + accountNode.getBoolean("allowUseOtherIdentity", false))); + } + @Override public HookResult doAuth(SMTPSession session, Username username, String password) { - Optional loggedInUser = Optional.ofNullable(accounts.get(username)) - .filter(allowedsPass -> allowedsPass.stream().anyMatch(password::equals)) - .map(any -> username); - - if (loggedInUser.isPresent()) { - session.setUsername(loggedInUser.get()); - session.setRelayingAllowed(true); - - return HookResult.builder() - .hookReturnCode(HookReturnCode.ok()) - .smtpDescription("Authentication Successful") - .build(); + return accounts.stream() + .filter(account -> account.matches(username, password)) + .findFirst() + .map(account -> authenticate(session, account)) + .orElse(HookResult.DECLINED); + } + + private HookResult authenticate(SMTPSession session, Account account) { + session.setUsername(account.username()); + session.setRelayingAllowed(true); + if (account.allowUseOtherIdentity()) { + session.setAttachment(SMTPSession.ALLOW_USE_OTHER_IDENTITY, true, ProtocolSession.State.Connection); } - return HookResult.DECLINED; + + return HookResult.builder() + .hookReturnCode(HookReturnCode.ok()) + .smtpDescription("Authentication Successful") + .build(); } @Override diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/SenderAuthIdentifyVerificationHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/SenderAuthIdentifyVerificationHook.java index 300b79bc251..d5d9e956b78 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/SenderAuthIdentifyVerificationHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/SenderAuthIdentifyVerificationHook.java @@ -139,6 +139,9 @@ protected boolean isSenderAllowed(Username connectedUser, Username sender) { @Override public HookResult onMessage(SMTPSession session, Mail mail) { + if (session.allowUseOtherIdentity()) { + return HookResult.DECLINED; + } ExtendedSMTPSession nSession = (ExtendedSMTPSession) session; boolean shouldCheck = nSession.senderVerificationConfiguration().mode() == SMTPConfiguration.SenderVerificationMode.STRICT || (nSession.senderVerificationConfiguration().mode() == SMTPConfiguration.SenderVerificationMode.RELAXED && session.getUsername() != null); diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/ConfiguredAuthOtherIdentityTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/ConfiguredAuthOtherIdentityTest.java new file mode 100644 index 00000000000..682d631d21f --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/ConfiguredAuthOtherIdentityTest.java @@ -0,0 +1,93 @@ +/**************************************************************** + * 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.smtpserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.InetSocketAddress; +import java.util.Base64; + +import org.apache.commons.net.smtp.SMTPClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ConfiguredAuthOtherIdentityTest { + private static final String END_USER = "bob@example.local"; + + private final SMTPServerTestSystem smtpServerTestSystem = new SMTPServerTestSystem(); + + @BeforeEach + void setUp() throws Exception { + smtpServerTestSystem.setUp("smtpserver-configured-auth-other-identity.xml"); + } + + @AfterEach + void tearDown() { + smtpServerTestSystem.smtpServer.destroy(); + } + + private SMTPClient authenticate(String username, String password) throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = smtpServerTestSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("AUTH PLAIN"); + smtpProtocol.sendCommand(Base64.getEncoder().encodeToString(("\0" + username + "\0" + password + "\0").getBytes(UTF_8))); + assertThat(smtpProtocol.getReplyCode()) + .as("authenticated") + .isEqualTo(235); + smtpProtocol.login("domain.tld"); + return smtpProtocol; + } + + @Test + void mailFromOtherIdentityShouldBeRejectedWhenNotAllowed() throws Exception { + SMTPClient smtpProtocol = authenticate("noreply-tdrive@domain.tld", "secret123456"); + + smtpProtocol.setSender(END_USER); + + assertThat(smtpProtocol.getReplyCode()) + .isEqualTo(503); + } + + @Test + void mailFromOtherIdentityShouldBeAcceptedWhenAllowed() throws Exception { + SMTPClient smtpProtocol = authenticate("noreply-tcalendar@domain.tld", "secret234567"); + + smtpProtocol.setSender(END_USER); + + assertThat(smtpProtocol.getReplyCode()) + .isEqualTo(250); + } + + @Test + void headerFromOtherIdentityShouldBeAcceptedWhenAllowed() throws Exception { + SMTPClient smtpProtocol = authenticate("noreply-tcalendar@domain.tld", "secret234567"); + + smtpProtocol.setSender(END_USER); + smtpProtocol.addRecipient("mail@sample.com"); + smtpProtocol.sendShortMessageData("From: " + END_USER + "\r\nSubject: test\r\n\r\nTest body\r\n.\r\n"); + smtpProtocol.quit(); + + assertThat(smtpServerTestSystem.queue.getLastMail().getMaybeSender().asString()) + .isEqualTo(END_USER); + } +} diff --git a/server/protocols/protocols-smtp/src/test/resources/smtpserver-configured-auth-other-identity.xml b/server/protocols/protocols-smtp/src/test/resources/smtpserver-configured-auth-other-identity.xml new file mode 100644 index 00000000000..1ed6feb6bf2 --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/resources/smtpserver-configured-auth-other-identity.xml @@ -0,0 +1,73 @@ + + + + + + + + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + + forUnauthorizedAddresses + false + + strict + 0 + true + Apache JAMES awesome SMTP Server + + + + technicaluser + true + + + + + noreply-tdrive@domain.tld + + secret123456 + here_to_ease_secret_rotation + here_to_give_different_creds_to_each_app + + + + + noreply-tcalendar@domain.tld + + secret234567 + + true + + + + + false + ENHANCEDSTATUSCODES + \ No newline at end of file diff --git a/src/site/xdoc/server/config-smtp-lmtp.xml b/src/site/xdoc/server/config-smtp-lmtp.xml index d6b632734aa..5d85cb39215 100644 --- a/src/site/xdoc/server/config-smtp-lmtp.xml +++ b/src/site/xdoc/server/config-smtp-lmtp.xml @@ -184,6 +184,11 @@
  • true: act as strict
  • false: act as disabled
  • + + Note that individual accounts declared on the ConfigurationAuthHook handler can be granted + allowUseOtherIdentity (defaults to false), which bypasses these checks for the + sessions they authenticate. This allows application accounts to send on behalf of end users + (calendar invitations, notifications...) without turning identity verification off for everybody else.
    handler.maxmessagesize
    This is an optional tag with a non-negative integer body. It specifies the maximum