This is an automated email from the ASF dual-hosted git repository. quantranhong1999 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit a1a6dedf4e99e15acbe02c96a9edee799f00d981 Author: Quan Tran <[email protected]> AuthorDate: Tue Jul 7 09:21:53 2026 +0700 JAMES-4210 Adapt IMAP authentication processors to SASL exchanges Drive IMAP AUTHENTICATE and LOGIN through the new SASL exchange model. Add IMAP SASL bridge encoding and decoding, final server-data handling, exchange cleanup, typed failure mapping, and preserve mailbox session semantics including delegation. --- protocols/imap/pom.xml | 4 + .../imap/encode/AuthenticateResponseEncoder.java | 6 +- .../message/response/AuthenticateResponse.java | 16 +- .../imap/processor/AbstractAuthProcessor.java | 179 ++++------ .../imap/processor/AuthenticateProcessor.java | 393 ++++++++++++++------- .../james/imap/processor/DefaultProcessor.java | 49 ++- .../james/imap/processor/LoginProcessor.java | 39 +- .../james/imap/processor/sasl/ImapSaslBridge.java | 104 ++++++ .../imap/processor/AuthenticateProcessorTest.java | 277 +++++++++++++++ .../imap/processor/sasl/ImapSaslBridgeTest.java | 240 +++++++++++++ 10 files changed, 1055 insertions(+), 252 deletions(-) diff --git a/protocols/imap/pom.xml b/protocols/imap/pom.xml index 1d4d6642d0..e1ec4819fb 100644 --- a/protocols/imap/pom.xml +++ b/protocols/imap/pom.xml @@ -91,6 +91,10 @@ <groupId>${james.protocols.groupId}</groupId> <artifactId>protocols-api</artifactId> </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-sasl</artifactId> + </dependency> <dependency> <groupId>com.beetstra.jutf7</groupId> <artifactId>jutf7</artifactId> 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 a4dab679c8..3e77db72e6 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 AuthenticateResponseEncoder implements ImapResponseEncoder<Authenti @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/message/response/AuthenticateResponse.java b/protocols/imap/src/main/java/org/apache/james/imap/message/response/AuthenticateResponse.java index 345fc9e5f4..dcf4355e34 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<String> response; + + public AuthenticateResponse() { + this.response = Optional.empty(); + } + + public AuthenticateResponse(String response) { + this.response = Optional.of(response); + } + public Optional<String> 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 8786a6ec94..cad4359952 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.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.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.exception.UserDoesNotExistException; 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 abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr 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 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr 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 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr 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 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr } } - 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 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr 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> username, Optional<Username> assumedUser, String log) { + protected void authFailure(ImapSession session, ImapRequest request, Responder responder, HumanReadableText failed, Optional<Username> username, + Optional<Username> assumedUser, String failureReason) { AuditTrail.Entry entry = AuditTrail.entry() .username(() -> username.map(name -> name.asString()).orElse(null)) .sessionId(() -> session.sessionId().asString()) @@ -275,35 +246,7 @@ public abstract class AbstractAuthProcessor<R extends ImapRequest> extends Abstr 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<Username> delegateUserName; - private final Username authenticationId; - private final String password; - - public AuthenticationAttempt(Optional<Username> 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<Username> 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 cfae43c6f3..8a9d87fbd3 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 @@ import reactor.core.publisher.Mono; public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateRequest> 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<Capability> 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<SaslMechanism> 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 class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq @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<SaslMechanism> 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<String> 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<Capability> getImplementedCapabilities(ImapSession session) { - List<Capability> 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<Capability> 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 @@ public class AuthenticateProcessor extends AbstractAuthProcessor<AuthenticateReq .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<SaslMechanism> saslMechanisms) { + this.saslMechanisms = saslMechanisms; + } + + private Optional<String> 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<SaslMechanism> 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<SaslStep> 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 794954c85d..be56964b79 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.SubscriptionManager; 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 class DefaultProcessor implements ImapProcessor { 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<SaslMechanism> 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<SaslMechanism> defaultSaslMechanisms, + JamesSaslAuthenticator saslAuthenticator) { PathConverter.Factory pathConverterFactory = PathConverter.Factory.DEFAULT; ImmutableList.Builder<AbstractProcessor> 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 class DefaultProcessor implements ImapProcessor { 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/LoginProcessor.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/LoginProcessor.java index c7c2a845b3..2f2ba5d168 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 @@ package org.apache.james.imap.processor; 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.main.PathConverter; 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<LoginRequest> implemen private static final List<Capability> LOGINDISABLED_CAPS = ImmutableList.of(Capability.of("LOGINDISABLED")); private static final Logger LOGGER = LoggerFactory.getLogger(LoginProcessor.class); + private final JamesSaslAuthenticator jamesSaslAuthenticator; + private Optional<PlainSaslMechanism> 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 class LoginProcessor extends AbstractAuthProcessor<LoginRequest> implemen */ @Override protected void processRequest(LoginRequest request, ImapSession session, Responder responder) { + Optional<PlainSaslMechanism> 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 @@ public class LoginProcessor extends AbstractAuthProcessor<LoginRequest> implemen public List<Capability> 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<SaslMechanism> saslMechanisms) { + this.plainSaslMechanism = saslMechanisms.stream() + .filter(mechanism -> SaslMechanismNames.PLAIN.equalsIgnoreCase(mechanism.name())) + .filter(PlainSaslMechanism.class::isInstance) + .map(PlainSaslMechanism.class::cast) + .findFirst(); + } + + private Optional<PlainSaslMechanism> 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/sasl/ImapSaslBridge.java b/protocols/imap/src/main/java/org/apache/james/imap/processor/sasl/ImapSaslBridge.java new file mode 100644 index 0000000000..7e5a8217fe --- /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<String> 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 0000000000..1d66db3437 --- /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("[email protected]"); + 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 0000000000..63053ee11c --- /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("[email protected]"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final ImapSaslBridge testee = new ImapSaslBridge(); + + private static class RecordingExchange implements SaslExchange { + protected final List<String> 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<String> 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); + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
