Skip to content

Commit 6fd9b62

Browse files
committed
Add support for secondary authentication
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.
1 parent 8fcbd1d commit 6fd9b62

File tree

13 files changed

+884
-34
lines changed

13 files changed

+884
-34
lines changed

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

+27
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
import org.elasticsearch.xpack.core.security.authc.Authentication;
1616
import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
1717
import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer;
18+
import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication;
1819
import org.elasticsearch.xpack.core.security.user.User;
1920

2021
import java.io.IOException;
2122
import java.io.UncheckedIOException;
2223
import java.util.Collections;
2324
import java.util.Objects;
2425
import java.util.function.Consumer;
26+
import java.util.function.Function;
2527

2628
/**
2729
* A lightweight utility that can find the current user and authentication information for the local thread.
@@ -55,6 +57,19 @@ public Authentication getAuthentication() {
5557
}
5658
}
5759

60+
/**
61+
* Returns the "secondary authentication" (see {@link SecondaryAuthentication}) information,
62+
* or {@code null} if the current request does not have a secondary authentication context
63+
*/
64+
public SecondaryAuthentication getSecondaryAuthentication() {
65+
try {
66+
return SecondaryAuthentication.restoreFromContext(this);
67+
} catch (IOException e) {
68+
logger.error("failed to read secondary authentication", e);
69+
throw new UncheckedIOException(e);
70+
}
71+
}
72+
5873
public ThreadContext getThreadContext() {
5974
return threadContext;
6075
}
@@ -97,6 +112,18 @@ public void executeAsUser(User user, Consumer<StoredContext> consumer, Version v
97112
}
98113
}
99114

115+
/**
116+
* Runs the consumer in a new context as the provided user. The original context is provided to the consumer. When this method
117+
* returns, the original context is restored.
118+
*/
119+
public <T> T executeWithAuthentication(Authentication authentication, Function<StoredContext, T> consumer) {
120+
final StoredContext original = threadContext.newStoredContext(true);
121+
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
122+
setAuthentication(authentication);
123+
return consumer.apply(original);
124+
}
125+
}
126+
100127
/**
101128
* Runs the consumer in a new context after setting a new version of the authentication that is compatible with the version provided.
102129
* The original context is provided to the consumer. When this method returns, the original context is restored.

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ public int hashCode() {
143143
@Override
144144
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
145145
builder.startObject();
146+
toXContentFragment(builder);
147+
return builder.endObject();
148+
}
149+
150+
/**
151+
* Generates XContent without the start/end object.
152+
*/
153+
public void toXContentFragment(XContentBuilder builder) throws IOException {
146154
builder.field(User.Fields.USERNAME.getPreferredName(), user.principal());
147155
builder.array(User.Fields.ROLES.getPreferredName(), user.roles());
148156
builder.field(User.Fields.FULL_NAME.getPreferredName(), user.fullName());
@@ -162,7 +170,6 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
162170
builder.field(User.Fields.REALM_TYPE.getPreferredName(), getAuthenticatedBy().getType());
163171
}
164172
builder.endObject();
165-
return builder.endObject();
166173
}
167174

