Skip to content

Commit a525c36

Browse files
bizybotjaymode
authored andcommitted
[Kerberos] Add Kerberos authentication support (elastic#32263)
This commit adds support for Kerberos authentication with a platinum license. Kerberos authentication support relies on SPNEGO, which is triggered by challenging clients with a 401 response with the `WWW-Authenticate: Negotiate` header. A SPNEGO client will then provide a Kerberos ticket in the `Authorization` header. The tickets are validated using Java's built-in GSS support. The JVM uses a vm wide configuration for Kerberos, so there can be only one Kerberos realm. This is enforced by a bootstrap check that also enforces the existence of the keytab file. In many cases a fallback authentication mechanism is needed when SPNEGO authentication is not available. In order to support this, the DefaultAuthenticationFailureHandler now takes a list of failure response headers. For example, one realm can provide a `WWW-Authenticate: Negotiate` header as its default and another could provide `WWW-Authenticate: Basic` to indicate to the client that basic authentication can be used in place of SPNEGO. In order to test Kerberos, unit tests are run against an in-memory KDC that is backed by an in-memory ldap server. A QA project has also been added to test against an actual KDC, which is provided by the krb5kdc fixture. Closes elastic#30243
1 parent 99426eb commit a525c36

34 files changed

+3522
-61
lines changed

test/fixtures/krb5kdc-fixture/src/main/resources/provision/addprinc.sh

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@
2020
set -e
2121

2222
if [[ $# -lt 1 ]]; then
23-
echo 'Usage: addprinc.sh <principalNameNoRealm>'
23+
echo 'Usage: addprinc.sh principalName [password]'
24+
echo ' principalName user principal name without realm'
25+
echo ' password If provided then will set password for user else it will provision user with keytab'
2426
exit 1
2527
fi
2628

2729
PRINC="$1"
30+
PASSWD="$2"
2831
USER=$(echo $PRINC | tr "/" "_")
2932

3033
VDIR=/vagrant
@@ -47,12 +50,17 @@ ADMIN_KTAB=$LOCALSTATEDIR/admin.keytab
4750
USER_PRIN=$PRINC@$REALM
4851
USER_KTAB=$LOCALSTATEDIR/$USER.keytab
4952

50-
if [ -f $USER_KTAB ]; then
53+
if [ -f $USER_KTAB ] && [ -z "$PASSWD" ]; then
5154
echo "Principal '${PRINC}@${REALM}' already exists. Re-copying keytab..."
55+
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
5256
else
53-
echo "Provisioning '${PRINC}@${REALM}' principal and keytab..."
54-
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -randkey $USER_PRIN"
55-
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $USER_PRIN"
57+
if [ -z "$PASSWD" ]; then
58+
echo "Provisioning '${PRINC}@${REALM}' principal and keytab..."
59+
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -randkey $USER_PRIN"
60+
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $USER_PRIN"
61+
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
62+
else
63+
echo "Provisioning '${PRINC}@${REALM}' principal with password..."
64+
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -pw $PASSWD $PRINC"
65+
fi
5666
fi
57-
58-
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab

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

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,60 +10,132 @@
1010
import org.elasticsearch.rest.RestRequest;
1111
import org.elasticsearch.rest.RestStatus;
1212
import org.elasticsearch.transport.TransportMessage;
13+
import org.elasticsearch.xpack.core.XPackField;
14+
15+
import java.util.Collections;
16+
import java.util.List;
17+
import java.util.Map;
1318

1419
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError;
1520

1621
/**
17-
* The default implementation of a {@link AuthenticationFailureHandler}. This handler will return an exception with a
18-
* RestStatus of 401 and the WWW-Authenticate header with a Basic challenge.
22+
* The default implementation of a {@link AuthenticationFailureHandler}. This
23+
* handler will return an exception with a RestStatus of 401 and default failure
24+
* response headers like 'WWW-Authenticate'
1925
*/
2026
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {
27+
private final Map<String, List<String>> defaultFailureResponseHeaders;
28+
29+
/**
30+
* Constructs default authentication failure handler
31+
*
32+
* @deprecated replaced by {@link #DefaultAuthenticationFailureHandler(Map)}
33+
*/
34+
@Deprecated
35+
public DefaultAuthenticationFailureHandler() {
36+
this(null);
37+
}
38+
39+
/**
40+
* Constructs default authentication failure handler with provided default
41+
* response headers.
42+
*
43+
* @param failureResponseHeaders Map of header key and list of header values to
44+
* be sent as failure response.
45+
* @see Realm#getAuthenticationFailureHeaders()
46+
*/
47+
public DefaultAuthenticationFailureHandler(Map<String, List<String>> failureResponseHeaders) {
48+
if (failureResponseHeaders == null || failureResponseHeaders.isEmpty()) {
49+
failureResponseHeaders = Collections.singletonMap("WWW-Authenticate",
50+
Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""));
51+
}
52+
this.defaultFailureResponseHeaders = Collections.unmodifiableMap(failureResponseHeaders);
53+
}
2154

2255
@Override
23-
public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token,
24-
ThreadContext context) {
25-
return authenticationError("unable to authenticate user [{}] for REST request [{}]", token.principal(), request.uri());
56+
public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, ThreadContext context) {
57+
return createAuthenticationError("unable to authenticate user [{}] for REST request [{}]", null, token.principal(), request.uri());
2658
}
2759

2860
@Override
2961
public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action,
30-
ThreadContext context) {
31-
return authenticationError("unable to authenticate user [{}] for action [{}]", token.principal(), action);
62+
ThreadContext context) {
63+
return createAuthenticationError("unable to authenticate user [{}] for action [{}]", null, token.principal(), action);
3264
}
3365

3466
@Override
3567
public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) {
36-
if (e instanceof ElasticsearchSecurityException) {
37-
assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
38-
assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
39-
return (ElasticsearchSecurityException) e;
40-
}
41-
return authenticationError("error attempting to authenticate request", e);
68+
return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
4269
}
4370

