Skip to content

Commit 5afc759

Browse files
authored
Preserve ApiKey credentials for async verification (#51797)
The ApiKeyService would aggressively "close" ApiKeyCredentials objects during processing. However, under rare circumstances, the verfication of the secret key would be performed asychronously and may need access to the SecureString after it had been closed by the caller. The trigger for this would be if the cache already held a Future for that ApiKey, but the future was not yet complete. In this case the verification of the secret key would take place asynchronously on the generic thread pool. This commit moves the "close" of the credentials to the body of the listener so that it only occurs after key verification is complete. Backport of: #51244
1 parent 3655fc6 commit 5afc759

File tree

2 files changed

+126
-41
lines changed

2 files changed

+126
-41
lines changed

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -300,27 +300,16 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
300300
}
301301

302302
if (credentials != null) {
303-
final String docId = credentials.getId();
304-
final GetRequest getRequest = client
305-
.prepareGet(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME, docId)
306-
.setFetchSource(true)
307-
.request();
308-
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> {
309-
if (response.isExists()) {
310-
try (ApiKeyCredentials ignore = credentials) {
311-
final Map<String, Object> source = response.getSource();
312-
validateApiKeyCredentials(docId, source, credentials, clock, listener);
313-
}
314-
} else {
303+
loadApiKeyAndValidateCredentials(ctx, credentials, ActionListener.wrap(
304+
response -> {
315305
credentials.close();
316-
listener.onResponse(
317-
AuthenticationResult.unsuccessful("unable to find apikey with id " + credentials.getId(), null));
306+
listener.onResponse(response);
307+
},
308+
e -> {
309+
credentials.close();
310+
listener.onFailure(e);
318311
}
319-
}, e -> {
320-
credentials.close();
321-
listener.onResponse(AuthenticationResult.unsuccessful("apikey authentication for id " + credentials.getId() +
322-
" encountered a failure", e));
323-
}), client::get);
312+
));
324313
} else {
325314
listener.onResponse(AuthenticationResult.notHandled());
326315
}
@@ -329,6 +318,27 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
329318
}
330319
}
331320