168175
@Override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.authc.support;
8+
9+
import org.elasticsearch.action.ActionListener;
10+
import org.elasticsearch.common.Nullable;
11+
import org.elasticsearch.common.util.concurrent.ThreadContext;
12+
import org.elasticsearch.rest.RestRequest;
13+
import org.elasticsearch.transport.TransportRequest;
14+
import org.elasticsearch.xpack.core.security.SecurityContext;
15+
import org.elasticsearch.xpack.core.security.authc.Authentication;
16+
17+
import java.io.IOException;
18+
import java.util.Objects;
19+
import java.util.function.Function;
20+
21+
/**
22+
* Some Elasticsearch APIs need to be provided with 2 sets of credentials.
23+
* Typically this happens when a system user needs to perform an action while accessing data on behalf of, or user information regarding
24+
* a logged in user.
25+
* This class is a representation of that secondary user that can be activated in the security context while processing specific blocks
26+
* of code or within a listener.
27+
*/
28+
public class SecondaryAuthentication {
29+
30+
private static final String THREAD_CTX_KEY = "_xpack_security_2nd_authc";
31+
32+
public interface Authenticator {
33+
34+
void authenticate(String action, TransportRequest request, ActionListener<SecondaryAuthentication> listener);
35+
36+
void authenticateAndAttachToContext(RestRequest request, ActionListener<SecondaryAuthentication> listener);
37+
38+
boolean hasSecondaryAuthenticationHeader();
39+
}
40+
41+
private final SecurityContext securityContext;
42+
private final Authentication authentication;
43+
44+
public SecondaryAuthentication(SecurityContext securityContext, Authentication authentication) {
45+
this.securityContext = Objects.requireNonNull(securityContext);
46+
this.authentication = Objects.requireNonNull(authentication);
47+
}
48+
49+
@Nullable
50+
public static SecondaryAuthentication restoreFromContext(SecurityContext securityContext) throws IOException {
51+
final Authentication authentication = serializer().readFromContext(securityContext.getThreadContext());
52+
if (authentication == null) {
53+
return null;
54+
}
55+
return new SecondaryAuthentication(securityContext, authentication);
56+
}
57+
58+
public void writeToContext(ThreadContext threadContext) throws IOException {
59+
serializer().writeToContext(this.authentication, threadContext);
60+
}
61+
62+
private static AuthenticationContextSerializer serializer() {
63+
return new AuthenticationContextSerializer(THREAD_CTX_KEY);
64+
}
65+
66+
public Authentication getAuthentication() {
67+
return authentication;
68+
}
69+
70+
public <T> T execute(Function<ThreadContext.StoredContext, T> body) {
71+
return this.securityContext.executeWithAuthentication(this.authentication, body);
72+
}
73+
74+
public Runnable wrap(Runnable runnable) {
75+
return () -> execute(ignore -> {
76+
runnable.run();
77+
return null;
78+
});
79+
}
80+
81+
@Override
82+
public String toString() {
83+
return getClass().getSimpleName() + "{" + authentication + "}";
84+
}
85+
86+
@Override
87+
public boolean equals(Object o) {
88+
if (this == o) return true;
89+
if (o == null || getClass() != o.getClass()) return false;
90+
final SecondaryAuthentication that = (SecondaryAuthentication) o;
91+
return authentication.equals(that.authentication);
92+
}
93+
94+
@Override
95+
public int hashCode() {
96+
return Objects.hash(authentication);
97+
}
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
package org.elasticsearch.xpack.core.security.authc.support;
8+
9+
import org.elasticsearch.Version;
10+
import org.elasticsearch.action.ActionListener;
11+
import org.elasticsearch.action.support.ContextPreservingActionListener;
12+
import org.elasticsearch.common.settings.Settings;
13+
import org.elasticsearch.common.util.concurrent.ThreadContext;
14+
import org.elasticsearch.test.ESTestCase;
15+
import org.elasticsearch.threadpool.TestThreadPool;
16+
import org.elasticsearch.threadpool.ThreadPool;
17+
import org.elasticsearch.xpack.core.security.SecurityContext;
18+
import org.elasticsearch.xpack.core.security.authc.Authentication;
19+
import org.elasticsearch.xpack.core.security.user.User;
20+
import org.elasticsearch.xpack.core.security.authc.support.SecondaryAuthentication;
21+
import org.junit.After;
22+
import org.junit.Before;
23+
24+
import java.util.concurrent.Future;
25+
import java.util.concurrent.Semaphore;
26+
import java.util.concurrent.TimeUnit;
27+
import java.util.concurrent.atomic.AtomicReference;
28+
29+
import static org.hamcrest.Matchers.equalTo;
30+
import static org.hamcrest.Matchers.notNullValue;
31+
import static org.hamcrest.Matchers.sameInstance;
32+
33+
public class SecondaryAuthenticationTests extends ESTestCase {
34+
35+
private SecurityContext securityContext;
36+
private ThreadPool threadPool;
37+
38+
@Before
39+
public void setupObjects() {
40+
this.threadPool = new TestThreadPool(getTestName());
41+
this.securityContext = new SecurityContext(Settings.EMPTY, threadPool.getThreadContext());
42+
}
43+
44+
@After
45+
public void cleanup() {
46+
this.threadPool.shutdownNow();
47+
}
48+
49+
public void testCannotCreateObjectWithNullAuthentication() {
50+
expectThrows(NullPointerException.class, () -> new SecondaryAuthentication(securityContext, null));
51+
}
52+
53+
public void testSynchronousExecuteInSecondaryContext() {
54+
final User user1 = new User("u1", "role1");
55+
securityContext.setUser(user1, Version.CURRENT);
56+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
57+
58+
final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm());
59+
final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2);
60+
61+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
62+
var result = secondaryAuth.execute(original -> {
63+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
64+
assertThat(securityContext.getAuthentication(), sameInstance(authentication2));
65+
return "xyzzy";
66+
});
67+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
68+
assertThat(result, equalTo("xyzzy"));
69+
}
70+
71+
public void testSecondaryContextCanBeRestored() {
72+
final User user1 = new User("u1", "role1");
73+
securityContext.setUser(user1, Version.CURRENT);
74+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
75+
76+
final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm());
77+
final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2);
78+
79+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
80+
final AtomicReference<ThreadContext.StoredContext> secondaryContext = new AtomicReference<>();
81+
secondaryAuth.execute(storedContext -> {
82+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
83+
assertThat(securityContext.getAuthentication(), sameInstance(authentication2));
84+
secondaryContext.set(threadPool.getThreadContext().newStoredContext(false));
85+
storedContext.restore();
86+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
87+
return null;
88+
});
89+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
90+
secondaryContext.get().restore();
91+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
92+
}
93+
94+
public void testWrapRunnable() throws Exception {
95+
final User user1 = new User("u1", "role1");
96+
securityContext.setUser(user1, Version.CURRENT);
97+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
98+
99+
final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm());
100+
final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2);
101+
102+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
103+
final Semaphore semaphore = new Semaphore(0);
104+
final Future<?> future = threadPool.generic().submit(secondaryAuth.wrap(() -> {
105+
try {
106+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
107+
semaphore.acquire();
108+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
109+
semaphore.acquire();
110+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
111+
} catch (InterruptedException e) {
112+
Thread.currentThread().interrupt();
113+
throw new RuntimeException(e);
114+
}
115+
}));
116+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
117+
semaphore.release();
118+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
119+
semaphore.release();
120+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
121+
122+
// ensure that the runnable didn't throw any exceptions / assertions
123+
future.get(1, TimeUnit.SECONDS);
124+
}
125+
126+
public void testPreserveSecondaryContextAcrossThreads() throws Exception {
127+
final User user1 = new User("u1", "role1");
128+
securityContext.setUser(user1, Version.CURRENT);
129+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
130+
131+
final Authentication authentication2 = new Authentication(new User("u2", "role2"), realm(), realm());
132+
final SecondaryAuthentication secondaryAuth = new SecondaryAuthentication(securityContext, authentication2);
133+
134+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
135+
136+
final AtomicReference<User> threadUser = new AtomicReference<>();
137+
final AtomicReference<User> listenerUser = new AtomicReference<>();
138+
139+
final ThreadContext threadContext = threadPool.getThreadContext();
140+
secondaryAuth.execute(originalContext -> {
141+
assertThat(securityContext.getUser().principal(), equalTo("u2"));
142+
ActionListener<Void> listener = new ContextPreservingActionListener<>(threadContext.newRestorableContext(false),
143+
ActionListener.wrap(() -> listenerUser.set(securityContext.getUser())));
144+
originalContext.restore();
145+
threadPool.generic().execute(() -> {
146+
threadUser.set(securityContext.getUser());
147+
listener.onResponse(null);
148+
});
149+
return null;
150+
});
151+
assertThat(securityContext.getUser().principal(), equalTo("u1"));
152+
assertBusy(() -> assertThat(listenerUser.get(), notNullValue()), 1, TimeUnit.SECONDS);
153+
assertThat(threadUser.get(), notNullValue());
154+
assertThat(threadUser.get().principal(), equalTo("u1"));
155+
assertThat(listenerUser.get().principal(), equalTo("u2"));
156+
}
157+
158+
private Authentication.RealmRef realm() {
159+
return new Authentication.RealmRef(randomAlphaOfLengthBetween(4, 8), randomAlphaOfLengthBetween(2, 4), randomAlphaOfLength(12));
160+
}
161+
162+
}

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

