You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@activemq.apache.org by ma...@apache.org on 2026/10/15 11:14:03 UTC
(activemq) branch main updated: Feature: Support for authoriztion of connection fields (#2461)
This is an automated email from the ASF dual-hosted git repository.
mattrpav pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/activemq.git
The following commit(s) were added to refs/heads/main by this push:
new b67c5e0d40 Feature: Support for authoriztion of connection fields (#2461)
b67c5e0d40 is described below
commit b67c5e0d408449c652284c13957de2d6272489bf
Author: Matt Pavlovich <ma...@apache.org>
AuthorDate: Thu Oct 15 06:13:57 2026 -0500
Feature: Support for authoriztion of connection fields (#2461)
* Add isSsl to Connector
* Feature: Add support for authorizing connection values such as clientId to userId
- Includes support for clientId as a network connection authorization
* Review changes
---
.../java/org/apache/activemq/broker/Connector.java | 6 +
.../activemq/broker/TransportConnection.java | 30 +-
.../apache/activemq/broker/TransportConnector.java | 23 ++
.../apache/activemq/broker/jmx/ConnectorView.java | 5 +
.../activemq/broker/jmx/ConnectorViewMBean.java | 3 +
.../network/DemandForwardingBridgeSupport.java | 7 +-
.../security/JaasAuthenticationBroker.java | 65 +++-
.../security/JaasDualAuthenticationBroker.java | 7 +-
.../apache/activemq/security/SecurityContext.java | 19 +
.../transport/https/HttpsTransportServer.java | 5 +
.../apache/activemq/jaas/ConnectionCallback.java | 113 ++++++
.../apache/activemq/jaas/ConnectionPrincipal.java | 134 +++++++
.../jaas/JassCredentialCallbackHandler.java | 17 +
.../activemq/jaas/PropertiesLoginModule.java | 152 +++++++-
.../jaas/ClientIdPropertiesLoginModuleTest.java | 238 ++++++++++++
.../jaas/JassCredentialCallbackHandlerTest.java | 72 ++++
.../src/test/resources/clientid-users.properties | 21 ++
.../src/test/resources/clientids.properties | 33 ++
activemq-jaas/src/test/resources/login.config | 8 +
.../activemq/broker/TransportConnectorSslTest.java | 115 ++++++
.../JaasNetworkConnectionAuthorizationTest.java | 397 +++++++++++++++++++++
.../src/test/resources/login.config | 10 +-
.../activemq/security/clientid-users.properties | 19 +
.../apache/activemq/security/clientids.properties | 31 ++
assembly/src/release/conf/clientids.properties | 64 ++++
assembly/src/release/conf/login.config | 5 +-
26 files changed, 1577 insertions(+), 22 deletions(-)
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
index bf6fd7bec7..2133e66388 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
@@ -71,6 +71,12 @@ public interface Connector extends Service {
@Deprecated(forRemoval = true)
int connectionCount();
+ /**
+ * @return true if connections accepted by this connector are protected by
+ * SSL/TLS, that is the transport negotiates TLS with each client
+ */
+ boolean isSsl();
+
/**
* If enabled, older connections with the same clientID are stopped
*
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
index 3315cf0225..9696d37c0d 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
@@ -82,6 +82,7 @@ import org.apache.activemq.network.NetworkBridgeConfiguration;
import org.apache.activemq.network.NetworkBridgeFactory;
import org.apache.activemq.network.NetworkConnector;
import org.apache.activemq.security.MessageAuthorizationPolicy;
+import org.apache.activemq.security.SecurityContext;
import org.apache.activemq.state.CommandVisitor;
import org.apache.activemq.state.ConnectionState;
import org.apache.activemq.state.ConsumerState;
@@ -1222,9 +1223,9 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
if (stopping.compareAndSet(false, true)) {
// Let all the connection contexts know we are shutting down
// so that in progress operations can notice and unblock.
- List<TransportConnectionState> connectionStates = listConnectionStates();
- for (TransportConnectionState cs : connectionStates) {
- ConnectionContext connectionContext = cs.getContext();
+ var connectionStates = listConnectionStates();
+ for (var cs : connectionStates) {
+ var connectionContext = cs.getContext();
if (connectionContext != null) {
connectionContext.getStopping().set(true);
}
@@ -1298,8 +1299,8 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
// Remove all logical connection associated with this connection
// from the broker.
if (!broker.isStopped()) {
- List<TransportConnectionState> connectionStates = listConnectionStates();
- for (TransportConnectionState cs : connectionStates) {
+ var connectionStates = listConnectionStates();
+ for (var cs : connectionStates) {
cs.getContext().getStopping().set(true);
try {
LOG.debug("Cleaning up connection resources: {}", getRemoteAddress());
@@ -1480,9 +1481,26 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
setDuplexNetworkConnectorId(duplexNetworkConnectorId);
}
+ // Every bridge sends BrokerInfo before ConnectionInfo, so authentication sees the
+ // network flag and the login module decides. A BrokerInfo arriving after a
+ // connection has authenticated is a client promoting itself after the fact; where
+ // network connection authorization is configured that is refused outright, as the
+ // decision was taken without the flag.
+ var connectionStates = listConnectionStates();
+ for (var cs : connectionStates) {
+ var securityContext = cs.getContext().getSecurityContext();
+ if (securityContext == null || !securityContext.isNetworkConnectionAuthorizationRequired()) {
+ LOG.debug("Network connection authorization not configured for {}", getRemoteAddress());
+ continue;
+ }
+ LOG.warn("Rejecting BrokerInfo from {}: user {} authenticated before declaring a network connection",
+ getRemoteAddress(), securityContext.getUserName());
+ throw new IOException("BrokerInfo received after authentication from " + getRemoteAddress()
+ + "; network connections must send BrokerInfo first");
+ }
+
this.brokerInfo = info;
networkConnection = true;
- List<TransportConnectionState> connectionStates = listConnectionStates();
for (TransportConnectionState cs : connectionStates) {
cs.getContext().setNetworkConnection(true);
}
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
index 15ede72a80..4887d8e83a 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
@@ -22,6 +22,7 @@ import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import java.util.StringTokenizer;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -730,6 +731,28 @@ public class TransportConnector implements Connector, BrokerServiceAware {
return (server != null ? server.getMaxConnectionExceededCount() : 0l);
}
+ /**
+ * Once the transport server is bound its own answer is authoritative. Before
+ * that the configured scheme decides, so the flag is also usable while a broker
+ * is still being configured: ssl, nio+ssl, auto+nio+ssl, mqtt+ssl and so on,
+ * plus https and wss.
+ */
+ @Override
+ public boolean isSsl() {
+ if (server != null) {
+ return server.isSslServer();
+ }
+ if (uri == null || uri.getScheme() == null) {
+ return false;
+ }
+ for (String part : uri.getScheme().toLowerCase(Locale.ROOT).split("\\+")) {
+ if ("ssl".equals(part) || "https".equals(part) || "wss".equals(part)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public boolean isStarted() {
return started.get();
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
index acd00377d0..7ff3e8927c 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
@@ -86,6 +86,11 @@ public class ConnectorView implements ConnectorViewMBean {
return connector.getStatistics().isEnabled();
}
+ @Override
+ public boolean isSsl() {
+ return connector.isSsl();
+ }
+
/**
* Returns the number of current connections
*/
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
index 4d04022561..abf100d2f6 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
@@ -54,6 +54,9 @@ public interface ConnectorViewMBean extends Service {
@MBeanInfo("Statistics gathering enabled")
boolean isStatisticsEnabled();
+ @MBeanInfo("Connections are protected by SSL/TLS")
+ boolean isSsl();
+
/**
* Returns true if link stealing is enabled on this Connector
*
diff --git a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
index 9db3fc8b56..965efa8b53 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
@@ -530,6 +530,11 @@ public abstract class DemandForwardingBridgeSupport implements NetworkBridge, Br
X509Certificate[] peerCerts = originalTransport.getPeerCertificates();
localConnectionInfo.setTransportContext(peerCerts);
}
+ // Let the local broker know the remote broker's ID before the connection
+ // authenticates, so the local side declares itself a network connection the
+ // same way the remote and duplex inbound sides already do. This is an in-VM
+ // transport; nothing on the wire changes.
+ localBroker.oneway(remoteBrokerInfo);
// sync requests that may fail
Object resp = localBroker.request(localConnectionInfo);
if (resp instanceof ExceptionResponse) {
@@ -571,8 +576,6 @@ public abstract class DemandForwardingBridgeSupport implements NetworkBridge, Br
l.onStart(this);
}
- // Let the local broker know the remote broker's ID.
- localBroker.oneway(remoteBrokerInfo);
// new peer broker (a consumer can work with remote broker also)
brokerService.getBroker().addBroker(null, remoteBrokerInfo);
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java b/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
index 6756027361..a6b8b356a6 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
@@ -27,6 +27,10 @@ import org.apache.activemq.broker.Broker;
import org.apache.activemq.broker.ConnectionContext;
import org.apache.activemq.command.ConnectionInfo;
import org.apache.activemq.jaas.JassCredentialCallbackHandler;
+import org.apache.activemq.broker.Connection;
+import org.apache.activemq.broker.Connector;
+import org.apache.activemq.jaas.ConnectionCallback;
+import org.apache.activemq.jaas.ConnectionPrincipal;
/**
* Logs a user in using JAAS.
@@ -55,6 +59,25 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
public Set<Principal> getPrincipals() {
return subject.getPrincipals();
}
+
+ /**
+ * The login module records its decision on the ConnectionPrincipal when
+ * clientId authorization is configured; with no ConnectionPrincipal the
+ * feature is off and the connection is not restricted.
+ */
+ @Override
+ public boolean isNetworkConnectionAllowed() {
+ for (ConnectionPrincipal connection : subject.getPrincipals(ConnectionPrincipal.class)) {
+ return connection.isNetworkConnection();
+ }
+ return true;
+ }
+
+ /** a ConnectionPrincipal is only added when clientId authorization is configured */
+ @Override
+ public boolean isNetworkConnectionAuthorizationRequired() {
+ return !subject.getPrincipals(ConnectionPrincipal.class).isEmpty();
+ }
}
@Override
@@ -65,7 +88,7 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
Thread.currentThread().setContextClassLoader(JaasAuthenticationBroker.class.getClassLoader());
SecurityContext securityContext = null;
try {
- securityContext = authenticate(info.getUserName(), info.getPassword(), null);
+ securityContext = authenticate(info.getUserName(), info.getPassword(), connectionCallbackFrom(context, info));
context.setSecurityContext(securityContext);
securityContexts.add(securityContext);
super.addConnection(context, info);
@@ -83,14 +106,48 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
}
}
+ private ConnectionCallback connectionCallbackFrom(ConnectionContext context, ConnectionInfo info) {
+ var callback = new ConnectionCallback();
+ callback.setConnectionId(info.getConnectionId() != null ? info.getConnectionId().getValue() : null);
+ callback.setClientId(info.getClientId());
+ callback.setBrokerName(getBrokerName());
+ callback.setNetworkConnection(context.isNetworkConnection());
+ // the transport's view of the peer; ConnectionInfo.clientIp is client supplied
+ var connection = context.getConnection();
+ if (connection != null) {
+ callback.setRemoteAddress(connection.getRemoteAddress());
+ }
+ if (info.getTransportContext() instanceof X509Certificate[]) {
+ callback.setCertificates((X509Certificate[]) info.getTransportContext());
+ }
+ var connector = context.getConnector();
+ if (connector != null) {
+ callback.setSsl(connector.isSsl());
+ callback.setTransportConnectorName(connector.getName());
+ }
+ return callback;
+ }
+
@Override
public SecurityContext authenticate(String username, String password, X509Certificate[] certificates) throws SecurityException {
+ var connection = new ConnectionCallback();
+ connection.setCertificates(certificates);
+ return authenticate(username, password, connection);
+ }
+
+ /**
+ * Authenticates a connection, also handing the login module a description of
+ * the connection (id, clientId, broker name, network declaration, SSL, remote
+ * address, transport connector, client certificates) so clientId and network
+ * connection authorization can be applied.
+ */
+ public SecurityContext authenticate(String username, String password, ConnectionCallback connection) throws SecurityException {
SecurityContext result = null;
- JassCredentialCallbackHandler callback = new JassCredentialCallbackHandler(username, password);
+ var callback = new JassCredentialCallbackHandler(username, password, connection);
try {
- LoginContext lc = new LoginContext(jassConfiguration, callback);
+ var lc = new LoginContext(jassConfiguration, callback);
lc.login();
- Subject subject = lc.getSubject();
+ var subject = lc.getSubject();
result = new JaasSecurityContext(username, subject);
} catch (Exception ex) {
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java b/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
index 9a8d4dfd0e..24accbb812 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
@@ -24,7 +24,6 @@ import org.apache.activemq.broker.BrokerFilter;
import org.apache.activemq.broker.ConnectionContext;
import org.apache.activemq.broker.Connector;
import org.apache.activemq.broker.EmptyBroker;
-import org.apache.activemq.broker.TransportConnector;
import org.apache.activemq.command.ActiveMQDestination;
import org.apache.activemq.command.ConnectionInfo;
@@ -111,12 +110,8 @@ public class JaasDualAuthenticationBroker extends BrokerFilter implements Authen
}
protected boolean isSSL(ConnectionContext context, ConnectionInfo info) throws Exception {
- boolean sslCapable = false;
Connector connector = context.getConnector();
- if (connector instanceof TransportConnector) {
- TransportConnector transportConnector = (TransportConnector) connector;
- sslCapable = transportConnector.getServer().isSslServer();
- }
+ boolean sslCapable = connector != null && connector.isSsl();
// AMQ-5943, also check if transport context carries X509 cert
if (!sslCapable && info.getTransportContext() instanceof X509Certificate[]) {
sslCapable = true;
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java b/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
index 39835117fd..d1ab3094d0 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
@@ -83,6 +83,25 @@ public abstract class SecurityContext {
public abstract Set<Principal> getPrincipals();
+ /**
+ * Whether the authenticated user may register this connection as a network
+ * connection. Consulted when a connection identifies itself as a network
+ * bridge after it has already been authenticated. Defaults to allowed so
+ * authentication plugins that do not make the distinction are unaffected.
+ */
+ public boolean isNetworkConnectionAllowed() {
+ return true;
+ }
+
+ /**
+ * Whether the authenticating plugin made a network connection decision for this
+ * connection. When false the broker applies no network connection restriction and
+ * {@link #isNetworkConnectionAllowed()} is not consulted.
+ */
+ public boolean isNetworkConnectionAuthorizationRequired() {
+ return false;
+ }
+
public boolean contains(Object principal) {
Set<Principal> principals = getPrincipals();
return principals != null && principals.contains(principal);
diff --git a/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java b/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
index f31bf4b01b..b082f14279 100644
--- a/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
+++ b/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
@@ -24,6 +24,11 @@ import org.apache.activemq.transport.http.HttpTransportServer;
public class HttpsTransportServer extends HttpTransportServer {
+ @Override
+ public boolean isSslServer() {
+ return true;
+ }
+
public HttpsTransportServer(URI uri, HttpsTransportFactory factory, SslContext context) {
super(uri, factory);
this.socketConnectorFactory = new SecureSocketConnectorFactory(context);
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java
new file mode 100644
index 0000000000..57a5c01a2d
--- /dev/null
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java
@@ -0,0 +1,113 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.callback.Callback;
+
+/**
+ * Callback used to pass details of the connection being authenticated to a
+ * login module so it can authorize more than the user credentials: the
+ * connection id, the requested clientId, the name of the broker performing the
+ * authentication, whether the connection has declared itself a network
+ * connection, whether it arrived over SSL/TLS, its remote address, the name of
+ * the transport connector that accepted it and the client certificate chain it
+ * presented, if any.
+ */
+public class ConnectionCallback implements Callback {
+
+ private String connectionId;
+ private String clientId;
+ private String brokerName;
+ private boolean networkConnection;
+ private boolean ssl;
+ private String remoteAddress;
+ private String transportConnectorName;
+ private X509Certificate[] certificates;
+
+ /** the client certificate chain presented during the TLS handshake, or null when none */
+ public X509Certificate[] getCertificates() {
+ return certificates;
+ }
+
+ public void setCertificates(X509Certificate[] certificates) {
+ this.certificates = certificates;
+ }
+
+ /** true when the connection was accepted over SSL/TLS */
+ public boolean isSsl() {
+ return ssl;
+ }
+
+ public void setSsl(boolean ssl) {
+ this.ssl = ssl;
+ }
+
+ /** the client's remote address as seen by the transport, or null when unknown */
+ public String getRemoteAddress() {
+ return remoteAddress;
+ }
+
+ public void setRemoteAddress(String remoteAddress) {
+ this.remoteAddress = remoteAddress;
+ }
+
+ /** the name of the transport connector that accepted the connection, or null when unknown */
+ public String getTransportConnectorName() {
+ return transportConnectorName;
+ }
+
+ public void setTransportConnectorName(String transportConnectorName) {
+ this.transportConnectorName = transportConnectorName;
+ }
+
+ /** the connection's id as assigned by the client, or null when unknown */
+ public String getConnectionId() {
+ return connectionId;
+ }
+
+ public void setConnectionId(String connectionId) {
+ this.connectionId = connectionId;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ /** name of the broker authenticating the connection, or null when unknown */
+ public String getBrokerName() {
+ return brokerName;
+ }
+
+ public void setBrokerName(String brokerName) {
+ this.brokerName = brokerName;
+ }
+
+ /** true when the connection has identified itself as a network bridge */
+ public boolean isNetworkConnection() {
+ return networkConnection;
+ }
+
+ public void setNetworkConnection(boolean networkConnection) {
+ this.networkConnection = networkConnection;
+ }
+}
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java
new file mode 100644
index 0000000000..e6aee78412
--- /dev/null
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java
@@ -0,0 +1,134 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.Principal;
+import java.util.Objects;
+
+/**
+ * Principal describing the connection that was authenticated. Its name is the
+ * connection id, which identifies the connection uniquely; it also carries the
+ * clientId the connection was permitted to use, if it presented one, whether the
+ * user's rule allows it to act as a network connection, whether it arrived over
+ * SSL/TLS, its remote address and the transport connector that accepted it.
+ *
+ * <p>The connection id is generated by the client from its host name, port and a
+ * counter (bridges prefix it with the broker names). It is unique but not
+ * verified by the broker, so it identifies the connection and must not be the
+ * basis of an authorization decision.
+ *
+ * <p>Added to the Subject alongside the {@link UserPrincipal} whenever clientId
+ * authorization is enabled on the login module, so the broker can hold a
+ * connection to the network decision even if it declares itself a network
+ * connection after authenticating. Its absence means the feature is not
+ * configured.
+ */
+public class ConnectionPrincipal implements Principal {
+
+ private final String connectionId;
+ private final String clientId;
+ private final boolean networkConnection;
+ private final boolean ssl;
+ private final String remoteAddress;
+ private final String transportConnectorName;
+ private transient int hash;
+
+ public ConnectionPrincipal(String connectionId, String clientId, boolean networkConnection) {
+ this(connectionId, clientId, networkConnection, false, null, null);
+ }
+
+ public ConnectionPrincipal(String connectionId, String clientId, boolean networkConnection, boolean ssl,
+ String remoteAddress, String transportConnectorName) {
+ this.connectionId = connectionId;
+ this.clientId = clientId;
+ this.networkConnection = networkConnection;
+ this.ssl = ssl;
+ this.remoteAddress = remoteAddress;
+ this.transportConnectorName = transportConnectorName;
+ }
+
+ /** true when the connection was accepted over SSL/TLS */
+ public boolean isSsl() {
+ return ssl;
+ }
+
+ /** the client's remote address as seen by the transport, or null when unknown */
+ public String getRemoteAddress() {
+ return remoteAddress;
+ }
+
+ /** the name of the transport connector that accepted the connection, or null when unknown */
+ public String getTransportConnectorName() {
+ return transportConnectorName;
+ }
+
+ /** the connection id assigned by the client, or null when the callback handler did not supply one */
+ public String getConnectionId() {
+ return connectionId;
+ }
+
+ /** the clientId the connection presented and was allowed, or null when it set none */
+ public String getClientId() {
+ return clientId;
+ }
+
+ /** true when the user's rule permits this connection to be a network connection */
+ public boolean isNetworkConnection() {
+ return networkConnection;
+ }
+
+ /** the connection id, or an empty string when none was supplied */
+ @Override
+ public String getName() {
+ return connectionId != null ? connectionId : "";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final ConnectionPrincipal that = (ConnectionPrincipal) o;
+ return networkConnection == that.networkConnection
+ && ssl == that.ssl
+ && Objects.equals(connectionId, that.connectionId)
+ && Objects.equals(clientId, that.clientId)
+ && Objects.equals(remoteAddress, that.remoteAddress)
+ && Objects.equals(transportConnectorName, that.transportConnectorName);
+ }
+
+ @Override
+ public int hashCode() {
+ if (hash == 0) {
+ hash = Objects.hash(connectionId, clientId, networkConnection, ssl, remoteAddress, transportConnectorName);
+ }
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return getName()
+ + (clientId != null ? " clientId=" + clientId : "")
+ + (transportConnectorName != null ? " connector=" + transportConnectorName : "")
+ + (remoteAddress != null ? " from " + remoteAddress : "")
+ + (ssl ? " [ssl]" : "")
+ + (networkConnection ? " [networkConnection]" : "");
+ }
+}
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
index 3208d77497..da7449aa77 100644
--- a/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
@@ -31,10 +31,17 @@ public class JassCredentialCallbackHandler implements CallbackHandler {
private final String username;
private final String password;
+ // describes the connection being authenticated; null when the caller has no connection details
+ private final ConnectionCallback connection;
public JassCredentialCallbackHandler(String username, String password) {
+ this(username, password, null);
+ }
+
+ public JassCredentialCallbackHandler(String username, String password, ConnectionCallback connection) {
this.username = username;
this.password = password;
+ this.connection = connection;
}
@Override
@@ -55,6 +62,16 @@ public class JassCredentialCallbackHandler implements CallbackHandler {
} else {
nameCallback.setName(username);
}
+ } else if (callback instanceof ConnectionCallback && connection != null) {
+ ConnectionCallback connectionCallback = (ConnectionCallback)callback;
+ connectionCallback.setConnectionId(connection.getConnectionId());
+ connectionCallback.setClientId(connection.getClientId());
+ connectionCallback.setBrokerName(connection.getBrokerName());
+ connectionCallback.setNetworkConnection(connection.isNetworkConnection());
+ connectionCallback.setSsl(connection.isSsl());
+ connectionCallback.setRemoteAddress(connection.getRemoteAddress());
+ connectionCallback.setTransportConnectorName(connection.getTransportConnectorName());
+ connectionCallback.setCertificates(connection.getCertificates());
}
}
}
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
index 153a12534e..37929c4221 100644
--- a/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
@@ -18,10 +18,13 @@ package org.apache.activemq.jaas;
import java.io.IOException;
import java.security.Principal;
-import java.util.HashSet;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
+import java.util.regex.Pattern;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
@@ -40,6 +43,27 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
private static final String USER_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.user";
private static final String GROUP_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.group";
+ private static final String CLIENTID_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.clientid";
+
+ /** matches the authenticated user name when expanded in a clientId pattern */
+ private static final String USER_TOKEN = "${userId}";
+ /** matches the name of the broker performing the authentication when expanded in a clientId pattern */
+ private static final String BROKER_TOKEN = "${brokerName}";
+
+ /**
+ * A parsed clientids.properties entry:
+ * {@code <userId> = true|false, <clientId pattern>, <clientId pattern>...}
+ * The leading boolean states whether the user may register a network connection.
+ */
+ private static final class ClientIdRule {
+ final boolean networkConnectionAllowed;
+ final List<String> patterns;
+
+ ClientIdRule(boolean networkConnectionAllowed, List<String> patterns) {
+ this.networkConnectionAllowed = networkConnectionAllowed;
+ this.patterns = patterns;
+ }
+ }
private static final Logger LOG = LoggerFactory.getLogger(PropertiesLoginModule.class);
@@ -48,8 +72,18 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
private Properties users;
private Map<String,Set<String>> groups;
+ // Optional: userId -> "true|false, clientId patterns". Null when clientId
+ // authentication is not configured (the CLIENTID_FILE_PROP_NAME option is absent).
+ private Properties clientIds;
private String user;
- private final Set<Principal> principals = new HashSet<Principal>();
+ // the connection being authenticated, as described by the callback handler
+ private ConnectionCallback connection;
+ private String clientId;
+ private boolean networkConnectionAllowed;
+ // LinkedHashSet so principal insertion order is preserved when copied into the
+ // Subject: UserPrincipal is always added first, then ConnectionPrincipal (when
+ // clientId authorization is enabled), then group principals.
+ private final Set<Principal> principals = new LinkedHashSet<Principal>();
/** the authentication status*/
private boolean succeeded = false;
@@ -63,6 +97,10 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
init(options);
users = load(USER_FILE_PROP_NAME, "user", options).getProps();
groups = load(GROUP_FILE_PROP_NAME, "group", options).invertedPropertiesValuesMap();
+ // clientId authentication is opt-in: only enabled when the file option is present
+ if (options.containsKey(CLIENTID_FILE_PROP_NAME)) {
+ clientIds = load(CLIENTID_FILE_PROP_NAME, "clientids", options).getProps();
+ }
}
@Override
@@ -94,6 +132,28 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
if (!password.equals(new String(tmpPassword))) {
throw new FailedLoginException("Password does not match");
}
+
+ // When enabled, also authenticate the connection's clientId. A connection
+ // that presents a clientId it is not permitted to use fails to log in. A
+ // connection with no clientId is allowed (it cannot own durable subscriptions).
+ if (clientIds != null) {
+ connection = getConnectionCallback();
+ ClientIdRule rule = ruleFor(user);
+ // A network connection is denied unless the administrator has explicitly
+ // allowed this user to register one. Application connections are unaffected.
+ networkConnectionAllowed = rule != null && rule.networkConnectionAllowed;
+ if (connection.isNetworkConnection() && !networkConnectionAllowed) {
+ throw new FailedLoginException("network connection is not allowed for user");
+ }
+ String requestedClientId = connection.getClientId();
+ if (requestedClientId != null && !requestedClientId.isEmpty()) {
+ if (rule == null || !isClientIdAllowed(rule, user, requestedClientId, connection.getBrokerName())) {
+ throw new FailedLoginException("clientId is not allowed for user");
+ }
+ clientId = requestedClientId;
+ }
+ }
+
succeeded = true;
if (debug) {
@@ -112,8 +172,18 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
return false;
}
+ // UserPrincipal is always added first; ConnectionPrincipal (when clientId
+ // authorization is enabled) is added second, ahead of any group principals. It
+ // carries the allowed clientId and the network connection decision so the broker
+ // can enforce the latter if the connection declares itself a network connection
+ // after authenticating.
principals.add(new UserPrincipal(user));
+ if (clientIds != null) {
+ principals.add(new ConnectionPrincipal(connection.getConnectionId(), clientId, networkConnectionAllowed,
+ connection.isSsl(), connection.getRemoteAddress(), connection.getTransportConnectorName()));
+ }
+
Set<String> matchedGroups = groups.get(user);
if (matchedGroups != null) {
for (String entry : matchedGroups) {
@@ -164,7 +234,85 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
private void clear() {
user = null;
+ connection = null;
+ clientId = null;
+ networkConnectionAllowed = false;
principals.clear();
}
+ private ConnectionCallback getConnectionCallback() throws LoginException {
+ ConnectionCallback connectionCallback = new ConnectionCallback();
+ try {
+ callbackHandler.handle(new Callback[] {connectionCallback});
+ } catch (IOException ioe) {
+ throw new LoginException(ioe.getMessage());
+ } catch (UnsupportedCallbackException uce) {
+ // callback handler does not describe the connection; treat as an
+ // application connection with no clientId
+ }
+ return connectionCallback;
+ }
+
+ /**
+ * The rule for a user: their own entry, or the {@code ${userId}} fallback when
+ * they have none. An explicit entry that fails to parse denies everything for
+ * that user rather than falling back.
+ */
+ private ClientIdRule ruleFor(String userId) {
+ String value = clientIds.getProperty(userId);
+ if (value != null) {
+ return parseRule(userId, value);
+ }
+ value = clientIds.getProperty(USER_TOKEN);
+ return value != null ? parseRule(USER_TOKEN, value) : null;
+ }
+
+ /**
+ * Parses {@code true|false, pattern, pattern...}. The boolean is required so
+ * that permission to register a network connection is always stated.
+ */
+ private static ClientIdRule parseRule(String key, String value) {
+ String[] tokens = value.split(",");
+ String first = tokens[0].trim();
+ if (!"true".equalsIgnoreCase(first) && !"false".equalsIgnoreCase(first)) {
+ LOG.warn("Ignoring clientId rule for '{}': the first value must be true or false (may register a network connection), found '{}'",
+ key, first);
+ return null;
+ }
+ List<String> patterns = new ArrayList<String>();
+ for (int i = 1; i < tokens.length; i++) {
+ String pattern = tokens[i].trim();
+ if (!pattern.isEmpty()) {
+ patterns.add(pattern);
+ }
+ }
+ return new ClientIdRule(Boolean.parseBoolean(first), patterns);
+ }
+
+ private static boolean isClientIdAllowed(ClientIdRule rule, String userId, String clientId, String brokerName) {
+ for (String pattern : rule.patterns) {
+ pattern = pattern.replace(USER_TOKEN, userId);
+ if (brokerName != null) {
+ pattern = pattern.replace(BROKER_TOKEN, brokerName);
+ }
+ if (matches(pattern, clientId)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean matches(String pattern, String clientId) {
+ // '*' is a multi-character wildcard; all other characters match literally.
+ StringBuilder regex = new StringBuilder();
+ String[] segments = pattern.split("\\*", -1);
+ for (int i = 0; i < segments.length; i++) {
+ if (i > 0) {
+ regex.append(".*");
+ }
+ regex.append(Pattern.quote(segments[i]));
+ }
+ return clientId.matches(regex.toString());
+ }
+
}
diff --git a/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java b/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java
new file mode 100644
index 0000000000..8fa946ef62
--- /dev/null
+++ b/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java
@@ -0,0 +1,238 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.FailedLoginException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+
+import junit.framework.TestCase;
+
+/**
+ * Verifies the optional clientId authentication of {@link PropertiesLoginModule}:
+ * a connection's clientId is authorized together with the user credentials.
+ */
+public class ClientIdPropertiesLoginModuleTest extends TestCase {
+
+ private static final String LOGIN_MODULE = "PropertiesLoginClientId";
+ private static final String CONNECTION_ID = "ID:test-host-61616-1-1:1";
+ private static final String REMOTE_ADDRESS = "tcp://127.0.0.1:52000";
+ private static final String CONNECTOR_NAME = "openwire";
+
+ static {
+ var path = System.getProperty("java.security.auth.login.config");
+ if (path == null) {
+ var resource = ClientIdPropertiesLoginModuleTest.class.getClassLoader().getResource("login.config");
+ if (resource != null) {
+ System.setProperty("java.security.auth.login.config", resource.getFile());
+ }
+ }
+ }
+
+ public void testExplicitClientIdAllowed() throws Exception {
+ // 'first = false, first-primary, first-*' — exact match
+ login("first", "secret", "first-primary");
+ }
+
+ public void testWildcardClientIdAllowed() throws Exception {
+ // 'first = false, ..., first-*' — wildcard match
+ login("first", "secret", "first-42");
+ }
+
+ public void testClientIdNotAllowedFailsLogin() throws Exception {
+ // 'first' is confined to first-*; a foreign clientId must fail login
+ try {
+ login("first", "secret", "quote-1");
+ fail("Should have thrown a FailedLoginException for a disallowed clientId");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testWildcardUserAllowedAnyClientId() throws Exception {
+ // 'admin = false, *' — any clientId permitted
+ login("admin", "admin", "anything-goes");
+ }
+
+ public void testFallbackRuleAppliesToUserWithoutEntry() throws Exception {
+ // 'second' has no explicit entry -> '${userId} = false, ${userId}-*' -> second-*
+ login("second", "password", "second-1");
+ }
+
+ public void testFallbackRuleRejectsForeignPrefix() throws Exception {
+ try {
+ login("second", "password", "first-1");
+ fail("Should have thrown a FailedLoginException; second may only use second-*");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testNoClientIdAllowed() throws Exception {
+ // a connection that presents no clientId still authenticates (no durable ownership)
+ var subject = login("first", "secret", null);
+ var connection = subject.getPrincipals(ConnectionPrincipal.class).iterator().next();
+ assertEquals("no clientId recorded", null, connection.getClientId());
+ assertFalse(connection.isNetworkConnection());
+ }
+
+ public void testUserPrincipalFirstConnectionPrincipalSecond() throws Exception {
+ var subject = login("first", "secret", "first-primary");
+
+ assertEquals("one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+ assertEquals("one connection principal", 1, subject.getPrincipals(ConnectionPrincipal.class).size());
+ var connection = subject.getPrincipals(ConnectionPrincipal.class).iterator().next();
+ assertEquals("connection principal is named by the connection id", CONNECTION_ID, connection.getName());
+ assertEquals("connection principal carries the clientId", "first-primary", connection.getClientId());
+ assertTrue("connection principal carries the ssl flag", connection.isSsl());
+ assertEquals("connection principal carries the remote address", REMOTE_ADDRESS, connection.getRemoteAddress());
+ assertEquals("connection principal carries the connector name", CONNECTOR_NAME, connection.getTransportConnectorName());
+
+ var ordered = new ArrayList<>(subject.getPrincipals());
+ assertTrue("UserPrincipal must be first", ordered.get(0) instanceof UserPrincipal);
+ assertTrue("ConnectionPrincipal must be second", ordered.get(1) instanceof ConnectionPrincipal);
+ }
+
+ public void testNetworkConnectionAllowedForFlaggedUser() throws Exception {
+ // 'system = true, ...' and NC_*_outbound is an allowed clientId
+ var subject = login("system", "manager", "NC_broker2_outbound", "broker1", true);
+ assertTrue("decision must be recorded as allowed", networkDecision(subject));
+ }
+
+ public void testNetworkConnectionDeniedWithoutFlag() throws Exception {
+ // 'first = false, ...' has an allowed clientId but may not register a network connection
+ try {
+ login("first", "secret", "first-primary", "broker1", true);
+ fail("Should have thrown a FailedLoginException; first may not register a network connection");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testWildcardClientIdDoesNotGrantNetworkConnection() throws Exception {
+ // 'admin = false, *' permits any clientId, but the leading false denies network connections
+ try {
+ login("admin", "admin", "NC_broker2_outbound", "broker1", true);
+ fail("Should have thrown a FailedLoginException; admin rule starts with false");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testApplicationConnectionUnaffectedByNetworkFlag() throws Exception {
+ // a flagged user is still free to make an ordinary application connection
+ var subject = login("system", "manager", null, "broker1", false);
+ assertTrue(networkDecision(subject));
+ }
+
+ public void testBrokerNameMacroExpandsToAuthenticatingBroker() throws Exception {
+ // NC_*_inbound_${brokerName} with brokerName=broker1
+ login("system", "manager", "NC_broker2_inbound_broker1", "broker1", true);
+ try {
+ login("system", "manager", "NC_broker2_inbound_broker9", "broker1", true);
+ fail("Should have thrown a FailedLoginException; inbound clientId names another broker");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testNetworkDecisionRecordedAsDeniedByDefault() throws Exception {
+ // the principal is always present when clientId authorization is on, so a
+ // connection that later declares itself a network connection can be held to it
+ var subject = login("second", "password", "second-1");
+ assertFalse(networkDecision(subject));
+ }
+
+ public void testEntryWithoutLeadingBooleanDeniesClientIds() throws Exception {
+ // 'broken = broken-*' has no leading true|false and is ignored as a whole
+ try {
+ login("broken", "broken", "broken-1");
+ fail("Should have thrown a FailedLoginException; the malformed rule must not allow any clientId");
+ } catch (FailedLoginException expected) {
+ }
+ }
+
+ public void testEntryWithoutLeadingBooleanDeniesNetworkConnection() throws Exception {
+ try {
+ login("broken", "broken", null, "broker1", true);
+ fail("Should have thrown a FailedLoginException; the malformed rule must not allow a network connection");
+ } catch (FailedLoginException expected) {
+ }
+ // credentials alone still work for an application connection without a clientId
+ assertFalse(networkDecision(login("broken", "broken", null)));
+ }
+
+ private static boolean networkDecision(Subject subject) {
+ var connections = subject.getPrincipals(ConnectionPrincipal.class);
+ assertEquals("exactly one connection principal expected", 1, connections.size());
+ return connections.iterator().next().isNetworkConnection();
+ }
+
+ private Subject login(String user, String pass, String clientId) throws LoginException {
+ return login(user, pass, clientId, null, false);
+ }
+
+ private Subject login(String user, String pass, String clientId, String brokerName, boolean networkConnection) throws LoginException {
+ var context = new LoginContext(LOGIN_MODULE,
+ new UserPassClientIdHandler(user, pass, clientId, brokerName, networkConnection));
+ context.login();
+ return context.getSubject();
+ }
+
+ private static class UserPassClientIdHandler implements CallbackHandler {
+
+ private final String user;
+ private final String pass;
+ private final String clientId;
+ private final String brokerName;
+ private final boolean networkConnection;
+
+ UserPassClientIdHandler(String user, String pass, String clientId, String brokerName, boolean networkConnection) {
+ this.user = user;
+ this.pass = pass;
+ this.clientId = clientId;
+ this.brokerName = brokerName;
+ this.networkConnection = networkConnection;
+ }
+
+ @Override
+ public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+ for (var callback : callbacks) {
+ if (callback instanceof NameCallback) {
+ ((NameCallback) callback).setName(user);
+ } else if (callback instanceof PasswordCallback) {
+ ((PasswordCallback) callback).setPassword(pass.toCharArray());
+ } else if (callback instanceof ConnectionCallback) {
+ var connection = (ConnectionCallback) callback;
+ connection.setConnectionId(CONNECTION_ID);
+ connection.setClientId(clientId);
+ connection.setSsl(true);
+ connection.setRemoteAddress(REMOTE_ADDRESS);
+ connection.setTransportConnectorName(CONNECTOR_NAME);
+ connection.setBrokerName(brokerName);
+ connection.setNetworkConnection(networkConnection);
+ } else {
+ throw new UnsupportedCallbackException(callback);
+ }
+ }
+ }
+ }
+}
diff --git a/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java b/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java
new file mode 100644
index 0000000000..77907dc65b
--- /dev/null
+++ b/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java
@@ -0,0 +1,72 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+
+import junit.framework.TestCase;
+
+/**
+ * The handler must hand every connection attribute it was given to the login
+ * module's ConnectionCallback, and tolerate having none.
+ */
+public class JassCredentialCallbackHandlerTest extends TestCase {
+
+ public void testCopiesEveryConnectionAttribute() throws Exception {
+ var certificates = new X509Certificate[0];
+ var given = new ConnectionCallback();
+ given.setConnectionId("ID:host-1-1:1");
+ given.setClientId("order-1");
+ given.setBrokerName("broker1");
+ given.setNetworkConnection(true);
+ given.setSsl(true);
+ given.setRemoteAddress("tcp://127.0.0.1:5000");
+ given.setTransportConnectorName("openwire");
+ given.setCertificates(certificates);
+
+ var name = new NameCallback("Username: ");
+ var password = new PasswordCallback("Password: ", false);
+ var asked = new ConnectionCallback();
+ new JassCredentialCallbackHandler("order", "secret", given).handle(new Callback[] {name, password, asked});
+
+ assertEquals("order", name.getName());
+ assertEquals("secret", new String(password.getPassword()));
+ assertEquals("ID:host-1-1:1", asked.getConnectionId());
+ assertEquals("order-1", asked.getClientId());
+ assertEquals("broker1", asked.getBrokerName());
+ assertTrue(asked.isNetworkConnection());
+ assertTrue(asked.isSsl());
+ assertEquals("tcp://127.0.0.1:5000", asked.getRemoteAddress());
+ assertEquals("openwire", asked.getTransportConnectorName());
+ assertSame(certificates, asked.getCertificates());
+ }
+
+ public void testNoConnectionLeavesCallbackUntouched() throws Exception {
+ var asked = new ConnectionCallback();
+ new JassCredentialCallbackHandler("order", "secret").handle(new Callback[] {asked});
+
+ assertNull(asked.getConnectionId());
+ assertNull(asked.getClientId());
+ assertFalse(asked.isNetworkConnection());
+ assertFalse(asked.isSsl());
+ assertNull(asked.getCertificates());
+ }
+}
diff --git a/activemq-jaas/src/test/resources/clientid-users.properties b/activemq-jaas/src/test/resources/clientid-users.properties
new file mode 100644
index 0000000000..2e1c390e1c
--- /dev/null
+++ b/activemq-jaas/src/test/resources/clientid-users.properties
@@ -0,0 +1,21 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements. See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License. You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+first=secret
+second=password
+admin=admin
+system=manager
+broken=broken
diff --git a/activemq-jaas/src/test/resources/clientids.properties b/activemq-jaas/src/test/resources/clientids.properties
new file mode 100644
index 0000000000..7f5278e587
--- /dev/null
+++ b/activemq-jaas/src/test/resources/clientids.properties
@@ -0,0 +1,33 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements. See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License. You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## clientId authorization rules used by ClientIdPropertiesLoginModuleTest.
+
+# Format: <userId> = <may register a network connection: true|false>, <clientId patterns...>
+
+# admin may use any clientId but is not a network bridge
+admin = false, *
+# 'first' has an explicit rule: an exact id plus a wildcard, no network connections
+first = false, first-primary, first-*
+# 'second' has no explicit entry and falls back to the generic per-user rule
+${userId} = false, ${userId}-*
+# 'system' operates network bridges: may use the bridge clientId formats and is
+# explicitly allowed to register network connections. ${brokerName} expands to the
+# name of the broker doing the authentication.
+system = true, NC_*_inbound_${brokerName}, NC_*_outbound
+# 'broken' is missing the leading boolean; the rule is ignored and denies everything
+broken = broken-*
diff --git a/activemq-jaas/src/test/resources/login.config b/activemq-jaas/src/test/resources/login.config
index 2dca7b45d6..a7524a2482 100644
--- a/activemq-jaas/src/test/resources/login.config
+++ b/activemq-jaas/src/test/resources/login.config
@@ -30,6 +30,14 @@ PropertiesLoginReload {
org.apache.activemq.jaas.properties.group="groups.properties";
};
+PropertiesLoginClientId {
+ org.apache.activemq.jaas.PropertiesLoginModule required
+ debug=true
+ org.apache.activemq.jaas.properties.user="clientid-users.properties"
+ org.apache.activemq.jaas.properties.group="groups.properties"
+ org.apache.activemq.jaas.properties.clientid="clientids.properties";
+};
+
EncryptedPropertiesLogin {
org.apache.activemq.jaas.PropertiesLoginModule required
debug=true
diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java
new file mode 100644
index 0000000000..bec8afce22
--- /dev/null
+++ b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.broker;
+
+import static org.junit.Assert.assertEquals;
+
+import java.net.URI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.activemq.broker.jmx.ConnectorView;
+import org.apache.activemq.test.annotations.ParallelTest;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Connector.isSsl(): the bound transport server's answer once started, the
+ * configured scheme before that.
+ */
+@Category(ParallelTest.class)
+public class TransportConnectorSslTest {
+
+ private static final String KEYSTORE = "src/test/resources/org/apache/activemq/security/broker1.ks";
+
+ private BrokerService broker;
+
+ @BeforeClass
+ public static void keystore() {
+ System.setProperty("javax.net.ssl.keyStore", KEYSTORE);
+ System.setProperty("javax.net.ssl.keyStorePassword", "password");
+ System.setProperty("javax.net.ssl.keyStoreType", "jks");
+ System.setProperty("javax.net.ssl.trustStore", KEYSTORE);
+ System.setProperty("javax.net.ssl.trustStorePassword", "password");
+ System.setProperty("javax.net.ssl.trustStoreType", "jks");
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (broker != null) {
+ broker.stop();
+ broker.waitUntilStopped();
+ }
+ }
+
+ @Test
+ public void testConfiguredSchemeDecidesBeforeStart() throws Exception {
+ var expectations = new LinkedHashMap<String, Boolean>();
+ expectations.put("tcp", false);
+ expectations.put("nio", false);
+ expectations.put("auto", false);
+ expectations.put("auto+nio", false);
+ expectations.put("vm", false);
+ expectations.put("http", false);
+ expectations.put("ws", false);
+ expectations.put("ssl", true);
+ expectations.put("nio+ssl", true);
+ expectations.put("auto+ssl", true);
+ expectations.put("auto+nio+ssl", true);
+ expectations.put("mqtt+ssl", true);
+ expectations.put("stomp+nio+ssl", true);
+ expectations.put("amqp+nio+ssl", true);
+ expectations.put("https", true);
+ expectations.put("wss", true);
+ expectations.put("SSL", true);
+
+ for (Map.Entry<String, Boolean> expectation : expectations.entrySet()) {
+ var connector = new TransportConnector();
+ connector.setUri(new URI(expectation.getKey() + "://localhost:0"));
+ assertEquals(expectation.getKey(), expectation.getValue(), connector.isSsl());
+ }
+ assertEquals("no uri configured", false, new TransportConnector().isSsl());
+ }
+
+ @Test(timeout = 60000)
+ public void testBoundServerDecidesAfterStart() throws Exception {
+ broker = new BrokerService();
+ broker.setPersistent(false);
+ broker.setUseJmx(false);
+ var expectations = new LinkedHashMap<TransportConnector, Boolean>();
+ expectations.put(broker.addConnector("tcp://localhost:0"), false);
+ expectations.put(broker.addConnector("nio://localhost:0"), false);
+ expectations.put(broker.addConnector("auto://localhost:0"), false);
+ expectations.put(broker.addConnector("ssl://localhost:0"), true);
+ expectations.put(broker.addConnector("nio+ssl://localhost:0"), true);
+ expectations.put(broker.addConnector("auto+ssl://localhost:0"), true);
+ expectations.put(broker.addConnector("auto+nio+ssl://localhost:0"), true);
+ expectations.put(broker.addConnector("mqtt+ssl://localhost:0"), true);
+ expectations.put(broker.addConnector("stomp+nio+ssl://localhost:0"), true);
+ broker.start();
+ broker.waitUntilStarted();
+
+ for (Map.Entry<TransportConnector, Boolean> expectation : expectations.entrySet()) {
+ var connector = expectation.getKey();
+ var label = connector.getUri().toString();
+ assertEquals(label, expectation.getValue(), connector.isSsl());
+ assertEquals(label + " via JMX view", expectation.getValue(), new ConnectorView(connector).isSsl());
+ }
+ }
+}
diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java
new file mode 100644
index 0000000000..89d0987874
--- /dev/null
+++ b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java
@@ -0,0 +1,397 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.security;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.jms.Session;
+
+import org.apache.activemq.ActiveMQConnectionFactory;
+import org.apache.activemq.broker.Broker;
+import org.apache.activemq.broker.BrokerFilter;
+import org.apache.activemq.broker.BrokerPlugin;
+import org.apache.activemq.broker.BrokerService;
+import org.apache.activemq.broker.ConnectionContext;
+import org.apache.activemq.broker.Connection;
+import org.apache.activemq.broker.TransportConnection;
+import org.apache.activemq.command.ActiveMQQueue;
+import org.apache.activemq.command.BrokerId;
+import org.apache.activemq.command.BrokerInfo;
+import org.apache.activemq.command.ConnectionId;
+import org.apache.activemq.command.ConnectionInfo;
+import org.apache.activemq.command.Response;
+import org.apache.activemq.jaas.ConnectionPrincipal;
+import org.apache.activemq.network.NetworkConnector;
+import org.apache.activemq.test.annotations.ParallelTest;
+import org.apache.activemq.transport.DefaultTransportListener;
+import org.apache.activemq.transport.TransportFactory;
+import org.apache.activemq.util.Wait;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Network connection authorization through the JAAS PropertiesLoginModule with
+ * clientId authorization enabled: only users whose rule starts with true
+ * ({@code <userId> = true, <patterns>}) may register a network connection, whether they announce it before authenticating (a real bridge) or
+ * after (an application promoting itself with a late BrokerInfo).
+ *
+ * Fixtures: activemq-clientid-domain in login.config, clientid-users.properties
+ * and clientids.properties under org/apache/activemq/security. User 'bridge' is
+ * 'true, NC_*'; user 'app' is 'false, *'.
+ */
+@Category(ParallelTest.class)
+public class JaasNetworkConnectionAuthorizationTest {
+
+ private static final ActiveMQQueue QUEUE = new ActiveMQQueue("TEST.NETWORK.AUTHZ");
+
+ private BrokerService brokerA;
+ private BrokerService brokerB;
+ private String brokerBUri;
+
+ @Before
+ public void setUp() throws Exception {
+ System.setProperty("java.security.auth.login.config", "src/test/resources/login.config");
+ brokerB = createBroker("brokerB");
+ brokerB.addConnector("tcp://localhost:0");
+ brokerB.start();
+ brokerB.waitUntilStarted();
+ brokerBUri = brokerB.getTransportConnectors().get(0).getPublishableConnectString();
+ brokerA = createBroker("brokerA");
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ for (var broker : new BrokerService[] {brokerA, brokerB}) {
+ if (broker != null) {
+ broker.stop();
+ broker.waitUntilStopped();
+ }
+ }
+ }
+
+ private static BrokerService createBroker(String name) {
+ return createBroker(name, "activemq-clientid-domain");
+ }
+
+ private static BrokerService createBroker(String name, String jaasConfiguration) {
+ var broker = new BrokerService();
+ broker.setBrokerName(name);
+ broker.setPersistent(false);
+ broker.setUseJmx(false);
+ // advisory support stays on: demand forwarding across the bridge relies on it
+ var jaas = new JaasAuthenticationPlugin();
+ jaas.setConfiguration(jaasConfiguration);
+ // plugins wrap in order, so the capture sits inside JAAS and sees the security context it set
+ broker.setPlugins(new BrokerPlugin[] {new PrincipalCapture(), jaas});
+ return broker;
+ }
+
+ /** records the ConnectionPrincipal JAAS attached to each admitted connection, keyed by clientId */
+ private static final Map<String, ConnectionPrincipal> PRINCIPALS = new ConcurrentHashMap<>();
+
+ private static final class PrincipalCapture implements BrokerPlugin {
+ @Override
+ public Broker installPlugin(Broker broker) {
+ return new BrokerFilter(broker) {
+ @Override
+ public void addConnection(ConnectionContext context, ConnectionInfo info) throws Exception {
+ super.addConnection(context, info);
+ var securityContext = context.getSecurityContext();
+ if (securityContext != null && info.getClientId() != null) {
+ for (var principal : securityContext.getPrincipals()) {
+ if (principal instanceof ConnectionPrincipal) {
+ PRINCIPALS.put(info.getClientId(), (ConnectionPrincipal) principal);
+ }
+ }
+ }
+ }
+ };
+ }
+ }
+
+ @Test(timeout = 60000)
+ public void testFlaggedUserCanEstablishBridge() throws Exception {
+ addNetworkConnector(null, "bridge", "bridge");
+ brokerA.start();
+ brokerA.waitUntilStarted();
+
+ assertTrue("bridge should form for the flagged user",
+ Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+ assertMessageCrossesBridge("app", "app");
+ }
+
+ /**
+ * The configuration most existing deployments have: JAAS password authentication
+ * on a realm without the clientids file. A password authenticated bridge must be
+ * admitted with no rule at all, and the local side's late BrokerInfo must pass the
+ * guard, because no network connection decision was ever made.
+ */
+ @Test(timeout = 60000)
+ public void testBridgeAdmittedWhenClientIdAuthorizationNotConfigured() throws Exception {
+ useRealm("activemq-domain");
+ // 'system' / 'manager' from users.properties
+ addNetworkConnector(null, "system", "manager");
+ brokerA.start();
+ brokerA.waitUntilStarted();
+
+ assertTrue("bridge should form without any clientId rule",
+ Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+ assertMessageCrossesBridge("user", "password");
+ }
+
+ /** Replaces both brokers with ones authenticating against the given JAAS realm. */
+ private void useRealm(String jaasConfiguration) throws Exception {
+ brokerB.stop();
+ brokerB.waitUntilStopped();
+ brokerB = createBroker("brokerB", jaasConfiguration);
+ brokerB.addConnector("tcp://localhost:0");
+ brokerB.start();
+ brokerB.waitUntilStarted();
+ brokerBUri = brokerB.getTransportConnectors().get(0).getPublishableConnectString();
+ brokerA = createBroker("brokerA", jaasConfiguration);
+ }
+
+ /**
+ * The networkConnector name replaces the default NC prefix in the bridge
+ * clientIds: toB_brokerA_outbound on B and toB_brokerB_inbound_brokerA on A.
+ * 'bridge-named = true, toB_*' follows that, so the bridge is admitted.
+ */
+ @Test(timeout = 60000)
+ public void testNamedConnectorAdmittedWhenPatternFollowsConnectorName() throws Exception {
+ addNetworkConnector("toB", "bridge-named", "bridge-named");
+ brokerA.start();
+ brokerA.waitUntilStarted();
+
+ assertTrue("bridge should form when the pattern covers the connector name prefix",
+ Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+ assertMessageCrossesBridge("app", "app");
+ }
+
+ /**
+ * Same named connector, but 'bridge = true, NC_*' only covers the default
+ * prefix. The user may register network connections, yet the clientId check
+ * refuses toB_... so no bridge forms. This is the operator mistake to expect
+ * when a connector is given a name.
+ */
+ @Test(timeout = 60000)
+ public void testNamedConnectorRefusedWhenPatternOnlyCoversDefaultPrefix() throws Exception {
+ addNetworkConnector("toB", "bridge", "bridge");
+ brokerA.start();
+ brokerA.waitUntilStarted();
+
+ assertFalse("a bridge formed although the clientId pattern does not cover the connector name",
+ Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) > 0, 3000, 10));
+ }
+
+ private void assertMessageCrossesBridge(String user, String password) throws Exception {
+ try (var consumerConnection = new ActiveMQConnectionFactory("vm://brokerB?create=false").createConnection(user, password);
+ var consumerSession = consumerConnection.createSession(false, Session.AUTO_ACKNOWLEDGE);
+ var consumer = consumerSession.createConsumer(QUEUE);
+ var producerConnection = new ActiveMQConnectionFactory("vm://brokerA?create=false").createConnection(user, password);
+ var producerSession = producerConnection.createSession(false, Session.AUTO_ACKNOWLEDGE);
+ var producer = producerSession.createProducer(QUEUE)) {
+ consumerConnection.start();
+ producerConnection.start();
+ producer.send(producerSession.createTextMessage("across the bridge"));
+ var received = consumer.receive(TimeUnit.SECONDS.toMillis(10));
+ assertNotNull("message did not cross the bridge", received);
+ }
+ }
+
+ @Test(timeout = 60000)
+ public void testUnflaggedUserCannotEstablishBridge() throws Exception {
+ // 'app = false, *' may use any clientId, so only the leading false can refuse it
+ var connector = addNetworkConnector(null, "app", "app");
+ brokerA.start();
+ brokerA.waitUntilStarted();
+
+ // give the connector several reconnect attempts; none may be admitted
+ assertFalse("a bridge formed for a user whose rule starts with false",
+ Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) > 0, 3000, 10));
+ assertTrue("connector should still be trying", connector.activeBridges().size() >= 0);
+ }
+
+ @Test(timeout = 60000)
+ public void testUnflaggedUserCannotPromoteConnectionWithLateBrokerInfo() throws Exception {
+ var clientId = "app-late";
+ var transport = TransportFactory.connect(new URI(brokerBUri));
+ try {
+ transport.setTransportListener(new DefaultTransportListener());
+ transport.start();
+ var added = (Response) transport.request(connectionInfo("late-app", "app", "app", clientId));
+ assertFalse("application connection should authenticate: " + added, added.isException());
+
+ var rejected = requestBrokerInfo(transport);
+ assertTrue("late BrokerInfo from an unflagged user must be rejected", rejected);
+ assertFalse("connection must not have been promoted", isNetworkConnection(brokerB, clientId));
+ } finally {
+ transport.stop();
+ }
+ }
+
+ /**
+ * Every bridge side now sends BrokerInfo before it authenticates, so a BrokerInfo
+ * arriving afterwards is a client promoting itself. On a realm with clientId
+ * authorization that is refused even for a user whose rule allows network
+ * connections; the principal attributes are still checked here as this is the
+ * one case that authenticates a bridge user over tcp.
+ */
+ @Test(timeout = 60000)
+ public void testLateBrokerInfoRefusedEvenForFlaggedUser() throws Exception {
+ var clientId = "NC_brokerA_inbound_brokerB";
+ var transport = TransportFactory.connect(new URI(brokerBUri));
+ try {
+ transport.setTransportListener(new DefaultTransportListener());
+ transport.start();
+ var info = connectionInfo("late-bridge", "bridge", "bridge", clientId);
+ // a client can plant anything in clientIp; the principal must carry the transport's view instead
+ info.setClientIp("spoofed://10.0.0.1:1");
+ var added = (Response) transport.request(info);
+ assertFalse("bridge connection should authenticate: " + added, added.isException());
+
+ var principal = PRINCIPALS.get(clientId);
+ assertNotNull("JAAS should have attached a ConnectionPrincipal", principal);
+ assertEquals(clientId, principal.getClientId());
+ assertTrue("network connection permitted by the bridge rule", principal.isNetworkConnection());
+ assertEquals("connector name from the accepting connector",
+ brokerB.getTransportConnectors().get(0).getName(), principal.getTransportConnectorName());
+ assertFalse("tcp connector is not ssl", principal.isSsl());
+ assertTrue("remote address must come from the transport, not clientIp: " + principal.getRemoteAddress(),
+ principal.getRemoteAddress() != null && principal.getRemoteAddress().startsWith("tcp://"));
+ assertEquals("remote address matches the broker side connection", transportRemoteAddress(brokerB, clientId),
+ principal.getRemoteAddress());
+
+ var rejected = requestBrokerInfo(transport);
+ assertTrue("late BrokerInfo must be refused once authentication has happened", rejected);
+ assertFalse("connection must not have been promoted", isNetworkConnection(brokerB, clientId));
+ } finally {
+ transport.stop();
+ }
+ }
+
+ /**
+ * A realm without the clientid file makes no network connection decision, so the
+ * broker must not apply the late BrokerInfo guard at all: an ordinary user may
+ * still promote a connection, exactly as before the feature existed.
+ */
+ @Test(timeout = 60000)
+ public void testLateBrokerInfoNotRestrictedWhenClientIdAuthorizationNotConfigured() throws Exception {
+ var brokerC = createBroker("brokerC", "activemq-domain");
+ brokerC.addConnector("tcp://localhost:0");
+ brokerC.start();
+ brokerC.waitUntilStarted();
+ var clientId = "plain-late";
+ var transport = TransportFactory.connect(new URI(brokerC.getTransportConnectors().get(0).getPublishableConnectString()));
+ try {
+ transport.setTransportListener(new DefaultTransportListener());
+ transport.start();
+ // 'system' / 'manager' from users.properties; the realm has no clientids file
+ var added = (Response) transport.request(connectionInfo("late-plain", "system", "manager", clientId));
+ assertFalse("connection should authenticate: " + added, added.isException());
+
+ var rejected = requestBrokerInfo(transport);
+ assertFalse("late BrokerInfo must be accepted when authorization is not configured", rejected);
+ assertTrue("connection should now be a network connection",
+ Wait.waitFor(() -> isNetworkConnection(brokerC, clientId), 5000, 10));
+ } finally {
+ transport.stop();
+ brokerC.stop();
+ brokerC.waitUntilStopped();
+ }
+ }
+
+ /**
+ * With a null name the connector keeps the default, so the bridge clientIds take
+ * the NC_ prefix: NC_brokerA_outbound on B, NC_brokerB_inbound_brokerA on A. A
+ * given name replaces that prefix.
+ */
+ private NetworkConnector addNetworkConnector(String name, String user, String password) throws Exception {
+ var connector = brokerA.addNetworkConnector("static:(" + brokerBUri + ")");
+ if (name != null) {
+ connector.setName(name);
+ }
+ connector.setUserName(user);
+ connector.setPassword(password);
+ return connector;
+ }
+
+ private static ConnectionInfo connectionInfo(String connectionId, String user, String password, String clientId) {
+ var info = new ConnectionInfo(new ConnectionId(connectionId));
+ info.setClientId(clientId);
+ info.setUserName(user);
+ info.setPassword(password);
+ return info;
+ }
+
+ /** Sends a BrokerInfo on an already authenticated connection; true when the broker refused it. */
+ private static boolean requestBrokerInfo(org.apache.activemq.transport.Transport transport) throws Exception {
+ var brokerInfo = new BrokerInfo();
+ brokerInfo.setBrokerId(new BrokerId("rogue"));
+ brokerInfo.setBrokerName("rogue");
+ brokerInfo.setNetworkConnection(true);
+ try {
+ var response = (Response) transport.request(brokerInfo);
+ return response != null && response.isException();
+ } catch (IOException refusedAndClosed) {
+ return true;
+ }
+ }
+
+ /**
+ * Connections the broker actually admitted (authentication passed) that are
+ * flagged as network connections. The transport level flag alone is set on
+ * BrokerInfo before authentication, so it cannot distinguish a refused bridge.
+ */
+ private static long admittedNetworkConnectionsOn(BrokerService broker) {
+ try {
+ return Arrays.stream(broker.getBroker().getClients()).filter(Connection::isNetworkConnection).count();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static String transportRemoteAddress(BrokerService broker, String clientId) {
+ for (TransportConnection connection : broker.getTransportConnectors().get(0).getConnections()) {
+ if (clientId.equals(connection.getConnectionId())) {
+ return connection.getRemoteAddress();
+ }
+ }
+ return null;
+ }
+
+ /** TransportConnection.getConnectionId() reports the clientId when the connection set one. */
+ private static boolean isNetworkConnection(BrokerService broker, String clientId) {
+ for (TransportConnection connection : broker.getTransportConnectors().get(0).getConnections()) {
+ if (clientId.equals(connection.getConnectionId())) {
+ return connection.isNetworkConnection();
+ }
+ }
+ return false;
+ }
+}
diff --git a/activemq-unit-tests/src/test/resources/login.config b/activemq-unit-tests/src/test/resources/login.config
index 1f5f77c805..f71cfe2a3e 100644
--- a/activemq-unit-tests/src/test/resources/login.config
+++ b/activemq-unit-tests/src/test/resources/login.config
@@ -84,4 +84,12 @@ LDAPLogin {
roleSearchMatching="(uid={1})"
roleSearchSubtree=true
;
-};
\ No newline at end of file
+};
+
+activemq-clientid-domain {
+ org.apache.activemq.jaas.PropertiesLoginModule required
+ debug=true
+ org.apache.activemq.jaas.properties.user="org/apache/activemq/security/clientid-users.properties"
+ org.apache.activemq.jaas.properties.group="org/apache/activemq/security/groups.properties"
+ org.apache.activemq.jaas.properties.clientid="org/apache/activemq/security/clientids.properties";
+};
diff --git a/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties
new file mode 100644
index 0000000000..2e26ab9a3e
--- /dev/null
+++ b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties
@@ -0,0 +1,19 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements. See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License. You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+bridge=bridge
+app=app
+bridge-named=bridge-named
diff --git a/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties
new file mode 100644
index 0000000000..54f759f5df
--- /dev/null
+++ b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties
@@ -0,0 +1,31 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements. See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License. You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## Used by JaasNetworkConnectionAuthorizationTest.
+
+# Format: <userId> = <may register a network connection: true|false>, <clientId patterns...>
+
+# the bridge account may use the network connector clientId formats and is
+# explicitly allowed to register network connections
+bridge = true, NC_*
+
+# a bridge account for a networkConnector named 'toB': the connector name replaces
+# the NC prefix in the bridge clientIds, so the pattern has to follow it
+bridge-named = true, toB_*
+
+# an application account: any clientId, but no network connections
+app = false, *
diff --git a/assembly/src/release/conf/clientids.properties b/assembly/src/release/conf/clientids.properties
new file mode 100644
index 0000000000..85a6a8ed9b
--- /dev/null
+++ b/assembly/src/release/conf/clientids.properties
@@ -0,0 +1,64 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements. See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License. You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## Optional clientId authorization for the PropertiesLoginModule.
+## Enable by adding this option to the module in login.config:
+## org.apache.activemq.jaas.properties.clientid="clientids.properties"
+##
+## When enabled, a connection's clientId is authenticated together with the user
+## credentials: the clientId must match one of the patterns allowed for the
+## authenticated user, otherwise login fails. A connection that sets no clientId
+## is allowed (it cannot own a durable subscription).
+##
+## Format: <userId> = <true|false>, <comma-separated clientId patterns>
+## the first value states whether the user may register a network connection
+## and is required; an entry without it is ignored and denies the user everything
+## '*' is a multi-character wildcard
+## ${userId} in a pattern expands to the authenticated user name
+## ${brokerName} in a pattern expands to the name of this broker
+## an entry keyed ${userId} is the fallback applied to any user that has no
+## explicit entry; with no matching entry the clientId is denied.
+##
+## Network connections are a separate permission from clientIds: a connection that
+## identifies itself as a network bridge is refused unless its user's rule starts
+## with true, no matter which clientIds the user may use. This applies both when the
+## bridge announces itself before authenticating and when an already authenticated
+## connection later declares itself a bridge, so an application account can never
+## register as a network connection.
+##
+## Network bridges use these clientIds (prefix is the networkConnector name,
+## default NC; token is the connector clientIdToken, default _):
+## on the broker being connected to: NC_<remoteBrokerName>_outbound
+## on the broker running the connector: NC_<remoteBrokerName>_inbound_${brokerName}
+## duplex, connector side: NC_<remoteBrokerName>_inbound_duplex_${brokerName}
+## A bridge user therefore needs a true rule with matching patterns on both brokers.
+
+# admin may use any clientId, but is not a network bridge
+admin = false, *
+
+# examples of a per-user rule (explicit list or wildcard):
+# order = false, order-primary, order-batch
+# order = false, order-*
+
+# example bridge account: matches the network connector clientId formats and is
+# explicitly allowed to register network connections
+# bridge = true, NC_*_outbound, NC_*_inbound_${brokerName}, NC_*_inbound_duplex_${brokerName}
+
+# zero-provisioning default: each user may use clientIds prefixed with their own
+# id, e.g. user 'order' may use 'order-1' or 'order-<random>'. Pair this with
+# clientIDPrefix="${userId}-" on the client ConnectionFactory.
+${userId} = false, ${userId}-*
diff --git a/assembly/src/release/conf/login.config b/assembly/src/release/conf/login.config
index c22608290a..ba28562e62 100644
--- a/assembly/src/release/conf/login.config
+++ b/assembly/src/release/conf/login.config
@@ -17,5 +17,8 @@
activemq {
org.apache.activemq.jaas.PropertiesLoginModule required
org.apache.activemq.jaas.properties.user="users.properties"
- org.apache.activemq.jaas.properties.group="groups.properties";
+ org.apache.activemq.jaas.properties.group="groups.properties"
+ // Uncomment to also authenticate the connection clientId (see clientids.properties):
+ // org.apache.activemq.jaas.properties.clientid="clientids.properties"
+ ;
};
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@activemq.apache.org
For additional commands, e-mail: commits-help@activemq.apache.org
For further information, visit: https://activemq.apache.org/contact