Skip to content

Commit e73c9c9

Browse files
authored
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 517aa95 commit e73c9c9

4 files changed

Lines changed: 230 additions & 35 deletions

File tree

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
@@ -438,7 +438,8 @@ Collection<Object> createComponents(Client client, ThreadPool threadPool, Cluste
438438
rolesProviders.addAll(extension.getRolesProviders(settings, resourceWatcherService));
439439
}
440440

441-
final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex.get(), clusterService);
441+
final ApiKeyService apiKeyService = new ApiKeyService(settings, Clock.systemUTC(), client, securityIndex.get(), clusterService,
442+
threadPool);
442443
components.add(apiKeyService);
443444
final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore,
444445
privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService);
@@ -631,6 +632,9 @@ public static List<Setting<?>> getSettings(boolean transportClientMode, List<Sec
631632
settingsList.add(ApiKeyService.PASSWORD_HASHING_ALGORITHM);
632633
settingsList.add(ApiKeyService.DELETE_TIMEOUT);
633634
settingsList.add(ApiKeyService.DELETE_INTERVAL);
635+
settingsList.add(ApiKeyService.CACHE_HASH_ALGO_SETTING);
636+
settingsList.add(ApiKeyService.CACHE_MAX_KEYS_SETTING);
637+
settingsList.add(ApiKeyService.CACHE_TTL_SETTING);
634638

635639
// hide settings
636640
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 & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@
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.settings.SecureString;
3840
import org.elasticsearch.common.settings.Setting;
3941
import org.elasticsearch.common.settings.Setting.Property;
4042
import org.elasticsearch.common.settings.Settings;
4143
import org.elasticsearch.common.unit.TimeValue;
44+
import org.elasticsearch.common.util.concurrent.FutureUtils;
45+
import org.elasticsearch.common.util.concurrent.ListenableFuture;
4246
import org.elasticsearch.common.util.concurrent.ThreadContext;
4347
import org.elasticsearch.common.xcontent.DeprecationHandler;
4448
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
@@ -49,6 +53,7 @@
4953
import org.elasticsearch.index.query.BoolQueryBuilder;
5054
import org.elasticsearch.index.query.QueryBuilders;
5155
import org.elasticsearch.search.SearchHit;
56+
import org.elasticsearch.threadpool.ThreadPool;
5257
import org.elasticsearch.xpack.core.XPackSettings;
5358
import org.elasticsearch.xpack.core.security.ScrollHelper;
5459
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

@@ -96,7 +104,6 @@ public class ApiKeyService {
96104
static final String API_KEY_ID_KEY = "_security_api_key_id";
97105
static final String API_KEY_ROLE_DESCRIPTORS_KEY = "_security_api_key_role_descriptors";
98106
static final String API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY = "_security_api_key_limited_by_role_descriptors";
99-
static final String API_KEY_ROLE_KEY = "_security_api_key_role";
100107

101108
public static final Setting<String> PASSWORD_HASHING_ALGORITHM = new Setting<>(
102109
"xpack.security.authc.api_key_hashing.algorithm", "pbkdf2", Function.identity(), v -> {
@@ -117,6 +124,12 @@ public class ApiKeyService {
117124
TimeValue.MINUS_ONE, Property.NodeScope);
118125
public static final Setting<TimeValue> DELETE_INTERVAL = Setting.timeSetting("xpack.security.authc.api_key.delete.interval",
119126
TimeValue.timeValueHours(24L), Property.NodeScope);
127+
public static final Setting<String> CACHE_HASH_ALGO_SETTING = Setting.simpleString("xpack.security.authc.api_key.cache.hash_algo",
128+
"ssha256", Setting.Property.NodeScope);
129+
public static final Setting<TimeValue> CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authc.api_key.cache.ttl",
130+
TimeValue.timeValueHours(24L), Property.NodeScope);
131+
public static final Setting<Integer> CACHE_MAX_KEYS_SETTING = Setting.intSetting("xpack.security.authc.api_key.cache.max_keys",
132+
10000, Property.NodeScope);
120133

121134
private final Clock clock;
122135
private final Client client;
@@ -127,11 +140,14 @@ public class ApiKeyService {
127140
private final Settings settings;
128141
private final ExpiredApiKeysRemover expiredApiKeysRemover;
129142
private final TimeValue deleteInterval;
143+
private final Cache<String, ListenableFuture<CachedApiKeyHashResult>> apiKeyAuthCache;
144+
private final Hasher cacheHasher;
145+
private final ThreadPool threadPool;
130146

131147
private volatile long lastExpirationRunMs;
132148

133-
public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex,
134-
ClusterService clusterService) {
149+
public ApiKeyService(Settings settings, Clock clock, Client client, SecurityIndexManager securityIndex, ClusterService clusterService,
150+
ThreadPool threadPool) {
135151
this.clock = clock;
136152
this.client = client;
137153
this.securityIndex = securityIndex;
@@ -141,6 +157,17 @@ public ApiKeyService(Settings settings, Clock clock, Client client, SecurityInde
141157
this.settings = settings;
142158
this.deleteInterval = DELETE_INTERVAL.get(settings);
143159
this.expiredApiKeysRemover = new ExpiredApiKeysRemover(settings, client);
160+
this.threadPool = threadPool;
161+
this.cacheHasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings));
162+
final TimeValue ttl = CACHE_TTL_SETTING.get(settings);
163+
if (ttl.getNanos() > 0) {
164+
this.apiKeyAuthCache = CacheBuilder.<String, ListenableFuture<CachedApiKeyHashResult>>builder()
165+
.setExpireAfterWrite(ttl)
166+
.setMaximumWeight(CACHE_MAX_KEYS_SETTING.get(settings))
167+
.build();
168+
} else {
169+
this.apiKeyAuthCache = null;
170+
}
144171
}
145172

146173
/**
@@ -363,8 +390,8 @@ private List<RoleDescriptor> parseRoleDescriptors(final String apiKeyId, final M
363390
* @param credentials the credentials provided by the user
364391
* @param listener the listener to notify after verification
365392
*/
366-
static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock,
367-
ActionListener<AuthenticationResult> listener) {
393+
void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock,
394+
ActionListener<AuthenticationResult> listener) {
368395
final Boolean invalidated = (Boolean) source.get("api_key_invalidated");
369396
if (invalidated == null) {
370397
listener.onResponse(AuthenticationResult.terminate("api key document is missing invalidated field", null));
@@ -375,33 +402,87 @@ static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredenti
375402
if (apiKeyHash == null) {
376403
throw new IllegalStateException("api key hash is missing");
377404
}
378-
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
379-
380-
if (verified) {
381-
final Long expirationEpochMilli = (Long) source.get("expiration_time");
382-
if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) {
383-
final Map<String, Object> creator = Objects.requireNonNull((Map<String, Object>) source.get("creator"));
384-
final String principal = Objects.requireNonNull((String) creator.get("principal"));
385-
final Map<String, Object> metadata = (Map<String, Object>) creator.get("metadata");
386-
final Map<String, Object> roleDescriptors = (Map<String, Object>) source.get("role_descriptors");
387-
final Map<String, Object> limitedByRoleDescriptors = (Map<String, Object>) source.get("limited_by_role_descriptors");
388-
final String[] roleNames = (roleDescriptors != null) ? roleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY)
389-
: limitedByRoleDescriptors.keySet().toArray(Strings.EMPTY_ARRAY);
390-
final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true);
391-
final Map<String, Object> authResultMetadata = new HashMap<>();
392-
authResultMetadata.put(API_KEY_ROLE_DESCRIPTORS_KEY, roleDescriptors);
393-
authResultMetadata.put(API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY, limitedByRoleDescriptors);
394-
authResultMetadata.put(API_KEY_ID_KEY, credentials.getId());
395-
listener.onResponse(AuthenticationResult.success(apiKeyUser, authResultMetadata));
405+
406+
if (apiKeyAuthCache != null) {
407+
final AtomicBoolean valueAlreadyInCache = new AtomicBoolean(true);
408+
final ListenableFuture<CachedApiKeyHashResult> listenableCacheEntry;
409+
try {
410+
listenableCacheEntry = apiKeyAuthCache.computeIfAbsent(credentials.getId(),
411+
k -> {
412+
valueAlreadyInCache.set(false);
413+
return new ListenableFuture<>();
414+
});
415+
} catch (ExecutionException e) {
416+
listener.onFailure(e);
417+
return;
418+
}
419+
420+
if (valueAlreadyInCache.get()) {
421+
listenableCacheEntry.addListener(ActionListener.wrap(result -> {
422+
if (result.success) {
423+
if (result.verify(credentials.getKey())) {
424+
// move on
425+
validateApiKeyExpiration(source, credentials, clock, listener);
426+
} else {
427+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
428+
}
429+
} else if (result.verify(credentials.getKey())) { // same key, pass the same result
430+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
431+
} else {
432+
apiKeyAuthCache.invalidate(credentials.getId(), listenableCacheEntry);
433+
validateApiKeyCredentials(source, credentials, clock, listener);
434+
}
435+
}, listener::onFailure),
436+
threadPool.generic(), threadPool.getThreadContext());
396437
} else {
397-
listener.onResponse(AuthenticationResult.terminate("api key is expired", null));
438+
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
439+
listenableCacheEntry.onResponse(new CachedApiKeyHashResult(verified, credentials.getKey()));
440+
if (verified) {
441+
// move on
442+
validateApiKeyExpiration(source, credentials, clock, listener);
443+
} else {
444+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
445+
}
398446
}
399447
} else {
400-
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
448+
final boolean verified = verifyKeyAgainstHash(apiKeyHash, credentials);
449+
if (verified) {
450+
// move on
451+
validateApiKeyExpiration(source, credentials, clock, listener);
452+
} else {
453+
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null));
454+
}
401455
}
402456
}
403457
}
404458

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

935+
final class CachedApiKeyHashResult {
936+
final boolean success;
937+
final char[] hash;
938+
939+
CachedApiKeyHashResult(boolean success, SecureString apiKey) {
940+
this.success = success;
941+
this.hash = cacheHasher.hash(apiKey);
942+
}
943+
944+
private boolean verify(SecureString password) {
945+
return hash != null && cacheHasher.verify(password, hash);
946+
}
947+
}
854948
}

0 commit comments

Comments
 (0)