Skip to content

Commit ebaa0f0

Browse files
authored
Cache API key hashing results on creation time (#74106) (#74161)
The API key hashing result is now cached on the creation time of an API key, i.e. pre-warm the cache. Previously it is cached when the API key is authenticated for the first time. Since it is reasonable to assume that an API key will be used shortly after its creation, this change has following advantages: * It removes the need for expensive pbkdf2 hashing computation on authentication time and therefore reduces overall server load * It makes the first authentication faster We expect all keys to be used, that is, caching on creation time does not change the total number of keys need to be cached. Hence this PR does not introduce any extra logic to fine tune whether a key should be cached (for example, only cache if the load factor is lower than certain threshold etc.).
1 parent 5564d52 commit ebaa0f0

File tree

3 files changed

+57
-6
lines changed

3 files changed

+57
-6
lines changed

x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,7 @@ public void testAuthenticationReturns429WhenThreadPoolIsSaturated() throws IOExc
10031003
final Settings settings = internalCluster().getInstance(Settings.class, nodeName);
10041004
final int allocatedProcessors = EsExecutors.allocatedProcessors(settings);
10051005
final ThreadPool threadPool = internalCluster().getInstance(ThreadPool.class, nodeName);
1006+
final ApiKeyService apiKeyService = internalCluster().getInstance(ApiKeyService.class, nodeName);
10061007

10071008
final RoleDescriptor descriptor = new RoleDescriptor("auth_only", new String[] { }, null, null);
10081009
final Client client = client().filterWithHeader(
@@ -1016,6 +1017,8 @@ public void testAuthenticationReturns429WhenThreadPoolIsSaturated() throws IOExc
10161017

10171018
assertNotNull(createApiKeyResponse.getId());
10181019
assertNotNull(createApiKeyResponse.getKey());
1020+
// Clear the auth cache to force recompute the expensive hash which requires the crypto thread pool
1021+
apiKeyService.getApiKeyAuthCache().invalidateAll();
10191022

10201023
final List<NodeInfo> nodeInfos = client().admin().cluster().prepareNodesInfo().get().getNodes().stream()
10211024
.filter(nodeInfo -> nodeInfo.getNode().getName().equals(nodeName))

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR
286286
Version.V_6_7_0);
287287
}
288288

