Skip to content

Commit b59cff4

Browse files
authored
Preserve ApiKey credentials for async verification (#51845)
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 63a2253 commit b59cff4

File tree

2 files changed

+124
-39
lines changed

2 files changed

+124
-39
lines changed

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

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -334,25 +334,16 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
334334
}
335335

336336
if (credentials != null) {
337-
final String docId = credentials.getId();
338-
final GetRequest getRequest = client.prepareGet(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE, docId)
339-
.setFetchSource(true).request();
340-
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> {
341-
if (response.isExists()) {
342-
try (ApiKeyCredentials ignore = credentials) {
343-
final Map<String, Object> source = response.getSource();
344-
validateApiKeyCredentials(docId, source, credentials, clock, listener);
345-
}
346-
} else {
337+
loadApiKeyAndValidateCredentials(ctx, credentials, ActionListener.wrap(
338+
response -> {
339+
credentials.close();
340+
listener.onResponse(response);
341+
},
342+
e -> {
347343
credentials.close();
348-
listener.onResponse(
349-
AuthenticationResult.unsuccessful("unable to find apikey with id " + credentials.getId(), null));
344+
listener.onFailure(e);
350345
}
351-
}, e -> {
352-
credentials.close();
353-
listener.onResponse(AuthenticationResult.unsuccessful("apikey authentication for id " + credentials.getId() +
354-
" encountered a failure", e));
355-
}), client::get);
346+
));
356347
} else {
357348
listener.onResponse(AuthenticationResult.notHandled());
358349
}
@@ -361,6 +352,27 @@ void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<Authentic
361352
}
362353
}
363354

