Skip to content

Commit 49da679

Browse files
committed
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.
1 parent 6e418b6 commit 49da679

File tree

4 files changed

+250
-39
lines changed

4 files changed

+250
-39
lines changed

client/rest-high-level/src/main/java/org/elasticsearch/client/security/PutUserRequest.java

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,46 @@ public final class PutUserRequest implements Validatable, ToXContentObject {
3939

4040
private final User user;
4141
private final @Nullable char[] password;
42+
private final @Nullable char[] passwordHash;
4243
private final boolean enabled;
4344
private final RefreshPolicy refreshPolicy;
4445

46+
/**
47+
* Create or update a user in the native realm, with the user's new or updated password specified in plaintext.
48+
* @param user the user to be created or updated
49+
* @param password the password of the user. The password array is not modified by this class.
50+
* It is the responsibility of the caller to clear the password after receiving
51+
* a response.
52+
* @param enabled true if the user is enabled and allowed to access elasticsearch
53+
* @param refreshPolicy the refresh policy for the request.
54+
*/
55+
public static PutUserRequest withPassword(User user, char[] password, boolean enabled, RefreshPolicy refreshPolicy) {
56+
return new PutUserRequest(user, password, null, enabled, refreshPolicy);
57+
}
58+
59+
/**
60+
* Create or update a user in the native realm, with the user's new or updated password specified as a cryptographic hash.
61+
* @param user the user to be created or updated
62+
* @param passwordHash the hash of the password of the user. It must be in the correct format for the password hashing algorithm in
63+
* use on this elasticsearch cluster. The array is not modified by this class.
64+
* It is the responsibility of the caller to clear the hash after receiving a response.
65+
* @param enabled true if the user is enabled and allowed to access elasticsearch
66+
* @param refreshPolicy the refresh policy for the request.
67+
*/
68+
public static PutUserRequest withPasswordHash(User user, char[] passwordHash, boolean enabled, RefreshPolicy refreshPolicy) {
69+
return new PutUserRequest(user, null, passwordHash, enabled, refreshPolicy);
70+
}
71+
72+
/**
73+
* Update an existing user in the native realm without modifying their password.
74+
* @param user the user to be created or updated
75+
* @param enabled true if the user is enabled and allowed to access elasticsearch
76+
* @param refreshPolicy the refresh policy for the request.
77+
*/
78+
public static PutUserRequest updateUser(User user, boolean enabled, RefreshPolicy refreshPolicy) {
79+
return new PutUserRequest(user, null, null, enabled, refreshPolicy);
80+
}
81+
4582
/**
4683
* Creates a new request that is used to create or update a user in the native realm.
4784
*
@@ -51,10 +88,33 @@ public final class PutUserRequest implements Validatable, ToXContentObject {
5188
* a response.
5289
* @param enabled true if the user is enabled and allowed to access elasticsearch
5390
* @param refreshPolicy the refresh policy for the request.
91+
* @deprecated Use {@link #withPassword(User, char[], boolean, RefreshPolicy)} or
92+
* {@link #updateUser(User, boolean, RefreshPolicy)} instead.
5493
*/
94+
@Deprecated
5595
public PutUserRequest(User user, @Nullable char[] password, boolean enabled, @Nullable RefreshPolicy refreshPolicy) {
96+
this(user, password, null, enabled, refreshPolicy);
97+
}
98+
99+
/**
100+
* Creates a new request that is used to create or update a user in the native realm.
101+
* @param user the user to be created or updated
102+
* @param password the password of the user. The password array is not modified by this class.
103+
* It is the responsibility of the caller to clear the password after receiving
104+
* a response.
105+
* @param passwordHash the hash of the password. Only one of "password" or "passwordHash" may be populated.
106+
* The other parameter must be {@code null}.
107+
* @param enabled true if the user is enabled and allowed to access elasticsearch
108+
* @param refreshPolicy the refresh policy for the request.
109+
*/
110+
private PutUserRequest(User user, @Nullable char[] password, @Nullable char[] passwordHash, boolean enabled,
111+
RefreshPolicy refreshPolicy) {
56112
this.user = Objects.requireNonNull(user, "user is required, cannot be null");
113+
if (password != null && passwordHash != null) {
114+
throw new IllegalArgumentException("cannot specify both password and passwordHash");
115+
}
57116
this.password = password;
117+
this.passwordHash = passwordHash;
58118
this.enabled = enabled;
59119
this.refreshPolicy = refreshPolicy == null ? RefreshPolicy.getDefault() : refreshPolicy;
60120
}
@@ -82,6 +142,7 @@ public boolean equals(Object o) {
82142
final PutUserRequest that = (PutUserRequest) o;
83143
return Objects.equals(user, that.user)
84144
&& Arrays.equals(password, that.password)
145+
&& Arrays.equals(passwordHash, that.passwordHash)
85146
&& enabled == that.enabled
86147
&& refreshPolicy == that.refreshPolicy;
87148
}
@@ -90,6 +151,7 @@ public boolean equals(Object o) {
90151
public int hashCode() {
91152
int result = Objects.hash(user, enabled, refreshPolicy);
92153
result = 31 * result + Arrays.hashCode(password);
154+
result = 31 * result + Arrays.hashCode(passwordHash);
93155
return result;
94156
}
95157

@@ -108,12 +170,10 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
108170
builder.startObject();
109171
builder.field("username", user.getUsername());
110172
if (password != null) {
111-
byte[] charBytes = CharArrays.toUtf8Bytes(password);
112-
try {
113-
builder.field("password").utf8Value(charBytes, 0, charBytes.length);
114-
} finally {
115-
Arrays.fill(charBytes, (byte) 0);
116-
}
173+
charField(builder, "password", password);
174+
}
175+
if (passwordHash != null) {
176+
charField(builder, "password_hash", passwordHash);
117177
}
118178
builder.field("roles", user.getRoles());
119179
if (user.getFullName() != null) {
@@ -126,4 +186,13 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
126186
builder.field("enabled", enabled);
127187
return builder.endObject();
128188
}
189+
190+
private void charField(XContentBuilder builder, String fieldName, char[] chars) throws IOException {
191+
byte[] charBytes = CharArrays.toUtf8Bytes(chars);
192+
try {
193+
builder.field(fieldName).utf8Value(charBytes, 0, charBytes.length);
194+
} finally {
195+
Arrays.fill(charBytes, (byte) 0);
196+
}
197+
}
129198
}

client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.apache.http.client.methods.HttpPost;
2323
import org.apache.http.entity.ContentType;
2424
import org.apache.http.nio.entity.NStringEntity;
25+
import org.elasticsearch.ElasticsearchStatusException;
2526
import org.elasticsearch.action.ActionListener;
2627
import org.elasticsearch.action.LatchedActionListener;
2728
import org.elasticsearch.action.support.PlainActionFuture;
@@ -80,7 +81,11 @@
8081
import org.elasticsearch.rest.RestStatus;
8182
import org.hamcrest.Matchers;
8283

84+
import javax.crypto.SecretKeyFactory;
85+
import javax.crypto.spec.PBEKeySpec;
8386
import java.io.IOException;
87+
import java.security.SecureRandom;
88+
import java.util.Base64;
8489
import java.util.Arrays;
8590
import java.util.Collections;
8691
import java.util.HashMap;
@@ -93,6 +98,7 @@
9398

9499
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
95100
import static org.hamcrest.Matchers.contains;
101+
import static org.hamcrest.Matchers.containsString;
96102
import static org.hamcrest.Matchers.containsInAnyOrder;
97103
import static org.hamcrest.Matchers.empty;
98104
import static org.hamcrest.Matchers.emptyIterable;
@@ -108,10 +114,13 @@ public void testPutUser() throws Exception {
108114
RestHighLevelClient client = highLevelClient();
109115

110116
{
111-
//tag::put-user-execute
117+
//tag::put-user-password-request
112118
char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
113119
User user = new User("example", Collections.singletonList("superuser"));
114-
PutUserRequest request = new PutUserRequest(user, password, true, RefreshPolicy.NONE);
120+
PutUserRequest request = PutUserRequest.withPassword(user, password, true, RefreshPolicy.NONE);
121+
//end::put-user-password-request
122+
123+
//tag::put-user-execute
115124
PutUserResponse response = client.security().putUser(request, RequestOptions.DEFAULT);
116125
//end::put-user-execute
117126

@@ -121,11 +130,37 @@ public void testPutUser() throws Exception {
121130

122131
assertTrue(isCreated);
123132
}
124-
125133
{
134+
byte[] salt = new byte[32];
135+
SecureRandom.getInstanceStrong().nextBytes(salt);
126136
char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
127-
User user2 = new User("example2", Collections.singletonList("superuser"));
128-
PutUserRequest request = new PutUserRequest(user2, password, true, RefreshPolicy.NONE);
137+
User user = new User("example2", Collections.singletonList("superuser"));
138+
139+
//tag::put-user-hash-request
140+
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2withHMACSHA512");
141+
PBEKeySpec keySpec = new PBEKeySpec(password, salt, 10000, 256);
142+
final byte[] pbkdfEncoded = secretKeyFactory.generateSecret(keySpec).getEncoded();
143+
char[] passwordHash = ("{PBKDF2}10000$" + Base64.getEncoder().encodeToString(salt)
144+
+ "$" + Base64.getEncoder().encodeToString(pbkdfEncoded)).toCharArray();
145+
146+
PutUserRequest request = PutUserRequest.withPasswordHash(user, passwordHash, true, RefreshPolicy.NONE);
147+
//end::put-user-hash-request
148+
149+
try {
150+
client.security().putUser(request, RequestOptions.DEFAULT);
151+
} catch (ElasticsearchStatusException e) {
152+
// This is expected to fail as the server will not be using PBKDF2, but that's easiest hasher to support
153+
// in a standard JVM without introducing additional libraries.
154+
assertThat(e.getDetailedMessage(), containsString("PBKDF2"));
155+
}
156+
}
157+
158+
{
159+
User user = new User("example", Arrays.asList("superuser", "another-role"));
160+
//tag::put-user-update-request
161+
PutUserRequest request = PutUserRequest.updateUser(user, true, RefreshPolicy.NONE);
162+
//end::put-user-update-request
163+
129164
// tag::put-user-execute-listener
130165
ActionListener<PutUserResponse> listener = new ActionListener<PutUserResponse>() {
131166
@Override
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.elasticsearch.client.security;
20+
21+
import org.elasticsearch.client.security.user.User;
22+
import org.elasticsearch.common.Strings;
23+
import org.elasticsearch.common.xcontent.XContentHelper;
24+
import org.elasticsearch.common.xcontent.XContentType;
25+
import org.elasticsearch.test.ESTestCase;
26+
27+
import java.util.Arrays;
28+
import java.util.Collections;
29+
import java.util.List;
30+
import java.util.Map;
31+
32+
import static org.hamcrest.Matchers.containsInAnyOrder;
33+
import static org.hamcrest.Matchers.instanceOf;
34+
import static org.hamcrest.Matchers.is;
35+
36+
public class PutUserRequestTests extends ESTestCase {
37+
38+
public void testBuildRequestWithPassword() throws Exception {
39+
final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"),
40+
Collections.singletonMap("status", "active"), "Clinton Barton", null);
41+
final char[] password = "f@rmb0y".toCharArray();
42+
final PutUserRequest request = PutUserRequest.withPassword(user, password, true, RefreshPolicy.IMMEDIATE);
43+
String json = Strings.toString(request);
44+
final Map<String, Object> requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false);
45+
assertThat(requestAsMap.get("username"), is("hawkeye"));
46+
assertThat(requestAsMap.get("roles"), instanceOf(List.class));
47+
assertThat((List<?>) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers"));
48+
assertThat(requestAsMap.get("password"), is("f@rmb0y"));
49+
assertThat(requestAsMap.containsKey("password_hash"), is(false));
50+
assertThat(requestAsMap.get("full_name"), is("Clinton Barton"));
51+
assertThat(requestAsMap.containsKey("email"), is(false));
52+
assertThat(requestAsMap.get("enabled"), is(true));
53+
assertThat(requestAsMap.get("metadata"), instanceOf(Map.class));
54+
final Map<?, ?> metadata = (Map<?, ?>) requestAsMap.get("metadata");
55+
assertThat(metadata.size(), is(1));
56+
assertThat(metadata.get("status"), is("active"));
57+
}
58+
59+
public void testBuildRequestWithPasswordHash() throws Exception {
60+
final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"),
61+
Collections.singletonMap("status", "active"), "Clinton Barton", null);
62+
final char[] passwordHash = "$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi".toCharArray();
63+
final PutUserRequest request = PutUserRequest.withPasswordHash(user, passwordHash, true, RefreshPolicy.IMMEDIATE);
64+
String json = Strings.toString(request);
65+
final Map<String, Object> requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false);
66+
assertThat(requestAsMap.get("username"), is("hawkeye"));
67+
assertThat(requestAsMap.get("roles"), instanceOf(List.class));
68+
assertThat((List<?>) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers"));
69+
assertThat(requestAsMap.get("password_hash"), is("$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi"));
70+
assertThat(requestAsMap.containsKey("password"), is(false));
71+
assertThat(requestAsMap.get("full_name"), is("Clinton Barton"));
72+
assertThat(requestAsMap.containsKey("email"), is(false));
73+
assertThat(requestAsMap.get("enabled"), is(true));
74+
assertThat(requestAsMap.get("metadata"), instanceOf(Map.class));
75+
final Map<?, ?> metadata = (Map<?, ?>) requestAsMap.get("metadata");
76+
assertThat(metadata.size(), is(1));
77+
assertThat(metadata.get("status"), is("active"));
78+
}
79+
80+
public void testBuildRequestForUpdateOnly() throws Exception {
81+
final User user = new User("hawkeye", Arrays.asList("kibana_user", "avengers"),
82+
Collections.singletonMap("status", "active"), "Clinton Barton", null);
83+
final char[] passwordHash = "$2a$04$iu1G4x3ZKVDNi6egZIjkFuIPja6elQXiBF1LdRVauV4TGog6FYOpi".toCharArray();
84+
final PutUserRequest request = PutUserRequest.updateUser(user, true, RefreshPolicy.IMMEDIATE);
85+
String json = Strings.toString(request);
86+
final Map<String, Object> requestAsMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), json, false);
87+
assertThat(requestAsMap.get("username"), is("hawkeye"));
88+
assertThat(requestAsMap.get("roles"), instanceOf(List.class));
89+
assertThat((List<?>) requestAsMap.get("roles"), containsInAnyOrder("kibana_user", "avengers"));
90+
assertThat(requestAsMap.containsKey("password"), is(false));
91+
assertThat(requestAsMap.containsKey("password_hash"), is(false));
92+
assertThat(requestAsMap.get("full_name"), is("Clinton Barton"));
93+
assertThat(requestAsMap.containsKey("email"), is(false));
94+
assertThat(requestAsMap.get("enabled"), is(true));
95+
assertThat(requestAsMap.get("metadata"), instanceOf(Map.class));
96+
final Map<?, ?> metadata = (Map<?, ?>) requestAsMap.get("metadata");
97+
assertThat(metadata.size(), is(1));
98+
assertThat(metadata.get("status"), is("active"));
99+
}
100+
101+
}

0 commit comments

Comments
 (0)