Usability improvement for the password bootstrap tool (reserved users) (elastic/x-pack-elasticsearch#2444)

Generate or prompt for passwords of the reserved users, validating strength
and typos, then issue change requests, keeping elastic user change request last.

Closes: elastic/x-pack-elasticsearch#2424

Original commit: elastic/x-pack-elasticsearch@1f827d393c
This commit is contained in:
Albert Zaharovits 2017-09-21 11:26:28 +03:00 committed by GitHub
parent 70687fbef3
commit c84c48fa01
3 changed files with 288 additions and 145 deletions

View File

@ -6,7 +6,8 @@
package org.elasticsearch.xpack.security.authc.esnative.tool; package org.elasticsearch.xpack.security.authc.esnative.tool;
import org.bouncycastle.util.io.Streams; import org.bouncycastle.util.io.Streams;
import org.elasticsearch.common.Nullable; import org.elasticsearch.common.CheckedConsumer;
import org.elasticsearch.common.CheckedSupplier;
import org.elasticsearch.common.Strings; import org.elasticsearch.common.Strings;
import org.elasticsearch.common.SuppressForbidden; import org.elasticsearch.common.SuppressForbidden;
import org.elasticsearch.common.lease.Releasables; import org.elasticsearch.common.lease.Releasables;
@ -14,10 +15,8 @@ import org.elasticsearch.common.network.InetAddresses;
import org.elasticsearch.common.network.NetworkService; import org.elasticsearch.common.network.NetworkService;
import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.PortsRange;
import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.env.Environment; import org.elasticsearch.env.Environment;
import org.elasticsearch.http.HttpTransportSettings;
import org.elasticsearch.xpack.XPackSettings; import org.elasticsearch.xpack.XPackSettings;
import org.elasticsearch.xpack.common.socket.SocketAccess; import org.elasticsearch.xpack.common.socket.SocketAccess;
import org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken;
@ -30,7 +29,6 @@ import java.io.OutputStream;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.URI;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.AccessController; import java.security.AccessController;
@ -57,16 +55,30 @@ public class CommandLineHttpClient {
this.env = env; this.env = env;
} }
// We do not install the security manager when calling from the commandline. /**
// However, doPrivileged blocks will be necessary for any test code that calls this. * General purpose HTTP(S) call with JSON Content-Type and Authorization Header.
* SSL settings are read from the settings file, if any.
*
* @param user
* user in the authorization header.
* @param password
* password in the authorization header.
* @param requestBodySupplier
* supplier for the JSON string body of the request.
* @param responseConsumer
* consumer of the response Input Stream.
* @return HTTP protocol response code.
*
* @SuppressForbidden We do not install the security manager when calling from
* the commandline. However, doPrivileged blocks will be
* necessary for any test code that calls this.
*/
@SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission") @SuppressForbidden(reason = "We call connect in doPrivileged and provide SocketPermission")
public String postURL(String method, String urlString, String user, SecureString password, @Nullable String bodyString) public int postURL(String method, URL url, String user, SecureString password, CheckedSupplier<String, Exception> requestBodySupplier,
throws Exception { CheckedConsumer<InputStream, Exception> responseConsumer) throws Exception {
URI uri = new URI(urlString);
URL url = uri.toURL();
HttpURLConnection conn; HttpURLConnection conn;
// If using SSL, need a custom service because it's likely a self-signed certificate // If using SSL, need a custom service because it's likely a self-signed certificate
if ("https".equalsIgnoreCase(uri.getScheme())) { if ("https".equalsIgnoreCase(url.getProtocol())) {
Settings sslSettings = settings.getByPrefix(setting("http.ssl.")); Settings sslSettings = settings.getByPrefix(setting("http.ssl."));
final SSLService sslService = new SSLService(settings, env); final SSLService sslService = new SSLService(settings, env);
final HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection(); final HttpsURLConnection httpsConn = (HttpsURLConnection) url.openConnection();
@ -85,6 +97,7 @@ public class CommandLineHttpClient {
String token = UsernamePasswordToken.basicAuthHeaderValue(user, password); String token = UsernamePasswordToken.basicAuthHeaderValue(user, password);
conn.setRequestProperty("Authorization", token); conn.setRequestProperty("Authorization", token);
conn.setRequestProperty("Content-Type", XContentType.JSON.mediaType()); conn.setRequestProperty("Content-Type", XContentType.JSON.mediaType());
String bodyString = requestBodySupplier.get();
conn.setDoOutput(bodyString != null); // set true if we are sending a body conn.setDoOutput(bodyString != null); // set true if we are sending a body
SocketAccess.doPrivileged(conn::connect); SocketAccess.doPrivileged(conn::connect);
if (bodyString != null) { if (bodyString != null) {
@ -95,17 +108,19 @@ public class CommandLineHttpClient {
throw e; throw e;
} }
} }
// this throws IOException if there is a network problem
final int ans = conn.getResponseCode();
try (InputStream inputStream = conn.getInputStream()) { try (InputStream inputStream = conn.getInputStream()) {
byte[] bytes = Streams.readAll(inputStream); responseConsumer.accept(inputStream);
return new String(bytes, StandardCharsets.UTF_8);
} catch (IOException e) { } catch (IOException e) {
// this IOException is if the HTTP response code is 'BAD' (>= 400)
try (InputStream errorStream = conn.getErrorStream()) { try (InputStream errorStream = conn.getErrorStream()) {
byte[] bytes = Streams.readAll(errorStream); responseConsumer.accept(errorStream);
throw new IOException(new String(bytes, StandardCharsets.UTF_8), e);
} }
} finally { } finally {
conn.disconnect(); Releasables.closeWhileHandlingException(conn::disconnect);
} }
return ans;
} }
String getDefaultURL() { String getDefaultURL() {

View File

@ -8,12 +8,16 @@ package org.elasticsearch.xpack.security.authc.esnative.tool;
import joptsimple.OptionParser; import joptsimple.OptionParser;
import joptsimple.OptionSet; import joptsimple.OptionSet;
import joptsimple.OptionSpec; import joptsimple.OptionSpec;
import org.bouncycastle.util.io.Streams;
import org.elasticsearch.cli.EnvironmentAwareCommand; import org.elasticsearch.cli.EnvironmentAwareCommand;
import org.elasticsearch.cli.ExitCodes; import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.MultiCommand; import org.elasticsearch.cli.MultiCommand;
import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.Terminal.Verbosity;
import org.elasticsearch.cli.UserException; import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.Booleans; import org.elasticsearch.common.Booleans;
import org.elasticsearch.common.CheckedBiConsumer;
import org.elasticsearch.common.CheckedFunction; import org.elasticsearch.common.CheckedFunction;
import org.elasticsearch.common.settings.KeyStoreWrapper; import org.elasticsearch.common.settings.KeyStoreWrapper;
import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.SecureString;
@ -22,44 +26,52 @@ import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.json.JsonXContent; import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.env.Environment; import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.support.Validation;
import org.elasticsearch.xpack.security.user.ElasticUser; import org.elasticsearch.xpack.security.user.ElasticUser;
import org.elasticsearch.xpack.security.user.KibanaUser; import org.elasticsearch.xpack.security.user.KibanaUser;
import org.elasticsearch.xpack.security.user.LogstashSystemUser; import org.elasticsearch.xpack.security.user.LogstashSystemUser;
import java.io.IOException; import java.io.IOException;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Arrays; import java.util.Arrays;
import java.util.function.BiConsumer; import java.util.HashMap;
import java.util.function.Function; import java.util.List;
import java.util.Map;
/** /**
* A tool to set passwords of internal users. It first sets the elastic user password. After the elastic user * A tool to set passwords of reserved users (elastic, kibana and
* password is set, it will set the remaining user passwords. This tool will only work if the passwords have * logstash_system). Can run in `interactive` or `auto` mode. In `auto` mode
* not already been set by something else. * generates random passwords and prints them on the console. In `interactive`
* mode prompts for each individual user's password. This tool only runs once,
* if successful. After the elastic user password is set you have to use the
* `security` API to manipulate passwords.
*/ */
public class SetupPasswordTool extends MultiCommand { public class SetupPasswordTool extends MultiCommand {
private static final char[] CHARS = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private static final char[] CHARS = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray();
"~!@#$%^&*-_=+?").toCharArray(); public static final List<String> USERS = Arrays.asList(ElasticUser.NAME, KibanaUser.NAME, LogstashSystemUser.NAME);
private static final String[] USERS = new String[]{ElasticUser.NAME, KibanaUser.NAME, LogstashSystemUser.NAME};
private final Function<Environment, CommandLineHttpClient> clientFunction; private final CheckedFunction<Environment, CommandLineHttpClient, Exception> clientFunction;
private final CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction; private final CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction;
private CommandLineHttpClient client; private CommandLineHttpClient client;
SetupPasswordTool() { SetupPasswordTool() {
this((environment) -> new CommandLineHttpClient(environment.settings(), environment), this((environment) -> new CommandLineHttpClient(environment.settings(), environment), (environment) -> {
(environment) -> { KeyStoreWrapper keyStoreWrapper = KeyStoreWrapper.load(environment.configFile());
KeyStoreWrapper keyStoreWrapper = KeyStoreWrapper.load(environment.configFile()); if (keyStoreWrapper == null) {
if (keyStoreWrapper == null) { throw new UserException(ExitCodes.CONFIG,
throw new UserException(ExitCodes.CONFIG, "Keystore does not exist"); "Elasticsearch keystore file is missing [" + KeyStoreWrapper.keystorePath(environment.configFile()) + "]");
} }
return keyStoreWrapper; return keyStoreWrapper;
}); });
} }
SetupPasswordTool(Function<Environment, CommandLineHttpClient> clientFunction, SetupPasswordTool(CheckedFunction<Environment, CommandLineHttpClient, Exception> clientFunction,
CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction) { CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction) {
super("Sets the passwords for reserved users"); super("Sets the passwords for reserved users");
subcommands.put("auto", newAutoSetup()); subcommands.put("auto", newAutoSetup());
subcommands.put("interactive", newInteractiveSetup()); subcommands.put("interactive", newInteractiveSetup());
@ -85,8 +97,8 @@ public class SetupPasswordTool extends MultiCommand {
} }
/** /**
* This class sets the passwords using automatically generated random passwords. The passwords will be * This class sets the passwords using automatically generated random passwords.
* printed to the console. * The passwords will be printed to the console.
*/ */
class AutoSetup extends SetupCommand { class AutoSetup extends SetupCommand {
@ -97,9 +109,10 @@ public class SetupPasswordTool extends MultiCommand {
@Override @Override
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
setupOptions(options, env); setupOptions(options, env);
checkElasticKeystorePasswordValid(terminal);
if (shouldPrompt) { if (shouldPrompt) {
terminal.println("Initiating the setup of reserved user " + Arrays.toString(USERS) + " passwords."); terminal.println("Initiating the setup of reserved user " + String.join(",", USERS) + " passwords.");
terminal.println("The passwords will be randomly generated and printed to the console."); terminal.println("The passwords will be randomly generated and printed to the console.");
boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false); boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false);
terminal.println("\n"); terminal.println("\n");
@ -109,7 +122,7 @@ public class SetupPasswordTool extends MultiCommand {
} }
SecureRandom secureRandom = new SecureRandom(); SecureRandom secureRandom = new SecureRandom();
changePasswords(terminal, (user) -> generatePassword(secureRandom, user), changePasswords((user) -> generatePassword(secureRandom, user),
(user, password) -> changedPasswordCallback(terminal, user, password)); (user, password) -> changedPasswordCallback(terminal, user, password));
} }
@ -129,7 +142,7 @@ public class SetupPasswordTool extends MultiCommand {
} }
/** /**
* This class sets the passwords using password entered manually by the user from the console. * This class sets the passwords using input prompted on the console
*/ */
class InteractiveSetup extends SetupCommand { class InteractiveSetup extends SetupCommand {
@ -140,9 +153,10 @@ public class SetupPasswordTool extends MultiCommand {
@Override @Override
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception {
setupOptions(options, env); setupOptions(options, env);
checkElasticKeystorePasswordValid(terminal);
if (shouldPrompt) { if (shouldPrompt) {
terminal.println("Initiating the setup of reserved user " + Arrays.toString(USERS) + " passwords."); terminal.println("Initiating the setup of reserved user " + String.join(",", USERS) + " passwords.");
terminal.println("You will be prompted to enter passwords as the process progresses."); terminal.println("You will be prompted to enter passwords as the process progresses.");
boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false); boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false);
terminal.println("\n"); terminal.println("\n");
@ -151,28 +165,41 @@ public class SetupPasswordTool extends MultiCommand {
} }
} }
changePasswords(terminal, user -> promptForPassword(terminal, user), changePasswords(user -> promptForPassword(terminal, user),
(user, password) -> changedPasswordCallback(terminal, user, password)); (user, password) -> changedPasswordCallback(terminal, user, password));
} }
private SecureString promptForPassword(Terminal terminal, String user) throws UserException { private SecureString promptForPassword(Terminal terminal, String user) throws UserException {
SecureString password1 = new SecureString(terminal.readSecret("Enter password for [" + user + "]: ")); // loop for two consecutive good passwords
try (SecureString password2 = new SecureString(terminal.readSecret("Reenter password for [" + user + "]: "))) { while (true) {
if (password1.equals(password2) == false) { SecureString password1 = new SecureString(terminal.readSecret("Enter password for [" + user + "]: "));
Validation.Error err = Validation.Users.validatePassword(password1.getChars());
if (err != null) {
terminal.println(err.toString());
terminal.println("Try again.");
password1.close(); password1.close();
throw new UserException(ExitCodes.USAGE, "Passwords for user [" + user + "] do not match"); continue;
} }
try (SecureString password2 = new SecureString(terminal.readSecret("Reenter password for [" + user + "]: "))) {
if (password1.equals(password2) == false) {
terminal.println("Passwords do not match.");
terminal.println("Try again.");
password1.close();
continue;
}
}
return password1;
} }
return password1;
} }
private void changedPasswordCallback(Terminal terminal, String user, SecureString password) { private void changedPasswordCallback(Terminal terminal, String user, SecureString password) {
terminal.println("Changed password for user " + user + "\n"); terminal.println("Changed password for user [" + user + "]");
} }
} }
/** /**
* An abstract class that provides functionality common to both the auto and interactive setup modes. * An abstract class that provides functionality common to both the auto and
* interactive setup modes.
*/ */
private abstract class SetupCommand extends EnvironmentAwareCommand { private abstract class SetupCommand extends EnvironmentAwareCommand {
@ -197,7 +224,7 @@ public class SetupPasswordTool extends MultiCommand {
url = providedUrl == null ? client.getDefaultURL() : providedUrl; url = providedUrl == null ? client.getDefaultURL() : providedUrl;
setShouldPrompt(options); setShouldPrompt(options);
// TODO: We currently do not support keystore passwords // TODO: We currently do not support keystore passwords
keyStore.decrypt(new char[0]); keyStore.decrypt(new char[0]);
Settings build = Settings.builder().setSecureSettings(keyStore).build(); Settings build = Settings.builder().setSecureSettings(keyStore).build();
elasticUserPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(build); elasticUserPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(build);
@ -205,9 +232,9 @@ public class SetupPasswordTool extends MultiCommand {
} }
private void setParser() { private void setParser() {
urlOption = parser.acceptsAll(Arrays.asList("u", "url"), "The url for the change password request").withOptionalArg(); urlOption = parser.acceptsAll(Arrays.asList("u", "url"), "The url for the change password request.").withOptionalArg();
noPromptOption = parser.acceptsAll(Arrays.asList("b", "batch"), "Whether the user should be prompted to initiate the " noPromptOption = parser.acceptsAll(Arrays.asList("b", "batch"),
+ "change password process").withOptionalArg(); "If enabled, run the change password process without prompting the user.").withOptionalArg();
} }
private void setShouldPrompt(OptionSet options) { private void setShouldPrompt(OptionSet options) {
@ -219,42 +246,98 @@ public class SetupPasswordTool extends MultiCommand {
} }
} }
void changePasswords(Terminal terminal, CheckedFunction<String, SecureString, UserException> passwordFn, /**
BiConsumer<String, SecureString> callback) throws Exception { * Validates the bootstrap password from the local keystore by making an
for (String user : USERS) { * '_authenticate' call. Returns silently if server is reachable and password is
changePassword(terminal, url, user, passwordFn, callback); * valid. Throws {@link UserException} otherwise.
} *
} * @param terminal
* where to write verbose info.
private void changePassword(Terminal terminal, String url, String user, */
CheckedFunction<String, SecureString, UserException> passwordFn, void checkElasticKeystorePasswordValid(Terminal terminal) throws Exception {
BiConsumer<String, SecureString> callback) throws Exception { URL route = new URL(url + "/_xpack/security/_authenticate?pretty");
boolean isSuperUser = user.equals(elasticUser);
SecureString password = passwordFn.apply(user);
try { try {
String route = url + "/_xpack/security/user/" + user + "/_password"; terminal.println(Verbosity.VERBOSE, "Testing if bootstrap password is valid for " + route.toString());
client.postURL("PUT", route, elasticUser, elasticUserPassword, buildPayload(password)); int httpCode = client.postURL("GET", route, elasticUser, elasticUserPassword, () -> null, is -> {
callback.accept(user, password); byte[] bytes = Streams.readAll(is);
if (isSuperUser) { terminal.println(Verbosity.VERBOSE, new String(bytes, StandardCharsets.UTF_8));
elasticUserPassword = password; });
} // keystore password is not valid
} catch (Exception e) { if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
terminal.println("Exception making http rest request for user [" + user + "]"); throw new UserException(ExitCodes.CONFIG, "Failed to verify bootstrap password.");
throw e;
} finally {
// We do not close the password if it is the super user as we are going to use the super user
// password in the followup requests to change other user passwords
if (isSuperUser == false) {
password.close();
} }
} catch (ConnectException e) {
throw new UserException(ExitCodes.CONFIG,
"Failed to connect to elasticsearch at " + route.toString() + ". Is the URL correct and elasticsearch running?", e);
} }
} }
private String buildPayload(SecureString password) throws IOException { /**
XContentBuilder xContentBuilder = JsonXContent.contentBuilder(); * Sets one user's password using the elastic superUser credentials.
xContentBuilder.startObject().field("password", password.toString()).endObject(); *
return xContentBuilder.string(); * @param user
* The user who's password will change.
* @param password
* the new password of the user.
*/
private void changeUserPassword(String user, SecureString password) throws Exception {
URL route = new URL(url + "/_xpack/security/user/" + user + "/_password");
try {
// supplier should own his resources
SecureString supplierPassword = password.clone();
client.postURL("PUT", route, elasticUser, elasticUserPassword, () -> {
try {
XContentBuilder xContentBuilder = JsonXContent.contentBuilder();
xContentBuilder.startObject().field("password", supplierPassword.toString()).endObject();
return xContentBuilder.string();
} finally {
supplierPassword.close();
}
}, is -> {
});
} catch (IOException e) {
throw new UserException(ExitCodes.TEMP_FAILURE, "Failed to set password for user [" + user + "].", e);
}
}
/**
* Collects passwords for all the users, then issues set requests. Fails on the
* first failed request. In this case rerun the tool to redo all the operations.
*
* @param passwordFn
* Function to generate or prompt for each user's password.
* @param successCallback
* Callback for each successful operation
*/
void changePasswords(CheckedFunction<String, SecureString, UserException> passwordFn,
CheckedBiConsumer<String, SecureString, Exception> successCallback) throws Exception {
Map<String, SecureString> passwordsMap = new HashMap<>(USERS.size());
try {
for (String user : USERS) {
passwordsMap.put(user, passwordFn.apply(user));
}
/*
* Change elastic user last. This tool will not run after the elastic user
* password is changed even if changing password for any subsequent user fails.
* Stay safe and change elastic last.
*/
Map.Entry<String, SecureString> superUserEntry = null;
for (Map.Entry<String, SecureString> entry : passwordsMap.entrySet()) {
if (entry.getKey().equals(elasticUser)) {
superUserEntry = entry;
continue;
}
changeUserPassword(entry.getKey(), entry.getValue());
successCallback.accept(entry.getKey(), entry.getValue());
}
// change elastic superuser
if (superUserEntry != null) {
changeUserPassword(superUserEntry.getKey(), superUserEntry.getValue());
successCallback.accept(superUserEntry.getKey(), superUserEntry.getValue());
}
} finally {
passwordsMap.forEach((user, pass) -> pass.close());
}
} }
} }
} }