4471
@Override
4572
public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e,
46-
ThreadContext context) {
47-
if (e instanceof ElasticsearchSecurityException) {
48-
assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
49-
assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
50-
return (ElasticsearchSecurityException) e;
51-
}
52-
return authenticationError("error attempting to authenticate request", e);
73+
ThreadContext context) {
74+
return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
5375
}
5476

5577
@Override
5678
public ElasticsearchSecurityException missingToken(RestRequest request, ThreadContext context) {
57-
return authenticationError("missing authentication token for REST request [{}]", request.uri());
79+
return createAuthenticationError("missing authentication token for REST request [{}]", null, request.uri());
5880
}
5981

6082
@Override
6183
public ElasticsearchSecurityException missingToken(TransportMessage message, String action, ThreadContext context) {
62-
return authenticationError("missing authentication token for action [{}]", action);
84+
return createAuthenticationError("missing authentication token for action [{}]", null, action);
6385
}
6486

6587
@Override
6688
public ElasticsearchSecurityException authenticationRequired(String action, ThreadContext context) {
67-
return authenticationError("action [{}] requires authentication", action);
89+
return createAuthenticationError("action [{}] requires authentication", null, action);
90+
}
91+
92+
/**
93+
* Creates an instance of {@link ElasticsearchSecurityException} with
94+
* {@link RestStatus#UNAUTHORIZED} status.
95+
* <p>
96+
* Also adds default failure response headers as configured for this
97+
* {@link DefaultAuthenticationFailureHandler}
98+
* <p>
99+
* It may replace existing response headers if the cause is an instance of
100+
* {@link ElasticsearchSecurityException}
101+
*
102+
* @param message error message
103+
* @param t cause, if it is an instance of
104+
* {@link ElasticsearchSecurityException} asserts status is
105+
* RestStatus.UNAUTHORIZED and adds headers to it, else it will
106+
* create a new instance of {@link ElasticsearchSecurityException}
107+
* @param args error message args
108+
* @return instance of {@link ElasticsearchSecurityException}
109+
*/
110+
private ElasticsearchSecurityException createAuthenticationError(final String message, final Throwable t, final Object... args) {
111+
final ElasticsearchSecurityException ese;
112+
final boolean containsNegotiateWithToken;
113+
if (t instanceof ElasticsearchSecurityException) {
114+
assert ((ElasticsearchSecurityException) t).status() == RestStatus.UNAUTHORIZED;
115+
ese = (ElasticsearchSecurityException) t;
116+
if (ese.getHeader("WWW-Authenticate") != null && ese.getHeader("WWW-Authenticate").isEmpty() == false) {
117+
/**
118+
* If 'WWW-Authenticate' header is present with 'Negotiate ' then do not
119+
* replace. In case of kerberos spnego mechanism, we use
120+
* 'WWW-Authenticate' header value to communicate outToken to peer.
121+
*/
122+
containsNegotiateWithToken =
123+
ese.getHeader("WWW-Authenticate").stream()
124+
.anyMatch(s -> s != null && s.regionMatches(true, 0, "Negotiate ", 0, "Negotiate ".length()));
125+
} else {
126+
containsNegotiateWithToken = false;
127+
}
128+
} else {
129+
ese = authenticationError(message, t, args);
130+
containsNegotiateWithToken = false;
131+
}
132+
defaultFailureResponseHeaders.entrySet().stream().forEach((e) -> {
133+
if (containsNegotiateWithToken && e.getKey().equalsIgnoreCase("WWW-Authenticate")) {
134+
return;
135+
}
136+
// If it is already present then it will replace the existing header.
137+
ese.addHeader(e.getKey(), e.getValue());
138+
});
139+
return ese;
68140
}
69141
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
import org.apache.logging.log4j.Logger;
99
import org.elasticsearch.action.ActionListener;
1010
import org.elasticsearch.common.util.concurrent.ThreadContext;
11+
import org.elasticsearch.xpack.core.XPackField;
1112
import org.elasticsearch.xpack.core.security.user.User;
1213

