This closes #1771
This commit is contained in:
commit
0d9a114a96
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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.cli.test;
|
||||
|
||||
import org.apache.activemq.artemis.cli.commands.util.HashUtil;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HashUtilTest {
|
||||
|
||||
@Test
|
||||
public void testDefaultHashFormat() throws Exception {
|
||||
final String password = "helloworld";
|
||||
String hash = HashUtil.tryHash(new TestActionContext(), password);
|
||||
String hashStr = PasswordMaskingUtil.unwrap(hash);
|
||||
System.out.println("hashString: " + hashStr);
|
||||
String[] parts = hashStr.split(":");
|
||||
assertEquals(3, parts.length);
|
||||
//first part should be able to convert to an int
|
||||
Integer.parseInt(parts[0]);
|
||||
//second and third parts are all hex values
|
||||
checkHexBytes(parts[1], parts[2]);
|
||||
}
|
||||
|
||||
private void checkHexBytes(String... parts) throws Exception {
|
||||
for (String p : parts) {
|
||||
assertTrue(p.matches("^[0-9A-F]+$"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,14 +28,54 @@ import org.apache.activemq.artemis.logs.ActiveMQUtilBundle;
|
|||
|
||||
public final class PasswordMaskingUtil {
|
||||
|
||||
public static final String BEGIN_ENC = "ENC(";
|
||||
public static final String END_ENC = ")";
|
||||
|
||||
private PasswordMaskingUtil() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This method deals with password masking and returns the password in its plain text form.
|
||||
* @param maskPassword : explicit mask flag. If it's true, the password is interpreted as
|
||||
* masked. If it is false, the password is interpreted as plain text.
|
||||
* if it is null, the password will be interpreted as masked if the
|
||||
* password is wrapped in ENC(), or as plain text otherwise.
|
||||
* @param password : the original value of password string
|
||||
* @param codecClass : the codec used to decode the password. Only when the password is interpreted
|
||||
* as masked will this codec be used. Ignored otherwise.
|
||||
* @return
|
||||
*/
|
||||
public static String resolveMask(Boolean maskPassword, String password, String codecClass) throws Exception {
|
||||
String plainText = password;
|
||||
if (maskPassword == null) {
|
||||
if (isEncMasked(password)) {
|
||||
//masked
|
||||
String bareMaskedPassword = unwrap(password);
|
||||
plainText = getCodec(codecClass).decode(bareMaskedPassword);
|
||||
}
|
||||
} else if (maskPassword) {
|
||||
plainText = getCodec(codecClass).decode(password);
|
||||
}
|
||||
return plainText;
|
||||
}
|
||||
|
||||
public static boolean isEncMasked(String password) {
|
||||
return (password.startsWith(BEGIN_ENC) && password.endsWith(END_ENC));
|
||||
}
|
||||
|
||||
//remove ENC() from the password body
|
||||
public static String unwrap(String password) {
|
||||
return password.substring(4, password.length() - 1);
|
||||
}
|
||||
|
||||
public static String wrap(String password) {
|
||||
return BEGIN_ENC + password + END_ENC;
|
||||
}
|
||||
|
||||
private static final class LazyPlainTextProcessorHolder {
|
||||
|
||||
private LazyPlainTextProcessorHolder() {
|
||||
|
||||
}
|
||||
|
||||
private static final HashProcessor INSTANCE = new NoHashProcessor();
|
||||
|
@ -83,7 +123,7 @@ public final class PasswordMaskingUtil {
|
|||
}
|
||||
|
||||
private static boolean isEncoded(String storedPassword) {
|
||||
return storedPassword == null || (storedPassword.startsWith(SecureHashProcessor.BEGIN_HASH) && storedPassword.endsWith(SecureHashProcessor.END_HASH));
|
||||
return storedPassword == null || (isEncMasked(storedPassword));
|
||||
}
|
||||
|
||||
public static HashProcessor getHashProcessor() {
|
||||
|
@ -107,6 +147,10 @@ public final class PasswordMaskingUtil {
|
|||
public static SensitiveDataCodec<String> getCodec(String codecDesc) throws ActiveMQException {
|
||||
SensitiveDataCodec<String> codecInstance;
|
||||
|
||||
if (codecDesc == null) {
|
||||
return getDefaultCodec();
|
||||
}
|
||||
|
||||
// semi colons
|
||||
String[] parts = codecDesc.split(";");
|
||||
if (parts.length < 1)
|
||||
|
|
|
@ -18,9 +18,6 @@ package org.apache.activemq.artemis.utils;
|
|||
|
||||
public class SecureHashProcessor implements HashProcessor {
|
||||
|
||||
public static final String BEGIN_HASH = "ENC(";
|
||||
public static final String END_HASH = ")";
|
||||
|
||||
private DefaultSensitiveStringCodec codec;
|
||||
|
||||
public SecureHashProcessor(DefaultSensitiveStringCodec codec) {
|
||||
|
@ -29,13 +26,13 @@ public class SecureHashProcessor implements HashProcessor {
|
|||
|
||||
@Override
|
||||
public String hash(String plainText) throws Exception {
|
||||
return BEGIN_HASH + codec.encode(plainText) + END_HASH;
|
||||
return PasswordMaskingUtil.wrap(codec.encode(plainText));
|
||||
}
|
||||
|
||||
@Override
|
||||
//storedValue must take form of ENC(...)
|
||||
public boolean compare(char[] inputValue, String storedValue) {
|
||||
String storedHash = storedValue.substring(4, storedValue.length() - 2);
|
||||
String storedHash = storedValue.substring(4, storedValue.length() - 1);
|
||||
return codec.verify(inputValue, storedHash);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,5 +31,6 @@ public interface SensitiveDataCodec<T> {
|
|||
|
||||
T encode(Object secret) throws Exception;
|
||||
|
||||
void init(Map<String, String> params) throws Exception;
|
||||
default void init(Map<String, String> params) throws Exception {
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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.artemis.utils;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.Parameterized;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public class MaskPasswordResolvingTest {
|
||||
|
||||
private static final String plainPassword = "password";
|
||||
private static final String defaultMaskPassword = "defaultmasked";
|
||||
private static final String customizedCodecPassword = "secret";
|
||||
private static final String oldDefaultMaskedPassword = "oldmasked";
|
||||
private static final String oldCustomizedCodecPassword = "secret";
|
||||
private static final String oldExplicitPlainPassword = "PASSWORD";
|
||||
|
||||
@Parameterized.Parameters(name = "mask({0})password({1})codec({2})")
|
||||
public static Collection<Object[]> params() {
|
||||
return Arrays.asList(new Object[][]{{null, plainPassword, null},
|
||||
{null, "ENC(3bdfd94fe8cdf710e7fefa72f809ea90)", null},
|
||||
{null, "ENC(momsword)", "org.apache.activemq.artemis.utils.MaskPasswordResolvingTest$SimplePasswordCodec"},
|
||||
{true, "662d05f5a83f9e073af6b8dc081d34aa", null},
|
||||
{true, "momsword", "org.apache.activemq.artemis.utils.MaskPasswordResolvingTest$SimplePasswordCodec"},
|
||||
{false, oldExplicitPlainPassword, null},
|
||||
{false, oldExplicitPlainPassword, "org.apache.activemq.artemis.utils.MaskPasswordResolvingTest$SimplePasswordCodec"}});
|
||||
}
|
||||
|
||||
private Boolean maskPassword;
|
||||
private String password;
|
||||
private String codec;
|
||||
|
||||
public MaskPasswordResolvingTest(Boolean maskPassword, String password, String codec) {
|
||||
this.maskPassword = maskPassword;
|
||||
this.password = password;
|
||||
this.codec = codec;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPasswordResolving() throws Exception {
|
||||
String resolved = PasswordMaskingUtil.resolveMask(maskPassword, password, codec);
|
||||
System.out.println("resolved: " + resolved);
|
||||
checkResult(resolved);
|
||||
}
|
||||
|
||||
private void checkResult(String resolved) throws Exception {
|
||||
if (this.maskPassword == null) {
|
||||
if (PasswordMaskingUtil.isEncMasked(this.password)) {
|
||||
if (this.codec != null) {
|
||||
assertEquals(customizedCodecPassword, resolved);
|
||||
} else {
|
||||
assertEquals(defaultMaskPassword, resolved);
|
||||
}
|
||||
} else {
|
||||
assertEquals(plainPassword, resolved);
|
||||
}
|
||||
} else {
|
||||
if (this.maskPassword) {
|
||||
if (this.codec != null) {
|
||||
assertEquals(oldCustomizedCodecPassword, resolved);
|
||||
} else {
|
||||
assertEquals(oldDefaultMaskedPassword, resolved);
|
||||
}
|
||||
} else {
|
||||
assertEquals(oldExplicitPlainPassword, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class SimplePasswordCodec implements SensitiveDataCodec<String> {
|
||||
|
||||
private Map<String, String> passwordBook = new HashMap<>();
|
||||
|
||||
public SimplePasswordCodec() {
|
||||
passwordBook.put("momsword", "secret");
|
||||
passwordBook.put("youneverknow", "keypass");
|
||||
passwordBook.put("youcanguess", "trustpass");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String decode(Object mask) throws Exception {
|
||||
String password = passwordBook.get(mask);
|
||||
if (password == null) {
|
||||
throw new IllegalArgumentException("I don't know the password " + mask);
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encode(Object secret) throws Exception {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -179,7 +179,7 @@ public final class ActiveMQDefaultConfiguration {
|
|||
private static String DEFAULT_CLUSTER_PASSWORD = "CHANGE ME!!";
|
||||
|
||||
// This option controls whether passwords in server configuration need be masked. If set to "true" the passwords are masked.
|
||||
private static boolean DEFAULT_MASK_PASSWORD = false;
|
||||
private static Boolean DEFAULT_MASK_PASSWORD = null;
|
||||
|
||||
// true means that the management API is available via JMX
|
||||
private static boolean DEFAULT_JMX_MANAGEMENT_ENABLED = true;
|
||||
|
@ -622,7 +622,7 @@ public final class ActiveMQDefaultConfiguration {
|
|||
/**
|
||||
* This option controls whether passwords in server configuration need be masked. If set to "true" the passwords are masked.
|
||||
*/
|
||||
public static boolean isDefaultMaskPassword() {
|
||||
public static Boolean isDefaultMaskPassword() {
|
||||
return DEFAULT_MASK_PASSWORD;
|
||||
}
|
||||
|
||||
|
|
|
@ -178,9 +178,6 @@ public interface ActiveMQClientMessageBundle {
|
|||
@Message(id = 119043, value = "Invalid argument null handler")
|
||||
IllegalArgumentException nullHandler();
|
||||
|
||||
@Message(id = 119044, value = "No available codec to decode password!")
|
||||
IllegalArgumentException noCodec();
|
||||
|
||||
@Message(id = 119045, value = "the first node to be compared is null")
|
||||
IllegalArgumentException firstNodeNull();
|
||||
|
||||
|
@ -214,9 +211,6 @@ public interface ActiveMQClientMessageBundle {
|
|||
@Message(id = 119055, value = "Element {0} requires a valid Long value, but ''{1}'' cannot be parsed as a Long", format = Message.Format.MESSAGE_FORMAT)
|
||||
IllegalArgumentException mustBeLong(Node element, String value);
|
||||
|
||||
@Message(id = 119056, value = "Failed to get decoder")
|
||||
IllegalArgumentException failedToGetDecoder(@Cause Exception e);
|
||||
|
||||
@Message(id = 119057, value = "Error decoding password")
|
||||
IllegalArgumentException errordecodingPassword(@Cause Exception e);
|
||||
|
||||
|
|
|
@ -20,7 +20,6 @@ import java.util.HashSet;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQException;
|
||||
import org.apache.activemq.artemis.core.client.ActiveMQClientLogger;
|
||||
import org.apache.activemq.artemis.core.client.ActiveMQClientMessageBundle;
|
||||
|
||||
|
@ -164,25 +163,9 @@ public class ConfigurationHelper {
|
|||
|
||||
String value = prop.toString();
|
||||
Boolean useMask = (Boolean) props.get(defaultMaskPassword);
|
||||
if (useMask == null || (!useMask)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final String classImpl = (String) props.get(defaultPasswordCodec);
|
||||
|
||||
if (classImpl == null) {
|
||||
throw ActiveMQClientMessageBundle.BUNDLE.noCodec();
|
||||
}
|
||||
|
||||
SensitiveDataCodec<String> codec = null;
|
||||
try {
|
||||
codec = PasswordMaskingUtil.getCodec(classImpl);
|
||||
} catch (ActiveMQException e1) {
|
||||
throw ActiveMQClientMessageBundle.BUNDLE.failedToGetDecoder(e1);
|
||||
}
|
||||
|
||||
try {
|
||||
return codec.decode(value);
|
||||
return PasswordMaskingUtil.resolveMask(useMask, value, classImpl);
|
||||
} catch (Exception e) {
|
||||
throw ActiveMQClientMessageBundle.BUNDLE.errordecodingPassword(e);
|
||||
}
|
||||
|
|
|
@ -31,6 +31,14 @@
|
|||
<activemq.basedir>${project.basedir}/..</activemq.basedir>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.activemq</groupId>
|
||||
<artifactId>artemis-commons</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
package org.apache.activemq.artemis.dto;
|
||||
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlAttribute;
|
||||
|
@ -37,21 +39,44 @@ public class WebServerDTO extends ComponentDTO {
|
|||
public Boolean clientAuth;
|
||||
|
||||
@XmlAttribute
|
||||
public String keyStorePath;
|
||||
public String passwordCodec;
|
||||
|
||||
@XmlAttribute
|
||||
public String keyStorePassword;
|
||||
public String keyStorePath;
|
||||
|
||||
@XmlAttribute
|
||||
public String trustStorePath;
|
||||
|
||||
@XmlAttribute
|
||||
public String trustStorePassword;
|
||||
|
||||
@XmlElementRef
|
||||
public List<AppDTO> apps;
|
||||
|
||||
@XmlAttribute
|
||||
private String keyStorePassword;
|
||||
|
||||
@XmlAttribute
|
||||
private String trustStorePassword;
|
||||
|
||||
public WebServerDTO() {
|
||||
componentClassName = "org.apache.activemq.artemis.component.WebServerComponent";
|
||||
}
|
||||
|
||||
public String getKeyStorePassword() throws Exception {
|
||||
return getPassword(this.keyStorePassword);
|
||||
}
|
||||
|
||||
private String getPassword(String password) throws Exception {
|
||||
return PasswordMaskingUtil.resolveMask(null, password, this.passwordCodec);
|
||||
}
|
||||
|
||||
public void setKeyStorePassword(String keyStorePassword) {
|
||||
this.keyStorePassword = keyStorePassword;
|
||||
}
|
||||
|
||||
public String getTrustStorePassword() throws Exception {
|
||||
return getPassword(this.trustStorePassword);
|
||||
}
|
||||
|
||||
public void setTrustStorePassword(String trustStorePassword) {
|
||||
this.trustStorePassword = trustStorePassword;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,9 +69,7 @@ import org.apache.activemq.artemis.jms.server.ActiveMQJMSServerBundle;
|
|||
import org.apache.activemq.artemis.service.extensions.ServiceUtils;
|
||||
import org.apache.activemq.artemis.service.extensions.xa.recovery.ActiveMQRegistry;
|
||||
import org.apache.activemq.artemis.service.extensions.xa.recovery.XARecoveryConfig;
|
||||
import org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
|
||||
public final class JMSBridgeImpl implements JMSBridge {
|
||||
|
||||
|
@ -168,7 +166,7 @@ public final class JMSBridgeImpl implements JMSBridge {
|
|||
|
||||
private ObjectName objectName;
|
||||
|
||||
private boolean useMaskedPassword = false;
|
||||
private Boolean useMaskedPassword;
|
||||
|
||||
private String passwordCodec;
|
||||
|
||||
|
@ -440,26 +438,17 @@ public final class JMSBridgeImpl implements JMSBridge {
|
|||
}
|
||||
|
||||
private void initPasswords() throws ActiveMQException {
|
||||
if (useMaskedPassword) {
|
||||
SensitiveDataCodec<String> codecInstance = new DefaultSensitiveStringCodec();
|
||||
|
||||
if (passwordCodec != null) {
|
||||
codecInstance = PasswordMaskingUtil.getCodec(passwordCodec);
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.sourcePassword != null) {
|
||||
sourcePassword = codecInstance.decode(sourcePassword);
|
||||
sourcePassword = PasswordMaskingUtil.resolveMask(useMaskedPassword, sourcePassword, passwordCodec);
|
||||
}
|
||||
|
||||
if (this.targetPassword != null) {
|
||||
targetPassword = codecInstance.decode(targetPassword);
|
||||
targetPassword = PasswordMaskingUtil.resolveMask(useMaskedPassword, targetPassword, passwordCodec);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw ActiveMQJMSServerBundle.BUNDLE.errorDecodingPassword(e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -20,9 +20,7 @@ import java.io.Serializable;
|
|||
import java.util.Hashtable;
|
||||
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQException;
|
||||
import org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
|
||||
/**
|
||||
* The RA default properties - these are set in the ra.xml file
|
||||
|
@ -66,14 +64,12 @@ public class ActiveMQRAProperties extends ConnectionFactoryProperties implements
|
|||
|
||||
private boolean useJNDI;
|
||||
|
||||
private boolean useMaskedPassword = false;
|
||||
private Boolean useMaskedPassword = null;
|
||||
|
||||
private String passwordCodec;
|
||||
|
||||
private boolean initialized = false;
|
||||
|
||||
private transient SensitiveDataCodec<String> codecInstance;
|
||||
|
||||
/**
|
||||
* Class used to get a JChannel
|
||||
*/
|
||||
|
@ -217,11 +213,11 @@ public class ActiveMQRAProperties extends ConnectionFactoryProperties implements
|
|||
this.setupInterval = setupInterval;
|
||||
}
|
||||
|
||||
public boolean isUseMaskedPassword() {
|
||||
public Boolean isUseMaskedPassword() {
|
||||
return useMaskedPassword;
|
||||
}
|
||||
|
||||
public void setUseMaskedPassword(boolean useMaskedPassword) {
|
||||
public void setUseMaskedPassword(Boolean useMaskedPassword) {
|
||||
this.useMaskedPassword = useMaskedPassword;
|
||||
}
|
||||
|
||||
|
@ -243,27 +239,19 @@ public class ActiveMQRAProperties extends ConnectionFactoryProperties implements
|
|||
if (initialized)
|
||||
return;
|
||||
|
||||
if (useMaskedPassword) {
|
||||
codecInstance = new DefaultSensitiveStringCodec();
|
||||
|
||||
if (passwordCodec != null) {
|
||||
codecInstance = PasswordMaskingUtil.getCodec(passwordCodec);
|
||||
}
|
||||
|
||||
try {
|
||||
if (password != null) {
|
||||
password = codecInstance.decode(password);
|
||||
}
|
||||
try {
|
||||
password = PasswordMaskingUtil.resolveMask(useMaskedPassword, password, passwordCodec);
|
||||
} catch (Exception e) {
|
||||
throw ActiveMQRABundle.BUNDLE.errorDecodingPassword(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
public SensitiveDataCodec<String> getCodecInstance() {
|
||||
return codecInstance;
|
||||
public String getCodec() {
|
||||
return passwordCodec;
|
||||
}
|
||||
|
||||
public String getJgroupsChannelLocatorClass() {
|
||||
|
|
|
@ -59,7 +59,6 @@ import org.apache.activemq.artemis.ra.inflow.ActiveMQActivationSpec;
|
|||
import org.apache.activemq.artemis.ra.recovery.RecoveryManager;
|
||||
import org.apache.activemq.artemis.service.extensions.ServiceUtils;
|
||||
import org.apache.activemq.artemis.service.extensions.xa.recovery.XARecoveryConfig;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jgroups.JChannel;
|
||||
|
||||
|
@ -2037,8 +2036,8 @@ public class ActiveMQResourceAdapter implements ResourceAdapter, Serializable {
|
|||
managedConnectionFactories.add(activeMQRAManagedConnectionFactory);
|
||||
}
|
||||
|
||||
public SensitiveDataCodec<String> getCodecInstance() {
|
||||
return raProperties.getCodecInstance();
|
||||
public String getCodec() {
|
||||
return raProperties.getCodec();
|
||||
}
|
||||
|
||||
public synchronized void closeConnectionFactory(ConnectionFactoryProperties properties) {
|
||||
|
|
|
@ -57,7 +57,7 @@ import org.apache.activemq.artemis.ra.ActiveMQRaUtils;
|
|||
import org.apache.activemq.artemis.ra.ActiveMQResourceAdapter;
|
||||
import org.apache.activemq.artemis.service.extensions.xa.recovery.XARecoveryConfig;
|
||||
import org.apache.activemq.artemis.utils.FutureLatch;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
|
@ -148,18 +148,14 @@ public class ActiveMQActivation {
|
|||
logger.trace("constructor(" + ra + ", " + endpointFactory + ", " + spec + ")");
|
||||
}
|
||||
|
||||
if (ra.isUseMaskedPassword()) {
|
||||
String pass = spec.getOwnPassword();
|
||||
if (pass != null) {
|
||||
SensitiveDataCodec<String> codec = ra.getCodecInstance();
|
||||
|
||||
try {
|
||||
spec.setPassword(codec.decode(pass));
|
||||
spec.setPassword(PasswordMaskingUtil.resolveMask(ra.isUseMaskedPassword(), pass, ra.getCodec()));
|
||||
} catch (Exception e) {
|
||||
throw new ResourceException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ra = ra;
|
||||
this.endpointFactory = endpointFactory;
|
||||
|
|
|
@ -1000,12 +1000,12 @@ public interface Configuration {
|
|||
/**
|
||||
* Sets if passwords should be masked or not. True means the passwords should be masked.
|
||||
*/
|
||||
Configuration setMaskPassword(boolean maskPassword);
|
||||
Configuration setMaskPassword(Boolean maskPassword);
|
||||
|
||||
/**
|
||||
* If passwords are masked. True means the passwords are masked.
|
||||
*/
|
||||
boolean isMaskPassword();
|
||||
Boolean isMaskPassword();
|
||||
|
||||
/*
|
||||
* Whether or not that ActiveMQ Artemis should use all protocols available on the classpath. If false only the core protocol will
|
||||
|
|
|
@ -249,7 +249,7 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
|
||||
protected List<ConnectorServiceConfiguration> connectorServiceConfigurations = new ArrayList<>();
|
||||
|
||||
private boolean maskPassword = ActiveMQDefaultConfiguration.isDefaultMaskPassword();
|
||||
private Boolean maskPassword = ActiveMQDefaultConfiguration.isDefaultMaskPassword();
|
||||
|
||||
private transient String passwordCodec;
|
||||
|
||||
|
@ -1461,12 +1461,12 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
}
|
||||
|
||||
@Override
|
||||
public boolean isMaskPassword() {
|
||||
public Boolean isMaskPassword() {
|
||||
return maskPassword;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConfigurationImpl setMaskPassword(boolean maskPassword) {
|
||||
public ConfigurationImpl setMaskPassword(Boolean maskPassword) {
|
||||
this.maskPassword = maskPassword;
|
||||
return this;
|
||||
}
|
||||
|
@ -1673,7 +1673,6 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
return false;
|
||||
if (asyncConnectionExecutionEnabled != other.asyncConnectionExecutionEnabled)
|
||||
return false;
|
||||
|
||||
if (bindingsDirectory == null) {
|
||||
if (other.bindingsDirectory != null)
|
||||
return false;
|
||||
|
@ -1689,11 +1688,13 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
return false;
|
||||
} else if (!broadcastGroupConfigurations.equals(other.broadcastGroupConfigurations))
|
||||
return false;
|
||||
|
||||
if (clusterConfigurations == null) {
|
||||
if (other.clusterConfigurations != null)
|
||||
return false;
|
||||
} else if (!clusterConfigurations.equals(other.clusterConfigurations))
|
||||
return false;
|
||||
|
||||
if (clusterPassword == null) {
|
||||
if (other.clusterPassword != null)
|
||||
return false;
|
||||
|
@ -1720,6 +1721,7 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
return false;
|
||||
if (createJournalDir != other.createJournalDir)
|
||||
return false;
|
||||
|
||||
if (discoveryGroupConfigurations == null) {
|
||||
if (other.discoveryGroupConfigurations != null)
|
||||
return false;
|
||||
|
@ -1801,8 +1803,15 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
return false;
|
||||
} else if (!managementNotificationAddress.equals(other.managementNotificationAddress))
|
||||
return false;
|
||||
if (maskPassword != other.maskPassword)
|
||||
|
||||
if (this.maskPassword == null) {
|
||||
if (other.maskPassword != null)
|
||||
return false;
|
||||
} else {
|
||||
if (!this.maskPassword.equals(other.maskPassword))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxConcurrentPageIO != other.maxConcurrentPageIO)
|
||||
return false;
|
||||
if (memoryMeasureInterval != other.memoryMeasureInterval)
|
||||
|
@ -1824,7 +1833,6 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
return false;
|
||||
} else if (!name.equals(other.name))
|
||||
return false;
|
||||
|
||||
if (outgoingInterceptorClassNames == null) {
|
||||
if (other.outgoingInterceptorClassNames != null)
|
||||
return false;
|
||||
|
@ -1881,6 +1889,7 @@ public class ConfigurationImpl implements Configuration, Serializable {
|
|||
if (journalDatasync != other.journalDatasync) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (globalMaxSize != null && !globalMaxSize.equals(other.globalMaxSize)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import java.util.Set;
|
|||
|
||||
import org.apache.activemq.artemis.core.server.ActiveMQServerLogger;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
|
||||
@Deprecated
|
||||
public class FileSecurityConfiguration extends SecurityConfiguration {
|
||||
|
@ -31,7 +30,7 @@ public class FileSecurityConfiguration extends SecurityConfiguration {
|
|||
|
||||
private final String rolesUrl;
|
||||
|
||||
private boolean maskPassword;
|
||||
private Boolean maskPassword;
|
||||
|
||||
private String passwordCodec;
|
||||
|
||||
|
@ -65,14 +64,7 @@ public class FileSecurityConfiguration extends SecurityConfiguration {
|
|||
if (started) {
|
||||
return;
|
||||
}
|
||||
SensitiveDataCodec<String> codec = null;
|
||||
if (maskPassword) {
|
||||
if (passwordCodec != null) {
|
||||
codec = PasswordMaskingUtil.getDefaultCodec();
|
||||
} else {
|
||||
codec = PasswordMaskingUtil.getCodec(passwordCodec);
|
||||
}
|
||||
}
|
||||
|
||||
URL theUsersUrl = getClass().getClassLoader().getResource(usersUrl);
|
||||
|
||||
if (theUsersUrl == null) {
|
||||
|
@ -94,9 +86,7 @@ public class FileSecurityConfiguration extends SecurityConfiguration {
|
|||
|
||||
for (String username : keys) {
|
||||
String password = userProps.getProperty(username);
|
||||
if (codec != null) {
|
||||
password = codec.decode(password);
|
||||
}
|
||||
password = PasswordMaskingUtil.resolveMask(this.maskPassword, password, passwordCodec);
|
||||
addUser(username, password);
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,6 @@ import org.apache.activemq.artemis.utils.ByteUtil;
|
|||
import org.apache.activemq.artemis.utils.ClassloadingUtil;
|
||||
import org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.apache.activemq.artemis.utils.SensitiveDataCodec;
|
||||
import org.apache.activemq.artemis.utils.XMLConfigurationUtil;
|
||||
import org.apache.activemq.artemis.utils.XMLUtil;
|
||||
import org.apache.activemq.artemis.utils.critical.CriticalAnalyzerPolicy;
|
||||
|
@ -339,7 +338,7 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
|
||||
config.setManagementNotificationAddress(new SimpleString(getString(e, "management-notification-address", config.getManagementNotificationAddress().toString(), Validators.NOT_NULL_OR_EMPTY)));
|
||||
|
||||
config.setMaskPassword(getBoolean(e, "mask-password", false));
|
||||
config.setMaskPassword(getBoolean(e, "mask-password", null));
|
||||
|
||||
config.setPasswordCodec(getString(e, "password-codec", DefaultSensitiveStringCodec.class.getName(), Validators.NOT_NULL_OR_EMPTY));
|
||||
|
||||
|
@ -369,15 +368,11 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
// parsing cluster password
|
||||
String passwordText = getString(e, "cluster-password", null, Validators.NO_CHECK);
|
||||
|
||||
final boolean maskText = config.isMaskPassword();
|
||||
final Boolean maskText = config.isMaskPassword();
|
||||
|
||||
if (passwordText != null) {
|
||||
if (maskText) {
|
||||
SensitiveDataCodec<String> codec = PasswordMaskingUtil.getCodec(config.getPasswordCodec());
|
||||
config.setClusterPassword(codec.decode(passwordText));
|
||||
} else {
|
||||
config.setClusterPassword(passwordText);
|
||||
}
|
||||
String resolvedPassword = PasswordMaskingUtil.resolveMask(maskText, passwordText, config.getPasswordCodec());
|
||||
config.setClusterPassword(resolvedPassword);
|
||||
}
|
||||
|
||||
config.setClusterUser(getString(e, "cluster-user", config.getClusterUser(), Validators.NO_CHECK));
|
||||
|
@ -1164,13 +1159,11 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
|
||||
Map<String, Object> params = configurations.get(0).getParams();
|
||||
|
||||
if (mainConfig.isMaskPassword()) {
|
||||
params.put(ActiveMQDefaultConfiguration.getPropMaskPassword(), mainConfig.isMaskPassword());
|
||||
|
||||
if (mainConfig.getPasswordCodec() != null) {
|
||||
params.put(ActiveMQDefaultConfiguration.getPropPasswordCodec(), mainConfig.getPasswordCodec());
|
||||
}
|
||||
}
|
||||
|
||||
return configurations.get(0);
|
||||
}
|
||||
|
@ -1187,13 +1180,11 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
|
||||
Map<String, Object> params = configurations.get(0).getParams();
|
||||
|
||||
if (mainConfig.isMaskPassword()) {
|
||||
params.put(ActiveMQDefaultConfiguration.getPropMaskPassword(), mainConfig.isMaskPassword());
|
||||
|
||||
if (mainConfig.getPasswordCodec() != null) {
|
||||
params.put(ActiveMQDefaultConfiguration.getPropPasswordCodec(), mainConfig.getPasswordCodec());
|
||||
}
|
||||
}
|
||||
|
||||
return configurations.get(0);
|
||||
}
|
||||
|
@ -1720,9 +1711,6 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
|
||||
NodeList clusterPassNodes = brNode.getElementsByTagName("password");
|
||||
String password = null;
|
||||
boolean maskPassword = mainConfig.isMaskPassword();
|
||||
|
||||
SensitiveDataCodec<String> codec = null;
|
||||
|
||||
if (clusterPassNodes.getLength() > 0) {
|
||||
Node passNode = clusterPassNodes.item(0);
|
||||
|
@ -1730,10 +1718,7 @@ public final class FileConfigurationParser extends XMLConfigurationUtil {
|
|||
}
|
||||
|
||||
if (password != null) {
|
||||
if (maskPassword) {
|
||||
codec = PasswordMaskingUtil.getCodec(mainConfig.getPasswordCodec());
|
||||
password = codec.decode(password);
|
||||
}
|
||||
password = PasswordMaskingUtil.resolveMask(mainConfig.isMaskPassword(), password, mainConfig.getPasswordCodec());
|
||||
} else {
|
||||
password = ActiveMQDefaultConfiguration.getDefaultClusterPassword();
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ import java.util.Queue;
|
|||
import java.util.Set;
|
||||
|
||||
import org.apache.activemq.artemis.core.server.ActiveMQServerLogger;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
public class LDAPLoginModule implements LoginModule {
|
||||
|
@ -83,6 +84,8 @@ public class LDAPLoginModule implements LoginModule {
|
|||
private static final String SASL_LOGIN_CONFIG_SCOPE = "saslLoginConfigScope";
|
||||
private static final String AUTHENTICATE_USER = "authenticateUser";
|
||||
private static final String REFERRAL = "referral";
|
||||
private static final String MASK_PASSWORD = "maskPassword";
|
||||
private static final String PASSWORD_CODEC = "passwordCodec";
|
||||
|
||||
protected DirContext context;
|
||||
|
||||
|
@ -97,6 +100,8 @@ public class LDAPLoginModule implements LoginModule {
|
|||
private boolean isRoleAttributeSet = false;
|
||||
private String roleAttributeName = null;
|
||||
|
||||
private String codecClass = null;
|
||||
|
||||
@Override
|
||||
public void initialize(Subject subject,
|
||||
CallbackHandler callbackHandler,
|
||||
|
@ -105,12 +110,38 @@ public class LDAPLoginModule implements LoginModule {
|
|||
this.subject = subject;
|
||||
this.handler = callbackHandler;
|
||||
|
||||
config = new LDAPLoginProperty[]{new LDAPLoginProperty(INITIAL_CONTEXT_FACTORY, (String) options.get(INITIAL_CONTEXT_FACTORY)), new LDAPLoginProperty(CONNECTION_URL, (String) options.get(CONNECTION_URL)), new LDAPLoginProperty(CONNECTION_USERNAME, (String) options.get(CONNECTION_USERNAME)), new LDAPLoginProperty(CONNECTION_PASSWORD, (String) options.get(CONNECTION_PASSWORD)), new LDAPLoginProperty(CONNECTION_PROTOCOL, (String) options.get(CONNECTION_PROTOCOL)), new LDAPLoginProperty(AUTHENTICATION, (String) options.get(AUTHENTICATION)), new LDAPLoginProperty(USER_BASE, (String) options.get(USER_BASE)), new LDAPLoginProperty(USER_SEARCH_MATCHING, (String) options.get(USER_SEARCH_MATCHING)), new LDAPLoginProperty(USER_SEARCH_SUBTREE, (String) options.get(USER_SEARCH_SUBTREE)), new LDAPLoginProperty(ROLE_BASE, (String) options.get(ROLE_BASE)), new LDAPLoginProperty(ROLE_NAME, (String) options.get(ROLE_NAME)), new LDAPLoginProperty(ROLE_SEARCH_MATCHING, (String) options.get(ROLE_SEARCH_MATCHING)), new LDAPLoginProperty(ROLE_SEARCH_SUBTREE, (String) options.get(ROLE_SEARCH_SUBTREE)), new LDAPLoginProperty(USER_ROLE_NAME, (String) options.get(USER_ROLE_NAME)), new LDAPLoginProperty(EXPAND_ROLES, (String) options.get(EXPAND_ROLES)), new LDAPLoginProperty(EXPAND_ROLES_MATCHING, (String) options.get(EXPAND_ROLES_MATCHING)), new LDAPLoginProperty(REFERRAL, (String) options.get(REFERRAL))};
|
||||
config = new LDAPLoginProperty[]{new LDAPLoginProperty(INITIAL_CONTEXT_FACTORY, (String) options.get(INITIAL_CONTEXT_FACTORY)),
|
||||
new LDAPLoginProperty(CONNECTION_URL, (String) options.get(CONNECTION_URL)),
|
||||
new LDAPLoginProperty(CONNECTION_USERNAME, (String) options.get(CONNECTION_USERNAME)),
|
||||
new LDAPLoginProperty(CONNECTION_PASSWORD, (String) options.get(CONNECTION_PASSWORD)),
|
||||
new LDAPLoginProperty(CONNECTION_PROTOCOL, (String) options.get(CONNECTION_PROTOCOL)),
|
||||
new LDAPLoginProperty(AUTHENTICATION, (String) options.get(AUTHENTICATION)),
|
||||
new LDAPLoginProperty(USER_BASE, (String) options.get(USER_BASE)),
|
||||
new LDAPLoginProperty(USER_SEARCH_MATCHING, (String) options.get(USER_SEARCH_MATCHING)),
|
||||
new LDAPLoginProperty(USER_SEARCH_SUBTREE, (String) options.get(USER_SEARCH_SUBTREE)),
|
||||
new LDAPLoginProperty(ROLE_BASE, (String) options.get(ROLE_BASE)),
|
||||
new LDAPLoginProperty(ROLE_NAME, (String) options.get(ROLE_NAME)),
|
||||
new LDAPLoginProperty(ROLE_SEARCH_MATCHING, (String) options.get(ROLE_SEARCH_MATCHING)),
|
||||
new LDAPLoginProperty(ROLE_SEARCH_SUBTREE, (String) options.get(ROLE_SEARCH_SUBTREE)),
|
||||
new LDAPLoginProperty(USER_ROLE_NAME, (String) options.get(USER_ROLE_NAME)),
|
||||
new LDAPLoginProperty(EXPAND_ROLES, (String) options.get(EXPAND_ROLES)),
|
||||
new LDAPLoginProperty(EXPAND_ROLES_MATCHING, (String) options.get(EXPAND_ROLES_MATCHING)),
|
||||
new LDAPLoginProperty(REFERRAL, (String) options.get(REFERRAL))};
|
||||
|
||||
if (isLoginPropertySet(AUTHENTICATE_USER)) {
|
||||
authenticateUser = Boolean.valueOf(getLDAPPropertyValue(AUTHENTICATE_USER));
|
||||
}
|
||||
isRoleAttributeSet = isLoginPropertySet(ROLE_NAME);
|
||||
roleAttributeName = getLDAPPropertyValue(ROLE_NAME);
|
||||
codecClass = (String) options.get(PASSWORD_CODEC);
|
||||
}
|
||||
|
||||
private String getPlainPassword(String password) {
|
||||
try {
|
||||
return PasswordMaskingUtil.resolveMask(null, password, codecClass);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to decode password", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -511,7 +542,7 @@ public class LDAPLoginModule implements LoginModule {
|
|||
context.removeFromEnvironment(Context.SECURITY_PRINCIPAL);
|
||||
}
|
||||
if (isLoginPropertySet(CONNECTION_PASSWORD)) {
|
||||
context.addToEnvironment(Context.SECURITY_CREDENTIALS, getLDAPPropertyValue(CONNECTION_PASSWORD));
|
||||
context.addToEnvironment(Context.SECURITY_CREDENTIALS, getPlainPassword(getLDAPPropertyValue(CONNECTION_PASSWORD)));
|
||||
} else {
|
||||
context.removeFromEnvironment(Context.SECURITY_CREDENTIALS);
|
||||
}
|
||||
|
@ -575,7 +606,7 @@ public class LDAPLoginModule implements LoginModule {
|
|||
}
|
||||
|
||||
if (isLoginPropertySet(CONNECTION_PASSWORD)) {
|
||||
env.put(Context.SECURITY_CREDENTIALS, getLDAPPropertyValue(CONNECTION_PASSWORD));
|
||||
env.put(Context.SECURITY_CREDENTIALS, getPlainPassword(getLDAPPropertyValue(CONNECTION_PASSWORD)));
|
||||
} else {
|
||||
throw new NamingException("Empty password is not allowed");
|
||||
}
|
||||
|
|
|
@ -116,7 +116,7 @@ public class XMLConfigurationUtil {
|
|||
return getTextBytesAsLongBytes(e, name, def, validator).intValue();
|
||||
}
|
||||
|
||||
public static final Boolean getBoolean(final Element e, final String name, final boolean def) {
|
||||
public static final Boolean getBoolean(final Element e, final String name, final Boolean def) {
|
||||
NodeList nl = e.getElementsByTagName(name);
|
||||
if (nl.getLength() > 0) {
|
||||
return XMLUtil.parseBoolean(nl.item(0));
|
||||
|
|
|
@ -19,10 +19,12 @@ package org.apache.activemq.artemis.core.config.impl;
|
|||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.activemq.artemis.api.config.ActiveMQDefaultConfiguration;
|
||||
import org.apache.activemq.artemis.api.core.SimpleString;
|
||||
import org.apache.activemq.artemis.core.config.BridgeConfiguration;
|
||||
import org.apache.activemq.artemis.core.config.Configuration;
|
||||
import org.apache.activemq.artemis.core.config.FileDeploymentManager;
|
||||
import org.apache.activemq.artemis.core.config.HAPolicyConfiguration;
|
||||
|
@ -32,6 +34,7 @@ import org.apache.activemq.artemis.core.deployers.impl.FileConfigurationParser;
|
|||
import org.apache.activemq.artemis.core.server.ActiveMQServer;
|
||||
import org.apache.activemq.artemis.tests.util.ActiveMQTestBase;
|
||||
import org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
|
@ -190,6 +193,85 @@ public class FileConfigurationParserTest extends ActiveMQTestBase {
|
|||
assertEquals("newpassword", config.getClusterPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParsingDefaultServerConfigWithENCMaskedPwd() throws Exception {
|
||||
FileConfigurationParser parser = new FileConfigurationParser();
|
||||
|
||||
String configStr = firstPart + lastPart;
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(configStr.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Configuration config = parser.parseMainConfig(input);
|
||||
|
||||
String clusterPassword = config.getClusterPassword();
|
||||
|
||||
assertEquals(ActiveMQDefaultConfiguration.getDefaultClusterPassword(), clusterPassword);
|
||||
|
||||
//if we add cluster-password, it should be default plain text
|
||||
String clusterPasswordPart = "<cluster-password>ENC(5aec0780b12bf225a13ab70c6c76bc8e)</cluster-password>";
|
||||
|
||||
configStr = firstPart + clusterPasswordPart + lastPart;
|
||||
|
||||
config = parser.parseMainConfig(new ByteArrayInputStream(configStr.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertEquals("helloworld", config.getClusterPassword());
|
||||
|
||||
//if we add mask, it should be able to decode correctly
|
||||
DefaultSensitiveStringCodec codec = new DefaultSensitiveStringCodec();
|
||||
String mask = (String) codec.encode("helloworld");
|
||||
|
||||
clusterPasswordPart = "<cluster-password>" + PasswordMaskingUtil.wrap(mask) + "</cluster-password>";
|
||||
|
||||
configStr = firstPart + clusterPasswordPart + lastPart;
|
||||
|
||||
config = parser.parseMainConfig(new ByteArrayInputStream(configStr.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertEquals("helloworld", config.getClusterPassword());
|
||||
|
||||
//if we change key, it should be able to decode correctly
|
||||
codec = new DefaultSensitiveStringCodec();
|
||||
Map<String, String> prop = new HashMap<>();
|
||||
prop.put("key", "newkey");
|
||||
codec.init(prop);
|
||||
|
||||
mask = (String) codec.encode("newpassword");
|
||||
|
||||
clusterPasswordPart = "<cluster-password>" + PasswordMaskingUtil.wrap(mask) + "</cluster-password>";
|
||||
|
||||
String codecPart = "<password-codec>" + "org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec" +
|
||||
";key=newkey</password-codec>";
|
||||
|
||||
configStr = firstPart + clusterPasswordPart + codecPart + lastPart;
|
||||
|
||||
config = parser.parseMainConfig(new ByteArrayInputStream(configStr.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
assertEquals("newpassword", config.getClusterPassword());
|
||||
|
||||
configStr = firstPart + bridgePart + lastPart;
|
||||
config = parser.parseMainConfig(new ByteArrayInputStream(configStr.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
List<BridgeConfiguration> bridgeConfigs = config.getBridgeConfigurations();
|
||||
assertEquals(1, bridgeConfigs.size());
|
||||
|
||||
BridgeConfiguration bconfig = bridgeConfigs.get(0);
|
||||
|
||||
assertEquals("helloworld", bconfig.getPassword());
|
||||
}
|
||||
|
||||
private static String bridgePart = "<bridges>\n" +
|
||||
" <bridge name=\"my-bridge\">\n" +
|
||||
" <queue-name>sausage-factory</queue-name>\n" +
|
||||
" <forwarding-address>mincing-machine</forwarding-address>\n" +
|
||||
" <filter string=\"name='aardvark'\"/>\n" +
|
||||
" <transformer-class-name>org.apache.activemq.artemis.jms.example.HatColourChangeTransformer</transformer-class-name>\n" +
|
||||
" <reconnect-attempts>-1</reconnect-attempts>\n" +
|
||||
" <user>bridge-user</user>" +
|
||||
" <password>ENC(5aec0780b12bf225a13ab70c6c76bc8e)</password>" +
|
||||
" <static-connectors>\n" +
|
||||
" <connector-ref>remote-connector</connector-ref>\n" +
|
||||
" </static-connectors>\n" +
|
||||
" </bridge>\n" +
|
||||
"</bridges>\n";
|
||||
|
||||
private static String firstPart = "<core xmlns=\"urn:activemq:core\">" + "\n" +
|
||||
"<name>ActiveMQ.main.config</name>" + "\n" +
|
||||
"<log-delegate-factory-class-name>org.apache.activemq.artemis.integration.logging.Log4jLogDelegateFactory</log-delegate-factory-class-name>" + "\n" +
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* 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.artemis.core.security.jaas;
|
||||
|
||||
import org.apache.directory.server.annotations.CreateLdapServer;
|
||||
import org.apache.directory.server.annotations.CreateTransport;
|
||||
import org.apache.directory.server.core.annotations.ApplyLdifFiles;
|
||||
import org.apache.directory.server.core.integ.AbstractLdapTestUnit;
|
||||
import org.apache.directory.server.core.integ.FrameworkRunner;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
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 static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
@RunWith(FrameworkRunner.class)
|
||||
@CreateLdapServer(transports = {@CreateTransport(protocol = "LDAP", port = 1024)})
|
||||
@ApplyLdifFiles("test.ldif")
|
||||
public class LDAPLoginModuleMaskPasswordTest extends AbstractLdapTestUnit {
|
||||
|
||||
private final String loginConfigSysPropName = "java.security.auth.login.config";
|
||||
private String oldLoginConfig;
|
||||
|
||||
@Before
|
||||
public void setLoginConfigSysProperty() {
|
||||
oldLoginConfig = System.getProperty(loginConfigSysPropName, null);
|
||||
System.setProperty(loginConfigSysPropName, "src/test/resources/login.config");
|
||||
}
|
||||
|
||||
@After
|
||||
public void resetLoginConfigSysProperty() {
|
||||
if (oldLoginConfig != null) {
|
||||
System.setProperty(loginConfigSysPropName, oldLoginConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginMaskedPassword() throws LoginException {
|
||||
LoginContext context = new LoginContext("LDAPLoginMaskedPassword", callbacks -> {
|
||||
for (int i = 0; i < callbacks.length; i++) {
|
||||
if (callbacks[i] instanceof NameCallback) {
|
||||
((NameCallback) callbacks[i]).setName("first");
|
||||
} else if (callbacks[i] instanceof PasswordCallback) {
|
||||
((PasswordCallback) callbacks[i]).setPassword("secret".toCharArray());
|
||||
} else {
|
||||
throw new UnsupportedCallbackException(callbacks[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
context.login();
|
||||
context.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginMaskedPasswordUnauthenticated() throws LoginException {
|
||||
LoginContext context = new LoginContext("LDAPLoginMaskedPassword", callbacks -> {
|
||||
for (int i = 0; i < callbacks.length; i++) {
|
||||
if (callbacks[i] instanceof NameCallback) {
|
||||
((NameCallback) callbacks[i]).setName("first");
|
||||
} else if (callbacks[i] instanceof PasswordCallback) {
|
||||
((PasswordCallback) callbacks[i]).setPassword("nosecret".toCharArray());
|
||||
} else {
|
||||
throw new UnsupportedCallbackException(callbacks[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
context.login();
|
||||
} catch (FailedLoginException le) {
|
||||
assertEquals(le.getMessage(), "Password does not match for user: first");
|
||||
return;
|
||||
}
|
||||
fail("Should have failed authenticating");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginExternalCodec() throws LoginException {
|
||||
LoginContext context = new LoginContext("LDAPLoginExternalPasswordCodec", callbacks -> {
|
||||
for (int i = 0; i < callbacks.length; i++) {
|
||||
if (callbacks[i] instanceof NameCallback) {
|
||||
((NameCallback) callbacks[i]).setName("first");
|
||||
} else if (callbacks[i] instanceof PasswordCallback) {
|
||||
((PasswordCallback) callbacks[i]).setPassword("secret".toCharArray());
|
||||
} else {
|
||||
throw new UnsupportedCallbackException(callbacks[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
context.login();
|
||||
context.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginExternalCodec2() throws LoginException {
|
||||
LoginContext context = new LoginContext("LDAPLoginExternalPasswordCodec2", callbacks -> {
|
||||
for (int i = 0; i < callbacks.length; i++) {
|
||||
if (callbacks[i] instanceof NameCallback) {
|
||||
((NameCallback) callbacks[i]).setName("first");
|
||||
} else if (callbacks[i] instanceof PasswordCallback) {
|
||||
((PasswordCallback) callbacks[i]).setPassword("secret".toCharArray());
|
||||
} else {
|
||||
throw new UnsupportedCallbackException(callbacks[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
context.login();
|
||||
context.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginExternalCodecUnauthenticated() throws LoginException {
|
||||
LoginContext context = new LoginContext("LDAPLoginExternalPasswordCodec", callbacks -> {
|
||||
for (int i = 0; i < callbacks.length; i++) {
|
||||
if (callbacks[i] instanceof NameCallback) {
|
||||
((NameCallback) callbacks[i]).setName("first");
|
||||
} else if (callbacks[i] instanceof PasswordCallback) {
|
||||
((PasswordCallback) callbacks[i]).setPassword("nosecret".toCharArray());
|
||||
} else {
|
||||
throw new UnsupportedCallbackException(callbacks[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
context.login();
|
||||
} catch (FailedLoginException le) {
|
||||
assertEquals(le.getMessage(), "Password does not match for user: first");
|
||||
return;
|
||||
}
|
||||
fail("Should have failed authenticating");
|
||||
}
|
||||
}
|
|
@ -76,6 +76,13 @@ public class PropertiesLoginModuleTest extends Assert {
|
|||
assertEquals("Should have zero principals", 0, subject.getPrincipals().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginMasked() throws LoginException {
|
||||
LoginContext context = new LoginContext("PropertiesLogin", new UserPassHandler("third", "helloworld"));
|
||||
context.login();
|
||||
context.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginReload() throws Exception {
|
||||
File targetPropDir = new File("target/loginReloadTest");
|
||||
|
|
|
@ -125,3 +125,62 @@ OpenLdapConfiguration {
|
|||
roleSearchSubtree=true
|
||||
;
|
||||
};
|
||||
|
||||
LDAPLoginMaskedPassword {
|
||||
org.apache.activemq.artemis.spi.core.security.jaas.LDAPLoginModule required
|
||||
debug=true
|
||||
initialContextFactory=com.sun.jndi.ldap.LdapCtxFactory
|
||||
connectionURL="ldap://localhost:1024"
|
||||
connectionUsername="uid=admin,ou=system"
|
||||
connectionPassword="ENC(-41e444c3ed07d6dd)"
|
||||
connectionProtocol=s
|
||||
authentication=simple
|
||||
userBase="ou=system"
|
||||
userSearchMatching="(uid={0})"
|
||||
userSearchSubtree=false
|
||||
roleBase="ou=system"
|
||||
roleName=cn
|
||||
roleSearchMatching="(member=uid={1},ou=system)"
|
||||
roleSearchSubtree=false
|
||||
;
|
||||
};
|
||||
|
||||
LDAPLoginExternalPasswordCodec {
|
||||
org.apache.activemq.artemis.spi.core.security.jaas.LDAPLoginModule required
|
||||
debug=true
|
||||
passwordCodec="org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;key=helloworld"
|
||||
initialContextFactory=com.sun.jndi.ldap.LdapCtxFactory
|
||||
connectionURL="ldap://localhost:1024"
|
||||
connectionUsername="uid=admin,ou=system"
|
||||
connectionPassword="ENC(-170b9ef34d79ed12)"
|
||||
connectionProtocol=s
|
||||
authentication=simple
|
||||
userBase="ou=system"
|
||||
userSearchMatching="(uid={0})"
|
||||
userSearchSubtree=false
|
||||
roleBase="ou=system"
|
||||
roleName=dummyRoleName
|
||||
roleSearchMatching="(uid={1})"
|
||||
roleSearchSubtree=false
|
||||
;
|
||||
};
|
||||
|
||||
LDAPLoginExternalPasswordCodec2 {
|
||||
org.apache.activemq.artemis.spi.core.security.jaas.LDAPLoginModule required
|
||||
debug=true
|
||||
passwordCodec="org.apache.activemq.artemis.utils.MaskPasswordResolvingTest$SimplePasswordCodec"
|
||||
initialContextFactory=com.sun.jndi.ldap.LdapCtxFactory
|
||||
connectionURL="ldap://localhost:1024"
|
||||
connectionUsername="uid=admin,ou=system"
|
||||
connectionPassword="ENC(momsword)"
|
||||
connectionProtocol=s
|
||||
authentication=simple
|
||||
userBase="ou=system"
|
||||
userSearchMatching="(uid={0})"
|
||||
userSearchSubtree=false
|
||||
roleBase="ou=system"
|
||||
roleName=dummyRoleName
|
||||
roleSearchMatching="(uid={1})"
|
||||
roleSearchSubtree=false
|
||||
;
|
||||
};
|
||||
|
|
|
@ -17,3 +17,4 @@
|
|||
|
||||
first=secret
|
||||
second=password
|
||||
third=ENC(1024:439F45267508BB4F02150B9AAFB8D774AB7FCDDE9B19D096F318787487F7BD17:78818E53AEE8AF26A34A38C1498D1CDA5636861D2FE9804FEDE3656D0BC05696191A027F095DF109EC8F6385FAE9971915449EC808945A0F5907B29D5F9D44B7)
|
|
@ -68,6 +68,13 @@
|
|||
<artifactId>artemis-commons</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.activemq</groupId>
|
||||
<artifactId>artemis-commons</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<classifier>tests</classifier>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-buffer</artifactId>
|
||||
|
|
|
@ -68,12 +68,12 @@ public class WebServerComponent implements ExternalComponent {
|
|||
if ("https".equals(scheme)) {
|
||||
SslContextFactory sslFactory = new SslContextFactory();
|
||||
sslFactory.setKeyStorePath(webServerConfig.keyStorePath == null ? artemisInstance + "/etc/keystore.jks" : webServerConfig.keyStorePath);
|
||||
sslFactory.setKeyStorePassword(webServerConfig.keyStorePassword == null ? "password" : webServerConfig.keyStorePassword);
|
||||
sslFactory.setKeyStorePassword(webServerConfig.getKeyStorePassword() == null ? "password" : webServerConfig.getKeyStorePassword());
|
||||
if (webServerConfig.clientAuth != null) {
|
||||
sslFactory.setNeedClientAuth(webServerConfig.clientAuth);
|
||||
if (webServerConfig.clientAuth) {
|
||||
sslFactory.setTrustStorePath(webServerConfig.trustStorePath);
|
||||
sslFactory.setTrustStorePassword(webServerConfig.trustStorePassword);
|
||||
sslFactory.setTrustStorePassword(webServerConfig.getTrustStorePassword());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ package org.apache.activemq.cli.test;
|
|||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
|
@ -43,9 +44,11 @@ import io.netty.handler.codec.http.HttpRequest;
|
|||
import io.netty.handler.codec.http.HttpVersion;
|
||||
import io.netty.handler.ssl.SslHandler;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import org.apache.activemq.artemis.cli.factory.xml.XmlBrokerFactoryHandler;
|
||||
import org.apache.activemq.artemis.component.WebServerComponent;
|
||||
import org.apache.activemq.artemis.core.remoting.impl.ssl.SSLSupport;
|
||||
import org.apache.activemq.artemis.core.server.ActiveMQComponent;
|
||||
import org.apache.activemq.artemis.dto.BrokerDTO;
|
||||
import org.apache.activemq.artemis.dto.WebServerDTO;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
|
@ -163,7 +166,7 @@ public class WebServerComponentTest extends Assert {
|
|||
webServerDTO.bind = "https://localhost:0";
|
||||
webServerDTO.path = "webapps";
|
||||
webServerDTO.keyStorePath = "./src/test/resources/server.keystore";
|
||||
webServerDTO.keyStorePassword = "password";
|
||||
webServerDTO.setKeyStorePassword("password");
|
||||
|
||||
WebServerComponent webServerComponent = new WebServerComponent();
|
||||
Assert.assertFalse(webServerComponent.isStarted());
|
||||
|
@ -174,7 +177,7 @@ public class WebServerComponentTest extends Assert {
|
|||
// Make the connection attempt.
|
||||
String keyStoreProvider = "JKS";
|
||||
|
||||
SSLContext context = SSLSupport.createContext(keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.keyStorePassword, keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.keyStorePassword);
|
||||
SSLContext context = SSLSupport.createContext(keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.getKeyStorePassword(), keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.getKeyStorePassword());
|
||||
|
||||
SSLEngine engine = context.createSSLEngine();
|
||||
engine.setUseClientMode(true);
|
||||
|
@ -215,10 +218,10 @@ public class WebServerComponentTest extends Assert {
|
|||
webServerDTO.bind = "https://localhost:0";
|
||||
webServerDTO.path = "webapps";
|
||||
webServerDTO.keyStorePath = "./src/test/resources/server.keystore";
|
||||
webServerDTO.keyStorePassword = "password";
|
||||
webServerDTO.setKeyStorePassword("password");
|
||||
webServerDTO.clientAuth = true;
|
||||
webServerDTO.trustStorePath = "./src/test/resources/server.keystore";
|
||||
webServerDTO.trustStorePassword = "password";
|
||||
webServerDTO.setTrustStorePassword("password");
|
||||
|
||||
WebServerComponent webServerComponent = new WebServerComponent();
|
||||
Assert.assertFalse(webServerComponent.isStarted());
|
||||
|
@ -229,7 +232,7 @@ public class WebServerComponentTest extends Assert {
|
|||
// Make the connection attempt.
|
||||
String keyStoreProvider = "JKS";
|
||||
|
||||
SSLContext context = SSLSupport.createContext(keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.keyStorePassword, keyStoreProvider, webServerDTO.trustStorePath, webServerDTO.trustStorePassword);
|
||||
SSLContext context = SSLSupport.createContext(keyStoreProvider, webServerDTO.keyStorePath, webServerDTO.getKeyStorePassword(), keyStoreProvider, webServerDTO.trustStorePath, webServerDTO.getTrustStorePassword());
|
||||
|
||||
SSLEngine engine = context.createSSLEngine();
|
||||
engine.setUseClientMode(true);
|
||||
|
@ -264,6 +267,44 @@ public class WebServerComponentTest extends Assert {
|
|||
Assert.assertFalse(webServerComponent.isStarted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDefaultMaskPasswords() throws Exception {
|
||||
File bootstrap = new File("./target/test-classes/bootstrap_web.xml");
|
||||
File brokerHome = new File("./target");
|
||||
XmlBrokerFactoryHandler xmlHandler = new XmlBrokerFactoryHandler();
|
||||
BrokerDTO broker = xmlHandler.createBroker(bootstrap.toURI(), brokerHome.getAbsolutePath(), brokerHome.getAbsolutePath(), brokerHome.toURI());
|
||||
assertNotNull(broker.web);
|
||||
assertNull(broker.web.passwordCodec);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaskPasswords() throws Exception {
|
||||
final String keyPassword = "keypass";
|
||||
final String trustPassword = "trustpass";
|
||||
File bootstrap = new File("./target/test-classes/bootstrap_secure_web.xml");
|
||||
File brokerHome = new File("./target");
|
||||
XmlBrokerFactoryHandler xmlHandler = new XmlBrokerFactoryHandler();
|
||||
BrokerDTO broker = xmlHandler.createBroker(bootstrap.toURI(), brokerHome.getAbsolutePath(), brokerHome.getAbsolutePath(), brokerHome.toURI());
|
||||
assertNotNull(broker.web);
|
||||
assertEquals(keyPassword, broker.web.getKeyStorePassword());
|
||||
assertEquals(trustPassword, broker.web.getTrustStorePassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaskPasswordCodec() throws Exception {
|
||||
final String keyPassword = "keypass";
|
||||
final String trustPassword = "trustpass";
|
||||
File bootstrap = new File("./target/test-classes/bootstrap_web_codec.xml");
|
||||
File brokerHome = new File("./target");
|
||||
XmlBrokerFactoryHandler xmlHandler = new XmlBrokerFactoryHandler();
|
||||
BrokerDTO broker = xmlHandler.createBroker(bootstrap.toURI(), brokerHome.getAbsolutePath(), brokerHome.getAbsolutePath(), brokerHome.toURI());
|
||||
assertNotNull(broker.web);
|
||||
assertNotNull("password codec not picked up!", broker.web.passwordCodec);
|
||||
|
||||
assertEquals(keyPassword, broker.web.getKeyStorePassword());
|
||||
assertEquals(trustPassword, broker.web.getTrustStorePassword());
|
||||
}
|
||||
|
||||
class ClientHandler extends SimpleChannelInboundHandler<HttpObject> {
|
||||
|
||||
private CountDownLatch latch;
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<broker xmlns="http://activemq.org/schema">
|
||||
|
||||
<jaas-security domain="activemq"/>
|
||||
|
||||
<!-- artemis.URI.instance is parsed from artemis.instance by the CLI startup.
|
||||
This is to avoid situations where you could have spaces or special characters on this URI -->
|
||||
<server configuration="${artemis.URI.instance}/etc/broker.xml"/>
|
||||
|
||||
<!-- The web server is only bound to localhost by default -->
|
||||
<web bind="https://localhost:8443" path="web" keyStorePassword="ENC(-5a2376c61c668aaf)" trustStorePassword="ENC(3d617352d12839eb71208edf41d66b34)">
|
||||
<app url="activemq-branding" war="activemq-branding.war"/>
|
||||
</web>
|
||||
|
||||
|
||||
</broker>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<broker xmlns="http://activemq.org/schema">
|
||||
|
||||
<jaas-security domain="activemq"/>
|
||||
|
||||
<!-- artemis.URI.instance is parsed from artemis.instance by the CLI startup.
|
||||
This is to avoid situations where you could have spaces or special characters on this URI -->
|
||||
<server configuration="${artemis.URI.instance}/etc/broker.xml"/>
|
||||
|
||||
<!-- The web server is only bound to localhost by default -->
|
||||
<web bind="http://localhost:8161" path="web">
|
||||
<app url="activemq-branding" war="activemq-branding.war"/>
|
||||
</web>
|
||||
|
||||
|
||||
</broker>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<broker xmlns="http://activemq.org/schema">
|
||||
|
||||
<jaas-security domain="activemq"/>
|
||||
|
||||
<!-- artemis.URI.instance is parsed from artemis.instance by the CLI startup.
|
||||
This is to avoid situations where you could have spaces or special characters on this URI -->
|
||||
<server configuration="${artemis.URI.instance}/etc/broker.xml"/>
|
||||
|
||||
<!-- The web server is only bound to localhost by default -->
|
||||
<web bind="https://localhost:8443" path="web" passwordCodec="org.apache.activemq.artemis.utils.MaskPasswordResolvingTest$SimplePasswordCodec" keyStorePassword="ENC(youneverknow)" trustStorePassword="ENC(youcanguess)">
|
||||
<app url="console" war="console.war"/>
|
||||
</web>
|
||||
|
||||
|
||||
</broker>
|
||||
|
|
@ -18,12 +18,33 @@ Apache ActiveMQ Artemis provides a default password encoder and decoder. Optiona
|
|||
users can use or implement their own encoder and decoder for masking the
|
||||
passwords.
|
||||
|
||||
In general, a masked password can be identified using one of two ways. The first one
|
||||
is the ENC() syntax, i.e. any string value wrapped in ENC() is to be treated as
|
||||
a masked password. For example
|
||||
|
||||
`ENC(xyz)`
|
||||
|
||||
The above indicates that the password is masked and the masked value is `xyz`.
|
||||
|
||||
The ENC() syntax is the preferred way to indicating a masked password and is
|
||||
universally supported in every password configuration in Artemis.
|
||||
|
||||
The other way is to use a `mask-password` attribute to tell that a password
|
||||
in a configuration file should be treated as 'masked'. For example:
|
||||
|
||||
```
|
||||
<mask-password>true</mask-password>
|
||||
<cluster-password>xyz</cluster-password>
|
||||
```
|
||||
This method is now deprecated and exists only to maintain backward-compatibility.
|
||||
Newer configurations may not support it.
|
||||
|
||||
### Password Masking in Server Configuration File
|
||||
|
||||
#### General Masking Configuration
|
||||
|
||||
The server configuration file (i.e. broker.xml )has a property that defines the
|
||||
default masking behaviors over the entire file scope.
|
||||
Besides supporting the ENC() syntax, the server configuration file (i.e. broker.xml)
|
||||
has a property that defines the default masking behaviors over the entire file scope.
|
||||
|
||||
`mask-password`: this boolean type property indicates if a password
|
||||
should be masked or not. Set it to "true" if you want your passwords
|
||||
|
@ -38,6 +59,8 @@ will be used.
|
|||
|
||||
##### cluster-password
|
||||
|
||||
If it is specified in ENC() syntax it will be treated as masked, or
|
||||
|
||||
If `mask-password` is `true` the `cluster-password` will be treated as masked.
|
||||
|
||||
##### Passwords in connectors and acceptors
|
||||
|
@ -55,16 +78,16 @@ and `activemq.passwordcodec` respectively. The Netty and InVM implementations
|
|||
will use these as needed and any other implementations will have access to
|
||||
these to use if they so wish.
|
||||
|
||||
The preferred way, however, is to use the ENC() syntax.
|
||||
|
||||
##### Passwords in bridge configurations
|
||||
|
||||
Core Bridges are configured in the server configuration file and so the
|
||||
masking of its `password` properties follows the same rules as that of
|
||||
`cluster-password`.
|
||||
`cluster-password`. It supports ENC() syntax.
|
||||
|
||||
#### Examples
|
||||
|
||||
The following table summarizes the relations among the above-mentioned
|
||||
properties
|
||||
For using 'mask-password' property, the following table summarizes the
|
||||
relations among the above-mentioned properties
|
||||
|
||||
mask-password | cluster-password | acceptor/connector passwords | bridge password
|
||||
:------------- | :---------------- | :--------------------------- | :---------------
|
||||
|
@ -72,7 +95,9 @@ properties
|
|||
false | plain text | plain text | plain text
|
||||
true | masked | masked | masked
|
||||
|
||||
Examples
|
||||
It is recommended that you use the ENC() syntax for new applications/deployments.
|
||||
|
||||
#### Examples
|
||||
|
||||
Note: In the following examples if related attributed or properties are
|
||||
absent, it means they are not specified in the configure file.
|
||||
|
@ -87,6 +112,14 @@ This indicates the cluster password is a plain text value ("bbc").
|
|||
|
||||
example 2
|
||||
|
||||
```xml
|
||||
<cluster-password>ENC(xyz)</cluster-password>
|
||||
```
|
||||
|
||||
This indicates the cluster password is a masked value ("xyz").
|
||||
|
||||
example 3
|
||||
|
||||
```xml
|
||||
<mask-password>true</mask-password>
|
||||
<cluster-password>80cf731af62c290</cluster-password>
|
||||
|
@ -97,27 +130,75 @@ use its built-in decoder to decode it. All other passwords in the
|
|||
configuration file, Connectors, Acceptors and Bridges, will also use
|
||||
masked passwords.
|
||||
|
||||
#### Passwords in bootstrap.xml
|
||||
|
||||
The broker embeds a web-server for hosting some web applications such as a
|
||||
management console. It is configured in bootstrap.xml as a web
|
||||
component. The web server can be secured using https protocol, and it can be
|
||||
configured with a keystore password and/or truststore password which by
|
||||
default are specified in plain text forms.
|
||||
|
||||
To mask these passwords you need to use ENC() syntax. The `mask-password` is
|
||||
not supported here.
|
||||
|
||||
You can also set the `passwordCodec` attribute if you want to use a password codec
|
||||
other than the default one. For example
|
||||
|
||||
```xml
|
||||
<web bind="https://localhost:8443" path="web"
|
||||
keyStorePassword="ENC(-5a2376c61c668aaf)"
|
||||
trustStorePassword="ENC(3d617352d12839eb71208edf41d66b34)">
|
||||
<app url="activemq-branding" war="activemq-branding.war"/>
|
||||
</web>
|
||||
```
|
||||
|
||||
### Masking passwords in ActiveMQ Artemis JCA ResourceAdapter and MDB activation configurations
|
||||
|
||||
Both ra.xml and MDB activation configuration have a `password` property
|
||||
that can be masked. They are controlled by the following two optional
|
||||
Resource Adapter properties in ra.xml:
|
||||
that can be masked preferably using ENC() syntax.
|
||||
|
||||
Alternatively it can use a optional attribute in ra.xml to indicate that a password
|
||||
is masked:
|
||||
|
||||
`UseMaskedPassword` -- If setting to "true" the passwords are masked.
|
||||
Default is false.
|
||||
|
||||
There is another property in ra.xml that can specify a codec:
|
||||
|
||||
`PasswordCodec` -- Class name and its parameters for the Decoder used to
|
||||
decode the masked password. Ignored if UseMaskedPassword is false. The
|
||||
format of this property is a full qualified class name optionally
|
||||
followed by key/value pairs. It is the same format as that for JMS
|
||||
Bridges. Example:
|
||||
|
||||
Example 1 Using the ENC() syntax:
|
||||
|
||||
```xml
|
||||
<config-property>
|
||||
<config-property-name>password</config-property-name>
|
||||
<config-property-type>String</config-property-type>
|
||||
<config-property-value>ENC(xyz)</config-property-value>
|
||||
</config-property>
|
||||
<config-property>
|
||||
<config-property-name>PasswordCodec</config-property-name>
|
||||
<config-property-type>java.lang.String</config-property-type>
|
||||
<config-property-value>com.foo.ADecoder;key=helloworld</config-property-value>
|
||||
</config-property>
|
||||
```
|
||||
|
||||
Example 2 Using the "UseMaskedPassword" property:
|
||||
|
||||
```xml
|
||||
<config-property>
|
||||
<config-property-name>UseMaskedPassword</config-property-name>
|
||||
<config-property-type>boolean</config-property-type>
|
||||
<config-property-value>true</config-property-value>
|
||||
</config-property>
|
||||
<config-property>
|
||||
<config-property-name>password</config-property-name>
|
||||
<config-property-type>String</config-property-type>
|
||||
<config-property-value>xyz</config-property-value>
|
||||
</config-property>
|
||||
<config-property>
|
||||
<config-property-name>PasswordCodec</config-property-name>
|
||||
<config-property-type>java.lang.String</config-property-type>
|
||||
|
@ -150,6 +231,43 @@ Passwords in `artemis-users.properties` are automatically detected as hashed or
|
|||
by looking for the syntax `ENC(<hash>)`. The `mask-password` parameter does not need
|
||||
to be `true` to use hashed passwords here.
|
||||
|
||||
### Masking password in JAAS login config file (login.config)
|
||||
|
||||
Artemis supports LDAP login modules to be configured in JAAS configuration
|
||||
file (default name is `login.config`). When connecting to a LDAP server usually
|
||||
you need to supply a connection password in the config file. By default this
|
||||
password is in plain text form.
|
||||
|
||||
To mask it you need to configure the passwords in your login module
|
||||
using ENC() syntax. To specify a codec using the following property:
|
||||
|
||||
`passwordCodec` - the password codec class name. (the default codec
|
||||
will be used if it is absent)
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
LDAPLoginExternalPasswordCodec {
|
||||
org.apache.activemq.artemis.spi.core.security.jaas.LDAPLoginModule required
|
||||
debug=true
|
||||
initialContextFactory=com.sun.jndi.ldap.LdapCtxFactory
|
||||
connectionURL="ldap://localhost:1024"
|
||||
connectionUsername="uid=admin,ou=system"
|
||||
connectionPassword="ENC(-170b9ef34d79ed12)"
|
||||
passwordCodec="org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;key=helloworld"
|
||||
connectionProtocol=s
|
||||
authentication=simple
|
||||
userBase="ou=system"
|
||||
userSearchMatching="(uid={0})"
|
||||
userSearchSubtree=false
|
||||
roleBase="ou=system"
|
||||
roleName=dummyRoleName
|
||||
roleSearchMatching="(uid={1})"
|
||||
roleSearchSubtree=false
|
||||
;
|
||||
};
|
||||
```
|
||||
|
||||
### Choosing a decoder for password masking
|
||||
|
||||
As described in the previous sections, all password masking requires a
|
||||
|
@ -206,8 +324,7 @@ pairs when configuring. For instance if your decoder needs say a
|
|||
Then configure your cluster-password like this:
|
||||
|
||||
```xml
|
||||
<mask-password>true</mask-password>
|
||||
<cluster-password>masked_password</cluster-password>
|
||||
<cluster-password>ENC(masked_password)</cluster-password>
|
||||
```
|
||||
|
||||
When Apache ActiveMQ Artemis reads the cluster-password it will initialize the
|
||||
|
|
|
@ -41,6 +41,7 @@ import org.apache.activemq.artemis.service.extensions.xa.recovery.XARecoveryConf
|
|||
import org.apache.activemq.artemis.tests.unit.ra.BootstrapContext;
|
||||
import org.apache.activemq.artemis.tests.unit.ra.MessageEndpointFactory;
|
||||
import org.apache.activemq.artemis.utils.DefaultSensitiveStringCodec;
|
||||
import org.apache.activemq.artemis.utils.PasswordMaskingUtil;
|
||||
import org.junit.Test;
|
||||
|
||||
public class ResourceAdapterTest extends ActiveMQRATestBase {
|
||||
|
@ -565,7 +566,7 @@ public class ResourceAdapterTest extends ActiveMQRATestBase {
|
|||
ActiveMQRATestBase.MyBootstrapContext ctx = new ActiveMQRATestBase.MyBootstrapContext();
|
||||
|
||||
DefaultSensitiveStringCodec codec = new DefaultSensitiveStringCodec();
|
||||
String mask = (String) codec.encode("helloworld");
|
||||
String mask = codec.encode("helloworld");
|
||||
|
||||
qResourceAdapter.setUseMaskedPassword(true);
|
||||
qResourceAdapter.setPassword(mask);
|
||||
|
@ -594,6 +595,41 @@ public class ResourceAdapterTest extends ActiveMQRATestBase {
|
|||
assertTrue(endpoint.released);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaskPasswordENC() throws Exception {
|
||||
ActiveMQResourceAdapter qResourceAdapter = new ActiveMQResourceAdapter();
|
||||
qResourceAdapter.setConnectorClassName(INVM_CONNECTOR_FACTORY);
|
||||
ActiveMQRATestBase.MyBootstrapContext ctx = new ActiveMQRATestBase.MyBootstrapContext();
|
||||
|
||||
DefaultSensitiveStringCodec codec = new DefaultSensitiveStringCodec();
|
||||
String mask = codec.encode("helloworld");
|
||||
|
||||
qResourceAdapter.setPassword(PasswordMaskingUtil.wrap(mask));
|
||||
|
||||
qResourceAdapter.start(ctx);
|
||||
|
||||
assertEquals("helloworld", qResourceAdapter.getPassword());
|
||||
|
||||
ActiveMQActivationSpec spec = new ActiveMQActivationSpec();
|
||||
spec.setResourceAdapter(qResourceAdapter);
|
||||
spec.setUseJNDI(false);
|
||||
spec.setDestinationType("javax.jms.Queue");
|
||||
spec.setDestination(MDBQUEUE);
|
||||
|
||||
mask = codec.encode("mdbpassword");
|
||||
spec.setPassword(PasswordMaskingUtil.wrap(mask));
|
||||
qResourceAdapter.setConnectorClassName(INVM_CONNECTOR_FACTORY);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
DummyMessageEndpoint endpoint = new DummyMessageEndpoint(latch);
|
||||
DummyMessageEndpointFactory endpointFactory = new DummyMessageEndpointFactory(endpoint, false);
|
||||
qResourceAdapter.endpointActivation(endpointFactory, spec);
|
||||
|
||||
assertEquals("mdbpassword", spec.getPassword());
|
||||
|
||||
qResourceAdapter.stop();
|
||||
assertTrue(endpoint.released);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaskPassword2() throws Exception {
|
||||
ActiveMQResourceAdapter qResourceAdapter = new ActiveMQResourceAdapter();
|
||||
|
@ -609,7 +645,7 @@ public class ResourceAdapterTest extends ActiveMQRATestBase {
|
|||
prop.put("key", "anotherkey");
|
||||
codec.init(prop);
|
||||
|
||||
String mask = (String) codec.encode("helloworld");
|
||||
String mask = codec.encode("helloworld");
|
||||
|
||||
qResourceAdapter.setPassword(mask);
|
||||
|
||||
|
@ -623,7 +659,7 @@ public class ResourceAdapterTest extends ActiveMQRATestBase {
|
|||
spec.setDestinationType("javax.jms.Queue");
|
||||
spec.setDestination(MDBQUEUE);
|
||||
|
||||
mask = (String) codec.encode("mdbpassword");
|
||||
mask = codec.encode("mdbpassword");
|
||||
spec.setPassword(mask);
|
||||
qResourceAdapter.setConnectorClassName(INVM_CONNECTOR_FACTORY);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
@ -637,6 +673,48 @@ public class ResourceAdapterTest extends ActiveMQRATestBase {
|
|||
assertTrue(endpoint.released);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaskPassword2ENC() throws Exception {
|
||||
ActiveMQResourceAdapter qResourceAdapter = new ActiveMQResourceAdapter();
|
||||
qResourceAdapter.setConnectorClassName(INVM_CONNECTOR_FACTORY);
|
||||
ActiveMQRATestBase.MyBootstrapContext ctx = new ActiveMQRATestBase.MyBootstrapContext();
|
||||
|
||||
qResourceAdapter.setPasswordCodec(DefaultSensitiveStringCodec.class.getName() + ";key=anotherkey");
|
||||
|
||||
DefaultSensitiveStringCodec codec = new DefaultSensitiveStringCodec();
|
||||
Map<String, String> prop = new HashMap<>();
|
||||
|
||||
prop.put("key", "anotherkey");
|
||||
codec.init(prop);
|
||||
|
||||
String mask = codec.encode("helloworld");
|
||||
|
||||
qResourceAdapter.setPassword(PasswordMaskingUtil.wrap(mask));
|
||||
|
||||
qResourceAdapter.start(ctx);
|
||||
|
||||
assertEquals("helloworld", qResourceAdapter.getPassword());
|
||||
|
||||
ActiveMQActivationSpec spec = new ActiveMQActivationSpec();
|
||||
spec.setResourceAdapter(qResourceAdapter);
|
||||
spec.setUseJNDI(false);
|
||||
spec.setDestinationType("javax.jms.Queue");
|
||||
spec.setDestination(MDBQUEUE);
|
||||
|
||||
mask = codec.encode("mdbpassword");
|
||||
spec.setPassword(PasswordMaskingUtil.wrap(mask));
|
||||
qResourceAdapter.setConnectorClassName(INVM_CONNECTOR_FACTORY);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
DummyMessageEndpoint endpoint = new DummyMessageEndpoint(latch);
|
||||
DummyMessageEndpointFactory endpointFactory = new DummyMessageEndpointFactory(endpoint, false);
|
||||
qResourceAdapter.endpointActivation(endpointFactory, spec);
|
||||
|
||||
assertEquals("mdbpassword", spec.getPassword());
|
||||
|
||||
qResourceAdapter.stop();
|
||||
assertTrue(endpoint.released);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConnectionParameterStringParsing() throws Exception {
|
||||
ActiveMQResourceAdapter resourceAdapter = new ActiveMQResourceAdapter();
|
||||
|
|
|
@ -287,6 +287,45 @@ public class JMSBridgeImplTest extends ActiveMQTestBase {
|
|||
* we receive only 1 message. The message is sent when the maxBatchTime
|
||||
* expires even if the maxBatchSize is not reached
|
||||
*/
|
||||
@Test
|
||||
public void testBridgeWithMaskPasswords() throws Exception {
|
||||
|
||||
ConnectionFactoryFactory sourceCFF = JMSBridgeImplTest.newConnectionFactoryFactory(JMSBridgeImplTest.createConnectionFactory());
|
||||
ConnectionFactoryFactory targetCFF = JMSBridgeImplTest.newConnectionFactoryFactory(JMSBridgeImplTest.createConnectionFactory());
|
||||
DestinationFactory sourceDF = JMSBridgeImplTest.newDestinationFactory(ActiveMQJMSClient.createQueue(JMSBridgeImplTest.SOURCE));
|
||||
DestinationFactory targetDF = JMSBridgeImplTest.newDestinationFactory(ActiveMQJMSClient.createQueue(JMSBridgeImplTest.TARGET));
|
||||
TransactionManager tm = JMSBridgeImplTest.newTransactionManager();
|
||||
|
||||
JMSBridgeImpl bridge = new JMSBridgeImpl();
|
||||
Assert.assertNotNull(bridge);
|
||||
|
||||
bridge.setSourceConnectionFactoryFactory(sourceCFF);
|
||||
bridge.setSourceDestinationFactory(sourceDF);
|
||||
bridge.setTargetConnectionFactoryFactory(targetCFF);
|
||||
bridge.setTargetDestinationFactory(targetDF);
|
||||
bridge.setFailureRetryInterval(10);
|
||||
bridge.setMaxRetries(1);
|
||||
bridge.setMaxBatchSize(1);
|
||||
bridge.setMaxBatchTime(-1);
|
||||
bridge.setTransactionManager(tm);
|
||||
bridge.setQualityOfServiceMode(QualityOfServiceMode.AT_MOST_ONCE);
|
||||
|
||||
bridge.setSourceUsername("sourceuser");
|
||||
bridge.setSourcePassword("ENC(5493dd76567ee5ec269d11823973462f)");
|
||||
bridge.setTargetUsername("targetuser");
|
||||
bridge.setTargetPassword("ENC(56a0db3b71043054269d11823973462f)");
|
||||
|
||||
Assert.assertFalse(bridge.isStarted());
|
||||
bridge.start();
|
||||
Assert.assertTrue(bridge.isStarted());
|
||||
|
||||
assertEquals("sourcepassword", bridge.getSourcePassword());
|
||||
assertEquals("targetpassword", bridge.getTargetPassword());
|
||||
|
||||
bridge.stop();
|
||||
Assert.assertFalse(bridge.isStarted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessagesWhenMaxBatchTimeExpires() throws Exception {
|
||||
int maxBatchSize = 2;
|
||||
|
|
Loading…
Reference in New Issue