View File

@ -10,6 +10,8 @@ import org.elasticsearch.cli.CommandTestCase;
import org.elasticsearch.cli.ExitCodes; import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.UserException; import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.CheckedConsumer;
import org.elasticsearch.common.CheckedSupplier;
import org.elasticsearch.common.settings.KeyStoreWrapper; import org.elasticsearch.common.settings.KeyStoreWrapper;
import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.Settings;
@ -18,47 +20,48 @@ import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.json.JsonXContent; import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.env.Environment; import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.support.Validation;
import org.elasticsearch.xpack.security.user.ElasticUser; import org.elasticsearch.xpack.security.user.ElasticUser;
import org.elasticsearch.xpack.security.user.KibanaUser; import org.hamcrest.CoreMatchers;
import org.elasticsearch.xpack.security.user.LogstashSystemUser;
import org.junit.Before; import org.junit.Before;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.Mockito; import org.mockito.Mockito;
import java.io.IOException; import java.io.IOException;
import java.security.GeneralSecurityException; import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.contains;
import static org.mockito.Matchers.eq; import static org.mockito.Matchers.eq;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class SetupPasswordToolTests extends CommandTestCase { public class SetupPasswordToolTests extends CommandTestCase {
private final String pathHomeParameter = "-Epath.home=" + createTempDir(); private final String pathHomeParameter = "-Epath.home=" + createTempDir();
private SecureString bootstrapPassword; private SecureString bootstrapPassword;
private final String ep = "elastic-password";
private final String kp = "kibana-password";
private final String lp = "logstash-password";
private CommandLineHttpClient httpClient; private CommandLineHttpClient httpClient;
private KeyStoreWrapper keyStore; private KeyStoreWrapper keyStore;
private List<String> usersInSetOrder;
@Before @Before
public void setSecretsAndKeyStore() throws GeneralSecurityException { public void setSecretsAndKeyStore() throws Exception {
// sometimes we fall back to the keystore seed as this is the default when a new node starts // sometimes we fall back to the keystore seed as this is the default when a new node starts
boolean useFallback = randomBoolean(); boolean useFallback = randomBoolean();
bootstrapPassword = useFallback ? new SecureString("0xCAFEBABE".toCharArray()) : bootstrapPassword = useFallback ? new SecureString("0xCAFEBABE".toCharArray()) :
new SecureString("bootstrap-password".toCharArray()); new SecureString("bootstrap-password".toCharArray());
this.keyStore = mock(KeyStoreWrapper.class); this.keyStore = mock(KeyStoreWrapper.class);
this.httpClient = mock(CommandLineHttpClient.class); this.httpClient = mock(CommandLineHttpClient.class);
when(keyStore.isLoaded()).thenReturn(true); when(keyStore.isLoaded()).thenReturn(true);
if (useFallback) { if (useFallback) {
when(keyStore.getSettingNames()).thenReturn(new HashSet<>(Arrays.asList(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), when(keyStore.getSettingNames()).thenReturn(new HashSet<>(Arrays.asList(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(),
@ -68,14 +71,24 @@ public class SetupPasswordToolTests extends CommandTestCase {
when(keyStore.getSettingNames()).thenReturn(Collections.singleton(KeyStoreWrapper.SEED_SETTING.getKey())); when(keyStore.getSettingNames()).thenReturn(Collections.singleton(KeyStoreWrapper.SEED_SETTING.getKey()));
when(keyStore.getString(KeyStoreWrapper.SEED_SETTING.getKey())).thenReturn(bootstrapPassword); when(keyStore.getString(KeyStoreWrapper.SEED_SETTING.getKey())).thenReturn(bootstrapPassword);
} }
when(httpClient.getDefaultURL()).thenReturn("http://localhost:9200"); when(httpClient.getDefaultURL()).thenReturn("http://localhost:9200");
terminal.addSecretInput(ep); when(httpClient.postURL(anyString(), any(URL.class), anyString(), any(SecureString.class), any(CheckedSupplier.class),
terminal.addSecretInput(ep); any(CheckedConsumer.class))).thenReturn(HttpURLConnection.HTTP_OK);
terminal.addSecretInput(kp);
terminal.addSecretInput(kp); // elastic user is updated last
terminal.addSecretInput(lp); usersInSetOrder = new ArrayList<>(SetupPasswordTool.USERS);
terminal.addSecretInput(lp); for (int i = 0; i < usersInSetOrder.size() - 1; i++) {
if (ElasticUser.NAME.equals(usersInSetOrder.get(i))) {
Collections.swap(usersInSetOrder, i, i + 1);
}
}
for (String user : SetupPasswordTool.USERS) {
terminal.addSecretInput(user + "-password");
terminal.addSecretInput(user + "-password");
}
} }
@Override @Override
@ -106,78 +119,110 @@ public class SetupPasswordToolTests extends CommandTestCase {
} }
public void testAutoSetup() throws Exception { public void testAutoSetup() throws Exception {
String url = httpClient.getDefaultURL();
execute("auto", pathHomeParameter, "-b", "true"); execute("auto", pathHomeParameter, "-b", "true");
verify(keyStore).decrypt(new char[0]); verify(keyStore).decrypt(new char[0]);
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
InOrder inOrder = Mockito.inOrder(httpClient); InOrder inOrder = Mockito.inOrder(httpClient);
String elasticUrl = "http://localhost:9200/_xpack/security/user/elastic/_password";
inOrder.verify(httpClient).postURL(eq("PUT"), eq(elasticUrl), eq(ElasticUser.NAME), eq(bootstrapPassword),
passwordCaptor.capture());
String[] users = {KibanaUser.NAME, LogstashSystemUser.NAME}; URL checkUrl = new URL(url + "/_xpack/security/_authenticate?pretty");
SecureString newPassword = new SecureString(parsePassword(passwordCaptor.getValue()).toCharArray()); inOrder.verify(httpClient).postURL(eq("GET"), eq(checkUrl), eq(ElasticUser.NAME), eq(bootstrapPassword), any(CheckedSupplier.class),
for (String user : users) { any(CheckedConsumer.class));
String urlWithRoute = "http://localhost:9200/_xpack/security/user/" + user + "/_password"; for (String user : usersInSetOrder) {
inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(newPassword), anyString()); URL urlWithRoute = new URL(url + "/_xpack/security/user/" + user + "/_password");
inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(bootstrapPassword),
any(CheckedSupplier.class), any(CheckedConsumer.class));
} }
}
public void testAuthnFail() throws Exception {
URL authnURL = new URL(httpClient.getDefaultURL() + "/_xpack/security/_authenticate?pretty");
when(httpClient.postURL(eq("GET"), eq(authnURL), eq(ElasticUser.NAME), any(SecureString.class), any(CheckedSupplier.class),
any(CheckedConsumer.class))).thenReturn(HttpURLConnection.HTTP_UNAUTHORIZED);
try {
execute(randomBoolean() ? "auto" : "interactive", pathHomeParameter);
fail("Should have thrown exception");
} catch (UserException e) {
assertEquals(ExitCodes.CONFIG, e.exitCode);
}
} }
public void testUrlOption() throws Exception { public void testUrlOption() throws Exception {
String url = "http://localhost:9202"; String url = "http://localhost:9202";
execute("auto", pathHomeParameter, "-u", url, "-b"); execute("auto", pathHomeParameter, "-u", url, "-b");
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
InOrder inOrder = Mockito.inOrder(httpClient); InOrder inOrder = Mockito.inOrder(httpClient);
String elasticUrl = url + "/_xpack/security/user/elastic/_password";
inOrder.verify(httpClient).postURL(eq("PUT"), eq(elasticUrl), eq(ElasticUser.NAME), eq(bootstrapPassword),
passwordCaptor.capture());
String[] users = {KibanaUser.NAME, LogstashSystemUser.NAME}; URL checkUrl = new URL(url + "/_xpack/security/_authenticate?pretty");
SecureString newPassword = new SecureString(parsePassword(passwordCaptor.getValue()).toCharArray()); inOrder.verify(httpClient).postURL(eq("GET"), eq(checkUrl), eq(ElasticUser.NAME), eq(bootstrapPassword), any(CheckedSupplier.class),
for (String user : users) { any(CheckedConsumer.class));
String urlWithRoute = url + "/_xpack/security/user/" + user + "/_password"; for (String user : usersInSetOrder) {
inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(newPassword), anyString()); URL urlWithRoute = new URL(url + "/_xpack/security/user/" + user + "/_password");
inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(bootstrapPassword),
any(CheckedSupplier.class), any(CheckedConsumer.class));
} }
} }
public void testInteractiveSetup() throws Exception { public void testInteractiveSetup() throws Exception {
String url = httpClient.getDefaultURL();
terminal.addTextInput("Y"); terminal.addTextInput("Y");
execute("interactive", pathHomeParameter);
InOrder inOrder = Mockito.inOrder(httpClient);
URL checkUrl = new URL(url + "/_xpack/security/_authenticate?pretty");
inOrder.verify(httpClient).postURL(eq("GET"), eq(checkUrl), eq(ElasticUser.NAME), eq(bootstrapPassword), any(CheckedSupplier.class),
any(CheckedConsumer.class));
for (String user : usersInSetOrder) {
URL urlWithRoute = new URL(url + "/_xpack/security/user/" + user + "/_password");
ArgumentCaptor<CheckedSupplier<String, Exception>> passwordCaptor = ArgumentCaptor.forClass((Class) CheckedSupplier.class);
inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(bootstrapPassword),
passwordCaptor.capture(), any(CheckedConsumer.class));
assertThat(passwordCaptor.getValue().get(), CoreMatchers.containsString(user + "-password"));
}
}
public void testInteractivePasswordsFatFingers() throws Exception {
String url = httpClient.getDefaultURL();
terminal.reset();
terminal.addTextInput("Y");
for (String user : SetupPasswordTool.USERS) {
// fail in strength and match
int failCount = randomIntBetween(3, 10);
while (failCount-- > 0) {
String password1 = randomAlphaOfLength(randomIntBetween(3, 10));
terminal.addSecretInput(password1);
Validation.Error err = Validation.Users.validatePassword(password1.toCharArray());
if (err == null) {
// passes strength validation, fail by mismatch
terminal.addSecretInput(password1 + "typo");
}
}
// two good passwords
terminal.addSecretInput(user + "-password");
terminal.addSecretInput(user + "-password");
}
execute("interactive", pathHomeParameter); execute("interactive", pathHomeParameter);
InOrder inOrder = Mockito.inOrder(httpClient); InOrder inOrder = Mockito.inOrder(httpClient);
String elasticUrl = "http://localhost:9200/_xpack/security/user/elastic/_password";
SecureString newPassword = new SecureString(ep.toCharArray());
inOrder.verify(httpClient).postURL(eq("PUT"), eq(elasticUrl), eq(ElasticUser.NAME), eq(bootstrapPassword), contains(ep));
String kibanaUrl = "http://localhost:9200/_xpack/security/user/" + KibanaUser.NAME + "/_password"; URL checkUrl = new URL(url + "/_xpack/security/_authenticate?pretty");
inOrder.verify(httpClient).postURL(eq("PUT"), eq(kibanaUrl), eq(ElasticUser.NAME), eq(newPassword), contains(kp)); inOrder.verify(httpClient).postURL(eq("GET"), eq(checkUrl), eq(ElasticUser.NAME), eq(bootstrapPassword), any(CheckedSupplier.class),
String logstashUrl = "http://localhost:9200/_xpack/security/user/" + LogstashSystemUser.NAME + "/_password"; any(CheckedConsumer.class));
inOrder.verify(httpClient).postURL(eq("PUT"), eq(logstashUrl), eq(ElasticUser.NAME), eq(newPassword), contains(lp)); for (String user : usersInSetOrder) {
} URL urlWithRoute = new URL(url + "/_xpack/security/user/" + user + "/_password");
ArgumentCaptor<CheckedSupplier<String, Exception>> passwordCaptor = ArgumentCaptor.forClass((Class) CheckedSupplier.class);
public void testInteractivePasswordsNotMatching() throws Exception { inOrder.verify(httpClient).postURL(eq("PUT"), eq(urlWithRoute), eq(ElasticUser.NAME), eq(bootstrapPassword),
String ep = "elastic-password"; passwordCaptor.capture(), any(CheckedConsumer.class));
assertThat(passwordCaptor.getValue().get(), CoreMatchers.containsString(user + "-password"));
terminal.reset();
terminal.addTextInput("Y");
terminal.addSecretInput(ep);
terminal.addSecretInput(ep + "typo");
String url = "http://localhost:9200";
try {
execute("interactive", pathHomeParameter, "-u", url);
fail("Should have thrown exception");
} catch (UserException e) {
assertEquals(ExitCodes.USAGE, e.exitCode);
assertEquals("Passwords for user [elastic] do not match", e.getMessage());
} }
verifyZeroInteractions(httpClient);
} }
private String parsePassword(String value) throws IOException { private String parsePassword(String value) throws IOException {