diff --git a/.github/actions/conversationstest-action/action.yml b/.github/actions/conversationstest-action/action.yml index 95fb14b1d3..4fa0a8d37f 100644 --- a/.github/actions/conversationstest-action/action.yml +++ b/.github/actions/conversationstest-action/action.yml @@ -32,7 +32,7 @@ runs: sudo udevadm trigger --name-match=kvm - name: Cache Conversations APK - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: conversations.apk key: conversations-apk-4217303 # Update this number at the same time as the version in run-tests.sh diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index 293184ac0b..008e780d81 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -340,9 +340,9 @@ jobs: - name: demoboot maestro-tags: demoboot config-file: '' # Use default demoboot config - #- name: sasl2 - # maestro-tags: sasl2 - # config-file: build/ci/conversations/configs/sasl2.xml + - name: sasl2 + maestro-tags: sasl2 + config-file: build/ci/conversations/configs/sasl2.xml steps: - name: Checkout local actions and test flows # Do this _before_ untarring the distribution, as the checkout will empty the directory prior to the checkout! @@ -367,12 +367,19 @@ jobs: with: logLevel: debug - name: Run Conversations tests (${{ matrix.name }}) + id: runConversationsTests uses: ./.github/actions/conversationstest-action with: includeTags: ${{ matrix.maestro-tags }} - name: Stop CI server if: ${{ always() && steps.startCIServer.conclusion == 'success' }} uses: ./.github/actions/stopserver-action + - name: Upload Openfire logs on test failure + if: ${{ failure() && steps.runConversationsTests.conclusion == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: Conversations Test (${{ matrix.name }}) Openfire logs + path: distribution/target/distribution-base/logs/openfire.log should-do-database-tests: diff --git a/build/ci/conversations/configs/sasl2.xml b/build/ci/conversations/configs/sasl2.xml index 274adf2941..c5d9ab245f 100644 --- a/build/ci/conversations/configs/sasl2.xml +++ b/build/ci/conversations/configs/sasl2.xml @@ -75,8 +75,10 @@ - - - - + + + + true + + diff --git a/build/ci/conversations/flows/sasl2.yaml b/build/ci/conversations/flows/sasl2.yaml index de908f7731..66ce53282a 100644 --- a/build/ci/conversations/flows/sasl2.yaml +++ b/build/ci/conversations/flows/sasl2.yaml @@ -47,7 +47,20 @@ onFlowStart: - assertVisible: "Let app always run in background?" - tapOn: "Allow" +# Using SASL2 - runScript: file: scripts/checkForLogs.js env: PATTERN: .*SASL 2.0 authorization identifier was jane@example.org + +# Using Channel Binding +- runScript: + file: scripts/checkForLogs.js + env: + PATTERN: 'jane@example\.org.*Authenticating with SASL_2\/[A-Z0-9-]+-PLUS' + +# Authentication successful +- runScript: + file: scripts/checkForLogs.js + env: + PATTERN: 'jane@example\.org.*logged in \(using SASL_2\)' diff --git a/documentation/openfire.doap b/documentation/openfire.doap index 09363e735b..3a7aeb25c5 100644 --- a/documentation/openfire.doap +++ b/documentation/openfire.doap @@ -417,6 +417,11 @@ 1.0.0 + + + + + diff --git a/i18n/src/main/resources/openfire_i18n.properties b/i18n/src/main/resources/openfire_i18n.properties index 8a7fc88d06..07434e93af 100644 --- a/i18n/src/main/resources/openfire_i18n.properties +++ b/i18n/src/main/resources/openfire_i18n.properties @@ -1315,6 +1315,8 @@ system_property.xmpp.auth.anonymous=Set to true to allow anonymous login, otherw system_property.xmpp.auth.external.client.skip-cert-revalidation=Set to true to avoid validation of the client-provided PKIX certificate (for mutual authentication) other than the validation that happens when the TLS session is established. 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 SASL authentication (XEP-0388) +system_property.xmpp.auth.sasl2.require-tls=Require TLS in order to authenticate with SASL2 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.client.csi.enabled=Controls if Client State Indication (XEP-0352) functionality is supported by Openfire. system_property.xmpp.client.csi.delay.enabled=Determines if 'unimportant' stanzas are delayed for a client that is inactive. diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionManager.java index 59208d01ad..ec1adcaef6 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,11 @@ public void start() throws IllegalStateException { super.start(); localSessionManager.start(); + // Register the XEP-0198 Stream Management handler for SASL2 Bind2 inline feature processing. + if (StreamManager.isStreamManagementActive()) { + 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 +1925,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/SessionPacketRouter.java b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionPacketRouter.java index 8a66f56cfc..6a8c443043 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/SessionPacketRouter.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/SessionPacketRouter.java @@ -59,7 +59,7 @@ public void route(Element wrappedElement) throws UnknownStanzaException { String tag = wrappedElement.getName(); if ("auth".equals(tag) || "response".equals(tag)) { - SASLAuthentication.handle(session, wrappedElement); + SASLAuthentication.handle(session, wrappedElement, false); } else if ("iq".equals(tag)) { route(getIQ(wrappedElement)); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/XMPPServer.java b/xmppserver/src/main/java/org/jivesoftware/openfire/XMPPServer.java index 5589f0c4d3..66798ba0dd 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/XMPPServer.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/XMPPServer.java @@ -34,6 +34,7 @@ import org.jivesoftware.openfire.container.AdminConsolePlugin; import org.jivesoftware.openfire.container.Module; import org.jivesoftware.openfire.container.PluginManager; +import org.jivesoftware.openfire.csi.CsiModule; import org.jivesoftware.openfire.disco.*; import org.jivesoftware.openfire.entitycaps.EntityCapabilitiesManager; import org.jivesoftware.openfire.filetransfer.DefaultFileTransferManager; @@ -769,6 +770,7 @@ private void loadModules() { loadModule(OfflineMessageStore.class.getName()); loadModule(VCardManager.class.getName()); // Load standard modules + loadModule(CsiModule.class.getName()); loadModule(IQBindHandler.class.getName()); loadModule(IQSessionEstablishmentHandler.class.getName()); loadModule(IQPingHandler.class.getName()); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/auth/ScramUtils.java b/xmppserver/src/main/java/org/jivesoftware/openfire/auth/ScramUtils.java index 14cdc1d245..a55f6a13ee 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/auth/ScramUtils.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/auth/ScramUtils.java @@ -36,8 +36,8 @@ public class ScramUtils { private ScramUtils() {} - public static byte[] createSaltedPassword(byte[] salt, String password, int iters) throws SaslException { - Mac mac = createSha1Hmac(password.getBytes(StandardCharsets.UTF_8)); + public static byte[] createSaltedPassword(byte[] salt, String password, int iters, String algorithm) throws SaslException { + Mac mac = createHmac(password.getBytes(StandardCharsets.UTF_8), algorithm); mac.update(salt); mac.update(new byte[]{0, 0, 0, 1}); byte[] result = mac.doFinal(); @@ -54,22 +54,40 @@ public static byte[] createSaltedPassword(byte[] salt, String password, int iter return result; } - public static byte[] computeHmac(final byte[] key, final String string) + public static byte[] computeHmac(final byte[] key, final String string, String algorithm) throws SaslException { - Mac mac = createSha1Hmac(key); + Mac mac = createHmac(key, algorithm); mac.update(string.getBytes(StandardCharsets.UTF_8)); return mac.doFinal(); } - public static Mac createSha1Hmac(final byte[] keyBytes) + public static Mac createHmac(final byte[] keyBytes, String algorithm) throws SaslException { try { - SecretKeySpec key = new SecretKeySpec(keyBytes, "HmacSHA1"); - Mac mac = Mac.getInstance("HmacSHA1"); + String hmacAlgorithm = getHmacAlgorithm(algorithm); + SecretKeySpec key = new SecretKeySpec(keyBytes, hmacAlgorithm); + Mac mac = Mac.getInstance(hmacAlgorithm); mac.init(key); return mac; } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new SaslException(e.getMessage(), e); } } + + private static String getHmacAlgorithm(String hashAlgorithm) { + return "Hmac" + hashAlgorithm.toUpperCase().replace("-", ""); + } + + // Keep backward compatibility methods for existing SHA-1 usage + public static byte[] createSaltedPassword(byte[] salt, String password, int iters) throws SaslException { + return createSaltedPassword(salt, password, iters, "SHA-1"); + } + + public static byte[] computeHmac(final byte[] key, final String string) throws SaslException { + return computeHmac(key, string, "SHA-1"); + } + + public static Mac createSha1Hmac(final byte[] keyBytes) throws SaslException { + return createHmac(keyBytes, "SHA-1"); + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/csi/CsiModule.java b/xmppserver/src/main/java/org/jivesoftware/openfire/csi/CsiModule.java new file mode 100644 index 0000000000..d4f512a620 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/csi/CsiModule.java @@ -0,0 +1,46 @@ +package org.jivesoftware.openfire.csi; + +import org.dom4j.Element; +import org.jivesoftware.openfire.container.BasicModule; +import org.jivesoftware.openfire.net.Bind2InlineHandler; +import org.jivesoftware.openfire.net.Bind2Request; +import org.jivesoftware.openfire.session.LocalClientSession; + +public class CsiModule extends BasicModule { + static class Bind2CSIHandler implements Bind2InlineHandler { + + @Override + public String getNamespace() { + return CsiManager.NAMESPACE; + } + + @Override + public boolean handleElement(LocalClientSession session, Element bound, Element element) { + if (element.getName().equals("active")) { + session.getCsiManager().activate(); + } + return true; + } + } + private static final Bind2CSIHandler handler = new Bind2CSIHandler(); + /** + *

Create a basic module with the given name.

