From 5bb47e1a2dd1d085c689934cbb0312e0107c8f6c Mon Sep 17 00:00:00 2001 From: Ioannis Kakavas Date: Tue, 20 Jul 2021 13:42:14 +0300 Subject: [PATCH] Reset elastic password cli tool (#74892) This change introduces a CLI tool that can be used to create enrollment tokens. It doesn't require credentials, but simply write access to the local filesystem of a node. It uses an auto-generated user in the file-realm with superuser role. For this purpose, this change also introduces a base class for a CLI tool that can be used by any CLI tool needs to perform actions against an ES node as a superuser without requiring credentials from the user. It is worth noting that this doesn't change our existing thread model, because already an actor with write access to the fs of an ES node, can become superuser (again, by adding a superuser to the file realm, albeit manually). --- docs/reference/commands/index.asciidoc | 2 + .../commands/reset-elastic-password.asciidoc | 63 +++++ .../bin/elasticsearch-reset-elastic-password | 11 + .../elasticsearch-reset-elastic-password.bat | 21 ++ .../tool/ResetElasticPasswordTool.java | 152 ++++++++++++ .../tool/BaseRunAsSuperuserCommand.java | 6 +- .../tool/AbstractPasswordToolTestCase.java | 85 +++++++ .../tool/ResetElasticPasswordToolIT.java | 64 +++++ .../esnative/tool/SetupPasswordToolIT.java | 65 +---- .../tool/ResetElasticPasswordToolTests.java | 229 ++++++++++++++++++ 10 files changed, 631 insertions(+), 67 deletions(-) create mode 100644 docs/reference/commands/reset-elastic-password.asciidoc create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password.bat create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordTool.java create mode 100644 x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/AbstractPasswordToolTestCase.java create mode 100644 x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolIT.java create mode 100644 x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolTests.java diff --git a/docs/reference/commands/index.asciidoc b/docs/reference/commands/index.asciidoc index 1bf71038e1e35..086ee00f49690 100644 --- a/docs/reference/commands/index.asciidoc +++ b/docs/reference/commands/index.asciidoc @@ -13,6 +13,7 @@ tasks from the command line: * <> * <> * <> +* <> * <> * <> * <> @@ -27,6 +28,7 @@ include::create-enrollment-token.asciidoc[] include::croneval.asciidoc[] include::keystore.asciidoc[] include::node-tool.asciidoc[] +include::reset-elastic-password.asciidoc[] include::saml-metadata.asciidoc[] include::service-tokens-command.asciidoc[] include::setup-passwords.asciidoc[] diff --git a/docs/reference/commands/reset-elastic-password.asciidoc b/docs/reference/commands/reset-elastic-password.asciidoc new file mode 100644 index 0000000000000..a440a43844fc9 --- /dev/null +++ b/docs/reference/commands/reset-elastic-password.asciidoc @@ -0,0 +1,63 @@ +[roles="xpack"] +[[reset-elastic-password]] +== elasticsearch-reset-elastic-password + +The `elasticsearch-reset-elastic-password` command resets the password for the +`elastic` <>. + +[discrete] +=== Synopsis + +[source,shell] +---- +bin/elasticsearch-reset-elastic-password +[-a, --auto] [-b, --batch] [-E > to run the request +that changes the `elastic` user password. +IMPORTANT: You cannot use this tool if the file realm is disabled in your `elasticsearch.yml` file. + +This command uses an HTTP connection to connect to the cluster and run the user +management requests. The command automatically attempts to establish the connection +over HTTPS by using the `xpack.security.http.ssl` settings in +the `elasticsearch.yml` file. If you do not use the default config directory +location, ensure that the `ES_PATH_CONF` environment variable returns the +correct path before you run the `elasticsearch-reset-elastic-password` command. You can +override settings in your `elasticsearch.yml` file by using the `-E` command +option. For more information about debugging connection failures, see +<>. + +[discrete] +[[reset-elastic-password-parameters]] +=== Parameters + +`-a, --auto`:: Resets the password of the `elastic` user to an auto-generated strong password. (Default) + +`-b, --batch`:: Runs the reset password process without prompting the user for verification. + +`-E `:: Configures a standard {es} or {xpack} setting. + +`-f, --force`:: Forces the command to run against an unhealthy cluster. + +`-h, --help`:: Returns all of the command parameters. + +`-i, --interactive`:: Prompts the user for the password of the `elastic` user. Use this option to explicitly set a password. + +[discrete] +=== Examples + +The following example resets the password of the `elastic` user to an auto-generated value and +prints the new password in the console. + +[source,shell] +---- +bin/elasticsearch-reset-elastic-password +---- 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..db5ea7630b3d5 --- /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.authc.esnative.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/bin/elasticsearch-reset-elastic-password.bat b/x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password.bat new file mode 100644 index 0000000000000..c3168fe1510c6 --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-reset-elastic-password.bat @@ -0,0 +1,21 @@ +@echo off + +rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +rem or more contributor license agreements. Licensed under the Elastic License +rem 2.0; you may not use this file except in compliance with the Elastic License +rem 2.0. + +setlocal enabledelayedexpansion +setlocal enableextensions + +set ES_MAIN_CLASS=org.elasticsearch.xpack.security.authc.esnative.tool.ResetElasticPasswordTool +set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env +set ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli +call "%~dp0elasticsearch-cli.bat" ^ + %%* ^ + || goto exit + +endlocal +endlocal +:exit +exit /b %ERRORLEVEL% diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordTool.java new file mode 100644 index 0000000000000..0e43611c3ea63 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordTool.java @@ -0,0 +1,152 @@ +/* + * 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.authc.esnative.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 org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; + +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; + + public 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, "Resets the password of the elastic built-in user"); + 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, String username, SecureString password) + 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 { + 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, + username, + password, + () -> requestBodySupplier(elasticPassword), + this::responseBuilder + ); + final int responseStatus = httpResponse.getHttpStatus(); + if (httpResponse.getHttpStatus() != HttpURLConnection.HTTP_OK) { + throw new UserException(ExitCodes.TEMP_FAILURE, + "Failed to reset password for the elastic user. Unexpected http status [" + responseStatus + "]"); + } 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.print(Terminal.Verbosity.NORMAL,"New value: "); + terminal.println(Terminal.Verbosity.SILENT, elasticPassword.toString()); + } + } + } 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("Re-enter 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/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 29708d6cb4e13..a7bd629aa260b 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 @@ -249,14 +249,14 @@ private void checkClusterHealthWithRetries(Environment env, Terminal terminal, S } } - HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) throws IOException { + protected 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; } - char[] generatePassword(int passwordLength) { + protected char[] generatePassword(int passwordLength) { final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray(); char[] characters = new char[passwordLength]; for (int i = 0; i < passwordLength; ++i) { @@ -275,7 +275,7 @@ private String generateUsername() { return "enrollment_autogenerated_" + new String(characters); } - URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException { + protected 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/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/AbstractPasswordToolTestCase.java b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/AbstractPasswordToolTestCase.java new file mode 100644 index 0000000000000..3baaac084500a --- /dev/null +++ b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/AbstractPasswordToolTestCase.java @@ -0,0 +1,85 @@ +/* + * 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.authc.esnative.tool; + +import org.elasticsearch.cli.MockTerminal; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.network.InetAddresses; +import org.elasticsearch.common.network.NetworkService; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.core.PathUtils; +import org.elasticsearch.core.SuppressForbidden; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.junit.Before; + +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public abstract class AbstractPasswordToolTestCase extends ESRestTestCase { + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); + return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build(); + } + + @Before + @SuppressWarnings("unchecked") + void writeConfigurationToDisk() throws Exception { + final String testConfigDir = System.getProperty("tests.config.dir"); + logger.info("--> CONF: {}", testConfigDir); + final Path configPath = PathUtils.get(testConfigDir); + setSystemPropsForTool(configPath); + + Response nodesResponse = client().performRequest(new Request("GET", "/_nodes/http")); + Map nodesMap = entityAsMap(nodesResponse); + + Map nodes = (Map) nodesMap.get("nodes"); + Map firstNode = (Map) nodes.entrySet().iterator().next().getValue(); + Map firstNodeHttp = (Map) firstNode.get("http"); + String nodePublishAddress = (String) firstNodeHttp.get("publish_address"); + final int lastColonIndex = nodePublishAddress.lastIndexOf(':'); + InetAddress actualPublishAddress = InetAddresses.forString(nodePublishAddress.substring(0, lastColonIndex)); + InetAddress expectedPublishAddress = new NetworkService(Collections.emptyList()).resolvePublishHostAddresses(Strings.EMPTY_ARRAY); + final int port = Integer.valueOf(nodePublishAddress.substring(lastColonIndex + 1)); + + List lines = Files.readAllLines(configPath.resolve("elasticsearch.yml")); + lines = lines.stream() + .filter(s -> s.startsWith("http.port") == false && s.startsWith("http.publish_port") == false) + .collect(Collectors.toList()); + lines.add(randomFrom("http.port", "http.publish_port") + ": " + port); + if (expectedPublishAddress.equals(actualPublishAddress) == false) { + lines.add("http.publish_address: " + InetAddresses.toAddrString(actualPublishAddress)); + } + Files.write(configPath.resolve("elasticsearch.yml"), lines, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + } + + protected void possiblyDecryptKeystore(MockTerminal mockTerminal) { + if (inFipsJvm()) { + // In our FIPS 140-2 tests, we set the keystore password to `keystore-password` + mockTerminal.addSecretInput("keystore-password"); + } + } + + @SuppressForbidden(reason = "need to set sys props for CLI tool") + void setSystemPropsForTool(Path configPath) { + System.setProperty("es.path.conf", configPath.toString()); + System.setProperty("es.path.home", configPath.getParent().toString()); + } +} diff --git a/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolIT.java b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolIT.java new file mode 100644 index 0000000000000..abfe40bd6aea7 --- /dev/null +++ b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolIT.java @@ -0,0 +1,64 @@ +/* + * 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.authc.esnative.tool; + +import org.elasticsearch.cli.MockTerminal; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +public class ResetElasticPasswordToolIT extends AbstractPasswordToolTestCase { + + @SuppressWarnings("unchecked") + public void testResetElasticPasswordTool() throws Exception { + MockTerminal mockTerminal = new MockTerminal(); + ResetElasticPasswordTool resetElasticPasswordTool = new ResetElasticPasswordTool(); + final int status; + final String password; + if (randomBoolean()) { + possiblyDecryptKeystore(mockTerminal); + status = resetElasticPasswordTool.main(new String[] { "-a", "-b" }, mockTerminal); + password = readPasswordFromOutput(mockTerminal.getOutput()); + } else { + password = randomAlphaOfLengthBetween(14, 20); + possiblyDecryptKeystore(mockTerminal); + mockTerminal.addSecretInput(password); + mockTerminal.addSecretInput(password); + status = resetElasticPasswordTool.main(new String[] { "-i", "-b" }, mockTerminal); + } + logger.info("CLI TOOL OUTPUT:\n{}", mockTerminal.getOutput()); + assertEquals(0, status); + final String basicHeader = "Basic " + Base64.getEncoder().encodeToString(("elastic:" + password).getBytes(StandardCharsets.UTF_8)); + try { + Request request = new Request("GET", "/_security/_authenticate"); + RequestOptions.Builder options = request.getOptions().toBuilder(); + options.addHeader("Authorization", basicHeader); + request.setOptions(options); + Map userInfoMap = entityAsMap(client().performRequest(request)); + assertEquals("elastic", userInfoMap.get("username")); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private String readPasswordFromOutput(String output) { + String[] lines = output.split("\\n"); + for (String line : lines) { + if (line.startsWith("New value: ")) { + return line.substring(11); + } + } + return null; + } + +} diff --git a/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/SetupPasswordToolIT.java b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/SetupPasswordToolIT.java index ec93080224100..4f1682d6183e1 100644 --- a/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/SetupPasswordToolIT.java +++ b/x-pack/qa/security-setup-password-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/SetupPasswordToolIT.java @@ -9,70 +9,19 @@ import org.elasticsearch.cli.MockTerminal; import org.elasticsearch.client.Request; import org.elasticsearch.client.RequestOptions; -import org.elasticsearch.client.Response; import org.elasticsearch.client.WarningsHandler; -import org.elasticsearch.common.Strings; -import org.elasticsearch.core.SuppressForbidden; -import org.elasticsearch.core.PathUtils; -import org.elasticsearch.common.network.InetAddresses; -import org.elasticsearch.common.network.NetworkService; -import org.elasticsearch.common.settings.SecureString; -import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.common.util.concurrent.ThreadContext; -import org.elasticsearch.test.rest.ESRestTestCase; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.InetAddress; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; -public class SetupPasswordToolIT extends ESRestTestCase { +public class SetupPasswordToolIT extends AbstractPasswordToolTestCase { - @Override - protected Settings restClientSettings() { - String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); - return Settings.builder() - .put(ThreadContext.PREFIX + ".Authorization", token) - .build(); - } - - @SuppressWarnings("unchecked") public void testSetupPasswordToolAutoSetup() throws Exception { - final String testConfigDir = System.getProperty("tests.config.dir"); - logger.info("--> CONF: {}", testConfigDir); - final Path configPath = PathUtils.get(testConfigDir); - setSystemPropsForTool(configPath); - - Response nodesResponse = client().performRequest(new Request("GET", "/_nodes/http")); - Map nodesMap = entityAsMap(nodesResponse); - - Map nodes = (Map) nodesMap.get("nodes"); - Map firstNode = (Map) nodes.entrySet().iterator().next().getValue(); - Map firstNodeHttp = (Map) firstNode.get("http"); - String nodePublishAddress = (String) firstNodeHttp.get("publish_address"); - final int lastColonIndex = nodePublishAddress.lastIndexOf(':'); - InetAddress actualPublishAddress = InetAddresses.forString(nodePublishAddress.substring(0, lastColonIndex)); - InetAddress expectedPublishAddress = new NetworkService(Collections.emptyList()).resolvePublishHostAddresses(Strings.EMPTY_ARRAY); - final int port = Integer.valueOf(nodePublishAddress.substring(lastColonIndex + 1)); - - List lines = Files.readAllLines(configPath.resolve("elasticsearch.yml")); - lines = lines.stream().filter(s -> s.startsWith("http.port") == false && s.startsWith("http.publish_port") == false) - .collect(Collectors.toList()); - lines.add(randomFrom("http.port", "http.publish_port") + ": " + port); - if (expectedPublishAddress.equals(actualPublishAddress) == false) { - lines.add("http.publish_address: " + InetAddresses.toAddrString(actualPublishAddress)); - } - Files.write(configPath.resolve("elasticsearch.yml"), lines, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); MockTerminal mockTerminal = new MockTerminal(); SetupPasswordTool tool = new SetupPasswordTool(); @@ -121,16 +70,4 @@ public void testSetupPasswordToolAutoSetup() throws Exception { }); } - private void possiblyDecryptKeystore(MockTerminal mockTerminal) { - if (inFipsJvm()) { - // In our FIPS 140-2 tests, we set the keystore password to `keystore-password` - mockTerminal.addSecretInput("keystore-password"); - } - } - - @SuppressForbidden(reason = "need to set sys props for CLI tool") - private void setSystemPropsForTool(Path configPath) { - System.setProperty("es.path.conf", configPath.toString()); - System.setProperty("es.path.home", configPath.getParent().toString()); - } } diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolTests.java new file mode 100644 index 0000000000000..6485cd1a1f5d5 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/esnative/tool/ResetElasticPasswordToolTests.java @@ -0,0 +1,229 @@ +/* + * 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.authc.esnative.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.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.hasLength; +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 testFailureClusterUnhealthyWithForce() throws Exception { + terminal.addTextInput("y"); + 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); + execute("-a", randomFrom("-f", "--force")); + 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 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())); + } + + public void testAutoBatchSilent() throws Exception { + execute(randomFrom("--silent", "-s"), randomFrom("--batch", "-b")); + String output = terminal.getOutput(); + assertThat(output, hasLength(21)); // password + new line char + assertThat(terminal.getErrorOutput(), 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"); + } +}