diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java index 59208d01ad..91349d8ed1 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java @@ -38,6 +38,8 @@ 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.TerminationDelegate; import org.jivesoftware.util.*; import org.jivesoftware.util.cache.*; @@ -270,6 +272,7 @@ public class SessionManager extends BasicModule implements ClusterEventListener private RoutingTable routingTable; private StreamIDFactory streamIDFactory; + private Bind2StreamManagementHandler bind2StreamManagementHandler; /** * Returns the instance of SessionManagerImpl being used by the XMPPServer. @@ -1897,10 +1900,15 @@ private Message createServerMessage(String subject, String body) { } @Override - public void start() throws IllegalStateException { + public synchronized void start() throws IllegalStateException { super.start(); localSessionManager.start(); + // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. + final Bind2StreamManagementHandler localHandler = new Bind2StreamManagementHandler(); + Bind2Request.registerElementHandler(localHandler); + bind2StreamManagementHandler = localHandler; // Only dereference any previous handler after registration succeeds, otherwise that previous handler can never be removed again. + // 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. @@ -1915,8 +1923,14 @@ public void start() throws IllegalStateException { } @Override - public void stop() { + public synchronized void stop() { Log.debug("SessionManager: Stopping server"); + + if (bind2StreamManagementHandler != null) { + Bind2Request.unregisterElementHandler(bind2StreamManagementHandler); + bind2StreamManagementHandler = null; + } + // 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..b55a83b2e2 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -0,0 +1,101 @@ +/* + * 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.openfire.net.Bind2InlineHandler; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManagementException; +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("Received unexpected element '{}'; ignoring.", element.getName()); + return false; + } + final String resumeAttr = element.attributeValue("resume"); + final boolean resume = "true".equalsIgnoreCase(resumeAttr) || "1".equals(resumeAttr) || "yes".equalsIgnoreCase(resumeAttr); + bound.add(session.getStreamManager().enableAndBuildElement(getNamespace(), resume)); + return true; + } + + /** + * Adds the {@code } element that XEP-0198 § 9.1.1 requires inside {@code } when stream + * management could not be enabled. + * + * The condition is taken from the failure itself where one is available. A handler that declined the request + * without failing did so because the element was not an {@code }, which is a malformed request. + */ + @Override + public void handleFailure(LocalClientSession session, Element bound, Element element, Exception cause) { + final PacketError.Condition condition; + if (cause instanceof StreamManagementException sme) { + condition = sme.getCondition(); + } else if (cause == null) { + condition = PacketError.Condition.bad_request; + } else { + condition = PacketError.Condition.internal_server_error; + } + bound.addElement("failed", StreamManager.NAMESPACE_V3) + .addElement(condition.toXMPP(), "urn:ietf:params:xml:ns:xmpp-stanzas"); + } +} 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 41d6d7d0f6..3883f3efe0 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -43,6 +43,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmpp.packet.JID; +import org.xmpp.packet.StreamError; import javax.annotation.Nonnull; import javax.security.sasl.Sasl; @@ -642,15 +643,24 @@ else if ( decoded.length == 0 ) { failure = Failure.NOT_AUTHORIZED; } - SaslOutcome.authenticationFailed( session, failure, usingSASL2 ); - session.removeSessionData( "SaslServer" ); + + if (usingSASL2) { + abortSasl2(session, failure); + } else { + SaslOutcome.authenticationFailed(session, failure, usingSASL2); + session.removeSessionData("SaslServer"); + } return Status.failed; } catch( Exception ex ) { Log.warn( "An unexpected exception occurred during SASL negotiation. Affected session: {}", session, ex ); - SaslOutcome.authenticationFailed( session, Failure.NOT_AUTHORIZED, usingSASL2 ); - session.removeSessionData( "SaslServer" ); + if (usingSASL2) { + abortSasl2(session, Failure.NOT_AUTHORIZED); + } else { + SaslOutcome.authenticationFailed(session, Failure.NOT_AUTHORIZED, usingSASL2); + session.removeSessionData("SaslServer"); + } return Status.failed; } } @@ -804,7 +814,6 @@ else if (session instanceof LocalIncomingServerSession serverSession) { fastToken = FastSessionState.getRotatedToken(session); } FastSessionState.clearAuthenticationAttempt(session); - clientSession.setAuthToken(clientAuthToken); final FastToken finalFastToken = fastToken; final Bind2Request bind2Request = (Bind2Request) session.getSessionData("bind2-request"); @@ -812,43 +821,36 @@ else if (session instanceof LocalIncomingServerSession serverSession) { clientSession.removeSessionData("bind2-request"); final UserAgentInfo userAgentInfo = (UserAgentInfo) session.getSessionData("user-agent-info"); final String resource = bind2Request.generateResourceString(userAgentInfo); - final AuthToken authToken = clientSession.getAuthToken(); - SessionManager.getInstance().bindResource(clientSession, authToken, resource) - .whenComplete((result, throwable) -> { - try { + final JID preBindAddress = clientSession.getAddress(); + + if (clientAuthToken.isAnonymous()) { + // An anonymous session needs no conflict resolution: its node-part and resource are both the session's + // own generated identifier, so no other session can hold the same full JID. SessionManager#bindResource + // documents this and dereferences the (null) username, so it must not be used here. Note that this + // discards the resource that Bind2 generated; XEP-0386 leaves the assigned resource to the server. + clientSession.setAnonymousAuth(); + final JID bound = clientSession.getAddress(); + completeSasl2Bind2(clientSession, bind2Request, successData, finalFastToken, bound.toBareJID(), bound.getResource(), preBindAddress); + } else { + // A non-anonymous session performs regular resource binding. + final String bareJid = new JID(clientAuthToken.getUsername(), XMPPServer.getInstance().getServerInfo().getXMPPDomain(), null, true).toString(); + SessionManager.getInstance().bindResource(clientSession, clientAuthToken, resource) + .whenComplete((result, throwable) -> { if (throwable != null) { Log.warn("An exception occurred while binding resource '{}' for session '{}' during SASL2+Bind2 authentication.", resource, clientSession, throwable); } - final boolean bound = throwable == null && result == SessionManager.BindResult.BOUND; - if (!bound) { + if (throwable != null || result != SessionManager.BindResult.BOUND) { Log.warn("Unable to bind resource '{}' for session '{}' during SASL2+Bind2 authentication. Bind result: {}", resource, clientSession, result); - SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); + abortSasl2(clientSession, Failure.TEMPORARY_AUTH_FAILURE); return; } - final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, resource, finalFastToken); - clientSession.setStatus(Session.Status.AUTHENTICATED); - bind2Request.processFeatureRequests(clientSession, success); - SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound); - - // Deliver stream features now that has been sent. - final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); - final List specificFeatures = clientSession.getAvailableStreamFeatures(); - if (specificFeatures != null) { - for (final org.dom4j.Element feature : specificFeatures) { - features.add(feature); - } - } - // Deliver these here. - clientSession.deliverRawText(success.asXML()); - clientSession.deliverRawText(features.asXML()); - } catch(Exception e) { - Log.warn("An exception occurred while processing SASL2+Bind2 for '{}' during SASL2+Bind2 authentication.", clientSession, e); - SaslOutcome.authenticationFailed(clientSession, Failure.TEMPORARY_AUTH_FAILURE, true); - } - }); - // Response and features are sent asynchronously from the completion stage. + clientSession.setAuthToken(clientAuthToken); + completeSasl2Bind2(clientSession, bind2Request, successData, finalFastToken, bareJid, resource, preBindAddress); + }); + } } else { // No Bind2 request, or session already authenticated: send synchronously without . + clientSession.setAuthToken(clientAuthToken); final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, null, finalFastToken); session.deliverRawText(success.asXML()); } @@ -868,6 +870,95 @@ private static FastToken issueFastToken(final String username, final String clie return FastTokenManager.issueToken(username, clientId, mechanism); } + /** + * Aborts the SASL2 authentication process for a given session and handles the failure scenario. + * + * @param session The LocalSession object representing the session. Must not be null. + * @param failure The Failure object representing the reason for the authentication failure. Must not be null. + */ + private static void abortSasl2(@Nonnull final LocalSession session, @Nonnull final Failure failure) + { + if (session instanceof LocalClientSession clientSession) { + clientSession.setAuthToken(null); + } + session.removeSessionData("bind2-request"); + session.removeSessionData("user-agent-info"); + session.removeSessionData("SaslServer"); + FastSessionState.clearAuthenticationAttempt(session); + SaslOutcome.authenticationFailed(session, failure, true); + } + + /** + * Completes a SASL2 negotiation for which a resource has been bound: renders and delivers {@code }, + * then the post-authentication stream features. + *

+ * Failure is handled differently either side of the {@code } write. Before it, the peer has not been + * told anything, so the bind is undone and the negotiation fails. After it, authentication genuinely succeeded + * and the session is live and routable. A failure then is a stream-level problem rather than a SASL one. + * + * @param clientSession The LocalClientSession object representing the client session. Must not be null. + * @param bind2Request The Bind2Request object representing the bind request. Must not be null. + * @param successData The byte array representing the success data. + * @param fastToken The FastToken if one was issued. + * @param authorizationIdentity the bare JID authorization identity (e.g. user@domain or uuid@domain for anonymous). + * @param resource the bound resource, or null if no resource was bound. + * @param preBindAddress The session's address prior to the binding attempt. Must not be null. + */ + private static void completeSasl2Bind2(@Nonnull final LocalClientSession clientSession, + @Nonnull final Bind2Request bind2Request, + final byte[] successData, + final FastToken fastToken, + final String authorizationIdentity, + final String resource, + @Nonnull final JID preBindAddress) + { + boolean successDelivered = false; + try + { + final Element success = SaslOutcome.buildSasl2SuccessElement(successData, authorizationIdentity, resource, fastToken); + bind2Request.processFeatureRequests(clientSession, success); + clientSession.deliverRawText(success.asXML()); + successDelivered = true; + + SessionEventDispatcher.dispatchEvent(clientSession, SessionEventDispatcher.EventType.resource_bound); + + // Deliver stream features now that has been sent. + final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); + final List specificFeatures = clientSession.getAvailableStreamFeatures(); + if (specificFeatures != null) { + specificFeatures.forEach(features::add); + } + clientSession.deliverRawText(features.asXML()); + } + catch (final Exception e) + { + if (successDelivered) { + Log.warn("An exception occurred after SASL2+Bind2 success was delivered to '{}'. The session is authenticated and bound, so it is closed with a stream error rather than failed.", clientSession, e); + clientSession.close(new StreamError(StreamError.Condition.internal_server_error, "An error occurred while completing resource binding.")); + } else { + Log.warn("An exception occurred while processing SASL2+Bind2 for '{}'. Undoing the resource binding.", clientSession, e); + unwindBind(clientSession, preBindAddress); + abortSasl2(clientSession, Failure.TEMPORARY_AUTH_FAILURE); + } + } + } + + /** + * Reverses the session state installed by a successful resource binding, returning the session to the + * pre-binding state in which another SASL2 negotiation can be attempted. + * + * @param clientSession The LocalClientSession object representing the client session. Must not be null. + * @param preBindAddress The session's address prior to the binding attempt. Must not be null. + */ + private static void unwindBind(@Nonnull final LocalClientSession clientSession, @Nonnull final JID preBindAddress) + { + // removeSession reads the auth token to decide which session-destroyed event to fire, so it must run before + // abortSasl2 clears that token - otherwise a named session is reported as an anonymous one. + SessionManager.getInstance().removeSession(clientSession); + clientSession.setStatus(Session.Status.CONNECTED); + clientSession.setAddress(preBindAddress); + } + /** * Adds a new SASL mechanism to the list of supported SASL mechanisms by the server. The * new mechanism will be offered to clients and connection managers as stream features.

diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java index 704c537f37..a0ba40ba3e 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java @@ -831,16 +831,22 @@ else if (this.presence.isAvailable()) { @Override public List getAvailableStreamFeatures() { + final Connection connection = conn; + if (connection == null) { + // Detached or closed: there is no stream on which to advertise anything. + return Collections.emptyList(); + } + // Offer authenticate and registration only if TLS was not required or if required // then the connection is already encrypted - if (conn.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !conn.isEncrypted()) { + if (connection.getConfiguration().getTlsPolicy() == Connection.TLSPolicy.required && !connection.isEncrypted()) { return Collections.emptyList(); } final List result = new LinkedList<>(); // Include Stream Compression Mechanism - if (conn.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !conn.isCompressed()) { + if (connection.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !connection.isCompressed()) { final Element compression = DocumentHelper.createElement(QName.get("compression", "http://jabber.org/features/compress")); compression.addElement("method").addText("zlib"); result.add(compression); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.java new file mode 100644 index 0000000000..6e3cc38a93 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManagementException.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.streammanagement; + +import org.xmpp.packet.PacketError; + +import javax.annotation.Nonnull; + +/** + * Thrown when stream management could not be enabled for a session, carrying the error condition that XEP-0198 § 6 + * requires to be reported to the peer. + * + * The condition travels with the exception so that a caller which embeds the outcome in a larger response — such as a + * Bind2 inline request — can report the same reason that a standalone request would have received. + */ +public class StreamManagementException extends RuntimeException +{ + private final PacketError.Condition condition; + + public StreamManagementException(@Nonnull final PacketError.Condition condition, @Nonnull final String message) { + super(message); + this.condition = condition; + } + + @Nonnull + public PacketError.Condition getCondition() { + return condition; + } +} 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..2f365f2e78 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/streammanagement/StreamManager.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017-2025 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2017-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. @@ -243,24 +243,45 @@ private boolean allowResume() { * @param resume Whether the client is requesting a resumable session. */ private void enable( String namespace, boolean resume ) + { + final Element outcome; + try { + outcome = enableAndBuildElement(namespace, resume); + } catch (final StreamManagementException e) { + Log.debug("Unable to enable stream management for session {}: {}", session, e.getMessage()); + session.deliverRawText(buildFailedElement(namespace, e.getCondition()).asXML()); + return; + } + session.deliverRawText(outcome.asXML()); + } + + /** + * Enables stream management, returning the {@code } element rather than sending it. + * + * Leaving delivery to the caller allows an inline caller to embed the element in its enclosing response, as + * XEP-0198 § 9.1 requires of a Bind2 inline request. + * + * @param namespace the SM namespace to use + * @param resume whether the client requests a resumable session + * @return the {@code } element + * @throws StreamManagementException when stream management could not be enabled, carrying the condition to report + */ + @Nonnull + public Element enableAndBuildElement( String namespace, boolean resume ) throws StreamManagementException { boolean offerResume = allowResume(); - // Ensure that resource binding has occurred. if (!session.isAuthenticated()) { - this.namespace = namespace; - sendUnexpectedError(); - return; + throw new StreamManagementException(PacketError.Condition.unexpected_request, + "Stream management cannot be enabled before the session is authenticated."); } String smId = null; - synchronized ( this ) { - // Do nothing if already enabled if ( isEnabled() ) { - sendUnexpectedError(); - return; + throw new StreamManagementException(PacketError.Condition.unexpected_request, + "Stream management is already enabled for this session."); } this.namespace = namespace; @@ -271,7 +292,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,7 +310,7 @@ private void enable( String namespace, boolean resume ) } } } - session.deliverRawText(enabled.asXML()); + return enabled; } private void startResume(String namespace, String previd, long h) { @@ -456,12 +477,24 @@ 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. } + /** + * Constructs an XML element representing a failed stream management negotiation. + * + * @param namespace The namespace indicating the version of stream management being used. Must not be null. + * @param condition The specific error condition that caused the failure. Must not be null. + * @return An XML {@code } element containing information about the failure. + */ + private static Element buildFailedElement(@Nonnull final String namespace, @Nonnull final 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. 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..c044c8008c --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementAvailabilityTest.java @@ -0,0 +1,82 @@ +/* + * 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 +{ + private Bind2StreamManagementHandler handler; + + @BeforeAll + static void configureOpenfire() throws Exception + { + Fixtures.reconfigureOpenfireHome(); + Fixtures.disableDatabasePersistence(); + } + + @AfterAll + static void clearProperties() + { + Fixtures.clearExistingProperties(); + } + + @BeforeEach + void registerHandler() + { + handler = new Bind2StreamManagementHandler(); + Bind2Request.registerElementHandler(handler); + } + + @AfterEach + void restoreState() + { + StreamManager.ACTIVE.setValue(StreamManager.ACTIVE.getDefaultValue()); + Bind2Request.unregisterElementHandler(handler); + handler = null; + } + + @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..783870e8dd --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -0,0 +1,219 @@ +/* + * 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.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.streammanagement.StreamManagementException; +import org.jivesoftware.openfire.streammanagement.StreamManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xmpp.packet.PacketError; + +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")); + } + + /** + * Verifies that a value which is not a lexical representation of xs:boolean does not request resumption. + * + * XEP-0198 § 3 note 5 admits only "true"/"1" and "false"/"0". Treating anything else as an affirmative would let a + * client believe it had a resumable stream when a conforming server would not have given it one. + */ + @Test + public void testResumeIsNotRequestedByANonBooleanValue() { + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + enable.addAttribute("resume", "foobar"); + when(mockStreamManager.enableAndBuildElement(StreamManager.NAMESPACE_V3, false)) + .thenReturn(DocumentHelper.createElement(QName.get("enabled", StreamManager.NAMESPACE_V3))); + + handler.handleElement(mockSession, boundElement, enable); + + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, false); + } + + @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(QName.get("enabled", StreamManager.NAMESPACE_V3))); + + // Execute + final boolean result = handler.handleElement(mockSession, boundElement, enableElement); + + // Verify + assertTrue(result); + verify(mockStreamManager).enableAndBuildElement(StreamManager.NAMESPACE_V3, true); + } + + /** + * Verifies that a failure to enable stream management propagates with the condition that XEP-0198 requires, and is + * not delivered to the peer as a standalone stanza. + * + * The enclosing Bind2 request catches the failure and asks the handler to embed it in {@code }, as + * XEP-0198 § 9.1.1 requires. Sending it separately would put a {@code } on the stream before the + * {@code } that should contain it. + */ + @Test + public void testEnableFailurePropagatesWithoutStandaloneDelivery() { + // Setup test fixture: a session that has not authenticated. + final StreamManager streamManager = new StreamManager(mockSession); + when(mockSession.getStreamManager()).thenReturn(streamManager); + when(mockSession.isAuthenticated()).thenReturn(false); + final Element enable = DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)); + + // Execute system under test. + final StreamManagementException e = assertThrows(StreamManagementException.class, + () -> handler.handleElement(mockSession, boundElement, enable)); + + // Verify result. + assertEquals(PacketError.Condition.unexpected_request, e.getCondition(), + "An enable request before authentication must report unexpected-request."); + assertTrue(boundElement.elements().isEmpty(), "Nothing may be added to when enabling failed."); + verify(mockSession, never()).deliverRawText(anyString()); + } + + /** + * Verifies that the condition carried by a failure reaches the {@code } element, rather than being + * replaced by a generic one. + */ + @Test + public void testFailureConditionIsPreservedInTheBoundElement() { + // Setup test fixture. + final StreamManagementException cause = new StreamManagementException( + PacketError.Condition.unexpected_request, "already enabled"); + + // Execute system under test. + handler.handleFailure(mockSession, boundElement, DocumentHelper.createElement(QName.get("enable", StreamManager.NAMESPACE_V3)), cause); + + // Verify 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")), + "The condition that caused the failure must be reported, not a generic one."); + } + + @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/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java index 5fc741a1a0..a3fc82dc06 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -47,6 +47,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import org.xmpp.packet.JID; import javax.security.sasl.SaslServer; import java.util.*; @@ -60,6 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -312,10 +314,8 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 when(bind2Request.generateResourceString(any())).thenReturn(anonymousUsername); session.setSessionData("bind2-request", bind2Request); - // Stub SessionManager.bindResource to complete successfully (synchronously). final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); - when(sessionManager.bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + // Deliberately no bindResource stub: an anonymous session must not be bound through SessionManager. // Execute system under test. SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0], true); @@ -337,6 +337,9 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNullWithSasl2 final String responseValue2 = response.getAllValues().get(1); assertTrue(responseValue2.contains(" failedBind = new CompletableFuture<>(); failedBind.completeExceptionally(new IllegalStateException("test failure")); - when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())).thenReturn(failedBind); + when(XMPPServer.getInstance().getSessionManager().bindResource(notNull(), notNull(), notNull())).thenReturn(failedBind); // Execute system under test. SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); @@ -404,6 +408,7 @@ public void bind2ExceptionFailsSasl2WithoutSuccessOrFeatures() throws Exception "An unexpected failure to bind is not permanent, so the client must be told it may try again."); verify(bind2Request, never()).processFeatureRequests(any(), any()); assertFalse(session.isAuthenticated(), "A session whose resource could not be bound must not be authenticated."); + assertNull(session.getAuthToken(), "A session whose resource could not be bound must not have an auth token."); } /** @@ -423,8 +428,8 @@ public void bind2InlineHandlersRunAgainstAnAuthenticatedSession() throws Excepti final Bind2Request bind2Request = mock(Bind2Request.class); when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); session.setSessionData("bind2-request", bind2Request); - when(XMPPServer.getInstance().getSessionManager().bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); + stubSuccessfulBind(sessionManager); final AtomicReference statusWhenHandlersRan = new AtomicReference<>(); when(bind2Request.processFeatureRequests(any(), any())).thenAnswer(invocation -> { @@ -532,8 +537,7 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A // Stub SessionManager.bindResource to complete successfully (synchronously). final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); - when(sessionManager.bindResource(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + stubSuccessfulBind(sessionManager); // Execute system under test. SASLAuthentication.authenticationSuccessful(session, username, "PLAIN", new byte[0], true); @@ -555,9 +559,125 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvidedWithSasl2A final String responseValue2 = response.getAllValues().get(1); assertTrue(responseValue2.contains(" token = ArgumentCaptor.forClass(AuthToken.class); + verify(sessionManager).bindResource(same(session), token.capture(), eq("test-resource")); + assertNotNull(token.getValue(), "The bind must be driven by an authentication token, not null."); + assertFalse(token.getValue().isAnonymous(), "Expected the token of the user that authenticated."); + assertEquals("testuser", token.getValue().getUsername(), "Expected the token of the user that authenticated."); + } + + /** + * Verifies that a failure occurring after the resource was bound, but before {@code } reached the peer, + * returns the session to its pre-binding state. + * + * The peer has been told the negotiation failed and may attempt another one. A session left authenticated, bound + * and holding a route would have that retry skip binding altogether, and would leave a routable session for an + * authentication the peer believes did not happen. + */ + @Test + public void bind2FailureBeforeSuccessUnwindsTheBind() throws Exception + { + // Setup test fixture. + final Connection connection = mock(Connection.class); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, + new BasicStreamIDFactory().createStreamID(), Locale.ENGLISH); + final JID preBindAddress = session.getAddress(); + final Bind2Request bind2Request = mock(Bind2Request.class); + when(bind2Request.generateResourceString(any())).thenReturn("test-resource"); + when(bind2Request.processFeatureRequests(any(), any())).thenThrow(new IllegalStateException("test failure")); + session.setSessionData("bind2-request", bind2Request); + final SessionManager sessionManager = XMPPServer.getInstance().getSessionManager(); + stubSuccessfulBind(sessionManager); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + // Verify result. + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection).deliverRawText(delivered.capture()); + final Element failure = DocumentHelper.parseText(delivered.getValue()).getRootElement(); + assertEquals("failure", failure.getName(), "Expected the negotiation to fail, as was never delivered."); + assertNotNull(failure.element(QName.get("temporary-auth-failure", SASLAuthentication.SASL_NAMESPACE)), + "The client may retry, so the failure must not be permanent."); + verify(sessionManager).removeSession(session); + assertNull(session.getAuthToken(), "An undone bind must not leave an authentication token behind."); + assertEquals(Session.Status.CONNECTED, session.getStatus(), "An undone bind must not leave the session authenticated."); + assertSame(preBindAddress, session.getAddress(), "An undone bind must restore the pre-binding address."); + } + + /** + * Verifies that a failure occurring after {@code } was delivered closes the stream with an error, rather + * than following it with a SASL {@code }. + * + * By that point the authentication genuinely succeeded and the session is live and routable. Two contradictory + * outcomes for one negotiation leave the peer with no defined behaviour, and the SASL failure would not remove the + * route in any case. + */ + @Test + public void bind2FailureAfterSuccessClosesTheStream() throws Exception + { + // Setup test fixture: feature generation fails, which happens only after has been written. + final Connection connection = mock(Connection.class); + final ConnectionConfiguration connectionConfiguration = mock(ConnectionConfiguration.class); + when(connectionConfiguration.getTlsPolicy()).thenReturn(Connection.TLSPolicy.disabled); + when(connectionConfiguration.getCompressionPolicy()).thenThrow(new IllegalStateException("test failure")); + when(connection.getConfiguration()).thenReturn(connectionConfiguration); + 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); + stubSuccessfulBind(XMPPServer.getInstance().getSessionManager()); + + // Execute system under test. + SASLAuthentication.authenticationSuccessful(session, "testuser", "PLAIN", new byte[0], true); + + // Verify result. + final ArgumentCaptor delivered = ArgumentCaptor.forClass(String.class); + verify(connection, times(2)).deliverRawText(delivered.capture()); + assertEquals("success", DocumentHelper.parseText(delivered.getAllValues().get(0)).getRootElement().getName(), + "Expected to have been delivered before the failure occurred."); + assertTrue(delivered.getAllValues().get(1).contains("internal-server-error"), + "Expected the stream to be closed with an error: " + delivered.getAllValues().get(1)); + assertFalse(delivered.getAllValues().stream().anyMatch(xml -> xml.contains(" { + final LocalClientSession bound = invocation.getArgument(0); + final AuthToken token = invocation.getArgument(1); + final String resource = invocation.getArgument(2); + final String node = token.isAnonymous() ? resource : token.getUsername(); + bound.setAddress(new JID(node, bound.getServerName(), resource, true)); + bound.setAuthToken(token); + bound.setStatus(Session.Status.AUTHENTICATED); + return CompletableFuture.completedFuture(SessionManager.BindResult.BOUND); + }); + } }