Skip to content

Commit 8e9a89a

Browse files
committed
Add an authentication cache for API keys (#38469)
This commit adds an authentication cache for API keys that caches the hash of an API key with a faster hash. This will enable better performance when API keys are used for bulk or heavy searching.
1 parent 3a0c896 commit 8e9a89a

File tree

4 files changed

+230
-49
lines changed

4 files changed

+230
-49
lines changed

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,8 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
502502
rolesProviders.addAll(extension.getRolesProviders(settings, resourceWatcherService));
503503
}
504504

505-
final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex.get(), clusterService);
505+
final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex.get(), clusterService,
506+
threadPool);
506507
components.add(apiKeyService);
507508
final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore,
508509
reservedRolesStore, privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache,
@@ -707,6 +708,9 @@ public static List<Setting<?>> getSettings(boolean transportClientMode, List<Sec
707708
settingsList.add(ApiKeyService.PASSWORD_HASHING_ALGORITHM);
708709
settingsList.add(ApiKeyService.DELETE_TIMEOUT);
709710
settingsList.add(ApiKeyService.DELETE_INTERVAL);
711+
settingsList.add(ApiKeyService.CACHE_HASH_ALGO_SETTING);
712+
settingsList.add(ApiKeyService.CACHE_MAX_KEYS_SETTING);
713+
settingsList.add(ApiKeyService.CACHE_TTL_SETTING);
710714

711715
// hide settings
712716
settingsList.add(Setting.listSetting(SecurityField.setting("hide_settings"), Collections.emptyList(), Function.identity(),

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

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@
3333
import org.elasticsearch.common.Strings;
3434
import org.elasticsearch.common.UUIDs;
3535
import org.elasticsearch.common.bytes.BytesReference;
36+
import org.elasticsearch.common.cache.Cache;
37+
import org.elasticsearch.common.cache.CacheBuilder;
3638
import org.elasticsearch.common.logging.DeprecationLogger;
3739
import org.elasticsearch.common.logging.LoggerMessageFormat;
3840
import org.elasticsearch.common.settings.SecureString;
3941
import org.elasticsearch.common.settings.Setting;
4042
import org.elasticsearch.common.settings.Setting.Property;
4143
import org.elasticsearch.common.settings.Settings;
4244
import org.elasticsearch.common.unit.TimeValue;
45+
import org.elasticsearch.common.util.concurrent.FutureUtils;
46+
import org.elasticsearch.common.util.concurrent.ListenableFuture;
4347
import org.elasticsearch.common.util.concurrent.ThreadContext;
4448
import org.elasticsearch.common.xcontent.DeprecationHandler;
4549
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
@@ -50,6 +54,7 @@
5054
import org.elasticsearch.index.query.BoolQueryBuilder;
5155
import org.elasticsearch.index.query.QueryBuilders;
5256
import org.elasticsearch.search.SearchHit;
57+
import org.elasticsearch.threadpool.ThreadPool;
5358
import org.elasticsearch.xpack.core.XPackSettings;
5459
import org.elasticsearch.xpack.core.security.ScrollHelper;
5560
import org.elasticsearch.xpack.core.security.action.ApiKey;
@@ -81,6 +86,9 @@
8186
import java.util.Map;
8287
import java.util.Objects;
8388
import java.util.Set;
89+
import java.util.concurrent.ExecutionException;
90+
import java.util.concurrent.TimeUnit;
91+
import java.util.concurrent.atomic.AtomicBoolean;
8492
import java.util.function.Function;
8593
import java.util.stream.Collectors;
8694

@@ -118,6 +126,12 @@ public class ApiKeyService {
118126
TimeValue.MINUS_ONE, Property.NodeScope);
119127
public static final Setting<TimeValue> DELETE_INTERVAL = Setting.timeSetting("xpack.security.authc.api_key.delete.interval",
120128
TimeValue.timeValueHours(24L), Property.NodeScope);
129+
public static final Setting<String> CACHE_HASH_ALGO_SETTING = Setting.simpleString("xpack.security.authc.api_key.cache.hash_algo",
130+
"ssha256", Setting.Property.NodeScope);
131+
public static final Setting<TimeValue> CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authc.api_key.cache.ttl",
132+
TimeValue.timeValueHours(24L), Property.NodeScope);
133+
public static final Setting<Integer> CACHE_MAX_KEYS_SETTING = Setting.intSetting("xpack.security.authc.api_key.cache.max_keys",
134+
10000, Property.NodeScope);
121135

122136
private final Clock clock;
123137
private final Client client;
@@ -128,11 +142,14 @@ public class ApiKeyService {
128142
private final Settings settings;
129143
private final ExpiredApiKeysRemover expiredApiKeysRemover;
130144
private final TimeValue deleteInterval;
145+
private final Cache<String, ListenableFuture<CachedApiKeyHashResult>> apiKeyAuthCache;
146+
private final Hasher cacheHasher;
147+
private final ThreadPool threadPool;
131148

132149
private volatile long lastExpirationRunMs;
133150

134-
public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex,
135-
ClusterService clusterService) {
151+
public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex, ClusterService clusterService,
152+
ThreadPool threadPool) {
136153
this.clock = clock;
137154
this.client = client;
138155
this.securityIndex = securityIndex;
@@ -142,6 +159,17 @@ public ApiKeyService(Settings settings, Clock clock, Client client, SecurityInde
142159
this.settings = settings;
143160
this.deleteInterval = DELETE_INTERVAL.get(settings);
144161
this.expiredApiKeysRemover = new ExpiredApiKeysRemover(settings, client);
162+
this.threadPool = threadPool;
163+
this.cacheHasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings));
164+
final TimeValue ttl = CACHE_TTL_SETTING.get(settings);
165+
if (ttl.getNanos() > 0) {
166+
this.apiKeyAuthCache = CacheBuilder.<String, ListenableFuture<CachedApiKeyHashResult>>builder()
167+
.setExpireAfterWrite(ttl)
168+
.setMaximumWeight(CACHE_MAX_KEYS_SETTING.get(settings))
169+
.build();
170+
} else {
171+
this.apiKeyAuthCache = null;
172+
}
145173
}
146174

147175
/**
@@ -364,8 +392,8 @@ private List<RoleDescriptor> parseRoleDescriptors(final String apiKeyId, final M
364392
* @param credentials the credentials provided by the user
365393
* @param listener the listener to notify after verification
366394
*/
367-
static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock,
368-
ActionListener<AuthenticationResult> listener) {
395+
void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock,
396+
ActionListener<AuthenticationResult> listener) {
369397
final Boolean invalidated = (Boolean) source.get("api_key_invalidated");
370398
if (invalidated == null) {
371399
listener.onResponse(AuthenticationResult.terminate("api key document is missing invalidated field", null));
@@ -376,33 +404,87 @@ static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredenti
376404
if (apiKeyHash == null) {
377405
throw new IllegalStateException("api key hash is missing");
378406
}
379-
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
380-
381-
if (verified) {
382-
final Long expirationEpochMilli = (Long) source.get("expiration_time");
383-
if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) {
384-
final Map<String, Object> creator = Objects.requireNonNull((Map<String, Object>) source.get("creator"));
385-
final String principal = Objects.requireNonNull((String) creator.get("principal"));
386-
final Map<String, Object> metadata = (Map<String, Object>) creator.get("metadata");
387-
final Map<String, Object> roleDescriptors = (Map<String, Object>) source.get("role_descriptors");
388-
final Map<String, Object> limitedByRoleDescriptors = (Map<String, Object>) source.get("limited_by_role_descriptors");
389-
final String[] roleNames = (roleDescriptors != null) ? roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY)
390-
: limitedByRoleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY);
391-
final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true);
392-
final Map<String, Object> authResultMetadata = new HashMap<>();
393-
authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors);
394-
authResultMetadata.put(API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, limitedByRoleDescriptors);
395-
authResultMetadata.put(API_KEY_ID_KEY, credentials.getId());
396-
listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata));
407+
408+
if (apiKeyAuthCache != null) {
409+
final AtomicBoolean valueAlreadyInCache = new AtomicBoolean(true);
410+
final ListenableFuture<CachedApiKeyHashResult> listenableCacheEntry;
411+
try {
412+
listenableCacheEntry = apiKeyAuthCache.computeIfAbsent(credentials.getId(),
413+
k -> {
414+
valueAlreadyInCache.set(false);
415+
return new ListenableFuture<>();
416+
});
417+
} catch (ExecutionException e) {
418+
listener.onFailure(e);
419+
return;
420+
}
421+
422+
if (valueAlreadyInCache.get()) {
423+
listenableCacheEntry.addListener(ActionListener.wrap(result -> {
424+
if (result.success) {
425+
if (result.verify(credentials.getKey())) {
426+
// move on
427+
validateApiKeyExpiration(source, credentials, clock, listener);
428+
} else {
429+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
430+
}
431+
} else if (result.verify(credentials.getKey())) { // same key, pass the same result
432+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
433+
} else {
434+
apiKeyAuthCache.invalidate(credentials.getId(), listenableCacheEntry);
435+
validateApiKeyCredentials(source, credentials, clock, listener);
436+
}
437+
}, listener::onFailure),
438+
threadPool.generic(), threadPool.getThreadContext());
397439
} else {
398-
listener.onResponse(AuthenticationResult.terminate("api key is expired", null));
440+
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
441+
listenableCacheEntry.onResponse(new CachedApiKeyHashResult(verified, credentials.getKey()));
442+
if (verified) {
443+
// move on
444+
validateApiKeyExpiration(source, credentials, clock, listener);
445+
} else {
446+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
447+
}
399448
}
400449
} else {
401-
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
450+
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
451+
if (verified) {
452+
// move on
453+
validateApiKeyExpiration(source, credentials, clock, listener);
454+
} else {
455+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
456+
}
402457
}
403458
}
404459
}
405460

