From 3435fc4613653a159fb8e0353204acfd98a225ca Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Tue, 27 Nov 2018 15:07:24 +1100 Subject: [PATCH] HLRC: Add ability to put user with a password hash (#35844) Update PutUserRequest to support password_hash (see: #35242) This also updates the documentation to bring it in line with our more recent approach to HLRC docs. --- .../client/security/PutUserRequest.java | 81 ++++++++++++-- .../SecurityDocumentationIT.java | 45 +++++++- .../client/security/PutUserRequestTests.java | 101 ++++++++++++++++++ .../high-level/security/put-user.asciidoc | 76 +++++++------ 4 files changed, 257 insertions(+), 46 deletions(-) create mode 100644 client/rest-high-level/src/test/java/org/elasticsearch/client/security/PutUserRequestTests.java diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/PutUserRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/PutUserRequest.java index 66af9fca31c..f7bf87da002 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/PutUserRequest.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/PutUserRequest.java @@ -39,9 +39,46 @@ public final class PutUserRequest implements Validatable, ToXContentObject { private final User user; private final @Nullable char[] password; + private final @Nullable char[] passwordHash; private final boolean enabled; private final RefreshPolicy refreshPolicy; + /** + * Create or update a user in the native realm, with the user's new or updated password specified in plaintext. + * @param user the user to be created or updated + * @param password the password of the user. The password array is not modified by this class. + * It is the responsibility of the caller to clear the password after receiving + * a response. + * @param enabled true if the user is enabled and allowed to access elasticsearch + * @param refreshPolicy the refresh policy for the request. + */ + public static PutUserRequest withPassword(User user, char[] password, boolean enabled, RefreshPolicy refreshPolicy) { + return new PutUserRequest(user, password, null, enabled, refreshPolicy); + } + + /** + * Create or update a user in the native realm, with the user's new or updated password specified as a cryptographic hash. + * @param user the user to be created or updated + * @param passwordHash the hash of the password of the user. It must be in the correct format for the password hashing algorithm in + * use on this elasticsearch cluster. The array is not modified by this class. + * It is the responsibility of the caller to clear the hash after receiving a response. + * @param enabled true if the user is enabled and allowed to access elasticsearch + * @param refreshPolicy the refresh policy for the request. + */ + public static PutUserRequest withPasswordHash(User user, char[] passwordHash, boolean enabled, RefreshPolicy refreshPolicy) { + return new PutUserRequest(user, null, passwordHash, enabled, refreshPolicy); + } + + /** + * Update an existing user in the native realm without modifying their password. + * @param user the user to be created or updated + * @param enabled true if the user is enabled and allowed to access elasticsearch + * @param refreshPolicy the refresh policy for the request. + */ + public static PutUserRequest updateUser(User user, boolean enabled, RefreshPolicy refreshPolicy) { + return new PutUserRequest(user, null, null, enabled, refreshPolicy); + } + /** * Creates a new request that is used to create or update a user in the native realm. * @@ -51,10 +88,33 @@ public final class PutUserRequest implements Validatable, ToXContentObject { * a response. * @param enabled true if the user is enabled and allowed to access elasticsearch * @param refreshPolicy the refresh policy for the request. + * @deprecated Use {@link #withPassword(User, char[], boolean, RefreshPolicy)} or + * {@link #updateUser(User, boolean, RefreshPolicy)} instead. */ + @Deprecated public PutUserRequest(User user, @Nullable char[] password, boolean enabled, @Nullable RefreshPolicy refreshPolicy) { + this(user, password, null, enabled, refreshPolicy); + } + + /** + * Creates a new request that is used to create or update a user in the native realm. + * @param user the user to be created or updated + * @param password the password of the user. The password array is not modified by this class. + * It is the responsibility of the caller to clear the password after receiving + * a response. + * @param passwordHash the hash of the password. Only one of "password" or "passwordHash" may be populated. + * The other parameter must be {@code null}. + * @param enabled true if the user is enabled and allowed to access elasticsearch + * @param refreshPolicy the refresh policy for the request. + */ + private PutUserRequest(User user, @Nullable char[] password, @Nullable char[] passwordHash, boolean enabled, + RefreshPolicy refreshPolicy) { this.user = Objects.requireNonNull(user, "user is required, cannot be null"); + if (password != null && passwordHash != null) { + throw new IllegalArgumentException("cannot specify both password and passwordHash"); + } this.password = password; + this.passwordHash = passwordHash; this.enabled = enabled; this.refreshPolicy = refreshPolicy == null ? RefreshPolicy.getDefault() : refreshPolicy; } @@ -82,6 +142,7 @@ public final class PutUserRequest implements Validatable, ToXContentObject { final PutUserRequest that = (PutUserRequest) o; return Objects.equals(user, that.user) && Arrays.equals(password, that.password) + && Arrays.equals(passwordHash, that.passwordHash) && enabled == that.enabled && refreshPolicy == that.refreshPolicy; } @@ -90,6 +151,7 @@ public final class PutUserRequest implements Validatable, ToXContentObject { public int hashCode() { int result = Objects.hash(user, enabled, refreshPolicy); result = 31 * result + Arrays.hashCode(password); + result = 31 * result + Arrays.hashCode(passwordHash); return result; } @@ -108,12 +170,10 @@ public final class PutUserRequest implements Validatable, ToXContentObject { builder.startObject(); builder.field("username", user.getUsername()); if (password != null) { - byte[] charBytes = CharArrays.toUtf8Bytes(password); - try { - builder.field("password").utf8Value(charBytes, 0, charBytes.length); - } finally { - Arrays.fill(charBytes, (byte) 0); - } + charField(builder, "password", password); + } + if (passwordHash != null) { + charField(builder, "password_hash", passwordHash); } builder.field("roles", user.getRoles()); if (user.getFullName() != null) { @@ -126,4 +186,13 @@ public final class PutUserRequest implements Validatable, ToXContentObject { builder.field("enabled", enabled); return builder.endObject(); } + + private void charField(XContentBuilder builder, String fieldName, char[] chars) throws IOException { + byte[] charBytes = CharArrays.toUtf8Bytes(chars); + try { + builder.field(fieldName).utf8Value(charBytes, 0, charBytes.length); + } finally { + Arrays.fill(charBytes, (byte) 0); + } + } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index 8d36381eeaa..130ab83ebda 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -22,6 +22,7 @@ package org.elasticsearch.client.documentation; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.nio.entity.NStringEntity; +import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.LatchedActionListener; import org.elasticsearch.action.support.PlainActionFuture; @@ -78,7 +79,11 @@ import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.rest.RestStatus; import org.hamcrest.Matchers; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; import java.io.IOException; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -91,6 +96,7 @@ import java.util.concurrent.TimeUnit; import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.emptyIterable; @@ -106,10 +112,13 @@ public class SecurityDocumentationIT extends ESRestHighLevelClientTestCase { RestHighLevelClient client = highLevelClient(); { - //tag::put-user-execute + //tag::put-user-password-request char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'}; User user = new User("example", Collections.singletonList("superuser")); - PutUserRequest request = new PutUserRequest(user, password, true, RefreshPolicy.NONE); + PutUserRequest request = PutUserRequest.withPassword(user, password, true, RefreshPolicy.NONE); + //end::put-user-password-request + + //tag::put-user-execute PutUserResponse response = client.security().putUser(request, RequestOptions.DEFAULT); //end::put-user-execute @@ -119,11 +128,37 @@ public class SecurityDocumentationIT extends ESRestHighLevelClientTestCase { assertTrue(isCreated); } + { + byte[] salt = new byte[32]; + SecureRandom.getInstanceStrong().nextBytes(salt); + char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'}; + User user = new User("example2", Collections.singletonList("superuser")); + + //tag::put-user-hash-request + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2withHMACSHA512"); + PBEKeySpec keySpec = new PBEKeySpec(password, salt, 10000, 256); + final byte[] pbkdfEncoded = secretKeyFactory.generateSecret(keySpec).getEncoded(); + char[] passwordHash = ("{PBKDF2}10000$" + Base64.getEncoder().encodeToString(salt) + + "$" + Base64.getEncoder().encodeToString(pbkdfEncoded)).toCharArray(); + + PutUserRequest request = PutUserRequest.withPasswordHash(user, passwordHash, true, RefreshPolicy.NONE); + //end::put-user-hash-request + + try { + client.security().putUser(request, RequestOptions.DEFAULT); + } catch (ElasticsearchStatusException e) { + // This is expected to fail as the server will not be using PBKDF2, but that's easiest hasher to support + // in a standard JVM without introducing additional libraries. + assertThat(e.getDetailedMessage(), containsString("PBKDF2")); + } + } { - char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'}; - User user2 = new User("example2", Collections.singletonList("superuser")); - PutUserRequest request = new PutUserRequest(user2, password, true, RefreshPolicy.NONE); + User user = new User("example", Arrays.asList("superuser", "another-role")); + //tag::put-user-update-request + PutUserRequest request = PutUserRequest.updateUser(user, true, RefreshPolicy.NONE); + //end::put-user-update-request + // tag::put-user-execute-listener ActionListener listener = new ActionListener() { @Override diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/PutUserRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/PutUserRequestTests.java new file mode 100644 index 00000000000..76d3b283b0d --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/PutUserRequestTests.java @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.elasticsearch.client.security; + +import org.elasticsearch.client.security.user.User; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.ESTestCase; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +public class PutUserRequestTests extends ESTestCase { + + public void testBuildRequestWithPassword() throws Exception { + final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"), + Collections.singletonMap("status", "active"), "Clinton Barton", null); + final char[] password = "f@rmb0y".toCharArray(); + final PutUserRequest request = PutUserRequest.withPassword(user, password, true, RefreshPolicy.IMMEDIATE); + String json = Strings.toString(request); + final Map requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false); + assertThat(requestAsMap.get("username"), is("hawkeye")); + assertThat(requestAsMap.get("roles"), instanceOf(List.class)); + assertThat((List) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers")); + assertThat(requestAsMap.get("password"), is("f@rmb0y")); + assertThat(requestAsMap.containsKey("password_hash"), is(false)); + assertThat(requestAsMap.get("full_name"), is("Clinton Barton")); + assertThat(requestAsMap.containsKey("email"), is(false)); + assertThat(requestAsMap.get("enabled"), is(true)); + assertThat(requestAsMap.get("metadata"), instanceOf(Map.class)); + final Map metadata = (Map) requestAsMap.get("metadata"); + assertThat(metadata.size(), is(1)); + assertThat(metadata.get("status"), is("active")); + } + + public void testBuildRequestWithPasswordHash() throws Exception { + final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"), + Collections.singletonMap("status", "active"), "Clinton Barton", null); + final char[] passwordHash = "$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi".toCharArray(); + final PutUserRequest request = PutUserRequest.withPasswordHash(user, passwordHash, true, RefreshPolicy.IMMEDIATE); + String json = Strings.toString(request); + final Map requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false); + assertThat(requestAsMap.get("username"), is("hawkeye")); + assertThat(requestAsMap.get("roles"), instanceOf(List.class)); + assertThat((List) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers")); + assertThat(requestAsMap.get("password_hash"), is("$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi")); + assertThat(requestAsMap.containsKey("password"), is(false)); + assertThat(requestAsMap.get("full_name"), is("Clinton Barton")); + assertThat(requestAsMap.containsKey("email"), is(false)); + assertThat(requestAsMap.get("enabled"), is(true)); + assertThat(requestAsMap.get("metadata"), instanceOf(Map.class)); + final Map metadata = (Map) requestAsMap.get("metadata"); + assertThat(metadata.size(), is(1)); + assertThat(metadata.get("status"), is("active")); + } + + public void testBuildRequestForUpdateOnly() throws Exception { + final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"), + Collections.singletonMap("status", "active"), "Clinton Barton", null); + final char[] passwordHash = "$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi".toCharArray(); + final PutUserRequest request = PutUserRequest.updateUser(user, true, RefreshPolicy.IMMEDIATE); + String json = Strings.toString(request); + final Map requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false); + assertThat(requestAsMap.get("username"), is("hawkeye")); + assertThat(requestAsMap.get("roles"), instanceOf(List.class)); + assertThat((List) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers")); + assertThat(requestAsMap.containsKey("password"), is(false)); + assertThat(requestAsMap.containsKey("password_hash"), is(false)); + assertThat(requestAsMap.get("full_name"), is("Clinton Barton")); + assertThat(requestAsMap.containsKey("email"), is(false)); + assertThat(requestAsMap.get("enabled"), is(true)); + assertThat(requestAsMap.get("metadata"), instanceOf(Map.class)); + final Map metadata = (Map) requestAsMap.get("metadata"); + assertThat(metadata.size(), is(1)); + assertThat(metadata.get("status"), is("active")); + } + +} diff --git a/docs/java-rest/high-level/security/put-user.asciidoc b/docs/java-rest/high-level/security/put-user.asciidoc index aca69b81828..714dd61e119 100644 --- a/docs/java-rest/high-level/security/put-user.asciidoc +++ b/docs/java-rest/high-level/security/put-user.asciidoc @@ -1,19 +1,52 @@ -[[java-rest-high-security-put-user]] +-- +:api: put-user +:request: PutUserRequest +:response: PutUserResponse +-- + +[id="{upid}-{api}"] === Put User API -[[java-rest-high-security-put-user-execution]] -==== Execution +[id="{upid}-{api}-request"] +==== Put User Request Request -Creating and updating a user can be performed using the `security().putUser()` -method: +The +{request}+ class is used to create or update a user in the Native Realm. +There are 3 different factory methods for creating a request. + +===== Create or Update User with a Password + +If you wish to create a new user (or update an existing user) and directly specifying the user's new password, use the +`withPassword` method as shown below: ["source","java",subs="attributes,callouts,macros"] -------------------------------------------------- -include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute] +include-tagged::{doc-tests-file}[{api}-password-request] -------------------------------------------------- -[[java-rest-high-security-put-user-response]] -==== Response +===== Create or Update User with a Hashed Password + +If you wish to create a new user (or update an existing user) and perform password hashing on the client, +then use the `withPasswordHash` method: + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-hash-request] + +-------------------------------------------------- +===== Update a User without changing their password + +If you wish to update an existing user, and do not wish to change the user's password, +then use the `updateUserProperties` method: + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-update-request] +-------------------------------------------------- + +include::../execution.asciidoc[] + +[id="{upid}-{api}-response"] +==== Put User Response The returned `PutUserResponse` contains a single field, `created`. This field serves as an indication if a user was created or if an existing entry was updated. @@ -23,30 +56,3 @@ serves as an indication if a user was created or if an existing entry was update include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-response] -------------------------------------------------- <1> `created` is a boolean indicating whether the user was created or updated - -[[java-rest-high-security-put-user-async]] -==== Asynchronous Execution - -This request can be executed asynchronously: - -["source","java",subs="attributes,callouts,macros"] --------------------------------------------------- -include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute-async] --------------------------------------------------- -<1> The `PutUserRequest` to execute and the `ActionListener` to use when -the execution completes. - -The asynchronous method does not block and returns immediately. Once the request -has completed the `ActionListener` is called back using the `onResponse` method -if the execution successfully completed or using the `onFailure` method if -it failed. - -A typical listener for a `PutUserResponse` looks like: - -["source","java",subs="attributes,callouts,macros"] --------------------------------------------------- -include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute-listener] --------------------------------------------------- -<1> Called when the execution is successfully completed. The response is -provided as an argument. -<2> Called in case of failure. The raised exception is provided as an argument.