+9-1
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@
184184
import org.elasticsearch.xpack.security.authc.TokenService;
185185
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
186186
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
187+
import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator;
187188
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
188189
import org.elasticsearch.xpack.security.authz.AuthorizationService;
189190
import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener;
@@ -287,6 +288,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
287288
private final SetOnce<TransportInterceptor> securityInterceptor = new SetOnce<>();
288289
private final SetOnce<IPFilter> ipFilter = new SetOnce<>();
289290
private final SetOnce<AuthenticationService> authcService = new SetOnce<>();
291+
private final SetOnce<SecondaryAuthenticator> secondayAuthc = new SetOnce<>();
290292
private final SetOnce<AuditTrailService> auditTrailService = new SetOnce<>();
291293
private final SetOnce<SecurityContext> securityContext = new SetOnce<>();
292294
private final SetOnce<ThreadContext> threadContext = new SetOnce<>();
@@ -467,6 +469,10 @@ auditTrailService, failureHandler, threadPool, anonymousUser, getAuthorizationEn
467469
components.add(allRolesStore); // for SecurityInfoTransportAction and clear roles cache
468470
components.add(authzService);
469471

472+
final SecondaryAuthenticator secondaryAuthenticator = new SecondaryAuthenticator(securityContext.get(), authcService.get());
473+
this.secondayAuthc.set(secondaryAuthenticator);
474+
components.add(secondaryAuthenticator);
475+
470476
ipFilter.set(new IPFilter(settings, auditTrailService, clusterService.getClusterSettings(), getLicenseState()));
471477
components.add(ipFilter.get());
472478
DestructiveOperations destructiveOperations = new DestructiveOperations(settings, clusterService.getClusterSettings());
@@ -637,6 +643,7 @@ public static List<Setting<?>> getSettings(List<SecurityExtension> securityExten
637643
public Collection<RestHeaderDefinition> getRestHeaders() {
638644
Set<RestHeaderDefinition> headers = new HashSet<>();
639645
headers.add(new RestHeaderDefinition(UsernamePasswordToken.BASIC_AUTH_HEADER, false));
646+
headers.add(new RestHeaderDefinition(SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME, false));
640647
if (XPackSettings.AUDIT_ENABLED.get(settings)) {
641648
headers.add(new RestHeaderDefinition(AuditTrail.X_FORWARDED_FOR_HEADER, true));
642649
}
@@ -963,7 +970,8 @@ public UnaryOperator<RestHandler> getRestHandlerWrapper(ThreadContext threadCont
963970
final boolean ssl = HTTP_SSL_ENABLED.get(settings);
964971
final SSLConfiguration httpSSLConfig = getSslService().getHttpTransportSSLConfiguration();
965972
boolean extractClientCertificate = ssl && getSslService().isSSLClientAuthEnabled(httpSSLConfig);
966-
return handler -> new SecurityRestFilter(getLicenseState(), threadContext, authcService.get(), handler, extractClientCertificate);
973+
return handler -> new SecurityRestFilter(getLicenseState(), threadContext, authcService.get(), secondayAuthc.get(),
974+
handler, extractClientCertificate);
967975
}
968976

969977
@Override

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/filter/SecurityActionFilter.java

+1
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ the system itself (e.g. pings, update mappings, share relocation, etc...) and we
150150
it to the action without an associated user (not via REST or transport - this is taken care of by
151151
the {@link Rest} filter and the {@link ServerTransport} filter respectively), it's safe to assume a system user
152152
here if a request is not associated with any other user.
153+
Because we want to fallback to the SystemUser, we don't allow anonymous (AnonymousUser) requests.
153154
*/
154155
final String securityAction = actionMapper.action(action, request);
155156
authcService.authenticate(securityAction, request, SystemUser.INSTANCE,

0 commit comments

Comments
 (0)