From f67eaf1f73660e3eb8ce43149fa772963e8d8399 Mon Sep 17 00:00:00 2001 From: Ioannis Kakavas Date: Sun, 4 Jul 2021 00:14:47 +0300 Subject: [PATCH] elastic user password reset CLI tool This change introduces a new CLI tool that allows users to reset the password for the elastic user, setting it to a user defined or an auto-generated value. Resolves: #70113 --- .../bin/elasticsearch-reset-elastic-password | 11 + .../tool/BaseRunAsSuperuserCommand.java | 15 +- .../tool/ResetElasticPasswordTool.java | 146 ++++++++++++ .../tool/CreateEnrollmentTokenToolTests.java | 26 --- .../tool/ResetElasticPasswordToolTests.java | 207 ++++++++++++++++++ 5 files changed, 372 insertions(+), 33 deletions(-) create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/ResetElasticPasswordTool.java create mode 100644 x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/ResetElasticPasswordToolTests.java diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password b/x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password new file mode 100644 index 0000000000000..9a5e12efa9d6b --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password @@ -0,0 +1,11 @@ +#!/bin/bash + +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. + +ES_MAIN_CLASS=org.elasticsearch.xpack.security.tool.ResetElasticPasswordTool \ + ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \ + "`dirname "$0"`"/elasticsearch-cli \ + "$@" diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java index 24c5ed543bd4e..13dff7e3b300b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java @@ -46,11 +46,13 @@ */ public abstract class BaseRunAsSuperuserCommand extends KeyStoreAwareCommand { + private static final String[] ROLES = new String[] { "superuser" }; + private static final int PASSWORD_LENGTH = 14; + private final Function clientFunction; private final CheckedFunction keyStoreFunction; private SecureString password; private String username; - private static final String[] ROLES = new String[] { "superuser" }; final SecureRandom secureRandom = new SecureRandom(); public BaseRunAsSuperuserCommand( @@ -92,7 +94,7 @@ protected final void execute(Terminal terminal, OptionSet options, Environment e ensureFileRealmEnabled(settings); try { final Hasher hasher = Hasher.resolve(XPackSettings.PASSWORD_HASHING_ALGORITHM.get(settings)); - password = new SecureString(generatePassword()); + password = new SecureString(generatePassword(PASSWORD_LENGTH)); username = generateUsername(); final Path passwordFile = FileUserPasswdStore.resolveFile(newEnv); @@ -199,7 +201,7 @@ private void checkClusterHealthWithRetries(Environment env, Terminal terminal, i try { response = client.execute("GET", clusterHealthUrl, username, password, () -> null, this::responseBuilder); } catch (Exception e) { - throw new UserException(ExitCodes.UNAVAILABLE, "Failed to determine the health of the cluster. " + e.getMessage()); + throw new UserException(ExitCodes.UNAVAILABLE, "Failed to determine the health of the cluster. ", e); } final int responseStatus = response.getHttpStatus(); if (responseStatus != HttpURLConnection.HTTP_OK) { @@ -233,16 +235,15 @@ private void checkClusterHealthWithRetries(Environment env, Terminal terminal, i } } - private HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) throws IOException { + HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) throws IOException { final HttpResponse.HttpResponseBuilder httpResponseBuilder = new HttpResponse.HttpResponseBuilder(); final String responseBody = Streams.readFully(is).utf8ToString(); httpResponseBuilder.withResponseBody(responseBody); return httpResponseBuilder; } - private char[] generatePassword() { + char[] generatePassword(int passwordLength) { final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray(); - int passwordLength = 14; char[] characters = new char[passwordLength]; for (int i = 0; i < passwordLength; ++i) { characters[i] = passwordChars[secureRandom.nextInt(passwordChars.length)]; @@ -260,7 +261,7 @@ private String generateUsername() { return "enrollment_autogenerated_" + new String(characters); } - private URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException { + URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException { return new URL(url, (url.toURI().getPath() + path).replaceAll("/+", "/") + query); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/ResetElasticPasswordTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/ResetElasticPasswordTool.java new file mode 100644 index 0000000000000..ac44cbd05fc6d --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/ResetElasticPasswordTool.java @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.tool; + +import joptsimple.OptionSet; + +import joptsimple.OptionSpecBuilder; + +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.security.support.Validation; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.function.Function; + +public class ResetElasticPasswordTool extends BaseRunAsSuperuserCommand { + + private final Function clientFunction; + private final OptionSpecBuilder interactive; + private final OptionSpecBuilder auto; + private final OptionSpecBuilder batch; + + ResetElasticPasswordTool() { + this(environment -> new CommandLineHttpClient(environment), environment -> KeyStoreWrapper.load(environment.configFile())); + } + + public static void main(String[] args) throws Exception { + exit(new ResetElasticPasswordTool().main(args, Terminal.DEFAULT)); + } + + protected ResetElasticPasswordTool( + Function clientFunction, + CheckedFunction keyStoreFunction) { + super(clientFunction, keyStoreFunction); + interactive = parser.acceptsAll(List.of("i", "interactive")); + auto = parser.acceptsAll(List.of("a", "auto")); // default + batch = parser.acceptsAll(List.of("b", "batch")); + this.clientFunction = clientFunction; + } + + @Override + protected void executeCommand(Terminal terminal, OptionSet options, Environment env) throws Exception { + final SecureString elasticPassword; + if (options.has(interactive)) { + if (options.has(batch) == false) { + terminal.println("This tool will reset the password of the [elastic] user."); + terminal.println("You will be prompted to enter the password."); + boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false); + terminal.println("\n"); + if (shouldContinue == false) { + throw new UserException(ExitCodes.OK, "User cancelled operation"); + } + } + elasticPassword = promptForPassword(terminal); + } else { + if (options.has(batch) == false) { + terminal.println("This tool will reset the password of the [elastic] user to an autogenerated value."); + terminal.println("The password will be printed in the console."); + boolean shouldContinue = terminal.promptYesNo("Please confirm that you would like to continue", false); + terminal.println("\n"); + if (shouldContinue == false) { + throw new UserException(ExitCodes.OK, "User cancelled operation"); + } + } + elasticPassword = new SecureString(generatePassword(20)); + } + try (SecureString fileRealmSuperuserPassword = getPassword()) { + final String fileRealmSuperuser = getUsername(); + final CommandLineHttpClient client = clientFunction.apply(env); + final URL changePasswordUrl = createURL(new URL(client.getDefaultURL()), "_security/user/elastic/_password", "?pretty"); + final HttpResponse httpResponse = client.execute( + "POST", + changePasswordUrl, + fileRealmSuperuser, + fileRealmSuperuserPassword, + () -> requestBodySupplier(elasticPassword), + this::responseBuilder + ); + if (httpResponse.getHttpStatus() != HttpURLConnection.HTTP_OK) { + throw new UserException(ExitCodes.TEMP_FAILURE, "Failed to reset password for the elastic user"); + } else { + if (options.has(interactive)) { + terminal.println("Password for the elastic user successfully reset."); + } else { + terminal.println("Password for the elastic user successfully reset."); + terminal.println("New value: " + elasticPassword); + } + } + } catch (Exception e) { + throw new UserException(ExitCodes.TEMP_FAILURE, "Failed to reset password for the elastic user", e); + } finally { + elasticPassword.close(); + } + } + + private SecureString promptForPassword(Terminal terminal) { + while (true) { + SecureString password1 = new SecureString(terminal.readSecret("Enter password for [elastic]: ")); + Validation.Error err = Validation.Users.validatePassword(password1); + if (err != null) { + terminal.errorPrintln(err.toString()); + terminal.errorPrintln("Try again."); + password1.close(); + continue; + } + try (SecureString password2 = new SecureString(terminal.readSecret("Reenter password for [elastic]: "))) { + if (password1.equals(password2) == false) { + terminal.errorPrintln("Passwords do not match."); + terminal.errorPrintln("Try again."); + password1.close(); + continue; + } + } + return password1; + } + } + + private String requestBodySupplier(SecureString pwd) throws Exception { + XContentBuilder xContentBuilder = JsonXContent.contentBuilder(); + xContentBuilder.startObject().field("password", pwd.toString()).endObject(); + return Strings.toString(xContentBuilder); + } + + @Override + protected void validate(Terminal terminal, OptionSet options, Environment env) throws Exception { + if ((options.has("i") || options.has("interactive")) && (options.has("a") || options.has("auto"))) { + throw new UserException(ExitCodes.USAGE, "You can only run the tool in one of [auto] or [interactive] modes"); + } + } + +} diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java index 472fd1466172a..3ac6310016a4d 100644 --- a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java @@ -39,7 +39,6 @@ import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Map; @@ -90,39 +89,14 @@ public void setup() throws Exception { Files.createDirectories(confDir); Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); - final Path httpCaPath = confDir.resolve("httpCa.p12"); - final Path srcHttpCaPath = - Paths.get(getClass().getResource("/org/elasticsearch/xpack/security/enrollment/tool/httpCa.p12").toURI()).toAbsolutePath() - .normalize(); - Files.copy(srcHttpCaPath, httpCaPath); - final Path srcTransportPath = Paths.get(getClass().getResource("/org/elasticsearch/xpack/security/enrollment/tool/transport.p12") - .toURI()).toAbsolutePath().normalize(); - final Path transportPath = confDir.resolve("transport.p12"); - Files.copy(srcTransportPath, transportPath); settings = Settings.builder() .put("path.home", homeDir) - .put("xpack.security.enabled", true) .put("xpack.security.enrollment.enabled", true) - .put("xpack.security.authc.api_key.enabled", true) - .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "/work/" + httpCaPath) - .put("xpack.security.http.ssl.truststore.path", "/work/" + httpCaPath) - .put("xpack.security.transport.ssl.enabled", true) - .put("xpack.security.transport.ssl.keystore.path", "/work/" + transportPath) - .put("xpack.security.transport.ssl.truststore.path", "/work/" + transportPath) .build(); pathHomeParameter = "-Epath.home=" + homeDir; this.keyStoreWrapper = mock(KeyStoreWrapper.class); when(keyStoreWrapper.isLoaded()).thenReturn(true); - when(keyStoreWrapper.getString("xpack.security.http.ssl.keystore.secure_password")) - .thenReturn(new SecureString("password".toCharArray())); - when(keyStoreWrapper.getString("xpack.security.http.ssl.truststore.secure_password")) - .thenReturn(new SecureString("password".toCharArray())); - when(keyStoreWrapper.getString("xpack.security.transport.ssl.keystore.secure_password")) - .thenReturn(new SecureString("password".toCharArray())); - when(keyStoreWrapper.getString("xpack.security.transport.ssl.truststore.secure_password")) - .thenReturn(new SecureString("password".toCharArray())); this.client = mock(CommandLineHttpClient.class); when(client.getDefaultURL()).thenReturn("https://localhost:9200"); diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/ResetElasticPasswordToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/ResetElasticPasswordToolTests.java new file mode 100644 index 0000000000000..ce68f47430ae4 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/ResetElasticPasswordToolTests.java @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.enrollment.tool; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedSupplier; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.PathUtilsForTesting; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; +import org.elasticsearch.xpack.security.tool.ResetElasticPasswordTool; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class ResetElasticPasswordToolTests extends CommandTestCase { + static FileSystem jimfs; + String pathHomeParameter; + Path confDir; + Settings settings; + + private CommandLineHttpClient client; + private KeyStoreWrapper keyStoreWrapper; + + @Override + protected Command newCommand() { + return new ResetElasticPasswordTool(environment -> client, environment -> keyStoreWrapper) { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(ResetElasticPasswordToolTests.this.settings, confDir); + } + }; + } + + @BeforeClass + public static void setupJimfs() { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setup() throws Exception { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); + Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); + settings = Settings.builder().put("path.home", homeDir).put("xpack.security.enrollment.enabled", true).build(); + pathHomeParameter = "-Epath.home=" + homeDir; + + this.keyStoreWrapper = mock(KeyStoreWrapper.class); + when(keyStoreWrapper.isLoaded()).thenReturn(true); + + this.client = mock(CommandLineHttpClient.class); + when(client.getDefaultURL()).thenReturn("https://localhost:9200"); + + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("yellow", "green"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + HttpResponse changePasswordResponse = new HttpResponse(HttpURLConnection.HTTP_OK, Map.of()); + when(client.execute(anyString(), eq(changePasswordUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(changePasswordResponse); + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + public void testSuccessAutoMode() throws Exception { + terminal.addTextInput("y"); + execute(); + String output = terminal.getOutput(); + assertThat(output, containsString("This tool will reset the password of the [elastic] user to an autogenerated value.")); + assertThat(output, containsString("The password will be printed in the console.")); + assertThat(output, containsString("Password for the elastic user successfully reset.")); + assertThat(output, containsString("New value:")); + } + + public void testSuccessInteractiveMode() throws Exception { + final String password = randomAlphaOfLengthBetween(6, 18); + terminal.addTextInput("y"); + terminal.addSecretInput(password); + terminal.addSecretInput(password); + execute(randomFrom("-i", "--interactive")); + String output = terminal.getOutput(); + assertThat(output, containsString("This tool will reset the password of the [elastic] user.")); + assertThat(output, containsString("You will be prompted to enter the password.")); + assertThat(output, containsString("Password for the elastic user successfully reset.")); + } + + public void testUserCancelledAutoMode() throws Exception { + terminal.addTextInput("n"); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), equalTo("User cancelled operation")); + String output = terminal.getOutput(); + assertThat(output, containsString("This tool will reset the password of the [elastic] user to an autogenerated value.")); + assertThat(output, containsString("The password will be printed in the console.")); + } + + public void testFailureInteractiveModeDifferentPassword() throws Exception { + final String password1 = randomAlphaOfLengthBetween(6, 18); + final String password2 = randomAlphaOfLengthBetween(6, 18); + terminal.addTextInput("y"); + terminal.addSecretInput(password1); + terminal.addSecretInput(password2); + terminal.addSecretInput(password1); + terminal.addSecretInput(password1); + execute(randomFrom("-i", "--interactive")); + String output = terminal.getOutput(); + String error = terminal.getErrorOutput(); + assertThat(output, containsString("This tool will reset the password of the [elastic] user.")); + assertThat(output, containsString("You will be prompted to enter the password.")); + assertThat(output, containsString("Password for the elastic user successfully reset.")); + assertThat(error, containsString("Passwords do not match.")); + assertThat(error, containsString("Try again.")); + } + + public void testFailureClusterUnhealthy() throws Exception { + final URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-i", "-a")); + }); + assertThat(e.exitCode, equalTo(ExitCodes.UNAVAILABLE)); + assertThat(e.getMessage(), containsString("RED")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + public void testFailureUnableToChangePassword() throws Exception { + terminal.addTextInput("y"); + final URL url = new URL(client.getDefaultURL()); + HttpResponse changePasswordResponse = new HttpResponse(HttpURLConnection.HTTP_UNAVAILABLE, Map.of()); + when(client.execute(anyString(), eq(changePasswordUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(changePasswordResponse); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.exitCode, equalTo(ExitCodes.TEMP_FAILURE)); + assertThat(e.getMessage(), equalTo("Failed to reset password for the elastic user")); + String output = terminal.getOutput(); + assertThat(output, containsString("This tool will reset the password of the [elastic] user to an autogenerated value.")); + assertThat(output, containsString("The password will be printed in the console.")); + } + + public void testAutoInteractiveModesMutuallyExclusive() throws Exception { + UserException e = expectThrows(UserException.class, () -> execute(randomFrom("-i", "--interactive"), randomFrom("-a", "--auto"))); + assertThat(e.exitCode, equalTo(ExitCodes.USAGE)); + assertThat(e.getMessage(), equalTo("You can only run the tool in one of [auto] or [interactive] modes")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + private URL changePasswordUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_security/user/elastic/_password").replaceAll("/+", "/") + "?pretty"); + } + + private URL clusterHealthUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_cluster/health").replaceAll("/+", "/") + "?pretty"); + } +}