14+
import java.util.Collections;
1315
import java.util.HashMap;
16+
import java.util.List;
1417
import java.util.Map;
1518

1619
/**
@@ -56,6 +59,18 @@ public int order() {
5659
return config.order;
5760
}
5861

62+
/**
63+
* Each realm can define response headers to be sent on failure.
64+
* <p>
65+
* By default it adds 'WWW-Authenticate' header with auth scheme 'Basic'.
66+
*
67+
* @return Map of authentication failure response headers.
68+
*/
69+
public Map<String, List<String>> getAuthenticationFailureHeaders() {
70+
return Collections.singletonMap("WWW-Authenticate",
71+
Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""));
72+
}
73+
5974
@Override
6075
public int compareTo(Realm other) {
6176
int result = Integer.compare(config.order, other.config.order);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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.kerberos;
8+
9+
import org.elasticsearch.common.settings.Setting;
10+
import org.elasticsearch.common.settings.Setting.Property;
11+
import org.elasticsearch.common.unit.TimeValue;
12+
import org.elasticsearch.common.util.set.Sets;
13+
14+
import java.util.Set;
15+
16+
/**
17+
* Kerberos Realm settings
18+
*/
19+
public final class KerberosRealmSettings {
20+
public static final String TYPE = "kerberos";
21+
22+
/**
23+
* Kerberos key tab for Elasticsearch service<br>
24+
* Uses single key tab for multiple service accounts.
25+
*/
26+
public static final Setting<String> HTTP_SERVICE_KEYTAB_PATH =
27+
Setting.simpleString("keytab.path", Property.NodeScope);
28+
public static final Setting<Boolean> SETTING_KRB_DEBUG_ENABLE =
29+
Setting.boolSetting("krb.debug", Boolean.FALSE, Property.NodeScope);
30+
public static final Setting<Boolean> SETTING_REMOVE_REALM_NAME =
31+
Setting.boolSetting("remove_realm_name", Boolean.FALSE, Property.NodeScope);
32+
33+
// Cache
34+
private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20);
35+
private static final int DEFAULT_MAX_USERS = 100_000; // 100k users
36+
public static final Setting<TimeValue> CACHE_TTL_SETTING = Setting.timeSetting("cache.ttl", DEFAULT_TTL, Setting.Property.NodeScope);
37+
public static final Setting<Integer> CACHE_MAX_USERS_SETTING =
38+
Setting.intSetting("cache.max_users", DEFAULT_MAX_USERS, Property.NodeScope);
39+
40+
private KerberosRealmSettings() {
41+
}
42+
43+
/**
44+
* @return the valid set of {@link Setting}s for a {@value #TYPE} realm
45+
*/
46+
public static Set<Setting<?>> getSettings() {
47+
return Sets.newHashSet(HTTP_SERVICE_KEYTAB_PATH, CACHE_TTL_SETTING, CACHE_MAX_USERS_SETTING, SETTING_KRB_DEBUG_ENABLE,
48+
SETTING_REMOVE_REALM_NAME);
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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;
8+
9+
import org.elasticsearch.ElasticsearchSecurityException;
10+
import org.elasticsearch.common.settings.Settings;
11+
import org.elasticsearch.common.util.concurrent.ThreadContext;
12+
import org.elasticsearch.rest.RestRequest;
13+
import org.elasticsearch.rest.RestStatus;
14+
import org.elasticsearch.test.ESTestCase;
15+
import org.elasticsearch.xpack.core.XPackField;
16+
import org.mockito.Mockito;
17+
18+
import java.util.Arrays;
19+
import java.util.Collections;
20+
import java.util.HashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import static org.hamcrest.Matchers.contains;
25+
import static org.hamcrest.Matchers.equalTo;
26+
import static org.hamcrest.Matchers.is;
27+
import static org.hamcrest.Matchers.notNullValue;
28+
import static org.hamcrest.Matchers.sameInstance;
29+
30+
public class DefaultAuthenticationFailureHandlerTests extends ESTestCase {
31+
32+
public void testAuthenticationRequired() {
33+
final boolean testDefault = randomBoolean();
34+
final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"";
35+
final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\"";
36+
final DefaultAuthenticationFailureHandler failuerHandler;
37+
if (testDefault) {
38+
failuerHandler = new DefaultAuthenticationFailureHandler();
39+
} else {
40+
final Map<String, List<String>> failureResponeHeaders = new HashMap<>();
41+
failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme));
42+
failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders);
43+
}
44+
assertThat(failuerHandler, is(notNullValue()));
45+
final ElasticsearchSecurityException ese =
46+
failuerHandler.authenticationRequired("someaction", new ThreadContext(Settings.builder().build()));
47+
assertThat(ese, is(notNullValue()));
48+
assertThat(ese.getMessage(), equalTo("action [someaction] requires authentication"));
49+
assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue()));
50+
if (testDefault) {
51+
assertWWWAuthenticateWithSchemes(ese, basicAuthScheme);
52+
} else {
53+
assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme);
54+
}
55+
}
56+
57+
public void testExceptionProcessingRequest() {
58+
final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"";
59+
final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\"";
60+
final String negotiateAuthScheme = randomFrom("Negotiate", "Negotiate Ijoijksdk");
61+
final Map<String, List<String>> failureResponeHeaders = new HashMap<>();
62+
failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme, negotiateAuthScheme));
63+
final DefaultAuthenticationFailureHandler failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders);
64+
65+
assertThat(failuerHandler, is(notNullValue()));
66+
final boolean causeIsElasticsearchSecurityException = randomBoolean();
67+
final boolean causeIsEseAndUnauthorized = causeIsElasticsearchSecurityException && randomBoolean();
68+
final ElasticsearchSecurityException eseCause = (causeIsEseAndUnauthorized)
69+
? new ElasticsearchSecurityException("unauthorized", RestStatus.UNAUTHORIZED, null, (Object[]) null)
70+
: new ElasticsearchSecurityException("different error", RestStatus.BAD_REQUEST, null, (Object[]) null);
71+
final Exception cause = causeIsElasticsearchSecurityException ? eseCause : new Exception("other error");
72+
final boolean withAuthenticateHeader = randomBoolean();
73+
final String selectedScheme = randomFrom(bearerAuthScheme, basicAuthScheme, negotiateAuthScheme);
74+
if (withAuthenticateHeader) {
75+
eseCause.addHeader("WWW-Authenticate", Collections.singletonList(selectedScheme));
76+
}
77+
78+
if (causeIsElasticsearchSecurityException) {
79+
if (causeIsEseAndUnauthorized) {
80+
final ElasticsearchSecurityException ese = failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause,
81+
new ThreadContext(Settings.builder().build()));
82+
assertThat(ese, is(notNullValue()));
83+
assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue()));
84+
assertThat(ese, is(sameInstance(cause)));
85+
if (withAuthenticateHeader == false) {
86+
assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme);
87+
} else {
88+
if (selectedScheme.contains("Negotiate ")) {
89+
assertWWWAuthenticateWithSchemes(ese, selectedScheme);
90+
} else {
91+
assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme);
92+
}
93+
}
94+
assertThat(ese.getMessage(), equalTo("unauthorized"));
95+
} else {
96+
expectThrows(AssertionError.class, () -> failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause,
97+
new ThreadContext(Settings.builder().build())));
98+
}
99+
} else {
100+
final ElasticsearchSecurityException ese = failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause,
101+
new ThreadContext(Settings.builder().build()));
102+
assertThat(ese, is(notNullValue()));
103+
assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue()));
104+
assertThat(ese.getMessage(), equalTo("error attempting to authenticate request"));
105+
assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme);
106+
}
107+
108+
}
109+
110+
private void assertWWWAuthenticateWithSchemes(final ElasticsearchSecurityException ese, final String... schemes) {
111+
assertThat(ese.getHeader("WWW-Authenticate").size(), is(schemes.length));
112+
assertThat(ese.getHeader("WWW-Authenticate"), contains(schemes));
113+
}
114+
}

0 commit comments

Comments
 (0)