[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
This commit is contained in:
Yogesh Gaikwad 2018-07-25 00:44:26 +10:00 committed by Jay Modi
parent 99426eb4f8
commit a525c36c60
34 changed files with 3522 additions and 61 deletions

View File

@ -20,11 +20,14 @@
set -e set -e
if [[ $# -lt 1 ]]; then if [[ $# -lt 1 ]]; then
echo 'Usage: addprinc.sh <principalNameNoRealm>' 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 exit 1
fi fi
PRINC="$1" PRINC="$1"
PASSWD="$2"
USER=$(echo $PRINC | tr "/" "_") USER=$(echo $PRINC | tr "/" "_")
VDIR=/vagrant VDIR=/vagrant
@ -47,12 +50,17 @@ ADMIN_KTAB=$LOCALSTATEDIR/admin.keytab
USER_PRIN=$PRINC@$REALM USER_PRIN=$PRINC@$REALM
USER_KTAB=$LOCALSTATEDIR/$USER.keytab 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..." echo "Principal '${PRINC}@${REALM}' already exists. Re-copying keytab..."
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
else else
if [ -z "$PASSWD" ]; then
echo "Provisioning '${PRINC}@${REALM}' principal and keytab..." 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 "addprinc -randkey $USER_PRIN"
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $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 fi
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab

View File

@ -10,60 +10,132 @@ import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.transport.TransportMessage; 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; 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 * The default implementation of a {@link AuthenticationFailureHandler}. This
* RestStatus of 401 and the WWW-Authenticate header with a Basic challenge. * handler will return an exception with a RestStatus of 401 and default failure
* response headers like 'WWW-Authenticate'
*/ */
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler { public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final Map<String, List<String>> 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<String, List<String>> 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 @Override
public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, ThreadContext context) {
ThreadContext context) { return createAuthenticationError("unable to authenticate user [{}] for REST request [{}]", null, token.principal(), request.uri());
return authenticationError("unable to authenticate user [{}] for REST request [{}]", token.principal(), request.uri());
} }
@Override @Override
public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action, public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action,
ThreadContext context) { ThreadContext context) {
return authenticationError("unable to authenticate user [{}] for action [{}]", token.principal(), action); return createAuthenticationError("unable to authenticate user [{}] for action [{}]", null, token.principal(), action);
} }
@Override @Override
public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) { public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) {
if (e instanceof ElasticsearchSecurityException) { return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
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);
} }
@Override @Override
public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e, public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e,
ThreadContext context) { ThreadContext context) {
if (e instanceof ElasticsearchSecurityException) { return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
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);
} }
@Override @Override
public ElasticsearchSecurityException missingToken(RestRequest request, ThreadContext context) { 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 @Override
public ElasticsearchSecurityException missingToken(TransportMessage message, String action, ThreadContext context) { 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 @Override
public ElasticsearchSecurityException authenticationRequired(String action, ThreadContext context) { 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.
* <p>
* Also adds default failure response headers as configured for this
* {@link DefaultAuthenticationFailureHandler}
* <p>
* 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;
} }
} }

View File

@ -8,9 +8,12 @@ package org.elasticsearch.xpack.core.security.authc;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.xpack.core.XPackField;
import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.core.security.user.User;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -56,6 +59,18 @@ public abstract class Realm implements Comparable<Realm> {
return config.order; return config.order;
} }
/**
* Each realm can define response headers to be sent on failure.
* <p>
* By default it adds 'WWW-Authenticate' header with auth scheme 'Basic'.
*
* @return Map of authentication failure response headers.
*/
public Map<String, List<String>> getAuthenticationFailureHeaders() {
return Collections.singletonMap("WWW-Authenticate",
Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""));
}
@Override @Override
public int compareTo(Realm other) { public int compareTo(Realm other) {
int result = Integer.compare(config.order, other.config.order); int result = Integer.compare(config.order, other.config.order);

View File

@ -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<br>
* Uses single key tab for multiple service accounts.
*/
public static final Setting<String> HTTP_SERVICE_KEYTAB_PATH =
Setting.simpleString("keytab.path", Property.NodeScope);
public static final Setting<Boolean> SETTING_KRB_DEBUG_ENABLE =
Setting.boolSetting("krb.debug", Boolean.FALSE, Property.NodeScope);
public static final Setting<Boolean> 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<TimeValue> CACHE_TTL_SETTING = Setting.timeSetting("cache.ttl", DEFAULT_TTL, Setting.Property.NodeScope);
public static final Setting<Integer> 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<Setting<?>> getSettings() {
return Sets.newHashSet(HTTP_SERVICE_KEYTAB_PATH, CACHE_TTL_SETTING, CACHE_MAX_USERS_SETTING, SETTING_KRB_DEBUG_ENABLE,
SETTING_REMOVE_REALM_NAME);
}
}

View File

@ -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<String, List<String>> 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<String, List<String>> 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));
}
}

