diff --git a/i18n/src/main/resources/openfire_i18n.properties b/i18n/src/main/resources/openfire_i18n.properties index 4a0f3b77de..bedb260e31 100644 --- a/i18n/src/main/resources/openfire_i18n.properties +++ b/i18n/src/main/resources/openfire_i18n.properties @@ -1326,7 +1326,7 @@ system_property.xmpp.auth.external.client.skip-cert-revalidation=Set to true to system_property.xmpp.auth.external.server.require-authzid=Require the peer to provide an authorization identity through SASL (typically in the Initial Response) when authenticating an inbound S2S connection that uses the EXTERNAL SASL mechanism. This is not required by the XMPP protocol specification, but it was required by Openfire versions prior to release 4.8.0. This configuration option is added to allow for backwards compatibility. system_property.xmpp.auth.external.server.skip-sending-authzid=Send an authorization identity in the Initial Response when attempting to authenticate using the SASL EXTERNAL mechanism with a remote XMPP domain. Sending the authzid in this manner is not required by the XMPP protocol specification, but is recommended in XEP-0178 for compatibility with older server implementations. system_property.xmpp.auth.sasl2=Enables support for SASL2 authentication (XEP-0388) -system_property.xmpp.auth.sasl2.require-tls=Require TLS in order to authenticate with SASL2 +system_property.xmpp.auth.sasl2.require-tls=Require TLS in order to authenticate with SASL2 (disabling this is not XEP-0388 compliant) system_property.xmpp.auth.ssl.default-trustmanager-impl=The class to use as the default TLS TrustManager (which checks certificates from peers). system_property.xmpp.auth.scram.mechanisms-per-user=Offer a client only the SCRAM mechanisms that the user it claims to be has credentials for, rather than those that every user is assumed to have. Requires the client to identify itself in the 'from' attribute of its stream header system_property.xmpp.client.csi.enabled=Controls if Client State Indication (XEP-0352) functionality is supported by Openfire. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java index 59208d01ad..a6e6ecb377 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java @@ -38,6 +38,9 @@ import org.jivesoftware.openfire.session.*; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.openfire.streammanagement.TerminationDelegate; import org.jivesoftware.util.*; import org.jivesoftware.util.cache.*; @@ -1901,6 +1904,9 @@ public void start() throws IllegalStateException { super.start(); localSessionManager.start(); + // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + // Run through the server sessions every 10% of the time of the maximum time that a session is allowed to be // detached, or every 3 minutes if the max time is outside the default boundaries. // TODO Reschedule task if getSessionDetachTime value changes. @@ -1917,6 +1923,7 @@ public void start() throws IllegalStateException { @Override public void stop() { Log.debug("SessionManager: Stopping server"); + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); // Stop threads that are sending packets to remote servers OutgoingSessionPromise.getInstance().shutdown(); if (JiveGlobals.getBooleanProperty("shutdownMessage.enabled")) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java new file mode 100644 index 0000000000..d39143fd95 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024-2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.handler; + +import org.dom4j.Element; +import org.jivesoftware.openfire.net.Bind2InlineHandler; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmpp.packet.PacketError; + +/** + * A {@link Bind2InlineHandler} that processes XEP-0198 Stream Management {@code } elements + * sent inline within a SASL2 Bind2 request (XEP-0388 / XEP-0386). + * + *

When a client includes an {@code } element in the {@code urn:xmpp:sm:3} namespace + * inside its Bind2 {@code } element, this handler delegates to the session's + * {@link StreamManager} to enable stream management (and optionally resumption) immediately + * after resource binding, without requiring a separate round-trip.

+ * + *

The {@code } response from the server is added as a child of the {@code } + * element in the SASL2 {@code } stanza.

+ * + * @see XEP-0198: Stream Management + * @see XEP-0388: Extensible SASL Profile + */ +public class Bind2StreamManagementHandler implements Bind2InlineHandler { + + private static final Logger Log = LoggerFactory.getLogger(Bind2StreamManagementHandler.class); + + @Override + public String getNamespace() { + return StreamManager.NAMESPACE_V3; + } + + @Override + public boolean isEnabled() { + return StreamManager.isStreamManagementActive(); + } + + /** + * Handles an {@code } element from a Bind2 inline feature request by enabling + * XEP-0198 stream management on the session. The {@code } response element + * produced by the stream manager is added as a child of the provided {@code bound} element. + * + *

Only {@code } elements are processed; any other element name is ignored.

+ * + * @param session the client session on which stream management should be enabled + * @param bound the {@code } element to which the {@code } response is added + * @param element the inline element from the Bind2 request (expected to be {@code }) + * @return {@code true} if the element was an {@code } and was processed; + * {@code false} if the element was not an {@code } or processing failed + */ + @Override + public boolean handleElement(LocalClientSession session, Element bound, Element element) { + if (!"enable".equals(element.getName())) { + Log.debug("Bind2StreamManagementHandler received unexpected element '{}'; ignoring.", element.getName()); + return false; + } + Log.debug("Processing inline SM for session {}", session.getAddress()); + final String namespace = element.getNamespaceURI(); + final String resumeAttr = element.attributeValue("resume"); + final boolean resume = "true".equalsIgnoreCase(resumeAttr) || "1".equals(resumeAttr) || "yes".equalsIgnoreCase(resumeAttr); + final Element outcome = session.getStreamManager().enableAndBuildElement(namespace, resume); + if (outcome != null) { + bound.add(outcome); + return true; + } + return false; + } + + @Override + public void handleFailure(LocalClientSession session, Element bound, Element element, Throwable cause) { + final PacketError.Condition condition = cause == null + ? PacketError.Condition.bad_request + : PacketError.Condition.internal_server_error; + final Element failed = bound.addElement("failed", StreamManager.NAMESPACE_V3); + failed.addElement(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"); + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java index 5c6f7ac08a..3901e820d3 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java @@ -31,6 +31,15 @@ public interface Bind2InlineHandler { */ String getNamespace(); + /** + * Indicates whether this handler is currently available for advertisement and request processing. + * + * @return {@code true} when the inline feature is available + */ + default boolean isEnabled() { + return true; + } + /** * Process an inline element from a bind2 request. * @@ -39,4 +48,16 @@ public interface Bind2InlineHandler { * @return true if the element was handled successfully, false otherwise */ boolean handleElement(LocalClientSession session, Element bound, Element element); + + /** + * Gives a handler an opportunity to add the protocol-defined failure response after request processing failed. + * + * @param session the client session + * @param bound the Bind2 response element + * @param element the request that could not be processed + * @param cause the processing exception, or {@code null} when the handler returned {@code false} + */ + default void handleFailure(LocalClientSession session, Element bound, Element element, Throwable cause) { + // Most inline extensions do not define a failure response. + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java index 5539c6fca0..d40b370c7c 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java @@ -94,13 +94,15 @@ public Element processFeatureRequests(LocalClientSession clientSession, Element String namespace = element.getNamespaceURI(); Bind2InlineHandler handler = elementHandlers.get(namespace); - if (handler != null) { + if (handler != null && handler.isEnabled()) { try { if (!handler.handleElement(clientSession, bound, element)) { Log.warn("Handler for namespace {} failed to process element", namespace); + handler.handleFailure(clientSession, bound, element, null); } } catch (Exception e) { Log.error("Error processing element with namespace: " + namespace, e); + handler.handleFailure(clientSession, bound, element, e); } } else { Log.debug("No handler registered for namespace: {}", namespace); @@ -115,6 +117,9 @@ public static Element featureElement() { Element bind2 = DocumentHelper.createElement(new QName("bind", new Namespace("", "urn:xmpp:bind:0"))); Element bind2inline = bind2.addElement("inline"); for (Bind2InlineHandler handler : elementHandlers.values()) { + if (!handler.isEnabled()) { + continue; + } Element var = bind2inline.addElement("feature"); var.addAttribute("var", handler.getNamespace()); } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java index 7845e1dbfb..9f068f1c14 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -41,6 +41,7 @@ import org.jivesoftware.openfire.event.SessionEventDispatcher; import org.jivesoftware.openfire.session.*; import org.jivesoftware.openfire.spi.ConnectionType; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.util.CertificateManager; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.PropertyEventDispatcher; @@ -87,6 +88,8 @@ public class SASLAuthentication { private static final Logger Log = LoggerFactory.getLogger(SASLAuthentication.class); + static final String SASL2_RESUME_REQUEST = "sasl2-resume-request"; + static final String SASL2_RESUMPTION_RESULT = "sasl2-resumption-result"; // TODO how is this different from a singular entry in APPROVED_REALMS? Should these two properties be folded into eachother? public static final SystemProperty REALM = SystemProperty.Builder.ofType(String.class) @@ -163,6 +166,8 @@ public class SASLAuthentication { /** * Require TLS for SASL2. This is currently on by default, and means that SASL2 is not advertised in features without TLS. + * Disabling this option is intentionally supported for specialized deployments, but violates the XEP-0388 requirement + * that SASL2 is offered and used only after TLS negotiation. * * @see XEP-0388: Extensible SASL Profile */ @@ -535,7 +540,9 @@ static Element asSASLMechanismsElementForClientSessions(@Nonnull final Set has been sent. final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); final List specificFeatures = clientSession.getAvailableStreamFeatures(); @@ -1021,9 +1049,10 @@ else if (session instanceof LocalIncomingServerSession serverSession) { } }); // Response and features are sent asynchronously from the completion stage. + return true; } else { // No Bind2 request, or session already authenticated: send synchronously without . - final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null); + final Element success = buildSasl2SuccessElement(successData, authorizationIdentity, null, resumeResponse); session.deliverRawText(success.asXML()); } } else { @@ -1034,6 +1063,7 @@ else if (session instanceof LocalIncomingServerSession serverSession) { } else { sendElement(session, "success", successData, false); } + return false; } /** @@ -1045,6 +1075,10 @@ else if (session instanceof LocalIncomingServerSession serverSession) { * @return the <success/> element. */ private static Element buildSasl2SuccessElement(byte[] successData, String authorizationIdentity, String resource) { + return buildSasl2SuccessElement(successData, authorizationIdentity, resource, null); + } + + private static Element buildSasl2SuccessElement(byte[] successData, String authorizationIdentity, String resource, Element inlineResponse) { final Element success = DocumentHelper.createElement(new QName("success", new Namespace("", SASL2_NAMESPACE))); if (successData != null && successData.length > 0) { final String data_b64 = Base64.getEncoder().encodeToString(successData).trim(); @@ -1055,9 +1089,21 @@ private static Element buildSasl2SuccessElement(byte[] successData, String autho authId.append('/').append(resource); } success.addElement("authorization-identifier").setText(authId.toString()); + if (inlineResponse != null) { + success.add(inlineResponse); + } return success; } + @VisibleForTesting + static String authorizationIdentityForSasl2Success(String authenticatedIdentity, @Nullable JID resumedAddress) { + return resumedAddress == null ? authenticatedIdentity : resumedAddress.toString(); + } + + static StreamManager.Sasl2ResumeResult consumeSasl2ResumptionResult(LocalSession connectionProvider) { + return (StreamManager.Sasl2ResumeResult) connectionProvider.removeSessionData(SASL2_RESUMPTION_RESULT); + } + private static void authenticationFailed(LocalSession session, Failure failure, boolean usingSASL2) { final Element reply = DocumentHelper.createElement(QName.get("failure", usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE)); if (usingSASL2) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java index e6a9beb163..1ee841032c 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -105,6 +105,8 @@ public abstract class StanzaHandler { */ protected LocalSession session; + private StreamManager.Sasl2ResumeResult sasl2Resumption; + /** * Router used to route incoming packets to the correct channels. */ @@ -245,6 +247,7 @@ else if ("auth".equals(tag)) { usingSASL2 = true; saslStatus = SASLAuthentication.handle(session, doc, usingSASL2); if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + adoptSasl2ResumedSession(); // No Bind2: send features synchronously now. startedSASL = false; // Without a multi-step SASL mechanism, this can be reset here immediately, rather than in initiateSession (as SASL1 does). sasl2Successful(); @@ -262,6 +265,7 @@ else if ("auth".equals(tag)) { usingSASL2 = false; } if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + adoptSasl2ResumedSession(); startedSASL = false; // Symmetric with the single-step reset in the 'authenticate' branch. sasl2Successful(); } else if (saslStatus == SASLAuthentication.Status.authenticatedAwaitingFeatures) { @@ -585,10 +589,26 @@ protected void saslSuccessful() { * (e.g. RFC 7395 WebSocket) override this. */ protected void sasl2Successful() { + if (sasl2Resumption != null && sasl2Resumption.completeAfterSuccess()) { + sasl2Resumption = null; + return; + } + deliverSasl2Features(); + } + + protected void deliverSasl2Features() { final Element features = generateFeatures(); connection.deliverRawText(features.asXML()); } + protected void adoptSasl2ResumedSession() { + final StreamManager.Sasl2ResumeResult result = SASLAuthentication.consumeSasl2ResumptionResult(session); + if (result != null && result.isResumed()) { + sasl2Resumption = result; + setSession(result.getResumedSession()); + } + } + /** * Helper to generate stream:features, populated simply from the session., * diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java index 9e0cbdcc90..523f468267 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -205,6 +205,32 @@ public void reattach(LocalSession connectionProvider, long h) { this.sessionManager.removeSession((LocalClientSession)connectionProvider); } + /** + * Reattaches the connection from {@code connectionProvider} to this session for a SASL2-based + * stream resumption (XEP-0198 + XEP-0388). Unlike {@link #reattach(LocalSession, long)}, this + * method does not send the {@code } element; the caller is responsible for + * embedding it in the SASL2 {@code } element before delivering it. + * + * @param connectionProvider the new (unauthenticated) session whose connection will be taken over + */ + public void reattachForSasl2(LocalSession connectionProvider) { + lock.lock(); + try { + Log.debug("Reattaching (SASL2) session with address {} and streamID {} using connection from session with address {} and streamID {}.", this.address, this.streamID, connectionProvider.getAddress(), connectionProvider.getStreamID()); + if (this.conn != null && !this.conn.isClosed()) + { + this.conn.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + } + this.conn = connectionProvider.releaseConnection(); + this.conn.reinit(this); + } finally { + lock.unlock(); + } + this.status = Session.Status.AUTHENTICATED; + this.sessionManager.removeDetached(this); + this.sessionManager.removeSession((LocalClientSession) connectionProvider); + } + /** * Obtain the address of the session. The address is used by services like the core * server packet router to determine if a packet should be sent to the handler. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java index ff5c140c52..30552a74dd 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -97,6 +97,16 @@ public static boolean isStreamManagementActive() { public static final String NAMESPACE_V2 = "urn:xmpp:sm:2"; public static final String NAMESPACE_V3 = "urn:xmpp:sm:3"; + /** + * Returns an XML element advertising XEP-0198 stream management as an inline feature for use + * in SASL2 (XEP-0388) inline feature advertisement. + * + * @return an {@code } element in the {@link #NAMESPACE_V3} namespace + */ + public static Element featureElement() { + return DocumentHelper.createElement(QName.get("sm", NAMESPACE_V3)); + } + /** * Session (stream) to client. */ @@ -243,13 +253,32 @@ private boolean allowResume() { * @param resume Whether the client is requesting a resumable session. */ private void enable( String namespace, boolean resume ) + { + session.deliverRawText(enableInternal(namespace, resume).asXML()); + } + + /** + * Enables stream management and returns the {@code } element without sending it. + * This allows callers (e.g. the SASL2 Bind2 handler) to embed the element in another stanza. + * + *

The returned element is either {@code } or {@code }. It is never sent by this method, + * allowing an inline caller to embed either outcome in its enclosing response.

+ * + * @param namespace the SM namespace to use + * @param resume whether the client requests a resumable session + * @return the {@code } or {@code } outcome element + */ + public Element enableAndBuildElement( String namespace, boolean resume ) + { + return enableInternal(namespace, resume); + } + + private Element enableInternal( String namespace, boolean resume ) { boolean offerResume = allowResume(); // Ensure that resource binding has occurred. if (!session.isAuthenticated()) { - this.namespace = namespace; - sendUnexpectedError(); - return; + return buildFailedElement(namespace, PacketError.Condition.unexpected_request); } String smId = null; @@ -259,8 +288,7 @@ private void enable( String namespace, boolean resume ) // Do nothing if already enabled if ( isEnabled() ) { - sendUnexpectedError(); - return; + return buildFailedElement(namespace, PacketError.Condition.unexpected_request); } this.namespace = namespace; @@ -271,7 +299,7 @@ private void enable( String namespace, boolean resume ) } } - // Send confirmation to the requestee. + // Build confirmation element. Element enabled = new DOMElement(QName.get("enabled", namespace)); if (this.resume) { enabled.addAttribute("resume", "true"); @@ -289,121 +317,223 @@ private void enable( String namespace, boolean resume ) } } } - session.deliverRawText(enabled.asXML()); + return enabled; } private void startResume(String namespace, String previd, long h) { Log.debug("Attempting resumption for {}, h={}", previd, h); this.namespace = namespace; - // Ensure that resource binding has NOT occurred. - if (!allowResume() ) { - Log.debug("Unable to process session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); - sendUnexpectedError(); + final ResumeValidationResult validation = validateResumeRequest(namespace, previd, h); + if (!validation.isValid()) { + sendError(new PacketError(validation.getFailureCondition())); return; } - if (session.isAuthenticated()) { - Log.debug("Unable to process session resumption attempt, as session {} is not authenticated.", session); - sendUnexpectedError(); - return; + + final LocalClientSession otherSession = validation.getSession(); + final JID fullJid = validation.getFullJid(); + if (!otherSession.isDetached()) { + Log.debug("Existing session {} of '{}' is not detached; detaching.", otherSession.getStreamID(), fullJid); + Connection oldConnection = otherSession.getConnection(); + otherSession.setDetached(); + assert oldConnection != null; // If the other session is not detached, the connection can't be null. + oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); } - AuthToken authToken = null; - // Ensure that resource binding has occurred. - if (session instanceof ClientSession) { - authToken = ((LocalClientSession) session).getAuthToken(); + Log.debug("Attaching to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); + // If we're all happy, re-attach the connection from the pre-existing session to the new session, discarding the old session. + otherSession.reattach(session, h); + Log.debug("Perform resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); + } + + /** + * Processes a SASL2-inline XEP-0198 {@code } element. Unlike the standard + * {@link #process(Element)} path, this method does not send the {@code } + * element directly; instead it returns it so the caller can embed it in the SASL2 + * {@code } element. + * + * @param resumeElement the {@code } element from the SASL2 {@code } + * @return the SM result to embed in {@code }, including the session that owns the + * connection when resumption succeeds + */ + public Sasl2ResumeResult processSasl2Resume(Element resumeElement) { + final String namespace = resumeElement.getNamespaceURI(); + + final String hValue = resumeElement.attributeValue("h"); + final long h; + try { + h = Long.parseLong(hValue); + } catch (NumberFormatException e) { + Log.warn("Client sends non-numeric value for SM 'h' in SASL2 resume: {}, session: {}", hValue, session); + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); + } + if (h < 0 || h > MASK) { + Log.warn("Client sends out-of-range value for SM 'h' in SASL2 resume: {}, session: {}", h, session); + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); + } + + final String previd = resumeElement.attributeValue("previd"); + if (previd == null || previd.isEmpty()) { + return Sasl2ResumeResult.failed(namespace, PacketError.Condition.bad_request); + } + + final ResumeValidationResult validation = validateResumeRequest(namespace, previd, h); + if (!validation.isValid()) { + return Sasl2ResumeResult.failed(namespace, validation.getFailureCondition()); + } + + final LocalClientSession otherSession = validation.getSession(); + final JID fullJid = validation.getFullJid(); + if (!otherSession.isDetached()) { + Log.debug("Existing session {} of '{}' is not detached (SASL2 resume); detaching.", otherSession.getStreamID(), fullJid); + Connection oldConnection = otherSession.getConnection(); + otherSession.setDetached(); + assert oldConnection != null; + oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + } + Log.debug("Attaching (SASL2) to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); + otherSession.reattachForSasl2(session); + final StreamManager resumedStreamManager = otherSession.getStreamManager(); + final Element resumed = resumedStreamManager.buildResumedElement(); + resumedStreamManager.processClientAcknowledgement(h); + Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); + return Sasl2ResumeResult.resumed(resumed, otherSession); + } + + private ResumeValidationResult validateResumeRequest(String namespace, String previd, long h) { + if (!allowResume() || session.isAuthenticated()) { + Log.debug("Unable to process session resumption attempt, as session {} is in a state where resumption is not allowed.", session); + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); } + + final AuthToken authToken = session instanceof LocalClientSession ? ((LocalClientSession) session).getAuthToken() : null; if (authToken == null) { Log.debug("Unable to process session resumption attempt, as session {} does not provide any auth context.", session); - sendUnexpectedError(); - return; + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); } - // Decode previd. - String resource; - String streamId; + + final String resource; + final String streamId; try { - StringTokenizer toks = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); - resource = toks.nextToken(); - streamId = toks.nextToken(); + final StringTokenizer tokens = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); + resource = tokens.nextToken(); + streamId = tokens.nextToken(); } catch (Exception e) { - Log.debug("Exception from previd decode:", e); - sendUnexpectedError(); - return; + Log.debug("Unable to decode SM previd for session {}.", session, e); + return ResumeValidationResult.failed(PacketError.Condition.bad_request); } - final JID fullJid; - if ( authToken.isAnonymous() ){ - fullJid = new JID(resource, session.getServerName(), resource, true); - } else { - fullJid = new JID(authToken.getUsername(), session.getServerName(), resource, true); - } - Log.debug("Resuming session for '{}'. Current session: {}", fullJid, session.getStreamID()); - // Locate existing session. + final JID fullJid = authToken.isAnonymous() + ? new JID(resource, session.getServerName(), resource, true) + : new JID(authToken.getUsername(), session.getServerName(), resource, true); final ClientSession route = XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid); - if (route == null) { - Log.debug("Not able for client of '{}' to resume a session on this cluster node. No session was found for this client.", fullJid); + if (!(route instanceof LocalClientSession)) { + Log.debug("Unable to resume '{}' on this cluster node because no local session was found.", fullJid); if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { - // When the client tries to resume a connection on this host, it is unlikely to try other hosts. Remove any detached sessions living elsewhere in the cluster. (OF-2753) CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); } - sendError(new PacketError(PacketError.Condition.item_not_found)); - return; + return ResumeValidationResult.failed(PacketError.Condition.item_not_found); } - if (!(route instanceof LocalClientSession)) { - Log.debug("Not allowing a client of '{}' to resume a session on this cluster node. The session can only be resumed on the Openfire cluster node where the original session was connected.", fullJid); - if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { - // When the client tries to resume a connection on this host, it is unlikely to try other hosts. Remove any detached sessions living elsewhere in the cluster. (OF-2753) - CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); - } - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + final LocalClientSession resumableSession = (LocalClientSession) route; + if (!resumableSession.getStreamID().getID().equals(streamId)) { + return ResumeValidationResult.failed(PacketError.Condition.item_not_found); + } + if (resumableSession.isClosed() + || !resumableSession.getStreamManager().resume + || resumableSession.getStreamManager().namespace == null + || !resumableSession.getStreamManager().namespace.equals(namespace)) { + return ResumeValidationResult.failed(PacketError.Condition.unexpected_request); + } + if (!resumableSession.getStreamManager().validateClientAcknowledgement(h)) { + return ResumeValidationResult.failed(PacketError.Condition.undefined_condition); } + return ResumeValidationResult.valid(resumableSession, fullJid); + } - final LocalClientSession otherSession = (LocalClientSession) route; - if (!otherSession.getStreamID().getID().equals(streamId)) { - sendError(new PacketError(PacketError.Condition.item_not_found)); - return; + private static final class ResumeValidationResult { + private final LocalClientSession session; + private final JID fullJid; + private final PacketError.Condition failureCondition; + + private ResumeValidationResult(LocalClientSession session, JID fullJid, PacketError.Condition failureCondition) { + this.session = session; + this.fullJid = fullJid; + this.failureCondition = failureCondition; } - Log.debug("Found existing session for '{}', checking status", fullJid); - // OF-2811: Cannot resume a session that's already closed. That session is likely busy firing its 'closeListeners'. - if (route.isClosed()) { - Log.debug("Not allowing a client of '{}' to resume a session, as the preexisting session is already in process of being closed.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + static ResumeValidationResult valid(LocalClientSession session, JID fullJid) { + return new ResumeValidationResult(session, fullJid, null); } - // Previd identifies proper session. Now check SM status - if (!otherSession.getStreamManager().resume) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed does not have the stream management resumption feature enabled.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + static ResumeValidationResult failed(PacketError.Condition condition) { + return new ResumeValidationResult(null, null, condition); } - if (otherSession.getStreamManager().namespace == null) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + + boolean isValid() { + return session != null; } - if (!otherSession.getStreamManager().namespace.equals(namespace)) { - Log.debug("Not allowing a client of '{}' to resume a session, the session to be resumed used a different version ({}) of the session management resumption feature as compared to the version that's requested now: {}.", fullJid, otherSession.getStreamManager().namespace, namespace); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + + LocalClientSession getSession() { + return session; } - if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { - Log.debug("Not allowing a client of '{}' to resume a session, as it reports it received more stanzas from us than that we've send it.", fullJid); - sendError(new PacketError(PacketError.Condition.unexpected_request)); - return; + + JID getFullJid() { + return fullJid; } - if (!otherSession.isDetached()) { - Log.debug("Existing session {} of '{}' is not detached; detaching.", otherSession.getStreamID(), fullJid); - Connection oldConnection = otherSession.getConnection(); - otherSession.setDetached(); - assert oldConnection != null; // If the other session is not detached, the connection can't be null. - oldConnection.close(new StreamError(StreamError.Condition.conflict, "The stream previously served over this connection is resumed on a new connection.")); + + PacketError.Condition getFailureCondition() { + return failureCondition; + } + } + + public static final class Sasl2ResumeResult { + private final Element response; + private final LocalClientSession resumedSession; + private boolean completed; + + private Sasl2ResumeResult(Element response, LocalClientSession resumedSession) { + this.response = response; + this.resumedSession = resumedSession; + } + + public static Sasl2ResumeResult resumed(Element response, LocalClientSession resumedSession) { + return new Sasl2ResumeResult(response, resumedSession); + } + + public static Sasl2ResumeResult failed(String namespace, PacketError.Condition condition) { + final Element failed = DocumentHelper.createElement(QName.get("failed", namespace)); + failed.addElement(QName.get(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas")); + return new Sasl2ResumeResult(failed, null); + } + + public Element getResponse() { + return response; + } + + public LocalClientSession getResumedSession() { + return resumedSession; + } + + public boolean isResumed() { + return resumedSession != null; + } + + /** + * Completes a successful inline resumption after the enclosing SASL2 success has been sent. + * This method is idempotent. + * + * @return {@code true} when this result represents a resumed stream + */ + public synchronized boolean completeAfterSuccess() { + if (resumedSession == null) { + return false; + } + if (!completed) { + completed = true; + resumedSession.getStreamManager().redeliverUnackedStanzas( + new JID(null, resumedSession.getServerName(), null, true)); + } + return true; } - Log.debug("Attaching to other session '{}' of '{}'.", otherSession.getStreamID(), fullJid); - // If we're all happy, re-attach the connection from the pre-existing session to the new session, discarding the old session. - otherSession.reattach(session, h); - Log.debug("Perform resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); } /** @@ -456,12 +586,17 @@ private void sendUnexpectedError() { * @param error PacketError describing the failure. */ private void sendError(PacketError error) { - final Element failed = DocumentHelper.createElement(QName.get("failed", namespace)); - failed.addElement(QName.get(error.getCondition().toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas")); + final Element failed = buildFailedElement(namespace, error.getCondition()); session.deliverRawText(failed.asXML()); this.namespace = null; // isEnabled() is testing this. } + private static Element buildFailedElement(String namespace, PacketError.Condition condition) { + final Element failed = DocumentHelper.createElement(QName.get("failed", namespace)); + failed.addElement(QName.get(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas")); + return failed; + } + /** * Checks if the amount of stanzas that the client acknowledges is equal to or less than the amount of stanzas that * we've sent to the client. @@ -634,17 +769,41 @@ public void onClose(PacketRouter router, JID serverAddress) { } - public void onResume(JID serverAddress, long h) { - Log.debug("Agreeing to resume"); + /** + * Builds the XEP-0198 {@code } element for this session without sending it. + * This is used when the element needs to be embedded in another stanza (e.g. a SASL2 + * {@code } element) rather than sent standalone. + * + * @return the {@code } element + */ + public Element buildResumedElement() { Element resumed = new DOMElement(QName.get("resumed", namespace)); resumed.addAttribute("previd", Base64.getEncoder().encodeToString((session.getAddress().getResource() + "\0" + session.getStreamID().getID()).getBytes(StandardCharsets.UTF_8))); resumed.addAttribute("h", Long.toString(serverProcessedStanzas.get())); + return resumed; + } + + public void onResume(JID serverAddress, long h) { + Log.debug("Agreeing to resume"); + final Element resumed = buildResumedElement(); final Connection connection = session.getConnection(); assert connection != null; // While the client is resuming a session, the connection on which the session is resumed can't be null. connection.deliverRawText(resumed.asXML()); Log.debug("Resuming session: Ack for {}", h); processClientAcknowledgement(h); + redeliverUnackedStanzas(serverAddress); + } + + /** + * Re-delivers unacknowledged stanzas after a stream resumption and sends a server request for + * acknowledgement. Called by both the standard and SASL2 resume paths. + * + * @param serverAddress the server's JID, used to stamp delayed stanzas + */ + public void redeliverUnackedStanzas(JID serverAddress) { Log.debug("Processing remaining unacked stanzas"); + final Connection connection = session.getConnection(); + assert connection != null; // Re-deliver unacknowledged stanzas from broken stream (XEP-0198) synchronized (this) { if(isEnabled()) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java index 12f4911dc7..a2feca77fc 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java @@ -207,7 +207,7 @@ protected void saslSuccessful() { * updated features, as their own RFC 7395 frame. */ @Override - protected void sasl2Successful() { + protected void deliverSasl2Features() { sendStreamFeatures(); } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java new file mode 100644 index 0000000000..84bc25119e --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.handler; + +import org.dom4j.Element; +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Bind2StreamManagementAvailabilityTest +{ + @BeforeAll + static void configureOpenfire() throws Exception + { + Fixtures.reconfigureOpenfireHome(); + Fixtures.disableDatabasePersistence(); + } + + @AfterAll + static void clearProperties() + { + Fixtures.clearExistingProperties(); + } + + @BeforeEach + void registerHandler() + { + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + } + + @AfterEach + void restoreState() + { + StreamManager.ACTIVE.setValue(true); + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + + @Test + void followsDynamicStreamManagementSetting() + { + StreamManager.ACTIVE.setValue(false); + assertFalse(advertisesStreamManagement()); + + StreamManager.ACTIVE.setValue(true); + assertTrue(advertisesStreamManagement()); + + StreamManager.ACTIVE.setValue(false); + assertFalse(advertisesStreamManagement()); + } + + private static boolean advertisesStreamManagement() + { + final Element inline = Bind2Request.featureElement().element("inline"); + return inline.elements("feature").stream() + .anyMatch(feature -> StreamManager.NAMESPACE_V3.equals(feature.attributeValue("var"))); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java new file mode 100644 index 0000000000..922af7a261 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2024-2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.handler; + +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link Bind2StreamManagementHandler}. + */ +public class Bind2StreamManagementHandlerTest { + + private Bind2StreamManagementHandler handler; + private LocalClientSession mockSession; + private StreamManager mockStreamManager; + private Element boundElement; + + @BeforeEach + public void setUp() { + handler = new Bind2StreamManagementHandler(); + mockSession = mock(LocalClientSession.class); + mockStreamManager = mock(StreamManager.class); + when(mockSession.getStreamManager()).thenReturn(mockStreamManager); + boundElement = DocumentHelper.createElement(new QName("bound", new Namespace("", "urn:xmpp:bind:0"))); + } + + @Test + public void testGetNamespace() { + assertEquals(StreamManager.NAMESPACE_V3, handler.getNamespace()); + } + + @Test + public void testHandleEnableElementWithoutResume() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + final Element enabledElement = DocumentHelper.createElement( + new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3))); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(enabledElement); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false); + assertEquals(1, boundElement.elements().size()); + assertEquals("enabled", boundElement.elements().get(0).getName()); + } + + @Test + public void testHandleEnableElementWithResume() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "true"); + final Element enabledElement = DocumentHelper.createElement( + new QName("enabled", new Namespace("", StreamManager.NAMESPACE_V3))); + enabledElement.addAttribute("resume", "true"); + enabledElement.addAttribute("id", "someSmId"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(enabledElement); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + assertEquals(1, boundElement.elements().size()); + final Element addedEnabled = (Element) boundElement.elements().get(0); + assertEquals("enabled", addedEnabled.getName()); + assertEquals("true", addedEnabled.attributeValue("resume")); + } + + @Test + public void testHandleEnableElementWithResumeYes() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "yes"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(DocumentHelper.createElement("enabled")); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + @Test + public void testHandleEnableElementWithResume1() { + // Setup + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + enableElement.addAttribute("resume", "1"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true)) + .thenReturn(DocumentHelper.createElement("enabled")); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + @Test + public void testHandleEnableElementWhenEnableFails() { + // Setup: enableAndBuildElement returns null (e.g. SM already enabled) + final Element enableElement = DocumentHelper.createElement( + new QName("enable", new Namespace("", StreamManager.NAMESPACE_V3))); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(null); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertFalse(result); + assertTrue(boundElement.elements().isEmpty(), "No element should be added to on failure"); + } + + @Test + public void testEnableFailureIsEmbeddedInBoundWithoutStandaloneDelivery() { + final StreamManager streamManager = new StreamManager(mockSession); + when(mockSession.getStreamManager()).thenReturn(streamManager); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + final boolean result = handler.handleElement(mockSession, boundElement, enable); + + assertTrue(result); + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + verify(mockSession, never()).deliverRawText(anyString()); + } + + @Test + public void testHandleNonEnableElementIsIgnored() { + // Setup: send an unexpected element name + final Element wrongElement = DocumentHelper.createElement( + new QName("disable", new Namespace("", StreamManager.NAMESPACE_V3))); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, wrongElement); + + // Verify + assertFalse(result); + verifyNoInteractions(mockStreamManager); + assertTrue(boundElement.elements().isEmpty()); + } + + @Test + public void testMalformedRequestProducesBadRequestFailure() { + final Element wrongElement = DocumentHelper.createElement(QName.get("disable", StreamManager.NAMESPACE_V3)); + + handler.handleFailure(mockSession, boundElement, wrongElement, null); + + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + + @Test + public void testProcessingExceptionProducesInternalServerErrorFailure() { + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + handler.handleFailure(mockSession, boundElement, enable, new IllegalStateException("test failure")); + + final Element failed = boundElement.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("internal-server-error", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java index 63122872bf..8501fdca68 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java @@ -88,6 +88,8 @@ public void setUp() { when(mockHandler1.getNamespace()).thenReturn("http://test1.namespace"); when(mockHandler2.getNamespace()).thenReturn("http://test2.namespace"); + when(mockHandler1.isEnabled()).thenReturn(true); + when(mockHandler2.isEnabled()).thenReturn(true); when(mockHandler1.handleElement(any(), any(), any())).thenReturn(true); when(mockHandler2.handleElement(any(), any(), any())).thenReturn(true); } @@ -192,6 +194,7 @@ public void testProcessFeatureRequestsWithHandlerException() { assertNotNull(result); verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler1).handleFailure(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1), any(RuntimeException.class)); verify(mockHandler2).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); } @@ -206,6 +209,7 @@ public void testProcessFeatureRequestsWithHandlerReturnsFalse() { assertNotNull(result); verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler1).handleFailure(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1), isNull()); } @Test diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java index c2a241f821..22ed7269fe 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -34,6 +34,8 @@ import org.jivesoftware.openfire.session.LocalSession; import org.jivesoftware.openfire.session.ServerSession; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; @@ -55,6 +57,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -369,6 +372,27 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 "Expected no resource in authorization-identifier for non-Bind2 SASL2 case."); } + @Test + public void shouldEmbedFailedStreamResumeInSasl2Success() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", "invalid"); + resume.addAttribute("h", "not-a-number"); + session.setSessionData(SASLAuthentication.SASL2_RESUME_REQUEST, resume); + + SASLAuthentication.authenticationSuccessful(session, "romeo", "PLAIN", new byte[0], true); + + final ArgumentCaptor response = ArgumentCaptor.forClass(String.class); + verify(connection, times(1)).deliverRawText(response.capture()); + final Element success = DocumentHelper.parseText(response.getValue()).getRootElement(); + final Element failed = success.element(QName.get("failed", StreamManager.NAMESPACE_V3)); + assertNotNull(failed); + assertNotNull(failed.element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + /** * Verifies that authenticationSuccessful generates an anonymous auth token for a client with no username, using SASL2+Bind2, * and that the SASL2 success element contains a full JID authorization-identifier where node and resource are the same UUID. @@ -541,6 +565,127 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A } } + @Test + public void bind2ConflictFailsSasl2WithoutSuccessOrFeatures() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Bind2Request bind2Request = mock(Bind2Request.class); + when(bind2Request.generateResourceString(any())).thenReturn("conflicting-resource"); + session.setSessionData("bind2-request", bind2Request); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.CONFLICT)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName()); + assertEquals(SASL2_NAMESPACE, failure.getNamespaceURI()); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASL_NAMESPACE))); + verify(bind2Request, never()).processFeatureRequests(any(), any()); + assertFalse(session.isAuthenticated()); + } + + @Test + public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Bind2Request bind2Request = mock(Bind2Request.class); + when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); + session.setSessionData("bind2-request", bind2Request); + final CompletableFuture failedBind = new CompletableFuture<>(); + failedBind.completeExceptionally(new IllegalStateException("test failure")); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())).thenReturn(failedBind); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName()); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASL_NAMESPACE))); + verify(bind2Request, never()).processFeatureRequests(any(), any()); + assertFalse(session.isAuthenticated()); + } + + @Test + public void shouldEnableStreamManagementInlineWithSasl2AndBind2() throws Exception + { + try (final MockedStatic mockedEntityCaps = mockStatic(EntityCapabilitiesManager.class)) { + mockedEntityCaps.when(() -> EntityCapabilitiesManager.getLocalDomainVerHash(any())).thenReturn(null); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Connection connection = mock(Connection.class); + final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class); + when(configuration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(configuration.getCompressionPolicy()).thenReturn(Connection.CompressionPolicy.disabled); + when(connection.getConfiguration()).thenReturn(configuration); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + session.setSessionData("bind2-request", new Bind2Request("test-client", List.of(enable))); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + assertTrue(session.getStreamManager().isEnabled()); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + } + + @Test + public void failedInlineResumptionContinuesWithBind2AndFreshStreamManagement() throws Exception + { + try (final MockedStatic mockedEntityCaps = mockStatic(EntityCapabilitiesManager.class)) { + mockedEntityCaps.when(() -> EntityCapabilitiesManager.getLocalDomainVerHash(any())).thenReturn(null); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Connection connection = mock(Connection.class); + final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class); + when(configuration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(configuration.getCompressionPolicy()).thenReturn(Connection.CompressionPolicy.disabled); + when(connection.getConfiguration()).thenReturn(configuration); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", "invalid"); + resume.addAttribute("h", "0"); + session.setSessionData(SASLAuthentication.SASL2_RESUME_REQUEST, resume); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + session.setSessionData("bind2-request", new Bind2Request("test-client", List.of(enable))); + when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + assertNotNull(success.element(QName.get("failed", StreamManager.NAMESPACE_V3))); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + assertTrue(session.getStreamManager().isEnabled()); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + } + /** * Verifies that authenticationSuccessful marks the domain as validated for an inbound server session. */ diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2InlineFeaturesTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2InlineFeaturesTest.java new file mode 100644 index 0000000000..6c4fcbf7c0 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2InlineFeaturesTest.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.net; + +import org.dom4j.Element; +import org.dom4j.QName; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class Sasl2InlineFeaturesTest +{ + @Test + void advertisesStreamManagementResumption() + { + final Element authentication = SASLAuthentication.asSASLMechanismsElementForClientSessions(Set.of("PLAIN"), true); + + assertNotNull(authentication); + final Element inline = authentication.element("inline"); + assertNotNull(inline); + assertNotNull(inline.element(QName.get("sm", StreamManager.NAMESPACE_V3)), + "SASL2 inline features must advertise XEP-0198 resumption."); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java new file mode 100644 index 0000000000..064135ade1 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Sasl2SuccessTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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.jivesoftware.openfire.net; + +import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class Sasl2SuccessTest +{ + @Test + void usesResumedFullJidAsAuthorizationIdentifier() + { + final JID resumedAddress = new JID("romeo", "example.org", "balcony"); + + final String result = SASLAuthentication.authorizationIdentityForSasl2Success("romeo@example.org", resumedAddress); + + assertEquals("romeo@example.org/balcony", result); + } + + @Test + void retainsAuthenticatedIdentityWithoutResumption() + { + final String result = SASLAuthentication.authorizationIdentityForSasl2Success("romeo@example.org", null); + + assertEquals("romeo@example.org", result); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java index 81389891bd..4343de9f11 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerTest.java @@ -15,6 +15,8 @@ */ package org.jivesoftware.openfire.net; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; import org.dom4j.io.XMPPPacketReader; import org.jivesoftware.Fixtures; import org.jivesoftware.openfire.Connection; @@ -25,19 +27,28 @@ import org.jivesoftware.openfire.sasl.AnonymousSaslServer; import org.jivesoftware.openfire.session.LocalClientSession; import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.util.JiveGlobals; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.xmpp.packet.Message; +import org.xmpp.packet.Packet; import java.util.Arrays; import java.util.Locale; import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentCaptor.forClass; +import org.mockito.ArgumentCaptor; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -45,6 +56,92 @@ */ public class StanzaHandlerTest { + @Test + public void adoptsSessionThatOwnsResumedSasl2Connection() + { + final Connection connection = mock(Connection.class); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + final LocalClientSession resumedSession = mock(LocalClientSession.class); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), resumedSession); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); + handler.setSession(connectionProvider); + + handler.adoptSasl2ResumedSession(); + + assertSame(resumedSession, handler.session); + } + + @Test + public void successfulInlineResumptionDoesNotSendStreamFeatures() + { + final Connection connection = mock(Connection.class); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + final LocalClientSession session = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn(Fixtures.XMPP_DOMAIN); + final org.jivesoftware.openfire.streammanagement.StreamManager streamManager = + new org.jivesoftware.openfire.streammanagement.StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); + final Element features = DocumentHelper.createElement("features"); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { + @Override + protected Element generateFeatures() { + return features; + } + }; + handler.setSession(connectionProvider); + handler.adoptSasl2ResumedSession(); + + handler.sasl2Successful(); + + verify(connection, never()).deliverRawText(features.asXML()); + } + + @Test + public void successfulInlineResumptionPrunesAcknowledgedStanzasAndReplaysTheRemainder() throws Exception + { + final Connection connection = mock(Connection.class); + final LocalClientSession session = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn(Fixtures.XMPP_DOMAIN); + when(session.isAuthenticated()).thenReturn(true); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + streamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + final Message acknowledged = new Message(); + acknowledged.setID("acknowledged"); + final Message unacknowledged = new Message(); + unacknowledged.setID("unacknowledged"); + streamManager.sentStanza(acknowledged); + streamManager.sentStanza(unacknowledged); + streamManager.process(DocumentHelper.parseText("").getRootElement()); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData(SASLAuthentication.SASL2_RESUMPTION_RESULT)).thenReturn(result); + final Element features = DocumentHelper.createElement("features"); + final ClientStanzaHandler handler = new ClientStanzaHandler(mock(PacketRouter.class), connection) { + @Override + protected Element generateFeatures() { + return features; + } + }; + handler.setSession(connectionProvider); + handler.adoptSasl2ResumedSession(); + + handler.sasl2Successful(); + + final ArgumentCaptor replayed = forClass(Packet.class); + verify(connection).deliver(replayed.capture()); + assertEquals("unacknowledged", replayed.getValue().getID()); + verify(connection, never()).deliverRawText(features.asXML()); + } + @BeforeAll public static void setupClass() throws Exception { diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java index b0b4ba1ec6..4a07aaf498 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLIntegrationTest.java @@ -26,6 +26,8 @@ import org.jivesoftware.openfire.lockout.LockOutManager; import org.jivesoftware.openfire.lockout.LockOutProvider; import org.jivesoftware.openfire.net.SASLAuthentication; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.handler.Bind2StreamManagementHandler; import org.jivesoftware.openfire.session.LocalClientSession; import org.jivesoftware.openfire.session.LocalIncomingServerSession; import org.jivesoftware.openfire.session.LocalSession; @@ -42,11 +44,13 @@ import org.mockito.quality.Strictness; import org.xmpp.packet.JID; import org.jivesoftware.util.cache.CacheFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -793,6 +797,47 @@ public void testAuthenticationWithSASL2AndBind2IncludesResource() throws Excepti verify(clientSession).setAuthToken(any(AuthToken.class)); } + @Test + public void testSasl2MechanismBind2AndStreamManagementEnableAreProcessedTogether() throws Exception { + final SessionManager sessionManager = mock(SessionManager.class); + when(xmppServer.getSessionManager()).thenReturn(sessionManager); + when(sessionManager.bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + when(clientSession.getStatus()).thenReturn(org.jivesoftware.openfire.session.Session.Status.CONNECTED); + when(clientSession.getAuthToken()).thenReturn(AuthToken.generateUserToken("test-user")); + when(clientSession.getAvailableStreamFeatures()).thenReturn(Collections.emptyList()); + final StreamManager streamManager = new StreamManager(clientSession); + when(clientSession.getStreamManager()).thenReturn(streamManager); + final AtomicBoolean authenticated = new AtomicBoolean(); + when(clientSession.isAuthenticated()).thenAnswer(invocation -> authenticated.get()); + doAnswer(invocation -> { + if (invocation.getArgument(0) == org.jivesoftware.openfire.session.Session.Status.AUTHENTICATED) { + authenticated.set(true); + } + return null; + }).when(clientSession).setStatus(any()); + sessionDataMap.put(SASLAuthentication.AVAILABLE_MECHANISMS_FOR_SESSION, Set.of("TEST-MECHANISM")); + Bind2Request.registerElementHandler(new Bind2StreamManagementHandler()); + try { + final Element authenticate = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + authenticate.addElement(QName.get("bind", "urn:xmpp:bind:0")) + .addElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + final SASLAuthentication.Status status = SASLAuthentication.handle(clientSession, authenticate, true); + + assertEquals(SASLAuthentication.Status.authenticatedAwaitingFeatures, status); + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(clientSession, times(2)).deliverRawText(delivered.capture()); + final Element success = DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement(); + final Element bound = success.element(QName.get("bound", "urn:xmpp:bind:0")); + assertNotNull(bound); + assertNotNull(bound.element(QName.get("enabled", StreamManager.NAMESPACE_V3))); + } finally { + Bind2Request.unregisterElementHandler(StreamManager.NAMESPACE_V3); + } + } + @Test public void testSasl2DomainQualifiedAuthzidIsNormalized() throws Exception { // Setup test fixture: SASL yields an authzid that already carries a domain-part. diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java new file mode 100644 index 0000000000..37aafcf289 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalSessionSasl2ResumeTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved. + * + * Licensed 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 + */ +package org.jivesoftware.openfire.session; + +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.QName; +import org.jivesoftware.Fixtures; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.SessionManager; +import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.RoutingTable; +import org.jivesoftware.openfire.auth.AuthToken; +import org.jivesoftware.openfire.spi.BasicStreamIDFactory; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +class LocalSessionSasl2ResumeTest +{ + private XMPPServer server; + private SessionManager sessionManager; + + @BeforeEach + void setUp() + { + server = Fixtures.mockXMPPServer(); + XMPPServer.setInstance(server); + sessionManager = server.getSessionManager(); + } + + @Test + void validatesAndTransfersAResumedSessionFromTheInlineRequest() + { + final Connection oldConnection = mock(Connection.class); + final LocalClientSession resumed = new LocalClientSession(Fixtures.XMPP_DOMAIN, oldConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + resumed.setAddress(new JID("romeo", Fixtures.XMPP_DOMAIN, "balcony")); + resumed.setAuthToken(AuthToken.generateUserToken("romeo")); + resumed.setStatus(Session.Status.AUTHENTICATED); + final Element enabled = resumed.getStreamManager().enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + when(sessionManager.isDetached(resumed)).thenReturn(true); + final RoutingTable routingTable = mock(RoutingTable.class); + when(server.getRoutingTable()).thenReturn(routingTable); + when(routingTable.getClientRoute(resumed.getAddress())).thenReturn(resumed); + + final Connection newConnection = mock(Connection.class); + final LocalClientSession connectionProvider = new LocalClientSession(Fixtures.XMPP_DOMAIN, newConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + connectionProvider.setAuthToken(AuthToken.generateUserToken("romeo")); + final Element resume = DocumentHelper.createElement(QName.get("resume", StreamManager.NAMESPACE_V3)); + resume.addAttribute("previd", enabled.attributeValue("id")); + resume.addAttribute("h", "0"); + + final StreamManager.Sasl2ResumeResult result = connectionProvider.getStreamManager().processSasl2Resume(resume); + + assertTrue(result.isResumed()); + assertEquals(resumed, result.getResumedSession()); + assertEquals("resumed", result.getResponse().getName()); + assertEquals(enabled.attributeValue("id"), result.getResponse().attributeValue("previd")); + assertEquals("0", result.getResponse().attributeValue("h")); + assertEquals(newConnection, resumed.getConnection()); + verify(newConnection).reinit(resumed); + } + + @Test + void transfersConnectionOwnershipAndRemovesTheProviderSession() + { + final Connection oldConnection = mock(Connection.class); + when(oldConnection.isClosed()).thenReturn(true); + final LocalClientSession resumed = new LocalClientSession(Fixtures.XMPP_DOMAIN, oldConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + resumed.setAddress(new JID("romeo", Fixtures.XMPP_DOMAIN, "balcony")); + resumed.setAuthToken(AuthToken.generateUserToken("romeo")); + resumed.setStatus(Session.Status.AUTHENTICATED); + resumed.getStreamManager().enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + + final Connection newConnection = mock(Connection.class); + final LocalClientSession connectionProvider = new LocalClientSession(Fixtures.XMPP_DOMAIN, newConnection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + + resumed.reattachForSasl2(connectionProvider); + + assertEquals(newConnection, resumed.getConnection()); + assertNull(connectionProvider.getConnection()); + assertEquals(Session.Status.AUTHENTICATED, resumed.getStatus()); + verify(newConnection).reinit(resumed); + verify(sessionManager).removeDetached(resumed); + verify(sessionManager, atLeastOnce()).removeSession(connectionProvider); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java index 73a0e5fc6c..7c8e3304a8 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/streammanagement/StreamManagerTest.java @@ -15,12 +15,21 @@ */ package org.jivesoftware.openfire.streammanagement; +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.QName; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.session.LocalClientSession; import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; +import org.xmpp.packet.PacketError; import java.math.BigInteger; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; /** * Unit tests that verify the implementation of {@link StreamManager}. @@ -29,6 +38,18 @@ */ public class StreamManagerTest { + @Test + public void sasl2FailureBuildsEmbeddableFailedElement() { + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.failed( + StreamManager.NAMESPACE_V3, PacketError.Condition.bad_request); + + assertFalse(result.isResumed()); + assertNull(result.getResumedSession()); + assertEquals("failed", result.getResponse().getName()); + assertEquals(StreamManager.NAMESPACE_V3, result.getResponse().getNamespaceURI()); + assertNotNull(result.getResponse().element(QName.get("bad-request", "urn:ietf:params:xml:ns:xmpp-stanzas"))); + } + @Test public void testValidateClientAcknowledgement() throws Exception { @@ -326,4 +347,64 @@ public void testValidateClientAcknowledgement_rollover_edgecase5_unsent() throws // Verify results. assertFalse(result); } + + @Test + public void testFeatureElementHasCorrectName() { + // Execute system under test. + final Element feature = StreamManager.featureElement(); + + // Verify results. + assertNotNull(feature); + assertEquals("sm", feature.getName()); + } + + @Test + public void testFeatureElementHasCorrectNamespace() { + // Execute system under test. + final Element feature = StreamManager.featureElement(); + + // Verify results. + assertNotNull(feature); + assertEquals(StreamManager.NAMESPACE_V3, feature.getNamespaceURI()); + } + + @Test + public void testFeatureElementIsDistinctOnEachCall() { + // Execute system under test. + final Element feature1 = StreamManager.featureElement(); + final Element feature2 = StreamManager.featureElement(); + + // Verify results: each call returns a new element instance. + assertNotSame(feature1, feature2); + } + + @Test + public void failedSasl2ResumeResultDoesNotCompleteResumption() { + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.failed( + StreamManager.NAMESPACE_V3, PacketError.Condition.item_not_found); + + assertFalse(result.completeAfterSuccess()); + } + + @Test + public void successfulSasl2ResumeResultCompletesOnlyOnce() { + final LocalClientSession session = mock(LocalClientSession.class); + final Connection connection = mock(Connection.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn("example.org"); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + when(session.isAuthenticated()).thenReturn(true); + streamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + clearInvocations(session, connection); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement(QName.get("resumed", StreamManager.NAMESPACE_V3)), session); + + assertTrue(result.completeAfterSuccess()); + verify(session).deliverRawText(""); + clearInvocations(session, connection); + + assertTrue(result.completeAfterSuccess()); + verifyNoInteractions(session, connection); + } } diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java index 14c39a171a..dacb65bbd7 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandlerTest.java @@ -16,11 +16,16 @@ package org.jivesoftware.openfire.websocket; import org.dom4j.*; +import org.jivesoftware.openfire.PacketRouter; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; /** * Verifies the implementation of {@link WebSocketClientStanzaHandler} @@ -29,6 +34,41 @@ */ public class WebSocketClientStanzaHandlerTest { + @Test + public void successfulInlineResumptionDoesNotSendWebSocketStreamFeatures() + { + final WebSocketConnection connection = mock(WebSocketConnection.class); + final LocalClientSession session = mock(LocalClientSession.class); + final LocalClientSession connectionProvider = mock(LocalClientSession.class); + when(session.getConnection()).thenReturn(connection); + when(session.getServerName()).thenReturn("example.org"); + final StreamManager streamManager = new StreamManager(session); + when(session.getStreamManager()).thenReturn(streamManager); + final StreamManager.Sasl2ResumeResult result = StreamManager.Sasl2ResumeResult.resumed( + DocumentHelper.createElement("resumed"), session); + when(connectionProvider.removeSessionData("sasl2-resumption-result")).thenReturn(result); + class TestHandler extends WebSocketClientStanzaHandler { + TestHandler(WebSocketConnection testConnection) { + super(mock(PacketRouter.class), testConnection); + } + + void adoptResumption() { + adoptSasl2ResumedSession(); + } + + void completeSasl2() { + sasl2Successful(); + } + } + final TestHandler handler = new TestHandler(connection); + handler.setSession(connectionProvider); + handler.adoptResumption(); + + handler.completeSasl2(); + + verify(connection, never()).deliverRawText(anyString()); + } + /** * It is desired to collapse the 'open' element that's send as part of the websocket data exchange. This test * verifies that the {@link WebSocketClientStanzaHandler#withoutDeclaration(Document)} does not return an expanded