From a8677499d7b010f805187db6f593b22b942e7faf Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Fri, 13 Mar 2020 16:30:20 +1100 Subject: [PATCH] [Backport] Add support for secondary authentication (#53530) This change makes it possible to send secondary authentication credentials to select endpoints that need to perform a single action in the context of two users. Typically this need arises when a server process needs to call an endpoint that users should not (or might not) have direct access to, but some part of that action must be performed using the logged-in user's identity. Backport of: #52093 --- .../xpack/core/security/SecurityContext.java | 27 ++ .../core/security/authc/Authentication.java | 9 +- .../AuthenticationContextSerializer.java | 1 + .../support/SecondaryAuthentication.java | 86 +++++ .../support/SecondaryAuthenticationTests.java | 162 ++++++++++ .../xpack/security/Security.java | 10 +- .../authc/support/SecondaryAuthenticator.java | 132 ++++++++ .../security/rest/SecurityRestFilter.java | 54 ++-- .../logfile/LoggingAuditTrailFilterTests.java | 2 +- .../support/DummyUsernamePasswordRealm.java | 65 ++++ .../support/SecondaryAuthenticatorTests.java | 294 ++++++++++++++++++ .../rest/SecurityRestFilterTests.java | 80 ++++- .../xpack/security/test/SecurityMocks.java | 75 ++++- 13 files changed, 953 insertions(+), 44 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthentication.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthenticationTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticator.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java index 514bf49719d60..a12f12ae53017 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityContext.java @@ -15,6 +15,7 @@ import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer; +import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication; import org.elasticsearch.xpack.core.security.user.User; import java.io.IOException; @@ -22,6 +23,7 @@ import java.util.Collections; import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Function; /** * A lightweight utility that can find the current user and authentication information for the local thread. @@ -55,6 +57,19 @@ public Authentication getAuthentication() { } } + /** + * Returns the "secondary authentication" (see {@link SecondaryAuthentication}) information, + * or {@code null} if the current request does not have a secondary authentication context + */ + public SecondaryAuthentication getSecondaryAuthentication() { + try { + return SecondaryAuthentication.readFromContext(this); + } catch (IOException e) { + logger.error("failed to read secondary authentication", e); + throw new UncheckedIOException(e); + } + } + public ThreadContext getThreadContext() { return threadContext; } @@ -97,6 +112,18 @@ public void executeAsUser(User user, Consumer consumer, Version v } } + /** + * Runs the consumer in a new context as the provided user. The original context is provided to the consumer. When this method + * returns, the original context is restored. + */ + public T executeWithAuthentication(Authentication authentication, Function consumer) { + final StoredContext original = threadContext.newStoredContext(true); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + setAuthentication(authentication); + return consumer.apply(original); + } + } + /** * Runs the consumer in a new context after setting a new version of the authentication that is compatible with the version provided. * The original context is provided to the consumer. When this method returns, the original context is restored. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java index 4ec9283115505..10e8f621fe0b3 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java @@ -150,6 +150,14 @@ public int hashCode() { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); + toXContentFragment(builder); + return builder.endObject(); + } + + /** + * Generates XContent without the start/end object. + */ + public void toXContentFragment(XContentBuilder builder) throws IOException { builder.field(User.Fields.USERNAME.getPreferredName(), user.principal()); builder.array(User.Fields.ROLES.getPreferredName(), user.roles()); builder.field(User.Fields.FULL_NAME.getPreferredName(), user.fullName()); @@ -169,7 +177,6 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field(User.Fields.REALM_TYPE.getPreferredName(), getAuthenticatedBy().getType()); } builder.endObject(); - return builder.endObject(); } @Override diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/AuthenticationContextSerializer.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/AuthenticationContextSerializer.java index 34c5f9160ec7e..86f5e7dcfc9cc 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/AuthenticationContextSerializer.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/AuthenticationContextSerializer.java @@ -75,6 +75,7 @@ public Authentication getAuthentication(ThreadContext context) { public void writeToContext(Authentication authentication, ThreadContext ctx) throws IOException { ensureContextDoesNotContainAuthentication(ctx); String header = authentication.encode(); + assert header != null : "Authentication object encoded to null"; // this usually happens with mock objects in tests ctx.putTransient(contextKey, authentication); ctx.putHeader(contextKey, header); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthentication.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthentication.java new file mode 100644 index 0000000000000..8f032f480e07f --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthentication.java @@ -0,0 +1,86 @@ +/* + * 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.core.security.authc.support; + +import org.elasticsearch.common.Nullable; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.authc.Authentication; + +import java.io.IOException; +import java.util.Objects; +import java.util.function.Function; + +/** + * Some Elasticsearch APIs need to be provided with 2 sets of credentials. + * Typically this happens when a system user needs to perform an action while accessing data on behalf of, or user information regarding + * a logged in user. + * This class is a representation of that secondary user that can be activated in the security context while processing specific blocks + * of code or within a listener. + */ +public class SecondaryAuthentication { + + private static final String THREAD_CTX_KEY = "_xpack_security_secondary_authc"; + + private final SecurityContext securityContext; + private final Authentication authentication; + + public SecondaryAuthentication(SecurityContext securityContext, Authentication authentication) { + this.securityContext = Objects.requireNonNull(securityContext); + this.authentication = Objects.requireNonNull(authentication); + } + + @Nullable + public static SecondaryAuthentication readFromContext(SecurityContext securityContext) throws IOException { + final Authentication authentication = serializer().readFromContext(securityContext.getThreadContext()); + if (authentication == null) { + return null; + } + return new SecondaryAuthentication(securityContext, authentication); + } + + public void writeToContext(ThreadContext threadContext) throws IOException { + serializer().writeToContext(this.authentication, threadContext); + } + + private static AuthenticationContextSerializer serializer() { + return new AuthenticationContextSerializer(THREAD_CTX_KEY); + } + + public Authentication getAuthentication() { + return authentication; + } + + public T execute(Function body) { + return this.securityContext.executeWithAuthentication(this.authentication, body); + } + + public Runnable wrap(Runnable runnable) { + return () -> execute(ignore -> { + runnable.run(); + return null; + }); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{" + authentication + "}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final SecondaryAuthentication that = (SecondaryAuthentication) o; + return authentication.equals(that.authentication); + } + + @Override + public int hashCode() { + return Objects.hash(authentication); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthenticationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthenticationTests.java new file mode 100644 index 0000000000000..dbdcdcd5a6a73 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/support/SecondaryAuthenticationTests.java @@ -0,0 +1,162 @@ +/* + * 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.core.security.authc.support; + +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ContextPreservingActionListener; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication; +import org.junit.After; +import org.junit.Before; + +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.sameInstance; + +public class SecondaryAuthenticationTests extends ESTestCase { + + private SecurityContext securityContext; + private ThreadPool threadPool; + + @Before + public void setupObjects() { + this.threadPool = new TestThreadPool(getTestName()); + this.securityContext = new SecurityContext(Settings.EMPTY, threadPool.getThreadContext()); + } + + @After + public void cleanup() { + this.threadPool.shutdownNow(); + } + + public void testCannotCreateObjectWithNullAuthentication() { + expectThrows(NullPointerException.class, () -> new SecondaryAuthentication(securityContext, null)); + } + + public void testSynchronousExecuteInSecondaryContext() { + final User user1 = new User("u1", "role1"); + securityContext.setUser(user1, Version.CURRENT); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm()); + final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2); + + assertThat(securityContext.getUser().principal(), equalTo("u1")); + String result = secondaryAuth.execute(original -> { + assertThat(securityContext.getUser().principal(), equalTo("u2")); + assertThat(securityContext.getAuthentication(), sameInstance(authentication2)); + return "xyzzy"; + }); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + assertThat(result, equalTo("xyzzy")); + } + + public void testSecondaryContextCanBeRestored() { + final User user1 = new User("u1", "role1"); + securityContext.setUser(user1, Version.CURRENT); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm()); + final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2); + + assertThat(securityContext.getUser().principal(), equalTo("u1")); + final AtomicReference secondaryContext = new AtomicReference<>(); + secondaryAuth.execute(storedContext -> { + assertThat(securityContext.getUser().principal(), equalTo("u2")); + assertThat(securityContext.getAuthentication(), sameInstance(authentication2)); + secondaryContext.set(threadPool.getThreadContext().newStoredContext(false)); + storedContext.restore(); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + return null; + }); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + secondaryContext.get().restore(); + assertThat(securityContext.getUser().principal(), equalTo("u2")); + } + + public void testWrapRunnable() throws Exception { + final User user1 = new User("u1", "role1"); + securityContext.setUser(user1, Version.CURRENT); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm()); + final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2); + + assertThat(securityContext.getUser().principal(), equalTo("u1")); + final Semaphore semaphore = new Semaphore(0); + final Future future = threadPool.generic().submit(secondaryAuth.wrap(() -> { + try { + assertThat(securityContext.getUser().principal(), equalTo("u2")); + semaphore.acquire(); + assertThat(securityContext.getUser().principal(), equalTo("u2")); + semaphore.acquire(); + assertThat(securityContext.getUser().principal(), equalTo("u2")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + })); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + semaphore.release(); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + semaphore.release(); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + // ensure that the runnable didn't throw any exceptions / assertions + future.get(1, TimeUnit.SECONDS); + } + + public void testPreserveSecondaryContextAcrossThreads() throws Exception { + final User user1 = new User("u1", "role1"); + securityContext.setUser(user1, Version.CURRENT); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm()); + final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2); + + assertThat(securityContext.getUser().principal(), equalTo("u1")); + + final AtomicReference threadUser = new AtomicReference<>(); + final AtomicReference listenerUser = new AtomicReference<>(); + + final ThreadContext threadContext = threadPool.getThreadContext(); + secondaryAuth.execute(originalContext -> { + assertThat(securityContext.getUser().principal(), equalTo("u2")); + ActionListener listener = new ContextPreservingActionListener<>(threadContext.newRestorableContext(false), + ActionListener.wrap(() -> listenerUser.set(securityContext.getUser()))); + originalContext.restore(); + threadPool.generic().execute(() -> { + threadUser.set(securityContext.getUser()); + listener.onResponse(null); + }); + return null; + }); + assertThat(securityContext.getUser().principal(), equalTo("u1")); + assertBusy(() -> assertThat(listenerUser.get(), notNullValue()), 1, TimeUnit.SECONDS); + assertThat(threadUser.get(), notNullValue()); + assertThat(threadUser.get().principal(), equalTo("u1")); + assertThat(listenerUser.get().principal(), equalTo("u2")); + } + + private Authentication.RealmRef realm() { + return new Authentication.RealmRef(randomAlphaOfLengthBetween(4, 8), randomAlphaOfLengthBetween(2, 4), randomAlphaOfLength(12)); + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index c6fae6eb99d51..7f6b85e7eebf6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -184,6 +184,7 @@ import org.elasticsearch.xpack.security.authc.TokenService; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; import org.elasticsearch.xpack.security.authz.AuthorizationService; import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener; @@ -288,6 +289,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin, private final SetOnce securityInterceptor = new SetOnce<>(); private final SetOnce ipFilter = new SetOnce<>(); private final SetOnce authcService = new SetOnce<>(); + private final SetOnce secondayAuthc = new SetOnce<>(); private final SetOnce auditTrailService = new SetOnce<>(); private final SetOnce securityContext = new SetOnce<>(); private final SetOnce threadContext = new SetOnce<>(); @@ -515,6 +517,10 @@ auditTrailService, failureHandler, threadPool, anonymousUser, getAuthorizationEn components.add(allRolesStore); // for SecurityFeatureSet and clear roles cache components.add(authzService); + final SecondaryAuthenticator secondaryAuthenticator = new SecondaryAuthenticator(securityContext.get(), authcService.get()); + this.secondayAuthc.set(secondaryAuthenticator); + components.add(secondaryAuthenticator); + ipFilter.set(new IPFilter(settings, auditTrailService, clusterService.getClusterSettings(), getLicenseState())); components.add(ipFilter.get()); DestructiveOperations destructiveOperations = new DestructiveOperations(settings, clusterService.getClusterSettings()); @@ -688,6 +694,7 @@ public Collection getRestHeaders() { } Set headers = new HashSet<>(); headers.add(new RestHeaderDefinition(UsernamePasswordToken.BASIC_AUTH_HEADER, false)); + headers.add(new RestHeaderDefinition(SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME, false)); if (XPackSettings.AUDIT_ENABLED.get(settings)) { headers.add(new RestHeaderDefinition(AuditTrail.X_FORWARDED_FOR_HEADER, true)); } @@ -996,7 +1003,8 @@ public UnaryOperator getRestHandlerWrapper(ThreadContext threadCont final boolean ssl = HTTP_SSL_ENABLED.get(settings); final SSLConfiguration httpSSLConfig = getSslService().getHttpTransportSSLConfiguration(); boolean extractClientCertificate = ssl && getSslService().isSSLClientAuthEnabled(httpSSLConfig); - return handler -> new SecurityRestFilter(getLicenseState(), threadContext, authcService.get(), handler, extractClientCertificate); + return handler -> new SecurityRestFilter(getLicenseState(), threadContext, authcService.get(), secondayAuthc.get(), + handler, extractClientCertificate); } @Override diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticator.java new file mode 100644 index 0000000000000..ab076e52aa05e --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticator.java @@ -0,0 +1,132 @@ +/* + * 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.support; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ContextPreservingActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.security.authc.AuthenticationService; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Performs "secondary user authentication" (that is, a second user, _not_ second factor authentication). + */ +public class SecondaryAuthenticator { + + /** + * The term "Authorization" in the header value is to mimic the standard HTTP "Authorization" header + */ + public static final String SECONDARY_AUTH_HEADER_NAME = "es-secondary-authorization"; + + private final Logger logger = LogManager.getLogger(); + private final SecurityContext securityContext; + private final AuthenticationService authenticationService; + + public SecondaryAuthenticator(Settings settings, ThreadContext threadContext, AuthenticationService authenticationService) { + this(new SecurityContext(settings, threadContext), authenticationService); + } + + public SecondaryAuthenticator(SecurityContext securityContext, AuthenticationService authenticationService) { + this.securityContext = securityContext; + this.authenticationService = authenticationService; + } + + /** + * @param listener Handler for the {@link SecondaryAuthentication} object. + * If the secondary authentication credentials do not exist the thread context, the + * {@link ActionListener#onResponse(Object)} method is called with a {@code null} authentication value. + * If the secondary authentication credentials are found in the thread context, but fail to be authenticated, then + * the failure is returned through {@link ActionListener#onFailure(Exception)}. + */ + public void authenticate(String action, TransportRequest request, ActionListener listener) { + // We never want the secondary authentication to fallback to anonymous. + // Use cases for secondary authentication are far more likely to want to fall back to the primary authentication if no secondary + // auth is provided, so in that case we do no want to set anything in the context + authenticate(authListener -> authenticationService.authenticate(action, request, false, authListener), listener); + } + + /** + * @param listener Handler for the {@link SecondaryAuthentication} object. + * If the secondary authentication credentials do not exist the thread context, the + * {@link ActionListener#onResponse(Object)} method is called with a {@code null} authentication value. + * If the secondary authentication credentials are found in the thread context, but fail to be authenticated, then + * the failure is returned through {@link ActionListener#onFailure(Exception)}. + */ + public void authenticateAndAttachToContext(RestRequest request, ActionListener listener) { + final ThreadContext threadContext = securityContext.getThreadContext(); + // We never want the secondary authentication to fallback to anonymous. + // Use cases for secondary authentication are far more likely to want to fall back to the primary authentication if no secondary + // auth is provided, so in that case we do no want to set anything in the context + authenticate(authListener -> authenticationService.authenticate(request, false, authListener), + ActionListener.wrap(secondaryAuthentication -> { + if (secondaryAuthentication != null) { + secondaryAuthentication.writeToContext(threadContext); + } + listener.onResponse(secondaryAuthentication); + }, + listener::onFailure)); + } + + private void authenticate(Consumer> authenticate, ActionListener listener) { + final ThreadContext threadContext = securityContext.getThreadContext(); + final String header = threadContext.getHeader(SECONDARY_AUTH_HEADER_NAME); + if (Strings.isNullOrEmpty(header)) { + logger.trace("no secondary authentication credentials found (the [{}] header is [{}])", SECONDARY_AUTH_HEADER_NAME, header); + listener.onResponse(null); + return; + } + + final Supplier originalContext = threadContext.newRestorableContext(false); + final ActionListener authenticationListener = new ContextPreservingActionListener<>(originalContext, + ActionListener.wrap( + authentication -> { + if (authentication == null) { + logger.debug("secondary authentication failed - authentication service returned a null authentication object"); + listener.onFailure(new ElasticsearchSecurityException("Failed to authenticate secondary user")); + } else { + logger.debug("secondary authentication succeeded [{}]", authentication); + listener.onResponse(new SecondaryAuthentication(securityContext, authentication)); + } + }, + e -> { + logger.debug("secondary authentication failed - authentication service responded with failure", e); + listener.onFailure(new ElasticsearchSecurityException("Failed to authenticate secondary user", e)); + } + )); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + logger.trace("found secondary authentication credentials, placing them in the internal [{}] header for authentication", + UsernamePasswordToken.BASIC_AUTH_HEADER); + threadContext.putHeader(UsernamePasswordToken.BASIC_AUTH_HEADER, header); + authenticate.accept(authenticationListener); + } + } + + /** + * Checks whether this thread context provides secondary authentication credentials. + * This does not check whether the header contains valid credentials + * - you must call {@link #authenticateAndAttachToContext} to validate the header. + * + * @return {@code true} if a secondary authentication header exists in the thread context. + */ + public boolean hasSecondaryAuthenticationHeader() { + final String header = securityContext.getThreadContext().getHeader(SECONDARY_AUTH_HEADER_NAME); + return Strings.isNullOrEmpty(header) == false; + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/SecurityRestFilter.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/SecurityRestFilter.java index 62cf944819a27..a2b159c1024b0 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/SecurityRestFilter.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/SecurityRestFilter.java @@ -21,6 +21,7 @@ import org.elasticsearch.rest.RestRequest.Method; import org.elasticsearch.xpack.core.security.rest.RestRequestFilter; import org.elasticsearch.xpack.security.authc.AuthenticationService; +import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator; import org.elasticsearch.xpack.security.transport.SSLEngineUtils; import java.io.IOException; @@ -31,17 +32,19 @@ public class SecurityRestFilter implements RestHandler { private static final Logger logger = LogManager.getLogger(SecurityRestFilter.class); private final RestHandler restHandler; - private final AuthenticationService service; + private final AuthenticationService authenticationService; + private final SecondaryAuthenticator secondaryAuthenticator; private final XPackLicenseState licenseState; private final ThreadContext threadContext; private final boolean extractClientCertificate; - public SecurityRestFilter(XPackLicenseState licenseState, ThreadContext threadContext, AuthenticationService service, - RestHandler restHandler, boolean extractClientCertificate) { - this.restHandler = restHandler; - this.service = service; + public SecurityRestFilter(XPackLicenseState licenseState, ThreadContext threadContext, AuthenticationService authenticationService, + SecondaryAuthenticator secondaryAuthenticator, RestHandler restHandler, boolean extractClientCertificate) { this.licenseState = licenseState; this.threadContext = threadContext; + this.authenticationService = authenticationService; + this.secondaryAuthenticator = secondaryAuthenticator; + this.restHandler = restHandler; this.extractClientCertificate = extractClientCertificate; } @@ -53,30 +56,41 @@ public void handleRequest(RestRequest request, RestChannel channel, NodeClient c HttpChannel httpChannel = request.getHttpChannel(); SSLEngineUtils.extractClientCertificates(logger, threadContext, httpChannel); } - service.authenticate(maybeWrapRestRequest(request), ActionListener.wrap( + + final String requestUri = request.uri(); + authenticationService.authenticate(maybeWrapRestRequest(request), ActionListener.wrap( authentication -> { if (authentication == null) { - logger.trace("No authentication available for REST request [{}]", request.uri()); + logger.trace("No authentication available for REST request [{}]", requestUri); } else { - logger.trace("Authenticated REST request [{}] as {}", request.uri(), authentication); - } - RemoteHostHeader.process(request, threadContext); - restHandler.handleRequest(request, channel, client); - }, e -> { - logger.debug(new ParameterizedMessage("Authentication failed for REST request [{}]", request.uri()), e); - try { - channel.sendResponse(new BytesRestResponse(channel, e)); - } catch (Exception inner) { - inner.addSuppressed(e); - logger.error((Supplier) () -> - new ParameterizedMessage("failed to send failure response for uri [{}]", request.uri()), inner); + logger.trace("Authenticated REST request [{}] as {}", requestUri, authentication); } - })); + secondaryAuthenticator.authenticateAndAttachToContext(request, ActionListener.wrap( + secondaryAuthentication -> { + if (secondaryAuthentication != null) { + logger.trace("Found secondary authentication {} in REST request [{}]", secondaryAuthentication, requestUri); + } + RemoteHostHeader.process(request, threadContext); + restHandler.handleRequest(request, channel, client); + }, + e -> handleException("Secondary authentication", request, channel, e))); + }, e -> handleException("Authentication", request, channel, e))); } else { restHandler.handleRequest(request, channel, client); } } + private void handleException(String actionType, RestRequest request, RestChannel channel, Exception e) { + logger.debug(new ParameterizedMessage("{} failed for REST request [{}]", actionType, request.uri()), e); + try { + channel.sendResponse(new BytesRestResponse(channel, e)); + } catch (Exception inner) { + inner.addSuppressed(e); + logger.error((Supplier) () -> + new ParameterizedMessage("failed to send failure response for uri [{}]", request.uri()), inner); + } + } + @Override public boolean canTripCircuitBreaker() { return restHandler.canTripCircuitBreaker(); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailFilterTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailFilterTests.java index f4e0deac3e8dd..0534267c32df7 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailFilterTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailFilterTests.java @@ -27,12 +27,12 @@ import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; +import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; import org.elasticsearch.xpack.core.security.user.SystemUser; import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.audit.logfile.LoggingAuditTrail.AuditEventMetaInfo; import org.elasticsearch.xpack.security.audit.logfile.LoggingAuditTrailTests.MockMessage; import org.elasticsearch.xpack.security.audit.logfile.LoggingAuditTrailTests.RestContent; -import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; import org.elasticsearch.xpack.security.rest.RemoteHostHeader; import org.elasticsearch.xpack.security.transport.filter.SecurityIpFilterRule; import org.junit.Before; diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java new file mode 100644 index 0000000000000..1368ab5d1ff7f --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java @@ -0,0 +1,65 @@ +/* + * 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.support; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.user.User; + +import java.util.HashMap; +import java.util.Map; + +public class DummyUsernamePasswordRealm extends UsernamePasswordRealm { + + private Map> users; + + public DummyUsernamePasswordRealm(RealmConfig config) { + super(config); + this.users = new HashMap<>(); + } + + public void defineUser(User user, SecureString password) { + this.users.put(user.principal(), new Tuple<>(password, user)); + } + + public void defineUser(String username, SecureString password) { + defineUser(new User(username), password); + } + + @Override + public void authenticate(AuthenticationToken token, ActionListener listener) { + if (token instanceof UsernamePasswordToken) { + UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token; + User user = authenticate(usernamePasswordToken.principal(), usernamePasswordToken.credentials()); + if (user != null) { + listener.onResponse(AuthenticationResult.success(user)); + } else { + listener.onResponse(AuthenticationResult.unsuccessful("Failed to authenticate " + usernamePasswordToken.principal(), null)); + } + } else { + listener.onResponse(AuthenticationResult.notHandled()); + } + } + + private User authenticate(String principal, SecureString credentials) { + final Tuple tuple = users.get(principal); + if (tuple.v1().equals(credentials)) { + return tuple.v2(); + } + return null; + } + + @Override + public void lookupUser(String username, ActionListener listener) { + listener.onResponse(null); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java new file mode 100644 index 0000000000000..8dbe311a20284 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java @@ -0,0 +1,294 @@ +/* + * 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.support; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.license.License; +import org.elasticsearch.license.TestUtils; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.TestMatchers; +import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportRequest; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateAction; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType; +import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; +import org.elasticsearch.xpack.core.security.authc.AuthenticationFailureHandler; +import org.elasticsearch.xpack.core.security.authc.DefaultAuthenticationFailureHandler; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.RealmConfig.RealmIdentifier; +import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication; +import org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames; +import org.elasticsearch.xpack.core.security.user.AnonymousUser; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.audit.AuditTrailService; +import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.AuthenticationService; +import org.elasticsearch.xpack.security.authc.Realms; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; +import org.elasticsearch.xpack.security.test.SecurityMocks; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; +import org.mockito.Mockito; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Base64; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SecondaryAuthenticatorTests extends ESTestCase { + + private AuthenticationService authenticationService; + private SecondaryAuthenticator authenticator; + private DummyUsernamePasswordRealm realm; + private ThreadPool threadPool; + private SecurityContext securityContext; + private TokenService tokenService; + private Client client; + + @Before + public void setupMocks() throws Exception { + threadPool = new TestThreadPool(getTestName()); + final ThreadContext threadContext = threadPool.getThreadContext(); + + final Realms realms = mock(Realms.class); + final Settings settings = Settings.builder() + .put(buildEnvSettings(Settings.EMPTY)) + .put("xpack.security.authc.realms.dummy.test_realm.order", 1) + .put("xpack.security.authc.token.enabled", true) + .put("xpack.security.authc.api_key.enabled", false) + .build(); + final Environment env = TestEnvironment.newEnvironment(settings); + + realm = new DummyUsernamePasswordRealm(new RealmConfig(new RealmIdentifier("dummy", "test_realm"), settings, env, threadContext)); + when(realms.asList()).thenReturn(Collections.singletonList(realm)); + when(realms.getUnlicensedRealms()).thenReturn(Collections.emptyList()); + + final AuditTrailService auditTrail = mock(AuditTrailService.class); + final AuthenticationFailureHandler failureHandler = new DefaultAuthenticationFailureHandler(Collections.emptyMap()); + final AnonymousUser anonymous = new AnonymousUser(settings); + + final SecurityIndexManager securityIndex = SecurityMocks.mockSecurityIndexManager(RestrictedIndicesNames.SECURITY_MAIN_ALIAS); + final SecurityIndexManager tokensIndex = SecurityMocks.mockSecurityIndexManager(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS); + + client = Mockito.mock(Client.class); + when(client.threadPool()).thenReturn(threadPool); + + final TestUtils.UpdatableLicenseState licenseState = new TestUtils.UpdatableLicenseState(); + licenseState.update(License.OperationMode.PLATINUM, true, null); + + final Clock clock = Clock.systemUTC(); + + final ClusterService clusterService = mock(ClusterService.class); + final ClusterState clusterState = ClusterState.EMPTY_STATE; + when(clusterService.state()).thenReturn(clusterState); + + securityContext = new SecurityContext(settings, threadContext); + + tokenService = new TokenService(settings, clock, client, licenseState, securityContext, securityIndex, tokensIndex, clusterService); + final ApiKeyService apiKeyService = new ApiKeyService(settings, clock, client, licenseState, + securityIndex, clusterService, threadPool); + authenticationService = new AuthenticationService(settings, realms, auditTrail, failureHandler, threadPool, anonymous, + tokenService, apiKeyService); + authenticator = new SecondaryAuthenticator(securityContext, authenticationService); + } + + @After + public void cleanupMocks() throws Exception { + threadPool.shutdownNow(); + } + + public void testAuthenticateTransportRequestIsANoOpIfHeaderIsMissing() throws Exception { + final TransportRequest request = new AuthenticateRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + authenticator.authenticate(AuthenticateAction.NAME, request, future); + + assertThat(future.get(0, TimeUnit.MILLISECONDS), nullValue()); + } + + public void testAuthenticateRestRequestIsANoOpIfHeaderIsMissing() throws Exception { + final RestRequest request = new FakeRestRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + authenticator.authenticateAndAttachToContext(request, future); + + assertThat(future.get(0, TimeUnit.MILLISECONDS), nullValue()); + assertThat(SecondaryAuthentication.readFromContext(securityContext), nullValue()); + } + + public void testAuthenticateTransportRequestFailsIfHeaderHasUnrecognizedCredentials() throws Exception { + threadPool.getThreadContext().putHeader(SECONDARY_AUTH_HEADER_NAME, "Fake " + randomAlphaOfLengthBetween(5, 30)); + final TransportRequest request = new AuthenticateRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + authenticator.authenticate(AuthenticateAction.NAME, request, future); + + final ElasticsearchSecurityException ex = expectThrows(ElasticsearchSecurityException.class, + () -> future.actionGet(0, TimeUnit.MILLISECONDS)); + assertThat(ex, TestMatchers.throwableWithMessage(Matchers.containsString("secondary user"))); + assertThat(ex.getCause(), TestMatchers.throwableWithMessage(Matchers.containsString("credentials"))); + } + + public void testAuthenticateRestRequestFailsIfHeaderHasUnrecognizedCredentials() throws Exception { + threadPool.getThreadContext().putHeader(SECONDARY_AUTH_HEADER_NAME, "Fake " + randomAlphaOfLengthBetween(5, 30)); + final RestRequest request = new FakeRestRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + authenticator.authenticateAndAttachToContext(request, future); + + final ElasticsearchSecurityException ex = expectThrows(ElasticsearchSecurityException.class, + () -> future.actionGet(0, TimeUnit.MILLISECONDS)); + assertThat(ex, TestMatchers.throwableWithMessage(Matchers.containsString("secondary user"))); + assertThat(ex.getCause(), TestMatchers.throwableWithMessage(Matchers.containsString("credentials"))); + + assertThat(SecondaryAuthentication.readFromContext(securityContext), nullValue()); + } + + public void testAuthenticateTransportRequestSucceedsWithBasicAuthentication() throws Exception { + assertAuthenticateWithBasicAuthentication(listener -> { + final TransportRequest request = new AuthenticateRequest(); + authenticator.authenticate(AuthenticateAction.NAME, request, listener); + }); + } + + public void testAuthenticateRestRequestSucceedsWithBasicAuthentication() throws Exception { + final SecondaryAuthentication secondaryAuthentication = assertAuthenticateWithBasicAuthentication(listener -> { + final RestRequest request = new FakeRestRequest(); + authenticator.authenticateAndAttachToContext(request, listener); + }); + assertThat(SecondaryAuthentication.readFromContext(securityContext), equalTo(secondaryAuthentication)); + } + + private SecondaryAuthentication assertAuthenticateWithBasicAuthentication(Consumer> consumer) + throws Exception { + final String user = randomAlphaOfLengthBetween(6, 12); + final SecureString password = new SecureString(randomAlphaOfLengthBetween(8, 24).toCharArray()); + realm.defineUser(user, password); + + threadPool.getThreadContext().putHeader(SECONDARY_AUTH_HEADER_NAME, "Basic " + + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8))); + + final PlainActionFuture future = new PlainActionFuture<>(); + final AtomicReference listenerContext = new AtomicReference<>(); + consumer.accept(ActionListener.wrap( + result -> { + listenerContext.set(securityContext.getThreadContext().newStoredContext(false)); + future.onResponse(result); + }, + e -> future.onFailure(e) + )); + + final SecondaryAuthentication secondaryAuthentication = future.get(0, TimeUnit.MILLISECONDS); + assertThat(secondaryAuthentication, Matchers.notNullValue()); + assertThat(secondaryAuthentication.getAuthentication(), Matchers.notNullValue()); + assertThat(secondaryAuthentication.getAuthentication().getUser().principal(), equalTo(user)); + assertThat(secondaryAuthentication.getAuthentication().getAuthenticatedBy().getName(), equalTo(realm.name())); + + listenerContext.get().restore(); + return secondaryAuthentication; + } + + public void testAuthenticateTransportRequestFailsWithIncorrectPassword() throws Exception { + assertAuthenticateWithIncorrectPassword(listener -> { + final TransportRequest request = new AuthenticateRequest(); + authenticator.authenticate(AuthenticateAction.NAME, request, listener); + }); + } + + public void testAuthenticateRestRequestFailsWithIncorrectPassword() throws Exception { + assertAuthenticateWithIncorrectPassword(listener -> { + final RestRequest request = new FakeRestRequest(); + authenticator.authenticateAndAttachToContext(request, listener); + }); + assertThat(SecondaryAuthentication.readFromContext(securityContext), nullValue()); + } + + private void assertAuthenticateWithIncorrectPassword(Consumer> consumer) { + final String user = randomAlphaOfLengthBetween(6, 12); + final SecureString password = new SecureString(randomAlphaOfLengthBetween(8, 24).toCharArray()); + realm.defineUser(user, password); + + threadPool.getThreadContext().putHeader(SECONDARY_AUTH_HEADER_NAME, "Basic " + + Base64.getEncoder().encodeToString((user + ":NOT-" + password).getBytes(StandardCharsets.UTF_8))); + + final PlainActionFuture future = new PlainActionFuture<>(); + final AtomicReference listenerContext = new AtomicReference<>(); + consumer.accept(ActionListener.wrap( + future::onResponse, + e -> { + listenerContext.set(securityContext.getThreadContext().newStoredContext(false)); + future.onFailure(e); + } + )); + + final ElasticsearchSecurityException ex = expectThrows(ElasticsearchSecurityException.class, + () -> future.actionGet(0, TimeUnit.MILLISECONDS)); + + assertThat(ex, TestMatchers.throwableWithMessage(Matchers.containsString("secondary user"))); + assertThat(ex.getCause(), TestMatchers.throwableWithMessage(Matchers.containsString(user))); + + listenerContext.get().restore(); + } + + public void testAuthenticateUsingBearerToken() throws Exception { + final User user = new User(randomAlphaOfLengthBetween(6, 12)); + Authentication auth = new Authentication(user, + new RealmRef(randomAlphaOfLengthBetween(4, 8), randomAlphaOfLengthBetween(3, 6), randomAlphaOfLengthBetween(8, 12)), + null); + + final AtomicReference tokenDocId = new AtomicReference<>(); + final AtomicReference tokenSource = new AtomicReference<>(); + SecurityMocks.mockIndexRequest(client, RestrictedIndicesNames.SECURITY_TOKENS_ALIAS, request -> { + tokenDocId.set(request.id()); + tokenSource.set(request.source()); + }); + + final PlainActionFuture> tokenFuture = new PlainActionFuture<>(); + tokenService.createOAuth2Tokens(auth, auth, Collections.emptyMap(), false, tokenFuture); + final String token = tokenFuture.actionGet().v1(); + + threadPool.getThreadContext().putHeader(SECONDARY_AUTH_HEADER_NAME, "Bearer " + token); + + SecurityMocks.mockGetRequest(client, RestrictedIndicesNames.SECURITY_TOKENS_ALIAS, tokenDocId.get(), tokenSource.get()); + + final TransportRequest request = new AuthenticateRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + authenticator.authenticate(AuthenticateAction.NAME, request, future); + + final SecondaryAuthentication secondaryAuthentication = future.actionGet(0, TimeUnit.MILLISECONDS); + assertThat(secondaryAuthentication, Matchers.notNullValue()); + assertThat(secondaryAuthentication.getAuthentication(), Matchers.notNullValue()); + assertThat(secondaryAuthentication.getAuthentication().getUser(), equalTo(user)); + assertThat(secondaryAuthentication.getAuthentication().getAuthenticationType(), equalTo(AuthenticationType.TOKEN)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/SecurityRestFilterTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/SecurityRestFilterTests.java index 4c0ca977a2119..2486eb8f2cc6b 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/SecurityRestFilterTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/SecurityRestFilterTests.java @@ -5,6 +5,7 @@ */ package org.elasticsearch.xpack.security.rest; +import com.nimbusds.jose.util.StandardCharset; import org.apache.lucene.util.SetOnce; import org.elasticsearch.action.ActionListener; import org.elasticsearch.client.node.NodeClient; @@ -25,19 +26,26 @@ import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.SecuritySettingsSourceField; import org.elasticsearch.test.rest.FakeRestRequest; +import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; +import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication; import org.elasticsearch.xpack.core.security.rest.RestRequestFilter; import org.elasticsearch.xpack.core.security.user.XPackUser; import org.elasticsearch.xpack.security.authc.AuthenticationService; +import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator; import org.junit.Before; import org.mockito.ArgumentCaptor; +import java.util.Base64; import java.util.Collections; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.sameInstance; import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; @@ -48,7 +56,9 @@ public class SecurityRestFilterTests extends ESTestCase { + private ThreadContext threadContext; private AuthenticationService authcService; + private SecondaryAuthenticator secondaryAuthenticator; private RestChannel channel; private SecurityRestFilter filter; private XPackLicenseState licenseState; @@ -61,8 +71,9 @@ public void init() throws Exception { licenseState = mock(XPackLicenseState.class); when(licenseState.isAuthAllowed()).thenReturn(true); restHandler = mock(RestHandler.class); - filter = new SecurityRestFilter(licenseState, - new ThreadContext(Settings.EMPTY), authcService, restHandler, false); + threadContext = new ThreadContext(Settings.EMPTY); + secondaryAuthenticator = new SecondaryAuthenticator(Settings.EMPTY, threadContext, authcService); + filter = new SecurityRestFilter(licenseState, threadContext, authcService, secondaryAuthenticator, restHandler, false); } public void testProcess() throws Exception { @@ -80,6 +91,48 @@ public void testProcess() throws Exception { verifyZeroInteractions(channel); } + public void testProcessSecondaryAuthentication() throws Exception { + RestRequest request = mock(RestRequest.class); + when(channel.request()).thenReturn(request); + + when(request.getHttpChannel()).thenReturn(mock(HttpChannel.class)); + + Authentication primaryAuthentication = mock(Authentication.class); + when(primaryAuthentication.encode()).thenReturn(randomAlphaOfLengthBetween(12, 36)); + doAnswer(i -> { + final Object[] arguments = i.getArguments(); + ActionListener callback = (ActionListener) arguments[arguments.length - 1]; + callback.onResponse(primaryAuthentication); + return null; + }).when(authcService).authenticate(eq(request), any(ActionListener.class)); + + Authentication secondaryAuthentication = mock(Authentication.class); + when(secondaryAuthentication.encode()).thenReturn(randomAlphaOfLengthBetween(12, 36)); + doAnswer(i -> { + final Object[] arguments = i.getArguments(); + ActionListener callback = (ActionListener) arguments[arguments.length - 1]; + callback.onResponse(secondaryAuthentication); + return null; + }).when(authcService).authenticate(eq(request), eq(false), any(ActionListener.class)); + + SecurityContext securityContext = new SecurityContext(Settings.EMPTY, threadContext); + AtomicReference secondaryAuthRef = new AtomicReference<>(); + doAnswer(i -> { + secondaryAuthRef.set(securityContext.getSecondaryAuthentication()); + return null; + }).when(restHandler).handleRequest(request, channel, null); + + final String credentials = randomAlphaOfLengthBetween(4, 8) + ":" + randomAlphaOfLengthBetween(4, 12); + threadContext.putHeader(SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME, + "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharset.UTF_8))); + filter.handleRequest(request, channel, null); + verify(restHandler).handleRequest(request, channel, null); + verifyZeroInteractions(channel); + + assertThat(secondaryAuthRef.get(), notNullValue()); + assertThat(secondaryAuthRef.get().getAuthentication(), sameInstance(secondaryAuthentication)); + } + public void testProcessBasicLicense() throws Exception { RestRequest request = mock(RestRequest.class); when(licenseState.isAuthAllowed()).thenReturn(false); @@ -93,7 +146,7 @@ public void testProcessAuthenticationError() throws Exception { Exception exception = authenticationError("failed authc"); doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; + (ActionListener) i.getArguments()[1]; callback.onFailure(exception); return Void.TYPE; }).when(authcService).authenticate(eq(request), any(ActionListener.class)); @@ -117,8 +170,8 @@ public void testProcessOptionsMethod() throws Exception { public void testProcessFiltersBodyCorrectly() throws Exception { FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY) - .withContent(new BytesArray("{\"password\": \"" + SecuritySettingsSourceField.TEST_PASSWORD + "\", \"foo\": \"bar\"}"), - XContentType.JSON).build(); + .withContent(new BytesArray("{\"password\": \"" + SecuritySettingsSourceField.TEST_PASSWORD + "\", \"foo\": \"bar\"}"), + XContentType.JSON).build(); when(channel.request()).thenReturn(restRequest); SetOnce handlerRequest = new SetOnce<>(); restHandler = new FilteredRestHandler() { @@ -135,20 +188,20 @@ public Set getFilteredFields() { SetOnce authcServiceRequest = new SetOnce<>(); doAnswer((i) -> { ActionListener callback = - (ActionListener) i.getArguments()[1]; - authcServiceRequest.set((RestRequest)i.getArguments()[0]); + (ActionListener) i.getArguments()[1]; + authcServiceRequest.set((RestRequest) i.getArguments()[0]); callback.onResponse(new Authentication(XPackUser.INSTANCE, new RealmRef("test", "test", "t"), null)); return Void.TYPE; }).when(authcService).authenticate(any(RestRequest.class), any(ActionListener.class)); - filter = new SecurityRestFilter(licenseState, new ThreadContext(Settings.EMPTY), authcService, restHandler, false); + filter = new SecurityRestFilter(licenseState, threadContext, authcService, secondaryAuthenticator, restHandler, false); filter.handleRequest(restRequest, channel, null); assertEquals(restRequest, handlerRequest.get()); assertEquals(restRequest.content(), handlerRequest.get().content()); Map original = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, - DeprecationHandler.THROW_UNSUPPORTED_OPERATION, handlerRequest.get().content().streamInput()).map(); + .createParser(NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, handlerRequest.get().content().streamInput()).map(); assertEquals(2, original.size()); assertEquals(SecuritySettingsSourceField.TEST_PASSWORD, original.get("password")); assertEquals("bar", original.get("foo")); @@ -157,11 +210,12 @@ public Set getFilteredFields() { assertNotEquals(restRequest.content(), authcServiceRequest.get().content()); Map map = XContentType.JSON.xContent() - .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, - authcServiceRequest.get().content().streamInput()).map(); + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + authcServiceRequest.get().content().streamInput()).map(); assertEquals(1, map.size()); assertEquals("bar", map.get("foo")); } - private interface FilteredRestHandler extends RestHandler, RestRequestFilter {} + private interface FilteredRestHandler extends RestHandler, RestRequestFilter { + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java index 20108b0114933..1e192361e9b53 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java @@ -7,13 +7,20 @@ package org.elasticsearch.xpack.security.test; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.ActionType; import org.elasticsearch.action.get.GetAction; import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetRequestBuilder; import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.action.index.IndexAction; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexRequestBuilder; +import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.client.Client; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.index.get.GetResult; +import org.elasticsearch.index.shard.ShardId; +import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.junit.Assert; @@ -26,6 +33,8 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -44,10 +53,14 @@ private SecurityMocks() { } public static SecurityIndexManager mockSecurityIndexManager() { - return mockSecurityIndexManager(true, true); + return mockSecurityIndexManager(".security"); } - public static SecurityIndexManager mockSecurityIndexManager(boolean exists, boolean available) { + public static SecurityIndexManager mockSecurityIndexManager(String alias) { + return mockSecurityIndexManager(alias, true, true); + } + + public static SecurityIndexManager mockSecurityIndexManager(String alias, boolean exists, boolean available) { final SecurityIndexManager securityIndexManager = mock(SecurityIndexManager.class); doAnswer(invocationOnMock -> { Runnable runnable = (Runnable) invocationOnMock.getArguments()[1]; @@ -61,28 +74,33 @@ public static SecurityIndexManager mockSecurityIndexManager(boolean exists, bool }).when(securityIndexManager).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); when(securityIndexManager.indexExists()).thenReturn(exists); when(securityIndexManager.isAvailable()).thenReturn(available); + when(securityIndexManager.aliasName()).thenReturn(alias); return securityIndexManager; } public static void mockGetRequest(Client client, String documentId, BytesReference source) { - GetResult result = new GetResult(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME, documentId, 0, 1, 1, true, source, + mockGetRequest(client, SECURITY_MAIN_ALIAS, documentId, source); + } + + public static void mockGetRequest(Client client, String indexAliasName, String documentId, BytesReference source) { + GetResult result = new GetResult(indexAliasName, SINGLE_MAPPING_NAME, documentId, 0, 1, 1, true, source, emptyMap(), emptyMap()); - mockGetRequest(client, documentId, result); + mockGetRequest(client, indexAliasName, documentId, result); } - public static void mockGetRequest(Client client, String documentId, GetResult result) { + public static void mockGetRequest(Client client, String indexAliasName, String documentId, GetResult result) { final GetRequestBuilder requestBuilder = new GetRequestBuilder(client, GetAction.INSTANCE); - requestBuilder.setIndex(SECURITY_MAIN_ALIAS); + requestBuilder.setIndex(indexAliasName); requestBuilder.setType(SINGLE_MAPPING_NAME); requestBuilder.setId(documentId); - when(client.prepareGet(SECURITY_MAIN_ALIAS, SINGLE_MAPPING_NAME, documentId)).thenReturn(requestBuilder); + when(client.prepareGet(indexAliasName, SINGLE_MAPPING_NAME, documentId)).thenReturn(requestBuilder); doAnswer(inv -> { Assert.assertThat(inv.getArguments(), arrayWithSize(2)); Assert.assertThat(inv.getArguments()[0], instanceOf(GetRequest.class)); final GetRequest request = (GetRequest) inv.getArguments()[0]; Assert.assertThat(request.id(), equalTo(documentId)); - Assert.assertThat(request.index(), equalTo(SECURITY_MAIN_ALIAS)); + Assert.assertThat(request.index(), equalTo(indexAliasName)); Assert.assertThat(request.type(), equalTo(SINGLE_MAPPING_NAME)); Assert.assertThat(inv.getArguments()[1], instanceOf(ActionListener.class)); @@ -92,4 +110,45 @@ public static void mockGetRequest(Client client, String documentId, GetResult re return null; }).when(client).get(any(GetRequest.class), any(ActionListener.class)); } + + public static void mockIndexRequest(Client client, String indexAliasName, Consumer consumer) { + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + Assert.assertThat(args, arrayWithSize(2)); + final Object requestIndex = args[0]; + final Object requestType = args[1]; + Assert.assertThat(requestIndex, instanceOf(String.class)); + Assert.assertThat(requestType, equalTo(SINGLE_MAPPING_NAME)); + return new IndexRequestBuilder(client, IndexAction.INSTANCE) + .setIndex((String) requestIndex) + .setType((String) requestType); + }).when(client).prepareIndex(anyString(), anyString()); + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + Assert.assertThat(args, arrayWithSize(3)); + final Object requestIndex = args[0]; + final Object requestType = args[1]; + final Object requestId = args[2]; + Assert.assertThat(requestIndex, instanceOf(String.class)); + Assert.assertThat(requestType, equalTo(SINGLE_MAPPING_NAME)); + Assert.assertThat(requestId, instanceOf(String.class)); + return new IndexRequestBuilder(client, IndexAction.INSTANCE) + .setIndex((String) requestIndex) + .setType((String) requestType) + .setId((String) requestId); + }).when(client).prepareIndex(anyString(), anyString(), anyString()); + doAnswer(inv -> { + Assert.assertThat(inv.getArguments(), arrayWithSize(3)); + Assert.assertThat(inv.getArguments()[0], instanceOf(ActionType.class)); + Assert.assertThat(inv.getArguments()[1], instanceOf(IndexRequest.class)); + final IndexRequest request = (IndexRequest) inv.getArguments()[1]; + Assert.assertThat(request.index(), equalTo(indexAliasName)); + consumer.accept(request); + Assert.assertThat(inv.getArguments()[2], instanceOf(ActionListener.class)); + final ActionListener listener = (ActionListener) inv.getArguments()[2]; + final ShardId shardId = new ShardId(request.index(), ESTestCase.randomAlphaOfLength(12), 0); + listener.onResponse(new IndexResponse(shardId, request.type(), request.id(), 1, 1, 1, true)); + return null; + }).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class)); + } }