355+
private void loadApiKeyAndValidateCredentials(ThreadContext ctx, ApiKeyCredentials credentials,
356+
ActionListener<AuthenticationResult> listener) {
357+
final String docId = credentials.getId();
358+
final GetRequest getRequest = client
359+
.prepareGet(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE, docId)
360+
.setFetchSource(true)
361+
.request();
362+
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> {
363+
if (response.isExists()) {
364+
final Map<String, Object> source = response.getSource();
365+
validateApiKeyCredentials(docId, source, credentials, clock, listener);
366+
} else {
367+
listener.onResponse(
368+
AuthenticationResult.unsuccessful("unable to find apikey with id " + credentials.getId(), null));
369+
}
370+
},
371+
e -> listener.onResponse(AuthenticationResult.unsuccessful(
372+
"apikey authentication for id " + credentials.getId() + " encountered a failure", e))),
373+
client::get);
374+
}
375+
364376
/**
365377
* The current request has been authenticated by an API key and this method enables the
366378
* retrieval of role descriptors that are associated with the api key
@@ -572,7 +584,8 @@ static ApiKeyCredentials getCredentialsFromHeader(ThreadContext threadContext) {
572584
return null;
573585
}
574586

575-
private static boolean verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials) {
587+
// Protected instance method so this can be mocked
588+
protected boolean verifyKeyAgainstHash(String apiKeyHash, ApiKeyCredentials credentials) {
576589
final char[] apiKeyHashChars = apiKeyHash.toCharArray();
577590
try {
578591
Hasher hasher = Hasher.resolveFromHash(apiKeyHash.toCharArray());

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

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
import java.util.HashMap;
7171
import java.util.Map;
7272
import java.util.concurrent.ExecutionException;
73+
import java.util.concurrent.Semaphore;
74+
import java.util.concurrent.atomic.AtomicInteger;
7375

7476
import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR;
7577
import static org.hamcrest.Matchers.arrayContaining;
@@ -508,16 +510,7 @@ public void testApiKeyCache() {
508510
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
509511
final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
510512

511-
Map<String, Object> sourceMap = new HashMap<>();
512-
sourceMap.put("doc_type", "api_key");
513-
sourceMap.put("api_key_hash", new String(hash));
514-
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
515-
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
516-
Map<String, Object> creatorMap = new HashMap<>();
517-
creatorMap.put("principal", "test_user");
518-
creatorMap.put("metadata", Collections.emptyMap());
519-
sourceMap.put("creator", creatorMap);
520-
sourceMap.put("api_key_invalidated", false);
513+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
521514

522515
ApiKeyService service = createApiKeyService(Settings.EMPTY);
523516
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
@@ -565,6 +558,64 @@ public void testApiKeyCache() {
565558
assertThat(service.getFromCache(creds.getId()).success, is(true));
566559
}
567560

561+
public void testAuthenticateWhileCacheBeingPopulated() throws Exception {
562+
final String apiKey = randomAlphaOfLength(16);
563+
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
564+
final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
565+
566+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
567+
568+
ApiKeyService realService = createApiKeyService(Settings.EMPTY);
569+
ApiKeyService service = Mockito.spy(realService);
570+
571+
// Used to block the hashing of the first api-key secret so that we can guarantee
572+
// that a second api key authentication takes place while hashing is "in progress".
573+
final Semaphore hashWait = new Semaphore(0);
574+
final AtomicInteger hashCounter = new AtomicInteger(0);
575+
doAnswer(invocationOnMock -> {
576+
hashCounter.incrementAndGet();
577+
hashWait.acquire();
578+
return invocationOnMock.callRealMethod();
579+
}).when(service).verifyKeyAgainstHash(any(String.class), any(ApiKeyCredentials.class));
580+
581+
final ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
582+
final PlainActionFuture<AuthenticationResult> future1 = new PlainActionFuture<>();
583+
584+
// Call the top level authenticate... method because it has been known to be buggy in async situations
585+
writeCredentialsToThreadContext(creds);
586+
mockSourceDocument(creds.getId(), sourceMap);
587+
588+
// 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
589+
this.threadPool.generic().execute(() -> service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future1));
590+
591+
// Wait for the first credential validation to get to the blocked state
592+
assertBusy(() -> assertThat(hashCounter.get(), equalTo(1)));
593+
if (future1.isDone()) {
594+
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message
595+
fail("Expected authentication to be blocked, but was " + future1.actionGet());
596+
}
597+
598+
// The second authentication should pass (but not immediately, but will not block)
599+
PlainActionFuture<AuthenticationResult> future2 = new PlainActionFuture<>();
600+
601+
service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future2);
602+
603+
assertThat(hashCounter.get(), equalTo(1));
604+
if (future2.isDone()) {
605+
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message
606+
fail("Expected authentication to be blocked, but was " + future2.actionGet());
607+
}
608+
609+
hashWait.release();
610+
611+
assertThat(future1.actionGet(TimeValue.timeValueSeconds(2)).isAuthenticated(), is(true));
612+
assertThat(future2.actionGet(TimeValue.timeValueMillis(100)).isAuthenticated(), is(true));
613+
614+
CachedApiKeyHashResult cachedApiKeyHashResult = service.getFromCache(creds.getId());
615+
assertNotNull(cachedApiKeyHashResult);
616+
assertThat(cachedApiKeyHashResult.success, is(true));
617+
}
618+
568619
public void testApiKeyCacheDisabled() {
569620
final String apiKey = randomAlphaOfLength(16);
570621
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT);
@@ -573,16 +624,7 @@ public void testApiKeyCacheDisabled() {
573624
.put(ApiKeyService.CACHE_TTL_SETTING.getKey(), "0s")
574625
.build();
575626

576-
Map<String, Object> sourceMap = new HashMap<>();
577-
sourceMap.put("doc_type", "api_key");
578-
sourceMap.put("api_key_hash", new String(hash));
579-
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
580-
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
581-
Map<String, Object> creatorMap = new HashMap<>();
582-
creatorMap.put("principal", "test_user");
583-
creatorMap.put("metadata", Collections.emptyMap());
584-
sourceMap.put("creator", creatorMap);
585-
sourceMap.put("api_key_invalidated", false);
627+
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash);
586628

587629
ApiKeyService service = createApiKeyService(settings);
588630
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray()));
@@ -594,10 +636,40 @@ public void testApiKeyCacheDisabled() {
594636
assertNull(cachedApiKeyHashResult);
595637
}
596638

597-
private ApiKeyService createApiKeyService(Settings settings) {
639+
private ApiKeyService createApiKeyService(Settings baseSettings) {
640+
final Settings settings = Settings.builder()
641+
.put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true)
642+
.put(baseSettings)
643+
.build();
598644
return new ApiKeyService(settings, Clock.systemUTC(), client, licenseState, securityIndex,
599645
ClusterServiceUtils.createClusterService(threadPool), threadPool);
600646
}
601647

648+
private Map<String, Object> buildApiKeySourceDoc(char[] hash) {
649+
Map<String, Object> sourceMap = new HashMap<>();
650+
sourceMap.put("doc_type", "api_key");
651+
sourceMap.put("api_key_hash", new String(hash));
652+
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all")));
653+
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all")));
654+
Map<String, Object> creatorMap = new HashMap<>();
655+
creatorMap.put("principal", "test_user");
656+
creatorMap.put("metadata", Collections.emptyMap());
657+
sourceMap.put("creator", creatorMap);
658+
sourceMap.put("api_key_invalidated", false);
659+
return sourceMap;
660+
}
661+
662+
private void writeCredentialsToThreadContext(ApiKeyCredentials creds) {
663+
final String credentialString = creds.getId() + ":" + creds.getKey();
664+
this.threadPool.getThreadContext().putHeader("Authorization",
665+
"ApiKey " + Base64.getEncoder().encodeToString(credentialString.getBytes(StandardCharsets.US_ASCII)));
666+
}
667+
668+
private void mockSourceDocument(String id, Map<String, Object> sourceMap) throws IOException {
669+
try (XContentBuilder builder = JsonXContent.contentBuilder()) {
670+
builder.map(sourceMap);
671+
SecurityMocks.mockGetRequest(client, id, BytesReference.bytes(builder));
672+
}
673+
}
602674

603675
}

0 commit comments

Comments
 (0)