Skip to content

Commit 231f6c1

Browse files
authored
Formal support for "password_hash" in Put User (#35242)
For some time, the PutUser REST API has supported storing a pre-hashed password for a user. The change adds validation and tests around that feature so that it can be documented & officially supported. It also prevents the request from containing both a "password" and a "password_hash".
1 parent d8b1c23 commit 231f6c1

File tree

3 files changed

+139
-5
lines changed

3 files changed

+139
-5
lines changed

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/PutUserRequestBuilder.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ public PutUserRequestBuilder password(char[] password, Hasher hasher) {
5454
if (password != null) {
5555
Validation.Error error = Validation.Users.validatePassword(password);
5656
if (error != null) {
57-
ValidationException validationException = new ValidationException();
58-
validationException.addValidationError(error.toString());
59-
throw validationException;
57+
throw validationException(error.toString());
58+
}
59+
if (request.passwordHash() != null) {
60+
throw validationException("password_hash has already been set");
6061
}
6162
request.passwordHash(hasher.hash(new SecureString(password)));
6263
} else {
@@ -80,7 +81,15 @@ public PutUserRequestBuilder email(String email) {
8081
return this;
8182
}
8283

83-
public PutUserRequestBuilder passwordHash(char[] passwordHash) {
84+
public PutUserRequestBuilder passwordHash(char[] passwordHash, Hasher configuredHasher) {
85+
final Hasher resolvedHasher = Hasher.resolveFromHash(passwordHash);
86+
if (resolvedHasher.equals(configuredHasher) == false) {
87+
throw new IllegalArgumentException("Provided password hash uses [" + resolvedHasher
88+
+ "] but the configured hashing algorithm is [" + configuredHasher + "]");
89+
}
90+
if (request.passwordHash() != null) {
91+
throw validationException("password_hash has already been set");
92+
}
8493
request.passwordHash(passwordHash);
8594
return this;
8695
}
@@ -120,7 +129,7 @@ public PutUserRequestBuilder source(String username, BytesReference source, XCon
120129
} else if (User.Fields.PASSWORD_HASH.match(currentFieldName, parser.getDeprecationHandler())) {
121130
if (token == XContentParser.Token.VALUE_STRING) {
122131
char[] passwordChars = parser.text().toCharArray();
123-
passwordHash(passwordChars);
132+
passwordHash(passwordChars, hasher);
124133
} else {
125134
throw new ElasticsearchParseException(
126135
"expected field [{}] to be of type string, but found [{}] instead", currentFieldName, token);
@@ -177,4 +186,9 @@ public PutUserRequestBuilder source(String username, BytesReference source, XCon
177186
}
178187
}
179188

189+
private ValidationException validationException(String abc) {
190+
ValidationException validationException = new ValidationException();
191+
validationException.addValidationError(abc);
192+
return validationException;
193+
}
180194
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/PutUserRequestBuilderTests.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import org.elasticsearch.ElasticsearchParseException;
99
import org.elasticsearch.client.Client;
10+
import org.elasticsearch.common.ValidationException;
1011
import org.elasticsearch.common.bytes.BytesArray;
12+
import org.elasticsearch.common.bytes.BytesReference;
13+
import org.elasticsearch.common.settings.SecureString;
14+
import org.elasticsearch.common.xcontent.XContentBuilder;
1115
import org.elasticsearch.common.xcontent.XContentType;
1216
import org.elasticsearch.test.ESTestCase;
1317
import org.elasticsearch.xpack.core.security.action.user.PutUserRequest;
@@ -16,9 +20,12 @@
1620

1721
import java.io.IOException;
1822
import java.nio.charset.StandardCharsets;
23+
import java.util.Collections;
24+
import java.util.LinkedHashMap;
1925

2026
import static org.hamcrest.Matchers.arrayContaining;
2127
import static org.hamcrest.Matchers.containsString;
28+
import static org.hamcrest.Matchers.equalTo;
2229
import static org.hamcrest.Matchers.is;
2330
import static org.hamcrest.Matchers.nullValue;
2431
import static org.mockito.Mockito.mock;
@@ -135,4 +142,69 @@ public void testWithEnabled() throws IOException {
135142
builder.source("kibana4", new BytesArray(json.getBytes(StandardCharsets.UTF_8)), XContentType.JSON, Hasher.BCRYPT).request();
136143
assertFalse(request.enabled());
137144
}
145+
146+
public void testWithValidPasswordHash() throws IOException {
147+
final Hasher hasher = Hasher.BCRYPT4; // this is the fastest hasher we officially support
148+
final char[] hash = hasher.hash(new SecureString("secret".toCharArray()));
149+
final String json = "{\n" +
150+
" \"password_hash\": \"" + new String(hash) + "\"," +
151+
" \"roles\": []\n" +
152+
"}";
153+
154+
PutUserRequestBuilder requestBuilder = new PutUserRequestBuilder(mock(Client.class));
155+
PutUserRequest request = requestBuilder.source("hash_user",
156+
new BytesArray(json.getBytes(StandardCharsets.UTF_8)), XContentType.JSON, hasher).request();
157+
assertThat(request.passwordHash(), equalTo(hash));
158+
assertThat(request.username(), equalTo("hash_user"));
159+
}
160+
161+
public void testWithMismatchedPasswordHash() throws IOException {
162+
final Hasher systemHasher = Hasher.BCRYPT8;
163+
final Hasher userHasher = Hasher.BCRYPT4; // this is the fastest hasher we officially support
164+
final char[] hash = userHasher.hash(new SecureString("secret".toCharArray()));
165+
final String json = "{\n" +
166+
" \"password_hash\": \"" + new String(hash) + "\"," +
167+
" \"roles\": []\n" +
168+
"}";
169+
170+
PutUserRequestBuilder builder = new PutUserRequestBuilder(mock(Client.class));
171+
final IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> {
172+
builder.source("hash_user", new BytesArray(json.getBytes(StandardCharsets.UTF_8)), XContentType.JSON, systemHasher).request();
173+
});
174+
assertThat(ex.getMessage(), containsString(userHasher.name()));
175+
assertThat(ex.getMessage(), containsString(systemHasher.name()));
176+
}
177+
178+
public void testWithPasswordHashThatsNotReallyAHash() throws IOException {
179+
final Hasher systemHasher = Hasher.PBKDF2;
180+
final String json = "{\n" +
181+
" \"password_hash\": \"not-a-hash\"," +
182+
" \"roles\": []\n" +
183+
"}";
184+
185+
PutUserRequestBuilder builder = new PutUserRequestBuilder(mock(Client.class));
186+
final IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, () -> {
187+
builder.source("hash_user", new BytesArray(json.getBytes(StandardCharsets.UTF_8)), XContentType.JSON, systemHasher).request();
188+
});
189+
assertThat(ex.getMessage(), containsString(Hasher.NOOP.name()));
190+
assertThat(ex.getMessage(), containsString(systemHasher.name()));
191+
}
192+
193+
public void testWithBothPasswordAndHash() throws IOException {
194+
final Hasher hasher = randomFrom(Hasher.BCRYPT4, Hasher.PBKDF2_1000);
195+
final String password = randomAlphaOfLength(12);
196+
final char[] hash = hasher.hash(new SecureString(password.toCharArray()));
197+
final LinkedHashMap<String, Object> fields = new LinkedHashMap<>();
198+
fields.put("password", password);
199+
fields.put("password_hash", new String(hash));
200+
fields.put("roles", Collections.emptyList());
201+
BytesReference json = BytesReference.bytes(XContentBuilder.builder(XContentType.JSON.xContent())
202+
.map(shuffleMap(fields, Collections.emptySet())));
203+
204+
PutUserRequestBuilder builder = new PutUserRequestBuilder(mock(Client.class));
205+
final IllegalArgumentException ex = expectThrows(ValidationException.class, () -> {
206+
builder.source("hash_user", json, XContentType.JSON, hasher).request();
207+
});
208+
assertThat(ex.getMessage(), containsString("password_hash has already been set"));
209+
}
138210
}

x-pack/plugin/src/test/resources/rest-api-spec/test/users/10_basic.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ teardown:
1313
xpack.security.delete_user:
1414
username: "joe"
1515
ignore: 404
16+
- do:
17+
xpack.security.delete_user:
18+
username: "bob"
19+
ignore: 404
1620

1721
---
1822
"Test put user api":
@@ -101,3 +105,47 @@ teardown:
101105
"key2" : "val2"
102106
}
103107
}
108+
---
109+
"Test put user with password hash":
110+
111+
# Mostly this chain of put_user , search index, set value is to work around the fact that the
112+
# rest tests treat anything with a leading "$" as a stashed value, and bcrypt passwords start with "$"
113+
# But it has the nice side effect of automatically adjusting to any changes in the default hasher for
114+
# the ES cluster
115+
- do:
116+
xpack.security.put_user:
117+
username: "bob"
118+
body: >
119+
{
120+
"password" : "correct horse battery staple",
121+
"roles" : [ ]
122+
}
123+
124+
- do:
125+
get:
126+
index: .security
127+
type: doc
128+
id: user-bob
129+
- set: { _source.password: "hash" }
130+
131+
- do:
132+
xpack.security.put_user:
133+
username: "joe"
134+
body: >
135+
{
136+
"password_hash" : "$hash",
137+
"roles" : [ "superuser" ]
138+
}
139+
140+
# base64("joe:correct horse battery staple") = "am9lOmNvcnJlY3QgaG9yc2UgYmF0dGVyeSBzdGFwbGU="
141+
- do:
142+
headers:
143+
Authorization: "Basic am9lOmNvcnJlY3QgaG9yc2UgYmF0dGVyeSBzdGFwbGU="
144+
xpack.security.authenticate: {}
145+
- match: { username: "joe" }
146+
147+
- do:
148+
catch: unauthorized
149+
headers:
150+
Authorization: "Basic am9lOnMza3JpdA=="
151+
xpack.security.authenticate: {}

0 commit comments

Comments
 (0)