[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:
parent
99426eb4f8
commit
a525c36c60
|
@ -20,11 +20,14 @@
|
|||
set -e
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
PRINC="$1"
|
||||
PASSWD="$2"
|
||||
USER=$(echo $PRINC | tr "/" "_")
|
||||
|
||||
VDIR=/vagrant
|
||||
|
@ -47,12 +50,17 @@ ADMIN_KTAB=$LOCALSTATEDIR/admin.keytab
|
|||
USER_PRIN=$PRINC@$REALM
|
||||
USER_KTAB=$LOCALSTATEDIR/$USER.keytab
|
||||
|
||||
if [ -f $USER_KTAB ]; then
|
||||
if [ -f $USER_KTAB ] && [ -z "$PASSWD" ]; then
|
||||
echo "Principal '${PRINC}@${REALM}' already exists. Re-copying keytab..."
|
||||
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
|
||||
else
|
||||
if [ -z "$PASSWD" ]; then
|
||||
echo "Provisioning '${PRINC}@${REALM}' principal and keytab..."
|
||||
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -randkey $USER_PRIN"
|
||||
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "ktadd -k $USER_KTAB $USER_PRIN"
|
||||
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
|
||||
else
|
||||
echo "Provisioning '${PRINC}@${REALM}' principal with password..."
|
||||
sudo kadmin -p $ADMIN_PRIN -kt $ADMIN_KTAB -q "addprinc -pw $PASSWD $PRINC"
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo cp $USER_KTAB $KEYTAB_DIR/$USER.keytab
|
||||
|
|
|
@ -10,60 +10,132 @@ import org.elasticsearch.common.util.concurrent.ThreadContext;
|
|||
import org.elasticsearch.rest.RestRequest;
|
||||
import org.elasticsearch.rest.RestStatus;
|
||||
import org.elasticsearch.transport.TransportMessage;
|
||||
import org.elasticsearch.xpack.core.XPackField;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError;
|
||||
|
||||
/**
|
||||
* The default implementation of a {@link AuthenticationFailureHandler}. This handler will return an exception with a
|
||||
* RestStatus of 401 and the WWW-Authenticate header with a Basic challenge.
|
||||
* The default implementation of a {@link AuthenticationFailureHandler}. This
|
||||
* handler will return an exception with a RestStatus of 401 and default failure
|
||||
* response headers like 'WWW-Authenticate'
|
||||
*/
|
||||
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {
|
||||
private final Map<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
|
||||
public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token,
|
||||
ThreadContext context) {
|
||||
return authenticationError("unable to authenticate user [{}] for REST request [{}]", token.principal(), request.uri());
|
||||
public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, ThreadContext context) {
|
||||
return createAuthenticationError("unable to authenticate user [{}] for REST request [{}]", null, token.principal(), request.uri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action,
|
||||
ThreadContext context) {
|
||||
return authenticationError("unable to authenticate user [{}] for action [{}]", token.principal(), action);
|
||||
return createAuthenticationError("unable to authenticate user [{}] for action [{}]", null, token.principal(), action);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) {
|
||||
if (e instanceof ElasticsearchSecurityException) {
|
||||
assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
|
||||
assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
|
||||
return (ElasticsearchSecurityException) e;
|
||||
}
|
||||
return authenticationError("error attempting to authenticate request", e);
|
||||
return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e,
|
||||
ThreadContext context) {
|
||||
if (e instanceof ElasticsearchSecurityException) {
|
||||
assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
|
||||
assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
|
||||
return (ElasticsearchSecurityException) e;
|
||||
}
|
||||
return authenticationError("error attempting to authenticate request", e);
|
||||
return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException missingToken(RestRequest request, ThreadContext context) {
|
||||
return authenticationError("missing authentication token for REST request [{}]", request.uri());
|
||||
return createAuthenticationError("missing authentication token for REST request [{}]", null, request.uri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException missingToken(TransportMessage message, String action, ThreadContext context) {
|
||||
return authenticationError("missing authentication token for action [{}]", action);
|
||||
return createAuthenticationError("missing authentication token for action [{}]", null, action);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElasticsearchSecurityException authenticationRequired(String action, ThreadContext context) {
|
||||
return authenticationError("action [{}] requires authentication", action);
|
||||
return createAuthenticationError("action [{}] requires authentication", null, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of {@link ElasticsearchSecurityException} with
|
||||
* {@link RestStatus#UNAUTHORIZED} status.
|
||||
* <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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,9 +8,12 @@ package org.elasticsearch.xpack.core.security.authc;
|
|||
import org.apache.logging.log4j.Logger;
|
||||
import org.elasticsearch.action.ActionListener;
|
||||
import org.elasticsearch.common.util.concurrent.ThreadContext;
|
||||
import org.elasticsearch.xpack.core.XPackField;
|
||||
import org.elasticsearch.xpack.core.security.user.User;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
|
@ -56,6 +59,18 @@ public abstract class Realm implements Comparable<Realm> {
|
|||
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
|
||||
public int compareTo(Realm other) {
|
||||
int result = Integer.compare(config.order, other.config.order);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -58,6 +58,67 @@ dependencies {
|
|||
testCompile "org.elasticsearch:mocksocket:${versions.mocksocket}"
|
||||
//testCompile "org.yaml:snakeyaml:${versions.snakeyaml}"
|
||||
|
||||
// Test dependencies for Kerberos (MiniKdc)
|
||||
testCompile('commons-io:commons-io:2.5')
|
||||
testCompile('org.apache.kerby:kerb-simplekdc:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-client:1.1.1')
|
||||
testCompile('org.apache.kerby:kerby-config:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-core:1.1.1')
|
||||
testCompile('org.apache.kerby:kerby-pkix:1.1.1')
|
||||
testCompile('org.apache.kerby:kerby-asn1:1.1.1')
|
||||
testCompile('org.apache.kerby:kerby-util:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-common:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-crypto:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-util:1.1.1')
|
||||
testCompile('org.apache.kerby:token-provider:1.1.1')
|
||||
testCompile('com.nimbusds:nimbus-jose-jwt:4.41.2')
|
||||
testCompile('net.jcip:jcip-annotations:1.0')
|
||||
testCompile('org.apache.kerby:kerb-admin:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-server:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-identity:1.1.1')
|
||||
testCompile('org.apache.kerby:kerby-xdr:1.1.1')
|
||||
|
||||
// LDAP backend support for SimpleKdcServer
|
||||
testCompile('org.apache.kerby:kerby-backend:1.1.1')
|
||||
testCompile('org.apache.kerby:ldap-backend:1.1.1')
|
||||
testCompile('org.apache.kerby:kerb-identity:1.1.1')
|
||||
testCompile('org.apache.directory.api:api-ldap-client-api:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-schema-data:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-codec-core:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-extras-aci:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-extras-codec:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-extras-codec-api:1.0.0')
|
||||
testCompile('commons-pool:commons-pool:1.6')
|
||||
testCompile('commons-collections:commons-collections:3.2')
|
||||
testCompile('org.apache.mina:mina-core:2.0.17')
|
||||
testCompile('org.apache.directory.api:api-util:1.0.1')
|
||||
testCompile('org.apache.directory.api:api-i18n:1.0.1')
|
||||
testCompile('org.apache.directory.api:api-ldap-model:1.0.1')
|
||||
testCompile('org.apache.directory.api:api-asn1-api:1.0.1')
|
||||
testCompile('org.apache.directory.api:api-asn1-ber:1.0.1')
|
||||
testCompile('org.apache.servicemix.bundles:org.apache.servicemix.bundles.antlr:2.7.7_5')
|
||||
testCompile('org.apache.directory.server:apacheds-core-api:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-i18n:2.0.0-M24')
|
||||
testCompile('org.apache.directory.api:api-ldap-extras-util:1.0.0')
|
||||
testCompile('net.sf.ehcache:ehcache:2.10.4')
|
||||
testCompile('org.apache.directory.server:apacheds-kerberos-codec:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-protocol-ldap:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-protocol-shared:2.0.0-M24')
|
||||
testCompile('org.apache.directory.jdbm:apacheds-jdbm1:2.0.0-M3')
|
||||
testCompile('org.apache.directory.server:apacheds-jdbm-partition:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-xdbm-partition:2.0.0-M24')
|
||||
testCompile('org.apache.directory.api:api-ldap-extras-sp:1.0.0')
|
||||
testCompile('org.apache.directory.server:apacheds-test-framework:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-core-annotations:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-ldif-partition:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-mavibot-partition:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-protocol-kerberos:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-server-annotations:2.0.0-M24')
|
||||
testCompile('org.apache.directory.api:api-ldap-codec-standalone:1.0.0')
|
||||
testCompile('org.apache.directory.api:api-ldap-net-mina:1.0.0')
|
||||
testCompile('org.apache.directory.server:ldap-client-test:2.0.0-M24')
|
||||
testCompile('org.apache.directory.server:apacheds-interceptor-kerberos:2.0.0-M24')
|
||||
testCompile('org.apache.directory.mavibot:mavibot:1.0.0-M8')
|
||||
}
|
||||
|
||||
compileJava.options.compilerArgs << "-Xlint:-deprecation,-rawtypes,-serial,-try,-unchecked"
|
||||
|
|
|
@ -77,6 +77,7 @@ import org.elasticsearch.transport.TransportInterceptor;
|
|||
import org.elasticsearch.transport.TransportRequest;
|
||||
import org.elasticsearch.transport.TransportRequestHandler;
|
||||
import org.elasticsearch.watcher.ResourceWatcherService;
|
||||
import org.elasticsearch.xpack.core.XPackField;
|
||||
import org.elasticsearch.xpack.core.XPackPlugin;
|
||||
import org.elasticsearch.xpack.core.XPackSettings;
|
||||
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.esnative.NativeUsersStore;
|
||||
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
|
||||
import org.elasticsearch.xpack.security.authc.kerberos.KerberosRealmBootstrapCheck;
|
||||
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
|
||||
import org.elasticsearch.xpack.security.authz.AuthorizationService;
|
||||
import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener;
|
||||
|
@ -291,7 +293,8 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
|
|||
new TokenSSLBootstrapCheck(),
|
||||
new PkiRealmBootstrapCheck(getSslService()),
|
||||
new TLSLicenseBootstrapCheck(),
|
||||
new PasswordHashingAlgorithmBootstrapCheck()));
|
||||
new PasswordHashingAlgorithmBootstrapCheck(),
|
||||
new KerberosRealmBootstrapCheck(env)));
|
||||
checks.addAll(InternalRealms.getBootstrapChecks(settings, env));
|
||||
this.bootstrapChecks = Collections.unmodifiableList(checks);
|
||||
} else {
|
||||
|
@ -432,23 +435,7 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
|
|||
|
||||
securityIndex.get().addIndexStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);
|
||||
|
||||
AuthenticationFailureHandler failureHandler = null;
|
||||
String extensionName = null;
|
||||
for (SecurityExtension extension : securityExtensions) {
|
||||
AuthenticationFailureHandler extensionFailureHandler = extension.getAuthenticationFailureHandler();
|
||||
if (extensionFailureHandler != null && failureHandler != null) {
|
||||
throw new IllegalStateException("Extensions [" + extensionName + "] and [" + extension.toString() + "] " +
|
||||
"both set an authentication failure handler");
|
||||
}
|
||||
failureHandler = extensionFailureHandler;
|
||||
extensionName = extension.toString();
|
||||
}
|
||||
if (failureHandler == null) {
|
||||
logger.debug("Using default authentication failure handler");
|
||||
failureHandler = new DefaultAuthenticationFailureHandler();
|
||||
} else {
|
||||
logger.debug("Using authentication failure handler from extension [" + extensionName + "]");
|
||||
}
|
||||
final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms);
|
||||
|
||||
authcService.set(new AuthenticationService(settings, realms, auditTrailService, failureHandler, threadPool,
|
||||
anonymousUser, tokenService));
|
||||
|
@ -498,6 +485,45 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
|
|||
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
|
||||
public Settings additionalSettings() {
|
||||
return additionalSettings(settings, enabled, transportClientMode);
|
||||
|
|
|
@ -271,7 +271,9 @@ public class AuthenticationService extends AbstractComponent {
|
|||
if (result.getStatus() == AuthenticationResult.Status.TERMINATE) {
|
||||
logger.info("Authentication of [{}] was terminated by realm [{}] - {}",
|
||||
authenticationToken.principal(), realm.name(), result.getMessage());
|
||||
userListener.onFailure(Exceptions.authenticationError(result.getMessage(), result.getException()));
|
||||
Exception e = (result.getException() != null) ? result.getException()
|
||||
: Exceptions.authenticationError(result.getMessage());
|
||||
userListener.onFailure(e);
|
||||
} else {
|
||||
if (result.getMessage() != null) {
|
||||
messages.put(realm, new Tuple<>(result.getMessage(), result.getException()));
|
||||
|
|
|
@ -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.esnative.NativeRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
|
||||
|
@ -24,6 +25,7 @@ import org.elasticsearch.xpack.security.authc.esnative.NativeRealm;
|
|||
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
|
||||
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
|
||||
import org.elasticsearch.xpack.security.authc.file.FileRealm;
|
||||
import org.elasticsearch.xpack.security.authc.kerberos.KerberosRealm;
|
||||
import org.elasticsearch.xpack.security.authc.ldap.LdapRealm;
|
||||
import org.elasticsearch.xpack.security.authc.pki.PkiRealm;
|
||||
import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
|
||||
|
@ -32,10 +34,8 @@ import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingSt
|
|||
import org.elasticsearch.xpack.security.support.SecurityIndexManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
@ -50,17 +50,16 @@ public final class InternalRealms {
|
|||
/**
|
||||
* The list of all <em>internal</em> realm types, excluding {@link ReservedRealm#TYPE}.
|
||||
*/
|
||||
private static final Set<String> XPACK_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
|
||||
NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE,
|
||||
SamlRealmSettings.TYPE
|
||||
)));
|
||||
private static final Set<String> XPACK_TYPES = Collections
|
||||
.unmodifiableSet(Sets.newHashSet(NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE,
|
||||
LdapRealmSettings.LDAP_TYPE, PkiRealmSettings.TYPE, SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE));
|
||||
|
||||
/**
|
||||
* The list of all standard realm types, which are those provided by x-pack and do not have extensive
|
||||
* interaction with third party sources
|
||||
*/
|
||||
private static final Set<String> STANDARD_TYPES =
|
||||
Collections.unmodifiableSet(Sets.difference(XPACK_TYPES, Collections.singleton(SamlRealmSettings.TYPE)));
|
||||
private static final Set<String> STANDARD_TYPES = Collections.unmodifiableSet(Sets.newHashSet(NativeRealmSettings.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,
|
||||
|
@ -105,6 +104,7 @@ public final class InternalRealms {
|
|||
sslService, resourceWatcherService, nativeRoleMappingStore, threadPool));
|
||||
map.put(PkiRealmSettings.TYPE, config -> new PkiRealm(config, resourceWatcherService, nativeRoleMappingStore));
|
||||
map.put(SamlRealmSettings.TYPE, config -> SamlRealm.create(config, sslService, resourceWatcherService, nativeRoleMappingStore));
|
||||
map.put(KerberosRealmSettings.TYPE, config -> new KerberosRealm(config, nativeRoleMappingStore, threadPool));
|
||||
return Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)) };
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -10,6 +10,21 @@ grant {
|
|||
|
||||
// needed for multiple server implementations used in tests
|
||||
permission java.net.SocketPermission "*", "accept,connect";
|
||||
|
||||
// needed for Kerberos login
|
||||
permission javax.security.auth.AuthPermission "modifyPrincipals";
|
||||
permission javax.security.auth.AuthPermission "modifyPrivateCredentials";
|
||||
permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosKey * \"*\"", "read";
|
||||
permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KeyTab * \"*\"", "read";
|
||||
permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosTicket * \"*\"", "read";
|
||||
permission javax.security.auth.AuthPermission "doAs";
|
||||
permission javax.security.auth.kerberos.ServicePermission "*","initiate,accept";
|
||||
|
||||
permission java.util.PropertyPermission "javax.security.auth.useSubjectCredsOnly","write";
|
||||
permission java.util.PropertyPermission "java.security.krb5.conf","write";
|
||||
permission java.util.PropertyPermission "sun.security.krb5.debug","write";
|
||||
permission java.util.PropertyPermission "java.security.debug","write";
|
||||
permission java.util.PropertyPermission "sun.security.spnego.debug","write";
|
||||
};
|
||||
|
||||
grant codeBase "${codebase.xmlsec-2.0.8.jar}" {
|
||||
|
|
|
@ -49,6 +49,7 @@ import org.elasticsearch.threadpool.FixedExecutorBuilder;
|
|||
import org.elasticsearch.threadpool.TestThreadPool;
|
||||
import org.elasticsearch.threadpool.ThreadPool;
|
||||
import org.elasticsearch.transport.TransportMessage;
|
||||
import org.elasticsearch.xpack.core.XPackField;
|
||||
import org.elasticsearch.xpack.core.XPackSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.Authentication;
|
||||
import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
|
||||
|
@ -88,6 +89,7 @@ import static org.elasticsearch.test.SecurityTestsUtils.assertAuthenticationExce
|
|||
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError;
|
||||
import static org.elasticsearch.xpack.security.authc.TokenServiceTests.mockGetTokenFromId;
|
||||
import static org.hamcrest.Matchers.arrayContaining;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
|
@ -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 {
|
||||
AuthenticationToken token = mock(AuthenticationToken.class);
|
||||
when(secondRealm.token(threadContext)).thenReturn(token);
|
||||
|
@ -998,6 +1041,19 @@ public class AuthenticationServiceTests extends ESTestCase {
|
|||
}).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) {
|
||||
PlainActionFuture<Authentication> future = new PlainActionFuture<>();
|
||||
service.authenticate(restRequest, future);
|
||||
|
|
|
@ -14,6 +14,11 @@ import org.elasticsearch.watcher.ResourceWatcherService;
|
|||
import org.elasticsearch.xpack.core.security.authc.Realm;
|
||||
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
|
||||
import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.pki.PkiRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
|
||||
import org.elasticsearch.xpack.core.ssl.SSLService;
|
||||
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
|
||||
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
|
||||
|
@ -49,4 +54,12 @@ public class InternalRealmsTests extends ESTestCase {
|
|||
TestEnvironment.newEnvironment(settings), new ThreadContext(settings)));
|
||||
verify(securityIndex, times(2)).addIndexStateListener(isA(BiConsumer.class));
|
||||
}
|
||||
|
||||
public void testIsStandardType() {
|
||||
String type = randomFrom(NativeRealmSettings.TYPE, FileRealmSettings.TYPE, LdapRealmSettings.AD_TYPE, LdapRealmSettings.LDAP_TYPE,
|
||||
PkiRealmSettings.TYPE);
|
||||
assertThat(InternalRealms.isStandardRealm(type), is(true));
|
||||
type = randomFrom(SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE);
|
||||
assertThat(InternalRealms.isStandardRealm(type), is(false));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.esnative.NativeRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.ldap.LdapRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
|
||||
import org.elasticsearch.xpack.core.security.user.User;
|
||||
|
@ -335,10 +336,11 @@ public class RealmsTests extends ESTestCase {
|
|||
}
|
||||
|
||||
public void testUnlicensedWithNonStandardRealms() throws Exception {
|
||||
factories.put(SamlRealmSettings.TYPE, config -> new DummyRealm(SamlRealmSettings.TYPE, config));
|
||||
final String selectedRealmType = randomFrom(SamlRealmSettings.TYPE, KerberosRealmSettings.TYPE);
|
||||
factories.put(selectedRealmType, config -> new DummyRealm(selectedRealmType, config));
|
||||
Settings.Builder builder = Settings.builder()
|
||||
.put("path.home", createTempDir())
|
||||
.put("xpack.security.authc.realms.foo.type", SamlRealmSettings.TYPE)
|
||||
.put("xpack.security.authc.realms.foo.type", selectedRealmType)
|
||||
.put("xpack.security.authc.realms.foo.order", "0");
|
||||
Settings settings = builder.build();
|
||||
Environment env = TestEnvironment.newEnvironment(settings);
|
||||
|
@ -349,7 +351,7 @@ public class RealmsTests extends ESTestCase {
|
|||
assertThat(realm, is(reservedRealm));
|
||||
assertThat(iter.hasNext(), is(true));
|
||||
realm = iter.next();
|
||||
assertThat(realm.type(), is(SamlRealmSettings.TYPE));
|
||||
assertThat(realm.type(), is(selectedRealmType));
|
||||
assertThat(iter.hasNext(), is(false));
|
||||
|
||||
when(licenseState.allowedRealmType()).thenReturn(AllowedRealmType.DEFAULT);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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()));
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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\"";
|
||||
};
|
Loading…
Reference in New Issue