From 34fa6f3082a67e9623b38f974628beb3c810c420 Mon Sep 17 00:00:00 2001 From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com> Date: Wed, 25 Jul 2018 00:44:26 +1000 Subject: [PATCH] [Kerberos] Add Kerberos authentication support (#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 #30243 --- .../src/main/resources/provision/addprinc.sh | 22 +- .../DefaultAuthenticationFailureHandler.java | 118 +++++-- .../xpack/core/security/authc/Realm.java | 15 + .../authc/kerberos/KerberosRealmSettings.java | 50 +++ ...aultAuthenticationFailureHandlerTests.java | 114 +++++++ x-pack/plugin/security/build.gradle | 63 +++- .../xpack/security/Security.java | 69 ++-- .../security/authc/AuthenticationService.java | 4 +- .../xpack/security/authc/InternalRealms.java | 16 +- .../kerberos/KerberosAuthenticationToken.java | 150 +++++++++ .../authc/kerberos/KerberosRealm.java | 214 ++++++++++++ .../kerberos/KerberosRealmBootstrapCheck.java | 69 ++++ .../kerberos/KerberosTicketValidator.java | 273 +++++++++++++++ .../plugin-metadata/plugin-security.policy | 15 + .../authc/AuthenticationServiceTests.java | 56 ++++ .../security/authc/InternalRealmsTests.java | 13 + .../xpack/security/authc/RealmsTests.java | 8 +- .../KerberosAuthenticationTokenTests.java | 110 ++++++ .../KerberosRealmAuthenticateFailedTests.java | 108 ++++++ .../KerberosRealmBootstrapCheckTests.java | 114 +++++++ .../kerberos/KerberosRealmCacheTests.java | 141 ++++++++ .../kerberos/KerberosRealmSettingsTests.java | 46 +++ .../authc/kerberos/KerberosRealmTestCase.java | 168 ++++++++++ .../authc/kerberos/KerberosRealmTests.java | 97 ++++++ .../authc/kerberos/KerberosTestCase.java | 223 ++++++++++++ .../KerberosTicketValidatorTests.java | 133 ++++++++ .../authc/kerberos/SimpleKdcLdapServer.java | 224 +++++++++++++ .../kerberos/SimpleKdcLdapServerTests.java | 77 +++++ .../security/authc/kerberos/SpnegoClient.java | 257 ++++++++++++++ .../security/src/test/resources/kdc.ldiff | 23 ++ x-pack/qa/kerberos-tests/build.gradle | 127 +++++++ .../kerberos/KerberosAuthenticationIT.java | 152 +++++++++ ...SpnegoHttpClientConfigCallbackHandler.java | 317 ++++++++++++++++++ .../src/test/resources/plugin-security.policy | 4 + 34 files changed, 3525 insertions(+), 65 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealm.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidator.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmAuthenticateFailedTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmCacheTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTestCase.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTestCase.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidatorTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServer.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServerTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoClient.java create mode 100644 x-pack/plugin/security/src/test/resources/kdc.ldiff create mode 100644 x-pack/qa/kerberos-tests/build.gradle create mode 100644 x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java create mode 100644 x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java create mode 100644 x-pack/qa/kerberos-tests/src/test/resources/plugin-security.policy diff --git a/test/fixtures/krb5kdc-fixture/src/main/resources/provision/addprinc.sh b/test/fixtures/krb5kdc-fixture/src/main/resources/provision/addprinc.sh index 137135dc2aa4d..d0d1570ae299a 100755 --- a/test/fixtures/krb5kdc-fixture/src/main/resources/provision/addprinc.sh +++ b/test/fixtures/krb5kdc-fixture/src/main/resources/provision/addprinc.sh @@ -20,11 +20,14 @@ set -e if [[ $# -lt 1 ]]; then - echo 'Usage: addprinc.sh ' + echo 'Usage: addprinc.sh principalName [password]' + echo ' principalName user principal name without realm' + echo ' password If provided then will set password for user else it will provision user with keytab' exit 1 fi PRINC="$1" +PASSWD="$2" USER=$(echo $PRINC | tr "/" "_") VDIR=/vagrant @@ -47,12 +50,17 @@ ADMIN_KTAB=$LOCALSTATEDIR/admin.keytab USER_PRIN=$PRINC@$REALM USER_KTAB=$LOCALSTATEDIR/$USER.keytab -if [ -f $USER_KTAB ]; then +if [ -f $USER_KTAB ] && [ -z "$PASSWD" ]; then echo "Principal '${PRINC}@${REALM}' already exists. Re-copying keytab..." + sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab else - echo "Provisioning '${PRINC}@${REALM}' principal and keytab..." - sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -randkey $USER_PRIN" - sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $USER_PRIN" + if [ -z "$PASSWD" ]; then + echo "Provisioning '${PRINC}@${REALM}' principal and keytab..." + sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -randkey $USER_PRIN" + sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $USER_PRIN" + sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab + else + echo "Provisioning '${PRINC}@${REALM}' principal with password..." + sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -pw $PASSWD $PRINC" + fi fi - -sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java index 8b31e77f9f8b7..d6f678a2dcb90 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java @@ -10,60 +10,132 @@ import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.transport.TransportMessage; +import org.elasticsearch.xpack.core.XPackField; + +import java.util.Collections; +import java.util.List; +import java.util.Map; import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError; /** - * The default implementation of a {@link AuthenticationFailureHandler}. This handler will return an exception with a - * RestStatus of 401 and the WWW-Authenticate header with a Basic challenge. + * The default implementation of a {@link AuthenticationFailureHandler}. This + * handler will return an exception with a RestStatus of 401 and default failure + * response headers like 'WWW-Authenticate' */ public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler { + private final Map> defaultFailureResponseHeaders; + + /** + * Constructs default authentication failure handler + * + * @deprecated replaced by {@link #DefaultAuthenticationFailureHandler(Map)} + */ + @Deprecated + public DefaultAuthenticationFailureHandler() { + this(null); + } + + /** + * Constructs default authentication failure handler with provided default + * response headers. + * + * @param failureResponseHeaders Map of header key and list of header values to + * be sent as failure response. + * @see Realm#getAuthenticationFailureHeaders() + */ + public DefaultAuthenticationFailureHandler(Map> failureResponseHeaders) { + if (failureResponseHeaders == null || failureResponseHeaders.isEmpty()) { + failureResponseHeaders = Collections.singletonMap("WWW-Authenticate", + Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"")); + } + this.defaultFailureResponseHeaders = Collections.unmodifiableMap(failureResponseHeaders); + } @Override - public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, - ThreadContext context) { - return authenticationError("unable to authenticate user [{}] for REST request [{}]", token.principal(), request.uri()); + public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, ThreadContext context) { + return createAuthenticationError("unable to authenticate user [{}] for REST request [{}]", null, token.principal(), request.uri()); } @Override public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action, - ThreadContext context) { - return authenticationError("unable to authenticate user [{}] for action [{}]", token.principal(), action); + ThreadContext context) { + return createAuthenticationError("unable to authenticate user [{}] for action [{}]", null, token.principal(), action); } @Override public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) { - if (e instanceof ElasticsearchSecurityException) { - assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED; - assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1; - return (ElasticsearchSecurityException) e; - } - return authenticationError("error attempting to authenticate request", e); + return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null); } @Override public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e, - ThreadContext context) { - if (e instanceof ElasticsearchSecurityException) { - assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED; - assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1; - return (ElasticsearchSecurityException) e; - } - return authenticationError("error attempting to authenticate request", e); + ThreadContext context) { + return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null); } @Override public ElasticsearchSecurityException missingToken(RestRequest request, ThreadContext context) { - return authenticationError("missing authentication token for REST request [{}]", request.uri()); + return createAuthenticationError("missing authentication token for REST request [{}]", null, request.uri()); } @Override public ElasticsearchSecurityException missingToken(TransportMessage message, String action, ThreadContext context) { - return authenticationError("missing authentication token for action [{}]", action); + return createAuthenticationError("missing authentication token for action [{}]", null, action); } @Override public ElasticsearchSecurityException authenticationRequired(String action, ThreadContext context) { - return authenticationError("action [{}] requires authentication", action); + return createAuthenticationError("action [{}] requires authentication", null, action); + } + + /** + * Creates an instance of {@link ElasticsearchSecurityException} with + * {@link RestStatus#UNAUTHORIZED} status. + *

+ * Also adds default failure response headers as configured for this + * {@link DefaultAuthenticationFailureHandler} + *

+ * It may replace existing response headers if the cause is an instance of + * {@link ElasticsearchSecurityException} + * + * @param message error message + * @param t cause, if it is an instance of + * {@link ElasticsearchSecurityException} asserts status is + * RestStatus.UNAUTHORIZED and adds headers to it, else it will + * create a new instance of {@link ElasticsearchSecurityException} + * @param args error message args + * @return instance of {@link ElasticsearchSecurityException} + */ + private ElasticsearchSecurityException createAuthenticationError(final String message, final Throwable t, final Object... args) { + final ElasticsearchSecurityException ese; + final boolean containsNegotiateWithToken; + if (t instanceof ElasticsearchSecurityException) { + assert ((ElasticsearchSecurityException) t).status() == RestStatus.UNAUTHORIZED; + ese = (ElasticsearchSecurityException) t; + if (ese.getHeader("WWW-Authenticate") != null && ese.getHeader("WWW-Authenticate").isEmpty() == false) { + /** + * If 'WWW-Authenticate' header is present with 'Negotiate ' then do not + * replace. In case of kerberos spnego mechanism, we use + * 'WWW-Authenticate' header value to communicate outToken to peer. + */ + containsNegotiateWithToken = + ese.getHeader("WWW-Authenticate").stream() + .anyMatch(s -> s != null && s.regionMatches(true, 0, "Negotiate ", 0, "Negotiate ".length())); + } else { + containsNegotiateWithToken = false; + } + } else { + ese = authenticationError(message, t, args); + containsNegotiateWithToken = false; + } + defaultFailureResponseHeaders.entrySet().stream().forEach((e) -> { + if (containsNegotiateWithToken && e.getKey().equalsIgnoreCase("WWW-Authenticate")) { + return; + } + // If it is already present then it will replace the existing header. + ese.addHeader(e.getKey(), e.getValue()); + }); + return ese; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java index 8fa422d414650..786c4a69367a6 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java @@ -8,9 +8,12 @@ import org.apache.logging.log4j.Logger; import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.xpack.core.XPackField; import org.elasticsearch.xpack.core.security.user.User; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -56,6 +59,18 @@ public int order() { return config.order; } + /** + * Each realm can define response headers to be sent on failure. + *

+ * By default it adds 'WWW-Authenticate' header with auth scheme 'Basic'. + * + * @return Map of authentication failure response headers. + */ + public Map> getAuthenticationFailureHeaders() { + return Collections.singletonMap("WWW-Authenticate", + Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"")); + } + @Override public int compareTo(Realm other) { int result = Integer.compare(config.order, other.config.order); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java new file mode 100644 index 0000000000000..7524ef08c1e72 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java @@ -0,0 +1,50 @@ +/* + * 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.kerberos; + +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Setting.Property; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.set.Sets; + +import java.util.Set; + +/** + * Kerberos Realm settings + */ +public final class KerberosRealmSettings { + public static final String TYPE = "kerberos"; + + /** + * Kerberos key tab for Elasticsearch service
+ * Uses single key tab for multiple service accounts. + */ + public static final Setting HTTP_SERVICE_KEYTAB_PATH = + Setting.simpleString("keytab.path", Property.NodeScope); + public static final Setting SETTING_KRB_DEBUG_ENABLE = + Setting.boolSetting("krb.debug", Boolean.FALSE, Property.NodeScope); + public static final Setting SETTING_REMOVE_REALM_NAME = + Setting.boolSetting("remove_realm_name", Boolean.FALSE, Property.NodeScope); + + // Cache + private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20); + private static final int DEFAULT_MAX_USERS = 100_000; // 100k users + public static final Setting CACHE_TTL_SETTING = Setting.timeSetting("cache.ttl", DEFAULT_TTL, Setting.Property.NodeScope); + public static final Setting CACHE_MAX_USERS_SETTING = + Setting.intSetting("cache.max_users", DEFAULT_MAX_USERS, Property.NodeScope); + + private KerberosRealmSettings() { + } + + /** + * @return the valid set of {@link Setting}s for a {@value #TYPE} realm + */ + public static Set> getSettings() { + return Sets.newHashSet(HTTP_SERVICE_KEYTAB_PATH, CACHE_TTL_SETTING, CACHE_MAX_USERS_SETTING, SETTING_KRB_DEBUG_ENABLE, + SETTING_REMOVE_REALM_NAME); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java new file mode 100644 index 0000000000000..2598461c37280 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.authc; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.XPackField; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.sameInstance; + +public class DefaultAuthenticationFailureHandlerTests extends ESTestCase { + + public void testAuthenticationRequired() { + final boolean testDefault = randomBoolean(); + final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""; + final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\""; + final DefaultAuthenticationFailureHandler failuerHandler; + if (testDefault) { + failuerHandler = new DefaultAuthenticationFailureHandler(); + } else { + final Map> failureResponeHeaders = new HashMap<>(); + failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme)); + failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders); + } + assertThat(failuerHandler, is(notNullValue())); + final ElasticsearchSecurityException ese = + failuerHandler.authenticationRequired("someaction", new ThreadContext(Settings.builder().build())); + assertThat(ese, is(notNullValue())); + assertThat(ese.getMessage(), equalTo("action [someaction] requires authentication")); + assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue())); + if (testDefault) { + assertWWWAuthenticateWithSchemes(ese, basicAuthScheme); + } else { + assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme); + } + } + + public void testExceptionProcessingRequest() { + final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""; + final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\""; + final String negotiateAuthScheme = randomFrom("Negotiate", "Negotiate Ijoijksdk"); + final Map> failureResponeHeaders = new HashMap<>(); + failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme, negotiateAuthScheme)); + final DefaultAuthenticationFailureHandler failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders); + + assertThat(failuerHandler, is(notNullValue())); + final boolean causeIsElasticsearchSecurityException = randomBoolean(); + final boolean causeIsEseAndUnauthorized = causeIsElasticsearchSecurityException && randomBoolean(); + final ElasticsearchSecurityException eseCause = (causeIsEseAndUnauthorized) + ? new ElasticsearchSecurityException("unauthorized", RestStatus.UNAUTHORIZED, null, (Object[]) null) + : new ElasticsearchSecurityException("different error", RestStatus.BAD_REQUEST, null, (Object[]) null); + final Exception cause = causeIsElasticsearchSecurityException ? eseCause : new Exception("other error"); + final boolean withAuthenticateHeader = randomBoolean(); + final String selectedScheme = randomFrom(bearerAuthScheme, basicAuthScheme, negotiateAuthScheme); + if (withAuthenticateHeader) { + eseCause.addHeader("WWW-Authenticate", Collections.singletonList(selectedScheme)); + } + + if (causeIsElasticsearchSecurityException) { + if (causeIsEseAndUnauthorized) { + final ElasticsearchSecurityException ese = failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause, + new ThreadContext(Settings.builder().build())); + assertThat(ese, is(notNullValue())); + assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue())); + assertThat(ese, is(sameInstance(cause))); + if (withAuthenticateHeader == false) { + assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme); + } else { + if (selectedScheme.contains("Negotiate ")) { + assertWWWAuthenticateWithSchemes(ese, selectedScheme); + } else { + assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme); + } + } + assertThat(ese.getMessage(), equalTo("unauthorized")); + } else { + expectThrows(AssertionError.class, () -> failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause, + new ThreadContext(Settings.builder().build()))); + } + } else { + final ElasticsearchSecurityException ese = failuerHandler.exceptionProcessingRequest(Mockito.mock(RestRequest.class), cause, + new ThreadContext(Settings.builder().build())); + assertThat(ese, is(notNullValue())); + assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue())); + assertThat(ese.getMessage(), equalTo("error attempting to authenticate request")); + assertWWWAuthenticateWithSchemes(ese, basicAuthScheme, bearerAuthScheme, negotiateAuthScheme); + } + + } + + private void assertWWWAuthenticateWithSchemes(final ElasticsearchSecurityException ese, final String... schemes) { + assertThat(ese.getHeader("WWW-Authenticate").size(), is(schemes.length)); + assertThat(ese.getHeader("WWW-Authenticate"), contains(schemes)); + } +} diff --git a/x-pack/plugin/security/build.gradle b/x-pack/plugin/security/build.gradle index 2341073511c90..7f67f98b177d3 100644 --- a/x-pack/plugin/security/build.gradle +++ b/x-pack/plugin/security/build.gradle @@ -51,12 +51,73 @@ dependencies { compile "org.apache.httpcomponents:httpasyncclient:${versions.httpasyncclient}" compile "org.apache.httpcomponents:httpcore-nio:${versions.httpcore}" compile "org.apache.httpcomponents:httpclient-cache:${versions.httpclient}" - compile 'com.google.guava:guava:19.0' + compile 'com.google.guava:guava:19.0' testCompile 'org.elasticsearch:securemock:1.2' testCompile "org.elasticsearch:mocksocket:${versions.mocksocket}" //testCompile "org.yaml:snakeyaml:${versions.snakeyaml}" + // Test dependencies for Kerberos (MiniKdc) + testCompile('commons-io:commons-io:2.5') + testCompile('org.apache.kerby:kerb-simplekdc:1.1.1') + testCompile('org.apache.kerby:kerb-client:1.1.1') + testCompile('org.apache.kerby:kerby-config:1.1.1') + testCompile('org.apache.kerby:kerb-core:1.1.1') + testCompile('org.apache.kerby:kerby-pkix:1.1.1') + testCompile('org.apache.kerby:kerby-asn1:1.1.1') + testCompile('org.apache.kerby:kerby-util:1.1.1') + testCompile('org.apache.kerby:kerb-common:1.1.1') + testCompile('org.apache.kerby:kerb-crypto:1.1.1') + testCompile('org.apache.kerby:kerb-util:1.1.1') + testCompile('org.apache.kerby:token-provider:1.1.1') + testCompile('com.nimbusds:nimbus-jose-jwt:4.41.2') + testCompile('net.jcip:jcip-annotations:1.0') + testCompile('org.apache.kerby:kerb-admin:1.1.1') + testCompile('org.apache.kerby:kerb-server:1.1.1') + testCompile('org.apache.kerby:kerb-identity:1.1.1') + testCompile('org.apache.kerby:kerby-xdr:1.1.1') + + // LDAP backend support for SimpleKdcServer + testCompile('org.apache.kerby:kerby-backend:1.1.1') + testCompile('org.apache.kerby:ldap-backend:1.1.1') + testCompile('org.apache.kerby:kerb-identity:1.1.1') + testCompile('org.apache.directory.api:api-ldap-client-api:1.0.0') + testCompile('org.apache.directory.api:api-ldap-schema-data:1.0.0') + testCompile('org.apache.directory.api:api-ldap-codec-core:1.0.0') + testCompile('org.apache.directory.api:api-ldap-extras-aci:1.0.0') + testCompile('org.apache.directory.api:api-ldap-extras-codec:1.0.0') + testCompile('org.apache.directory.api:api-ldap-extras-codec-api:1.0.0') + testCompile('commons-pool:commons-pool:1.6') + testCompile('commons-collections:commons-collections:3.2') + testCompile('org.apache.mina:mina-core:2.0.17') + testCompile('org.apache.directory.api:api-util:1.0.1') + testCompile('org.apache.directory.api:api-i18n:1.0.1') + testCompile('org.apache.directory.api:api-ldap-model:1.0.1') + testCompile('org.apache.directory.api:api-asn1-api:1.0.1') + testCompile('org.apache.directory.api:api-asn1-ber:1.0.1') + testCompile('org.apache.servicemix.bundles:org.apache.servicemix.bundles.antlr:2.7.7_5') + testCompile('org.apache.directory.server:apacheds-core-api:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-i18n:2.0.0-M24') + testCompile('org.apache.directory.api:api-ldap-extras-util:1.0.0') + testCompile('net.sf.ehcache:ehcache:2.10.4') + testCompile('org.apache.directory.server:apacheds-kerberos-codec:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-protocol-ldap:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-protocol-shared:2.0.0-M24') + testCompile('org.apache.directory.jdbm:apacheds-jdbm1:2.0.0-M3') + testCompile('org.apache.directory.server:apacheds-jdbm-partition:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-xdbm-partition:2.0.0-M24') + testCompile('org.apache.directory.api:api-ldap-extras-sp:1.0.0') + testCompile('org.apache.directory.server:apacheds-test-framework:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-core-annotations:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-ldif-partition:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-mavibot-partition:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-protocol-kerberos:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-server-annotations:2.0.0-M24') + testCompile('org.apache.directory.api:api-ldap-codec-standalone:1.0.0') + testCompile('org.apache.directory.api:api-ldap-net-mina:1.0.0') + testCompile('org.apache.directory.server:ldap-client-test:2.0.0-M24') + testCompile('org.apache.directory.server:apacheds-interceptor-kerberos:2.0.0-M24') + testCompile('org.apache.directory.mavibot:mavibot:1.0.0-M8') } compileJava.options.compilerArgs << "-Xlint:-deprecation,-rawtypes,-serial,-try,-unchecked" 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 0121b1ba69ccf..7cc0a0b77faa7 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 @@ -170,6 +170,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.kerberos.KerberosRealmBootstrapCheck; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; import org.elasticsearch.xpack.security.authz.AuthorizationService; import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener; @@ -294,11 +295,12 @@ public Security(Settings settings, final Path configPath) { // fetched final List checks = new ArrayList<>(); checks.addAll(Arrays.asList( - new TokenPassphraseBootstrapCheck(settings), - new TokenSSLBootstrapCheck(), - new PkiRealmBootstrapCheck(getSslService()), - new TLSLicenseBootstrapCheck(), - new PasswordHashingAlgorithmBootstrapCheck())); + new TokenPassphraseBootstrapCheck(settings), + new TokenSSLBootstrapCheck(), + new PkiRealmBootstrapCheck(getSslService()), + new TLSLicenseBootstrapCheck(), + new PasswordHashingAlgorithmBootstrapCheck(), + new KerberosRealmBootstrapCheck(env))); checks.addAll(InternalRealms.getBootstrapChecks(settings, env)); this.bootstrapChecks = Collections.unmodifiableList(checks); } else { @@ -446,23 +448,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange); - AuthenticationFailureHandler failureHandler = null; - String extensionName = null; - for (SecurityExtension extension : securityExtensions) { - AuthenticationFailureHandler extensionFailureHandler = extension.getAuthenticationFailureHandler(); - if (extensionFailureHandler != null && failureHandler != null) { - throw new IllegalStateException("Extensions [" + extensionName + "] and [" + extension.toString() + "] " + - "both set an authentication failure handler"); - } - failureHandler = extensionFailureHandler; - extensionName = extension.toString(); - } - if (failureHandler == null) { - logger.debug("Using default authentication failure handler"); - failureHandler = new DefaultAuthenticationFailureHandler(); - } else { - logger.debug("Using authentication failure handler from extension [" + extensionName + "]"); - } + final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms); authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool, anonymousUser, tokenService)); components.add(authcService.get()); @@ -518,6 +504,45 @@ private synchronized void setIndicesAdminFilteredFields(boolean enabled) { this.indicesAdminFilteredFields = enabled; } + private AuthenticationFailureHandler createAuthenticationFailureHandler(final Realms realms) { + AuthenticationFailureHandler failureHandler = null; + String extensionName = null; + for (SecurityExtension extension : securityExtensions) { + AuthenticationFailureHandler extensionFailureHandler = extension.getAuthenticationFailureHandler(); + if (extensionFailureHandler != null && failureHandler != null) { + throw new IllegalStateException("Extensions [" + extensionName + "] and [" + extension.toString() + "] " + + "both set an authentication failure handler"); + } + failureHandler = extensionFailureHandler; + extensionName = extension.toString(); + } + if (failureHandler == null) { + logger.debug("Using default authentication failure handler"); + final Map> defaultFailureResponseHeaders = new HashMap<>(); + realms.asList().stream().forEach((realm) -> { + Map> realmFailureHeaders = realm.getAuthenticationFailureHeaders(); + realmFailureHeaders.entrySet().stream().forEach((e) -> { + String key = e.getKey(); + e.getValue().stream() + .filter(v -> defaultFailureResponseHeaders.computeIfAbsent(key, x -> new ArrayList<>()).contains(v) == false) + .forEach(v -> defaultFailureResponseHeaders.get(key).add(v)); + }); + }); + + if (TokenService.isTokenServiceEnabled(settings)) { + String bearerScheme = "Bearer realm=\"" + XPackField.SECURITY + "\""; + if (defaultFailureResponseHeaders.computeIfAbsent("WWW-Authenticate", x -> new ArrayList<>()) + .contains(bearerScheme) == false) { + defaultFailureResponseHeaders.get("WWW-Authenticate").add(bearerScheme); + } + } + failureHandler = new DefaultAuthenticationFailureHandler(defaultFailureResponseHeaders); + } else { + logger.debug("Using authentication failure handler from extension [" + extensionName + "]"); + } + return failureHandler; + } + @Override public Settings additionalSettings() { return additionalSettings(settings, enabled, transportClientMode); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 8bae951e88360..069516bcd2b6d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -271,7 +271,9 @@ private void consumeToken(AuthenticationToken token) { if (result.getStatus() == AuthenticationResult.Status.TERMINATE) { logger.info("Authentication of [{}] was terminated by realm [{}] - {}", authenticationToken.principal(), realm.name(), result.getMessage()); - userListener.onFailure(Exceptions.authenticationError(result.getMessage(), result.getException())); + Exception e = (result.getException() != null) ? result.getException() + : Exceptions.authenticationError(result.getMessage()); + userListener.onFailure(e); } else { if (result.getMessage() != null) { messages.put(realm, new Tuple<>(result.getMessage(), result.getException())); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/InternalRealms.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/InternalRealms.java index d8d0d26f99e0d..d568a052a5e15 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/InternalRealms.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/InternalRealms.java @@ -16,6 +16,7 @@ import org.elasticsearch.xpack.core.security.authc.RealmSettings; import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings; import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings; import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings; @@ -24,6 +25,7 @@ import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authc.file.FileRealm; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosRealm; import org.elasticsearch.xpack.security.authc.ldap.LdapRealm; import org.elasticsearch.xpack.security.authc.pki.PkiRealm; import org.elasticsearch.xpack.security.authc.saml.SamlRealm; @@ -32,10 +34,8 @@ import org.elasticsearch.xpack.security.support.SecurityIndexManager; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -50,17 +50,16 @@ public final class InternalRealms { /** * The list of all internal realm types, excluding {@link ReservedRealm#TYPE}. */ - private static final Set XPACK_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( - NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE, - SamlRealmSettings.TYPE - ))); + private static final Set XPACK_TYPES = Collections + .unmodifiableSet(Sets.newHashSet(NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, + LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE, SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE)); /** * The list of all standard realm types, which are those provided by x-pack and do not have extensive * interaction with third party sources */ - private static final Set STANDARD_TYPES = - Collections.unmodifiableSet(Sets.difference(XPACK_TYPES, Collections.singleton(SamlRealmSettings.TYPE))); + private static final Set STANDARD_TYPES = Collections.unmodifiableSet(Sets.newHashSet(NativeRealmSettings.TYPE, + FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE)); /** * Determines whether type is an internal realm-type that is provided by x-pack, @@ -105,6 +104,7 @@ public static Map getFactories(ThreadPool threadPool, Res sslService, resourceWatcherService, nativeRoleMappingStore, threadPool)); map.put(PkiRealmSettings.TYPE, config -> new PkiRealm(config, resourceWatcherService, nativeRoleMappingStore)); map.put(SamlRealmSettings.TYPE, config -> SamlRealm.create(config, sslService, resourceWatcherService, nativeRoleMappingStore)); + map.put(KerberosRealmSettings.TYPE, config -> new KerberosRealm(config, nativeRoleMappingStore, threadPool)); return Collections.unmodifiableMap(map); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java new file mode 100644 index 0000000000000..1a330bd2ddd54 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java @@ -0,0 +1,150 @@ +/* + * 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.kerberos; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; + +import java.util.Arrays; +import java.util.Base64; + +/** + * This class represents an AuthenticationToken for Kerberos authentication + * using SPNEGO. The token stores base 64 decoded token bytes, extracted from + * the Authorization header with auth scheme 'Negotiate'. + *

+ * Example Authorization header "Authorization: Negotiate + * YIIChgYGKwYBBQUCoII..." + *

+ * If there is any error handling during extraction of 'Negotiate' header then + * it throws {@link ElasticsearchSecurityException} with + * {@link RestStatus#UNAUTHORIZED} and header 'WWW-Authenticate: Negotiate' + */ +public final class KerberosAuthenticationToken implements AuthenticationToken { + + public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + public static final String AUTH_HEADER = "Authorization"; + public static final String NEGOTIATE_SCHEME_NAME = "Negotiate"; + public static final String NEGOTIATE_AUTH_HEADER_PREFIX = NEGOTIATE_SCHEME_NAME + " "; + + // authorization scheme check is case-insensitive + private static final boolean IGNORE_CASE_AUTH_HEADER_MATCH = true; + + private final byte[] decodedToken; + + public KerberosAuthenticationToken(final byte[] decodedToken) { + this.decodedToken = decodedToken; + } + + /** + * Extract token from authorization header and if it is valid + * {@value #NEGOTIATE_AUTH_HEADER_PREFIX} then returns + * {@link KerberosAuthenticationToken} + * + * @param authorizationHeader Authorization header from request + * @return returns {@code null} if {@link #AUTH_HEADER} is empty or does not + * start with {@value #NEGOTIATE_AUTH_HEADER_PREFIX} else returns valid + * {@link KerberosAuthenticationToken} + * @throws ElasticsearchSecurityException when negotiate header is invalid. + */ + public static KerberosAuthenticationToken extractToken(final String authorizationHeader) { + if (Strings.isNullOrEmpty(authorizationHeader)) { + return null; + } + if (authorizationHeader.regionMatches(IGNORE_CASE_AUTH_HEADER_MATCH, 0, NEGOTIATE_AUTH_HEADER_PREFIX, 0, + NEGOTIATE_AUTH_HEADER_PREFIX.length()) == false) { + return null; + } + + final String base64EncodedToken = authorizationHeader.substring(NEGOTIATE_AUTH_HEADER_PREFIX.length()).trim(); + if (Strings.isEmpty(base64EncodedToken)) { + throw unauthorized("invalid negotiate authentication header value, expected base64 encoded token but value is empty", null); + } + + byte[] decodedKerberosTicket = null; + try { + decodedKerberosTicket = Base64.getDecoder().decode(base64EncodedToken); + } catch (IllegalArgumentException iae) { + throw unauthorized("invalid negotiate authentication header value, could not decode base64 token {}", iae, base64EncodedToken); + } + + return new KerberosAuthenticationToken(decodedKerberosTicket); + } + + @Override + public String principal() { + return ""; + } + + @Override + public Object credentials() { + return decodedToken; + } + + @Override + public void clearCredentials() { + Arrays.fill(decodedToken, (byte) 0); + } + + @Override + public int hashCode() { + return Arrays.hashCode(decodedToken); + } + + @Override + public boolean equals(final Object other) { + if (this == other) + return true; + if (other == null) + return false; + if (getClass() != other.getClass()) + return false; + final KerberosAuthenticationToken otherKerbToken = (KerberosAuthenticationToken) other; + return Arrays.equals(otherKerbToken.decodedToken, this.decodedToken); + } + + /** + * Creates {@link ElasticsearchSecurityException} with + * {@link RestStatus#UNAUTHORIZED} and cause. This also populates + * 'WWW-Authenticate' header with value as 'Negotiate' scheme. + * + * @param message the detail message + * @param cause nested exception + * @param args the arguments for the message + * @return instance of {@link ElasticsearchSecurityException} + */ + static ElasticsearchSecurityException unauthorized(final String message, final Throwable cause, final Object... args) { + ElasticsearchSecurityException ese = new ElasticsearchSecurityException(message, RestStatus.UNAUTHORIZED, cause, args); + ese.addHeader(WWW_AUTHENTICATE, NEGOTIATE_SCHEME_NAME); + return ese; + } + + /** + * Sets 'WWW-Authenticate' header if outToken is not null on passed instance of + * {@link ElasticsearchSecurityException} and returns the instance.
+ * If outToken is provided and is not {@code null} or empty, then that is + * appended to 'Negotiate ' and is used as header value for header + * 'WWW-Authenticate' sent to the peer in the form 'Negotiate oYH1MIHyoAMK...'. + * This is required by client for GSS negotiation to continue further. + * + * @param ese instance of {@link ElasticsearchSecurityException} with status + * {@link RestStatus#UNAUTHORIZED} + * @param outToken if non {@code null} and not empty then this will be the value + * sent to the peer. + * @return instance of {@link ElasticsearchSecurityException} with + * 'WWW-Authenticate' header populated. + */ + static ElasticsearchSecurityException unauthorizedWithOutputToken(final ElasticsearchSecurityException ese, final String outToken) { + assert ese.status() == RestStatus.UNAUTHORIZED; + if (Strings.hasText(outToken)) { + ese.addHeader(WWW_AUTHENTICATE, NEGOTIATE_AUTH_HEADER_PREFIX + outToken); + } + return ese; + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealm.java new file mode 100644 index 0000000000000..72e3851c26169 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealm.java @@ -0,0 +1,214 @@ +/* + * 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.kerberos; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.cache.CacheBuilder; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; +import org.elasticsearch.xpack.core.security.authc.Realm; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.CachingRealm; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.support.UserRoleMapper; +import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; +import org.ietf.jgss.GSSException; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.security.auth.login.LoginException; + +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.AUTH_HEADER; +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX; +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.NEGOTIATE_SCHEME_NAME; +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.WWW_AUTHENTICATE; +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.unauthorized; +import static org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken.unauthorizedWithOutputToken; + +/** + * This class provides support for Kerberos authentication using spnego + * mechanism. + *

+ * It provides support to extract kerberos ticket using + * {@link KerberosAuthenticationToken#extractToken(String)} to build + * {@link KerberosAuthenticationToken} and then authenticating user when + * {@link KerberosTicketValidator} validates the ticket. + *

+ * On successful authentication, it will build {@link User} object populated + * with roles and will return {@link AuthenticationResult} with user object. On + * authentication failure, it will return {@link AuthenticationResult} with + * status to terminate authentication process. + */ +public final class KerberosRealm extends Realm implements CachingRealm { + + private final Cache userPrincipalNameToUserCache; + private final NativeRoleMappingStore userRoleMapper; + private final KerberosTicketValidator kerberosTicketValidator; + private final ThreadPool threadPool; + private final Path keytabPath; + private final boolean enableKerberosDebug; + private final boolean removeRealmName; + + public KerberosRealm(final RealmConfig config, final NativeRoleMappingStore nativeRoleMappingStore, final ThreadPool threadPool) { + this(config, nativeRoleMappingStore, new KerberosTicketValidator(), threadPool, null); + } + + // pkg scoped for testing + KerberosRealm(final RealmConfig config, final NativeRoleMappingStore nativeRoleMappingStore, + final KerberosTicketValidator kerberosTicketValidator, final ThreadPool threadPool, + final Cache userPrincipalNameToUserCache) { + super(KerberosRealmSettings.TYPE, config); + this.userRoleMapper = nativeRoleMappingStore; + this.userRoleMapper.refreshRealmOnChange(this); + final TimeValue ttl = KerberosRealmSettings.CACHE_TTL_SETTING.get(config.settings()); + if (ttl.getNanos() > 0) { + this.userPrincipalNameToUserCache = (userPrincipalNameToUserCache == null) + ? CacheBuilder.builder() + .setExpireAfterWrite(KerberosRealmSettings.CACHE_TTL_SETTING.get(config.settings())) + .setMaximumWeight(KerberosRealmSettings.CACHE_MAX_USERS_SETTING.get(config.settings())).build() + : userPrincipalNameToUserCache; + } else { + this.userPrincipalNameToUserCache = null; + } + this.kerberosTicketValidator = kerberosTicketValidator; + this.threadPool = threadPool; + this.keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + this.enableKerberosDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + this.removeRealmName = KerberosRealmSettings.SETTING_REMOVE_REALM_NAME.get(config.settings()); + } + + @Override + public Map> getAuthenticationFailureHeaders() { + return Collections.singletonMap(WWW_AUTHENTICATE, Collections.singletonList(NEGOTIATE_SCHEME_NAME)); + } + + @Override + public void expire(final String username) { + if (userPrincipalNameToUserCache != null) { + userPrincipalNameToUserCache.invalidate(username); + } + } + + @Override + public void expireAll() { + if (userPrincipalNameToUserCache != null) { + userPrincipalNameToUserCache.invalidateAll(); + } + } + + @Override + public boolean supports(final AuthenticationToken token) { + return token instanceof KerberosAuthenticationToken; + } + + @Override + public AuthenticationToken token(final ThreadContext context) { + return KerberosAuthenticationToken.extractToken(context.getHeader(AUTH_HEADER)); + } + + @Override + public void authenticate(final AuthenticationToken token, final ActionListener listener) { + assert token instanceof KerberosAuthenticationToken; + final KerberosAuthenticationToken kerbAuthnToken = (KerberosAuthenticationToken) token; + kerberosTicketValidator.validateTicket((byte[]) kerbAuthnToken.credentials(), keytabPath, enableKerberosDebug, + ActionListener.wrap(userPrincipalNameOutToken -> { + if (userPrincipalNameOutToken.v1() != null) { + final String username = maybeRemoveRealmName(userPrincipalNameOutToken.v1()); + buildUser(username, userPrincipalNameOutToken.v2(), listener); + } else { + /** + * This is when security context could not be established may be due to ongoing + * negotiation and requires token to be sent back to peer for continuing + * further. We are terminating the authentication process as this is spengo + * negotiation and no other realm can handle this. We can have only one Kerberos + * realm in the system so terminating with RestStatus Unauthorized (401) and + * with 'WWW-Authenticate' header populated with value with token in the form + * 'Negotiate oYH1MIHyoAMK...' + */ + String errorMessage = "failed to authenticate user, gss context negotiation not complete"; + ElasticsearchSecurityException ese = unauthorized(errorMessage, null); + ese = unauthorizedWithOutputToken(ese, userPrincipalNameOutToken.v2()); + listener.onResponse(AuthenticationResult.terminate(errorMessage, ese)); + } + }, e -> handleException(e, listener))); + } + + /** + * Usually principal names are in the form 'user/instance@REALM'. This method + * removes '@REALM' part from the principal name if + * {@link KerberosRealmSettings#SETTING_REMOVE_REALM_NAME} is {@code true} else + * will return the input string. + * + * @param principalName user principal name + * @return username after removal of realm + */ + protected String maybeRemoveRealmName(final String principalName) { + if (this.removeRealmName) { + int foundAtIndex = principalName.indexOf('@'); + if (foundAtIndex > 0) { + return principalName.substring(0, foundAtIndex); + } + } + return principalName; + } + + private void handleException(Exception e, final ActionListener listener) { + if (e instanceof LoginException) { + listener.onResponse(AuthenticationResult.terminate("failed to authenticate user, service login failure", + unauthorized(e.getLocalizedMessage(), e))); + } else if (e instanceof GSSException) { + listener.onResponse(AuthenticationResult.terminate("failed to authenticate user, gss context negotiation failure", + unauthorized(e.getLocalizedMessage(), e))); + } else { + listener.onFailure(e); + } + } + + private void buildUser(final String username, final String outToken, final ActionListener listener) { + // if outToken is present then it needs to be communicated with peer, add it to + // response header in thread context. + if (Strings.hasText(outToken)) { + threadPool.getThreadContext().addResponseHeader(WWW_AUTHENTICATE, NEGOTIATE_AUTH_HEADER_PREFIX + outToken); + } + final User user = (userPrincipalNameToUserCache != null) ? userPrincipalNameToUserCache.get(username) : null; + if (user != null) { + /** + * TODO: bizybot If authorizing realms configured, resolve user from those + * realms and then return. + */ + listener.onResponse(AuthenticationResult.success(user)); + } else { + /** + * TODO: bizybot If authorizing realms configured, resolve user from those + * realms, cache it and then return. + */ + final UserRoleMapper.UserData userData = new UserRoleMapper.UserData(username, null, Collections.emptySet(), null, this.config); + userRoleMapper.resolveRoles(userData, ActionListener.wrap(roles -> { + final User computedUser = new User(username, roles.toArray(new String[roles.size()]), null, null, null, true); + if (userPrincipalNameToUserCache != null) { + userPrincipalNameToUserCache.put(username, computedUser); + } + listener.onResponse(AuthenticationResult.success(computedUser)); + }, listener::onFailure)); + } + } + + @Override + public void lookupUser(final String username, final ActionListener listener) { + listener.onResponse(null); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java new file mode 100644 index 0000000000000..bab899a866425 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java @@ -0,0 +1,69 @@ +/* + * 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.kerberos; + +import org.elasticsearch.bootstrap.BootstrapCheck; +import org.elasticsearch.bootstrap.BootstrapContext; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.security.authc.RealmSettings; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Map.Entry; + +/** + * This class is used to perform bootstrap checks for kerberos realm. + *

+ * We use service keytabs for validating incoming kerberos tickets and is a + * required configuration. Due to JVM wide system properties for Kerberos we + * cannot support multiple Kerberos realms. This class adds checks for node to + * fail if service keytab does not exist or multiple kerberos realms have been + * configured. + */ +public class KerberosRealmBootstrapCheck implements BootstrapCheck { + private final Environment env; + + public KerberosRealmBootstrapCheck(final Environment env) { + this.env = env; + } + + @Override + public BootstrapCheckResult check(final BootstrapContext context) { + final Map realmsSettings = RealmSettings.getRealmSettings(context.settings); + boolean isKerberosRealmConfigured = false; + for (final Entry entry : realmsSettings.entrySet()) { + final String name = entry.getKey(); + final Settings realmSettings = entry.getValue(); + final String type = realmSettings.get("type"); + if (Strings.hasText(type) == false) { + return BootstrapCheckResult.failure("missing realm type for [" + name + "] realm"); + } + if (KerberosRealmSettings.TYPE.equals(type)) { + if (isKerberosRealmConfigured) { + return BootstrapCheckResult.failure( + "multiple [" + type + "] realms are configured. [" + type + "] can only have one such realm configured"); + } + isKerberosRealmConfigured = true; + + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(realmSettings)); + if (Files.exists(keytabPath) == false) { + return BootstrapCheckResult.failure("configured service key tab file [" + keytabPath + "] does not exist"); + } + } + } + return BootstrapCheckResult.success(); + } + + @Override + public boolean alwaysEnforce() { + return true; + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidator.java new file mode 100644 index 0000000000000..a63d90178dca4 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidator.java @@ -0,0 +1,273 @@ +/* + * 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.kerberos; + +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.logging.ESLoggerFactory; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.Oid; + +import java.nio.file.Path; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.security.auth.Subject; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +/** + * Utility class that validates kerberos ticket for peer authentication. + *

+ * This class takes care of login by ES service credentials using keytab, + * GSSContext establishment, and then validating the incoming token. + *

+ * It may respond with token which needs to be communicated with the peer. + */ +public class KerberosTicketValidator { + static final Oid SPNEGO_OID = getSpnegoOid(); + + private static Oid getSpnegoOid() { + Oid oid = null; + try { + oid = new Oid("1.3.6.1.5.5.2"); + } catch (GSSException gsse) { + throw ExceptionsHelper.convertToRuntime(gsse); + } + return oid; + } + + private static final Logger LOGGER = ESLoggerFactory.getLogger(KerberosTicketValidator.class); + + private static final String KEY_TAB_CONF_NAME = "KeytabConf"; + private static final String SUN_KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule"; + + /** + * Validates client kerberos ticket received from the peer. + *

+ * First performs service login using keytab, supports multiple principals in + * keytab and the principal is selected based on the request. + *

+ * The GSS security context establishment state is handled as follows:
+ * If the context is established it will call {@link ActionListener#onResponse} + * with a {@link Tuple} of username and outToken for peer reply.
+ * If the context is not established then it will call + * {@link ActionListener#onResponse} with a Tuple where username is null but + * with a outToken that needs to be sent to peer for further negotiation.
+ * Never calls {@link ActionListener#onResponse} with a {@code null} tuple.
+ * On failure, it will call {@link ActionListener#onFailure(Exception)} + * + * @param decodedToken base64 decoded kerberos ticket bytes + * @param keytabPath Path to Service key tab file containing credentials for ES + * service. + * @param krbDebug if {@code true} enables jaas krb5 login module debug logs. + */ + public void validateTicket(final byte[] decodedToken, final Path keytabPath, final boolean krbDebug, + final ActionListener> actionListener) { + final GSSManager gssManager = GSSManager.getInstance(); + GSSContext gssContext = null; + LoginContext loginContext = null; + try { + loginContext = serviceLogin(keytabPath.toString(), krbDebug); + GSSCredential serviceCreds = createCredentials(gssManager, loginContext.getSubject()); + gssContext = gssManager.createContext(serviceCreds); + final String base64OutToken = encodeToString(acceptSecContext(decodedToken, gssContext, loginContext.getSubject())); + LOGGER.trace("validateTicket isGSSContextEstablished = {}, username = {}, outToken = {}", gssContext.isEstablished(), + gssContext.getSrcName().toString(), base64OutToken); + actionListener.onResponse(new Tuple<>(gssContext.isEstablished() ? gssContext.getSrcName().toString() : null, base64OutToken)); + } catch (GSSException e) { + actionListener.onFailure(e); + } catch (PrivilegedActionException pve) { + if (pve.getCause() instanceof LoginException) { + actionListener.onFailure((LoginException) pve.getCause()); + } else if (pve.getCause() instanceof GSSException) { + actionListener.onFailure((GSSException) pve.getCause()); + } else { + actionListener.onFailure(pve.getException()); + } + } finally { + privilegedLogoutNoThrow(loginContext); + privilegedDisposeNoThrow(gssContext); + } + } + + /** + * Encodes the specified byte array using base64 encoding scheme + * + * @param outToken byte array to be encoded + * @return String containing base64 encoded characters. returns {@code null} if + * outToken is null or empty. + */ + private String encodeToString(final byte[] outToken) { + if (outToken != null && outToken.length > 0) { + return Base64.getEncoder().encodeToString(outToken); + } + return null; + } + + /** + * Handles GSS context establishment. Received token is passed to the GSSContext + * on acceptor side and returns with out token that needs to be sent to peer for + * further GSS context establishment. + *

+ * + * @param base64decodedTicket in token generated by peer + * @param gssContext instance of acceptor {@link GSSContext} + * @param subject authenticated subject + * @return a byte[] containing the token to be sent to the peer. null indicates + * that no token is generated. + * @throws PrivilegedActionException + * @see GSSContext#acceptSecContext(byte[], int, int) + */ + private static byte[] acceptSecContext(final byte[] base64decodedTicket, final GSSContext gssContext, Subject subject) + throws PrivilegedActionException { + // process token with gss context + return doAsWrapper(subject, + (PrivilegedExceptionAction) () -> gssContext.acceptSecContext(base64decodedTicket, 0, base64decodedTicket.length)); + } + + /** + * For acquiring SPNEGO mechanism credentials for service based on the subject + * + * @param gssManager {@link GSSManager} + * @param subject logged in {@link Subject} + * @return {@link GSSCredential} for particular mechanism + * @throws PrivilegedActionException + */ + private static GSSCredential createCredentials(final GSSManager gssManager, final Subject subject) throws PrivilegedActionException { + return doAsWrapper(subject, (PrivilegedExceptionAction) () -> gssManager.createCredential(null, + GSSCredential.DEFAULT_LIFETIME, SPNEGO_OID, GSSCredential.ACCEPT_ONLY)); + } + + /** + * Privileged Wrapper that invokes action with Subject.doAs to perform work as + * given subject. + * + * @param subject {@link Subject} to be used for this work + * @param action {@link PrivilegedExceptionAction} action for performing inside + * Subject.doAs + * @return the value returned by the PrivilegedExceptionAction's run method + * @throws PrivilegedActionException + */ + private static T doAsWrapper(final Subject subject, final PrivilegedExceptionAction action) throws PrivilegedActionException { + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAs(subject, action)); + } catch (PrivilegedActionException pae) { + if (pae.getCause() instanceof PrivilegedActionException) { + throw (PrivilegedActionException) pae.getCause(); + } + throw pae; + } + } + + /** + * Privileged wrapper for closing GSSContext, does not throw exceptions but logs + * them as a debug message. + * + * @param gssContext GSSContext to be disposed. + */ + private static void privilegedDisposeNoThrow(final GSSContext gssContext) { + if (gssContext != null) { + try { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + gssContext.dispose(); + return null; + }); + } catch (PrivilegedActionException e) { + LOGGER.debug("Could not dispose GSS Context", e.getCause()); + } + } + } + + /** + * Privileged wrapper for closing LoginContext, does not throw exceptions but + * logs them as a debug message. + * + * @param loginContext LoginContext to be closed + */ + private static void privilegedLogoutNoThrow(final LoginContext loginContext) { + if (loginContext != null) { + try { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + loginContext.logout(); + return null; + }); + } catch (PrivilegedActionException e) { + LOGGER.debug("Could not close LoginContext", e.getCause()); + } + } + } + + /** + * Performs authentication using provided keytab + * + * @param keytabFilePath Keytab file path + * @param krbDebug if {@code true} enables jaas krb5 login module debug logs. + * @return authenticated {@link LoginContext} instance. Note: This needs to be + * closed using {@link LoginContext#logout()} after usage. + * @throws PrivilegedActionException when privileged action threw exception + */ + private static LoginContext serviceLogin(final String keytabFilePath, final boolean krbDebug) throws PrivilegedActionException { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + final Subject subject = new Subject(false, Collections.emptySet(), Collections.emptySet(), Collections.emptySet()); + final Configuration conf = new KeytabJaasConf(keytabFilePath, krbDebug); + final LoginContext loginContext = new LoginContext(KEY_TAB_CONF_NAME, subject, null, conf); + loginContext.login(); + return loginContext; + }); + } + + /** + * Usually we would have a JAAS configuration file for login configuration. As + * we have static configuration except debug flag, we are constructing in + * memory. This avoids additional configuration required from the user. + *

+ * As we are using this instead of jaas.conf, this requires refresh of + * {@link Configuration} and requires appropriate security permissions to do so. + */ + static class KeytabJaasConf extends Configuration { + private final String keytabFilePath; + private final boolean krbDebug; + + KeytabJaasConf(final String keytabFilePath, final boolean krbDebug) { + this.keytabFilePath = keytabFilePath; + this.krbDebug = krbDebug; + } + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { + final Map options = new HashMap<>(); + options.put("keyTab", keytabFilePath); + /* + * As acceptor, we can have multiple SPNs, we do not want to use particular + * principal so it uses "*" + */ + options.put("principal", "*"); + options.put("useKeyTab", Boolean.TRUE.toString()); + options.put("storeKey", Boolean.TRUE.toString()); + options.put("doNotPrompt", Boolean.TRUE.toString()); + options.put("isInitiator", Boolean.FALSE.toString()); + options.put("debug", Boolean.toString(krbDebug)); + + return new AppConfigurationEntry[] { new AppConfigurationEntry(SUN_KRB5_LOGIN_MODULE, + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, Collections.unmodifiableMap(options)) }; + } + + } +} diff --git a/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy b/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy index 857c2f6e472d5..8ce72be3ef9a7 100644 --- a/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy +++ b/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy @@ -10,6 +10,21 @@ grant { // needed for multiple server implementations used in tests permission java.net.SocketPermission "*", "accept,connect"; + + // needed for Kerberos login + permission javax.security.auth.AuthPermission "modifyPrincipals"; + permission javax.security.auth.AuthPermission "modifyPrivateCredentials"; + permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosKey * \"*\"", "read"; + permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KeyTab * \"*\"", "read"; + permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosTicket * \"*\"", "read"; + permission javax.security.auth.AuthPermission "doAs"; + permission javax.security.auth.kerberos.ServicePermission "*","initiate,accept"; + + permission java.util.PropertyPermission "javax.security.auth.useSubjectCredsOnly","write"; + permission java.util.PropertyPermission "java.security.krb5.conf","write"; + permission java.util.PropertyPermission "sun.security.krb5.debug","write"; + permission java.util.PropertyPermission "java.security.debug","write"; + permission java.util.PropertyPermission "sun.security.spnego.debug","write"; }; grant codeBase "${codebase.xmlsec-2.0.8.jar}" { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index cd685b8f34c28..cf82083217e5d 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -49,6 +49,7 @@ import org.elasticsearch.threadpool.TestThreadPool; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.TransportMessage; +import org.elasticsearch.xpack.core.XPackField; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; @@ -88,6 +89,7 @@ import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError; import static org.elasticsearch.xpack.security.authc.TokenServiceTests.mockGetTokenFromId; import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -620,6 +622,47 @@ public void testRealmSupportsMethodThrowingExceptionRest() throws Exception { } } + public void testRealmAuthenticateTerminatingAuthenticationProcess() throws Exception { + final AuthenticationToken token = mock(AuthenticationToken.class); + when(secondRealm.token(threadContext)).thenReturn(token); + when(secondRealm.supports(token)).thenReturn(true); + final boolean terminateWithNoException = rarely(); + final boolean throwElasticsearchSecurityException = (terminateWithNoException == false) && randomBoolean(); + final boolean withAuthenticateHeader = throwElasticsearchSecurityException && randomBoolean(); + Exception throwE = new Exception("general authentication error"); + final String basicScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""; + String selectedScheme = randomFrom(basicScheme, "Negotiate IOJoj"); + if (throwElasticsearchSecurityException) { + throwE = new ElasticsearchSecurityException("authentication error", RestStatus.UNAUTHORIZED); + if (withAuthenticateHeader) { + ((ElasticsearchSecurityException) throwE).addHeader("WWW-Authenticate", selectedScheme); + } + } + mockAuthenticate(secondRealm, token, (terminateWithNoException) ? null : throwE, true); + + ElasticsearchSecurityException e = + expectThrows(ElasticsearchSecurityException.class, () -> authenticateBlocking("_action", message, null)); + if (terminateWithNoException) { + assertThat(e.getMessage(), is("terminate authc process")); + assertThat(e.getHeader("WWW-Authenticate"), contains(basicScheme)); + } else { + if (throwElasticsearchSecurityException) { + assertThat(e.getMessage(), is("authentication error")); + if (withAuthenticateHeader) { + assertThat(e.getHeader("WWW-Authenticate"), contains(selectedScheme)); + } else { + assertThat(e.getHeader("WWW-Authenticate"), contains(basicScheme)); + } + } else { + assertThat(e.getMessage(), is("error attempting to authenticate request")); + assertThat(e.getHeader("WWW-Authenticate"), contains(basicScheme)); + } + } + verify(auditTrail).authenticationFailed(secondRealm.name(), token, "_action", message); + verify(auditTrail).authenticationFailed(token, "_action", message); + verifyNoMoreInteractions(auditTrail); + } + public void testRealmAuthenticateThrowingException() throws Exception { AuthenticationToken token = mock(AuthenticationToken.class); when(secondRealm.token(threadContext)).thenReturn(token); @@ -996,6 +1039,19 @@ private void mockAuthenticate(Realm realm, AuthenticationToken token, User user) }).when(realm).authenticate(eq(token), any(ActionListener.class)); } + @SuppressWarnings("unchecked") + private void mockAuthenticate(Realm realm, AuthenticationToken token, Exception e, boolean terminate) { + doAnswer((i) -> { + ActionListener listener = (ActionListener) i.getArguments()[1]; + if (terminate) { + listener.onResponse(AuthenticationResult.terminate("terminate authc process", e)); + } else { + listener.onResponse(AuthenticationResult.unsuccessful("unsuccessful, but continue authc process", e)); + } + return null; + }).when(realm).authenticate(eq(token), any(ActionListener.class)); + } + private Authentication authenticateBlocking(RestRequest restRequest) { PlainActionFuture future = new PlainActionFuture<>(); service.authenticate(restRequest, future); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/InternalRealmsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/InternalRealmsTests.java index 0cbeced00b2ab..2e91c40677ed6 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/InternalRealmsTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/InternalRealmsTests.java @@ -14,6 +14,11 @@ import org.elasticsearch.xpack.core.security.authc.Realm; import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; +import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings; +import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings; +import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings; import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; @@ -49,4 +54,12 @@ public void testNativeRealmRegistersIndexHealthChangeListener() throws Exception TestEnvironment.newEnvironment(settings), new ThreadContext(settings))); verify(securityIndex, times(2)).addIndexStateListener(isA(BiConsumer.class)); } + + public void testIsStandardType() { + String type = randomFrom(NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, + PkiRealmSettings.TYPE); + assertThat(InternalRealms.isStandardRealm(type), is(true)); + type = randomFrom(SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE); + assertThat(InternalRealms.isStandardRealm(type), is(false)); + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java index ff4c30ddf8c0a..a71f5cb1cf761 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java @@ -20,6 +20,7 @@ import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings; import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings; import org.elasticsearch.xpack.core.security.user.User; @@ -335,10 +336,11 @@ public void testUnlicensedWithNativeRealmSettingss() throws Exception { } public void testUnlicensedWithNonStandardRealms() throws Exception { - factories.put(SamlRealmSettings.TYPE, config -> new DummyRealm(SamlRealmSettings.TYPE, config)); + final String selectedRealmType = randomFrom(SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE); + factories.put(selectedRealmType, config -> new DummyRealm(selectedRealmType, config)); Settings.Builder builder = Settings.builder() .put("path.home", createTempDir()) - .put("xpack.security.authc.realms.foo.type", SamlRealmSettings.TYPE) + .put("xpack.security.authc.realms.foo.type", selectedRealmType) .put("xpack.security.authc.realms.foo.order", "0"); Settings settings = builder.build(); Environment env = TestEnvironment.newEnvironment(settings); @@ -349,7 +351,7 @@ public void testUnlicensedWithNonStandardRealms() throws Exception { assertThat(realm, is(reservedRealm)); assertThat(iter.hasNext(), is(true)); realm = iter.next(); - assertThat(realm.type(), is(SamlRealmSettings.TYPE)); + assertThat(realm.type(), is(selectedRealmType)); assertThat(iter.hasNext(), is(false)); when(licenseState.allowedRealmType()).thenReturn(AllowedRealmType.DEFAULT); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java new file mode 100644 index 0000000000000..eaba796b41fe4 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java @@ -0,0 +1,110 @@ +/* + * 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.kerberos; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.EqualsHashCodeTestUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.notNullValue; + +public class KerberosAuthenticationTokenTests extends ESTestCase { + + private static final String UNAUTHENTICATED_PRINCIPAL_NAME = ""; + + public void testExtractTokenForValidAuthorizationHeader() throws IOException { + final String base64Token = Base64.getEncoder().encodeToString(randomAlphaOfLength(5).getBytes(StandardCharsets.UTF_8)); + final String negotiate = randomBoolean() ? KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX : "negotiate "; + final String authzHeader = negotiate + base64Token; + + final KerberosAuthenticationToken kerbAuthnToken = KerberosAuthenticationToken.extractToken(authzHeader); + assertNotNull(kerbAuthnToken); + assertEquals(UNAUTHENTICATED_PRINCIPAL_NAME, kerbAuthnToken.principal()); + assertThat(kerbAuthnToken.credentials(), instanceOf((byte[].class))); + assertArrayEquals(Base64.getDecoder().decode(base64Token), (byte[]) kerbAuthnToken.credentials()); + } + + public void testExtractTokenForInvalidNegotiateAuthorizationHeaderShouldReturnNull() throws IOException { + final String header = randomFrom("negotiate", "Negotiate", " Negotiate", "NegotiateToken", "Basic ", " Custom ", null); + assertNull(KerberosAuthenticationToken.extractToken(header)); + } + + public void testExtractTokenForNegotiateAuthorizationHeaderWithNoTokenShouldThrowException() throws IOException { + final String header = randomFrom(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX, "negotiate ", "Negotiate "); + final ElasticsearchSecurityException e = + expectThrows(ElasticsearchSecurityException.class, () -> KerberosAuthenticationToken.extractToken(header)); + assertThat(e.getMessage(), + equalTo("invalid negotiate authentication header value, expected base64 encoded token but value is empty")); + assertContainsAuthenticateHeader(e); + } + + public void testExtractTokenForNotBase64EncodedTokenThrowsException() throws IOException { + final String notBase64Token = "[B@6499375d"; + + final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> KerberosAuthenticationToken.extractToken(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + notBase64Token)); + assertThat(e.getMessage(), + equalTo("invalid negotiate authentication header value, could not decode base64 token " + notBase64Token)); + assertContainsAuthenticateHeader(e); + } + + public void testKerberoAuthenticationTokenClearCredentials() { + byte[] inputBytes = randomByteArrayOfLength(5); + final String base64Token = Base64.getEncoder().encodeToString(inputBytes); + final KerberosAuthenticationToken kerbAuthnToken = + KerberosAuthenticationToken.extractToken(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + base64Token); + kerbAuthnToken.clearCredentials(); + Arrays.fill(inputBytes, (byte) 0); + assertArrayEquals(inputBytes, (byte[]) kerbAuthnToken.credentials()); + } + + public void testEqualsHashCode() { + final KerberosAuthenticationToken kerberosAuthenticationToken = + new KerberosAuthenticationToken("base64EncodedToken".getBytes(StandardCharsets.UTF_8)); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> { + return new KerberosAuthenticationToken((byte[]) original.credentials()); + }); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> { + byte[] originalCreds = (byte[]) original.credentials(); + return new KerberosAuthenticationToken(Arrays.copyOf(originalCreds, originalCreds.length)); + }); + EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> { + return new KerberosAuthenticationToken((byte[]) original.credentials()); + }, KerberosAuthenticationTokenTests::mutateTestItem); + } + + private static KerberosAuthenticationToken mutateTestItem(KerberosAuthenticationToken original) { + switch (randomIntBetween(0, 2)) { + case 0: + return new KerberosAuthenticationToken(randomByteArrayOfLength(10)); + case 1: + return new KerberosAuthenticationToken("base64EncodedToken".getBytes(StandardCharsets.UTF_16)); + case 2: + return new KerberosAuthenticationToken("[B@6499375d".getBytes(StandardCharsets.UTF_8)); + default: + throw new IllegalArgumentException("unknown option"); + } + } + + private static void assertContainsAuthenticateHeader(ElasticsearchSecurityException e) { + assertThat(e.status(), is(RestStatus.UNAUTHORIZED)); + assertThat(e.getHeaderKeys(), hasSize(1)); + assertThat(e.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE), notNullValue()); + assertThat(e.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE), contains(KerberosAuthenticationToken.NEGOTIATE_SCHEME_NAME)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmAuthenticateFailedTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmAuthenticateFailedTests.java new file mode 100644 index 0000000000000..5bc239241cf11 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmAuthenticateFailedTests.java @@ -0,0 +1,108 @@ +/* + * 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.kerberos; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +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.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.user.User; +import org.ietf.jgss.GSSException; + +import java.nio.file.Path; +import java.util.List; + +import javax.security.auth.login.LoginException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; + +public class KerberosRealmAuthenticateFailedTests extends KerberosRealmTestCase { + + public void testAuthenticateWithNonKerberosAuthenticationToken() { + final KerberosRealm kerberosRealm = createKerberosRealm(randomAlphaOfLength(5)); + + final UsernamePasswordToken usernamePasswordToken = + new UsernamePasswordToken(randomAlphaOfLength(5), new SecureString(new char[] { 'a', 'b', 'c' })); + expectThrows(AssertionError.class, () -> kerberosRealm.authenticate(usernamePasswordToken, PlainActionFuture.newFuture())); + } + + public void testAuthenticateDifferentFailureScenarios() throws LoginException, GSSException { + final String username = randomPrincipalName(); + final String outToken = randomAlphaOfLength(10); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + final boolean validTicket = rarely(); + final boolean throwExceptionForInvalidTicket = validTicket ? false : randomBoolean(); + final boolean throwLoginException = randomBoolean(); + final byte[] decodedTicket = randomByteArrayOfLength(5); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + if (validTicket) { + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(username, outToken), null); + } else { + if (throwExceptionForInvalidTicket) { + if (throwLoginException) { + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, null, new LoginException("Login Exception")); + } else { + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, null, new GSSException(GSSException.FAILURE)); + } + } else { + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(null, outToken), null); + } + } + final boolean nullKerberosAuthnToken = rarely(); + final KerberosAuthenticationToken kerberosAuthenticationToken = + nullKerberosAuthnToken ? null : new KerberosAuthenticationToken(decodedTicket); + if (nullKerberosAuthnToken) { + expectThrows(AssertionError.class, + () -> kerberosRealm.authenticate(kerberosAuthenticationToken, PlainActionFuture.newFuture())); + } else { + final PlainActionFuture future = new PlainActionFuture<>(); + kerberosRealm.authenticate(kerberosAuthenticationToken, future); + AuthenticationResult result = future.actionGet(); + assertThat(result, is(notNullValue())); + if (validTicket) { + final String expectedUsername = maybeRemoveRealmName(username); + final User expectedUser = new User(expectedUsername, roles.toArray(new String[roles.size()]), null, null, null, true); + assertSuccessAuthenticationResult(expectedUser, outToken, result); + } else { + assertThat(result.getStatus(), is(equalTo(AuthenticationResult.Status.TERMINATE))); + if (throwExceptionForInvalidTicket == false) { + assertThat(result.getException(), is(instanceOf(ElasticsearchSecurityException.class))); + final List wwwAuthnHeader = ((ElasticsearchSecurityException) result.getException()) + .getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE); + assertThat(wwwAuthnHeader, is(notNullValue())); + assertThat(wwwAuthnHeader.get(0), is(equalTo(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + outToken))); + assertThat(result.getMessage(), is(equalTo("failed to authenticate user, gss context negotiation not complete"))); + } else { + if (throwLoginException) { + assertThat(result.getMessage(), is(equalTo("failed to authenticate user, service login failure"))); + } else { + assertThat(result.getMessage(), is(equalTo("failed to authenticate user, gss context negotiation failure"))); + } + assertThat(result.getException(), is(instanceOf(ElasticsearchSecurityException.class))); + final List wwwAuthnHeader = ((ElasticsearchSecurityException) result.getException()) + .getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE); + assertThat(wwwAuthnHeader, is(notNullValue())); + assertThat(wwwAuthnHeader.get(0), is(equalTo(KerberosAuthenticationToken.NEGOTIATE_SCHEME_NAME))); + } + } + verify(mockKerberosTicketValidator).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), + any(ActionListener.class)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java new file mode 100644 index 0000000000000..b6e1df9ddbb79 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc.kerberos; + +import org.elasticsearch.bootstrap.BootstrapCheck; +import org.elasticsearch.bootstrap.BootstrapContext; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authc.RealmSettings; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +public class KerberosRealmBootstrapCheckTests extends ESTestCase { + + public void testBootstrapCheckFailsForMultipleKerberosRealms() throws IOException { + final Path tempDir = createTempDir(); + final Settings settings1 = buildKerberosRealmSettings("kerb1", false, tempDir); + final Settings settings2 = buildKerberosRealmSettings("kerb2", false, tempDir); + final Settings settings3 = realm("pki1", PkiRealmSettings.TYPE, Settings.builder()).build(); + final Settings settings = + Settings.builder().put("path.home", tempDir).put(settings1).put(settings2).put(settings3).build(); + final BootstrapContext context = new BootstrapContext(settings, null); + final KerberosRealmBootstrapCheck kerbRealmBootstrapCheck = + new KerberosRealmBootstrapCheck(TestEnvironment.newEnvironment(settings)); + final BootstrapCheck.BootstrapCheckResult result = kerbRealmBootstrapCheck.check(context); + assertThat(result, is(notNullValue())); + assertThat(result.isFailure(), is(true)); + assertThat(result.getMessage(), equalTo("multiple [" + KerberosRealmSettings.TYPE + "] realms are configured. [" + + KerberosRealmSettings.TYPE + "] can only have one such realm configured")); + } + + public void testBootstrapCheckFailsForMissingKeytabFile() throws IOException { + final Path tempDir = createTempDir(); + final Settings settings = + Settings.builder().put("path.home", tempDir).put(buildKerberosRealmSettings("kerb1", true, tempDir)).build(); + final BootstrapContext context = new BootstrapContext(settings, null); + final KerberosRealmBootstrapCheck kerbRealmBootstrapCheck = + new KerberosRealmBootstrapCheck(TestEnvironment.newEnvironment(settings)); + final BootstrapCheck.BootstrapCheckResult result = kerbRealmBootstrapCheck.check(context); + assertThat(result, is(notNullValue())); + assertThat(result.isFailure(), is(true)); + assertThat(result.getMessage(), + equalTo("configured service key tab file [" + tempDir.resolve("kerb1.keytab").toString() + "] does not exist")); + } + + public void testBootstrapCheckFailsForMissingRealmType() throws IOException { + final Path tempDir = createTempDir(); + final String name = "kerb1"; + final Settings settings1 = buildKerberosRealmSettings("kerb1", false, tempDir); + final Settings settings2 = realm(name, randomFrom("", " "), Settings.builder()).build(); + final Settings settings = + Settings.builder().put("path.home", tempDir).put(settings1).put(settings2).build(); + final BootstrapContext context = new BootstrapContext(settings, null); + final KerberosRealmBootstrapCheck kerbRealmBootstrapCheck = + new KerberosRealmBootstrapCheck(TestEnvironment.newEnvironment(settings)); + final BootstrapCheck.BootstrapCheckResult result = kerbRealmBootstrapCheck.check(context); + assertThat(result, is(notNullValue())); + assertThat(result.isFailure(), is(true)); + assertThat(result.getMessage(), equalTo("missing realm type for [" + name + "] realm")); + } + + public void testBootstrapCheckSucceedsForCorrectConfiguration() throws IOException { + final Path tempDir = createTempDir(); + final Settings finalSettings = + Settings.builder().put("path.home", tempDir).put(buildKerberosRealmSettings("kerb1", false, tempDir)).build(); + final BootstrapContext context = new BootstrapContext(finalSettings, null); + final KerberosRealmBootstrapCheck kerbRealmBootstrapCheck = + new KerberosRealmBootstrapCheck(TestEnvironment.newEnvironment(finalSettings)); + final BootstrapCheck.BootstrapCheckResult result = kerbRealmBootstrapCheck.check(context); + assertThat(result, is(notNullValue())); + assertThat(result.isSuccess(), is(true)); + } + + public void testBootstrapCheckSucceedsForNoKerberosRealms() throws IOException { + final Path tempDir = createTempDir(); + final Settings finalSettings = Settings.builder().put("path.home", tempDir).build(); + final BootstrapContext context = new BootstrapContext(finalSettings, null); + final KerberosRealmBootstrapCheck kerbRealmBootstrapCheck = + new KerberosRealmBootstrapCheck(TestEnvironment.newEnvironment(finalSettings)); + final BootstrapCheck.BootstrapCheckResult result = kerbRealmBootstrapCheck.check(context); + assertThat(result, is(notNullValue())); + assertThat(result.isSuccess(), is(true)); + } + + private Settings buildKerberosRealmSettings(final String name, final boolean missingKeytab, final Path tempDir) throws IOException { + final Settings.Builder builder = Settings.builder(); + if (missingKeytab == false) { + KerberosTestCase.writeKeyTab(tempDir.resolve(name + ".keytab"), null); + } + builder.put(KerberosTestCase.buildKerberosRealmSettings(tempDir.resolve(name + ".keytab").toString())); + return realm(name, KerberosRealmSettings.TYPE, builder).build(); + } + + private Settings.Builder realm(final String name, final String type, final Settings.Builder settings) { + final String prefix = RealmSettings.PREFIX + name + "."; + if (type != null) { + settings.put("type", type); + } + final Settings.Builder builder = Settings.builder().put(settings.normalizePrefix(prefix).build(), false); + return builder; + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmCacheTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmCacheTests.java new file mode 100644 index 0000000000000..69ebe15c5d74b --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmCacheTests.java @@ -0,0 +1,141 @@ +/* + * 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.kerberos; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.support.UserRoleMapper.UserData; +import org.ietf.jgss.GSSException; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import javax.security.auth.login.LoginException; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +public class KerberosRealmCacheTests extends KerberosRealmTestCase { + + public void testAuthenticateWithCache() throws LoginException, GSSException { + final String username = randomPrincipalName(); + final String outToken = randomAlphaOfLength(10); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + + final String expectedUsername = maybeRemoveRealmName(username); + final User expectedUser = new User(expectedUsername, roles.toArray(new String[roles.size()]), null, null, null, true); + final byte[] decodedTicket = randomByteArrayOfLength(10); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(username, outToken), null); + final KerberosAuthenticationToken kerberosAuthenticationToken = new KerberosAuthenticationToken(decodedTicket); + + // authenticate + final User user1 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + // authenticate with cache + final User user2 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + + assertThat(user1, sameInstance(user2)); + verify(mockKerberosTicketValidator, times(2)).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), + any(ActionListener.class)); + verify(mockNativeRoleMappingStore).refreshRealmOnChange(kerberosRealm); + verify(mockNativeRoleMappingStore).resolveRoles(any(UserData.class), any(ActionListener.class)); + verifyNoMoreInteractions(mockKerberosTicketValidator, mockNativeRoleMappingStore); + } + + public void testCacheInvalidationScenarios() throws LoginException, GSSException { + final String outToken = randomAlphaOfLength(10); + final List userNames = Arrays.asList(randomPrincipalName(), randomPrincipalName()); + final KerberosRealm kerberosRealm = createKerberosRealm(userNames.toArray(new String[0])); + verify(mockNativeRoleMappingStore).refreshRealmOnChange(kerberosRealm); + + final String authNUsername = randomFrom(userNames); + final byte[] decodedTicket = randomByteArrayOfLength(10); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(authNUsername, outToken), null); + final String expectedUsername = maybeRemoveRealmName(authNUsername); + final User expectedUser = new User(expectedUsername, roles.toArray(new String[roles.size()]), null, null, null, true); + + final KerberosAuthenticationToken kerberosAuthenticationToken = new KerberosAuthenticationToken(decodedTicket); + final User user1 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + + final String expireThisUser = randomFrom(userNames); + boolean expireAll = randomBoolean(); + if (expireAll) { + kerberosRealm.expireAll(); + } else { + kerberosRealm.expire(maybeRemoveRealmName(expireThisUser)); + } + + final User user2 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + + if (expireAll || expireThisUser.equals(authNUsername)) { + assertThat(user1, is(not(sameInstance(user2)))); + verify(mockNativeRoleMappingStore, times(2)).resolveRoles(any(UserData.class), any(ActionListener.class)); + } else { + assertThat(user1, sameInstance(user2)); + verify(mockNativeRoleMappingStore).resolveRoles(any(UserData.class), any(ActionListener.class)); + } + verify(mockKerberosTicketValidator, times(2)).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), + any(ActionListener.class)); + verifyNoMoreInteractions(mockKerberosTicketValidator, mockNativeRoleMappingStore); + } + + public void testAuthenticateWithValidTicketSucessAuthnWithUserDetailsWhenCacheDisabled() + throws LoginException, GSSException, IOException { + // if cache.ttl <= 0 then the cache is disabled + settings = KerberosTestCase.buildKerberosRealmSettings( + KerberosTestCase.writeKeyTab(dir.resolve("key.keytab"), randomAlphaOfLength(4)).toString(), 100, "0m", true, + randomBoolean()); + final String username = randomPrincipalName(); + final String outToken = randomAlphaOfLength(10); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + + final String expectedUsername = maybeRemoveRealmName(username); + final User expectedUser = new User(expectedUsername, roles.toArray(new String[roles.size()]), null, null, null, true); + final byte[] decodedTicket = randomByteArrayOfLength(10); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(username, outToken), null); + final KerberosAuthenticationToken kerberosAuthenticationToken = new KerberosAuthenticationToken(decodedTicket); + + // authenticate + final User user1 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + // authenticate when cache has been disabled + final User user2 = authenticateAndAssertResult(kerberosRealm, expectedUser, kerberosAuthenticationToken, outToken); + + assertThat(user1, not(sameInstance(user2))); + verify(mockKerberosTicketValidator, times(2)).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), + any(ActionListener.class)); + verify(mockNativeRoleMappingStore).refreshRealmOnChange(kerberosRealm); + verify(mockNativeRoleMappingStore, times(2)).resolveRoles(any(UserData.class), any(ActionListener.class)); + verifyNoMoreInteractions(mockKerberosTicketValidator, mockNativeRoleMappingStore); + } + + private User authenticateAndAssertResult(final KerberosRealm kerberosRealm, final User expectedUser, + final KerberosAuthenticationToken kerberosAuthenticationToken, String outToken) { + final PlainActionFuture future = PlainActionFuture.newFuture(); + kerberosRealm.authenticate(kerberosAuthenticationToken, future); + final AuthenticationResult result = future.actionGet(); + assertSuccessAuthenticationResult(expectedUser, outToken, result); + return result.getUser(); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java new file mode 100644 index 0000000000000..2e47d03d49d06 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java @@ -0,0 +1,46 @@ +/* + * 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.kerberos; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class KerberosRealmSettingsTests extends ESTestCase { + + public void testKerberosRealmSettings() throws IOException { + final Path dir = createTempDir(); + Path configDir = dir.resolve("config"); + if (Files.exists(configDir) == false) { + configDir = Files.createDirectory(configDir); + } + final String keytabPathConfig = "config" + dir.getFileSystem().getSeparator() + "http.keytab"; + KerberosTestCase.writeKeyTab(dir.resolve(keytabPathConfig), null); + final Integer maxUsers = randomInt(); + final String cacheTTL = randomLongBetween(10L, 100L) + "m"; + final boolean enableDebugLogs = randomBoolean(); + final boolean removeRealmName = randomBoolean(); + final Settings settings = KerberosTestCase.buildKerberosRealmSettings(keytabPathConfig, maxUsers, cacheTTL, enableDebugLogs, + removeRealmName); + + assertThat(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings), equalTo(keytabPathConfig)); + assertThat(KerberosRealmSettings.CACHE_TTL_SETTING.get(settings), + equalTo(TimeValue.parseTimeValue(cacheTTL, KerberosRealmSettings.CACHE_TTL_SETTING.getKey()))); + assertThat(KerberosRealmSettings.CACHE_MAX_USERS_SETTING.get(settings), equalTo(maxUsers)); + assertThat(KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(settings), is(enableDebugLogs)); + assertThat(KerberosRealmSettings.SETTING_REMOVE_REALM_NAME.get(settings), is(removeRealmName)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTestCase.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTestCase.java new file mode 100644 index 0000000000000..9c2c6484c82ab --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTestCase.java @@ -0,0 +1,168 @@ +/* + * 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.kerberos; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.support.Exceptions; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.support.UserRoleMapper; +import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; +import org.junit.After; +import org.junit.Before; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public abstract class KerberosRealmTestCase extends ESTestCase { + + protected Path dir; + protected ThreadPool threadPool; + protected Settings globalSettings; + protected ResourceWatcherService resourceWatcherService; + protected Settings settings; + protected RealmConfig config; + + protected KerberosTicketValidator mockKerberosTicketValidator; + protected NativeRoleMappingStore mockNativeRoleMappingStore; + + protected static final Set roles = Sets.newHashSet("admin", "kibana_user"); + + @Before + public void setup() throws Exception { + threadPool = new TestThreadPool("kerb realm tests"); + resourceWatcherService = new ResourceWatcherService(Settings.EMPTY, threadPool); + dir = createTempDir(); + globalSettings = Settings.builder().put("path.home", dir).build(); + settings = KerberosTestCase.buildKerberosRealmSettings(KerberosTestCase.writeKeyTab(dir.resolve("key.keytab"), "asa").toString(), + 100, "10m", true, randomBoolean()); + } + + @After + public void shutdown() throws InterruptedException { + resourceWatcherService.stop(); + terminate(threadPool); + } + + protected void mockKerberosTicketValidator(final byte[] decodedTicket, final Path keytabPath, final boolean krbDebug, + final Tuple value, final Exception e) { + assert value != null || e != null; + doAnswer((i) -> { + ActionListener> listener = (ActionListener>) i.getArguments()[3]; + if (e != null) { + listener.onFailure(e); + } else { + listener.onResponse(value); + } + return null; + }).when(mockKerberosTicketValidator).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), any(ActionListener.class)); + } + + protected void assertSuccessAuthenticationResult(final User expectedUser, final String outToken, final AuthenticationResult result) { + assertThat(result, is(notNullValue())); + assertThat(result.getStatus(), is(equalTo(AuthenticationResult.Status.SUCCESS))); + assertThat(result.getUser(), is(equalTo(expectedUser))); + final Map> responseHeaders = threadPool.getThreadContext().getResponseHeaders(); + assertThat(responseHeaders, is(notNullValue())); + assertThat(responseHeaders.get(KerberosAuthenticationToken.WWW_AUTHENTICATE).get(0), + is(equalTo(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + outToken))); + } + + protected KerberosRealm createKerberosRealm(final String... userForRoleMapping) { + config = new RealmConfig("test-kerb-realm", settings, globalSettings, TestEnvironment.newEnvironment(globalSettings), + new ThreadContext(globalSettings)); + mockNativeRoleMappingStore = roleMappingStore(Arrays.asList(userForRoleMapping)); + mockKerberosTicketValidator = mock(KerberosTicketValidator.class); + final KerberosRealm kerberosRealm = + new KerberosRealm(config, mockNativeRoleMappingStore, mockKerberosTicketValidator, threadPool, null); + return kerberosRealm; + } + + @SuppressWarnings("unchecked") + protected NativeRoleMappingStore roleMappingStore(final List userNames) { + final List expectedUserNames = userNames.stream().map(this::maybeRemoveRealmName).collect(Collectors.toList()); + final Client mockClient = mock(Client.class); + when(mockClient.threadPool()).thenReturn(threadPool); + when(mockClient.settings()).thenReturn(settings); + + final NativeRoleMappingStore store = new NativeRoleMappingStore(Settings.EMPTY, mockClient, mock(SecurityIndexManager.class)); + final NativeRoleMappingStore roleMapper = spy(store); + + doAnswer(invocation -> { + final UserRoleMapper.UserData userData = (UserRoleMapper.UserData) invocation.getArguments()[0]; + final ActionListener> listener = (ActionListener>) invocation.getArguments()[1]; + if (expectedUserNames.contains(userData.getUsername())) { + listener.onResponse(roles); + } else { + listener.onFailure( + Exceptions.authorizationError("Expected UPN '" + expectedUserNames + "' but was '" + userData.getUsername() + "'")); + } + return null; + }).when(roleMapper).resolveRoles(any(UserRoleMapper.UserData.class), any(ActionListener.class)); + + return roleMapper; + } + + protected String randomPrincipalName() { + final StringBuilder principalName = new StringBuilder(); + principalName.append(randomAlphaOfLength(5)); + final boolean withInstance = randomBoolean(); + if (withInstance) { + principalName.append("/").append(randomAlphaOfLength(5)); + } + principalName.append(randomAlphaOfLength(5).toUpperCase(Locale.ROOT)); + return principalName.toString(); + } + + /** + * Usually principal names are in the form 'user/instance@REALM'. This method + * removes '@REALM' part from the principal name if + * {@link KerberosRealmSettings#SETTING_REMOVE_REALM_NAME} is {@code true} else + * will return the input string. + * + * @param principalName user principal name + * @return username after removal of realm + */ + protected String maybeRemoveRealmName(final String principalName) { + if (KerberosRealmSettings.SETTING_REMOVE_REALM_NAME.get(settings)) { + int foundAtIndex = principalName.indexOf('@'); + if (foundAtIndex > 0) { + return principalName.substring(0, foundAtIndex); + } + } + return principalName; + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTests.java new file mode 100644 index 0000000000000..43536abaf29e1 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmTests.java @@ -0,0 +1,97 @@ +/* + * 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.kerberos; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.support.UserRoleMapper.UserData; +import org.ietf.jgss.GSSException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; + +import javax.security.auth.login.LoginException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +public class KerberosRealmTests extends KerberosRealmTestCase { + + public void testSupports() { + final KerberosRealm kerberosRealm = createKerberosRealm("test@REALM"); + + final KerberosAuthenticationToken kerberosAuthenticationToken = new KerberosAuthenticationToken(randomByteArrayOfLength(5)); + assertThat(kerberosRealm.supports(kerberosAuthenticationToken), is(true)); + final UsernamePasswordToken usernamePasswordToken = + new UsernamePasswordToken(randomAlphaOfLength(5), new SecureString(new char[] { 'a', 'b', 'c' })); + assertThat(kerberosRealm.supports(usernamePasswordToken), is(false)); + } + + public void testAuthenticateWithValidTicketSucessAuthnWithUserDetails() throws LoginException, GSSException { + final String username = randomPrincipalName(); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + final String expectedUsername = maybeRemoveRealmName(username); + final User expectedUser = new User(expectedUsername, roles.toArray(new String[roles.size()]), null, null, null, true); + final byte[] decodedTicket = "base64encodedticket".getBytes(StandardCharsets.UTF_8); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>(username, "out-token"), null); + final KerberosAuthenticationToken kerberosAuthenticationToken = new KerberosAuthenticationToken(decodedTicket); + + final PlainActionFuture future = new PlainActionFuture<>(); + kerberosRealm.authenticate(kerberosAuthenticationToken, future); + assertSuccessAuthenticationResult(expectedUser, "out-token", future.actionGet()); + + verify(mockKerberosTicketValidator, times(1)).validateTicket(aryEq(decodedTicket), eq(keytabPath), eq(krbDebug), + any(ActionListener.class)); + verify(mockNativeRoleMappingStore).refreshRealmOnChange(kerberosRealm); + verify(mockNativeRoleMappingStore).resolveRoles(any(UserData.class), any(ActionListener.class)); + verifyNoMoreInteractions(mockKerberosTicketValidator, mockNativeRoleMappingStore); + } + + public void testFailedAuthorization() throws LoginException, GSSException { + final String username = randomPrincipalName(); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + final byte[] decodedTicket = "base64encodedticket".getBytes(StandardCharsets.UTF_8); + final Path keytabPath = config.env().configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(config.settings())); + final boolean krbDebug = KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.get(config.settings()); + mockKerberosTicketValidator(decodedTicket, keytabPath, krbDebug, new Tuple<>("does-not-exist@REALM", "out-token"), null); + + final PlainActionFuture future = new PlainActionFuture<>(); + kerberosRealm.authenticate(new KerberosAuthenticationToken(decodedTicket), future); + + ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); + assertThat(e.status(), is(RestStatus.FORBIDDEN)); + assertThat(e.getMessage(), equalTo("Expected UPN '" + Arrays.asList(maybeRemoveRealmName(username)) + "' but was '" + + maybeRemoveRealmName("does-not-exist@REALM") + "'")); + } + + public void testLookupUser() { + final String username = randomPrincipalName(); + final KerberosRealm kerberosRealm = createKerberosRealm(username); + final PlainActionFuture future = new PlainActionFuture<>(); + kerberosRealm.lookupUser(username, future); + assertThat(future.actionGet(), is(nullValue())); + } + +} \ No newline at end of file diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTestCase.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTestCase.java new file mode 100644 index 0000000000000..891f400c7be60 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTestCase.java @@ -0,0 +1,223 @@ +/* + * 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.kerberos; + +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.Randomness; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import javax.security.auth.Subject; + +/** + * Base Test class for Kerberos. + *

+ * Takes care of starting {@link SimpleKdcLdapServer} as Kdc server backed by + * Ldap Server. + *

+ * Also assists in building principal names, creation of principals and realm + * settings. + */ +public abstract class KerberosTestCase extends ESTestCase { + + protected Settings globalSettings; + protected Settings settings; + protected List serviceUserNames; + protected List clientUserNames; + protected Path workDir = null; + + protected SimpleKdcLdapServer simpleKdcLdapServer; + + private static Locale restoreLocale; + private static Set unsupportedLocaleLanguages; + static { + unsupportedLocaleLanguages = new HashSet<>(); + /* + * arabic and other languages have problem due to handling of GeneralizedTime in + * SimpleKdcServer For more look at : + * org.apache.kerby.asn1.type.Asn1GeneralizedTime#toBytes() + */ + unsupportedLocaleLanguages.add("ar"); + unsupportedLocaleLanguages.add("ja"); + unsupportedLocaleLanguages.add("th"); + unsupportedLocaleLanguages.add("hi"); + unsupportedLocaleLanguages.add("uz"); + unsupportedLocaleLanguages.add("fa"); + unsupportedLocaleLanguages.add("ks"); + } + + @BeforeClass + public static void setupKerberos() throws Exception { + if (isLocaleUnsupported()) { + Logger logger = Loggers.getLogger(KerberosTestCase.class); + logger.warn("Attempting to run Kerberos test on {} locale, but that breaks SimpleKdcServer. Switching to English.", + Locale.getDefault()); + restoreLocale = Locale.getDefault(); + Locale.setDefault(Locale.ENGLISH); + } + } + + @AfterClass + public static void restoreLocale() throws Exception { + if (restoreLocale != null) { + Locale.setDefault(restoreLocale); + restoreLocale = null; + } + } + + private static boolean isLocaleUnsupported() { + return unsupportedLocaleLanguages.contains(Locale.getDefault().getLanguage()); + } + + @Before + public void startSimpleKdcLdapServer() throws Exception { + workDir = createTempDir(); + globalSettings = Settings.builder().put("path.home", workDir).build(); + + final Path kdcLdiff = getDataPath("/kdc.ldiff"); + simpleKdcLdapServer = new SimpleKdcLdapServer(workDir, "com", "example", kdcLdiff); + + // Create SPNs and UPNs + serviceUserNames = new ArrayList<>(); + Randomness.get().ints(randomIntBetween(1, 6)).forEach((i) -> { + serviceUserNames.add("HTTP/" + randomAlphaOfLength(8)); + }); + final Path ktabPathForService = createPrincipalKeyTab(workDir, serviceUserNames.toArray(new String[0])); + clientUserNames = new ArrayList<>(); + Randomness.get().ints(randomIntBetween(1, 6)).forEach((i) -> { + String clientUserName = "client-" + randomAlphaOfLength(8); + clientUserNames.add(clientUserName); + try { + createPrincipal(clientUserName, "pwd".toCharArray()); + } catch (Exception e) { + throw ExceptionsHelper.convertToRuntime(e); + } + }); + settings = buildKerberosRealmSettings(ktabPathForService.toString()); + } + + @After + public void tearDownMiniKdc() throws IOException, PrivilegedActionException { + simpleKdcLdapServer.stop(); + } + + /** + * Creates principals and exports them to the keytab created in the directory. + * + * @param dir Directory where the key tab would be created. + * @param princNames principal names to be created + * @return {@link Path} to key tab file. + * @throws Exception + */ + protected Path createPrincipalKeyTab(final Path dir, final String... princNames) throws Exception { + final Path path = dir.resolve(randomAlphaOfLength(10) + ".keytab"); + simpleKdcLdapServer.createPrincipal(path, princNames); + return path; + } + + /** + * Creates principal with given name and password. + * + * @param principalName Principal name + * @param password Password + * @throws Exception + */ + protected void createPrincipal(final String principalName, final char[] password) throws Exception { + simpleKdcLdapServer.createPrincipal(principalName, new String(password)); + } + + /** + * Appends realm name to user to form principal name + * + * @param user user name + * @return principal name in the form user@REALM + */ + protected String principalName(final String user) { + return user + "@" + simpleKdcLdapServer.getRealm(); + } + + /** + * Invokes Subject.doAs inside a doPrivileged block + * + * @param subject {@link Subject} + * @param action {@link PrivilegedExceptionAction} action for performing inside + * Subject.doAs + * @return Type of value as returned by PrivilegedAction + * @throws PrivilegedActionException + */ + static T doAsWrapper(final Subject subject, final PrivilegedExceptionAction action) throws PrivilegedActionException { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAs(subject, action)); + } + + /** + * Write content to provided keytab file. + * + * @param keytabPath {@link Path} to keytab file. + * @param content Content for keytab + * @return key tab path + * @throws IOException + */ + public static Path writeKeyTab(final Path keytabPath, final String content) throws IOException { + try (BufferedWriter bufferedWriter = Files.newBufferedWriter(keytabPath, StandardCharsets.US_ASCII)) { + bufferedWriter.write(Strings.isNullOrEmpty(content) ? "test-content" : content); + } + return keytabPath; + } + + /** + * Build kerberos realm settings with default config and given keytab + * + * @param keytabPath key tab file path + * @return {@link Settings} for kerberos realm + */ + public static Settings buildKerberosRealmSettings(final String keytabPath) { + return buildKerberosRealmSettings(keytabPath, 100, "10m", true, false); + } + + /** + * Build kerberos realm settings + * + * @param keytabPath key tab file path + * @param maxUsersInCache max users to be maintained in cache + * @param cacheTTL time to live for cached entries + * @param enableDebugging for krb5 logs + * @param removeRealmName {@code true} if we want to remove realm name from the username of form 'user@REALM' + * @return {@link Settings} for kerberos realm + */ + public static Settings buildKerberosRealmSettings(final String keytabPath, final int maxUsersInCache, final String cacheTTL, + final boolean enableDebugging, final boolean removeRealmName) { + final Settings.Builder builder = Settings.builder().put(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.getKey(), keytabPath) + .put(KerberosRealmSettings.CACHE_MAX_USERS_SETTING.getKey(), maxUsersInCache) + .put(KerberosRealmSettings.CACHE_TTL_SETTING.getKey(), cacheTTL) + .put(KerberosRealmSettings.SETTING_KRB_DEBUG_ENABLE.getKey(), enableDebugging) + .put(KerberosRealmSettings.SETTING_REMOVE_REALM_NAME.getKey(), removeRealmName); + return builder.build(); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidatorTests.java new file mode 100644 index 0000000000000..8f35e0bde4454 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosTicketValidatorTests.java @@ -0,0 +1,133 @@ +/* + * 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.kerberos; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.util.concurrent.UncategorizedExecutionException; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.ietf.jgss.GSSException; + +import java.io.IOException; +import java.nio.file.Path; +import java.security.PrivilegedActionException; +import java.util.Base64; +import java.util.concurrent.ExecutionException; + +import javax.security.auth.login.LoginException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class KerberosTicketValidatorTests extends KerberosTestCase { + + private KerberosTicketValidator kerberosTicketValidator = new KerberosTicketValidator(); + + public void testKerbTicketGeneratedForDifferentServerFailsValidation() throws Exception { + createPrincipalKeyTab(workDir, "differentServer"); + + // Client login and init token preparation + final String clientUserName = randomFrom(clientUserNames); + try (SpnegoClient spnegoClient = + new SpnegoClient(principalName(clientUserName), new SecureString("pwd".toCharArray()), principalName("differentServer"));) { + final String base64KerbToken = spnegoClient.getBase64EncodedTokenForSpnegoHeader(); + assertThat(base64KerbToken, is(notNullValue())); + + final Environment env = TestEnvironment.newEnvironment(globalSettings); + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings)); + final PlainActionFuture> future = new PlainActionFuture<>(); + kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true, future); + final GSSException gssException = expectThrows(GSSException.class, () -> unwrapExpectedExceptionFromFutureAndThrow(future)); + assertThat(gssException.getMajor(), equalTo(GSSException.FAILURE)); + } + } + + public void testInvalidKerbTicketFailsValidation() throws Exception { + final String base64KerbToken = Base64.getEncoder().encodeToString(randomByteArrayOfLength(5)); + + final Environment env = TestEnvironment.newEnvironment(globalSettings); + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings)); + kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true, + new ActionListener>() { + boolean exceptionHandled = false; + + @Override + public void onResponse(Tuple response) { + fail("expected exception to be thrown of type GSSException"); + } + + @Override + public void onFailure(Exception e) { + assertThat(exceptionHandled, is(false)); + assertThat(e, instanceOf(GSSException.class)); + assertThat(((GSSException) e).getMajor(), equalTo(GSSException.DEFECTIVE_TOKEN)); + exceptionHandled = true; + } + }); + } + + public void testWhenKeyTabWithInvalidContentFailsValidation() + throws LoginException, GSSException, IOException, PrivilegedActionException { + // Client login and init token preparation + final String clientUserName = randomFrom(clientUserNames); + try (SpnegoClient spnegoClient = new SpnegoClient(principalName(clientUserName), new SecureString("pwd".toCharArray()), + principalName(randomFrom(serviceUserNames)));) { + final String base64KerbToken = spnegoClient.getBase64EncodedTokenForSpnegoHeader(); + assertThat(base64KerbToken, is(notNullValue())); + + final Path ktabPath = writeKeyTab(workDir.resolve("invalid.keytab"), "not - a - valid - key - tab"); + settings = buildKerberosRealmSettings(ktabPath.toString()); + final Environment env = TestEnvironment.newEnvironment(globalSettings); + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings)); + final PlainActionFuture> future = new PlainActionFuture<>(); + kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true, future); + final GSSException gssException = expectThrows(GSSException.class, () -> unwrapExpectedExceptionFromFutureAndThrow(future)); + assertThat(gssException.getMajor(), equalTo(GSSException.FAILURE)); + } + } + + public void testValidKebrerosTicket() throws PrivilegedActionException, GSSException, LoginException { + // Client login and init token preparation + final String clientUserName = randomFrom(clientUserNames); + try (SpnegoClient spnegoClient = new SpnegoClient(principalName(clientUserName), new SecureString("pwd".toCharArray()), + principalName(randomFrom(serviceUserNames)));) { + final String base64KerbToken = spnegoClient.getBase64EncodedTokenForSpnegoHeader(); + assertThat(base64KerbToken, is(notNullValue())); + + final Environment env = TestEnvironment.newEnvironment(globalSettings); + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings)); + final PlainActionFuture> future = new PlainActionFuture<>(); + kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true, future); + assertThat(future.actionGet(), is(notNullValue())); + assertThat(future.actionGet().v1(), equalTo(principalName(clientUserName))); + assertThat(future.actionGet().v2(), is(notNullValue())); + + final String outToken = spnegoClient.handleResponse(future.actionGet().v2()); + assertThat(outToken, is(nullValue())); + assertThat(spnegoClient.isEstablished(), is(true)); + } + } + + private void unwrapExpectedExceptionFromFutureAndThrow(PlainActionFuture> future) throws Throwable { + try { + future.actionGet(); + } catch (Throwable t) { + Throwable throwThis = t; + while (throwThis instanceof UncategorizedExecutionException || throwThis instanceof ExecutionException) { + throwThis = throwThis.getCause(); + } + throw throwThis; + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServer.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServer.java new file mode 100644 index 0000000000000..426cacb1a034c --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServer.java @@ -0,0 +1,224 @@ +/* + * 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.kerberos; + +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; + +import org.apache.kerby.kerberos.kerb.KrbException; +import org.apache.kerby.kerberos.kerb.client.KrbConfig; +import org.apache.kerby.kerberos.kerb.server.KdcConfigKey; +import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer; +import org.apache.kerby.util.NetworkUtil; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Utility wrapper around Apache {@link SimpleKdcServer} backed by Unboundid + * {@link InMemoryDirectoryServer}.
+ * Starts in memory Ldap server and then uses it as backend for Kdc Server. + */ +public class SimpleKdcLdapServer { + private static final Logger logger = Loggers.getLogger(SimpleKdcLdapServer.class); + + private Path workDir = null; + private SimpleKdcServer simpleKdc; + private InMemoryDirectoryServer ldapServer; + + // KDC properties + private String transport = ESTestCase.randomFrom("TCP", "UDP"); + private int kdcPort = 0; + private String host; + private String realm; + private boolean krb5DebugBackupConfigValue; + + // LDAP properties + private String baseDn; + private Path ldiff; + private int ldapPort; + + /** + * Constructor for SimpleKdcLdapServer, creates instance of Kdc server and ldap + * backend server. Also initializes and starts them with provided configuration. + *

+ * To stop the KDC and ldap server use {@link #stop()} + * + * @param workDir Base directory for server, used to locate kdc.conf, + * backend.conf and kdc.ldiff + * @param orgName Org name for base dn + * @param domainName domain name for base dn + * @param ldiff for ldap directory. + * @throws Exception + */ + public SimpleKdcLdapServer(final Path workDir, final String orgName, final String domainName, final Path ldiff) throws Exception { + this.workDir = workDir; + this.realm = domainName.toUpperCase(Locale.ROOT) + "." + orgName.toUpperCase(Locale.ROOT); + this.baseDn = "dc=" + domainName + ",dc=" + orgName; + this.ldiff = ldiff; + this.krb5DebugBackupConfigValue = AccessController.doPrivileged(new PrivilegedExceptionAction() { + @Override + @SuppressForbidden(reason = "set or clear system property krb5 debug in kerberos tests") + public Boolean run() throws Exception { + boolean oldDebugSetting = Boolean.parseBoolean(System.getProperty("sun.security.krb5.debug")); + System.setProperty("sun.security.krb5.debug", Boolean.TRUE.toString()); + return oldDebugSetting; + } + }); + + AccessController.doPrivileged(new PrivilegedExceptionAction() { + @Override + public Void run() throws Exception { + init(); + return null; + } + }); + logger.info("SimpleKdcLdapServer started."); + } + + @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer") + private void init() throws Exception { + // start ldap server + createLdapServiceAndStart(); + // create ldap backend conf + createLdapBackendConf(); + // Kdc Server + simpleKdc = new SimpleKdcServer(this.workDir.toFile(), new KrbConfig()); + prepareKdcServerAndStart(); + } + + private void createLdapServiceAndStart() throws Exception { + InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(baseDn); + config.setSchema(null); + ldapServer = new InMemoryDirectoryServer(config); + ldapServer.importFromLDIF(true, this.ldiff.toString()); + ldapServer.startListening(); + ldapPort = ldapServer.getListenPort(); + } + + private void createLdapBackendConf() throws IOException { + String backendConf = KdcConfigKey.KDC_IDENTITY_BACKEND.getPropertyKey() + + " = org.apache.kerby.kerberos.kdc.identitybackend.LdapIdentityBackend\n" + "host=127.0.0.1\n" + "port=" + ldapPort + "\n" + + "admin_dn=uid=admin,ou=system," + baseDn + "\n" + "admin_pw=secret\n" + "base_dn=" + baseDn; + Files.write(this.workDir.resolve("backend.conf"), backendConf.getBytes(StandardCharsets.UTF_8)); + assert Files.exists(this.workDir.resolve("backend.conf")); + } + + @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer") + private void prepareKdcServerAndStart() throws Exception { + // transport + simpleKdc.setWorkDir(workDir.toFile()); + simpleKdc.setKdcHost(host); + simpleKdc.setKdcRealm(realm); + if (kdcPort == 0) { + kdcPort = NetworkUtil.getServerPort(); + } + if (transport != null) { + if (transport.trim().equals("TCP")) { + simpleKdc.setKdcTcpPort(kdcPort); + simpleKdc.setAllowUdp(false); + } else if (transport.trim().equals("UDP")) { + simpleKdc.setKdcUdpPort(kdcPort); + simpleKdc.setAllowTcp(false); + } else { + throw new IllegalArgumentException("Invalid transport: " + transport); + } + } else { + throw new IllegalArgumentException("Need to set transport!"); + } + final TimeValue minimumTicketLifeTime = new TimeValue(1, TimeUnit.DAYS); + final TimeValue maxRenewableLifeTime = new TimeValue(7, TimeUnit.DAYS); + simpleKdc.getKdcConfig().setLong(KdcConfigKey.MINIMUM_TICKET_LIFETIME, minimumTicketLifeTime.getMillis()); + simpleKdc.getKdcConfig().setLong(KdcConfigKey.MAXIMUM_RENEWABLE_LIFETIME, maxRenewableLifeTime.getMillis()); + simpleKdc.init(); + simpleKdc.start(); + } + + public String getRealm() { + return realm; + } + + public int getLdapListenPort() { + return ldapPort; + } + + public int getKdcPort() { + return kdcPort; + } + + /** + * Creates a principal in the KDC with the specified user and password. + * + * @param principal principal name, do not include the domain. + * @param password password. + * @throws Exception thrown if the principal could not be created. + */ + public synchronized void createPrincipal(final String principal, final String password) throws Exception { + simpleKdc.createPrincipal(principal, password); + } + + /** + * Creates multiple principals in the KDC and adds them to a keytab file. + * + * @param keytabFile keytab file to add the created principals. If keytab file + * exists and then always appends to it. + * @param principals principals to add to the KDC, do not include the domain. + * @throws Exception thrown if the principals or the keytab file could not be + * created. + */ + @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer") + public synchronized void createPrincipal(final Path keytabFile, final String... principals) throws Exception { + simpleKdc.createPrincipals(principals); + for (String principal : principals) { + simpleKdc.getKadmin().exportKeytab(keytabFile.toFile(), principal); + } + } + + /** + * Stop Simple Kdc Server + * + * @throws PrivilegedActionException + */ + public synchronized void stop() throws PrivilegedActionException { + AccessController.doPrivileged(new PrivilegedExceptionAction() { + + @Override + @SuppressForbidden(reason = "set or clear system property krb5 debug in kerberos tests") + public Void run() throws Exception { + if (simpleKdc != null) { + try { + simpleKdc.stop(); + } catch (KrbException e) { + throw ExceptionsHelper.convertToRuntime(e); + } finally { + System.setProperty("sun.security.krb5.debug", Boolean.toString(krb5DebugBackupConfigValue)); + } + } + + if (ldapServer != null) { + ldapServer.shutDown(true); + } + return null; + } + }); + logger.info("SimpleKdcServer stoppped."); + } + +} \ No newline at end of file diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServerTests.java new file mode 100644 index 0000000000000..b1c75d957a7c8 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SimpleKdcLdapServerTests.java @@ -0,0 +1,77 @@ +/* + * 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.kerberos; + +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchScope; + +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosTicketValidator; +import org.elasticsearch.xpack.security.authc.ldap.support.LdapUtils; +import org.ietf.jgss.GSSException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PrivilegedActionException; +import java.text.ParseException; +import java.util.Base64; + +import javax.security.auth.login.LoginException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.notNullValue; + +public class SimpleKdcLdapServerTests extends KerberosTestCase { + + public void testPrincipalCreationAndSearchOnLdap() throws Exception { + simpleKdcLdapServer.createPrincipal(workDir.resolve("p1p2.keytab"), "p1", "p2"); + assertTrue(Files.exists(workDir.resolve("p1p2.keytab"))); + try (LDAPConnection ldapConn = + LdapUtils.privilegedConnect(() -> new LDAPConnection("localhost", simpleKdcLdapServer.getLdapListenPort()));) { + assertThat(ldapConn.isConnected(), is(true)); + SearchResult sr = ldapConn.search("dc=example,dc=com", SearchScope.SUB, "(krb5PrincipalName=p1@EXAMPLE.COM)"); + assertThat(sr.getSearchEntries(), hasSize(1)); + assertThat(sr.getSearchEntries().get(0).getDN(), equalTo("uid=p1,dc=example,dc=com")); + } + } + + public void testClientServiceMutualAuthentication() throws PrivilegedActionException, GSSException, LoginException, ParseException { + final String serviceUserName = randomFrom(serviceUserNames); + // Client login and init token preparation + final String clientUserName = randomFrom(clientUserNames); + try (SpnegoClient spnegoClient = + new SpnegoClient(principalName(clientUserName), new SecureString("pwd".toCharArray()), principalName(serviceUserName));) { + final String base64KerbToken = spnegoClient.getBase64EncodedTokenForSpnegoHeader(); + assertThat(base64KerbToken, is(notNullValue())); + final KerberosAuthenticationToken kerbAuthnToken = new KerberosAuthenticationToken(Base64.getDecoder().decode(base64KerbToken)); + + // Service Login + final Environment env = TestEnvironment.newEnvironment(globalSettings); + final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(settings)); + // Handle Authz header which contains base64 token + final PlainActionFuture> future = new PlainActionFuture<>(); + new KerberosTicketValidator().validateTicket((byte[]) kerbAuthnToken.credentials(), keytabPath, true, future); + assertThat(future.actionGet(), is(notNullValue())); + assertThat(future.actionGet().v1(), equalTo(principalName(clientUserName))); + + // Authenticate service on client side. + final String outToken = spnegoClient.handleResponse(future.actionGet().v2()); + assertThat(outToken, is(nullValue())); + assertThat(spnegoClient.isEstablished(), is(true)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoClient.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoClient.java new file mode 100644 index 0000000000000..1f883b928bd97 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoClient.java @@ -0,0 +1,257 @@ +/* + * 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.kerberos; + +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.logging.ESLoggerFactory; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosTicketValidator; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; + +import java.io.IOException; +import java.security.AccessController; +import java.security.Principal; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +/** + * This class is used as a Spnego client during testing and handles SPNEGO + * interactions using GSS context negotiation.
+ * It is not advisable to share a SpnegoClient between threads as there is no + * synchronization in place, internally this depends on {@link GSSContext} for + * context negotiation which maintains sequencing for replay detections.
+ * Use {@link #close()} to release and dispose {@link LoginContext} and + * {@link GSSContext} after usage. + */ +class SpnegoClient implements AutoCloseable { + private static final Logger LOGGER = ESLoggerFactory.getLogger(SpnegoClient.class); + + public static final String CRED_CONF_NAME = "PasswordConf"; + private static final String SUN_KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule"; + private final GSSManager gssManager = GSSManager.getInstance(); + private final LoginContext loginContext; + private final GSSContext gssContext; + + /** + * Creates SpengoClient to interact with given service principal
+ * Use {@link #close()} to logout {@link LoginContext} and dispose + * {@link GSSContext} after usage. + * @param userPrincipalName User principal name for login as client + * @param password password for client + * @param servicePrincipalName Service principal name with whom this client + * interacts with. + * @throws PrivilegedActionException + * @throws GSSException + */ + SpnegoClient(final String userPrincipalName, final SecureString password, final String servicePrincipalName) + throws PrivilegedActionException, GSSException { + String oldUseSubjectCredsOnlyFlag = null; + try { + oldUseSubjectCredsOnlyFlag = getAndSetUseSubjectCredsOnlySystemProperty("true"); + LOGGER.info("SpnegoClient with userPrincipalName : {}", userPrincipalName); + final GSSName gssUserPrincipalName = gssManager.createName(userPrincipalName, GSSName.NT_USER_NAME); + final GSSName gssServicePrincipalName = gssManager.createName(servicePrincipalName, GSSName.NT_USER_NAME); + loginContext = AccessController + .doPrivileged((PrivilegedExceptionAction) () -> loginUsingPassword(userPrincipalName, password)); + final GSSCredential userCreds = KerberosTestCase.doAsWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssManager.createCredential(gssUserPrincipalName, + GSSCredential.DEFAULT_LIFETIME, KerberosTicketValidator.SPNEGO_OID, GSSCredential.INITIATE_ONLY)); + gssContext = gssManager.createContext(gssServicePrincipalName.canonicalize(KerberosTicketValidator.SPNEGO_OID), + KerberosTicketValidator.SPNEGO_OID, userCreds, GSSCredential.DEFAULT_LIFETIME); + gssContext.requestMutualAuth(true); + } catch (PrivilegedActionException pve) { + LOGGER.error("privileged action exception, with root cause", pve.getException()); + throw pve; + } finally { + getAndSetUseSubjectCredsOnlySystemProperty(oldUseSubjectCredsOnlyFlag); + } + } + + /** + * GSSContext initiator side handling, initiates context establishment and returns the + * base64 encoded token to be sent to server. + * + * @return Base64 encoded token + * @throws PrivilegedActionException + */ + String getBase64EncodedTokenForSpnegoHeader() throws PrivilegedActionException { + final byte[] outToken = KerberosTestCase.doAsWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssContext.initSecContext(new byte[0], 0, 0)); + return Base64.getEncoder().encodeToString(outToken); + } + + /** + * Handles server response and returns new token if any to be sent to server. + * + * @param base64Token inToken received from server passed to initSecContext for + * gss negotiation + * @return Base64 encoded token to be sent to server. May return {@code null} if + * nothing to be sent. + * @throws PrivilegedActionException + */ + String handleResponse(final String base64Token) throws PrivilegedActionException { + if (gssContext.isEstablished()) { + throw new IllegalStateException("GSS Context has already been established"); + } + final byte[] token = Base64.getDecoder().decode(base64Token); + final byte[] outToken = KerberosTestCase.doAsWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssContext.initSecContext(token, 0, token.length)); + if (outToken == null || outToken.length == 0) { + return null; + } + return Base64.getEncoder().encodeToString(outToken); + } + + /** + * Spnego Client after usage needs to be closed in order to logout from + * {@link LoginContext} and dispose {@link GSSContext} + */ + public void close() throws LoginException, GSSException, PrivilegedActionException { + if (loginContext != null) { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + loginContext.logout(); + return null; + }); + } + if (gssContext != null) { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + gssContext.dispose(); + return null; + }); + } + } + + /** + * @return {@code true} If the gss security context was established + */ + boolean isEstablished() { + return gssContext.isEstablished(); + } + + /** + * Performs authentication using provided principal name and password for client + * + * @param principal Principal name + * @param password {@link SecureString} + * @param settings {@link Settings} + * @return authenticated {@link LoginContext} instance. Note: This needs to be + * closed {@link LoginContext#logout()} after usage. + * @throws LoginException + */ + private static LoginContext loginUsingPassword(final String principal, final SecureString password) throws LoginException { + final Set principals = Collections.singleton(new KerberosPrincipal(principal)); + + final Subject subject = new Subject(false, principals, Collections.emptySet(), Collections.emptySet()); + + final Configuration conf = new PasswordJaasConf(principal); + final CallbackHandler callback = new KrbCallbackHandler(principal, password); + final LoginContext loginContext = new LoginContext(CRED_CONF_NAME, subject, callback, conf); + loginContext.login(); + return loginContext; + } + + /** + * Usually we would have a JAAS configuration file for login configuration. + * Instead of an additional file setting as we do not want the options to be + * customizable we are constructing it in memory. + *

+ * As we are uing this instead of jaas.conf, this requires refresh of + * {@link Configuration} and reqires appropriate security permissions to do so. + */ + static class PasswordJaasConf extends Configuration { + private final String principal; + + PasswordJaasConf(final String principal) { + this.principal = principal; + } + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { + final Map options = new HashMap<>(); + options.put("principal", principal); + options.put("storeKey", Boolean.TRUE.toString()); + options.put("isInitiator", Boolean.TRUE.toString()); + options.put("debug", Boolean.TRUE.toString()); + // Refresh Krb5 config during tests as the port keeps changing for kdc server + options.put("refreshKrb5Config", Boolean.TRUE.toString()); + + return new AppConfigurationEntry[] { new AppConfigurationEntry(SUN_KRB5_LOGIN_MODULE, + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, Collections.unmodifiableMap(options)) }; + } + } + + /** + * Jaas call back handler to provide credentials. + */ + static class KrbCallbackHandler implements CallbackHandler { + private final String principal; + private final SecureString password; + + KrbCallbackHandler(final String principal, final SecureString password) { + this.principal = principal; + this.password = password; + } + + public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof PasswordCallback) { + PasswordCallback pc = (PasswordCallback) callback; + if (pc.getPrompt().contains(principal)) { + pc.setPassword(password.getChars()); + break; + } + } + } + } + } + + private static String getAndSetUseSubjectCredsOnlySystemProperty(final String value) { + String retVal = null; + try { + retVal = AccessController.doPrivileged(new PrivilegedExceptionAction() { + + @Override + @SuppressForbidden( + reason = "For tests where we provide credentials, need to set and reset javax.security.auth.useSubjectCredsOnly") + public String run() throws Exception { + String oldValue = System.getProperty("javax.security.auth.useSubjectCredsOnly"); + if (value != null) { + System.setProperty("javax.security.auth.useSubjectCredsOnly", value); + } + return oldValue; + } + + }); + } catch (PrivilegedActionException e) { + throw ExceptionsHelper.convertToRuntime(e); + } + return retVal; + } +} diff --git a/x-pack/plugin/security/src/test/resources/kdc.ldiff b/x-pack/plugin/security/src/test/resources/kdc.ldiff new file mode 100644 index 0000000000000..e213048d6f578 --- /dev/null +++ b/x-pack/plugin/security/src/test/resources/kdc.ldiff @@ -0,0 +1,23 @@ +dn: dc=example,dc=com +objectClass: top +objectClass: domain +dc: example + +dn: ou=system,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: system + +dn: ou=users,dc=example,dc=com +objectClass: organizationalUnit +objectClass: top +ou: users + +dn: uid=admin,ou=system,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: inetOrgPerson +cn: Admin +sn: Admin +uid: admin +userPassword: secret \ No newline at end of file diff --git a/x-pack/qa/kerberos-tests/build.gradle b/x-pack/qa/kerberos-tests/build.gradle new file mode 100644 index 0000000000000..5caf5d6947e8c --- /dev/null +++ b/x-pack/qa/kerberos-tests/build.gradle @@ -0,0 +1,127 @@ +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.Files + +apply plugin: 'elasticsearch.vagrantsupport' +apply plugin: 'elasticsearch.standalone-rest-test' +apply plugin: 'elasticsearch.rest-test' + +dependencies { + testCompile project(path: xpackModule('core'), configuration: 'runtime') + testCompile project(path: xpackModule('core'), configuration: 'testArtifacts') + testCompile project(path: xpackModule('security'), configuration: 'testArtifacts') +} + +// MIT Kerberos Vagrant Testing Fixture +String box = "krb5kdc" +Map vagrantEnvVars = [ + 'VAGRANT_CWD' : "${project(':test:fixtures:krb5kdc-fixture').projectDir}", + 'VAGRANT_VAGRANTFILE' : 'Vagrantfile', + 'VAGRANT_PROJECT_DIR' : "${project(':test:fixtures:krb5kdc-fixture').projectDir}" +] + +task krb5kdcUpdate(type: org.elasticsearch.gradle.vagrant.VagrantCommandTask) { + command 'box' + subcommand 'update' + boxName box + environmentVars vagrantEnvVars + dependsOn "vagrantCheckVersion", "virtualboxCheckVersion" +} + +task krb5kdcFixture(type: org.elasticsearch.gradle.test.VagrantFixture) { + command 'up' + args '--provision', '--provider', 'virtualbox' + boxName box + environmentVars vagrantEnvVars + dependsOn krb5kdcUpdate +} + +task krb5AddPrincipals { dependsOn krb5kdcFixture } + +List principals = [ + "HTTP/localhost", + "peppa", + "george~dino" +] +String realm = "BUILD.ELASTIC.CO" + +for (String principal : principals) { + String[] princPwdPair = principal.split('~'); + String princName = princPwdPair[0]; + String password = ""; + if (princPwdPair.length > 1) { + password = princPwdPair[1]; + } + Task create = project.tasks.create("addPrincipal#${principal}".replace('/', '_'), org.elasticsearch.gradle.vagrant.VagrantCommandTask) { + command 'ssh' + args '--command', "sudo bash /vagrant/src/main/resources/provision/addprinc.sh $princName $password" + boxName box + environmentVars vagrantEnvVars + dependsOn krb5kdcFixture + } + krb5AddPrincipals.dependsOn(create) +} + +def generatedResources = "$buildDir/generated-resources/keytabs" +task copyKeytabToGeneratedResources(type: Copy) { + Path peppaKeytab = project(':test:fixtures:krb5kdc-fixture').buildDir.toPath().resolve("keytabs").resolve("peppa.keytab").toAbsolutePath() + from peppaKeytab; + into generatedResources + dependsOn krb5AddPrincipals +} + +integTestCluster { + setting 'xpack.license.self_generated.type', 'trial' + setting 'xpack.security.enabled', 'true' + setting 'xpack.security.authc.realms.file.type', 'file' + setting 'xpack.security.authc.realms.file.order', '0' + setting 'xpack.ml.enabled', 'false' + setting 'xpack.security.audit.enabled', 'true' + // Kerberos realm + setting 'xpack.security.authc.realms.kerberos.type', 'kerberos' + setting 'xpack.security.authc.realms.kerberos.order', '1' + setting 'xpack.security.authc.realms.kerberos.keytab.path', 'es.keytab' + setting 'xpack.security.authc.realms.kerberos.krb.debug', 'true' + setting 'xpack.security.authc.realms.kerberos.remove_realm_name', 'false' + + Path krb5conf = project(':test:fixtures:krb5kdc-fixture').buildDir.toPath().resolve("conf").resolve("krb5.conf").toAbsolutePath() + String jvmArgsStr = " -Djava.security.krb5.conf=${krb5conf}" + " -Dsun.security.krb5.debug=true" + jvmArgs jvmArgsStr + Path esKeytab = project(':test:fixtures:krb5kdc-fixture').buildDir.toPath().resolve("keytabs").resolve("HTTP_localhost.keytab").toAbsolutePath() + extraConfigFile("es.keytab", "${esKeytab}") + + setupCommand 'setupTestAdmin', + 'bin/elasticsearch-users', 'useradd', "test_admin", '-p', 'x-pack-test-password', '-r', "superuser" + + waitCondition = { node, ant -> + File tmpFile = new File(node.cwd, 'wait.success') + ant.get(src: "http://${node.httpUri()}/_cluster/health?wait_for_nodes=>=${numNodes}&wait_for_status=yellow", + dest: tmpFile.toString(), + username: 'test_admin', + password: 'x-pack-test-password', + ignoreerrors: true, + retries: 10) + return tmpFile.exists() + } + +} + +integTestRunner { + Path peppaKeytab = Paths.get("${project.buildDir}", "generated-resources", "keytabs", "peppa.keytab") + systemProperty 'test.userkt', "peppa@${realm}" + systemProperty 'test.userkt.keytab', "${peppaKeytab}" + systemProperty 'test.userpwd', "george@${realm}" + systemProperty 'test.userpwd.password', "dino" + systemProperty 'tests.security.manager', 'true' + Path krb5conf = project(':test:fixtures:krb5kdc-fixture').buildDir.toPath().resolve("conf").resolve("krb5.conf").toAbsolutePath() + List jvmargs = ["-Djava.security.krb5.conf=${krb5conf}","-Dsun.security.krb5.debug=true"] + jvmArgs jvmargs +} + +if (project.rootProject.vagrantSupported == false) { + integTest.enabled = false +} else { + project.sourceSets.test.output.dir(generatedResources, builtBy: copyKeytabToGeneratedResources) + integTestCluster.dependsOn krb5AddPrincipals, krb5kdcFixture, copyKeytabToGeneratedResources + integTest.finalizedBy project(':test:fixtures:krb5kdc-fixture').halt +} diff --git a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java new file mode 100644 index 0000000000000..d5928cb58f687 --- /dev/null +++ b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java @@ -0,0 +1,152 @@ +/* + * 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.kerberos; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpHost; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.client.RestClientBuilder; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.junit.Before; + +import java.io.IOException; +import java.security.AccessControlContext; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.List; +import java.util.Map; + +import javax.security.auth.login.LoginContext; + +import static org.elasticsearch.common.xcontent.XContentHelper.convertToMap; +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; + +/** + * Integration test to demonstrate authentication against a real MIT Kerberos + * instance. + *

+ * Demonstrates login by keytab and login by password for given user principal + * name using rest client. + */ +public class KerberosAuthenticationIT extends ESRestTestCase { + private static final String ENABLE_KERBEROS_DEBUG_LOGS_KEY = "test.krb.debug"; + private static final String TEST_USER_WITH_KEYTAB_KEY = "test.userkt"; + private static final String TEST_USER_WITH_KEYTAB_PATH_KEY = "test.userkt.keytab"; + private static final String TEST_USER_WITH_PWD_KEY = "test.userpwd"; + private static final String TEST_USER_WITH_PWD_PASSWD_KEY = "test.userpwd.password"; + private static final String TEST_KERBEROS_REALM_NAME = "kerberos"; + + @Override + protected Settings restAdminSettings() { + final String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); + return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build(); + } + + /** + * Creates simple mapping that maps the users from 'kerberos' realm to + * the 'kerb_test' role. + */ + @Before + public void setupRoleMapping() throws IOException { + final String json = Strings // top-level + .toString(XContentBuilder.builder(XContentType.JSON.xContent()).startObject() + .array("roles", new String[] { "kerb_test" }) + .field("enabled", true) + .startObject("rules") + .startArray("all") + .startObject().startObject("field").field("realm.name", TEST_KERBEROS_REALM_NAME).endObject().endObject() + .endArray() // "all" + .endObject() // "rules" + .endObject()); + + final Request request = new Request("POST", "/_xpack/security/role_mapping/kerberosrolemapping"); + request.setJsonEntity(json); + final Response response = adminClient().performRequest(request); + assertOK(response); + } + + public void testLoginByKeytab() throws IOException, PrivilegedActionException { + final String userPrincipalName = System.getProperty(TEST_USER_WITH_KEYTAB_KEY); + final String keytabPath = System.getProperty(TEST_USER_WITH_KEYTAB_PATH_KEY); + final boolean enabledDebugLogs = Boolean.parseBoolean(System.getProperty(ENABLE_KERBEROS_DEBUG_LOGS_KEY)); + final SpnegoHttpClientConfigCallbackHandler callbackHandler = new SpnegoHttpClientConfigCallbackHandler(userPrincipalName, + keytabPath, enabledDebugLogs); + executeRequestAndVerifyResponse(userPrincipalName, callbackHandler); + } + + public void testLoginByUsernamePassword() throws IOException, PrivilegedActionException { + final String userPrincipalName = System.getProperty(TEST_USER_WITH_PWD_KEY); + final String password = System.getProperty(TEST_USER_WITH_PWD_PASSWD_KEY); + final boolean enabledDebugLogs = Boolean.parseBoolean(System.getProperty(ENABLE_KERBEROS_DEBUG_LOGS_KEY)); + final SpnegoHttpClientConfigCallbackHandler callbackHandler = new SpnegoHttpClientConfigCallbackHandler(userPrincipalName, + new SecureString(password.toCharArray()), enabledDebugLogs); + executeRequestAndVerifyResponse(userPrincipalName, callbackHandler); + } + + private void executeRequestAndVerifyResponse(final String userPrincipalName, + final SpnegoHttpClientConfigCallbackHandler callbackHandler) throws PrivilegedActionException, IOException { + final Request request = new Request("GET", "/_xpack/security/_authenticate"); + try (RestClient restClient = buildRestClientForKerberos(callbackHandler)) { + final AccessControlContext accessControlContext = AccessController.getContext(); + final LoginContext lc = callbackHandler.login(); + Response response = SpnegoHttpClientConfigCallbackHandler.doAsPrivilegedWrapper(lc.getSubject(), + (PrivilegedExceptionAction) () -> { + return restClient.performRequest(request); + }, accessControlContext); + + assertOK(response); + final Map map = parseResponseAsMap(response.getEntity()); + assertThat(map.get("username"), equalTo(userPrincipalName)); + assertThat(map.get("roles"), instanceOf(List.class)); + assertThat(((List) map.get("roles")), contains("kerb_test")); + } + } + + private Map parseResponseAsMap(final HttpEntity entity) throws IOException { + return convertToMap(XContentType.JSON.xContent(), entity.getContent(), false); + } + + private RestClient buildRestClientForKerberos(final SpnegoHttpClientConfigCallbackHandler callbackHandler) throws IOException { + final Settings settings = restAdminSettings(); + final HttpHost[] hosts = getClusterHosts().toArray(new HttpHost[getClusterHosts().size()]); + + final RestClientBuilder restClientBuilder = RestClient.builder(hosts); + configureRestClientBuilder(restClientBuilder, settings); + restClientBuilder.setHttpClientConfigCallback(callbackHandler); + return restClientBuilder.build(); + } + + private static void configureRestClientBuilder(final RestClientBuilder restClientBuilder, final Settings settings) + throws IOException { + final String requestTimeoutString = settings.get(CLIENT_RETRY_TIMEOUT); + if (requestTimeoutString != null) { + final TimeValue maxRetryTimeout = TimeValue.parseTimeValue(requestTimeoutString, CLIENT_RETRY_TIMEOUT); + restClientBuilder.setMaxRetryTimeoutMillis(Math.toIntExact(maxRetryTimeout.getMillis())); + } + final String socketTimeoutString = settings.get(CLIENT_SOCKET_TIMEOUT); + if (socketTimeoutString != null) { + final TimeValue socketTimeout = TimeValue.parseTimeValue(socketTimeoutString, CLIENT_SOCKET_TIMEOUT); + restClientBuilder.setRequestConfigCallback(conf -> conf.setSocketTimeout(Math.toIntExact(socketTimeout.getMillis()))); + } + if (settings.hasValue(CLIENT_PATH_PREFIX)) { + restClientBuilder.setPathPrefix(settings.get(CLIENT_PATH_PREFIX)); + } + } +} diff --git a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java new file mode 100644 index 0000000000000..e5768d8f2e944 --- /dev/null +++ b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java @@ -0,0 +1,317 @@ +/* + * 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.kerberos; + +import org.apache.http.auth.AuthSchemeProvider; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.KerberosCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.config.Lookup; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.impl.auth.SPNegoSchemeFactory; +import org.apache.http.impl.nio.client.HttpAsyncClientBuilder; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.client.RestClientBuilder.HttpClientConfigCallback; +import org.elasticsearch.common.settings.SecureString; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; + +import java.io.IOException; +import java.security.AccessControlContext; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.kerberos.KerberosPrincipal; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; + +/** + * This class implements {@link HttpClientConfigCallback} which allows for + * customization of {@link HttpAsyncClientBuilder}. + *

+ * Based on the configuration, configures {@link HttpAsyncClientBuilder} to + * support spengo auth scheme.
+ * It uses configured credentials either password or keytab for authentication. + */ +public class SpnegoHttpClientConfigCallbackHandler implements HttpClientConfigCallback { + private static final String SUN_KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule"; + private static final String CRED_CONF_NAME = "ESClientLoginConf"; + private static final Oid SPNEGO_OID = getSpnegoOid(); + + private static Oid getSpnegoOid() { + Oid oid = null; + try { + oid = new Oid("1.3.6.1.5.5.2"); + } catch (GSSException gsse) { + throw ExceptionsHelper.convertToRuntime(gsse); + } + return oid; + } + + private final String userPrincipalName; + private final SecureString password; + private final String keytabPath; + private final boolean enableDebugLogs; + private LoginContext loginContext; + + /** + * Constructs {@link SpnegoHttpClientConfigCallbackHandler} with given + * principalName and password. + * + * @param userPrincipalName user principal name + * @param password password for user + * @param enableDebugLogs if {@code true} enables kerberos debug logs + */ + public SpnegoHttpClientConfigCallbackHandler(final String userPrincipalName, final SecureString password, + final boolean enableDebugLogs) { + this.userPrincipalName = userPrincipalName; + this.password = password; + this.keytabPath = null; + this.enableDebugLogs = enableDebugLogs; + } + + /** + * Constructs {@link SpnegoHttpClientConfigCallbackHandler} with given + * principalName and keytab. + * + * @param userPrincipalName User principal name + * @param keytabPath path to keytab file for user + * @param enableDebugLogs if {@code true} enables kerberos debug logs + */ + public SpnegoHttpClientConfigCallbackHandler(final String userPrincipalName, final String keytabPath, final boolean enableDebugLogs) { + this.userPrincipalName = userPrincipalName; + this.keytabPath = keytabPath; + this.password = null; + this.enableDebugLogs = enableDebugLogs; + } + + @Override + public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) { + setupSpnegoAuthSchemeSupport(httpClientBuilder); + return httpClientBuilder; + } + + private void setupSpnegoAuthSchemeSupport(HttpAsyncClientBuilder httpClientBuilder) { + final Lookup authSchemeRegistry = RegistryBuilder.create() + .register(AuthSchemes.SPNEGO, new SPNegoSchemeFactory()).build(); + + final GSSManager gssManager = GSSManager.getInstance(); + try { + final GSSName gssUserPrincipalName = gssManager.createName(userPrincipalName, GSSName.NT_USER_NAME); + login(); + final AccessControlContext acc = AccessController.getContext(); + final GSSCredential credential = doAsPrivilegedWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssManager.createCredential(gssUserPrincipalName, + GSSCredential.DEFAULT_LIFETIME, SPNEGO_OID, GSSCredential.INITIATE_ONLY), + acc); + + final KerberosCredentialsProvider credentialsProvider = new KerberosCredentialsProvider(); + credentialsProvider.setCredentials( + new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, AuthScope.ANY_REALM, AuthSchemes.SPNEGO), + new KerberosCredentials(credential)); + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } catch (GSSException e) { + throw new RuntimeException(e); + } catch (PrivilegedActionException e) { + throw new RuntimeException(e.getCause()); + } + httpClientBuilder.setDefaultAuthSchemeRegistry(authSchemeRegistry); + } + + /** + * If logged in {@link LoginContext} is not available, it attempts login and + * returns {@link LoginContext} + * + * @return {@link LoginContext} + * @throws PrivilegedActionException + */ + public synchronized LoginContext login() throws PrivilegedActionException { + if (this.loginContext == null) { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + final Subject subject = new Subject(false, Collections.singleton(new KerberosPrincipal(userPrincipalName)), + Collections.emptySet(), Collections.emptySet()); + Configuration conf = null; + final CallbackHandler callback; + if (password != null) { + conf = new PasswordJaasConf(userPrincipalName, enableDebugLogs); + callback = new KrbCallbackHandler(userPrincipalName, password); + } else { + conf = new KeytabJaasConf(userPrincipalName, keytabPath, enableDebugLogs); + callback = null; + } + loginContext = new LoginContext(CRED_CONF_NAME, subject, callback, conf); + loginContext.login(); + return null; + }); + } + return loginContext; + } + + /** + * Privileged Wrapper that invokes action with Subject.doAs to perform work as + * given subject. + * + * @param subject {@link Subject} to be used for this work + * @param action {@link PrivilegedExceptionAction} action for performing inside + * Subject.doAs + * @param acc the {@link AccessControlContext} to be tied to the specified + * subject and action see + * {@link Subject#doAsPrivileged(Subject, PrivilegedExceptionAction, AccessControlContext) + * @return the value returned by the PrivilegedExceptionAction's run method + * @throws PrivilegedActionException + */ + static T doAsPrivilegedWrapper(final Subject subject, final PrivilegedExceptionAction action, final AccessControlContext acc) + throws PrivilegedActionException { + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAsPrivileged(subject, action, acc)); + } catch (PrivilegedActionException pae) { + if (pae.getCause() instanceof PrivilegedActionException) { + throw (PrivilegedActionException) pae.getCause(); + } + throw pae; + } + } + + /** + * This class matches {@link AuthScope} and based on that returns + * {@link Credentials}. Only supports {@link AuthSchemes#SPNEGO} in + * {@link AuthScope#getScheme()} + */ + private static class KerberosCredentialsProvider implements CredentialsProvider { + private AuthScope authScope; + private Credentials credentials; + + @Override + public void setCredentials(AuthScope authscope, Credentials credentials) { + if (authscope.getScheme().regionMatches(true, 0, AuthSchemes.SPNEGO, 0, AuthSchemes.SPNEGO.length()) == false) { + throw new IllegalArgumentException("Only " + AuthSchemes.SPNEGO + " auth scheme is supported in AuthScope"); + } + this.authScope = authscope; + this.credentials = credentials; + } + + @Override + public Credentials getCredentials(AuthScope authscope) { + assert this.authScope != null && authscope != null; + return authscope.match(this.authScope) > -1 ? this.credentials : null; + } + + @Override + public void clear() { + this.authScope = null; + this.credentials = null; + } + } + + /** + * Jaas call back handler to provide credentials. + */ + private static class KrbCallbackHandler implements CallbackHandler { + private final String principal; + private final SecureString password; + + KrbCallbackHandler(final String principal, final SecureString password) { + this.principal = principal; + this.password = password; + } + + public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof PasswordCallback) { + PasswordCallback pc = (PasswordCallback) callback; + if (pc.getPrompt().contains(principal)) { + pc.setPassword(password.getChars()); + break; + } + } + } + } + } + + /** + * Usually we would have a JAAS configuration file for login configuration. + * Instead of an additional file setting as we do not want the options to be + * customizable we are constructing it in memory. + *

+ * As we are using this instead of jaas.conf, this requires refresh of + * {@link Configuration} and reqires appropriate security permissions to do so. + */ + private static class PasswordJaasConf extends AbstractJaasConf { + + PasswordJaasConf(final String userPrincipalName, final boolean enableDebugLogs) { + super(userPrincipalName, enableDebugLogs); + } + + public void addOptions(final Map options) { + options.put("useTicketCache", Boolean.FALSE.toString()); + options.put("useKeyTab", Boolean.FALSE.toString()); + } + } + + /** + * Usually we would have a JAAS configuration file for login configuration. As + * we have static configuration except debug flag, we are constructing in + * memory. This avoids additional configuration required from the user. + *

+ * As we are using this instead of jaas.conf, this requires refresh of + * {@link Configuration} and requires appropriate security permissions to do so. + */ + private static class KeytabJaasConf extends AbstractJaasConf { + private final String keytabFilePath; + + KeytabJaasConf(final String userPrincipalName, final String keytabFilePath, final boolean enableDebugLogs) { + super(userPrincipalName, enableDebugLogs); + this.keytabFilePath = keytabFilePath; + } + + public void addOptions(final Map options) { + options.put("useKeyTab", Boolean.TRUE.toString()); + options.put("keyTab", keytabFilePath); + options.put("doNotPrompt", Boolean.TRUE.toString()); + } + + } + + private abstract static class AbstractJaasConf extends Configuration { + private final String userPrincipalName; + private final boolean enableDebugLogs; + + AbstractJaasConf(final String userPrincipalName, final boolean enableDebugLogs) { + this.userPrincipalName = userPrincipalName; + this.enableDebugLogs = enableDebugLogs; + } + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { + final Map options = new HashMap<>(); + options.put("principal", userPrincipalName); + options.put("isInitiator", Boolean.TRUE.toString()); + options.put("storeKey", Boolean.TRUE.toString()); + options.put("debug", Boolean.toString(enableDebugLogs)); + addOptions(options); + return new AppConfigurationEntry[] { new AppConfigurationEntry(SUN_KRB5_LOGIN_MODULE, + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, Collections.unmodifiableMap(options)) }; + } + + abstract void addOptions(Map options); + } +} diff --git a/x-pack/qa/kerberos-tests/src/test/resources/plugin-security.policy b/x-pack/qa/kerberos-tests/src/test/resources/plugin-security.policy new file mode 100644 index 0000000000000..fb7936bf62093 --- /dev/null +++ b/x-pack/qa/kerberos-tests/src/test/resources/plugin-security.policy @@ -0,0 +1,4 @@ +grant { + permission javax.security.auth.AuthPermission "doAsPrivileged"; + permission javax.security.auth.kerberos.DelegationPermission "\"HTTP/localhost@BUILD.ELASTIC.CO\" \"krbtgt/BUILD.ELASTIC.CO@BUILD.ELASTIC.CO\""; +}; \ No newline at end of file