289-
try (XContentBuilder builder = newDocument(apiKey, request.getName(), authentication, roleDescriptorSet, created, expiration,
289+
try (XContentBuilder builder = newDocument(apiKey, request.getName(), authentication,
290+
roleDescriptorSet, created, expiration,
290291
request.getRoleDescriptors(), version, request.getMetadata())) {
291292

292293
final IndexRequest indexRequest =
@@ -299,8 +300,13 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR
299300
securityIndex.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
300301
executeAsyncWithOrigin(client, SECURITY_ORIGIN, BulkAction.INSTANCE, bulkRequest,
301302
TransportSingleItemBulkWriteAction.<IndexResponse>wrapBulkResponse(ActionListener.wrap(
302-
indexResponse -> listener.onResponse(
303-
new CreateApiKeyResponse(request.getName(), indexResponse.getId(), apiKey, expiration)),
303+
indexResponse -> {
304+
final ListenableFuture<CachedApiKeyHashResult> listenableFuture = new ListenableFuture<>();
305+
listenableFuture.onResponse(new CachedApiKeyHashResult(true, apiKey));
306+
apiKeyAuthCache.put(indexResponse.getId(), listenableFuture);
307+
listener.onResponse(
308+
new CreateApiKeyResponse(request.getName(), indexResponse.getId(), apiKey, expiration));
309+
},
304310
listener::onFailure))));
305311
} catch (IOException e) {
306312
listener.onFailure(e);
@@ -311,15 +317,16 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR
311317
* package-private for testing
312318
*/
313319
XContentBuilder newDocument(SecureString apiKey, String name, Authentication authentication, Set<RoleDescriptor> userRoles,
314-
Instant created, Instant expiration, List<RoleDescriptor> keyRoles,
315-
Version version, @Nullable Map<String, Object> metadata) throws IOException {
320+
Instant created, Instant expiration, List<RoleDescriptor> keyRoles,
321+
Version version, @Nullable Map<String, Object> metadata) throws IOException {
316322
XContentBuilder builder = XContentFactory.jsonBuilder();
317323
builder.startObject()
318324
.field("doc_type", "api_key")
319325
.field("creation_time", created.toEpochMilli())
320326
.field("expiration_time", expiration == null ? null : expiration.toEpochMilli())
321327
.field("api_key_invalidated", false);
322328

329+
323330
byte[] utf8Bytes = null;
324331
final char[] keyHash = hasher.hash(apiKey);
325332
try {
@@ -1186,7 +1193,7 @@ final class CachedApiKeyHashResult {
11861193
this.hash = cacheHasher.hash(apiKey);
11871194
}
11881195

1189-
private boolean verify(SecureString password) {
1196+
boolean verify(SecureString password) {
11901197
return hash != null && cacheHasher.verify(password, hash);
11911198
}
11921199
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@
1010
import org.elasticsearch.ElasticsearchException;
1111
import org.elasticsearch.Version;
1212
import org.elasticsearch.action.ActionListener;
13+
import org.elasticsearch.action.DocWriteRequest;
1314
import org.elasticsearch.action.bulk.BulkAction;
15+
import org.elasticsearch.action.bulk.BulkItemResponse;
1416
import org.elasticsearch.action.bulk.BulkRequest;
17+
import org.elasticsearch.action.bulk.BulkResponse;
1518
import org.elasticsearch.action.get.GetRequest;
1619
import org.elasticsearch.action.index.IndexAction;
1720
import org.elasticsearch.action.index.IndexRequestBuilder;
21+
import org.elasticsearch.action.index.IndexResponse;
1822
import org.elasticsearch.action.support.PlainActionFuture;
1923
import org.elasticsearch.client.Client;
2024
import org.elasticsearch.common.bytes.BytesArray;
2125
import org.elasticsearch.common.bytes.BytesReference;
26+
import org.elasticsearch.common.cache.Cache;
2227
import org.elasticsearch.common.util.concurrent.ListenableFuture;
2328
import org.elasticsearch.core.Tuple;
2429
import org.elasticsearch.common.settings.SecureString;
@@ -35,6 +40,7 @@
3540
import org.elasticsearch.common.xcontent.XContentType;
3641
import org.elasticsearch.common.xcontent.json.JsonXContent;
3742
import org.elasticsearch.index.get.GetResult;
43+
import org.elasticsearch.index.shard.ShardId;
3844
import org.elasticsearch.license.XPackLicenseState;
3945
import org.elasticsearch.test.ClusterServiceUtils;
4046
import org.elasticsearch.test.ESTestCase;
@@ -170,6 +176,41 @@ public void testCreateApiKeyWillUseBulkAction() {
170176
verify(client).execute(eq(BulkAction.INSTANCE), any(BulkRequest.class), any());
171177
}
172178

179+
public void testCreateApiKeyWillCacheOnCreation() {
180+
final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build();
181+
final ApiKeyService service = createApiKeyService(settings);
182+
final Authentication authentication = new Authentication(
183+
new User(randomAlphaOfLengthBetween(8, 16), "superuser"),
184+
new RealmRef(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)),
185+
null);
186+
final CreateApiKeyRequest createApiKeyRequest = new CreateApiKeyRequest(randomAlphaOfLengthBetween(3, 8), null, null);
187+
when(client.prepareIndex(anyString(), anyString())).thenReturn(new IndexRequestBuilder(client, IndexAction.INSTANCE));
188+
when(client.threadPool()).thenReturn(threadPool);
189+
final String apiKeyId = randomAlphaOfLength(22);
190+
doAnswer(inv -> {
191+
final Object[] args = inv.getArguments();
192+
@SuppressWarnings("unchecked")
193+
final ActionListener<BulkResponse> listener = (ActionListener<BulkResponse>) args[2];
194+
final IndexResponse indexResponse = new IndexResponse(
195+
new ShardId(INTERNAL_SECURITY_MAIN_INDEX_7, randomAlphaOfLength(22), randomIntBetween(0, 1)),
196+
SINGLE_MAPPING_NAME, apiKeyId, randomLongBetween(1, 99), randomLongBetween(1, 99), randomIntBetween(1, 99), true);
197+
listener.onResponse(new BulkResponse(new BulkItemResponse[]{
198+
new BulkItemResponse(randomInt(), DocWriteRequest.OpType.INDEX, indexResponse)
199+
}, randomLongBetween(0, 100)));
200+
return null;
201+
}).when(client).execute(eq(BulkAction.INSTANCE), any(BulkRequest.class), any());
202+
203+
final Cache<String, ListenableFuture<CachedApiKeyHashResult>> apiKeyAuthCache = service.getApiKeyAuthCache();
204+
assertNull(apiKeyAuthCache.get(apiKeyId));
205+
final PlainActionFuture<CreateApiKeyResponse> listener = new PlainActionFuture<>();
206+
service.createApiKey(authentication, createApiKeyRequest, org.elasticsearch.core.Set.of(), listener);
207+
final CreateApiKeyResponse createApiKeyResponse = listener.actionGet();
208+
assertThat(createApiKeyResponse.getId(), equalTo(apiKeyId));
209+
final CachedApiKeyHashResult cachedApiKeyHashResult = service.getFromCache(createApiKeyResponse.getId());
210+
assertThat(cachedApiKeyHashResult.success, is(true));
211+
cachedApiKeyHashResult.verify(createApiKeyResponse.getKey());
212+
}
213+
173214
public void testGetCredentialsFromThreadContext() {
174215
ThreadContext threadContext = threadPool.getThreadContext();
175216
assertNull(ApiKeyService.getCredentialsFromHeader(threadContext));

0 commit comments

Comments
 (0)