-
Notifications
You must be signed in to change notification settings - Fork 25.2k
Implement verification of API keys #35318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
9938986
0fb01ba
cd23f20
f8b1752
ef0b10d
6909823
39e07d4
e9c5fdb
113ac85
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,37 +8,51 @@ | |
|
||
import org.apache.logging.log4j.LogManager; | ||
import org.apache.logging.log4j.Logger; | ||
import org.elasticsearch.ElasticsearchSecurityException; | ||
import org.elasticsearch.Version; | ||
import org.elasticsearch.action.ActionListener; | ||
import org.elasticsearch.action.get.GetRequest; | ||
import org.elasticsearch.action.get.GetResponse; | ||
import org.elasticsearch.action.index.IndexAction; | ||
import org.elasticsearch.action.index.IndexRequest; | ||
import org.elasticsearch.client.Client; | ||
import org.elasticsearch.cluster.service.ClusterService; | ||
import org.elasticsearch.common.CharArrays; | ||
import org.elasticsearch.common.Strings; | ||
import org.elasticsearch.common.UUIDs; | ||
import org.elasticsearch.common.settings.SecureString; | ||
import org.elasticsearch.common.settings.Setting; | ||
import org.elasticsearch.common.settings.Settings; | ||
import org.elasticsearch.common.util.concurrent.ThreadContext; | ||
import org.elasticsearch.common.xcontent.XContentBuilder; | ||
import org.elasticsearch.common.xcontent.XContentFactory; | ||
import org.elasticsearch.xpack.core.XPackSettings; | ||
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; | ||
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; | ||
import org.elasticsearch.xpack.core.security.authc.Authentication; | ||
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; | ||
import org.elasticsearch.xpack.core.security.authc.support.Hasher; | ||
import org.elasticsearch.xpack.core.security.user.User; | ||
import org.elasticsearch.xpack.security.support.SecurityIndexManager; | ||
|
||
import javax.crypto.SecretKeyFactory; | ||
import java.io.Closeable; | ||
import java.io.IOException; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.time.Clock; | ||
import java.time.Instant; | ||
import java.util.Arrays; | ||
import java.util.Base64; | ||
import java.util.List; | ||
import java.util.Locale; | ||
import java.util.Map; | ||
import java.util.Objects; | ||
import java.util.function.Function; | ||
import java.util.stream.Collectors; | ||
|
||
import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN; | ||
import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin; | ||
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError; | ||
|
||
public class ApiKeyService { | ||
|
||
|
@@ -142,6 +156,113 @@ public void createApiKey(Authentication authentication, CreateApiKeyRequest requ | |
} | ||
} | ||
|
||
/** | ||
* Checks for the presence of a {@code Authorization} header with a value that starts with | ||
* {@code ApiKey }. If found this will attempt to authenticate the key. | ||
*/ | ||
void authenticateWithApiKeyIfPresent(ThreadContext ctx, ActionListener<AuthenticationResult> listener) { | ||
if (enabled) { | ||
final ApiKeyCredentials credentials; | ||
try { | ||
credentials = getCredentialsFromHeader(ctx); | ||
} catch (ElasticsearchSecurityException ese) { | ||
listener.onResponse(AuthenticationResult.terminate(ese.getMessage(), ese)); | ||
return; | ||
} | ||
|
||
if (credentials != null) { | ||
final GetRequest getRequest = client.prepareGet(SecurityIndexManager.SECURITY_INDEX_NAME, TYPE, credentials.getId()) | ||
.setFetchSource(true).request(); | ||
executeAsyncWithOrigin(ctx, SECURITY_ORIGIN, getRequest, ActionListener.<GetResponse>wrap(response -> { | ||
if (response.isExists()) { | ||
try (ApiKeyCredentials ignore = credentials) { | ||
validateApiKeyCredentials(response.getSource(), credentials, clock, listener); | ||
} | ||
} else { | ||
credentials.close(); | ||
listener.onResponse(AuthenticationResult.unsuccessful("unable to authenticate", null)); | ||
jaymode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
}, e -> { | ||
credentials.close(); | ||
listener.onResponse(AuthenticationResult.unsuccessful("apikey auth encountered a failure", e)); | ||
}), client::get); | ||
} else { | ||
listener.onResponse(AuthenticationResult.notHandled()); | ||
} | ||
} else { | ||
listener.onResponse(AuthenticationResult.notHandled()); | ||
} | ||
} | ||
|
||
/** | ||
* Validates the ApiKey using the source map | ||
* @param source the source map from a get of the ApiKey document | ||
* @param credentials the credentials provided by the user | ||
* @param listener the listener to notify after verification | ||
*/ | ||
static void validateApiKeyCredentials(Map<String, Object> source, ApiKeyCredentials credentials, Clock clock, | ||
ActionListener<AuthenticationResult> listener) { | ||
final String apiKeyHash = (String) source.get("api_key_hash"); | ||
if (apiKeyHash == null) { | ||
throw new IllegalStateException("api key hash is missing"); | ||
} | ||
final char[] apiKeyHashChars = apiKeyHash.toCharArray(); | ||
jaymode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Hasher hasher = Hasher.resolveFromHash(apiKeyHash.toCharArray()); | ||
final boolean verified = hasher.verify(credentials.getKey(), apiKeyHashChars); | ||
if (verified) { | ||
final Long expirationEpochMilli = (Long) source.get("expiration_time"); | ||
if (expirationEpochMilli == null || Instant.ofEpochMilli(expirationEpochMilli).isAfter(clock.instant())) { | ||
final String principal = Objects.requireNonNull((String) source.get("principal")); | ||
final Map<String, Object> metadata = (Map<String, Object>) source.get("metadata"); | ||
final List<Map<String, Object>> roleDescriptors = (List<Map<String, Object>>) source.get("role_descriptors"); | ||
final String[] roleNames = roleDescriptors.stream() | ||
.map(rdSource -> (String) rdSource.get("name")) | ||
.collect(Collectors.toList()) | ||
.toArray(Strings.EMPTY_ARRAY); | ||
final User apiKeyUser = new User(principal, roleNames, null, null, metadata, true); | ||
listener.onResponse(AuthenticationResult.success(apiKeyUser)); | ||
} else { | ||
listener.onResponse(AuthenticationResult.unsuccessful("api key is expired", null)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Can you please elaborate on your reasoning behind this? I don't necessarily see a value in masking whether the key is invalid or expired. Also wouldn't the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
My reasoning is that an attacker can currently differentiate between a non-existent api key and an existing one based on timing, which allows the attacker to obtain the ID of a key that exists and then begin a brute force attack on a single ID. If they can also differentiate between one that exists and is expired, this helps them as well. Smart guesses/a vulnerability in our key generation process could wind up making the ability to find a non-expired ID useful to an attacker. That said, if you still feel there is not much value in this I am happy to reconsider. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I missed the second question.
No, the authentication result message is used for logging and is not returned to the user. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thanks for the clarification! No, I agree with you that this is a valuable protection layer, let's keep it ! |
||
} | ||
} else { | ||
listener.onResponse(AuthenticationResult.unsuccessful("invalid credentials", null)); | ||
} | ||
} | ||
|
||
/** | ||
* Gets the API Key from the <code>Authorization</code> header if the header begins with | ||
* <code>ApiKey </code> | ||
*/ | ||
static ApiKeyCredentials getCredentialsFromHeader(ThreadContext threadContext) { | ||
String header = threadContext.getHeader("Authorization"); | ||
if (Strings.hasText(header) && header.regionMatches(true, 0, "ApiKey ", 0, "ApiKey ".length()) | ||
bizybot marked this conversation as resolved.
Show resolved
Hide resolved
|
||
&& header.length() > "ApiKey ".length()) { | ||
final byte[] decodedApiKeyCredBytes = Base64.getDecoder().decode(header.substring("ApiKey ".length())); | ||
char[] apiKeyCredChars = null; | ||
try { | ||
apiKeyCredChars = CharArrays.utf8BytesToChars(decodedApiKeyCredBytes); | ||
int colonIndex = -1; | ||
for (int i = 0; i < apiKeyCredChars.length; i++) { | ||
if (apiKeyCredChars[i] == ':') { | ||
colonIndex = i; | ||
break; | ||
} | ||
} | ||
|
||
if (colonIndex < 1) { | ||
throw authenticationError("invalid ApiKey value"); | ||
} | ||
return new ApiKeyCredentials(new String(Arrays.copyOfRange(apiKeyCredChars, 0, colonIndex)), | ||
new SecureString(Arrays.copyOfRange(apiKeyCredChars, colonIndex + 1, apiKeyCredChars.length))); | ||
} finally { | ||
if (apiKeyCredChars != null) { | ||
Arrays.fill(apiKeyCredChars, (char) 0); | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { | ||
if (request.getExpiration() != null) { | ||
return now.plusSeconds(request.getExpiration().getSeconds()); | ||
|
@@ -155,4 +276,28 @@ private void ensureEnabled() { | |
throw new IllegalStateException("tokens are not enabled"); | ||
} | ||
} | ||
|
||
// package private class for testing | ||
static final class ApiKeyCredentials implements Closeable { | ||
private final String id; | ||
private final SecureString key; | ||
|
||
ApiKeyCredentials(String id, SecureString key) { | ||
this.id = id; | ||
this.key = key; | ||
} | ||
|
||
String getId() { | ||
return id; | ||
} | ||
|
||
SecureString getKey() { | ||
return key; | ||
} | ||
|
||
@Override | ||
public void close() { | ||
key.close(); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License; | ||
* you may not use this file except in compliance with the Elastic License. | ||
*/ | ||
|
||
package org.elasticsearch.xpack.security.authc; | ||
|
||
import org.elasticsearch.ElasticsearchSecurityException; | ||
import org.elasticsearch.action.support.PlainActionFuture; | ||
import org.elasticsearch.common.settings.SecureString; | ||
import org.elasticsearch.common.settings.Settings; | ||
import org.elasticsearch.common.util.concurrent.ThreadContext; | ||
import org.elasticsearch.test.ESTestCase; | ||
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; | ||
import org.elasticsearch.xpack.core.security.authc.support.Hasher; | ||
|
||
import java.nio.charset.StandardCharsets; | ||
import java.time.Clock; | ||
import java.time.temporal.ChronoUnit; | ||
import java.util.Base64; | ||
import java.util.Collections; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
import static org.hamcrest.Matchers.arrayContaining; | ||
import static org.hamcrest.Matchers.is; | ||
|
||
public class ApiKeyServiceTests extends ESTestCase { | ||
|
||
public void testGetCredentialsFromThreadContext() { | ||
ThreadContext threadContext = new ThreadContext(Settings.EMPTY); | ||
assertNull(ApiKeyService.getCredentialsFromHeader(threadContext)); | ||
|
||
final String apiKeyAuthScheme = randomFrom("apikey", "apiKey", "ApiKey", "APikey", "APIKEY"); | ||
final String id = randomAlphaOfLength(12); | ||
final String key = randomAlphaOfLength(16); | ||
String headerValue = apiKeyAuthScheme + " " + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); | ||
|
||
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { | ||
threadContext.putHeader("Authorization", headerValue); | ||
ApiKeyService.ApiKeyCredentials creds = ApiKeyService.getCredentialsFromHeader(threadContext); | ||
assertNotNull(creds); | ||
assertEquals(id, creds.getId()); | ||
assertEquals(key, creds.getKey().toString()); | ||
} | ||
|
||
// missing space | ||
headerValue = apiKeyAuthScheme + Base64.getEncoder().encodeToString((id + ":" + key).getBytes(StandardCharsets.UTF_8)); | ||
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { | ||
threadContext.putHeader("Authorization", headerValue); | ||
ApiKeyService.ApiKeyCredentials creds = ApiKeyService.getCredentialsFromHeader(threadContext); | ||
assertNull(creds); | ||
} | ||
|
||
// missing colon | ||
headerValue = apiKeyAuthScheme + " " + Base64.getEncoder().encodeToString((id + key).getBytes(StandardCharsets.UTF_8)); | ||
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { | ||
threadContext.putHeader("Authorization", headerValue); | ||
ElasticsearchSecurityException e = | ||
expectThrows(ElasticsearchSecurityException.class, () -> ApiKeyService.getCredentialsFromHeader(threadContext)); | ||
assertEquals("invalid ApiKey value", e.getMessage()); | ||
} | ||
} | ||
|
||
public void testValidateApiKey() throws Exception { | ||
final String apiKey = randomAlphaOfLength(16); | ||
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT); | ||
final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); | ||
|
||
Map<String, Object> sourceMap = new HashMap<>(); | ||
sourceMap.put("api_key_hash", new String(hash)); | ||
sourceMap.put("principal", "test_user"); | ||
sourceMap.put("metadata", Collections.emptyMap()); | ||
sourceMap.put("role_descriptors", Collections.singletonList(Collections.singletonMap("name", "a role"))); | ||
|
||
|
||
ApiKeyService.ApiKeyCredentials creds = | ||
new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); | ||
PlainActionFuture<AuthenticationResult> future = new PlainActionFuture<>(); | ||
ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); | ||
AuthenticationResult result = future.get(); | ||
assertNotNull(result); | ||
assertTrue(result.isAuthenticated()); | ||
assertThat(result.getUser().principal(), is("test_user")); | ||
assertThat(result.getUser().roles(), arrayContaining("a role")); | ||
assertThat(result.getUser().metadata(), is(Collections.emptyMap())); | ||
|
||
sourceMap.put("expiration_time", Clock.systemUTC().instant().plus(1L, ChronoUnit.HOURS).toEpochMilli()); | ||
future = new PlainActionFuture<>(); | ||
ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); | ||
result = future.get(); | ||
assertNotNull(result); | ||
assertTrue(result.isAuthenticated()); | ||
assertThat(result.getUser().principal(), is("test_user")); | ||
assertThat(result.getUser().roles(), arrayContaining("a role")); | ||
assertThat(result.getUser().metadata(), is(Collections.emptyMap())); | ||
|
||
sourceMap.put("expiration_time", Clock.systemUTC().instant().minus(1L, ChronoUnit.HOURS).toEpochMilli()); | ||
future = new PlainActionFuture<>(); | ||
ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); | ||
result = future.get(); | ||
assertNotNull(result); | ||
assertFalse(result.isAuthenticated()); | ||
|
||
sourceMap.remove("expiration_time"); | ||
creds = new ApiKeyService.ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(randomAlphaOfLength(15).toCharArray())); | ||
future = new PlainActionFuture<>(); | ||
ApiKeyService.validateApiKeyCredentials(sourceMap, creds, Clock.systemUTC(), future); | ||
result = future.get(); | ||
assertNotNull(result); | ||
assertFalse(result.isAuthenticated()); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.