From e79af2faaa26daa29383769bd9c511a435d24ad4 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Fri, 14 Aug 2026 14:08:52 -0500 Subject: [PATCH] [#] Add support for authorizing clientId values based on userId --- .../security/JaasAuthenticationBroker.java | 8 +- .../activemq/jaas/ClientIdCallback.java | 36 +++++ .../activemq/jaas/ClientIdPrincipal.java | 73 +++++++++ .../jaas/JassCredentialCallbackHandler.java | 8 + .../activemq/jaas/PropertiesLoginModule.java | 88 ++++++++++- .../ClientIdPropertiesLoginModuleTest.java | 141 ++++++++++++++++++ .../test/resources/clientid-users.properties | 19 +++ .../src/test/resources/clientids.properties | 25 ++++ activemq-jaas/src/test/resources/login.config | 8 + .../src/release/conf/clientids.properties | 43 ++++++ assembly/src/release/conf/login.config | 5 +- 11 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdCallback.java create mode 100644 activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdPrincipal.java create mode 100644 activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.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 assembly/src/release/conf/clientids.properties 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..8591168f604 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 @@ -65,7 +65,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(), null, info.getClientId()); context.setSecurityContext(securityContext); securityContexts.add(securityContext); super.addConnection(context, info); @@ -85,8 +85,12 @@ public void addConnection(ConnectionContext context, ConnectionInfo info) throws @Override public SecurityContext authenticate(String username, String password, X509Certificate[] certificates) throws SecurityException { + return authenticate(username, password, certificates, null); + } + + public SecurityContext authenticate(String username, String password, X509Certificate[] certificates, String clientId) throws SecurityException { SecurityContext result = null; - JassCredentialCallbackHandler callback = new JassCredentialCallbackHandler(username, password); + JassCredentialCallbackHandler callback = new JassCredentialCallbackHandler(username, password, clientId); try { LoginContext lc = new LoginContext(jassConfiguration, callback); lc.login(); diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdCallback.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdCallback.java new file mode 100644 index 00000000000..32f65db6c2b --- /dev/null +++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdCallback.java @@ -0,0 +1,36 @@ +/** + * 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 javax.security.auth.callback.Callback; + +/** + * Callback used to pass the connection's requested clientId to a login module + * so it can authorize the clientId in addition to the user credentials. + */ +public class ClientIdCallback implements Callback { + + private String clientId; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } +} diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdPrincipal.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdPrincipal.java new file mode 100644 index 00000000000..417ea7eff59 --- /dev/null +++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ClientIdPrincipal.java @@ -0,0 +1,73 @@ +/** + * 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; + +/** + * Principal representing the clientId a connection was authenticated to use. + * Added to the Subject alongside the {@link UserPrincipal} when clientId + * authentication is enabled on the login module. + */ +public class ClientIdPrincipal implements Principal { + + private final String name; + private transient int hash; + + public ClientIdPrincipal(String name) { + if (name == null) { + throw new IllegalArgumentException("name cannot be null"); + } + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final ClientIdPrincipal that = (ClientIdPrincipal)o; + + if (!name.equals(that.name)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + if (hash == 0) { + hash = name.hashCode(); + } + return hash; + } + + @Override + public String toString() { + return name; + } +} 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..b3829a47a0d 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,16 @@ public class JassCredentialCallbackHandler implements CallbackHandler { private final String username; private final String password; + private final String clientId; public JassCredentialCallbackHandler(String username, String password) { + this(username, password, null); + } + + public JassCredentialCallbackHandler(String username, String password, String clientId) { this.username = username; this.password = password; + this.clientId = clientId; } @Override @@ -55,6 +61,8 @@ public void handle(Callback[] callbacks) throws IOException, UnsupportedCallback } else { nameCallback.setName(username); } + } else if (callback instanceof ClientIdCallback) { + ((ClientIdCallback)callback).setClientId(clientId); } } } 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..71bf8a7dee3 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,11 @@ import java.io.IOException; import java.security.Principal; -import java.util.HashSet; +import java.util.LinkedHashSet; 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 +41,10 @@ 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}"; private static final Logger LOG = LoggerFactory.getLogger(PropertiesLoginModule.class); @@ -48,8 +53,15 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu private Properties users; private Map> groups; + // Optional: userId -> comma-separated 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(); + private String clientId; + // LinkedHashSet so principal insertion order is preserved when copied into the + // Subject: UserPrincipal is always added first, then ClientIdPrincipal (when + // clientId authentication is enabled), then group principals. + private final Set principals = new LinkedHashSet(); /** the authentication status*/ private boolean succeeded = false; @@ -63,6 +75,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 +110,20 @@ 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) { + String requestedClientId = getClientId(); + if (requestedClientId != null && !requestedClientId.isEmpty()) { + if (!isClientIdAllowed(user, requestedClientId)) { + throw new FailedLoginException("clientId is not allowed for user"); + } + clientId = requestedClientId; + } + } + succeeded = true; if (debug) { @@ -112,8 +142,14 @@ public boolean commit() throws LoginException { return false; } + // UserPrincipal is always added first; ClientIdPrincipal (when a clientId was + // authenticated) is added second, ahead of any group principals. principals.add(new UserPrincipal(user)); + if (clientId != null) { + principals.add(new ClientIdPrincipal(clientId)); + } + Set matchedGroups = groups.get(user); if (matchedGroups != null) { for (String entry : matchedGroups) { @@ -164,7 +200,55 @@ public boolean logout() throws LoginException { private void clear() { user = null; + clientId = null; principals.clear(); } + private String getClientId() throws LoginException { + ClientIdCallback clientIdCallback = new ClientIdCallback(); + try { + callbackHandler.handle(new Callback[] {clientIdCallback}); + } catch (IOException ioe) { + throw new LoginException(ioe.getMessage()); + } catch (UnsupportedCallbackException uce) { + // callback handler does not supply a clientId; treat as none + return null; + } + return clientIdCallback.getClientId(); + } + + private boolean isClientIdAllowed(String userId, String clientId) { + String patterns = clientIds.getProperty(userId); + if (patterns == null) { + // fall back to the generic per-user rule, e.g. ${userId} = ${userId}-* + patterns = clientIds.getProperty(USER_TOKEN); + } + if (patterns == null) { + return false; + } + for (String pattern : patterns.split(",")) { + pattern = pattern.trim(); + if (pattern.isEmpty()) { + continue; + } + if (matches(pattern.replace(USER_TOKEN, userId), 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..b40504fb446 --- /dev/null +++ b/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java @@ -0,0 +1,141 @@ +/** + * 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"; + + 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 = first-primary, first-*' — exact match + login("first", "secret", "first-primary"); + } + + public void testWildcardClientIdAllowed() throws Exception { + // 'first = ..., 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 = *' — any clientId permitted + login("admin", "admin", "anything-goes"); + } + + public void testFallbackRuleAppliesToUserWithoutEntry() throws Exception { + // 'second' has no explicit entry -> '${userId} = ${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); + assertEquals("no ClientIdPrincipal expected", 0, subject.getPrincipals(ClientIdPrincipal.class).size()); + } + + public void testUserPrincipalFirstClientIdPrincipalSecond() throws Exception { + var subject = login("first", "secret", "first-primary"); + + assertEquals("one user principal", 1, subject.getPrincipals(UserPrincipal.class).size()); + assertEquals("one clientId principal", 1, subject.getPrincipals(ClientIdPrincipal.class).size()); + assertEquals("clientId principal carries the clientId", "first-primary", + subject.getPrincipals(ClientIdPrincipal.class).iterator().next().getName()); + + var ordered = new ArrayList<>(subject.getPrincipals()); + assertTrue("UserPrincipal must be first", ordered.get(0) instanceof UserPrincipal); + assertTrue("ClientIdPrincipal must be second", ordered.get(1) instanceof ClientIdPrincipal); + } + + private Subject login(String user, String pass, String clientId) throws LoginException { + var context = new LoginContext(LOGIN_MODULE, new UserPassClientIdHandler(user, pass, clientId)); + context.login(); + return context.getSubject(); + } + + private static class UserPassClientIdHandler implements CallbackHandler { + + private final String user; + private final String pass; + private final String clientId; + + UserPassClientIdHandler(String user, String pass, String clientId) { + this.user = user; + this.pass = pass; + this.clientId = clientId; + } + + @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 ClientIdCallback) { + ((ClientIdCallback) callback).setClientId(clientId); + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + } +} 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..e327ad5bef1 --- /dev/null +++ b/activemq-jaas/src/test/resources/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. +## --------------------------------------------------------------------------- +first=secret +second=password +admin=admin diff --git a/activemq-jaas/src/test/resources/clientids.properties b/activemq-jaas/src/test/resources/clientids.properties new file mode 100644 index 00000000000..e86a926c483 --- /dev/null +++ b/activemq-jaas/src/test/resources/clientids.properties @@ -0,0 +1,25 @@ +## --------------------------------------------------------------------------- +## 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. + +# admin may use any clientId +admin = * +# 'first' has an explicit rule: an exact id plus a wildcard +first = first-primary, first-* +# 'second' has no explicit entry and falls back to the generic per-user rule +${userId} = ${userId}-* 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/assembly/src/release/conf/clientids.properties b/assembly/src/release/conf/clientids.properties new file mode 100644 index 00000000000..b3710451572 --- /dev/null +++ b/assembly/src/release/conf/clientids.properties @@ -0,0 +1,43 @@ +## --------------------------------------------------------------------------- +## 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: = +## '*' is a multi-character wildcard +## ${userId} in a value expands to the authenticated user name +## an entry keyed ${userId} is the fallback applied to any user that has no +## explicit entry; with no matching entry the clientId is denied. + +# admin may use any clientId +admin = * + +# examples of a per-user rule (explicit list or wildcard): +# order = order-primary,order-batch +# order = order-* + +# 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} = ${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