View File

@ -58,6 +58,67 @@ dependencies {
testCompile "org.elasticsearch:mocksocket:${versions.mocksocket}" testCompile "org.elasticsearch:mocksocket:${versions.mocksocket}"
//testCompile "org.yaml:snakeyaml:${versions.snakeyaml}" //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" compileJava.options.compilerArgs << "-Xlint:-deprecation,-rawtypes,-serial,-try,-unchecked"

View File

@ -77,6 +77,7 @@ import org.elasticsearch.transport.TransportInterceptor;
import org.elasticsearch.transport.TransportRequest; import org.elasticsearch.transport.TransportRequest;
import org.elasticsearch.transport.TransportRequestHandler; import org.elasticsearch.transport.TransportRequestHandler;
import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.watcher.ResourceWatcherService;
import org.elasticsearch.xpack.core.XPackField;
import org.elasticsearch.xpack.core.XPackPlugin; import org.elasticsearch.xpack.core.XPackPlugin;
import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.SecurityContext;
@ -168,6 +169,7 @@ import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.TokenService; import org.elasticsearch.xpack.security.authc.TokenService;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; 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.authc.support.mapper.NativeRoleMappingStore;
import org.elasticsearch.xpack.security.authz.AuthorizationService; import org.elasticsearch.xpack.security.authz.AuthorizationService;
import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener; import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener;
@ -291,7 +293,8 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
new TokenSSLBootstrapCheck(), new TokenSSLBootstrapCheck(),
new PkiRealmBootstrapCheck(getSslService()), new PkiRealmBootstrapCheck(getSslService()),
new TLSLicenseBootstrapCheck(), new TLSLicenseBootstrapCheck(),
new PasswordHashingAlgorithmBootstrapCheck())); new PasswordHashingAlgorithmBootstrapCheck(),
new KerberosRealmBootstrapCheck(env)));
checks.addAll(InternalRealms.getBootstrapChecks(settings, env)); checks.addAll(InternalRealms.getBootstrapChecks(settings, env));
this.bootstrapChecks = Collections.unmodifiableList(checks); this.bootstrapChecks = Collections.unmodifiableList(checks);
} else { } else {
@ -432,23 +435,7 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange); securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);
AuthenticationFailureHandler failureHandler = null; final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms);
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 + "]");
}
authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool, authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool,
anonymousUser, tokenService)); anonymousUser, tokenService));
@ -498,6 +485,45 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
return components; return components;
} }
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<String, List<String>> defaultFailureResponseHeaders = new HashMap<>();
realms.asList().stream().forEach((realm) -> {
Map<String, List<String>> 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 @Override
public Settings additionalSettings() { public Settings additionalSettings() {
return additionalSettings(settings, enabled, transportClientMode); return additionalSettings(settings, enabled, transportClientMode);

View File

@ -271,7 +271,9 @@ public class AuthenticationService extends AbstractComponent {
if (result.getStatus() == AuthenticationResult.Status.TERMINATE) { if (result.getStatus() == AuthenticationResult.Status.TERMINATE) {
logger.info("Authentication of [{}] was terminated by realm [{}] - {}", logger.info("Authentication of [{}] was terminated by realm [{}] - {}",
authenticationToken.principal(), realm.name(), result.getMessage()); 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 { } else {
if (result.getMessage() != null) { if (result.getMessage() != null) {
messages.put(realm, new Tuple<>(result.getMessage(), result.getException())); messages.put(realm, new Tuple<>(result.getMessage(), result.getException()));

View File

@ -16,6 +16,7 @@ import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.RealmSettings; import org.elasticsearch.xpack.core.security.authc.RealmSettings;
import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; 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.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.ldap.LdapRealmSettings;
import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings; import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings;
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings; import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
@ -24,6 +25,7 @@ import org.elasticsearch.xpack.security.authc.esnative.NativeRealm;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
import org.elasticsearch.xpack.security.authc.file.FileRealm; 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.ldap.LdapRealm;
import org.elasticsearch.xpack.security.authc.pki.PkiRealm; import org.elasticsearch.xpack.security.authc.pki.PkiRealm;
import org.elasticsearch.xpack.security.authc.saml.SamlRealm; import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
@ -32,10 +34,8 @@ import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingSt
import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.elasticsearch.xpack.security.support.SecurityIndexManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -50,17 +50,16 @@ public final class InternalRealms {
/** /**
* The list of all <em>internal</em> realm types, excluding {@link ReservedRealm#TYPE}. * The list of all <em>internal</em> realm types, excluding {@link ReservedRealm#TYPE}.
*/ */
private static final Set<String> XPACK_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( private static final Set<String> XPACK_TYPES = Collections
NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE, .unmodifiableSet(Sets.newHashSet(NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE,
SamlRealmSettings.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 * The list of all standard realm types, which are those provided by x-pack and do not have extensive
* interaction with third party sources * interaction with third party sources
*/ */
private static final Set<String> STANDARD_TYPES = private static final Set<String> STANDARD_TYPES = Collections.unmodifiableSet(Sets.newHashSet(NativeRealmSettings.TYPE,
Collections.unmodifiableSet(Sets.difference(XPACK_TYPES, Collections.singleton(SamlRealmSettings.TYPE))); FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE));
/** /**
* Determines whether <code>type</code> is an internal realm-type that is provided by x-pack, * Determines whether <code>type</code> is an internal realm-type that is provided by x-pack,
@ -105,6 +104,7 @@ public final class InternalRealms {
sslService, resourceWatcherService, nativeRoleMappingStore, threadPool)); sslService, resourceWatcherService, nativeRoleMappingStore, threadPool));
map.put(PkiRealmSettings.TYPE, config -> new PkiRealm(config, resourceWatcherService, nativeRoleMappingStore)); map.put(PkiRealmSettings.TYPE, config -> new PkiRealm(config, resourceWatcherService, nativeRoleMappingStore));
map.put(SamlRealmSettings.TYPE, config -> SamlRealm.create(config, sslService, 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); return Collections.unmodifiableMap(map);
} }

View File

@ -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'.
* <p>
* Example Authorization header "Authorization: Negotiate
* YIIChgYGKwYBBQUCoII..."
* <p>
* 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 "<Kerberos Token>";
}
@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. <br>
* 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;
}
}

View File

@ -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.user.User;
import org.elasticsearch.xpack.security.authc.support.CachingRealm;
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.
* <p>
* 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.
* <p>
* 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<String, User> 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<String, User> 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.<String, User>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<String, List<String>> 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<AuthenticationResult> 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<AuthenticationResult> 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<AuthenticationResult> 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<User> listener) {
listener.onResponse(null);
}
}

View File

@ -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.
* <p>
* 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<String, Settings> realmsSettings = RealmSettings.getRealmSettings(context.settings);
boolean isKerberosRealmConfigured = false;
for (final Entry<String, Settings> 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;
}
}

View File

@ -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.
* <p>
* This class takes care of login by ES service credentials using keytab,
* GSSContext establishment, and then validating the incoming token.
* <p>
* 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.
* <p>
* First performs service login using keytab, supports multiple principals in
* keytab and the principal is selected based on the request.
* <p>
* The GSS security context establishment state is handled as follows: <br>
* If the context is established it will call {@link ActionListener#onResponse}
* with a {@link Tuple} of username and outToken for peer reply. <br>
* 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. <br>
* Never calls {@link ActionListener#onResponse} with a {@code null} tuple. <br>
* 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<Tuple<String, String>> 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.
* <p>
*
* @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<byte[]>) () -> 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<GSSCredential>) () -> 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> T doAsWrapper(final Subject subject, final PrivilegedExceptionAction<T> action) throws PrivilegedActionException {
try {
return AccessController.doPrivileged((PrivilegedExceptionAction<T>) () -> 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<Void>) () -> {
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<Void>) () -> {
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<LoginContext>) () -> {
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.
* <p>
* 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<String, String> 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)) };
}
}
}

View File

@ -10,6 +10,21 @@ grant {
// needed for multiple server implementations used in tests // needed for multiple server implementations used in tests
permission java.net.SocketPermission "*", "accept,connect"; 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}" { grant codeBase "${codebase.xmlsec-2.0.8.jar}" {

View File

@ -49,6 +49,7 @@ import org.elasticsearch.threadpool.FixedExecutorBuilder;
import org.elasticsearch.threadpool.TestThreadPool; import org.elasticsearch.threadpool.TestThreadPool;
import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.TransportMessage; import org.elasticsearch.transport.TransportMessage;
import org.elasticsearch.xpack.core.XPackField;
import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.XPackSettings;
import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication;
import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
@ -88,6 +89,7 @@ import static org.elasticsearch.test.SecurityTestsUtils.assertAuthenticationExce
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError; import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError;
import static org.elasticsearch.xpack.security.authc.TokenServiceTests.mockGetTokenFromId; import static org.elasticsearch.xpack.security.authc.TokenServiceTests.mockGetTokenFromId;
import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.arrayContaining;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
@ -618,6 +620,47 @@ public class AuthenticationServiceTests extends ESTestCase {
} }
} }
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 { public void testRealmAuthenticateThrowingException() throws Exception {
AuthenticationToken token = mock(AuthenticationToken.class); AuthenticationToken token = mock(AuthenticationToken.class);
when(secondRealm.token(threadContext)).thenReturn(token); when(secondRealm.token(threadContext)).thenReturn(token);
@ -998,6 +1041,19 @@ public class AuthenticationServiceTests extends ESTestCase {
}).when(realm).authenticate(eq(token), any(ActionListener.class)); }).when(realm).authenticate(eq(token), any(ActionListener.class));
} }
@SuppressWarnings("unchecked")
private void mockAuthenticate(Realm realm, AuthenticationToken token, Exception e, boolean terminate) {
doAnswer((i) -> {
ActionListener<AuthenticationResult> listener = (ActionListener<AuthenticationResult>) 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) { private Authentication authenticateBlocking(RestRequest restRequest) {
PlainActionFuture<Authentication> future = new PlainActionFuture<>(); PlainActionFuture<Authentication> future = new PlainActionFuture<>();
service.authenticate(restRequest, future); service.authenticate(restRequest, future);

View File

@ -14,6 +14,11 @@ import org.elasticsearch.watcher.ResourceWatcherService;
import org.elasticsearch.xpack.core.security.authc.Realm; import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; 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.core.ssl.SSLService;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore; import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
@ -49,4 +54,12 @@ public class InternalRealmsTests extends ESTestCase {
TestEnvironment.newEnvironment(settings), new ThreadContext(settings))); TestEnvironment.newEnvironment(settings), new ThreadContext(settings)));
verify(securityIndex, times(2)).addIndexStateListener(isA(BiConsumer.class)); 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));
}
} }

View File

@ -20,6 +20,7 @@ import org.elasticsearch.xpack.core.security.authc.Realm;
import org.elasticsearch.xpack.core.security.authc.RealmConfig; import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; 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.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.ldap.LdapRealmSettings;
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings; import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.core.security.user.User;
@ -335,10 +336,11 @@ public class RealmsTests extends ESTestCase {
} }
public void testUnlicensedWithNonStandardRealms() 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() Settings.Builder builder = Settings.builder()
.put("path.home", createTempDir()) .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"); .put("xpack.security.authc.realms.foo.order", "0");
Settings settings = builder.build(); Settings settings = builder.build();
Environment env = TestEnvironment.newEnvironment(settings); Environment env = TestEnvironment.newEnvironment(settings);
@ -349,7 +351,7 @@ public class RealmsTests extends ESTestCase {
assertThat(realm, is(reservedRealm)); assertThat(realm, is(reservedRealm));
assertThat(iter.hasNext(), is(true)); assertThat(iter.hasNext(), is(true));
realm = iter.next(); realm = iter.next();
assertThat(realm.type(), is(SamlRealmSettings.TYPE)); assertThat(realm.type(), is(selectedRealmType));
assertThat(iter.hasNext(), is(false)); assertThat(iter.hasNext(), is(false));
when(licenseState.allowedRealmType()).thenReturn(AllowedRealmType.DEFAULT); when(licenseState.allowedRealmType()).thenReturn(AllowedRealmType.DEFAULT);

View File

@ -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 = "<Kerberos Token>";
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));
}
}

View File

@ -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<AuthenticationResult> 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<String> 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<String> 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));
}
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<AuthenticationResult> future = PlainActionFuture.newFuture();
kerberosRealm.authenticate(kerberosAuthenticationToken, future);
final AuthenticationResult result = future.actionGet();
assertSuccessAuthenticationResult(expectedUser, outToken, result);
return result.getUser();
}
}

View File

@ -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));
}
}

View File

@ -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<String> 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<String, String> value, final Exception e) {
assert value != null || e != null;
doAnswer((i) -> {
ActionListener<Tuple<String, String>> listener = (ActionListener<Tuple<String, String>>) 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<String, List<String>> 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<String> userNames) {
final List<String> 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<Set<String>> listener = (ActionListener<Set<String>>) 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;
}
}

View File

@ -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<AuthenticationResult> 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<AuthenticationResult> 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<User> future = new PlainActionFuture<>();
kerberosRealm.lookupUser(username, future);
assertThat(future.actionGet(), is(nullValue()));
}
}

View File

@ -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.
* <p>
* Takes care of starting {@link SimpleKdcLdapServer} as Kdc server backed by
* Ldap Server.
* <p>
* 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<String> serviceUserNames;
protected List<String> clientUserNames;
protected Path workDir = null;
protected SimpleKdcLdapServer simpleKdcLdapServer;
private static Locale restoreLocale;
private static Set<String> 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 <T> Type of value as returned by PrivilegedAction
* @throws PrivilegedActionException
*/
static <T> T doAsWrapper(final Subject subject, final PrivilegedExceptionAction<T> action) throws PrivilegedActionException {
return AccessController.doPrivileged((PrivilegedExceptionAction<T>) () -> 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();
}
}

View File

@ -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<Tuple<String, String>> 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<Tuple<String, String>>() {
boolean exceptionHandled = false;
@Override
public void onResponse(Tuple<String, String> 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<Tuple<String, String>> 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<Tuple<String, String>> 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<Tuple<String, String>> future) throws Throwable {
try {
future.actionGet();
} catch (Throwable t) {
Throwable throwThis = t;
while (throwThis instanceof UncategorizedExecutionException || throwThis instanceof ExecutionException) {
throwThis = throwThis.getCause();
}
throw throwThis;
}
}
}

View File

@ -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}.<br>
* 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.
* <p>
* 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<Boolean>() {
@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<Void>() {
@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<Void>() {
@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.");
}
}

View File

@ -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<Tuple<String, String>> 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));
}
}
}

View File

@ -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.<br>
* 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.<br>
* 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<br>
* 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<LoginContext>) () -> loginUsingPassword(userPrincipalName, password));
final GSSCredential userCreds = KerberosTestCase.doAsWrapper(loginContext.getSubject(),
(PrivilegedExceptionAction<GSSCredential>) () -> 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<byte[]>) () -> 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<byte[]>) () -> 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<Void>) () -> {
loginContext.logout();
return null;
});
}
if (gssContext != null) {
AccessController.doPrivileged((PrivilegedExceptionAction<Void>) () -> {
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<Principal> 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.
* <p>
* 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<String, String> 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<String>() {
@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;
}
}

View File

@ -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

View File

@ -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<String,String> 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<String> 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
}

View File

@ -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.
* <p>
* 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<Response>) () -> {
return restClient.performRequest(request);
}, accessControlContext);
assertOK(response);
final Map<String, Object> 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<String, Object> 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));
}
}
}

View File

@ -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}.
* <p>
* Based on the configuration, configures {@link HttpAsyncClientBuilder} to
* support spengo auth scheme.<br>
* 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<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>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<GSSCredential>) () -> 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<Void>) () -> {
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> T doAsPrivilegedWrapper(final Subject subject, final PrivilegedExceptionAction<T> action, final AccessControlContext acc)
throws PrivilegedActionException {
try {
return AccessController.doPrivileged((PrivilegedExceptionAction<T>) () -> 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.
* <p>
* 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<String, String> 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.
* <p>
* 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<String, String> 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<String, String> 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<String, String> options);
}
}

View File

@ -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\"";
};