+ * + * @param moduleName The name for the module or null to use the default + */ + public CsiModule(String moduleName) { + super(moduleName); + } + + @Override + public void start() throws IllegalStateException { + super.start(); + Bind2Request.registerElementHandler(handler); + } + + @Override + public void stop() { + Bind2Request.unregisterElementHandler(handler.getNamespace()); + super.stop(); + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2CarbonsHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2CarbonsHandler.java new file mode 100644 index 0000000000..a53c8d60f2 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2CarbonsHandler.java @@ -0,0 +1,18 @@ +package org.jivesoftware.openfire.handler; + +import org.dom4j.Element; +import org.jivesoftware.openfire.net.Bind2InlineHandler; +import org.jivesoftware.openfire.session.LocalClientSession; + +public class Bind2CarbonsHandler implements Bind2InlineHandler { + @Override + public String getNamespace() { + return "urn:xmpp:carbons:2"; + } + + @Override + public boolean handleElement(LocalClientSession session, Element bound, Element element) { + session.setMessageCarbonsEnabled(element.getName().equals("enable")); + return true; + } +} 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..20b008c362 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandler.java @@ -0,0 +1,79 @@ +/* + * 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; + +/** + * 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; + } + + /** + * 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 enabled = session.getStreamManager().enableAndBuildElement(namespace, resume); + if (enabled != null) { + bound.add(enabled); + return true; + } + return false; + } +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/IQMessageCarbonsHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/IQMessageCarbonsHandler.java index 5831e0e184..539248498a 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/handler/IQMessageCarbonsHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/handler/IQMessageCarbonsHandler.java @@ -20,6 +20,7 @@ import org.jivesoftware.openfire.IQHandlerInfo; import org.jivesoftware.openfire.XMPPServer; import org.jivesoftware.openfire.disco.ServerFeaturesProvider; +import org.jivesoftware.openfire.net.Bind2Request; import org.jivesoftware.openfire.session.ClientSession; import org.xmpp.packet.IQ; import org.xmpp.packet.PacketError; @@ -78,4 +79,16 @@ public IQHandlerInfo getInfo() { public Iterator getFeatures() { return Collections.singleton(NAMESPACE).iterator(); } + + @Override + public void start() throws IllegalStateException { + super.start(); + Bind2Request.registerElementHandler(new Bind2CarbonsHandler()); + } + + @Override + public void stop() { + super.stop(); + Bind2Request.unregisterElementHandler("urn:xmpp:carbons:2"); + } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/http/HttpSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/http/HttpSession.java index cf35532d55..ebe6d83d96 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/http/HttpSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/http/HttpSession.java @@ -227,9 +227,9 @@ public List getAvailableStreamFeatures() { // If authentication has not happened yet, include available authentication mechanisms. if (getAuthToken() == null) { - final Element sasl = SASLAuthentication.getSASLMechanismsElement(this); - if (sasl != null) { - elements.add(sasl); + final List mechanisms = SASLAuthentication.getSASLMechanisms(this); + for (Element mechanism : mechanisms) { + elements.add(mechanism); } } diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java new file mode 100644 index 0000000000..aa136eba70 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2InlineHandler.java @@ -0,0 +1,26 @@ +package org.jivesoftware.openfire.net; + +import org.dom4j.Element; +import org.jivesoftware.openfire.session.LocalClientSession; + +/** + * Interface for plugins that handle inline elements in SASL2 bind2 requests. + */ +public interface Bind2InlineHandler { + + /** + * Gets the namespace this handler processes. + * + * @return The XML namespace URI this handler supports + */ + String getNamespace(); + + /** + * Process an inline element from a bind2 request. + * + * @param bound The "bound" element to add any output to + * @param element The DOM element to process + * @return true if the element was handled successfully, false otherwise + */ + boolean handleElement(LocalClientSession session, Element bound, Element element); +} diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java new file mode 100644 index 0000000000..f845f18fba --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/Bind2Request.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2005-2008 Jive Software, 2017-2025 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.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.openfire.auth.ScramUtils; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.session.LocalSession; +import org.jivesoftware.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.security.sasl.SaslException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Represents a SASL2 bind2 request from a client. + * + * The bind request contains an optional tag identifying the client software + * and can include additional feature requests in other namespaces. + */ +public class Bind2Request { + private static final Logger Log = LoggerFactory.getLogger(Bind2Request.class); + + // Add a map to store registered handlers by namespace + private static final Map elementHandlers = + new ConcurrentHashMap<>(); + + /** + * Registers a handler for processing inline elements with a specific namespace. + * + * @param handler The handler to register + */ + public static void registerElementHandler(Bind2InlineHandler handler) { + if (handler == null) { + throw new NullPointerException("Handler cannot be null"); + } + String namespace = handler.getNamespace(); + if (namespace == null || namespace.trim().isEmpty()) { + throw new IllegalArgumentException("Handler namespace cannot be null or empty"); + } + + elementHandlers.put(namespace, handler); + if (Log.isDebugEnabled()) { + Log.debug("Registered inline element handler for namespace: {}", namespace); + } + } + + /** + * Unregisters a handler for a specific namespace. + * + * @param namespace The namespace of the handler to remove + * @return The removed handler, or null if none was registered for this namespace + */ + public static Bind2InlineHandler unregisterElementHandler(String namespace) { + if (namespace == null || namespace.trim().isEmpty()) { + throw new IllegalArgumentException("Namespace cannot be null or empty"); + } + + Bind2InlineHandler removed = elementHandlers.remove(namespace); + if (removed != null && Log.isDebugEnabled()) { + Log.debug("Unregistered inline element handler for namespace: {}", namespace); + } + return removed; + } + + /** + * Process feature request elements using registered handlers. + * + * @return True if all elements were processed successfully, false if any failed + */ + public Element processFeatureRequests(LocalSession session, Element successElement) { + if (session instanceof LocalClientSession) { + LocalClientSession clientSession = (LocalClientSession) session; + Element bound = successElement.addElement(new QName("bound", new Namespace("", NAMESPACE))); + + for (Element element : featureRequests) { + String namespace = element.getNamespaceURI(); + Bind2InlineHandler handler = elementHandlers.get(namespace); + + if (handler != null) { + try { + if (!handler.handleElement(clientSession, bound, element)) { + Log.warn("Handler for namespace {} failed to process element", namespace); + } + } catch (Exception e) { + Log.error("Error processing element with namespace: " + namespace, e); + } + } else { + Log.debug("No handler registered for namespace: {}", namespace); + // We don't fail here because there's no obvious way we could fail. + } + } + + return bound; + } else { + Log.debug("Session is not a LocalClientSession, cannot process feature requests"); + return null; + } + } + + 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()) { + Element var = bind2inline.addElement("feature"); + var.addAttribute("var", handler.getNamespace()); + } + return bind2; + } + + + private static final String NAMESPACE = "urn:xmpp:bind:0"; + private static final String ELEMENT_NAME = "bind"; + private static final String TAG_ELEMENT = "tag"; + + private final String clientTag; + private final List featureRequests; + + /** + * Creates a new Bind2Request instance. + * + * @param clientTag Optional string identifying the client software, can be null. + * @param featureRequests List of feature request elements, can be empty but not null. + */ + public Bind2Request(String clientTag, List featureRequests) { + this.clientTag = clientTag; + this.featureRequests = Collections.unmodifiableList(new ArrayList<>(featureRequests)); + } + + /** + * Extracts bind information from a SASL2 authenticate element. + * + * @param authenticateElement The authenticate element from which to extract bind data. + * @return A Bind2Request instance containing the extracted data, or null if no bind element was found. + */ + public static Bind2Request from(Element authenticateElement) { + if (authenticateElement == null) { + return null; + } + + Element bindElement = authenticateElement.element(ELEMENT_NAME); + if (bindElement == null || !NAMESPACE.equals(bindElement.getNamespaceURI())) { + return null; + } + + // Extract the optional client tag + Element tagElement = bindElement.element(TAG_ELEMENT); + String clientTag = tagElement != null ? tagElement.getTextTrim() : null; + + // Collect feature requests (elements from other namespaces) + List featureRequests = new ArrayList<>(); + for (Element element : bindElement.elements()) { + if (!NAMESPACE.equals(element.getNamespaceURI()) && !element.getName().equals(TAG_ELEMENT)) { + featureRequests.add(element.createCopy()); + } + } + + return new Bind2Request(clientTag, featureRequests); + } + + /** + * Gets the client software identifier tag. + * + * @return The client tag or null if none was provided. + */ + public String getClientTag() { + return clientTag; + } + + /** + * Gets the list of feature request elements. + * + * @return An unmodifiable list of feature request elements. + */ + public List getFeatureRequests() { + return featureRequests; + } + + + /** + * Generates a resource string using the user agent information or defaults. + * + * @param userAgentInfo The user agent information, can be null + * @return A resource string containing the user agent tag (or "Openfire") followed by a UUID + */ + public String generateResourceString(UserAgentInfo userAgentInfo) { + StringBuilder resource = new StringBuilder(); + + // Add the client tag if available + if (clientTag != null && !clientTag.isEmpty()) { + resource.append(clientTag); + resource.append('/'); + } + + String hmacKey; + + // Get the UUID to use as HMAC key + if (userAgentInfo != null && userAgentInfo.getId() != null) { + hmacKey = userAgentInfo.getId(); + } else { + hmacKey = UUID.randomUUID().toString(); + } + + try { + // Convert UUID string to bytes for use as HMAC key + byte[] keyBytes = hmacKey.getBytes(StandardCharsets.UTF_8); + + // Using a fixed constant here - building a rainbow table here for the case + // where the client supplies no tag is going tobe very expensive, so this + // prevents an id recovery attack. + String valueToHmac = resource.toString() + "OpenfireResourceConstant"; + + // Compute HMAC + byte[] hmacResult = ScramUtils.computeHmac(keyBytes, valueToHmac); + + // Convert first 8 bytes of HMAC to hex for resource suffix (16 chars) + String hmacHex = StringUtils.encodeHex(Arrays.copyOf(hmacResult, 8)); + + // Construct final resource string + return resource.toString() + hmacHex; + + } catch (SaslException e) { + // Fall back to UUID in case of HMAC computation failure + Log.error("Failed to compute HMAC for resource string", e); + return resource.toString() + UUID.randomUUID().toString(); + } + } +} 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 7c0bc39c03..ce61cdb4f0 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/SASLAuthentication.java @@ -34,7 +34,10 @@ import org.jivesoftware.openfire.sasl.JiveSharedSecretSaslServer; import org.jivesoftware.openfire.sasl.SaslFailureException; import org.jivesoftware.openfire.sasl.ScramSha1SaslServer; +import org.jivesoftware.openfire.SessionManager; +import org.jivesoftware.openfire.event.SessionEventDispatcher; import org.jivesoftware.openfire.session.*; +import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.openfire.spi.ConnectionType; import org.jivesoftware.util.CertificateManager; import org.jivesoftware.util.JiveGlobals; @@ -50,10 +53,13 @@ import javax.security.sasl.SaslException; import javax.security.sasl.SaslServer; import javax.security.sasl.SaslServerFactory; +import java.nio.charset.StandardCharsets; import java.security.Security; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.*; +import java.util.Base64; +import java.util.LinkedList; import java.util.regex.Pattern; /** @@ -127,11 +133,34 @@ public class SASLAuthentication { .setDefaultValue(false) .build(); + /** + * Enable (or disable) SASL2. This is currently off by default, and means that SASL2 is not advertised in features, primarily. + * + * @see XEP-0388: Extensible SASL Profile + */ + public static final SystemProperty ENABLE_SASL2 = SystemProperty.Builder.ofType(Boolean.class) + .setKey("xmpp.auth.sasl2") + .setDynamic(true) + .setDefaultValue(false) + .build(); + + /** + * Require TLS for SASL2. This is currently on by default, and means that SASL2 is not advertised in features without TLS. + * + * @see XEP-0388: Extensible SASL Profile + */ + public static final SystemProperty SASL2_REQUIRE_TLS = SystemProperty.Builder.ofType(Boolean.class) + .setKey("xmpp.auth.sasl2.require-tls") + .setDynamic(true) + .setDefaultValue(true) + .build(); + // http://stackoverflow.com/questions/8571501/how-to-check-whether-the-string-is-base64-encoded-or-not // plus an extra regex alternative to catch a single equals sign ('=', see RFC 6120 6.4.2) private static final Pattern BASE64_ENCODED = Pattern.compile("^(=|([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==))$"); - private static final String SASL_NAMESPACE = "urn:ietf:params:xml:ns:xmpp-sasl"; + public static final String SASL_NAMESPACE = "urn:ietf:params:xml:ns:xmpp-sasl"; + public static final String SASL2_NAMESPACE = "urn:xmpp:sasl:2"; /** * Java's SaslServer does not allow for null values. This makes it hard to distinguish between an empty (initial) @@ -151,7 +180,9 @@ public class SASLAuthentication { static { // Add (proprietary) Providers of SASL implementation to the Java security context. - Security.addProvider( new org.jivesoftware.openfire.sasl.SaslProvider() ); + if (Security.getProvider( "JiveSoftware" ) == null) { + Security.addProvider(new org.jivesoftware.openfire.sasl.SaslProvider()); + } // Convert XML based provider setup to Database based JiveGlobals.migrateProperty("sasl.mechs"); @@ -195,11 +226,19 @@ public enum ElementType { ABORT, AUTH, + AUTHENTICATE, RESPONSE, CHALLENGE, FAILURE, UNDEF; + /** + * Returns the ElementType corresponding to the given name, performing a case-insensitive lookup. + * Returns {@link #UNDEF} if the name is null, empty, or does not match any known element type. + * + * @param name the element name to look up (may be null or empty) + * @return the matching ElementType, or {@link #UNDEF} if no match is found + */ public static ElementType valueOfCaseInsensitive( String name ) { if ( name == null || name.isEmpty() ) { @@ -235,84 +274,112 @@ public enum Status } /** - * Returns an XML element with the valid SASL mechanisms available for the specified session. If - * the session's connection is not secured then only include the SASL mechanisms that don't - * require TLS. - * - * @param session The current session + * Returns a list of XML elements representing the SASL mechanism features that are applicable to the given session. + * The returned elements are suitable for inclusion in the stream features element sent to the peer. + * Both SASL (RFC 6120) and SASL2 (XEP-0388) feature elements may be included, depending on configuration. + * An empty list is returned if the session is already authenticated or if the session type is not recognized. * - * @return The valid SASL mechanisms available for the specified session. + * @param session the local session for which to determine applicable SASL mechanism feature elements (cannot be null) + * @return a list of XML elements representing SASL mechanism features; never null, possibly empty */ - public static Element getSASLMechanisms( LocalSession session ) + public static List getSASLMechanisms( @Nonnull LocalSession session ) { + final List features = new LinkedList<>(); + // Never list these if the session is already authenticated. + if (session.isAuthenticated()) return features; + if ( session instanceof ClientSession ) { - return getSASLMechanismsElement( (ClientSession) session ); + final Element sasl1Mechs = getSASLMechanismsElement( (ClientSession) session, false ); + if (sasl1Mechs != null) { + features.add(sasl1Mechs); + } + if (ENABLE_SASL2.getValue() && (!SASL2_REQUIRE_TLS.getValue() || session.isEncrypted())) + { + final Element sasl2Mechs = getSASLMechanismsElement((ClientSession) session, true); + if (sasl2Mechs != null) { + features.add(sasl2Mechs); + } + } } else if ( session instanceof LocalIncomingServerSession ) { - return getSASLMechanismsElement( (LocalIncomingServerSession) session ); + final Element sasl1Mechs = getSASLMechanismsElement( (LocalIncomingServerSession) session, false ); + if (sasl1Mechs != null) { + features.add(sasl1Mechs); + } + if (ENABLE_SASL2.getValue() && (!SASL2_REQUIRE_TLS.getValue() || session.isEncrypted())) + { + final Element sasl2Mechs = getSASLMechanismsElement((LocalIncomingServerSession) session, true); + if (sasl2Mechs != null) { + features.add(sasl2Mechs); + } + } } else { Log.debug( "Unable to determine SASL mechanisms that are applicable to session '{}'. Unrecognized session type.", session ); - return null; } + + return features; } /** - * Generates an XML element that represents the available SASL mechanisms for a given client session. + * Returns an XML element advertising the SASL mechanisms available to the given client session. + * The element will be in either the SASL (RFC 6120) or SASL2 (XEP-0388) namespace depending on + * the {@code usingSASL2} parameter. The EXTERNAL mechanism is only included if the session is + * encrypted and the peer has a trusted certificate. May return {@code null} if the resulting + * element would be empty and the {@code sasl.client.suppressEmpty} property is set to {@code true}. * - * Based on the session and server configuration, it optionally returns null if no mechanisms are available and the - * configuration suppresses empty mechanism lists. - * - * @param session the client session for which the available SASL mechanisms are to be retrieved. - * @return an XML element listing the available SASL mechanisms, or null if no mechanisms are available - * and the configuration suppresses empty lists. + * @param session the client session for which to generate the mechanisms element (cannot be null) + * @param usingSASL2 {@code true} to generate a SASL2 {@code } element; + * {@code false} to generate a SASL1 {@code } element + * @return an XML element listing the available SASL mechanisms, or {@code null} if the element + * would be empty and suppression of empty elements is configured */ - public static Element getSASLMechanismsElement(@Nonnull final ClientSession session) + public static Element getSASLMechanismsElement( ClientSession session, boolean usingSASL2 ) { final Set availableMechanisms = getAvailableMechanismsForClientSession(session); - // OF-2072: Return null instead of an empty element, if so configured. - if (JiveGlobals.getBooleanProperty("sasl.client.suppressEmpty", false) && availableMechanisms.isEmpty()) { - return null; - } - - final Element result = DocumentHelper.createElement( new QName("mechanisms", new Namespace("", SASL_NAMESPACE)) ); + final Namespace namespace = new Namespace("", usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE ); + final QName qName = new QName(usingSASL2 ? "authentication" : "mechanisms", namespace); + final Element result = DocumentHelper.createElement( qName ); for (final String mech : availableMechanisms) { final Element mechanism = result.addElement("mechanism"); mechanism.setText(mech); - - if (mech.endsWith("-PLUS")) { - // Prevent offering channel binding if the Connection implementation does not support it. - final Connection connection = ( (LocalClientSession) session ).getConnection(); - assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null. - if (connection.getSupportedChannelBindingTypes().isEmpty()) { - continue; - } - - // Channel binding would be a binding to TLS, thus encryption is required for channel binding. - if (!session.isEncrypted()) { // This ought to be redundant, as getSupportedChannelBindingTypes() will return an empty set if not encrypted. - continue; - } + } + if ( usingSASL2 ) + { + Element inlineElement = result.addElement("inline"); + inlineElement.add(Bind2Request.featureElement()); + if (StreamManager.isStreamManagementActive()) { + inlineElement.add(StreamManager.featureElement()); } } + // OF-2072: Return null instead of an empty element, if so configured. + if ( JiveGlobals.getBooleanProperty("sasl.client.suppressEmpty", false) && result.elements().isEmpty() ) { + return null; + } + return result; } /** - * Generates an XML element that contains the SASL mechanisms available for a given server session. + * Returns an XML element advertising the SASL mechanisms available to the given incoming server session. + * The element will be in either the SASL (RFC 6120) or SASL2 (XEP-0388) namespace depending on + * the {@code usingSASL2} parameter. The EXTERNAL mechanism is only offered if the session is + * encrypted and the peer has a trusted certificate that matches the session's default identity. + * May return {@code null} if the resulting element would be empty and the + * {@code sasl.server.suppressEmpty} property is set to {@code true}. * - * Depending on the configuration (property "sasl.server.suppressEmpty"), this method can return {@code null} - * instead of an empty mechanisms element if no SASL mechanisms are available. - * - * @param session the local incoming server session for which the available SASL mechanisms are determined. - * @return an XML {@code } element that lists all available SASL mechanisms for the given session, - * or {@code null} if no mechanisms are available and the configuration suppresses empty elements. + * @param session the incoming server session for which to generate the mechanisms element (cannot be null) + * @param usingSASL2 {@code true} to generate a SASL2 {@code } element in the SASL2 namespace; + * {@code false} to generate a SASL1 {@code } element + * @return an XML element listing the available SASL mechanisms, or {@code null} if the element + * would be empty and suppression of empty elements is configured */ - public static Element getSASLMechanismsElement(@Nonnull final LocalIncomingServerSession session) + public static Element getSASLMechanismsElement( LocalIncomingServerSession session, boolean usingSASL2 ) { final Set availableMechanisms = getAvailableMechanismsForServerSession(session); @@ -321,7 +388,7 @@ public static Element getSASLMechanismsElement(@Nonnull final LocalIncomingServe return null; } - final Element result = DocumentHelper.createElement( new QName("mechanisms", new Namespace("", SASL_NAMESPACE)) ); + final Element result = DocumentHelper.createElement( new QName("mechanisms", new Namespace("", usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE)) ); for (final String mech : availableMechanisms) { final Element mechanism = result.addElement("mechanism"); mechanism.setText(mech); @@ -330,31 +397,100 @@ public static Element getSASLMechanismsElement(@Nonnull final LocalIncomingServe return result; } + // emptyNull indicates whether a zero-length string is just a zero-length string, or if it's null. + // If emptyNull is false, the presence or absence of the element indicates null, whereas + // if it's true (for auth in SASL1) there's a "=" to indicate genuine empty strings. + private static byte[] decodeData(Element doc, boolean emptyNull) throws SaslFailureException { + // Decode any data that is provided in the client response. + if (doc == null) { + if (emptyNull) { + // I think this is only for SASL1 where there is a DIGEST-MD5 SASL-IR. + return new byte[0]; + } + return null; + } + final String encoded = doc.getTextTrim(); + final byte[] decoded; + if ( encoded == null ) + { + decoded = null; + } + else if ( encoded.isEmpty() ) + { + if (emptyNull) + { + decoded = null; + } + else + { + decoded = new byte[0]; + } + } + else if ( encoded.equals("=") ) + { + if (!emptyNull) + { + throw new SaslFailureException(Failure.INCORRECT_ENCODING); + } + decoded = new byte[0]; + } + else + { + // TODO: We shouldn't depend on regex-based validation. Instead, use a proper decoder implementation and handle any exceptions that it throws. + if ( !BASE64_ENCODED.matcher( encoded ).matches() ) + { + throw new SaslFailureException( Failure.INCORRECT_ENCODING ); + } + decoded = Base64.getDecoder().decode(encoded.getBytes(StandardCharsets.UTF_8)); + } + return decoded; + } + /** * Handles the SASL authentication packet. The entity may be sending an initial * authentication request or a response to a challenge made by the server. The returned * value indicates whether the authentication has finished either successfully or not or * if the entity is expected to send a response to a challenge. * - * @param session the session that is authenticating with the server. - * @param doc the stanza sent by the authenticating entity. + * @param session the session that is authenticating with the server. + * @param doc the stanza sent by the authenticating entity. + * @param usingSASL2 {@code true} if the authentication is being performed using SASL2 (XEP-0388); + * {@code false} if using standard SASL (RFC 6120) * @return value that indicates whether the authentication has finished either successfully * or not or if the entity is expected to send a response to a challenge. */ - public static Status handle(LocalSession session, Element doc) + public static Status handle(LocalSession session, Element doc, boolean usingSASL2) { try { - if ( !doc.getNamespaceURI().equals( SASL_NAMESPACE ) ) + if ( usingSASL2 && !doc.getNamespaceURI().equals( SASL2_NAMESPACE ) ) + { + throw new IllegalStateException( "Unexpected data received while negotiating SASL2 authentication. Name of the offending root element: " + doc.getName() + " Namespace: " + doc.getNamespaceURI() ); + } + else if ( !usingSASL2 && !doc.getNamespaceURI().equals( SASL_NAMESPACE ) ) { throw new IllegalStateException( "Unexpected data received while negotiating SASL authentication. Name of the offending root element: " + doc.getName() + " Namespace: " + doc.getNamespaceURI() ); } - switch ( ElementType.valueOfCaseInsensitive( doc.getName() ) ) + ElementType elementType = ElementType.valueOfCaseInsensitive(doc.getName()); + + if (elementType == ElementType.AUTHENTICATE) { + if (!usingSASL2) { + throw new IllegalStateException("Unexpected data received while negotiating SASL2 authentication. Name of the offending root element: " + doc.getName() + " Namespace: " + doc.getNamespaceURI()); + } + } else if (elementType == ElementType.AUTH && usingSASL2) { + throw new IllegalStateException( "Unexpected data received while negotiating SASL2 authentication. Name of the offending root element: " + doc.getName() + " Namespace: " + doc.getNamespaceURI() ); + } + + Element data = doc; + boolean emptyNull = false; // This is only true for SASL1 "auth" and "success". + SaslServer saslServer = (SaslServer) session.getSessionData( "SaslServer" ); // This may be null at this point. + switch (elementType) { case ABORT: throw new SaslFailureException( Failure.ABORTED ); + case AUTHENTICATE: case AUTH: if ( doc.attributeValue( "mechanism" ) == null ) { @@ -387,7 +523,7 @@ public static Status handle(LocalSession session, Element doc) props.put(Sasl.POLICY_NOANONYMOUS, Boolean.toString(!AnonymousSaslServer.ENABLED.getValue())); props.put( "com.sun.security.sasl.digest.realm", serverInfo.getXMPPDomain() ); - SaslServer saslServer = Sasl.createSaslServer( mechanismName, "xmpp", serverName, props, new XMPPCallbackHandler() ); + saslServer = Sasl.createSaslServer( mechanismName, "xmpp", serverName, props, new XMPPCallbackHandler() ); if ( saslServer == null ) { throw new SaslFailureException( Failure.INVALID_MECHANISM, "There is no provider that can provide a SASL server for the desired mechanism and properties." ); @@ -395,19 +531,44 @@ public static Status handle(LocalSession session, Element doc) session.setSessionData( "SaslServer", saslServer ); + if (elementType == ElementType.AUTHENTICATE) + { + data = doc.element("initial-response"); + } + else + { + emptyNull = true; + } + if ( mechanismName.equals( "DIGEST-MD5" ) ) { // RFC2831 (DIGEST-MD5) says the client MAY provide data in the initial response. Java SASL does // not (currently) support this and throws an exception. For XMPP, such data violates // the RFC, so we just strip any initial token. - doc.setText( "" ); + if (data != null) data = null; + } + if (usingSASL2 && session instanceof LocalClientSession) { + Element userAgentElement = doc.element("user-agent"); + if (userAgentElement != null) { + UserAgentInfo userAgentInfo = UserAgentInfo.extract(userAgentElement); + if (userAgentInfo != null) { + // Store the user agent info in the session + session.setSessionData("user-agent-info", userAgentInfo); + } + } + Bind2Request bind2Request = Bind2Request.from(doc); + if (bind2Request != null) { + session.setSessionData("bind2-request", bind2Request); + } + // XEP-0198 + SASL2: a element may be included inline. + final Element resumeElement = doc.element(new QName("resume", new Namespace("", StreamManager.NAMESPACE_V3))); + if (resumeElement != null) { + session.setSessionData("sm-resume-request", resumeElement.createCopy()); + } } // intended fall-through case RESPONSE: - - saslServer = (SaslServer) session.getSessionData( "SaslServer" ); - if ( saslServer == null ) { // Client sends response without a preceding auth? @@ -415,40 +576,25 @@ public static Status handle(LocalSession session, Element doc) } // Decode any data that is provided in the client response. - final String encoded = doc.getTextTrim(); - final byte[] decoded; + byte[] decoded = decodeData( data, emptyNull ); - // OF-2514: Java SaslServer cannot handle a null, but some SASL mechanisms need to differentiate - // between having received no initial response, and an empty response. - if ( encoded == null || encoded.isEmpty() ) - { - session.removeSessionData(SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY); - decoded = new byte[ 0 ]; - } - else if (encoded.equals("=")) + session.removeSessionData( SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY ); + if ( decoded == null ) { - session.setSessionData(SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY, true); - decoded = new byte[ 0 ]; + decoded = new byte[0]; } - else + else if ( decoded.length == 0 ) { - session.removeSessionData(SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY); - // TODO: OF-2515 We shouldn't depend on regex-based validation. Instead, use a proper decoder implementation and handle any exceptions that it throws. - if ( !BASE64_ENCODED.matcher( encoded ).matches() ) - { - throw new SaslFailureException( Failure.INCORRECT_ENCODING ); - } - - decoded = Base64.getDecoder().decode(encoded); + session.setSessionData(SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY, Boolean.TRUE); } // Process client response. - final byte[] challenge = saslServer.evaluateResponse( decoded ); // Either a challenge or success data. + final byte[] challenge = saslServer.evaluateResponse( decoded ); // Either a challenge or success data. Note that Java SASL cannot handle a null here. if ( !saslServer.isComplete() ) { // Not complete: client is challenged for additional steps. - sendChallenge( session, challenge ); + sendChallenge( session, challenge, usingSASL2 ); return Status.needResponse; } @@ -460,10 +606,11 @@ else if (encoded.equals("=")) // Success! Any mechanism-specific verification (such as certificate checks for EXTERNAL) is // performed by the SaslServer implementation. - authenticationSuccessful( session, saslServer.getAuthorizationID(), saslServer.getMechanismName(), challenge ); + authenticationSuccessful( session, saslServer.getAuthorizationID(), saslServer.getMechanismName(), challenge, usingSASL2 ); session.removeSessionData( "SaslServer" ); + session.removeSessionData( SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY ); session.setSessionData("SaslMechanism", saslServer.getMechanismName()); - if (saslServer.getMechanismName().endsWith("-PLUS")) { + if (requiresChannelBinding(saslServer.getMechanismName())) { session.setSessionData("ChannelBindingType", saslServer.getNegotiatedProperty(ScramSha1SaslServer.PROPNAME_CHANNELBINDINGTYPE)); } return Status.authenticated; @@ -484,19 +631,28 @@ else if (encoded.equals("=")) { failure = Failure.NOT_AUTHORIZED; } - authenticationFailed( session, failure ); + 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 ); - authenticationFailed( session, Failure.NOT_AUTHORIZED ); + authenticationFailed( session, Failure.NOT_AUTHORIZED, usingSASL2 ); session.removeSessionData( "SaslServer" ); return Status.failed; } } + /** + * Verifies that the given X.509 certificate is valid for the specified hostname. The certificate's + * server identities are checked against the hostname, with support for wildcard certificates. + * A wildcard identity (e.g. {@code *.example.com}) matches any direct subdomain of the base domain. + * + * @param trustedCert the X.509 certificate to verify (cannot be null) + * @param hostname the hostname to verify the certificate against (cannot be null) + * @return {@code true} if the certificate is valid for the given hostname; {@code false} otherwise + */ public static boolean verifyCertificate(X509Certificate trustedCert, String hostname) { for (String identity : CertificateManager.getServerIdentities(trustedCert)) { // Verify that either the identity is the same as the hostname, or for wildcarded @@ -511,6 +667,20 @@ public static boolean verifyCertificate(X509Certificate trustedCert, String host return false; } + /** + * Verifies that the end-entity certificate in the given certificate chain is trusted and valid + * for the specified hostname. The appropriate trust store is selected based on whether this is + * a server-to-server (S2S) or client-to-server (C2S) connection. + * + * @param chain the certificate chain to verify; the end-entity certificate will be extracted + * and checked against the trust store (may be null or empty, in which case + * verification will fail) + * @param hostname the hostname that the certificate must be valid for (cannot be null) + * @param isS2S {@code true} if this is a server-to-server connection (uses the S2S trust store); + * {@code false} if this is a client-to-server connection (uses the C2S trust store) + * @return {@code true} if a trusted end-entity certificate is found in the chain and it is valid + * for the given hostname; {@code false} otherwise + */ public static boolean verifyCertificates(Certificate[] chain, String hostname, boolean isS2S) { final CertificateStoreManager certificateStoreManager = XMPPServer.getInstance().getCertificateStoreManager(); final ConnectionType connectionType = isS2S ? ConnectionType.SOCKET_S2S : ConnectionType.SOCKET_C2S; @@ -522,8 +692,8 @@ public static boolean verifyCertificates(Certificate[] chain, String hostname, b return false; } - private static void sendElement(Session session, String element, byte[] data) { - final Element reply = DocumentHelper.createElement(QName.get(element, "urn:ietf:params:xml:ns:xmpp-sasl")); + private static void sendElement(Session session, String element, byte[] data, boolean usingSASL2) { + final Element reply = DocumentHelper.createElement(QName.get(element, usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE)); if (data != null) { String data_b64 = Base64.getEncoder().encodeToString(data).trim(); if (data_b64.isEmpty()) { @@ -534,8 +704,8 @@ private static void sendElement(Session session, String element, byte[] data) { session.deliverRawText(reply.asXML()); } - private static void sendChallenge(Session session, byte[] challenge) { - sendElement(session, "challenge", challenge); + private static void sendChallenge(Session session, byte[] challenge, boolean usingSASL2) { + sendElement(session, "challenge", challenge, usingSASL2); } /** @@ -548,12 +718,19 @@ private static void sendChallenge(Session session, byte[] challenge) { * @param username the authorized identity from SASL (can be null for anonymous). * @param mechanismName the name of the SASL mechanism that was used (cannot be null). * @param successData mechanism-specific success data (can be null). + * @param usingSASL2 are we using SASL2? */ @VisibleForTesting - static void authenticationSuccessful(LocalSession session, String username, String mechanismName, byte[] successData) + static void authenticationSuccessful(LocalSession session, String username, String mechanismName, byte[] successData, boolean usingSASL2) { - sendElement(session, "success", successData); - if (session instanceof ClientSession) { + if (username != null && LockOutManager.getInstance() != null && LockOutManager.getInstance().isAccountDisabled(username)) { + // Interception! This person is locked out, fail instead! + LockOutManager.getInstance().recordFailedLogin(username); + authenticationFailed(session, Failure.ACCOUNT_DISABLED, usingSASL2); + return; + } + // Set the auth token first so that bindResource (which needs it) can proceed. + if (session instanceof LocalClientSession clientSession) { final AuthToken authToken; if (username == null) { // AuthzId is null, which indicates that authentication was anonymous. @@ -561,18 +738,91 @@ static void authenticationSuccessful(LocalSession session, String username, Stri } else { authToken = AuthToken.generateUserToken(username); } - ((LocalClientSession) session).setAuthToken(authToken); - } - else if (session instanceof LocalIncomingServerSession serverSession) { + clientSession.setAuthToken(authToken); + } else if (session instanceof LocalIncomingServerSession serverSession) { // Add the validated domain as a valid domain. The remote server can now send packets from this address. serverSession.addValidatedDomain(username); serverSession.setAuthenticationMethod(ServerSession.AuthenticationMethod.fromSaslMechanismName(mechanismName)); Log.info("Inbound Server {} authenticated using SASL mechanism {}", username, mechanismName); } + + if (usingSASL2) { + if (session instanceof LocalClientSession clientSession) { + // XEP-0198 + SASL2: if a was included inline, process it and embed in . + final Element smResumeRequest = (Element) session.getSessionData("sm-resume-request"); + if (smResumeRequest != null) { + session.setSessionData("sm-resume-request", null); + final Element resumed = clientSession.getStreamManager().processSasl2Resume(smResumeRequest); + if (resumed != null) { + // Resume succeeded: build with inside, no . + final Element success = buildSasl2SuccessElement(successData, username, null); + success.add(resumed); + session.deliverRawText(success.asXML()); + } + // If resumed == null, processSasl2Resume already sent a stanza. + return; + } + + final Bind2Request bind2Request = (Bind2Request) session.getSessionData("bind2-request"); + if (bind2Request != null && clientSession.getStatus() != Session.Status.AUTHENTICATED) { + session.setSessionData("bind2-request", null); + final UserAgentInfo userAgentInfo = (UserAgentInfo) session.getSessionData("user-agent-info"); + final String resource = bind2Request.generateResourceString(userAgentInfo); + final AuthToken authToken = clientSession.getAuthToken(); + final byte[] finalSuccessData = successData; + SessionManager.getInstance().bindResource(clientSession, authToken, resource) + .whenComplete((result, throwable) -> { + final boolean bound = throwable == null && result == SessionManager.BindResult.BOUND; + final Element success = buildSasl2SuccessElement(finalSuccessData, username, bound ? resource : null); + if (bound) { + bind2Request.processFeatureRequests(session, success); + } + session.deliverRawText(success.asXML()); + if (bound) { + SessionEventDispatcher.dispatchEvent(session, SessionEventDispatcher.EventType.resource_bound); + } + }); + return; // Response is sent asynchronously from the completion stage. + } + } + // No SM resume, no Bind2 request, or session already authenticated: send synchronously without . + final Element success = buildSasl2SuccessElement(successData, username, null); + session.deliverRawText(success.asXML()); + } else { + sendElement(session, "success", successData, usingSASL2); + } } - private static void authenticationFailed(LocalSession session, Failure failure) { - final Element reply = DocumentHelper.createElement(QName.get("failure", "urn:ietf:params:xml:ns:xmpp-sasl")); + /** + * Builds a SASL2 <success/> element. + * + * @param successData optional mechanism-specific success data (can be null). + * @param username the authorized identity. + * @param resource the bound resource, or null if no resource was bound. + * @return the <success/> element. + */ + private static Element buildSasl2SuccessElement(byte[] successData, String username, String resource) { + 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(); + success.addElement("additional-data").setText(data_b64); + } + final StringBuilder authId = new StringBuilder(username != null ? username : ""); + authId.append('@').append(XMPPServer.getInstance().getServerInfo().getXMPPDomain()); + if (resource != null) { + authId.append('/').append(resource); + } + success.addElement("authorization-identifier").setText(authId.toString()); + if (resource != null) { + // Add element to indicate successful resource binding (XEP-0386). + // TODO: SHOULD add MAM metadata element here. + success.addElement(new QName("bound", new Namespace("", "urn:xmpp:bind:0"))); + } + return success; + } + + private static void authenticationFailed(LocalSession session, Failure failure, boolean usingSASL2) { + final Element reply = DocumentHelper.createElement(QName.get("failure", usingSASL2 ? SASL2_NAMESPACE : SASL_NAMESPACE)); reply.addElement(failure.toString()); session.deliverRawText(reply.asXML()); // Give a number of retries before closing the connection @@ -632,7 +882,7 @@ public static void removeSupportedMechanism(String mechanismName) { * is required that might be missing. Use {@link #addSupportedMechanism(String)} to add * new SASL mechanisms. * - * @return the list of supported SASL mechanisms by the server. + * @return the set of supported SASL mechanisms by the server. */ public static Set getSupportedMechanisms() { @@ -654,7 +904,7 @@ public static Set getSupportedMechanisms() continue; } - if (mechanism.endsWith("-PLUS") && ChannelBindingProviderManager.getInstance().getSupportedChannelBindingTypes().isEmpty()) { + if (requiresChannelBinding(mechanism) && ChannelBindingProviderManager.getInstance().getSupportedChannelBindingTypes().isEmpty()) { Log.trace( "Cannot support '{}' as there's no implementation available for channel binding.", mechanism ); it.remove(); continue; @@ -788,6 +1038,19 @@ public static void setEnabledMechanisms( List mechanisms ) initMechanisms(); } + /** + * Returns {@code true} if the given SASL mechanism name requires channel binding. + * Channel-binding mechanisms follow the naming convention of appending {@code -PLUS} to the + * base mechanism name (e.g. {@code SCRAM-SHA-1-PLUS}). + * + * @param mechanismName the SASL mechanism name to check (cannot be null) + * @return {@code true} if the mechanism requires channel binding; {@code false} otherwise + */ + @VisibleForTesting + static boolean requiresChannelBinding(@Nonnull final String mechanismName) { + return mechanismName.endsWith("-PLUS"); + } + private static void initMechanisms() { final List propertyValues = getEnabledMechanisms(); @@ -817,13 +1080,13 @@ private static void initMechanisms() */ private static Set getAvailableMechanismsForClientSession(@Nonnull final ClientSession session ) { + final Connection connection = ( (LocalClientSession) session ).getConnection(); + assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null. final Set result = new HashSet<>(); for (String mech : getSupportedMechanisms()) { if (mech.equals("EXTERNAL")) { boolean trustedCert = false; if (session.isEncrypted()) { - final Connection connection = ( (LocalClientSession) session ).getConnection(); - assert connection != null; // While the client is performing a SASL negotiation, the connection can't be null. if ( SKIP_PEER_CERT_REVALIDATION_CLIENT.getValue() ) { // Trust that the peer certificate has been validated when TLS got established. trustedCert = connection.getPeerCertificates() != null && connection.getPeerCertificates().length > 0; @@ -837,6 +1100,16 @@ private static Set getAvailableMechanismsForClientSession(@Nonnull final continue; // Do not offer EXTERNAL. } } + if (requiresChannelBinding(mech)) { + // Prevent offering channel binding if the Connection implementation does not support it. + if (connection.getSupportedChannelBindingTypes().isEmpty()) { + continue; // Do not offer channel-binding variants. + } + // Channel binding would be a binding to TLS, thus encryption is required for channel binding. + if (!session.isEncrypted()) { // This ought to be redundant, as getSupportedChannelBindingTypes() will return an empty set if not encrypted. + continue; + } + } result.add(mech); } return result; 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 e7e73e4743..eb797bb109 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/StanzaHandler.java @@ -29,7 +29,6 @@ import org.jivesoftware.openfire.spi.BasicStreamIDFactory; import org.jivesoftware.openfire.streammanagement.StreamManager; import org.jivesoftware.util.*; -import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; @@ -75,6 +74,7 @@ public abstract class StanzaHandler { // Flag that indicates that the client requested to be authenticated. Once the // authentication process is over the value will return to false. protected boolean startedSASL = false; + protected boolean usingSASL2 = false; /** * SASL status based on the last SASL interaction */ @@ -203,10 +203,29 @@ else if ("auth".equals(tag)) { // User is trying to authenticate using SASL startedSASL = true; // Process authentication stanza - saslStatus = SASLAuthentication.handle(session, doc); + saslStatus = SASLAuthentication.handle(session, doc, usingSASL2); + } else if ("authenticate".equals(tag)) { + // User is trying to authenticate using SASL2. + startedSASL = true; + usingSASL2 = true; + saslStatus = SASLAuthentication.handle(session, doc, usingSASL2); + if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + Element features = generateFeatures(); + session.deliverRawText(features.asXML()); + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); + } } else if (startedSASL && "response".equals(tag) || "abort".equals(tag)) { // User is responding to SASL challenge. Process response - saslStatus = SASLAuthentication.handle(session, doc); + saslStatus = SASLAuthentication.handle(session, doc, usingSASL2); + if (saslStatus == SASLAuthentication.Status.failed) { + startedSASL = false; + usingSASL2 = false; + } + if (saslStatus == SASLAuthentication.Status.authenticated && usingSASL2) { + Element features = generateFeatures(); + session.deliverRawText(features.asXML()); + session.getStreamManager().redeliverIfPendingSasl2(new JID(null, session.getServerName(), null, true)); + } } else if ("compress".equals(tag)) { // Client is trying to initiate compression @@ -314,7 +333,7 @@ else if ("iq".equals(tag)) { // The original packet contains a malformed JID so answer an error IQ reply = new IQ(); if (!doc.elements().isEmpty()) { - reply.setChildElement(((Element)doc.elements().get(0)).createCopy()); + reply.setChildElement((doc.elements().get(0)).createCopy()); } reply.setID(doc.attributeValue("id")); reply.setTo(session.getAddress()); @@ -354,7 +373,7 @@ private IQ getIQ(Element doc) { for (Element element : elements){ session.setSoftwareVersionData(element.getName(), element.getStringValue()); } - } + } } catch (Exception e) { Log.error("Unexpected exception while processing IQ Version stanza from '{}'", session.getAddress(), e); } @@ -492,13 +511,6 @@ protected void tlsNegotiated(XmlPullParser xpp) throws XmlPullParserException, I final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); document.getRootElement().add(features); - // Include available SASL Mechanisms - final Element mechanismsElement=SASLAuthentication.getSASLMechanisms(session); - if (mechanismsElement!=null) { - ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(mechanismsElement).ifPresent(features::add); - features.add(mechanismsElement); - } - // Include specific features such as auth and register for client sessions final List specificFeatures = session.getAvailableStreamFeatures(); if (specificFeatures != null) { @@ -518,8 +530,12 @@ protected void tlsNegotiated(XmlPullParser xpp) throws XmlPullParserException, I */ protected void saslSuccessful() { final Document document = getStreamHeader(); - final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); + final Element features = generateFeatures(); document.getRootElement().add(features); + connection.deliverRawText(StringUtils.asUnclosedStream(document)); + } + protected Element generateFeatures() { + final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); // Include specific features such as resource binding and session establishment for client sessions final List specificFeatures = session.getAvailableStreamFeatures(); @@ -529,7 +545,7 @@ protected void saslSuccessful() { } } - connection.deliverRawText(StringUtils.asUnclosedStream(document)); + return features; } /** @@ -595,14 +611,6 @@ protected void compressionSuccessful() { final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); document.getRootElement().add(features); - // Include SASL mechanisms only if client has not been authenticated - if (!session.isAuthenticated()) { - final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session); - if (saslMechanisms != null) { - ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add); - features.add(saslMechanisms); - } - } // Include specific features such as resource binding and session establishment for client sessions final List specificFeatures = session.getAvailableStreamFeatures(); if (specificFeatures != null) { diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/net/UserAgentInfo.java b/xmppserver/src/main/java/org/jivesoftware/openfire/net/UserAgentInfo.java new file mode 100644 index 0000000000..74bb952f71 --- /dev/null +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/net/UserAgentInfo.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2025 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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; + +/** + * Represents user agent information provided by XMPP clients during authentication. + * This information includes an optional UUID v4 identifier, software description, + * and device description. This information is not exposed to other entities. + */ +public class UserAgentInfo { + private static final Logger Log = LoggerFactory.getLogger(UserAgentInfo.class); + + private String id; // UUID v4 + private String software; // Software description + private String device; // Device description + + /** + * Extracts and validates user agent information from the authentication element. + * + * @param userAgentElement the authentication element containing potential user agent data + * @return UserAgentInfo containing the parsed data, or null if no user agent data present + */ + public static UserAgentInfo extract(Element userAgentElement) { + if (userAgentElement == null) { + return null; + } + + UserAgentInfo userAgentInfo = new UserAgentInfo(); + + // Extract and validate UUID v4 id if present + String id = userAgentElement.attributeValue("id"); + if (id != null) { + try { + UUID uuid = UUID.fromString(id); + // Validate it's a v4 UUID + if (uuid.version() == 4) { + userAgentInfo.setId(id); + } else { + Log.warn("Invalid UUID version in user-agent id (must be v4): " + id); + } + } catch (IllegalArgumentException e) { + Log.warn("Invalid UUID format in user-agent id: " + id); + } + } + + // Extract software info if present + Element softwareElement = userAgentElement.element("software"); + if (softwareElement != null) { + userAgentInfo.setSoftware(softwareElement.getTextTrim()); + } + + // Extract device info if present + Element deviceElement = userAgentElement.element("device"); + if (deviceElement != null) { + userAgentInfo.setDevice(deviceElement.getTextTrim()); + } + + return userAgentInfo; + } + + public String getId() { + return id; + } + + void setId(String id) { + this.id = id; + } + + public String getSoftware() { + return software; + } + + void setSoftware(String software) { + this.software = software; + } + + public String getDevice() { + return device; + } + + void setDevice(String device) { + this.device = device; + } +} 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 71f90924f2..399bda14e6 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalClientSession.java @@ -38,9 +38,9 @@ import org.jivesoftware.util.IpUtils; import org.jivesoftware.util.JiveGlobals; import org.jivesoftware.util.LocaleUtils; +import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.jivesoftware.util.StringUtils; import org.jivesoftware.util.cache.Cache; -import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; @@ -292,12 +292,6 @@ public static LocalClientSession createSession(String serverName, XmlPullParser } catch (KeyStoreException e) { Log.warn("Unable to access the identity store for client connections. StartTLS is not being offered as a feature for this session.", e); } - // Include available SASL Mechanisms - final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session); - if (saslMechanisms != null) { - ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add); - features.add(saslMechanisms); - } // Include Stream features final List specificFeatures = session.getAvailableStreamFeatures(); if (specificFeatures != null) { @@ -813,6 +807,12 @@ public List getAvailableStreamFeatures() { } if (getAuthToken() == null) { + // Include available SASL Mechanisms + result.addAll(SASLAuthentication.getSASLMechanisms(this)); + final Element saslMechanisms = result.stream().filter(e -> "mechanisms".equals(e.getName())).findFirst().orElse(null); + if (saslMechanisms != null) { + ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(result::add); + } // Advertise that the server supports Non-SASL Authentication if ( XMPPServer.getInstance().getIQRouter().supports( "jabber:iq:auth" ) ) { result.add(DocumentHelper.createElement(QName.get("auth", "http://jabber.org/features/iq-auth"))); @@ -825,7 +825,10 @@ public List getAvailableStreamFeatures() { else { // If the session has been authenticated then offer resource binding, // and session establishment - result.add(DocumentHelper.createElement(QName.get("bind", "urn:ietf:params:xml:ns:xmpp-bind"))); + if (getStatus() != Status.AUTHENTICATED) { + // We might be bound already via bind2 + result.add(DocumentHelper.createElement(QName.get("bind", "urn:ietf:params:xml:ns:xmpp-bind"))); + } final Element session = DocumentHelper.createElement(QName.get("session", "urn:ietf:params:xml:ns:xmpp-session")); session.addElement("optional"); result.add(session); diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java index bf24bbaa7d..dd1e137058 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java @@ -27,9 +27,9 @@ import org.jivesoftware.openfire.server.ServerDialbackErrorException; import org.jivesoftware.openfire.server.ServerDialbackKeyInvalidException; import org.jivesoftware.util.CertificateManager; +import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.jivesoftware.util.StreamErrorException; import org.jivesoftware.util.StringUtils; -import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; @@ -187,13 +187,6 @@ public static LocalIncomingServerSession createSession(String serverName, XmlPul features.add(starttls); } - // Include available SASL Mechanisms - final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session); - if (saslMechanisms != null) { - ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add); - features.add(saslMechanisms); - } - if (ServerDialback.isEnabled()) { // Also offer server dialback (when TLS is not required). Server dialback may be offered // after TLS has been negotiated and a self-signed certificate is being used @@ -426,6 +419,15 @@ public List getAvailableStreamFeatures() { final List result = new LinkedList<>(); + // Include available SASL Mechanisms + if (!isAuthenticated()) { + result.addAll(SASLAuthentication.getSASLMechanisms(this)); + final Element saslMechanisms = result.stream().filter(e -> "mechanisms".equals(e.getName())).findFirst().orElse(null); + if (saslMechanisms != null) { + ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(result::add); + } + } + // Include Stream Compression Mechanism if (conn.getConfiguration().getCompressionPolicy() != Connection.CompressionPolicy.disabled && !conn.isCompressed()) { final Element compression = DocumentHelper.createElement(QName.get("compression", "http://jabber.org/features/compress")); 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 c9c5554daa..e04f9bd2e8 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalSession.java @@ -199,6 +199,42 @@ 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 + * @param h the client's acknowledgement counter + * @return the {@code } element to be embedded in the SASL2 {@code } + */ + public Element reattachForSasl2(LocalSession connectionProvider, long h) { + 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); + // Build the element but do NOT send it — the caller will embed it in . + final Element resumed = this.streamManager.buildResumedElement(); + this.streamManager.processClientAcknowledgementPublic(h); + // Redelivery of unacked stanzas must happen AFTER stream features are sent following + // , so we only set the pending flag here; StanzaHandler will call + // redeliverIfPendingSasl2() after generateFeatures(). + this.streamManager.setPendingSasl2Redelivery(true); + this.sessionManager.removeSession((LocalClientSession) connectionProvider); + return resumed; + } + /** * 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..bbaef4d525 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. */ @@ -134,6 +144,14 @@ public static boolean isStreamManagementActive() { */ private final Set terminationDelegates = new HashSet<>(); + /** + * Set to {@code true} when a SASL2-inline stream resumption has been processed but unacknowledged + * stanzas have not yet been redelivered. The redelivery must happen after stream features are sent + * following the SASL2 {@code }, so {@link StanzaHandler} calls + * {@link #redeliverIfPendingSasl2(JID)} at that point. + */ + private volatile boolean pendingSasl2Redelivery = false; + public StreamManager(LocalSession session) { String address; try { @@ -243,13 +261,36 @@ private boolean allowResume() { * @param resume Whether the client is requesting a resumable session. */ private void enable( String namespace, boolean resume ) + { + final Element enabled = enableInternal(namespace, resume); + if (enabled != null) { + session.deliverRawText(enabled.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. + * + *

Returns {@code null} if enabling failed (an error stanza will have been sent already).

+ * + * @param namespace the SM namespace to use + * @param resume whether the client requests a resumable session + * @return the {@code } element, or {@code null} on failure + */ + 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 null; } String smId = null; @@ -260,7 +301,7 @@ private void enable( String namespace, boolean resume ) if ( isEnabled() ) { sendUnexpectedError(); - return; + return null; } this.namespace = namespace; @@ -271,7 +312,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 +330,7 @@ private void enable( String namespace, boolean resume ) } } } - session.deliverRawText(enabled.asXML()); + return enabled; } private void startResume(String namespace, String previd, long h) { @@ -406,6 +447,145 @@ private void startResume(String namespace, String previd, long 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. + * + *

On any error the method sends the appropriate SM {@code } stanza itself and + * returns {@code null}.

+ * + * @param resumeElement the {@code } element from the SASL2 {@code } + * @return the {@code } element to embed in {@code }, or {@code null} if + * resumption failed + */ + public Element processSasl2Resume(Element resumeElement) { + final String namespace = resumeElement.getNamespaceURI(); + this.namespace = namespace; + + 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); + sendUnexpectedError(); + return null; + } + if (h < 0) { + Log.warn("Client sends negative value for SM 'h' in SASL2 resume: {}, session: {}", h, session); + sendUnexpectedError(); + return null; + } + + final String previd = resumeElement.attributeValue("previd"); + + // Ensure that resource binding has NOT occurred. + if (!allowResume()) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} is in a state where session resumption is not allowed.", session); + sendUnexpectedError(); + return null; + } + if (session.isAuthenticated()) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} is already authenticated.", session); + sendUnexpectedError(); + return null; + } + AuthToken authToken = null; + if (session instanceof ClientSession) { + authToken = ((LocalClientSession) session).getAuthToken(); + } + if (authToken == null) { + Log.debug("Unable to process SASL2 session resumption attempt, as session {} does not provide any auth context.", session); + sendUnexpectedError(); + return null; + } + + // Decode previd. + String resource; + String streamId; + try { + StringTokenizer toks = new StringTokenizer(new String(Base64.getDecoder().decode(previd), StandardCharsets.UTF_8), "\0"); + resource = toks.nextToken(); + streamId = toks.nextToken(); + } catch (Exception e) { + Log.debug("Exception from previd decode in SASL2 resume:", e); + sendUnexpectedError(); + return null; + } + + 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("SASL2 resuming session for '{}'. Current session: {}", fullJid, session.getStreamID()); + + final ClientSession route = XMPPServer.getInstance().getRoutingTable().getClientRoute(fullJid); + if (route == null) { + Log.debug("Not able for client of '{}' to resume a session (SASL2) on this cluster node. No session was found.", fullJid); + if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { + CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); + } + sendError(new PacketError(PacketError.Condition.item_not_found)); + return null; + } + if (!(route instanceof LocalClientSession)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2) on this cluster node. The session can only be resumed on the original cluster node.", fullJid); + if (LOCATION_TERMINATE_OTHERS_ENABLED.getValue()) { + CacheFactory.doClusterTask(new ClientSessionTask(fullJid, RemoteSessionTask.Operation.removeDetached)); + } + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + + final LocalClientSession otherSession = (LocalClientSession) route; + if (!otherSession.getStreamID().getID().equals(streamId)) { + sendError(new PacketError(PacketError.Condition.item_not_found)); + return null; + } + Log.debug("Found existing session for '{}' (SASL2 resume), checking status", fullJid); + + if (route.isClosed()) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as the preexisting session is already in process of being closed.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().resume) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed does not have the stream management resumption feature enabled.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (otherSession.getStreamManager().namespace == null) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), the session to be resumed disabled SM functionality as a response to an earlier error.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().namespace.equals(namespace)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), namespace mismatch: {} vs {}.", fullJid, otherSession.getStreamManager().namespace, namespace); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + if (!otherSession.getStreamManager().validateClientAcknowledgement(h)) { + Log.debug("Not allowing a client of '{}' to resume a session (SASL2), as it reports it received more stanzas from us than that we've sent it.", fullJid); + sendError(new PacketError(PacketError.Condition.unexpected_request)); + return null; + } + 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); + final Element resumed = otherSession.reattachForSasl2(session, h); + Log.debug("Perform SASL2 resumption of session {} for '{}', using connection from session {}", otherSession.getStreamID(), fullJid, session.getStreamID()); + return resumed; + } + /** * Called when a session receives a closing stream tag, this prevents the * session from being detached. @@ -634,17 +814,78 @@ 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); + } + + /** + * Processes the client's acknowledgement counter as part of a SASL2-based stream resumption. + * This is a package-accessible wrapper around the private {@link #processClientAcknowledgement(long)} + * for use by {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2}. + * + * @param h the client's acknowledgement counter + */ + public void processClientAcknowledgementPublic(long h) { + processClientAcknowledgement(h); + } + + /** + * Sets the pending SASL2 redelivery flag. When {@code true}, {@link #redeliverIfPendingSasl2(JID)} + * will redeliver unacknowledged stanzas. Called by + * {@link org.jivesoftware.openfire.session.LocalSession#reattachForSasl2} to defer redelivery + * until after stream features have been sent. + * + * @param pending whether redelivery is pending + */ + public void setPendingSasl2Redelivery(boolean pending) { + this.pendingSasl2Redelivery = pending; + } + + /** + * If a SASL2-inline stream resumption is pending redelivery, redelivers unacknowledged stanzas + * now. This must be called by {@link org.jivesoftware.openfire.net.StanzaHandler} after stream + * features have been sent following the SASL2 {@code }. + * + * @param serverAddress the server's JID, used to stamp delayed stanzas + */ + public void redeliverIfPendingSasl2(JID serverAddress) { + if (pendingSasl2Redelivery) { + pendingSasl2Redelivery = false; + 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 f2c8fc7c56..c51aa859fa 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/websocket/WebSocketClientStanzaHandler.java @@ -29,7 +29,6 @@ import org.jivesoftware.openfire.session.LocalClientSession; import org.jivesoftware.openfire.session.Session; import org.jivesoftware.util.StreamErrorException; -import org.jivesoftware.util.channelbinding.ChannelBindingProviderManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xmlpull.v1.XmlPullParser; @@ -163,15 +162,7 @@ private void openStream() { private void sendStreamFeatures() { final Element features = DocumentHelper.createElement(QName.get("features", "stream", "http://etherx.jabber.org/streams")); - if (saslStatus != SASLAuthentication.Status.authenticated) { - // Include available SASL Mechanisms - final Element saslMechanisms = SASLAuthentication.getSASLMechanisms(session); - if (saslMechanisms != null) { - ChannelBindingProviderManager.getInstance().getSASLChannelBindingTypeCapabilityElement(saslMechanisms).ifPresent(features::add); - features.add(saslMechanisms); - } - } - // Include Stream features + // Include Stream features (including SASL mechanisms when not yet authenticated) final List specificFeatures = session.getAvailableStreamFeatures(); if (specificFeatures != null) { for (final Element feature : specificFeatures) { 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..95ead3e4ea --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/handler/Bind2StreamManagementHandlerTest.java @@ -0,0 +1,163 @@ +/* + * 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 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()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2InlineHandlerTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2InlineHandlerTest.java new file mode 100644 index 0000000000..80f8943bfc --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2InlineHandlerTest.java @@ -0,0 +1,145 @@ +package org.jivesoftware.openfire.net; + +import org.dom4j.Element; +import org.dom4j.DocumentHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Tests for the Bind2InlineHandler registration and processing functionality. + */ +public class Bind2InlineHandlerTest { + + private Bind2InlineHandler mockHandler; + private Element mockBoundElement; + private Element mockFeatureElement; + + @BeforeEach + public void setUp() { + mockHandler = mock(Bind2InlineHandler.class); + mockBoundElement = DocumentHelper.createElement("bound"); + mockFeatureElement = DocumentHelper.createElement("feature"); + mockFeatureElement.addNamespace("test", "http://test.namespace"); + } + + @AfterEach + public void tearDown() { + // Clean up any registered handlers to avoid test interference + Bind2Request.unregisterElementHandler("http://test.namespace"); + } + + @Test + public void testRegisterElementHandler() { + // Setup + when(mockHandler.getNamespace()).thenReturn("http://test.namespace"); + + // Execute + assertDoesNotThrow(() -> Bind2Request.registerElementHandler(mockHandler)); + + // Verify registration was successful by attempting to unregister + Bind2InlineHandler removed = Bind2Request.unregisterElementHandler("http://test.namespace"); + assertNotNull(removed); + assertEquals(mockHandler, removed); + } + + @Test + public void testRegisterNullHandler() { + // Execute & Verify + assertThrows(NullPointerException.class, () -> + Bind2Request.registerElementHandler(null)); + } + + @Test + public void testRegisterHandlerWithNullNamespace() { + // Setup + when(mockHandler.getNamespace()).thenReturn(null); + + // Execute & Verify + assertThrows(IllegalArgumentException.class, () -> + Bind2Request.registerElementHandler(mockHandler)); + } + + @Test + public void testRegisterHandlerWithEmptyNamespace() { + // Setup + when(mockHandler.getNamespace()).thenReturn(""); + + // Execute & Verify + assertThrows(IllegalArgumentException.class, () -> + Bind2Request.registerElementHandler(mockHandler)); + } + + @Test + public void testRegisterHandlerWithWhitespaceNamespace() { + // Setup + when(mockHandler.getNamespace()).thenReturn(" "); + + // Execute & Verify + assertThrows(IllegalArgumentException.class, () -> + Bind2Request.registerElementHandler(mockHandler)); + } + + @Test + public void testUnregisterElementHandler() { + // Setup + when(mockHandler.getNamespace()).thenReturn("http://test.namespace"); + Bind2Request.registerElementHandler(mockHandler); + + // Execute + Bind2InlineHandler removed = Bind2Request.unregisterElementHandler("http://test.namespace"); + + // Verify + assertNotNull(removed); + assertEquals(mockHandler, removed); + + // Verify it's actually gone + Bind2InlineHandler removedAgain = Bind2Request.unregisterElementHandler("http://test.namespace"); + assertNull(removedAgain); + } + + @Test + public void testUnregisterNonExistentHandler() { + // Execute + Bind2InlineHandler removed = Bind2Request.unregisterElementHandler("http://nonexistent.namespace"); + + // Verify + assertNull(removed); + } + + @Test + public void testUnregisterWithNullNamespace() { + // Execute & Verify + assertThrows(IllegalArgumentException.class, () -> + Bind2Request.unregisterElementHandler(null)); + } + + @Test + public void testUnregisterWithEmptyNamespace() { + // Execute & Verify + assertThrows(IllegalArgumentException.class, () -> + Bind2Request.unregisterElementHandler("")); + } + + @Test + public void testReplaceHandler() { + // Setup + Bind2InlineHandler firstHandler = mock(Bind2InlineHandler.class); + Bind2InlineHandler secondHandler = mock(Bind2InlineHandler.class); + when(firstHandler.getNamespace()).thenReturn("http://test.namespace"); + when(secondHandler.getNamespace()).thenReturn("http://test.namespace"); + + // Execute + Bind2Request.registerElementHandler(firstHandler); + Bind2Request.registerElementHandler(secondHandler); // Should replace first + + Bind2InlineHandler retrieved = Bind2Request.unregisterElementHandler("http://test.namespace"); + + // Verify + assertEquals(secondHandler, retrieved); + assertNotEquals(firstHandler, retrieved); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java new file mode 100644 index 0000000000..9cc8784285 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestProcessingTest.java @@ -0,0 +1,286 @@ +package org.jivesoftware.openfire.net; + +import org.dom4j.Element; +import org.dom4j.DocumentHelper; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; + +/** + * Tests for the processFeatureRequests method and featureElement advertisement in Bind2Request. + */ +public class Bind2RequestProcessingTest { + + @Mock + private Bind2InlineHandler mockHandler1; + + @Mock + private Bind2InlineHandler mockHandler2; + + @Mock + private LocalClientSession mockSession; + + private Element successElement; + private Element featureElement1; + private Element featureElement2; + + /** + * Returns a Mockito argument matcher that matches an Element by its local name and namespace URI, + * rather than by object identity. + */ + private static Element elementWithNameAndNS(String localName, String namespaceURI) { + return argThat(new BaseMatcher() { + @Override + public boolean matches(Object item) { + if (!(item instanceof Element)) return false; + Element el = (Element) item; + return localName.equals(el.getName()) && namespaceURI.equals(el.getNamespaceURI()); + } + @Override + public void describeTo(Description description) { + description.appendText("Element with name='" + localName + "' and namespace='" + namespaceURI + "'"); + } + }); + } + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + + successElement = DocumentHelper.createElement("success"); + + QName feature1 = new QName("feature1", new Namespace("", "http://test1.namespace")); + featureElement1 = DocumentHelper.createElement(feature1); + + QName feature2 = new QName("feature2", new Namespace("", "http://test2.namespace")); + featureElement2 = DocumentHelper.createElement(feature2); + + when(mockHandler1.getNamespace()).thenReturn("http://test1.namespace"); + when(mockHandler2.getNamespace()).thenReturn("http://test2.namespace"); + when(mockHandler1.handleElement(any(), any(), any())).thenReturn(true); + when(mockHandler2.handleElement(any(), any(), any())).thenReturn(true); + } + + @AfterEach + public void tearDown() { + Bind2Request.unregisterElementHandler("http://test1.namespace"); + Bind2Request.unregisterElementHandler("http://test2.namespace"); + Bind2Request.unregisterElementHandler("http://unhandled.namespace"); + } + + // ------------------------------------------------------------------------- + // processFeatureRequests tests — varying featureRequests content + // ------------------------------------------------------------------------- + + @Test + public void testProcessFeatureRequestsWithBothFeatures() { + Bind2Request bind2Request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + assertEquals("bound", result.getName()); + assertEquals("urn:xmpp:bind:0", result.getNamespaceURI()); + verify(mockHandler1).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler2).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); + } + + @Test + public void testProcessFeatureRequestsWithOnlyFirstFeature() { + Bind2Request bind2Request = new Bind2Request("clientTag", Collections.singletonList(featureElement1)); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler2, never()).handleElement(any(), any(), any()); + } + + @Test + public void testProcessFeatureRequestsWithOnlySecondFeature() { + Bind2Request bind2Request = new Bind2Request("clientTag", Collections.singletonList(featureElement2)); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1, never()).handleElement(any(), any(), any()); + verify(mockHandler2).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); + } + + @Test + public void testProcessFeatureRequestsWithNoFeatures() { + Bind2Request bind2Request = new Bind2Request("clientTag", Collections.emptyList()); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1, never()).handleElement(any(), any(), any()); + verify(mockHandler2, never()).handleElement(any(), any(), any()); + } + + @Test + public void testProcessFeatureRequestsWithNoRegisteredHandlers() { + Bind2Request bind2Request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1, never()).handleElement(any(), any(), any()); + verify(mockHandler2, never()).handleElement(any(), any(), any()); + } + + @Test + public void testProcessFeatureRequestsWithPartialHandlers() { + Bind2Request bind2Request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + Bind2Request.registerElementHandler(mockHandler1); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler2, never()).handleElement(any(), any(), any()); + } + + @Test + public void testProcessFeatureRequestsWithHandlerException() { + Bind2Request bind2Request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + when(mockHandler1.handleElement(any(), any(), any())).thenThrow(new RuntimeException("Test exception")); + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element result = assertDoesNotThrow(() -> + bind2Request.processFeatureRequests(mockSession, successElement)); + + assertNotNull(result); + verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + verify(mockHandler2).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement2)); + } + + @Test + public void testProcessFeatureRequestsWithHandlerReturnsFalse() { + Bind2Request bind2Request = new Bind2Request("clientTag", Collections.singletonList(featureElement1)); + when(mockHandler1.handleElement(any(), any(), any())).thenReturn(false); + Bind2Request.registerElementHandler(mockHandler1); + + Element result = assertDoesNotThrow(() -> + bind2Request.processFeatureRequests(mockSession, successElement)); + + assertNotNull(result); + verify(mockHandler1).handleElement(any(), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + } + + @Test + public void testProcessFeatureRequestsCreatesBoundElement() { + Bind2Request bind2Request = new Bind2Request("clientTag", Arrays.asList(featureElement1, featureElement2)); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + assertEquals("bound", result.getName()); + assertEquals("urn:xmpp:bind:0", result.getNamespaceURI()); + } + + @Test + public void testProcessFeatureRequestsWithNullClientTag() { + Bind2Request bind2Request = new Bind2Request(null, Collections.singletonList(featureElement1)); + Bind2Request.registerElementHandler(mockHandler1); + + Element result = bind2Request.processFeatureRequests(mockSession, successElement); + + assertNotNull(result); + verify(mockHandler1).handleElement(eq(mockSession), elementWithNameAndNS("bound", "urn:xmpp:bind:0"), eq(featureElement1)); + } + + // ------------------------------------------------------------------------- + // featureElement (stream features advertisement) tests + // ------------------------------------------------------------------------- + + @Test + public void testFeatureElementWithNoHandlers() { + Element feature = Bind2Request.featureElement(); + + assertNotNull(feature); + assertEquals("bind", feature.getName()); + assertEquals("urn:xmpp:bind:0", feature.getNamespaceURI()); + + Element inline = feature.element("inline"); + assertNotNull(inline); + assertTrue(inline.elements("feature").isEmpty(), "Expected no advertised features when no handlers are registered"); + } + + @Test + public void testFeatureElementAdvertisesOneHandler() { + Bind2Request.registerElementHandler(mockHandler1); + + Element feature = Bind2Request.featureElement(); + + assertNotNull(feature); + assertEquals("bind", feature.getName()); + assertEquals("urn:xmpp:bind:0", feature.getNamespaceURI()); + + Element inline = feature.element("inline"); + assertNotNull(inline); + List features = inline.elements("feature"); + assertEquals(1, features.size()); + assertEquals("http://test1.namespace", features.get(0).attributeValue("var")); + } + + @Test + public void testFeatureElementAdvertisesBothHandlers() { + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + + Element feature = Bind2Request.featureElement(); + + assertNotNull(feature); + Element inline = feature.element("inline"); + assertNotNull(inline); + List features = inline.elements("feature"); + assertEquals(2, features.size()); + + List vars = features.stream() + .map(e -> e.attributeValue("var")) + .collect(Collectors.toList()); + assertTrue(vars.contains("http://test1.namespace"), "Expected http://test1.namespace to be advertised"); + assertTrue(vars.contains("http://test2.namespace"), "Expected http://test2.namespace to be advertised"); + } + + @Test + public void testFeatureElementAfterUnregisteringHandler() { + Bind2Request.registerElementHandler(mockHandler1); + Bind2Request.registerElementHandler(mockHandler2); + Bind2Request.unregisterElementHandler("http://test1.namespace"); + + Element feature = Bind2Request.featureElement(); + + Element inline = feature.element("inline"); + assertNotNull(inline); + List features = inline.elements("feature"); + assertEquals(1, features.size()); + assertEquals("http://test2.namespace", features.get(0).attributeValue("var")); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestTest.java new file mode 100644 index 0000000000..e2c35a9576 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/Bind2RequestTest.java @@ -0,0 +1,148 @@ +package org.jivesoftware.openfire.net; + +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class Bind2RequestTest { + QName bindQName = new QName("bind", new Namespace("", "urn:xmpp:bind:0")); + + @Test + public void testFromNullElement() { + assertNull(Bind2Request.from(null)); + } + + @Test + public void testFromElementWithoutBindElement() { + Element authenticate = DocumentHelper.createElement("authenticate"); + assertNull(Bind2Request.from(authenticate)); + } + + @Test + public void testFromElementWithWrongNamespace() { + Element authenticate = DocumentHelper.createElement("authenticate"); + Element bind = authenticate.addElement("bind"); + bind.addNamespace("", "wrong:namespace"); + + assertNull(Bind2Request.from(authenticate)); + } + + @Test + public void testFromElementWithTagOnly() { + Element authenticate = DocumentHelper.createElement("authenticate"); + Element bind = authenticate.addElement(bindQName); + Element tag = bind.addElement("tag"); + tag.setText("MyXMPPClient"); + + Bind2Request result = Bind2Request.from(authenticate); + + assertNotNull(result); + assertEquals("MyXMPPClient", result.getClientTag()); + assertTrue(result.getFeatureRequests().isEmpty()); + } + + @Test + public void testFromElementWithFeatureRequestsOnly() { + Element authenticate = DocumentHelper.createElement("authenticate"); + Element bind = authenticate.addElement(bindQName); + + bind.addElement(new QName("feature1", new Namespace("", "urn:xmpp:feature1:0"))); + bind.addElement(new QName("feature2", new Namespace("", "urn:xmpp:feature2:0"))); + + Bind2Request result = Bind2Request.from(authenticate); + + assertNotNull(result); + assertNull(result.getClientTag()); + assertEquals(2, result.getFeatureRequests().size()); + assertEquals("feature1", result.getFeatureRequests().get(0).getName()); + assertEquals("feature2", result.getFeatureRequests().get(1).getName()); + } + + @Test + public void testFromElementWithTagAndFeatures() { + Element authenticate = DocumentHelper.createElement("authenticate"); + Element bind = authenticate.addElement(bindQName); + + Element tag = bind.addElement("tag"); + tag.setText("MyXMPPClient"); + + bind.addElement(new QName("feature", new Namespace("", "urn:xmpp:feature:0"))); + + Bind2Request result = Bind2Request.from(authenticate); + + assertNotNull(result); + assertEquals("MyXMPPClient", result.getClientTag()); + assertEquals(1, result.getFeatureRequests().size()); + } + + @Test + public void testGenerateResourceStringWithNoTagNoId() { + Bind2Request request = new Bind2Request(null, List.of()); + UserAgentInfo userAgentInfo = new UserAgentInfo(); + + String result = request.generateResourceString(userAgentInfo); + + assertTrue(result.matches("^[0-9a-f]{16}$")); // Should be a 16-character hex string + } + + @Test + public void testGenerateResourceStringWithTagNoId() { + Bind2Request request = new Bind2Request("MyClient", List.of()); + UserAgentInfo userAgentInfo = new UserAgentInfo(); + + String result = request.generateResourceString(userAgentInfo); + + assertTrue(result.startsWith("MyClient/")); + assertTrue(result.substring(9).matches("^[0-9a-f]{16}$")); // HMAC part should be 16 hex chars + } + + @Test + public void testGenerateResourceStringWithTagAndId() { + Bind2Request request = new Bind2Request("MyClient", List.of()); + UserAgentInfo userAgentInfo = new UserAgentInfo(); + String uuid = "550e8400-e29b-41d4-a716-446655440000"; + userAgentInfo.setId(uuid); + + String result = request.generateResourceString(userAgentInfo); + + assertTrue(result.startsWith("MyClient/")); + // Same UUID should generate same HMAC + assertEquals(result, request.generateResourceString(userAgentInfo)); + } + + @Test + public void testGenerateResourceStringDifferentTagsSameIdProduceDifferentResults() { + UserAgentInfo userAgentInfo = new UserAgentInfo(); + String uuid = UUID.randomUUID().toString(); + userAgentInfo.setId(uuid); + + Bind2Request request1 = new Bind2Request("Client1", List.of()); + Bind2Request request2 = new Bind2Request("Client2", List.of()); + + String result1 = request1.generateResourceString(userAgentInfo); + String result2 = request2.generateResourceString(userAgentInfo); + + assertNotEquals(result1, result2); + } + + @Test + public void testFeatureRequestsImmutability() { + List features = new ArrayList<>(); + Element feature = DocumentHelper.createElement("feature"); + features.add(feature); + + Bind2Request request = new Bind2Request("MyClient", features); + + assertThrows(UnsupportedOperationException.class, () -> { + request.getFeatureRequests().add(DocumentHelper.createElement("newfeature")); + }); + } +} 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 e27182b2d5..cf8d6b63c4 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/SASLAuthenticationTest.java @@ -29,6 +29,7 @@ import org.jivesoftware.openfire.session.LocalSession; import org.jivesoftware.openfire.session.ServerSession; 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; @@ -44,6 +45,8 @@ import java.util.Locale; import java.util.Set; +import static org.jivesoftware.openfire.net.SASLAuthentication.SASL_NAMESPACE; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -57,8 +60,6 @@ */ public class SASLAuthenticationTest { - private static final String SASL_NAMESPACE = "urn:ietf:params:xml:ns:xmpp-sasl"; - @BeforeAll public static void setupClass() throws Exception { @@ -98,7 +99,7 @@ public void shouldRejectExternalForUnencryptedClientSessionAsInvalidMechanism() final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); // Execute system under test. - final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("EXTERNAL")); + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("EXTERNAL"), false); // Verify result. assertEquals(SASLAuthentication.Status.failed, status, "Expected SASL negotiation to fail when EXTERNAL is requested on an unencrypted client session."); @@ -123,7 +124,7 @@ public void shouldRejectPlainForUnencryptedIncomingServerSessionAsInvalidMechani final LocalIncomingServerSession session = new LocalIncomingServerSession(Fixtures.XMPP_DOMAIN, connection, streamID, "remote.example.org"); // Execute system under test. - final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN")); + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN"), false); // Verify result. assertEquals(SASLAuthentication.Status.failed, status, "Expected SASL negotiation to fail when PLAIN is requested for an inbound server session that does not advertise it."); @@ -148,7 +149,7 @@ public void shouldAcceptPlainForUnencryptedClientSessionAsEligibleMechanism() final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); // Execute system under test. - final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN")); + final SASLAuthentication.Status status = SASLAuthentication.handle(session, authElement("PLAIN"), false); // Verify result. assertEquals(SASLAuthentication.Status.needResponse, status, "Expected PLAIN to be accepted and continue negotiation by issuing a challenge."); @@ -177,7 +178,7 @@ public void shouldMarkIncomingServerSessionAsSaslExternalForExternalMechanism() session.setSessionData("SaslServer", saslServer); // Execute system under test. - final SASLAuthentication.Status status = SASLAuthentication.handle(session, responseElement("")); + final SASLAuthentication.Status status = SASLAuthentication.handle(session, responseElement(""), false); // Verify result. assertEquals(SASLAuthentication.Status.authenticated, status, "Expected authentication to complete for a completed EXTERNAL SASL server."); @@ -203,7 +204,7 @@ public void shouldMarkIncomingServerSessionAsOtherForNonExternalMechanism() thro session.setSessionData("SaslServer", saslServer); // Execute system under test. - final SASLAuthentication.Status status = SASLAuthentication.handle(session, responseElement("")); + final SASLAuthentication.Status status = SASLAuthentication.handle(session, responseElement(""), false); // Verify result. assertEquals(SASLAuthentication.Status.authenticated, status, "Expected authentication to complete for a completed non-EXTERNAL SASL server."); @@ -312,7 +313,7 @@ public void shouldGenerateAnonymousAuthTokenForClientWhenUsernameIsNull() final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); // Execute system under test. - SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0]); + SASLAuthentication.authenticationSuccessful(session, null, "ANONYMOUS", new byte[0], false); // Verify result. final AuthToken authToken = session.getAuthToken(); @@ -336,7 +337,7 @@ public void shouldGenerateUserAuthTokenForClientWhenUsernameIsProvided() final String username = "testuser"; // Execute system under test. - SASLAuthentication.authenticationSuccessful(session, username, "PLAIN", new byte[0]); + SASLAuthentication.authenticationSuccessful(session, username, "PLAIN", new byte[0], false); // Verify result. final AuthToken authToken = session.getAuthToken(); @@ -360,7 +361,7 @@ public void shouldMarkDomainAsValidatedForIncomingServerSession() final String remoteDomain = "remote.example.org"; // Execute system under test. - SASLAuthentication.authenticationSuccessful(session, remoteDomain, "EXTERNAL", new byte[0]); + SASLAuthentication.authenticationSuccessful(session, remoteDomain, "EXTERNAL", new byte[0], false); // Verify result. assertTrue(session.isValidDomain(remoteDomain), "Expected remote domain to be marked as validated."); @@ -369,6 +370,80 @@ public void shouldMarkDomainAsValidatedForIncomingServerSession() assertTrue(response.getValue().contains("} inline feature when + * stream management is active. + */ + @Test + public void sasl2MechanismsElementShouldIncludeSmInlineFeatureWhenSmIsActive() + { + // Setup test fixture. + JiveGlobals.setProperty(StreamManager.ACTIVE.getKey(), "true"); + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + final Element result = SASLAuthentication.getSASLMechanismsElement(session, true); + + // Verify result. + assertNotNull(result, "Expected a non-null mechanisms element."); + final Element inline = result.element("inline"); + assertNotNull(inline, "Expected an element in the SASL2 mechanisms element."); + final Element sm = inline.element(new QName("sm", Namespace.get("", StreamManager.NAMESPACE_V3))); + assertNotNull(sm, "Expected an element in the SASL2 element when SM is active."); + assertEquals(StreamManager.NAMESPACE_V3, sm.getNamespaceURI(), "Expected the element to use the SM v3 namespace."); + } + + /** + * Verifies that the SASL2 mechanisms element does not include an {@code } inline feature + * when stream management is inactive. + */ + @Test + public void sasl2MechanismsElementShouldNotIncludeSmInlineFeatureWhenSmIsInactive() + { + // Setup test fixture. + JiveGlobals.setProperty(StreamManager.ACTIVE.getKey(), "false"); + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + final Element result = SASLAuthentication.getSASLMechanismsElement(session, true); + + // Verify result. + assertNotNull(result, "Expected a non-null mechanisms element."); + final Element inline = result.element("inline"); + assertNotNull(inline, "Expected an element in the SASL2 mechanisms element."); + final Element sm = inline.element(new QName("sm", Namespace.get("", StreamManager.NAMESPACE_V3))); + assertNull(sm, "Expected no element in the SASL2 element when SM is inactive."); + } + + /** + * Verifies that the SASL1 mechanisms element does not include an {@code } element + * (SM inline features are only for SASL2). + */ + @Test + public void sasl1MechanismsElementShouldNotIncludeInlineElement() + { + // Setup test fixture. + JiveGlobals.setProperty(StreamManager.ACTIVE.getKey(), "true"); + final Connection connection = mock(Connection.class); + when(connection.isEncrypted()).thenReturn(false); + final StreamID streamID = new BasicStreamIDFactory().createStreamID(); + final LocalClientSession session = new LocalClientSession(Fixtures.XMPP_DOMAIN, connection, streamID, Locale.ENGLISH); + + // Execute system under test. + final Element result = SASLAuthentication.getSASLMechanismsElement(session, false); + + // Verify result. + assertNotNull(result, "Expected a non-null mechanisms element."); + final Element inline = result.element("inline"); + assertNull(inline, "Expected no element in the SASL1 mechanisms element."); + } + private static Element authElement(final String mechanism) { final Element auth = DocumentHelper.createElement(new QName("auth", Namespace.get("", SASL_NAMESPACE))); diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/UserAgentInfoTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/UserAgentInfoTest.java new file mode 100644 index 0000000000..24dc304587 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/UserAgentInfoTest.java @@ -0,0 +1,127 @@ +package org.jivesoftware.openfire.net; + +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class UserAgentInfoTest { + + @Test + public void testNullElement() { + assertNull(UserAgentInfo.extract(null)); + } + + @Test + public void testEmptyUserAgent() { + Element userAgent = DocumentHelper.createElement("user-agent"); + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); + assertNull(info.getSoftware()); + assertNull(info.getDevice()); + } + + @Test + public void testCompleteValidUserAgent() { + Element userAgent = DocumentHelper.createElement("user-agent"); + userAgent.addAttribute("id", "123e4567-e89b-42d3-a456-556642440000"); // Valid UUID v4 + + Element software = userAgent.addElement("software"); + software.setText("My XMPP Client v1.0"); + + Element device = userAgent.addElement("device"); + device.setText("Android Phone Model X"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertEquals("123e4567-e89b-42d3-a456-556642440000", info.getId()); + assertEquals("My XMPP Client v1.0", info.getSoftware()); + assertEquals("Android Phone Model X", info.getDevice()); + } + + @Test + public void testInvalidUUIDFormat() { + Element userAgent = DocumentHelper.createElement("user-agent"); + userAgent.addAttribute("id", "not-a-uuid"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); // Invalid UUID should be ignored + } + + @Test + public void testNonV4UUID() { + // This is a valid UUID v1 + Element userAgent = DocumentHelper.createElement("user-agent"); + userAgent.addAttribute("id", "123e4567-e89b-12d3-a456-556642440000"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); // Non-v4 UUID should be ignored + } + + @Test + public void testOnlySoftware() { + Element userAgent = DocumentHelper.createElement("user-agent"); + Element software = userAgent.addElement("software"); + software.setText("My XMPP Client v1.0"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); + assertEquals("My XMPP Client v1.0", info.getSoftware()); + assertNull(info.getDevice()); + } + + @Test + public void testOnlyDevice() { + Element userAgent = DocumentHelper.createElement("user-agent"); + Element device = userAgent.addElement("device"); + device.setText("Android Phone Model X"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); + assertNull(info.getSoftware()); + assertEquals("Android Phone Model X", info.getDevice()); + } + + @Test + public void testEmptyElements() { + Element userAgent = DocumentHelper.createElement("user-agent"); + userAgent.addElement("software"); + userAgent.addElement("device"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertNull(info.getId()); + assertEquals("", info.getSoftware()); + assertEquals("", info.getDevice()); + } + + @Test + public void testWhitespaceHandling() { + Element userAgent = DocumentHelper.createElement("user-agent"); + + Element software = userAgent.addElement("software"); + software.setText(" My XMPP Client v1.0 "); + + Element device = userAgent.addElement("device"); + device.setText("\tAndroid Phone Model X\n"); + + UserAgentInfo info = UserAgentInfo.extract(userAgent); + + assertNotNull(info); + assertEquals("My XMPP Client v1.0", info.getSoftware()); + assertEquals("Android Phone Model X", info.getDevice()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLAuthenticationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLAuthenticationTest.java new file mode 100644 index 0000000000..efbcccf861 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/SASLAuthenticationTest.java @@ -0,0 +1,785 @@ +package org.jivesoftware.openfire.sasl; + +import org.dom4j.Element; +import org.dom4j.DocumentHelper; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.XMPPServerInfo; +import org.jivesoftware.openfire.lockout.LockOutFlag; +import org.jivesoftware.openfire.lockout.LockOutManager; +import org.jivesoftware.openfire.lockout.LockOutProvider; +import org.jivesoftware.openfire.net.SASLAuthentication; +import org.jivesoftware.openfire.session.LocalClientSession; +import org.jivesoftware.openfire.session.LocalIncomingServerSession; +import org.jivesoftware.openfire.session.LocalSession; +import org.jivesoftware.util.JiveGlobals; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.SessionManager; +import org.jivesoftware.openfire.auth.AuthToken; +import org.jivesoftware.openfire.XMPPServer; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.xmpp.packet.JID; +import org.jivesoftware.util.cache.CacheFactory; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SASLAuthenticationTest { + + @Mock(lenient = true) + private LocalClientSession clientSession; + + @Mock(lenient = true) + private Connection connection; + + @Mock + private LocalIncomingServerSession serverSession; + + @Mock(lenient = true) + private XMPPServer xmppServer; + + @Mock(lenient = true) + private XMPPServerInfo serverInfo; + + private Element features; + + private TestSaslMechanism.TestSaslServer testSaslServer; + + // Create a real map to store session data + private Map sessionDataMap; + + // Store/restore variables. + private static List originalEnabledMechanisms; + private static boolean originalSasl2Enabled; + private static boolean originalSasl2TLSRequired; + private static LockOutManager lockOutManager; + + @BeforeAll + public static void setUpClass() throws Exception { + CacheFactory.initialize(); + // Set this or I can't set anything else. + JiveGlobals.setXMLProperty("setup", "true"); + originalEnabledMechanisms = new ArrayList<>(SASLAuthentication.getEnabledMechanisms()); + originalSasl2Enabled = SASLAuthentication.ENABLE_SASL2.getValue(); + originalSasl2TLSRequired = SASLAuthentication.SASL2_REQUIRE_TLS.getValue(); + SASLAuthentication.setEnabledMechanisms(Arrays.asList("BLURDYBLOOP", "TEST-MECHANISM")); + // Enable SASL2 + SASLAuthentication.ENABLE_SASL2.setValue(true); + SASLAuthentication.SASL2_REQUIRE_TLS.setValue(false); + } + + @AfterAll + public static void tearDownClass() { + SASLAuthentication.setEnabledMechanisms(originalEnabledMechanisms); + SASLAuthentication.ENABLE_SASL2.setValue(originalSasl2Enabled); + SASLAuthentication.SASL2_REQUIRE_TLS.setValue(originalSasl2TLSRequired); + } + + @BeforeEach + public void setUp() throws Exception { + // Setup XMPPServer mock + XMPPServer.setInstance(xmppServer); + when(xmppServer.getServerInfo()).thenReturn(serverInfo); + when(xmppServer.createJID(anyString(), anyString())).thenReturn(new JID("foo@bar")); + when(xmppServer.createJID(anyString(), isNull())).thenReturn(new JID("foo@bar")); + when(xmppServer.createJID(anyString(), anyString(), anyBoolean())).thenReturn(new JID("foo@bar")); + + // Setup ServerInfo mock + when(serverInfo.getXMPPDomain()).thenReturn("example.com"); + when(serverInfo.getHostname()).thenReturn("openfire.example.com"); + + // Setup Connection mock + when(clientSession.getConnection()).thenReturn(connection); + when(connection.getSupportedChannelBindingTypes()).thenReturn(Collections.emptySet()); + + features = DocumentHelper.createElement("features"); + + // Create our test SASL server + testSaslServer = TestSaslMechanism.registerTestMechanism(clientSession); + + // Enable our test mechanism + SASLAuthentication.addSupportedMechanism("TEST-MECHANISM"); + + + sessionDataMap = new HashMap<>(); + // Mock both get and set to use the real map + when(clientSession.getSessionData(anyString())).thenAnswer(inv -> + sessionDataMap.get(inv.getArgument(0))); + + doAnswer(inv -> { + sessionDataMap.put(inv.getArgument(0), inv.getArgument(1)); + return null; + }).when(clientSession).setSessionData(anyString(), any()); + + + // Instead of setting property, directly set the provider through reflection + try { + Field providerField = LockOutManager.class.getDeclaredField("provider"); + providerField.setAccessible(true); + lockOutManager = (LockOutManager) providerField.get(null); + + // Create anonymous implementation + LockOutProvider mockProvider = new LockOutProvider() { + @Override + public LockOutFlag getDisabledStatus(String username) { + return null; + } + @Override + public void setDisabledStatus(LockOutFlag flag) {} + @Override + public void unsetDisabledStatus(String username) {} + @Override + public boolean isReadOnly() { return false; } + @Override + public boolean isDelayedStartSupported() { return false; } + @Override + public boolean isTimeoutSupported() { return false; } + @Override + public boolean shouldNotBeCached() { return true; } + }; + + providerField.set(null, mockProvider); + } catch (Exception e) { + fail("Could not set mock provider: " + e.getMessage()); + } + } + + @AfterEach + public void tearDown() throws Exception { + // Clear any SASL state + // SASLAuthentication.setEnabledMechanisms(null); + // Clear caches + CacheFactory.clearCaches(); + testSaslServer = null; + TestSaslMechanism.unregisterTestMechanism(); + XMPPServer.setInstance(null); + try { + Field providerField = LockOutManager.class.getDeclaredField("provider"); + providerField.setAccessible(true); + providerField.set(null, lockOutManager); + } catch (Exception ex) { + // Just ignore an error here. + } + } + + @Test + public void testRegisteredSaslProvider() { + Set implemented = SASLAuthentication.getImplementedMechanisms(); + assertNotNull(implemented); + assertFalse(implemented.isEmpty()); + assertTrue(implemented.contains("TEST-MECHANISM")); + Set enabled = SASLAuthentication.getSupportedMechanisms(); + assertNotNull(enabled); + assertFalse(enabled.isEmpty()); + assertTrue(enabled.contains("TEST-MECHANISM")); + } + + // Existing tests + @Test + public void testAddSupportedMechanism() { + // Test adding valid mechanism + SASLAuthentication.addSupportedMechanism("PLAIN"); + assertTrue(SASLAuthentication.getSupportedMechanisms().contains("PLAIN")); + + // Test adding lowercase mechanism (should be converted to uppercase) + SASLAuthentication.addSupportedMechanism("digest-md5"); + assertTrue(SASLAuthentication.getSupportedMechanisms().contains("DIGEST-MD5")); + + // Test null mechanism + assertThrows(IllegalArgumentException.class, () -> { + SASLAuthentication.addSupportedMechanism(null); + }); + + // Test empty mechanism + assertThrows(IllegalArgumentException.class, () -> { + SASLAuthentication.addSupportedMechanism(""); + }); + } + + @Test + public void testRemoveSupportedMechanism() { + // Add and then remove a mechanism + SASLAuthentication.addSupportedMechanism("PLAIN"); + SASLAuthentication.removeSupportedMechanism("PLAIN"); + assertFalse(SASLAuthentication.getSupportedMechanisms().contains("PLAIN")); + + // Test case insensitive removal + SASLAuthentication.addSupportedMechanism("DIGEST-MD5"); + SASLAuthentication.removeSupportedMechanism("digest-md5"); + assertFalse(SASLAuthentication.getSupportedMechanisms().contains("DIGEST-MD5")); + + // Test removing non-existent mechanism + SASLAuthentication.removeSupportedMechanism("NONEXISTENT"); + // Should not throw exception + + // Test null mechanism + assertThrows(IllegalArgumentException.class, () -> { + SASLAuthentication.removeSupportedMechanism(null); + }); + } + + @Test + public void testGetSupportedMechanisms() { + Set implemented = SASLAuthentication.getImplementedMechanisms(); + Set mechanisms = SASLAuthentication.getSupportedMechanisms(); + assertNotNull(mechanisms); + + // Add multiple mechanisms and verify they're all present + SASLAuthentication.addSupportedMechanism("PLAIN"); + SASLAuthentication.addSupportedMechanism("DIGEST-MD5"); + + mechanisms = SASLAuthentication.getSupportedMechanisms(); + + assertTrue(mechanisms.contains("PLAIN")); + assertTrue(mechanisms.contains("DIGEST-MD5")); + } + + @Test + public void testGetEnabledMechanisms() { + // Test default enabled mechanisms + List enabled = SASLAuthentication.getEnabledMechanisms(); + assertNotNull(enabled); + assertFalse(enabled.isEmpty()); + + // Verify expected default mechanisms are present + assertTrue(enabled.contains("BLURDYBLOOP")); + assertTrue(enabled.contains("TEST-MECHANISM")); + } + + @Test + public void testGetImplementedMechanisms() { + // Verify we get some implemented mechanisms + Set implemented = SASLAuthentication.getImplementedMechanisms(); + assertNotNull(implemented); + assertFalse(implemented.isEmpty()); + + // Verify common mechanisms are present + assertTrue(implemented.contains("PLAIN")); + assertTrue(implemented.contains("DIGEST-MD5")); + assertFalse(implemented.contains("BLURDYBLOOP")); + assertTrue(implemented.contains("TEST-MECHANISM")); + } + + // New tests for addSASLMechanisms functionality + @Test + public void testAddSASLMechanismsToAuthenticatedSession() { + // Setup + when(clientSession.isAuthenticated()).thenReturn(true); + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(clientSession); + + // Verify + assertTrue(mechanisms.isEmpty(), + "No SASL mechanisms should be added for authenticated sessions"); + } + + @Test + public void testAddSASLMechanismsToClientSession() { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + + // Disable SASL2 + SASLAuthentication.ENABLE_SASL2.setValue(false); + + try { + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(clientSession); + + // Verify + assertFalse(mechanisms.isEmpty(), "SASL mechanisms should be added"); + + // Should have no SASL2 mechanisms elements + assertEquals(1, mechanisms.size(), "Should have no SASL2 mechanisms"); + + // Verify both namespaces are present without assuming order + Set namespaces = mechanisms.stream() + .map(Element::getNamespaceURI) + .collect(Collectors.toSet()); + assertTrue(namespaces.contains("urn:ietf:params:xml:ns:xmpp-sasl"), + "SASL namespace should be present"); + assertFalse(namespaces.contains("urn:xmpp:sasl:2"), "SASL2 namespace should be present"); + } finally { + // Enable SASL2 + SASLAuthentication.ENABLE_SASL2.setValue(true); + + } + } + + @Test + public void testAddSASLMechanismsToClientSessionWithSASL2() { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(clientSession); + + // Verify + assertFalse(mechanisms.isEmpty(), "SASL mechanisms should be added"); + + // Should have both SASL and SASL2 mechanisms elements + assertEquals(2, mechanisms.size(), "Should have both SASL and SASL2 mechanisms"); + + // Verify both namespaces are present without assuming order + Set namespaces = mechanisms.stream() + .map(Element::getNamespaceURI) + .collect(Collectors.toSet()); + assertTrue(namespaces.contains("urn:ietf:params:xml:ns:xmpp-sasl"), + "SASL namespace should be present"); + assertTrue(namespaces.contains("urn:xmpp:sasl:2"), + "SASL2 namespace should be present"); + } + + @Test + public void testAddSASLMechanismsToServerSession() { + // Setup + when(serverSession.isAuthenticated()).thenReturn(false); + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(serverSession); + + // Verify + assertFalse(mechanisms.isEmpty(), "SASL mechanisms should be added"); + + // Should have both SASL and SASL2 mechanisms elements + assertEquals(2, mechanisms.size(), "Should have both SASL and SASL2 mechanisms"); + + // Verify both namespaces are present without assuming order + Set namespaces = mechanisms.stream() + .map(Element::getNamespaceURI) + .collect(Collectors.toSet()); + assertTrue(namespaces.contains("urn:ietf:params:xml:ns:xmpp-sasl"), + "SASL namespace should be present"); + assertTrue(namespaces.contains("urn:xmpp:sasl:2"), + "SASL2 namespace should be present"); + } + + @Test + public void testAddSASLMechanismsToList() { + // Setup + List featuresList = new ArrayList<>(); + when(clientSession.isAuthenticated()).thenReturn(false); + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(clientSession); + + // Verify + assertEquals(2, mechanisms.size(), + "Should add both SASL and SASL2 mechanisms to list"); + + // Verify both namespaces are present without assuming order + Set namespaces = mechanisms.stream() + .map(Element::getNamespaceURI) + .collect(Collectors.toSet()); + assertTrue(namespaces.contains("urn:ietf:params:xml:ns:xmpp-sasl"), + "SASL namespace should be present"); + assertTrue(namespaces.contains("urn:xmpp:sasl:2"), + "SASL2 namespace should be present"); + } + + @Test + public void testAddSASLMechanismsToUnknownSessionType() { + // Setup + LocalSession unknownSession = mock(LocalSession.class); + when(unknownSession.isAuthenticated()).thenReturn(false); + + // Execute + final List mechanisms = SASLAuthentication.getSASLMechanisms(unknownSession); + + // Verify + assertTrue(mechanisms.isEmpty(), + "Unknown session types should not get any mechanisms"); + } + + @Test + public void testAuthenticationWithoutInitialResponse() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Execute - First step: Client sends auth without initial response + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends success + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + String xml = responseCaptor.getValue(); + Element response = DocumentHelper.parseText(xml).getRootElement(); + assertNull(clientSession.getSessionData(SASLAuthentication.SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY)); + assertEquals("success", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + assertEquals("", response.getText()); // We gave no IR, so no success-data reflected. + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + assertFalse(clientSession.isAnonymousUser()); + } + + @Test + public void testAuthenticationWithInitialResponse() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + auth.setText(Base64.getEncoder().encodeToString("initial-response".getBytes())); // Empty initial response + + // Execute - Client sends auth with initial response + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends success + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("success", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + String additionalData = new String(Base64.getDecoder().decode(response.getText()), StandardCharsets.UTF_8); + assertEquals("initial-response", additionalData); + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + assertFalse(clientSession.isAnonymousUser()); + } + + @Test + public void testAuthenticationWithSASL2() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Execute - Client sends auth request + SASLAuthentication.handle(clientSession, auth, true); + + // Verify server sends success with SASL2 format + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("success", response.getName()); + assertEquals("urn:xmpp:sasl:2", response.getNamespaceURI()); + + Element additionalData = response.element("additional-data"); + assertNull(additionalData, "SASL2 success must not include additional-data"); + + // Verify authorization-identifier is present + Element authId = response.element("authorization-identifier"); + assertNotNull(authId, "SASL2 success must include authorization-identifier"); + assertTrue(authId.getText().contains("@"), "Authorization ID should be a full JID"); + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + assertFalse(clientSession.isAnonymousUser()); + } + + @Test + public void testAuthenticationWithSASL2andIR() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM") + .addElement("initial-response") + .addCDATA(Base64.getEncoder().encodeToString("initial-response".getBytes())) + .getParent(); + + // Execute - Client sends auth request + SASLAuthentication.handle(clientSession, auth, true); + + // Verify server sends success with SASL2 format + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("success", response.getName()); + assertEquals("urn:xmpp:sasl:2", response.getNamespaceURI()); + + Element additionalData = response.element("additional-data"); + assertNotNull(additionalData, "SASL2 success must include additional-data"); + String responseEnc = additionalData.getText(); + byte[] resp = Base64.getDecoder().decode(responseEnc.getBytes(StandardCharsets.UTF_8)); + String additionalDataString = new String(resp, StandardCharsets.UTF_8); + assertEquals("initial-response", additionalDataString); + + // Verify authorization-identifier is present + Element authId = response.element("authorization-identifier"); + assertNotNull(authId, "SASL2 success must include authorization-identifier"); + assertTrue(authId.getText().contains("@"), "Authorization ID should be a full JID"); + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + assertFalse(clientSession.isAnonymousUser()); + } + + @Test + public void testAuthenticationWithSASL2andIRMultistep() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + testSaslServer.setSteps(2); + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM") + .addElement("initial-response") + .addCDATA(Base64.getEncoder().encodeToString("initial-response".getBytes())) + .getParent(); + + // Execute - Client sends auth request + SASLAuthentication.handle(clientSession, auth, true); + + Element response = DocumentHelper.createElement(QName.get("response", "urn:xmpp:sasl:2")) + .addCDATA(Base64.getEncoder().encodeToString("subsequent-response".getBytes())); + SASLAuthentication.handle(clientSession, response, true); + + // Verify server sends challenge with SASL2 format + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession, times(2)).deliverRawText(responseCaptor.capture()); + + List responses = responseCaptor.getAllValues(); + + { + Element challenge = DocumentHelper.parseText(responses.get(0)).getRootElement(); + assertEquals("challenge", challenge.getName()); + assertEquals("urn:xmpp:sasl:2", challenge.getNamespaceURI()); + String responseEnc = challenge.getText(); + byte[] resp = Base64.getDecoder().decode(responseEnc.getBytes(StandardCharsets.UTF_8)); + String additionalDataString = new String(resp, StandardCharsets.UTF_8); + assertEquals("initial-response", additionalDataString); + } + + { + Element success = DocumentHelper.parseText(responses.get(1)).getRootElement(); + assertEquals("success", success.getName()); + assertEquals("urn:xmpp:sasl:2", success.getNamespaceURI()); + + Element additionalData = success.element("additional-data"); + assertNotNull(additionalData, "SASL2 success must include additional-data"); + String responseEnc2 = additionalData.getText(); + byte[] resp2 = Base64.getDecoder().decode(responseEnc2.getBytes(StandardCharsets.UTF_8)); + String additionalDataString2 = new String(resp2, StandardCharsets.UTF_8); + assertEquals("subsequent-response", additionalDataString2); + + // Verify authorization-identifier is present + Element authId = success.element("authorization-identifier"); + assertNotNull(authId, "SASL2 success must include authorization-identifier"); + assertTrue(authId.getText().contains("@"), "Authorization ID should be a full JID"); + } + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + assertFalse(clientSession.isAnonymousUser()); + } + + @Test + public void testAuthenticationFailureInvalidMechanism() throws Exception { + // Setup + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "INVALID-MECHANISM"); + + // Execute + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends failure + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("failure", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + assertNotNull(response.element("invalid-mechanism"), + "Should indicate invalid-mechanism as failure reason"); + + // Verify session state + verify(clientSession, never()).setAuthToken(any(AuthToken.class)); + verify(clientSession, never()).setAuthToken(null); + } + + @Test + public void testAuthenticationReplayAttack() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // First authentication + SASLAuthentication.handle(clientSession, auth, false); + + // Reset mock to verify second attempt + clearInvocations(clientSession); + + // Try to authenticate again with same session + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends failure + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + String xml = responseCaptor.getValue(); + Element response = DocumentHelper.parseText(xml).getRootElement(); + assertEquals("failure", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + assertNotNull(response.element("not-authorized"), + "Should indicate not-authorized as failure reason"); + } + + @Test + public void testSuccessfulAuthentication() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Execute - First step: Client sends auth without initial response + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends success + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("success", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + } + + @Test + public void testFailedAuthentication() throws Exception { + // Setup + Element auth = DocumentHelper.createElement(QName.get("auth", "urn:ietf:params:xml:ns:xmpp-sasl")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + testSaslServer.setThrowError(true); + + // Execute + SASLAuthentication.handle(clientSession, auth, false); + + // Verify server sends failure + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + Element response = DocumentHelper.parseText(responseCaptor.getValue()).getRootElement(); + assertEquals("failure", response.getName()); + assertEquals("urn:ietf:params:xml:ns:xmpp-sasl", response.getNamespaceURI()); + + // Verify session state + verify(clientSession, never()).setAuthToken(any(AuthToken.class)); + } + + @Test + public void testUserAgentCapturedForClientSession() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Add simple user-agent element + Element userAgent = auth.addElement("user-agent"); + userAgent.addElement("software").setText("Test Client"); + + // Execute authentication + SASLAuthentication.handle(clientSession, auth, true); // true for SASL2 + + // Verify user agent info was stored in session + assertNotNull(clientSession.getSessionData("user-agent-info")); +} + + @Test + public void testNoUserAgentWhenElementMissing() throws Exception { + // Setup + when(clientSession.isAuthenticated()).thenReturn(false); + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Execute authentication + SASLAuthentication.handle(clientSession, auth, true); // true for SASL2 + + // Verify no user agent info was stored + assertNull(clientSession.getSessionData("user-agent-info")); + } + + @Test + public void testNoUserAgentForServerSession() throws Exception { + // Setup + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Add user-agent element + Element userAgent = auth.addElement("user-agent"); + userAgent.addElement("software").setText("Test Server"); + + // Execute authentication + SASLAuthentication.handle(serverSession, auth, true); // true for SASL2 + + // Verify no user agent info was stored + assertNull(serverSession.getSessionData("user-agent-info")); // Fixed to check serverSession instead of clientSession + } + + @Test + public void testAuthenticationWithSASL2AndBind2IncludesResource() throws Exception { + // Setup a SessionManager mock that returns BOUND from bindResource. + SessionManager sessionManager = mock(SessionManager.class); + when(xmppServer.getSessionManager()).thenReturn(sessionManager); + when(sessionManager.bindResource(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(SessionManager.BindResult.BOUND)); + + // Mock connection so getAvailableMechanismsForClientSession assertion passes. + Connection connection = mock(Connection.class); + when(clientSession.getConnection()).thenReturn(connection); + + when(clientSession.isAuthenticated()).thenReturn(false); + when(clientSession.getStatus()).thenReturn(org.jivesoftware.openfire.session.Session.Status.CONNECTED); + when(clientSession.getAuthToken()).thenReturn(AuthToken.generateUserToken("testuser")); + + Element auth = DocumentHelper.createElement(QName.get("authenticate", "urn:xmpp:sasl:2")) + .addAttribute("mechanism", "TEST-MECHANISM"); + + // Add bind2 element + Element bind = auth.addElement(new QName("bind", new Namespace("", "urn:xmpp:bind:0"))); + bind.addElement("tag").setText("MyClient"); + + // Execute - Client sends auth request + SASLAuthentication.handle(clientSession, auth, true); + + // The response is delivered asynchronously inside the whenComplete callback. + // Since we used completedFuture, the callback runs synchronously on the calling thread. + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(String.class); + verify(clientSession).deliverRawText(responseCaptor.capture()); + + String responseString = responseCaptor.getValue(); + Element response = DocumentHelper.parseText(responseString).getRootElement(); + assertEquals("success", response.getName()); + assertEquals("urn:xmpp:sasl:2", response.getNamespaceURI()); + + // Verify authorization-identifier is present and includes a resource + Element authId = response.element("authorization-identifier"); + assertNotNull(authId, "SASL2 success must include authorization-identifier"); + String jid = authId.getText(); + assertTrue(jid.contains("@"), "Authorization ID should be a JID"); + assertTrue(jid.contains("/"), "Authorization ID should include a resource part"); + assertTrue(jid.contains("MyClient"), "Resource should include client tag"); + + // Verify is present (XEP-0386) + Element bound = response.element("bound"); + assertNotNull(bound, "SASL2 success must include bound element: " + responseString); + + // Verify session state + verify(clientSession).setAuthToken(any(AuthToken.class)); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/TestSaslMechanism.java b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/TestSaslMechanism.java new file mode 100644 index 0000000000..8775f4d389 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/sasl/TestSaslMechanism.java @@ -0,0 +1,153 @@ +package org.jivesoftware.openfire.sasl; + +import org.jivesoftware.openfire.net.SASLAuthentication; +import org.jivesoftware.openfire.session.LocalSession; + +import javax.security.auth.callback.CallbackHandler; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; +import javax.security.sasl.SaslServerFactory; +import java.security.Provider; +import java.security.Security; +import java.util.Map; + +public class TestSaslMechanism { + /** + * A test SASL mechanism that we can control for testing purposes + */ + public static class TestSaslServer implements SaslServer { + private String authorizationID = null; + private boolean throwError = false; + private long steps = 1; + private LocalSession clientSession; + + public TestSaslServer(LocalSession clientSession) { + this.clientSession = clientSession; + } + + public void reset() { + authorizationID = null; + throwError = false; + steps = 1; + } + + @Override + public String getMechanismName() { + return "TEST-MECHANISM"; + } + + @Override + public byte[] evaluateResponse(byte[] response) throws SaslException { + if ( response.length == 0 ) + { + if (clientSession.getSessionData(SASLAuthentication.SASL_LAST_RESPONSE_WAS_PROVIDED_BUT_EMPTY) == null) { + response = null; + } + } + if (throwError) { + throw new SaslException("Authentication failed"); + } + if (this.steps <= 0) { + throw new SaslException("Authentication steps exceeded: " + this.steps); + } + this.steps--; + authorizationID = "test-user"; + return response; + } + + @Override + public boolean isComplete() { + return this.steps == 0; + } + + @Override + public String getAuthorizationID() { + return authorizationID; + } + + @Override + public byte[] unwrap(byte[] incoming, int offset, int len) throws SaslException { + return new byte[0]; + } + + @Override + public byte[] wrap(byte[] outgoing, int offset, int len) throws SaslException { + return new byte[0]; + } + + @Override + public Object getNegotiatedProperty(String propName) { + return null; + } + + @Override + public void dispose() throws SaslException { + } + + public void setThrowError(boolean throwError) { + this.throwError = throwError; + } + + public void setSteps(long steps) { + this.steps = steps; + } + } + + /** + * A test SASL Server factory that creates our test mechanism + */ + public static class TestSaslServerFactory implements SaslServerFactory { + private static ThreadLocal saslServer = new ThreadLocal<>(); + + public TestSaslServerFactory() { + // Default constructor required for factory instantiation + } + + private static void setSaslServer(TestSaslServer server) { + saslServer.set(server); + } + + private static void clearSaslServer() { + saslServer.remove(); + } + + @Override + public SaslServer createSaslServer(String mechanism, String protocol, + String serverName, Map props, + CallbackHandler cbh) throws SaslException { + if ("TEST-MECHANISM".equals(mechanism)) { + return saslServer.get(); + } + return null; + } + + @Override + public String[] getMechanismNames(Map props) { + return new String[]{"TEST-MECHANISM"}; + } + } + + /** + * Helper method to register the test mechanism + */ + public static TestSaslServer registerTestMechanism(LocalSession clientSession) { + TestSaslServer testSaslServer = new TestSaslServer(clientSession); + + // Set the server instance before registering the provider + TestSaslServerFactory.setSaslServer(testSaslServer); + + if (Security.getProvider("Test Provider") == null) { + // Register the provider if not already registered + Security.addProvider(new Provider("Test Provider", "1.0", "Test Provider") {{ + put("SaslServerFactory.TEST-MECHANISM", TestSaslServerFactory.class.getName()); + }}); + } + + return testSaslServer; + } + + public static void unregisterTestMechanism() { + TestSaslServerFactory.clearSaslServer(); + Security.removeProvider("Test Provider"); + } +} 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..b8942bf92f 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,18 @@ */ package org.jivesoftware.openfire.streammanagement; +import org.dom4j.Element; +import org.jivesoftware.openfire.Connection; +import org.jivesoftware.openfire.session.LocalClientSession; import org.junit.jupiter.api.Test; +import org.xmpp.packet.JID; 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}. @@ -326,4 +332,105 @@ 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); + } + + /** + * Verifies that a freshly constructed StreamManager does not have a pending SASL2 redelivery. + */ + @Test + public void testPendingSasl2RedeliveryIsFalseByDefault() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + // Clear interactions caused by the constructor (e.g. getHostAddress()). + clearInvocations(mockConnection); + + // Execute system under test: redeliverIfPendingSasl2 with no flag set should be a no-op. + final JID serverAddress = new JID(null, "example.org", null, true); + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Verify result: no interaction with the connection (no stanzas delivered). + verifyNoInteractions(mockConnection); + } + + /** + * Verifies that setting the pending SASL2 redelivery flag and then calling + * redeliverIfPendingSasl2 clears the flag (i.e. a second call is a no-op). + */ + @Test + public void testRedeliverIfPendingSasl2ClearsFlagAfterFirstCall() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + streamManager.setPendingSasl2Redelivery(true); + + final JID serverAddress = new JID(null, "example.org", null, true); + + // First call: flag is set, so redelivery runs (no unacked stanzas, but the flag is consumed). + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Second call: flag has been cleared, so this must be a no-op. + // We verify by resetting the mock and confirming no further deliveries occur. + clearInvocations(mockConnection); + streamManager.redeliverIfPendingSasl2(serverAddress); + verifyNoInteractions(mockConnection); + } + + /** + * Verifies that setPendingSasl2Redelivery(false) prevents redeliverIfPendingSasl2 from acting. + */ + @Test + public void testSetPendingSasl2RedeliveryFalsePreventsTrigger() { + // Setup test fixture. + final LocalClientSession mockSession = mock(LocalClientSession.class); + final Connection mockConnection = mock(Connection.class); + when(mockSession.getConnection()).thenReturn(mockConnection); + final StreamManager streamManager = new StreamManager(mockSession); + // Clear interactions caused by the constructor (e.g. getHostAddress()). + clearInvocations(mockConnection); + + // Set then immediately clear the flag. + streamManager.setPendingSasl2Redelivery(true); + streamManager.setPendingSasl2Redelivery(false); + + // Execute system under test. + final JID serverAddress = new JID(null, "example.org", null, true); + streamManager.redeliverIfPendingSasl2(serverAddress); + + // Verify result: no interaction with the connection. + verifyNoInteractions(mockConnection); + } }