-
Notifications
You must be signed in to change notification settings - Fork 24.9k
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
Preserve ApiKey credentials for async verification #51244
Changes from 1 commit
45963e2
0858ef2
b585ab8
4100f2c
dbf164a
cfe8e76
b59c67a
a303bd4
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 | ||||
---|---|---|---|---|---|---|
|
@@ -13,6 +13,7 @@ | |||||
import org.elasticsearch.common.bytes.BytesReference; | ||||||
import org.elasticsearch.common.settings.SecureString; | ||||||
import org.elasticsearch.common.settings.Settings; | ||||||
import org.elasticsearch.common.unit.TimeValue; | ||||||
import org.elasticsearch.common.util.concurrent.ThreadContext; | ||||||
import org.elasticsearch.common.xcontent.ToXContent; | ||||||
import org.elasticsearch.common.xcontent.XContentBuilder; | ||||||
|
@@ -41,6 +42,7 @@ | |||||
import org.elasticsearch.xpack.security.test.SecurityMocks; | ||||||
import org.junit.After; | ||||||
import org.junit.Before; | ||||||
import org.mockito.Mockito; | ||||||
|
||||||
import java.io.IOException; | ||||||
import java.nio.charset.StandardCharsets; | ||||||
|
@@ -54,6 +56,7 @@ | |||||
import java.util.Collections; | ||||||
import java.util.HashMap; | ||||||
import java.util.Map; | ||||||
import java.util.concurrent.atomic.AtomicInteger; | ||||||
|
||||||
import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR; | ||||||
import static org.hamcrest.Matchers.arrayContaining; | ||||||
|
@@ -431,16 +434,7 @@ public void testApiKeyCache() { | |||||
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("doc_type", "api_key"); | ||||||
sourceMap.put("api_key_hash", new String(hash)); | ||||||
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all"))); | ||||||
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all"))); | ||||||
Map<String, Object> creatorMap = new HashMap<>(); | ||||||
creatorMap.put("principal", "test_user"); | ||||||
creatorMap.put("metadata", Collections.emptyMap()); | ||||||
sourceMap.put("creator", creatorMap); | ||||||
sourceMap.put("api_key_invalidated", false); | ||||||
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash); | ||||||
|
||||||
ApiKeyService service = createApiKeyService(Settings.EMPTY); | ||||||
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); | ||||||
|
@@ -488,6 +482,66 @@ public void testApiKeyCache() { | |||||
assertThat(service.getFromCache(creds.getId()).success, is(true)); | ||||||
} | ||||||
|
||||||
public void testAuthenticateWhileCacheBeingPopulated() 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 = buildApiKeySourceDoc(hash); | ||||||
|
||||||
ApiKeyService realService = createApiKeyService(Settings.EMPTY); | ||||||
ApiKeyService service = Mockito.spy(realService); | ||||||
|
||||||
final Object hashWait = new Object(); | ||||||
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. Trivial: Could probably replace this with a CompletableFuture object? If so, could save a couple of lines. 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. I used a semaphore instead, but thanks for the prompt. When I wrote the test I planned to come back and replace the wait/notify with something else after it was all working, but I forgot. |
||||||
final AtomicInteger hashCounter = new AtomicInteger(0); | ||||||
doAnswer(invocationOnMock -> { | ||||||
hashCounter.incrementAndGet(); | ||||||
synchronized (hashWait) { | ||||||
hashWait.wait(); | ||||||
} | ||||||
return invocationOnMock.callRealMethod(); | ||||||
}).when(service).verifyKeyAgainstHash(any(String.class), any(ApiKeyCredentials.class)); | ||||||
|
||||||
final ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); | ||||||
final PlainActionFuture<AuthenticationResult> future1 = new PlainActionFuture<>(); | ||||||
|
||||||
// Call the top level method because it has been know to be buggy in async situations | ||||||
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.
Suggested change
|
||||||
writeCredentialsToThreadContext(creds); | ||||||
mockSourceDocument(creds.getId(), sourceMap); | ||||||
|
||||||
// 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 | ||||||
this.threadPool.generic().execute(() -> service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future1)); | ||||||
|
||||||
// Wait for the first credential validation to get to the blocked state | ||||||
assertBusy(() -> assertThat(hashCounter.get(), equalTo(1))); | ||||||
if (future1.isDone()) { | ||||||
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message | ||||||
fail("Expected authentication to be blocked, but was " + future1.actionGet()); | ||||||
} | ||||||
|
||||||
// The second authentication should pass (but not immediately, but will not block) | ||||||
PlainActionFuture<AuthenticationResult> future2 = new PlainActionFuture<>(); | ||||||
|
||||||
service.authenticateWithApiKeyIfPresent(threadPool.getThreadContext(), future2); | ||||||
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. Without the fix to not close the credentials, this line would fail with:
|
||||||
|
||||||
assertThat(hashCounter.get(), equalTo(1)); | ||||||
if (future2.isDone()) { | ||||||
// We do this [ rather than assertFalse(isDone) ] so we can get a reasonable failure message | ||||||
fail("Expected authentication to be blocked, but was " + future2.actionGet()); | ||||||
} | ||||||
|
||||||
synchronized (hashWait) { | ||||||
hashWait.notify(); | ||||||
} | ||||||
|
||||||
assertThat(future1.actionGet(TimeValue.timeValueSeconds(2)).isAuthenticated(), is(true)); | ||||||
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. Mainly curious: it really takes 2 seconds for the notify to work? 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. No, but in these tests there's a delicate balance between not having the tests run too long and not getting noise. 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 that makes sense. |
||||||
assertThat(future2.actionGet(TimeValue.timeValueMillis(100)).isAuthenticated(), is(true)); | ||||||
|
||||||
CachedApiKeyHashResult cachedApiKeyHashResult = service.getFromCache(creds.getId()); | ||||||
assertNotNull(cachedApiKeyHashResult); | ||||||
assertThat(cachedApiKeyHashResult.success, is(true)); | ||||||
} | ||||||
|
||||||
public void testApiKeyCacheDisabled() { | ||||||
final String apiKey = randomAlphaOfLength(16); | ||||||
Hasher hasher = randomFrom(Hasher.PBKDF2, Hasher.BCRYPT4, Hasher.BCRYPT); | ||||||
|
@@ -496,16 +550,7 @@ public void testApiKeyCacheDisabled() { | |||||
.put(ApiKeyService.CACHE_TTL_SETTING.getKey(), "0s") | ||||||
.build(); | ||||||
|
||||||
Map<String, Object> sourceMap = new HashMap<>(); | ||||||
sourceMap.put("doc_type", "api_key"); | ||||||
sourceMap.put("api_key_hash", new String(hash)); | ||||||
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all"))); | ||||||
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all"))); | ||||||
Map<String, Object> creatorMap = new HashMap<>(); | ||||||
creatorMap.put("principal", "test_user"); | ||||||
creatorMap.put("metadata", Collections.emptyMap()); | ||||||
sourceMap.put("creator", creatorMap); | ||||||
sourceMap.put("api_key_invalidated", false); | ||||||
Map<String, Object> sourceMap = buildApiKeySourceDoc(hash); | ||||||
|
||||||
ApiKeyService service = createApiKeyService(settings); | ||||||
ApiKeyCredentials creds = new ApiKeyCredentials(randomAlphaOfLength(12), new SecureString(apiKey.toCharArray())); | ||||||
|
@@ -517,10 +562,40 @@ public void testApiKeyCacheDisabled() { | |||||
assertNull(cachedApiKeyHashResult); | ||||||
} | ||||||
|
||||||
private ApiKeyService createApiKeyService(Settings settings) { | ||||||
private ApiKeyService createApiKeyService(Settings baseSettings) { | ||||||
final Settings settings = Settings.builder() | ||||||
.put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true) | ||||||
.put(baseSettings) | ||||||
.build(); | ||||||
return new ApiKeyService(settings, Clock.systemUTC(), client, licenseState, securityIndex, | ||||||
ClusterServiceUtils.createClusterService(threadPool), threadPool); | ||||||
} | ||||||
|
||||||
private Map<String, Object> buildApiKeySourceDoc(char[] hash) { | ||||||
Map<String, Object> sourceMap = new HashMap<>(); | ||||||
sourceMap.put("doc_type", "api_key"); | ||||||
sourceMap.put("api_key_hash", new String(hash)); | ||||||
sourceMap.put("role_descriptors", Collections.singletonMap("a role", Collections.singletonMap("cluster", "all"))); | ||||||
sourceMap.put("limited_by_role_descriptors", Collections.singletonMap("limited role", Collections.singletonMap("cluster", "all"))); | ||||||
Map<String, Object> creatorMap = new HashMap<>(); | ||||||
creatorMap.put("principal", "test_user"); | ||||||
creatorMap.put("metadata", Collections.emptyMap()); | ||||||
sourceMap.put("creator", creatorMap); | ||||||
sourceMap.put("api_key_invalidated", false); | ||||||
return sourceMap; | ||||||
} | ||||||
|
||||||
private void writeCredentialsToThreadContext(ApiKeyCredentials creds) { | ||||||
final String credentialString = creds.getId() + ":" + creds.getKey(); | ||||||
this.threadPool.getThreadContext().putHeader("Authorization", | ||||||
"ApiKey " + Base64.getEncoder().encodeToString(credentialString.getBytes(StandardCharsets.US_ASCII))); | ||||||
} | ||||||
|
||||||
private void mockSourceDocument(String id, Map<String, Object> sourceMap) throws IOException { | ||||||
try (XContentBuilder builder = JsonXContent.contentBuilder()) { | ||||||
builder.map(sourceMap); | ||||||
SecurityMocks.mockGetRequest(client, id, BytesReference.bytes(builder)); | ||||||
} | ||||||
} | ||||||
|
||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A minor comment: Is it worthwhile to wrap the ActionListeners earlier on so that we don't have to call
credentials.close()
in multiple places. That is once we knowcredentials
is not null, we can have:Then just use the new listener in subsequent code. At this point, it may be worthwhile to extract this part of code into a new method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, I'll see how it looks.