461+
// pkg private for testing
462+
CachedApiKeyHashResult getFromCache(String id) {
463+
return apiKeyAuthCache == null ? null : FutureUtils.get(apiKeyAuthCache.get(id), 0L, TimeUnit.MILLISECONDS);
464+
}
465+
466+
private void validateApiKeyExpiration(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock,
467+
ActionListener<AuthenticationResult> listener) {
468+
final Long expirationEpochMilli = (Long) source.get("expiration_time");
469+
if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) {
470+
final Map<String, Object> creator = Objects.requireNonNull((Map<String, Object>) source.get("creator"));
471+
final String principal = Objects.requireNonNull((String) creator.get("principal"));
472+
final Map<String, Object> metadata = (Map<String, Object>) creator.get("metadata");
473+
final Map<String, Object> roleDescriptors = (Map<String, Object>) source.get("role_descriptors");
474+
final Map<String, Object> limitedByRoleDescriptors = (Map<String, Object>) source.get("limited_by_role_descriptors");
475+
final String[] roleNames = (roleDescriptors != null) ? roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY)
476+
: limitedByRoleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY);
477+
final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true);
478+
final Map<String, Object> authResultMetadata = new HashMap<>();
479+
authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors);
480+
authResultMetadata.put(API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, limitedByRoleDescriptors);
481+
authResultMetadata.put(API_KEY_ID_KEY, credentials.getId());
482+
listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata));
483+
} else {
484+
listener.onResponse(AuthenticationResult.terminate("api key is expired", null));
485+
}
486+
}
487+
406488
/**
407489
* Gets the API Key from the <code>Authorization</code> header if the header begins with
408490
* <code>ApiKey </code>
@@ -858,4 +940,17 @@ public void getApiKeyForApiKeyName(String apiKeyName, ActionListener<GetApiKeyRe
858940
}
859941
}
860942

943+
final class CachedApiKeyHashResult {
944+
final boolean success;
945+
final char[] hash;
946+
947+
CachedApiKeyHashResult(boolean success, SecureString apiKey) {
948+
this.success = success;
949+
this.hash = cacheHasher.hash(apiKey);
950+
}
951+
952+
private boolean verify(SecureString password) {
953+
return hash != null && cacheHasher.verify(password, hash);
954+
}
955+
}
861956
}

0 commit comments

Comments
 (0)