-
Notifications
You must be signed in to change notification settings - Fork 25.2k
Set elastic password and generate enrollment token #75816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
97f799a
45c0080
d83f324
8e2c773
3b0ca34
6a4821f
8d0b281
6e1c717
8ad067f
1d40d0c
b5f464e
791c195
e1c996e
7a6b58b
eb0eae8
4c4c69e
5f9b007
cebdbc8
1a169c1
945bddb
faae76a
c8c0214
23d1b38
d56fac3
14395a8
19e228f
d28e251
d67d318
eb7e14b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,51 @@ | ||||||
/* | ||||||
* 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; | ||||||
|
||||||
import org.elasticsearch.common.Strings; | ||||||
import org.elasticsearch.common.xcontent.XContentBuilder; | ||||||
import org.elasticsearch.common.xcontent.json.JsonXContent; | ||||||
|
||||||
import java.nio.charset.StandardCharsets; | ||||||
import java.util.Base64; | ||||||
import java.util.List; | ||||||
|
||||||
public class EnrollmentToken { | ||||||
private final String apiKey; | ||||||
private final String fingerprint; | ||||||
private final String version; | ||||||
private final List<String > bound_address; | ||||||
|
||||||
public String getApiKey() { return apiKey; } | ||||||
public String getFingerprint() { return fingerprint; } | ||||||
public String getVersion() { return version; } | ||||||
public List<String> getBound_address() { return bound_address; } | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
|
||||||
public EnrollmentToken(String apiKey, String fingerprint, String version, List<String> bound_address) { | ||||||
this.apiKey = apiKey; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Objects.requireNonNull all of the ctor parameters |
||||||
this.fingerprint = fingerprint; | ||||||
this.version = version; | ||||||
this.bound_address = bound_address; | ||||||
} | ||||||
|
||||||
public String encode() throws Exception{ | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the last sync we discussed that this should be either 2 methods or 1 method that takes a parameter. One output should be base64encoded and one should be unencoded json There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add tests for this method |
||||||
final XContentBuilder builder = JsonXContent.contentBuilder(); | ||||||
builder.startObject(); | ||||||
builder.field("ver", version); | ||||||
builder.startArray("adr"); | ||||||
for (String bound_address : bound_address) { | ||||||
builder.value(bound_address); | ||||||
} | ||||||
builder.endArray(); | ||||||
builder.field("fgr", fingerprint); | ||||||
builder.field("key", apiKey); | ||||||
builder.endObject(); | ||||||
final String jsonString = Strings.toString(builder); | ||||||
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)); | ||||||
jkakavas marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -36,67 +36,52 @@ | |
import java.net.URI; | ||
import java.net.URISyntaxException; | ||
import java.net.URL; | ||
import java.nio.charset.StandardCharsets; | ||
import java.security.PrivateKey; | ||
import java.security.cert.X509Certificate; | ||
import java.util.ArrayList; | ||
import java.util.Base64; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Objects; | ||
import java.util.stream.Collectors; | ||
|
||
public class CreateEnrollmentToken { | ||
public class EnrollmentTokenGenerator { | ||
protected static final String ENROLL_API_KEY_EXPIRATION = "30m"; | ||
|
||
private static final Logger logger = LogManager.getLogger(CreateEnrollmentToken.class); | ||
private static final Logger logger = LogManager.getLogger(EnrollmentTokenGenerator.class); | ||
private final Environment environment; | ||
private final SSLService sslService; | ||
private final CommandLineHttpClient client; | ||
private final URL defaultUrl; | ||
|
||
public CreateEnrollmentToken(Environment environment) throws MalformedURLException { | ||
public EnrollmentTokenGenerator(Environment environment) throws MalformedURLException { | ||
this(environment, new CommandLineHttpClient(environment)); | ||
} | ||
|
||
// protected for testing | ||
protected CreateEnrollmentToken(Environment environment, CommandLineHttpClient client) throws MalformedURLException { | ||
protected EnrollmentTokenGenerator(Environment environment, CommandLineHttpClient client) throws MalformedURLException { | ||
this.environment = environment; | ||
this.sslService = new SSLService(environment); | ||
this.client = client; | ||
this.defaultUrl = new URL(client.getDefaultURL()); | ||
} | ||
|
||
public String createNodeEnrollmentToken(String user, SecureString password) throws Exception { | ||
public EnrollmentToken createNodeEnrollmentToken(String user, SecureString password) throws Exception { | ||
return this.create(user, password, NodeEnrollmentAction.NAME); | ||
} | ||
|
||
public String createKibanaEnrollmentToken(String user, SecureString password) throws Exception { | ||
public EnrollmentToken createKibanaEnrollmentToken(String user, SecureString password) throws Exception { | ||
return this.create(user, password, KibanaEnrollmentAction.NAME); | ||
} | ||
|
||
protected String create(String user, SecureString password, String action) throws Exception { | ||
protected EnrollmentToken create(String user, SecureString password, String action) throws Exception { | ||
if (XPackSettings.ENROLLMENT_ENABLED.get(environment.settings()) != true) { | ||
throw new IllegalStateException("[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token"); | ||
} | ||
final String fingerprint = getCaFingerprint(); | ||
final String apiKey = getApiKeyCredentials(user, password, action); | ||
final Tuple<List<String>, String> httpInfo = getNodeInfo(user, password); | ||
|
||
try { | ||
final XContentBuilder builder = JsonXContent.contentBuilder(); | ||
builder.startObject(); | ||
builder.field("ver", httpInfo.v2()); | ||
builder.startArray("adr"); | ||
for (String bound_address : httpInfo.v1()) { | ||
builder.value(bound_address); | ||
} | ||
builder.endArray(); | ||
builder.field("fgr", fingerprint); | ||
builder.field("key", apiKey); | ||
builder.endObject(); | ||
final String jsonString = Strings.toString(builder); | ||
return Base64.getUrlEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)); | ||
return new EnrollmentToken(apiKey, fingerprint, httpInfo.v2(), httpInfo.v1()); | ||
} catch (Exception e) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this error handling is now redundant. |
||
logger.error(("Error generating enrollment token"), e); | ||
throw new IllegalStateException("Error generating enrollment token: " + e.getMessage()); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,199 @@ | ||
/* | ||
* 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 joptsimple.OptionParser; | ||
import joptsimple.OptionSet; | ||
|
||
import org.elasticsearch.cli.ExitCodes; | ||
import org.elasticsearch.cli.KeyStoreAwareCommand; | ||
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.settings.Settings; | ||
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.XPackSettings; | ||
import org.elasticsearch.xpack.core.security.user.ElasticUser; | ||
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; | ||
import org.elasticsearch.xpack.security.enrollment.EnrollmentToken; | ||
import org.elasticsearch.xpack.security.enrollment.EnrollmentTokenGenerator; | ||
import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; | ||
import org.elasticsearch.xpack.security.tool.HttpResponse; | ||
|
||
import java.io.IOException; | ||
import java.net.HttpURLConnection; | ||
import java.net.MalformedURLException; | ||
import java.net.URISyntaxException; | ||
import java.net.URL; | ||
import java.security.SecureRandom; | ||
import java.util.function.Function; | ||
|
||
import static org.elasticsearch.xpack.security.tool.CommandLineHttpClient.createURL; | ||
|
||
public class BootstrapPasswordAndEnrollmentTokenForInitialNode extends KeyStoreAwareCommand { | ||
private static final String elasticUser = ElasticUser.NAME; | ||
jkakavas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private KeyStoreWrapper keyStoreWrapper; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can keyStoreWrapper be a local variable as it is only used in readBootstrapPassword ? |
||
private final CheckedFunction<Environment, EnrollmentTokenGenerator, Exception> createEnrollmentTokenFunction; | ||
private final Function<Environment, CommandLineHttpClient> clientFunction; | ||
private final CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction; | ||
final SecureRandom secureRandom = new SecureRandom(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit make this private as well |
||
|
||
// Package-private for testing | ||
SecureString bootstrapPassword; | ||
SecureString credentialsPassword; | ||
|
||
CommandLineHttpClient getClient(Environment env) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's merely preference, so just a nit. I wouldn't add these constructors only so that we can mock the method in tests. You can mock the object it returns instead There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I need real client in test to test the checkClusterHealthWithRetries There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Apologies, I don't follow this, can you elaborate ? You use a real client in tests by doing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @BigPandaToo I think the point is that either you have a no-arg constructor with accessors that you can mock, or if the constructor takes args you can pass the mocks as args and avoid accessors altogether. There's no point in passing real objects as args to the constructor, but then mocking their accessors. Hope this helps. Personally, I find it clearer to mock things (accessor methods) rather than have the production code use functions/suppliers instead of plain fields. But I'm OK either way. |
||
return clientFunction.apply(env); | ||
} | ||
EnrollmentTokenGenerator getEnrollmentTokenGenerator(Environment env) throws Exception{ | ||
return createEnrollmentTokenFunction.apply(env); | ||
} | ||
KeyStoreWrapper getKeyStoreWrapper(Environment env) throws Exception { return keyStoreFunction.apply(env); } | ||
OptionParser getParser() { return parser; } | ||
|
||
BootstrapPasswordAndEnrollmentTokenForInitialNode() { | ||
this( | ||
environment -> new CommandLineHttpClient(environment), | ||
environment -> KeyStoreWrapper.load(environment.configFile()), | ||
environment -> new EnrollmentTokenGenerator(environment) | ||
); | ||
} | ||
|
||
BootstrapPasswordAndEnrollmentTokenForInitialNode(Function<Environment, CommandLineHttpClient> clientFunction, | ||
CheckedFunction<Environment, KeyStoreWrapper, Exception> keyStoreFunction, | ||
CheckedFunction<Environment, EnrollmentTokenGenerator, Exception> | ||
createEnrollmentTokenFunction){ | ||
super("Set elastic password and generate enrollment token for initial node"); | ||
this.clientFunction = clientFunction; | ||
this.keyStoreFunction = keyStoreFunction; | ||
this.createEnrollmentTokenFunction = createEnrollmentTokenFunction; | ||
parser.allowsUnrecognizedOptions(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not needed anymore |
||
} | ||
|
||
public static void main(String[] args) throws Exception { | ||
exit(new BootstrapPasswordAndEnrollmentTokenForInitialNode().main(args, Terminal.DEFAULT)); | ||
} | ||
|
||
@Override | ||
protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { | ||
if (options.nonOptionArguments().contains("--explicitly-acknowledge-execution") == false) { | ||
BigPandaToo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw new UserException(ExitCodes.CONFIG, null); | ||
} | ||
if (env.settings().hasValue(XPackSettings.ENROLLMENT_ENABLED.getKey()) && false == | ||
XPackSettings.ENROLLMENT_ENABLED.get(env.settings())) { | ||
throw new UserException(ExitCodes.NOOP, null); | ||
} | ||
SecureString password; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that if you first generate tokens and then set the password of the elastic user, it would allow you to simplify your code as you don't need to keep track of password here and first use boostrapPassword. You can keep using bootstrapPassword for everyhing and just print the autogenerated password of the user in the end |
||
final CommandLineHttpClient client = getClient(env); | ||
final EnrollmentTokenGenerator enrollmentTokenGenerator = getEnrollmentTokenGenerator(env); | ||
keyStoreWrapper = getKeyStoreWrapper(env); | ||
ReadBootstrapPassword(env, terminal); | ||
try { | ||
client.checkClusterHealthWithRetriesWaitingForCluster(elasticUser, credentialsPassword, 5); | ||
} catch (Exception e) { | ||
throw new UserException(ExitCodes.UNAVAILABLE, null); | ||
jkakavas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
if (Strings.isNullOrEmpty(bootstrapPassword.toString())) { | ||
password = changeElasticUserPassword(client); | ||
} else { | ||
password = bootstrapPassword; | ||
} | ||
final EnrollmentToken kibanaToken; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit, you can move the println in the try clause so that you don't have to split the declaration above |
||
try { | ||
kibanaToken = enrollmentTokenGenerator.createKibanaEnrollmentToken(elasticUser, password); | ||
} catch (Exception e) { | ||
throw new UserException(ExitCodes.UNAVAILABLE, null); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I think we should have one big try-catch block with all the endpoint calling methods grouped inside. I think it is easier to follow this way. Lets avoid swallowing exceptions. I know we're not going to show these to users in the first phase but it bothers me every time I see it, and it is distracting. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree on having one block. But what do you mean by swallowing exceptions? We don't swallow them, just don't have custom messages There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
->
|
||
if (Strings.isNullOrEmpty(bootstrapPassword.toString())) { | ||
terminal.println("'elastic' user password: " + password); | ||
} | ||
terminal.println("CA fingerprint: " + kibanaToken.getFingerprint()); | ||
terminal.println("Kibana enrollment token: " + kibanaToken.encode()); | ||
if (options.nonOptionArguments().contains("--docker")) { | ||
final EnrollmentToken nodeToken; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit, you can move the println in the try clause so that you don't have to split the declaration above |
||
try { | ||
nodeToken = enrollmentTokenGenerator.createNodeEnrollmentToken(elasticUser, password); | ||
} catch (Exception e) { | ||
throw new UserException(ExitCodes.UNAVAILABLE, null); | ||
} | ||
terminal.println("Node enrollment token: " + nodeToken.encode()); | ||
} | ||
} | ||
|
||
@Override | ||
public void close() { | ||
if (keyStoreWrapper != null) { | ||
keyStoreWrapper.close(); | ||
} | ||
if (bootstrapPassword != null) { | ||
bootstrapPassword.close(); | ||
} | ||
if (credentialsPassword != null) { | ||
credentialsPassword.close(); | ||
} | ||
} | ||
|
||
protected SecureString changeElasticUserPassword(CommandLineHttpClient client) throws Exception { | ||
final URL passwordChangeUrl = changeElasticUserPasswordUrl(client); | ||
final HttpResponse response; | ||
SecureString password = new SecureString(generatePassword(20)); | ||
try { | ||
response = client.execute("POST", passwordChangeUrl, elasticUser, credentialsPassword, | ||
() -> { | ||
XContentBuilder xContentBuilder = JsonXContent.contentBuilder(); | ||
xContentBuilder.startObject().field("password", password).endObject(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can't pass a |
||
return Strings.toString(xContentBuilder); | ||
}, CommandLineHttpClient::responseBuilder); | ||
if (response.getHttpStatus() != HttpURLConnection.HTTP_OK) { | ||
throw new UserException(ExitCodes.UNAVAILABLE, null); | ||
} | ||
} catch (IOException e) { | ||
throw new UserException(ExitCodes.IO_ERROR, null); | ||
} | ||
return password; | ||
} | ||
|
||
void ReadBootstrapPassword (Environment env, Terminal terminal) throws Exception { | ||
jkakavas marked this conversation as resolved.
Show resolved
Hide resolved
|
||
decryptKeyStore(keyStoreWrapper, terminal); | ||
Settings.Builder settingsBuilder = Settings.builder(); | ||
settingsBuilder.put(env.settings(), true); | ||
if (settingsBuilder.getSecureSettings() == null) { | ||
settingsBuilder.setSecureSettings(keyStoreWrapper); | ||
} | ||
final Settings settings = settingsBuilder.build(); | ||
bootstrapPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(settings); | ||
credentialsPassword = Strings.isNullOrEmpty(bootstrapPassword.toString()) ? KeyStoreWrapper.SEED_SETTING.get(settings) : | ||
bootstrapPassword; | ||
|
||
final Environment newEnv = new Environment(settings, env.configFile()); | ||
Environment.assertEquivalent(newEnv, env); | ||
} | ||
|
||
URL checkClusterHealthUrl(CommandLineHttpClient client) throws MalformedURLException, URISyntaxException { | ||
return createURL(new URL(client.getDefaultURL()), "_cluster/health", "?pretty"); | ||
} | ||
|
||
URL changeElasticUserPasswordUrl(CommandLineHttpClient client) throws MalformedURLException, URISyntaxException { | ||
return createURL(new URL(client.getDefaultURL()), "/_security/user/" + elasticUser + "/_password", | ||
"?pretty"); | ||
} | ||
|
||
protected char[] generatePassword(int passwordLength) { | ||
final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray(); | ||
char[] characters = new char[passwordLength]; | ||
for (int i = 0; i < passwordLength; ++i) { | ||
characters[i] = passwordChars[secureRandom.nextInt(passwordChars.length)]; | ||
} | ||
return characters; | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.