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 08ff441915cc36ae697129210aa8b25af71b6140 Author: Quan Tran <[email protected]> AuthorDate: Wed Jun 24 11:18:48 2026 +0700 JAMES-4210 Add SMTP AuthHook SASL migration adapter Keep legacy SMTP AuthHook registrations usable by wrapping them as a PLAIN SASL mechanism, add post-auth result hook support, deprecate AuthHook, and document migration expectations. --- .../servers/partials/configure/smtp-hooks.adoc | 5 +- .../servers/partials/customization/smtp-hooks.adoc | 6 +- .../smtp/core/esmtp/AuthHookSaslMechanism.java | 270 +++++++++++++++++++++ .../apache/james/protocols/smtp/hook/AuthHook.java | 10 +- .../{AuthHook.java => SaslAuthResultHook.java} | 30 +-- .../james/smtpserver/ConfigurationAuthHook.java | 5 +- .../james/smtpserver/CoreCmdHandlerLoader.java | 1 - .../james/smtpserver/UsersRepositoryAuthHook.java | 57 +---- .../smtp/core/esmtp/AuthHookSaslMechanismTest.java | 163 +++++++++++++ upgrade-instructions.md | 18 ++ 10 files changed, 487 insertions(+), 78 deletions(-) diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index 65ab5254fb..5028910546 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -498,6 +498,9 @@ include::partial$configure/smtp-limitation-hook.adoc[] Declarative authentication. +`ConfigurationAuthHook` is deprecated. Existing handler-chain registrations remain supported through the SMTP SASL +adapter, while new authentication logic should use a dedicated SASL mechanism factory. + It is possible to open and configure on a dedicated port (eg port 26) to accept application traffic in parallel of user traffic. Authentication is then done on the supplied configuration. @@ -529,4 +532,4 @@ user accounts for those applications) </accounts> </handler> </handlerchain> -.... \ No newline at end of file +.... diff --git a/docs/modules/servers/partials/customization/smtp-hooks.adoc b/docs/modules/servers/partials/customization/smtp-hooks.adoc index 8b27377659..207fec5b05 100644 --- a/docs/modules/servers/partials/customization/smtp-hooks.adoc +++ b/docs/modules/servers/partials/customization/smtp-hooks.adoc @@ -5,7 +5,8 @@ enqueued in the MailQueue, and before any mail processing takes place. The following interfaces allows interacting with the following commands: - * *AuthHook*: Implement this interfaces to hook in the AUTH Command. + * *AuthHook*: Legacy hook for the AUTH Command. Prefer implementing a SASL mechanism factory for new authentication + logic, or a `SaslAuthResultHook` when only post-authentication side effects are needed. .... HookResult doAuth(SMTPSession session, Username username, String password); @@ -52,6 +53,9 @@ The following interfaces allows interacting with the following commands: Register you hooks using xref:{pages-path}/configure/smtp.adoc[*smtpserver.xml*] handlerchain property. +For AUTH, `AuthHook` is deprecated. Existing handler-chain password hooks remain supported through the SMTP SASL +adapter while keeping `AuthCmdHandler` on the SASL exchange path. + == Writing additional SMTP commands What to do if the Hook API is not enough for you ? diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java new file mode 100644 index 0000000000..0c75392b88 --- /dev/null +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanism.java @@ -0,0 +1,270 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslAuthenticator; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanism; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.smtp.SMTPResponse; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.hook.AuthHook; +import org.apache.james.protocols.smtp.hook.HookResult; +import org.apache.james.protocols.smtp.hook.HookResultHook; +import org.apache.james.protocols.smtp.hook.HookReturnCode; + +import com.google.common.collect.ImmutableList; + +/** + * Legacy AuthHook adapter exposed as a standard PLAIN SASL mechanism. + */ +class AuthHookSaslMechanism implements SaslMechanism { + static Optional<Response> terminalResponse(SaslExchange exchange) { + if (exchange instanceof AuthHookSaslMechanism.Exchange authHookExchange) { + return authHookExchange.terminalResponse(); + } + return Optional.empty(); + } + + private final SaslMechanism plainMechanism; + private final ImmutableList<AuthHook> authHooks; + private final ImmutableList<HookResultHook> hookResultHooks; + + AuthHookSaslMechanism(SaslMechanism plainMechanism, List<AuthHook> authHooks, List<HookResultHook> hookResultHooks) { + this.plainMechanism = plainMechanism; + this.authHooks = ImmutableList.copyOf(authHooks); + this.hookResultHooks = ImmutableList.copyOf(hookResultHooks); + } + + @Override + public String name() { + return SaslMechanismNames.PLAIN; + } + + @Override + public boolean isAvailableOnTransport(boolean channelEncrypted) { + return plainMechanism.isAvailableOnTransport(channelEncrypted); + } + + @Override + public SaslExchange start(SaslInitialRequest request, SaslAuthenticator authenticator) { + throw new IllegalStateException("Legacy SMTP AuthHook adapter requires an SMTP session"); + } + + Exchange start(SaslInitialRequest request, SMTPSession session) { + return new Exchange(request.initialResponse(), session); + } + + final class Exchange implements SaslExchange { + private final Optional<byte[]> initialResponse; + private final SMTPSession session; + private Optional<Response> terminalResponse; + + private Exchange(Optional<byte[]> initialResponse, SMTPSession session) { + this.initialResponse = initialResponse; + this.session = session; + this.terminalResponse = Optional.empty(); + } + + @Override + public SaslStep firstStep() { + return initialResponse + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Challenge(Optional.empty())); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + return authenticate(clientResponse); + } + + @Override + public void close() { + } + + Optional<Response> terminalResponse() { + return terminalResponse; + } + + private SaslStep authenticate(byte[] clientResponse) { + return parse(clientResponse) + .map(this::authenticate) + .orElseGet(() -> new SaslStep.Failure(SaslFailure.malformed("Malformed authentication command."))); + } + + private SaslStep authenticate(PlainCredentials credentials) { + if (credentials.password().isEmpty()) { + return delegateWithHooks(credentials); + } + return authenticateWithHooks(credentials); + } + + private SaslStep authenticateWithHooks(PlainCredentials credentials) { + return authHooks.stream() + .map(hook -> executeHook(hook, hook2 -> hook2.doAuth(session, credentials.authenticationId(), credentials.password()), credentials)) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> invalidCredentials(credentials)); + } + + private SaslStep delegateWithHooks(PlainCredentials credentials) { + return authHooks.stream() + .map(hook -> executeHook(hook, hook2 -> hook2.doDelegation(session, credentials.authenticationId()), credentials)) + .flatMap(Optional::stream) + .findFirst() + .orElseGet(() -> invalidCredentials(credentials)); + } + + private Optional<SaslStep> executeHook(AuthHook hook, Function<AuthHook, HookResult> operation, PlainCredentials credentials) { + long start = System.currentTimeMillis(); + HookResult hookResult = operation.apply(hook); + long executionTime = System.currentTimeMillis() - start; + HookResult decoratedHookResult = hookResultHooks.stream() + .reduce(hookResult, + (result, hookResultHook) -> hookResultHook.onHookResult(session, result, executionTime, hook), + (left, right) -> { + throw new UnsupportedOperationException(); + }); + + return toSaslStep(decoratedHookResult, credentials); + } + + private Optional<SaslStep> toSaslStep(HookResult hookResult, PlainCredentials credentials) { + if (hookResult == null) { + return Optional.empty(); + } + HookReturnCode.Action action = hookResult.getResult().getAction(); + Optional<Response> response = toSmtpResponse(hookResult); + if (response.isEmpty()) { + return Optional.empty(); + } + terminalResponse = response; + return switch (action) { + case OK -> { + Username authorizedUser = Optional.ofNullable(session.getUsername()) + .or(() -> credentials.authorizationId()) + .orElse(credentials.authenticationId()); + yield Optional.of(new SaslStep.Success( + new SaslIdentity(credentials.authenticationId(), authorizedUser), + Optional.empty())); + } + case DENY -> Optional.of(new SaslStep.Failure(SaslFailure.invalidCredentials( + credentials.authenticationId(), + credentials.authorizationId(), + failureReason(hookResult, "Invalid credentials")))); + case DENYSOFT, DECLINED, NONE -> Optional.of(new SaslStep.Failure(SaslFailure.serverError( + Optional.of(credentials.authenticationId()), + credentials.authorizationId(), + failureReason(hookResult, "Authentication failed")))); + }; + } + + private Optional<Response> toSmtpResponse(HookResult hookResult) { + HookReturnCode returnCode = hookResult.getResult(); + if (!HookReturnCode.Action.ACTIVE_ACTIONS.contains(returnCode.getAction())) { + return returnCode.isDisconnected() ? Optional.of(Response.DISCONNECT) : Optional.empty(); + } + + String smtpReturnCode = Optional.ofNullable(hookResult.getSmtpRetCode()) + .orElseGet(() -> defaultSmtpReturnCode(returnCode.getAction())); + String smtpDescription = Optional.ofNullable(hookResult.getSmtpDescription()) + .orElseGet(() -> defaultSmtpDescription(returnCode.getAction())); + SMTPResponse response = new SMTPResponse(smtpReturnCode, smtpDescription); + if (returnCode.isDisconnected() && returnCode.getAction() != HookReturnCode.Action.OK) { + response.setEndSession(true); + } + return Optional.of(response); + } + + private String defaultSmtpReturnCode(HookReturnCode.Action action) { + return switch (action) { + case OK -> SMTPRetCode.AUTH_OK; + case DENY -> SMTPRetCode.AUTH_FAILED; + case DENYSOFT -> SMTPRetCode.LOCAL_ERROR; + case DECLINED, NONE -> throw new IllegalArgumentException("No SMTP response for declined AuthHook result"); + }; + } + + private String defaultSmtpDescription(HookReturnCode.Action action) { + return switch (action) { + case OK -> "Authentication Successful"; + case DENY -> "Authentication Failed"; + case DENYSOFT -> "Temporary problem. Please try again later"; + case DECLINED, NONE -> throw new IllegalArgumentException("No SMTP response for declined AuthHook result"); + }; + } + + private SaslStep invalidCredentials(PlainCredentials credentials) { + return new SaslStep.Failure(SaslFailure.invalidCredentials( + credentials.authenticationId(), + credentials.authorizationId(), + "Invalid credentials")); + } + + private String failureReason(HookResult hookResult, String defaultReason) { + return Optional.ofNullable(hookResult.getSmtpDescription()) + .orElse(defaultReason); + } + + } + + private Optional<PlainCredentials> parse(byte[] clientResponse) { + ImmutableList<String> tokens = Arrays.stream(new String(clientResponse, StandardCharsets.UTF_8).split("\0", -1)) + .collect(ImmutableList.toImmutableList()); + + if (tokens.size() == 4 && tokens.get(3).isEmpty()) { + return credentials(tokens.subList(0, 3)); + } + return credentials(tokens); + } + + private Optional<PlainCredentials> credentials(List<String> tokens) { + try { + if (tokens.size() == 2) { + return Optional.of(new PlainCredentials(Optional.empty(), Username.of(tokens.get(0)), tokens.get(1))); + } + if (tokens.size() == 3) { + Optional<Username> authorizationId = Optional.of(tokens.get(0)) + .filter(value -> !value.isEmpty()) + .map(Username::of); + return Optional.of(new PlainCredentials(authorizationId, Username.of(tokens.get(1)), tokens.get(2))); + } + return Optional.empty(); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private record PlainCredentials(Optional<Username> authorizationId, Username authenticationId, String password) { + } +} diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java index 3a7234356c..af5d5b01d0 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java @@ -23,8 +23,12 @@ import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.protocols.smtp.SMTPSession; /** - * Implement this interfaces to hook in the AUTH Command + * Legacy SMTP authentication hook. + * + * @deprecated Implement {@code SaslMechanismFactory} for authentication, or {@link SaslAuthResultHook} + * for post-authentication side effects. */ +@Deprecated public interface AuthHook extends Hook { /** @@ -37,7 +41,9 @@ public interface AuthHook extends Hook { */ HookResult doAuth(SMTPSession session, Username username, String password); - HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse); + default HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse) { + return HookResult.DECLINED; + } default HookResult doDelegation(SMTPSession session, Username target) { return HookResult.DECLINED; diff --git a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java similarity index 64% copy from protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java copy to protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java index 3a7234356c..caaf729433 100644 --- a/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/AuthHook.java +++ b/protocols/smtp/src/main/java/org/apache/james/protocols/smtp/hook/SaslAuthResultHook.java @@ -16,30 +16,22 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ + package org.apache.james.protocols.smtp.hook; -import org.apache.james.core.Username; -import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; import org.apache.james.protocols.smtp.SMTPSession; /** - * Implement this interfaces to hook in the AUTH Command + * Decorates terminal SMTP SASL authentication results without participating in credential validation. + * <p> + * Use this hook for legacy {@link AuthHook} use cases that only need authentication result side effects, + * such as audit events, notifications or metrics. Credential validation should be implemented by a SASL + * mechanism instead. */ -public interface AuthHook extends Hook { - - /** - * Return the HookResult after run the hook - * - * @param session the SMTPSession - * @param username the username - * @param password the password - * @return HockResult - */ - HookResult doAuth(SMTPSession session, Username username, String password); - - HookResult doSasl(SMTPSession session, OidcSASLConfiguration saslConfiguration, String initialResponse); +public interface SaslAuthResultHook extends Hook { + void onSuccess(SMTPSession session, String mechanismName, SaslIdentity identity); - default HookResult doDelegation(SMTPSession session, Username target) { - return HookResult.DECLINED; - } + void onFailure(SMTPSession session, String mechanismName, SaslFailure failure); } diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java index f68527afc1..22e575ddff 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/ConfigurationAuthHook.java @@ -44,9 +44,10 @@ import com.google.common.collect.Multimap; /** * Declarative authentication. * - * This is helpful for things like service accounts, used by other applications (and it is not desirable to create - * user accounts for those applications) + * @deprecated Prefer implementing a SASL mechanism factory. Existing handler-chain registrations + * are adapted by the SMTP AUTH handler during migration. */ +@Deprecated public class ConfigurationAuthHook implements AuthHook { private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationAuthHook.class); diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java index c0a6c8b9ea..87d5e0e492 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/CoreCmdHandlerLoader.java @@ -61,7 +61,6 @@ public class CoreCmdHandlerLoader implements HandlersPackage { RsetCmdHandler.class.getName(), VrfyCmdHandler.class.getName(), MailSizeEsmtpExtension.class.getName(), - UsersRepositoryAuthHook.class.getName(), AuthRequiredToRelayRcptHook.class.getName(), SenderAuthIdentifyVerificationHook.class.getName(), AuthRequiredHook.class.getName(), diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java index d60b0dafcc..28ffdfcdf6 100644 --- a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/UsersRepositoryAuthHook.java @@ -23,11 +23,6 @@ import java.util.Optional; import jakarta.inject.Inject; import org.apache.james.core.Username; -import org.apache.james.jwt.OidcJwtTokenVerifier; -import org.apache.james.jwt.OidcSASLConfiguration; -import org.apache.james.mailbox.Authorizator; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.protocols.api.OIDCSASLParser; import org.apache.james.protocols.smtp.SMTPSession; import org.apache.james.protocols.smtp.hook.AuthHook; import org.apache.james.protocols.smtp.hook.HookResult; @@ -38,19 +33,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * This Auth hook can be used to authenticate against the james user repository + * Legacy SMTP AuthHook backed by the James users repository. + * + * @deprecated Default SMTP authentication is now handled by PLAIN SASL and {@code SaslAuthenticator}. */ +@Deprecated public class UsersRepositoryAuthHook implements AuthHook { private static final Logger LOGGER = LoggerFactory.getLogger(UsersRepositoryAuthHook.class); private final UsersRepository users; - private final Authorizator authorizator; @Inject - public UsersRepositoryAuthHook(UsersRepository users, - Authorizator authorizator) { + public UsersRepositoryAuthHook(UsersRepository users) { this.users = users; - this.authorizator = authorizator; } @Override @@ -70,46 +65,4 @@ public class UsersRepositoryAuthHook implements AuthHook { } return HookResult.DECLINED; } - - @Override - public HookResult doSasl(SMTPSession session, OidcSASLConfiguration configuration, String initialResponse) { - return OIDCSASLParser.parse(initialResponse) - .flatMap(oidcInitialResponseValue -> new OidcJwtTokenVerifier(configuration).validateToken(oidcInitialResponseValue.getToken()) - .map(authenticatedUser -> { - Username associatedUser = Username.of(oidcInitialResponseValue.getAssociatedUser()); - if (!associatedUser.equals(authenticatedUser)) { - return doAuthWithDelegation(session, authenticatedUser, associatedUser); - } else { - return saslSuccess(session, authenticatedUser); - } - }) - ) - .orElse(HookResult.DECLINED); - } - - private HookResult doAuthWithDelegation(SMTPSession session, Username authenticatedUser, Username associatedUser) { - try { - if (Authorizator.AuthorizationState.ALLOWED.equals(authorizator.user(authenticatedUser).canLoginAs(associatedUser))) { - return saslSuccess(session, associatedUser); - } - } catch (MailboxException e) { - LOGGER.info("Unable to authorization", e); - } - return HookResult.DECLINED; - } - - private HookResult saslSuccess(SMTPSession session, Username username) { - try { - users.assertValid(username); - session.setUsername(username); - session.setRelayingAllowed(true); - return HookResult.builder() - .hookReturnCode(HookReturnCode.ok()) - .smtpDescription("Authentication successful.") - .build(); - } catch (UsersRepositoryException e) { - LOGGER.warn("Invalid username", e); - return HookResult.DECLINED; - } - } } diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java new file mode 100644 index 0000000000..5d8265031e --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/protocols/smtp/core/esmtp/AuthHookSaslMechanismTest.java @@ -0,0 +1,163 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.protocols.smtp.core.esmtp; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslFailure; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslMechanismNames; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.sasl.plain.PlainSaslMechanism; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.hook.AuthHook; +import org.apache.james.protocols.smtp.hook.HookResult; +import org.apache.james.protocols.smtp.hook.HookReturnCode; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class AuthHookSaslMechanismTest { + private static final Username USERNAME = Username.of("[email protected]"); + + private final SMTPSession session = mock(SMTPSession.class); + + @Test + void shouldDelegatePlainAvailabilityToWrappedMechanism() { + // GIVEN a PLAIN mechanism disabled by the SMTP configuration + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(false, true), + ImmutableList.of(mock(AuthHook.class)), + ImmutableList.of()); + + // WHEN checking whether it is available on either transport + // THEN the legacy adapter preserves the configured policy + assertThat(mechanism.isAvailableOnTransport(false)).isFalse(); + assertThat(mechanism.isAvailableOnTransport(true)).isFalse(); + } + + @Test + void shouldFailWhenAllAuthHooksDecline() { + // GIVEN a legacy hook declining the provided credentials + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(any(SMTPSession.class), eq(USERNAME), eq("password"))).thenReturn(HookResult.DECLINED); + SMTPSession session = mock(SMTPSession.class); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN authenticating through the adapter + SaslStep step = mechanism.start(initialRequest("\[email protected]\0password"), session).firstStep(); + + // THEN the legacy terminal failure is preserved without a fallback login + assertThat(step).isEqualTo(new SaslStep.Failure(SaslFailure.invalidCredentials( + USERNAME, + Optional.empty(), + "Invalid credentials"))); + } + + @Test + void shouldDelegateEmptyPasswordToAuthHooks() { + // GIVEN the legacy empty-password delegation form + AuthHook authHook = mock(AuthHook.class); + when(authHook.doDelegation(eq(session), eq(USERNAME))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.ok()) + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN authenticating through the adapter + SaslStep step = mechanism.start(initialRequest("\[email protected]\0"), session).firstStep(); + + // THEN the legacy hook receives the delegation call + assertThat(step).isEqualTo(new SaslStep.Success( + new SaslIdentity(USERNAME, USERNAME), + Optional.empty())); + verify(authHook).doDelegation(session, USERNAME); + } + + @Test + void shouldPreserveCustomDeniedResponseAndDisconnect() { + // GIVEN a legacy hook denying authentication with a custom response and disconnect policy + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(eq(session), eq(USERNAME), eq("password"))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.DENY)) + .smtpReturnCode("421") + .smtpDescription("Too many authentication attempts") + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN the adapter processes the PLAIN credentials + AuthHookSaslMechanism.Exchange exchange = mechanism.start(initialRequest("\[email protected]\0password"), session); + SaslStep step = exchange.firstStep(); + + // THEN the SASL failure keeps the legacy SMTP response for the protocol driver + assertThat(step).isInstanceOf(SaslStep.Failure.class); + Response response = AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow(); + assertThat(response.getRetCode()).isEqualTo("421"); + assertThat(response.getLines()).containsExactly("421 Too many authentication attempts"); + assertThat(response.isEndSession()).isTrue(); + } + + @Test + void shouldKeepConnectionOpenForSuccessfulAuthHookResponse() { + // GIVEN a legacy hook accepting authentication while requesting the connection to close + AuthHook authHook = mock(AuthHook.class); + when(authHook.doAuth(eq(session), eq(USERNAME), eq("password"))).thenReturn(HookResult.builder() + .hookReturnCode(HookReturnCode.disconnected(HookReturnCode.Action.OK)) + .smtpReturnCode(SMTPRetCode.AUTH_OK) + .smtpDescription("Authentication successful") + .build()); + AuthHookSaslMechanism mechanism = new AuthHookSaslMechanism( + new PlainSaslMechanism(), + ImmutableList.of(authHook), + ImmutableList.of()); + + // WHEN the adapter processes the PLAIN credentials + AuthHookSaslMechanism.Exchange exchange = mechanism.start(initialRequest("\[email protected]\0password"), session); + SaslStep step = exchange.firstStep(); + + // THEN SMTP success keeps the connection open + assertThat(step).isInstanceOf(SaslStep.Success.class); + assertThat(AuthHookSaslMechanism.terminalResponse(exchange).orElseThrow().isEndSession()).isFalse(); + } + + private SaslInitialRequest initialRequest(String value) { + return new SaslInitialRequest(SaslMechanismNames.PLAIN, Optional.of(value.getBytes(UTF_8))); + } +} diff --git a/upgrade-instructions.md b/upgrade-instructions.md index 29987f7061..efa37b07ff 100644 --- a/upgrade-instructions.md +++ b/upgrade-instructions.md @@ -19,6 +19,24 @@ Change list: - [Adding thread_id column to Cassandra email_query_view_sent_at and email_query_view_received_at tables](#adding-thread_id-column-to-cassandra-email_query_view_sent_at-and-email_query_view_received_at-tables) - [Adding thread_id column to Postgresql email_query_view table](#adding-thread_id-column-to-postgresql-email_query_view-table) - [Lucene mailbox index schema update for collapseThreads support](#lucene-mailbox-index-schema-update-for-collapsethreads-support) + - [JAMES-4210 SMTP AuthHook deprecation](#james-4210-smtp-authhook-deprecation) + +### JAMES-4210 SMTP AuthHook deprecation + +Date: 24/06/2026 + +Concerned products: SMTP servers declaring custom `AuthHook` implementations + +`AuthHook` is deprecated. Existing handler-chain registrations remain usable through the automatically applied +`AuthHookSaslMechanism` compatibility adapter, but new and existing extensions should avoid relying on `AuthHook` +going forward and migrate to dedicated SASL mechanisms. + +Extensions that only decorate authentication results for audit, notification or metrics should migrate to +`SaslAuthResultHook`. + +Normal SMTP user authentication now relies on `PlainSaslMechanism`, not a default `UsersRepositoryAuthHook`. +Deployments that relied on a custom `AuthHook` declining before `UsersRepositoryAuthHook` authenticated normal users +must explicitly declare `UsersRepositoryAuthHook` after their custom hook in the SMTP handler chain. ### Adding metadata column to blob tables --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
