Skip to content

Commit 17d8d61

Browse files
committed
HLRC: Add ability to put user with a password hash
Update PutUserRequest to support password_hash (see: elastic#35242) This also updates the documentation to bring it in line with our more recent approach to HLRC docs.
1 parent 95a09ab commit 17d8d61

File tree

3 files changed

+149
-39
lines changed

3 files changed

+149
-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;
@@ -68,7 +69,11 @@
6869
import org.elasticsearch.rest.RestStatus;
6970
import org.hamcrest.Matchers;
7071

72+
import javax.crypto.SecretKeyFactory;
73+
import javax.crypto.spec.PBEKeySpec;
7174
import java.io.IOException;
75+
import java.security.SecureRandom;
76+
import java.util.Base64;
7277
import java.util.Collections;
7378
import java.util.HashMap;
7479
import java.util.Iterator;
@@ -79,6 +84,7 @@
7984

8085
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
8186
import static org.hamcrest.Matchers.contains;
87+
import static org.hamcrest.Matchers.containsString;
8288
import static org.hamcrest.Matchers.empty;
8389
import static org.hamcrest.Matchers.equalTo;
8490
import static org.hamcrest.Matchers.is;
@@ -92,10 +98,13 @@ public void testPutUser() throws Exception {
9298
RestHighLevelClient client = highLevelClient();
9399

94100
{
95-
//tag::put-user-execute
101+
//tag::put-user-password-request
96102
char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
97103
User user = new User("example", Collections.singletonList("superuser"));
98-
PutUserRequest request = new PutUserRequest(user, password, true, RefreshPolicy.NONE);
104+
PutUserRequest request = PutUserRequest.withPassword(user, password, true, RefreshPolicy.NONE);
105+
//end::put-user-password-request
106+
107+
//tag::put-user-execute
99108
PutUserResponse response = client.security().putUser(request, RequestOptions.DEFAULT);
100109
//end::put-user-execute
101110

@@ -105,11 +114,37 @@ public void testPutUser() throws Exception {
105114

106115
assertTrue(isCreated);
107116
}
108-
109117
{
118+
byte[] salt = new byte[32];
119+
SecureRandom.getInstanceStrong().nextBytes(salt);
110120
char[] password = new char[]{'p', 'a', 's', 's', 'w', 'o', 'r', 'd'};
111-
User user2 = new User("example2", Collections.singletonList("superuser"));
112-
PutUserRequest request = new PutUserRequest(user2, password, true, RefreshPolicy.NONE);
121+
User user = new User("example2", Collections.singletonList("superuser"));
122+
123+
//tag::put-user-hash-request
124+
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2withHMACSHA512");
125+
PBEKeySpec keySpec = new PBEKeySpec(password, salt, 10000, 256);
126+
final byte[] pbkdfEncoded = secretKeyFactory.generateSecret(keySpec).getEncoded();
127+
char[] passwordHash = ("{PBKDF2}10000$" + Base64.getEncoder().encodeToString(salt)
128+
+ "$" + Base64.getEncoder().encodeToString(pbkdfEncoded)).toCharArray();
129+
130+
PutUserRequest request = PutUserRequest.withPasswordHash(user, passwordHash, true, RefreshPolicy.NONE);
131+
//end::put-user-hash-request
132+
133+
try {
134+
client.security().putUser(request, RequestOptions.DEFAULT);
135+
} catch (ElasticsearchStatusException e) {
136+
// This is expected to fail as the server will not be using PBKDF2, but that's easiest hasher to support
137+
// in a standard JVM without introducing additional libraries.
138+
assertThat(e.getDetailedMessage(), containsString("PBKDF2"));
139+
}
140+
}
141+
142+
{
143+
User user = new User("example", Collections.singletonList("superuser"));
144+
//tag::put-user-update-request
145+
PutUserRequest request = PutUserRequest.updateUser(user, true, RefreshPolicy.NONE);
146+
//end::put-user-update-request
147+
113148
// tag::put-user-execute-listener
114149
ActionListener<PutUserResponse> listener = new ActionListener<PutUserResponse>() {
115150
@Override
Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,58 @@
1-
[[java-rest-high-security-put-user]]
1+
--
2+
:api: put-user
3+
:request: PutUserRequest
4+
:response: PutUserResponse
5+
--
6+
7+
[id="{upid}-{api}"]
28
=== Put User API
39

4-
[[java-rest-high-security-put-user-execution]]
5-
==== Execution
10+
[id="{upid}-{api}-request"]
11+
==== Put User Request Request
12+
13+
The +{request}+ class is used to create or update a user in the Native Realm.
14+
There are 3 different factory methods for creating a request.
615

7-
Creating and updating a user can be performed using the `security().putUser()`
8-
method:
16+
===== Create or Update User with a Password
17+
18+
If you wish to create a new user (or update an existing user) and directly specifying the user's new password, use the
19+
`withPassword` method as shown below:
920

1021
["source","java",subs="attributes,callouts,macros"]
1122
--------------------------------------------------
12-
include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute]
23+
include-tagged::{doc-tests-file}[{api}-password-request]
1324
--------------------------------------------------
1425

15-
[[java-rest-high-security-put-user-response]]
16-
==== Response
26+
===== Create or Update User with a Hashed Password
1727

18-
The returned `PutUserResponse` contains a single field, `created`. This field
19-
serves as an indication if a user was created or if an existing entry was updated.
28+
If you wish to create a new user (or update an existing user) and perform password hashing on the client,
29+
then use the `withPasswordHash` method:
2030

2131
["source","java",subs="attributes,callouts,macros"]
2232
--------------------------------------------------
23-
include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-response]
24-
--------------------------------------------------
25-
<1> `created` is a boolean indicating whether the user was created or updated
33+
include-tagged::{doc-tests-file}[{api}-hash-request]
2634
27-
[[java-rest-high-security-put-user-async]]
28-
==== Asynchronous Execution
35+
--------------------------------------------------
36+
===== Update a User without changing their password
2937

30-
This request can be executed asynchronously:
38+
If you wish to update an existing user, and do not wish to change the user's password,
39+
then use the `updateUserProperties` method:
3140

3241
["source","java",subs="attributes,callouts,macros"]
3342
--------------------------------------------------
34-
include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute-async]
43+
include-tagged::{doc-tests-file}[{api}-update-request]
3544
--------------------------------------------------
36-
<1> The `PutUserRequest` to execute and the `ActionListener` to use when
37-
the execution completes.
3845

39-
The asynchronous method does not block and returns immediately. Once the request
40-
has completed the `ActionListener` is called back using the `onResponse` method
41-
if the execution successfully completed or using the `onFailure` method if
42-
it failed.
46+
include::../execution.asciidoc[]
4347

44-
A typical listener for a `PutUserResponse` looks like:
48+
[id="{upid}-{api}-response"]
49+
==== Put User Response
50+
51+
The returned `PutUserResponse` contains a single field, `created`. This field
52+
serves as an indication if a user was created or if an existing entry was updated.
4553

4654
["source","java",subs="attributes,callouts,macros"]
4755
--------------------------------------------------
48-
include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-execute-listener]
56+
include-tagged::{doc-tests}/SecurityDocumentationIT.java[put-user-response]
4957
--------------------------------------------------
50-
<1> Called when the execution is successfully completed. The response is
51-
provided as an argument.
52-
<2> Called in case of failure. The raised exception is provided as an argument.
58+
<1> `created` is a boolean indicating whether the user was created or updated

0 commit comments

Comments
 (0)