From 5532caaedc081ef3f0f36934143ad534801197d8 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Mon, 7 Sep 2026 11:04:07 -0500 Subject: [PATCH 1/2] Add isSsl to Connector --- .../org/apache/activemq/broker/Connector.java | 6 + .../activemq/broker/TransportConnector.java | 23 ++++ .../activemq/broker/jmx/ConnectorView.java | 5 + .../broker/jmx/ConnectorViewMBean.java | 3 + .../transport/https/HttpsTransportServer.java | 5 + .../broker/TransportConnectorSslTest.java | 115 ++++++++++++++++++ 6 files changed, 157 insertions(+) create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java 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 bf6fd7bec76..2133e66388c 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/TransportConnector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java index 938165195c2..f9c7de71add 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.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; @@ -693,6 +694,28 @@ public long getMaxConnectionExceededCount() { 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 acd00377d04..7ff3e8927c9 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 boolean isStatisticsEnabled() { 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 4d040225619..abf100d2f60 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-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 f31bf4b01b0..b082f142794 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 @@ 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-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 00000000000..bec8afce226 --- /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(); + 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 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(); + 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 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()); + } + } +} From da71780ad28415871c9f9459b96dbbb118f490f7 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Fri, 14 Aug 2026 14:08:52 -0500 Subject: [PATCH 2/2] Feature: Add support for authorizing clientId values based on userId - Includes support for clientId as a network connection authorization --- .../activemq/broker/TransportConnection.java | 30 +- .../security/JaasAuthenticationBroker.java | 65 ++- .../JaasDualAuthenticationBroker.java | 7 +- .../activemq/security/SecurityContext.java | 19 + .../activemq/jaas/ConnectionCallback.java | 113 +++++ .../activemq/jaas/ConnectionPrincipal.java | 134 ++++++ .../jaas/JassCredentialCallbackHandler.java | 17 + .../activemq/jaas/PropertiesLoginModule.java | 152 ++++++- .../ClientIdPropertiesLoginModuleTest.java | 238 +++++++++++ .../JassCredentialCallbackHandlerTest.java | 72 ++++ .../test/resources/clientid-users.properties | 21 + .../src/test/resources/clientids.properties | 33 ++ activemq-jaas/src/test/resources/login.config | 8 + ...aasNetworkConnectionAuthorizationTest.java | 392 ++++++++++++++++++ .../src/test/resources/login.config | 10 +- .../security/clientid-users.properties | 19 + .../activemq/security/clientids.properties | 31 ++ .../src/release/conf/clientids.properties | 64 +++ assembly/src/release/conf/login.config | 5 +- 19 files changed, 1410 insertions(+), 20 deletions(-) create mode 100644 activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java create mode 100644 activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java create mode 100644 activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java create mode 100644 activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java create mode 100644 activemq-jaas/src/test/resources/clientid-users.properties create mode 100644 activemq-jaas/src/test/resources/clientids.properties create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java create mode 100644 activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties create mode 100644 activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties create mode 100644 assembly/src/release/conf/clientids.properties 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 3315cf02253..824b749b04b 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.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 void stopAsync() { 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 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 @@ protected void doStop() throws Exception { // Remove all logical connection associated with this connection // from the broker. if (!broker.isStopped()) { - List 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 Response processBrokerInfo(BrokerInfo info) throws IOException { setDuplexNetworkConnectorId(duplexNetworkConnectorId); } + // A connection that authenticated as an ordinary client and only now declares + // itself a network connection must have been permitted to do so. Bridges normally + // send BrokerInfo first, in which case authentication already saw the flag and + // there are no connection states here yet. + 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; + } + if (!securityContext.isNetworkConnectionAllowed()) { + LOG.warn("Rejecting network connection from {}: user {} is not allowed to register a network connection", + getRemoteAddress(), securityContext.getUserName()); + throw new IOException("User " + securityContext.getUserName() + " is not allowed to register a network connection"); + } + } + this.brokerInfo = info; networkConnection = true; - List connectionStates = listConnectionStates(); for (TransportConnectionState cs : connectionStates) { cs.getContext().setNetworkConnection(true); } 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 6756027361d..a6b8b356a6a 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.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 JaasSecurityContext(String userName, Subject subject) { public Set 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 void addConnection(ConnectionContext context, ConnectionInfo info) throws 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 void addConnection(ConnectionContext context, ConnectionInfo info) throws } } + 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 9a8d4dfd0ee..24accbb8126 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.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 void removeConnection(ConnectionContext context, ConnectionInfo info, Thr } 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 39835117fd9..d1ab3094d00 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 boolean isInOneOf(Set allowedPrincipals) { public abstract Set 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 principals = getPrincipals(); return principals != null && principals.contains(principal); 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 00000000000..57a5c01a2d1 --- /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 00000000000..e6aee78412d --- /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. + * + *

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. + * + *

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 3208d77497e..da7449aa771 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 void handle(Callback[] callbacks) throws IOException, UnsupportedCallback } 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 153a12534e9..37929c4221f 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 @@ 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 = true|false, , ...} + * The leading boolean states whether the user may register a network connection. + */ + private static final class ClientIdRule { + final boolean networkConnectionAllowed; + final List patterns; + + ClientIdRule(boolean networkConnectionAllowed, List 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> 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 principals = new HashSet(); + // 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 principals = new LinkedHashSet(); /** the authentication status*/ private boolean succeeded = false; @@ -63,6 +97,10 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map sha 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 boolean login() throws LoginException { 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 boolean commit() throws LoginException { 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 matchedGroups = groups.get(user); if (matchedGroups != null) { for (String entry : matchedGroups) { @@ -164,7 +234,85 @@ public boolean logout() throws LoginException { 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 patterns = new ArrayList(); + 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 00000000000..8fa946ef622 --- /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 00000000000..77907dc65b8 --- /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 00000000000..2e1c390e1c2 --- /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 00000000000..7f5278e587b --- /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: = , + +# 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 2dca7b45d68..a7524a24824 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/security/JaasNetworkConnectionAuthorizationTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java new file mode 100644 index 00000000000..698ae445d14 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java @@ -0,0 +1,392 @@ +/** + * 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 = true, }) 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 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(); + } + } + + @Test(timeout = 60000) + public void testFlaggedUserCanPromoteConnectionWithLateBrokerInfo() throws Exception { + // the local side of a real bridge does exactly this: connect, then forward BrokerInfo + 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); + assertFalse("late BrokerInfo from a flagged user must be accepted", rejected); + assertTrue("connection should now be a network connection", + Wait.waitFor(() -> isNetworkConnection(brokerB, clientId), 5000, 10)); + } 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 1f5f77c8059..f71cfe2a3ec 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 00000000000..2e26ab9a3e7 --- /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 00000000000..54f759f5dfc --- /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: = , + +# 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 00000000000..85a6a8ed9b3 --- /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: = , +## 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__outbound +## on the broker running the connector: NC__inbound_${brokerName} +## duplex, connector side: NC__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-'. 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 c22608290a5..ba28562e62c 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