321+
private void loadApiKeyAndValidateCredentials(ThreadContext ctx, ApiKeyCredentials credentials,
322+
ActionListener<AuthenticationResult> listener) {
323+
final String docId = credentials.getId();
324+
final GetRequest getRequest = client
325+
.prepareGet(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME, docId)
326+
.setFetchSource(true)
327+
.request();
328+
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> {
329+
if (response.isExists()) {
330+
final Map<String, Object> source = response.getSource();
331+
validateApiKeyCredentials(docId, source, credentials, clock, listener);
332+
} else {
333+
listener.onResponse(
334+
AuthenticationResult.unsuccessful("unable to find apikey with id " + credentials.getId(), null));
335+
}
336+
},
337+
e -> listener.onResponse(AuthenticationResult.unsuccessful(
338+
"apikey authentication for id " + credentials.getId() + " encountered a failure", e))),
339+
client::get);
340+
}
341+
332342
/**
333343
* The current request has been authenticated by an API key and this method enables the
334344
* retrieval of role descriptors that are associated with the api key
@@ -541,7 +551,8 @@ static ApiKeyCredentials getCredentialsFromHeader(ThreadContext threadContext) {
541551
return null;
542552
}
543553

544-
private static boolean verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials) {
554+
// Protected instance method so this can be mocked
555+
protected boolean verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials) {
545556
final char[] apiKeyHashChars = apiKeyHash.toCharArray();
546557
try {
547558
Hasher hasher = Hasher.resolveFromHash(apiKeyHash.toCharArray());

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.elasticsearch.common.bytes.BytesReference;
1414
import org.elasticsearch.common.settings.SecureString;
1515
import org.elasticsearch.common.settings.Settings;
16+
import org.elasticsearch.common.unit.TimeValue;
1617
import org.elasticsearch.common.util.concurrent.ThreadContext;
1718
import org.elasticsearch.common.xcontent.ToXContent;
1819
import org.elasticsearch.common.xcontent.XContentBuilder;
@@ -41,6 +42,7 @@
4142
import org.elasticsearch.xpack.security.test.SecurityMocks;
4243
import org.junit.After;
4344
import org.junit.Before;
45+
import org.mockito.Mockito;
4446

4547
import java.io.IOException;
4648
import java.nio.charset.StandardCharsets;
@@ -54,6 +56,8 @@
5456
import java.util.Collections;
5557
import java.util.HashMap;
5658
import java.util.Map;
59+
import java.util.concurrent.Semaphore;
60+
import java.util.concurrent.atomic.AtomicInteger;
5761

5862
import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR;
5963
import static org.hamcrest.Matchers.arrayContaining;
@@ -431,16 +435,7 @@ public void testApiKeyCache() {
431435
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
432436
final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
433437

434-
Map<String, Object> sourceMap = new HashMap<>();
435-
sourceMap.put("doc_type", "api_key");
436-
sourceMap.put("api_key_hash", new String(hash));
437-
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
438-
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
439-
Map<String, Object> creatorMap = new HashMap<>();
440-
creatorMap.put("principal", "test_user");
441-
creatorMap.put("metadata", Collections.emptyMap());
442-
sourceMap.put("creator", creatorMap);
443-
sourceMap.put("api_key_invalidated", false);
438+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
444439

445440
ApiKeyService service = createApiKeyService(Settings.EMPTY);
446441
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
@@ -488,6 +483,64 @@ public void testApiKeyCache() {
488483
assertThat(service.getFromCache(creds.getId()).success, is(true));
489484
}
490485

486+
public void testAuthenticateWhileCacheBeingPopulated() throws Exception {
487+
final String apiKey = randomAlphaOfLength(16);
488+
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
489+
final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
490+
491+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
492+
493+
ApiKeyService realService = createApiKeyService(Settings.EMPTY);
494+
ApiKeyService service = Mockito.spy(realService);
495+
496+
// Used to block the hashing of the first api-key secret so that we can guarantee
497+
// that a second api key authentication takes place while hashing is "in progress".
498+
final Semaphore hashWait = new Semaphore(0);
499+
final AtomicInteger hashCounter = new AtomicInteger(0);
500+
doAnswer(invocationOnMock -> {
501+
hashCounter.incrementAndGet();
502+
hashWait.acquire();
503+
return invocationOnMock.callRealMethod();
504+
}).when(service).verifyKeyAgainstHash(any(String.class), any(ApiKeyCredentials.class));
505+
506+
final ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
507+
final PlainActionFuture<AuthenticationResult> future1 = new PlainActionFuture<>();
508+
509+
// Call the top level authenticate... method because it has been known to be buggy in async situations
510+
writeCredentialsToThreadContext(creds);
511+
mockSourceDocument(creds.getId(), sourceMap);
512+
513+
// This needs to be done in another thread, because we need it to not complete until we say so, but it should not block this test
514+
this.threadPool.generic().execute(() -> service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future1));
515+
516+
// Wait for the first credential validation to get to the blocked state
517+
assertBusy(() -> assertThat(hashCounter.get(), equalTo(1)));
518+
if (future1.isDone()) {
519+
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message
520+
fail("Expected authentication to be blocked, but was " + future1.actionGet());
521+
}
522+
523+
// The second authentication should pass (but not immediately, but will not block)
524+
PlainActionFuture<AuthenticationResult> future2 = new PlainActionFuture<>();
525+
526+
service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future2);
527+
528+
assertThat(hashCounter.get(), equalTo(1));
529+
if (future2.isDone()) {
530+
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message
531+
fail("Expected authentication to be blocked, but was " + future2.actionGet());
532+
}
533+
534+
hashWait.release();
535+
536+
assertThat(future1.actionGet(TimeValue.timeValueSeconds(2)).isAuthenticated(), is(true));
537+
assertThat(future2.actionGet(TimeValue.timeValueMillis(100)).isAuthenticated(), is(true));
538+
539+
CachedApiKeyHashResult cachedApiKeyHashResult = service.getFromCache(creds.getId());
540+
assertNotNull(cachedApiKeyHashResult);
541+
assertThat(cachedApiKeyHashResult.success, is(true));
542+
}
543+
491544
public void testApiKeyCacheDisabled() {
492545
final String apiKey = randomAlphaOfLength(16);
493546
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
@@ -496,16 +549,7 @@ public void testApiKeyCacheDisabled() {
496549
.put(ApiKeyService.CACHE_TTL_SETTING.getKey(), "0s")
497550
.build();
498551

499-
Map<String, Object> sourceMap = new HashMap<>();
500-
sourceMap.put("doc_type", "api_key");
501-
sourceMap.put("api_key_hash", new String(hash));
502-
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
503-
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
504-
Map<String, Object> creatorMap = new HashMap<>();
505-
creatorMap.put("principal", "test_user");
506-
creatorMap.put("metadata", Collections.emptyMap());
507-
sourceMap.put("creator", creatorMap);
508-
sourceMap.put("api_key_invalidated", false);
552+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
509553

510554
ApiKeyService service = createApiKeyService(settings);
511555
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
@@ -517,10 +561,40 @@ public void testApiKeyCacheDisabled() {
517561
assertNull(cachedApiKeyHashResult);
518562
}
519563

520-
private ApiKeyService createApiKeyService(Settings settings) {
564+
private ApiKeyService createApiKeyService(Settings baseSettings) {
565+
final Settings settings = Settings.builder()
566+
.put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true)
567+
.put(baseSettings)
568+
.build();
521569
return new ApiKeyService(settings, Clock.systemUTC(), client, licenseState, securityIndex,
522570
ClusterServiceUtils.createClusterService(threadPool), threadPool);
523571
}
524572

573+
private Map<String, Object> buildApiKeySourceDoc(char[] hash) {
574+
Map<String, Object> sourceMap = new HashMap<>();
575+
sourceMap.put("doc_type", "api_key");
576+
sourceMap.put("api_key_hash", new String(hash));
577+
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
578+
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
579+
Map<String, Object> creatorMap = new HashMap<>();
580+
creatorMap.put("principal", "test_user");
581+
creatorMap.put("metadata", Collections.emptyMap());
582+
sourceMap.put("creator", creatorMap);
583+
sourceMap.put("api_key_invalidated", false);
584+
return sourceMap;
585+
}
586+
587+
private void writeCredentialsToThreadContext(ApiKeyCredentials creds) {
588+
final String credentialString = creds.getId() + ":" + creds.getKey();
589+
this.threadPool.getThreadContext().putHeader("Authorization",
590+
"ApiKey " + Base64.getEncoder().encodeToString(credentialString.getBytes(StandardCharsets.US_ASCII)));
591+
}
592+
593+
private void mockSourceDocument(String id, Map<String, Object> sourceMap) throws IOException {
594+
try (XContentBuilder builder = JsonXContent.contentBuilder()) {
595+
builder.map(sourceMap);
596+
SecurityMocks.mockGetRequest(client, id, BytesReference.bytes(builder));
597+
}
598+
}
525599

526600
}

0 commit comments

Comments
 (0)