From 52d77016ca7bf9fbe58daf31dd4335dbf543d435 Mon Sep 17 00:00:00 2001
From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com>
Date: Sun, 24 Jun 2018 01:30:33 +1000
Subject: [PATCH 1/9] Add support framework for Kerberos Realm (#31023)
This change adds the framework to support Kerberos authN in elasticsearch.
ES is the service protected by Kerberos, each ES service node will have its
own keytab. Keytab is the file with Service principal name and encrypted key.
This can be then used to validate the authenticator coming in the request.
This change only adds support for SPNEGO mechanism and uses JGSS.
JVM options -Djava.security.krb5.conf can be used to specify krb5.conf with
additional settings if required.
For Kerberos Realm,
KerberosRealmSettings: Captures settings required for Kerberos
Usually keytab (stored in the config), cache settings and krb debug flag
KerberosAuthenticationToken: Handles extraction of token from request
Extracts the token from request header:
"Authorization: Negotiate "
If any error condition occurs, throws Exception with Rest status 401
Also adds response header "WWW-Authenticate: Negotiate"
KerberosTicketValidator: Used for kerberos ticket validation and
gss context establishment.
On service side, we need to login first, uses Jaas to complete service login.
To avoid more file configurations, we generate the JAAS configuration with
required modules in memory. The token extracted from authnToken is
passed on to GSSContext which uses service credentials (keytab) to verify
the passed token and generates output token. If GSS context is established
it returns tuple of client-username and out token (can be empty). If out token
is present but context is yet not established then it will return tuple with no
username and out token. The out token needs to be returned as response
header 401 and "WWW-Authenticate: Negotiate " for ongoing
negotiation. This will continue till either it fails or successful authentication on
context establishment.
Changes in plugin-security policy to add required permissions
Few settings like Jaas config and kerberos keytab access requires permissions.
For testing,
KerberosTestCase is the base class to start/stop kdc server
and build test settings. SimpleKdcLdapServer is a wrapper around
SimpleKdcServer(ApacheDS), which simplifies in memory testing with KDC and
uses in-memory LDAP server as its backend.
---
.../authc/kerberos/KerberosRealmSettings.java | 47 +++
x-pack/plugin/security/build.gradle | 63 +++-
.../kerberos/KerberosAuthenticationToken.java | 117 ++++++++
.../support/KerberosTicketValidator.java | 278 ++++++++++++++++++
.../plugin-metadata/plugin-security.policy | 15 +
.../KerberosAuthenticationTokenTests.java | 110 +++++++
.../kerberos/KerberosRealmSettingsTests.java | 43 +++
.../kerberos/support/KerberosTestCase.java | 218 ++++++++++++++
.../support/KerberosTicketValidatorTests.java | 101 +++++++
.../kerberos/support/SimpleKdcLdapServer.java | 224 ++++++++++++++
.../support/SimpleKdcLdapServerTests.java | 75 +++++
.../authc/kerberos/support/SpnegoClient.java | 258 ++++++++++++++++
.../security/src/test/resources/kdc.ldiff | 23 ++
13 files changed, 1571 insertions(+), 1 deletion(-)
create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java
create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java
create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidator.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTestCase.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidatorTests.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServer.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServerTests.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SpnegoClient.java
create mode 100644 x-pack/plugin/security/src/test/resources/kdc.ldiff
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java
new file mode 100644
index 0000000000000..a3dfb501928ee
--- /dev/null
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/kerberos/KerberosRealmSettings.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.core.security.authc.kerberos;
+
+import org.elasticsearch.common.settings.Setting;
+import org.elasticsearch.common.settings.Setting.Property;
+import org.elasticsearch.common.unit.TimeValue;
+import org.elasticsearch.common.util.set.Sets;
+
+import java.util.Set;
+
+/**
+ * Kerberos Realm settings
+ */
+public final class KerberosRealmSettings {
+ public static final String TYPE = "kerberos";
+
+ /**
+ * Kerberos key tab for Elasticsearch service
+ * Uses single key tab for multiple service accounts.
+ */
+ public static final Setting HTTP_SERVICE_KEYTAB_PATH =
+ Setting.simpleString("keytab.path", Property.NodeScope);
+ public static final Setting SETTING_KRB_DEBUG_ENABLE =
+ Setting.boolSetting("krb.debug", Boolean.FALSE, Property.NodeScope);
+
+ // Cache
+ private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20);
+ private static final int DEFAULT_MAX_USERS = 100_000; // 100k users
+ public static final Setting CACHE_TTL_SETTING = Setting.timeSetting("cache.ttl", DEFAULT_TTL, Setting.Property.NodeScope);
+ public static final Setting CACHE_MAX_USERS_SETTING =
+ Setting.intSetting("cache.max_users", DEFAULT_MAX_USERS, Property.NodeScope);
+
+ private KerberosRealmSettings() {
+ }
+
+ /**
+ * @return the valid set of {@link Setting}s for a {@value #TYPE} realm
+ */
+ public static Set> getSettings() {
+ return Sets.newHashSet(HTTP_SERVICE_KEYTAB_PATH, CACHE_TTL_SETTING, CACHE_MAX_USERS_SETTING, SETTING_KRB_DEBUG_ENABLE);
+ }
+}
diff --git a/x-pack/plugin/security/build.gradle b/x-pack/plugin/security/build.gradle
index 12533a389b5f1..c280a516ef823 100644
--- a/x-pack/plugin/security/build.gradle
+++ b/x-pack/plugin/security/build.gradle
@@ -52,12 +52,73 @@ dependencies {
compile "org.apache.httpcomponents:httpasyncclient:${versions.httpasyncclient}"
compile "org.apache.httpcomponents:httpcore-nio:${versions.httpcore}"
compile "org.apache.httpcomponents:httpclient-cache:${versions.httpclient}"
- compile 'com.google.guava:guava:19.0'
+ compile 'com.google.guava:guava:19.0'
testCompile 'org.elasticsearch:securemock:1.2'
testCompile "org.elasticsearch:mocksocket:${versions.mocksocket}"
//testCompile "org.yaml:snakeyaml:${versions.snakeyaml}"
+ // Test dependencies for Kerberos (MiniKdc)
+ testCompile('commons-io:commons-io:2.5')
+ testCompile('org.apache.kerby:kerb-simplekdc:1.1.1')
+ testCompile('org.apache.kerby:kerb-client:1.1.1')
+ testCompile('org.apache.kerby:kerby-config:1.1.1')
+ testCompile('org.apache.kerby:kerb-core:1.1.1')
+ testCompile('org.apache.kerby:kerby-pkix:1.1.1')
+ testCompile('org.apache.kerby:kerby-asn1:1.1.1')
+ testCompile('org.apache.kerby:kerby-util:1.1.1')
+ testCompile('org.apache.kerby:kerb-common:1.1.1')
+ testCompile('org.apache.kerby:kerb-crypto:1.1.1')
+ testCompile('org.apache.kerby:kerb-util:1.1.1')
+ testCompile('org.apache.kerby:token-provider:1.1.1')
+ testCompile('com.nimbusds:nimbus-jose-jwt:4.41.2')
+ testCompile('net.jcip:jcip-annotations:1.0')
+ testCompile('org.apache.kerby:kerb-admin:1.1.1')
+ testCompile('org.apache.kerby:kerb-server:1.1.1')
+ testCompile('org.apache.kerby:kerb-identity:1.1.1')
+ testCompile('org.apache.kerby:kerby-xdr:1.1.1')
+
+ // LDAP backend support for SimpleKdcServer
+ testCompile('org.apache.kerby:kerby-backend:1.1.1')
+ testCompile('org.apache.kerby:ldap-backend:1.1.1')
+ testCompile('org.apache.kerby:kerb-identity:1.1.1')
+ testCompile('org.apache.directory.api:api-ldap-client-api:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-schema-data:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-codec-core:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-extras-aci:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-extras-codec:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-extras-codec-api:1.0.0')
+ testCompile('commons-pool:commons-pool:1.6')
+ testCompile('commons-collections:commons-collections:3.2')
+ testCompile('org.apache.mina:mina-core:2.0.17')
+ testCompile('org.apache.directory.api:api-util:1.0.1')
+ testCompile('org.apache.directory.api:api-i18n:1.0.1')
+ testCompile('org.apache.directory.api:api-ldap-model:1.0.1')
+ testCompile('org.apache.directory.api:api-asn1-api:1.0.1')
+ testCompile('org.apache.directory.api:api-asn1-ber:1.0.1')
+ testCompile('org.apache.servicemix.bundles:org.apache.servicemix.bundles.antlr:2.7.7_5')
+ testCompile('org.apache.directory.server:apacheds-core-api:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-i18n:2.0.0-M24')
+ testCompile('org.apache.directory.api:api-ldap-extras-util:1.0.0')
+ testCompile('net.sf.ehcache:ehcache:2.10.4')
+ testCompile('org.apache.directory.server:apacheds-kerberos-codec:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-protocol-ldap:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-protocol-shared:2.0.0-M24')
+ testCompile('org.apache.directory.jdbm:apacheds-jdbm1:2.0.0-M3')
+ testCompile('org.apache.directory.server:apacheds-jdbm-partition:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-xdbm-partition:2.0.0-M24')
+ testCompile('org.apache.directory.api:api-ldap-extras-sp:1.0.0')
+ testCompile('org.apache.directory.server:apacheds-test-framework:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-core-annotations:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-ldif-partition:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-mavibot-partition:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-protocol-kerberos:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-server-annotations:2.0.0-M24')
+ testCompile('org.apache.directory.api:api-ldap-codec-standalone:1.0.0')
+ testCompile('org.apache.directory.api:api-ldap-net-mina:1.0.0')
+ testCompile('org.apache.directory.server:ldap-client-test:2.0.0-M24')
+ testCompile('org.apache.directory.server:apacheds-interceptor-kerberos:2.0.0-M24')
+ testCompile('org.apache.directory.mavibot:mavibot:1.0.0-M8')
}
compileJava.options.compilerArgs << "-Xlint:-deprecation,-rawtypes,-serial,-try,-unchecked"
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java
new file mode 100644
index 0000000000000..e78624ee687d7
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationToken.java
@@ -0,0 +1,117 @@
+/*
+ * 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'.
+ *
+ * If there is any error handling during extraction of 'Negotiate' header then
+ * it throws {@link ElasticsearchSecurityException} with
+ * {@link RestStatus#UNAUTHORIZED} and header 'WWW-Authenticate: Negotiate'
+ */
+public final class KerberosAuthenticationToken implements AuthenticationToken {
+
+ public static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+ public static final String AUTH_HEADER = "Authorization";
+ public static final String NEGOTIATE_SCHEME_NAME = "Negotiate";
+ public static final String NEGOTIATE_AUTH_HEADER_PREFIX = NEGOTIATE_SCHEME_NAME + " ";
+
+ // authorization scheme check is case-insensitive
+ private static final boolean IGNORE_CASE_AUTH_HEADER_MATCH = true;
+
+ private final byte[] decodedToken;
+
+ public KerberosAuthenticationToken(final byte[] decodedToken) {
+ this.decodedToken = decodedToken;
+ }
+
+ /**
+ * Extract token from authorization header and if it is valid
+ * {@value #NEGOTIATE_AUTH_HEADER_PREFIX} then returns
+ * {@link KerberosAuthenticationToken}
+ *
+ * @param authorizationHeader Authorization header from request
+ * @return returns {@code null} if {@link #AUTH_HEADER} is empty or does not
+ * start with {@value #NEGOTIATE_AUTH_HEADER_PREFIX} else returns valid
+ * {@link KerberosAuthenticationToken}
+ * @throws ElasticsearchSecurityException when negotiate header is invalid.
+ */
+ public static KerberosAuthenticationToken extractToken(final String authorizationHeader) {
+ if (Strings.isNullOrEmpty(authorizationHeader)) {
+ return null;
+ }
+ if (authorizationHeader.regionMatches(IGNORE_CASE_AUTH_HEADER_MATCH, 0, NEGOTIATE_AUTH_HEADER_PREFIX, 0,
+ NEGOTIATE_AUTH_HEADER_PREFIX.length()) == false) {
+ return null;
+ }
+
+ final String base64EncodedToken = authorizationHeader.substring(NEGOTIATE_AUTH_HEADER_PREFIX.length()).trim();
+ if (Strings.isEmpty(base64EncodedToken)) {
+ throw unauthorized("invalid negotiate authentication header value, expected base64 encoded token but value is empty", null);
+ }
+
+ byte[] decodedKerberosTicket = null;
+ try {
+ decodedKerberosTicket = Base64.getDecoder().decode(base64EncodedToken);
+ } catch (IllegalArgumentException iae) {
+ throw unauthorized("invalid negotiate authentication header value, could not decode base64 token {}", iae, base64EncodedToken);
+ }
+
+ return new KerberosAuthenticationToken(decodedKerberosTicket);
+ }
+
+ @Override
+ public String principal() {
+ return "";
+ }
+
+ @Override
+ public Object credentials() {
+ return decodedToken;
+ }
+
+ @Override
+ public void clearCredentials() {
+ Arrays.fill(decodedToken, (byte) 0);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(decodedToken);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (this == other)
+ return true;
+ if (other == null)
+ return false;
+ if (getClass() != other.getClass())
+ return false;
+ final KerberosAuthenticationToken otherKerbToken = (KerberosAuthenticationToken) other;
+ return Arrays.equals(otherKerbToken.decodedToken, this.decodedToken);
+ }
+
+ private 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;
+ }
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidator.java
new file mode 100644
index 0000000000000..61e8f7500d5f5
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidator.java
@@ -0,0 +1,278 @@
+/*
+ * 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.support;
+
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.common.collect.Tuple;
+import org.elasticsearch.common.logging.ESLoggerFactory;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.Oid;
+
+import java.nio.file.Path;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.security.auth.Subject;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+
+/**
+ * Utility class that validates kerberos ticket for peer authentication.
+ *
+ * This class takes care of login by ES service credentials using keytab,
+ * GSSContext establishment, and then validating the incoming token.
+ *
+ * It may respond with token which needs to be communicated with the peer.
+ */
+public class KerberosTicketValidator {
+ static final Oid SPNEGO_OID = getSpnegoOid();
+
+ private static Oid getSpnegoOid() {
+ Oid oid = null;
+ try {
+ oid = new Oid("1.3.6.1.5.5.2");
+ } catch (GSSException gsse) {
+ throw ExceptionsHelper.convertToRuntime(gsse);
+ }
+ return oid;
+ }
+
+ private static final Logger LOGGER = ESLoggerFactory.getLogger(KerberosTicketValidator.class);
+
+ private static final String KEY_TAB_CONF_NAME = "KeytabConf";
+ private static final String SUN_KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule";
+
+ /**
+ * Validates client kerberos ticket received from the peer.
+ *
+ * First performs service login using keytab, supports multiple principals in
+ * keytab and the principal is selected based on the request.
+ *
+ * The GSS security context establishment is handled and if it is established then
+ * returns the Tuple of username and out token for peer reply. It may return
+ * Tuple with null username but with a outToken to be sent to peer for further
+ * negotiation.
+ *
+ * @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.
+ * @return {@link Tuple} of user name {@link GSSContext#getSrcName()} and out
+ * token base64 encoded if any. When context is not yet established user
+ * name is {@code null}.
+ * @throws LoginException thrown when service authentication fails
+ * {@link LoginContext#login()}
+ * @throws GSSException thrown when GSS Context negotiation fails
+ * {@link GSSException}
+ */
+ public Tuple validateTicket(final byte[] decodedToken, final Path keytabPath, final boolean krbDebug)
+ throws LoginException, GSSException {
+ 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);
+ return new Tuple<>(gssContext.isEstablished() ? gssContext.getSrcName().toString() : null, base64OutToken);
+ } catch (PrivilegedActionException pve) {
+ if (pve.getCause() instanceof LoginException) {
+ throw (LoginException) pve.getCause();
+ }
+ if (pve.getCause() instanceof GSSException) {
+ throw (GSSException) pve.getCause();
+ }
+ throw ExceptionsHelper.convertToRuntime(pve.getException());
+ } finally {
+ privilegedLogoutNoThrow(loginContext);
+ privilegedDisposeNoThrow(gssContext);
+ }
+ }
+
+ /**
+ * Encodes the specified byte array using base64 encoding scheme
+ *
+ * @param outToken byte array to be encoded
+ * @return String containing base64 encoded characters. returns {@code null} if
+ * outToken is null or empty.
+ */
+ private String encodeToString(final byte[] outToken) {
+ if (outToken != null && outToken.length > 0) {
+ return Base64.getEncoder().encodeToString(outToken);
+ }
+ return null;
+ }
+
+ /**
+ * Handles GSS context establishment. Received token is passed to the GSSContext
+ * on acceptor side and returns with out token that needs to be sent to peer for
+ * further GSS context establishment.
+ *
+ *
+ * @param base64decodedTicket in token generated by peer
+ * @param gssContext instance of acceptor {@link GSSContext}
+ * @param subject authenticated subject
+ * @return a byte[] containing the token to be sent to the peer. null indicates
+ * that no token is generated.
+ * @throws PrivilegedActionException
+ * @see GSSContext#acceptSecContext(byte[], int, int)
+ */
+ private static byte[] acceptSecContext(final byte[] base64decodedTicket, final GSSContext gssContext, Subject subject)
+ throws PrivilegedActionException {
+ // process token with gss context
+ return doAsWrapper(subject,
+ (PrivilegedExceptionAction) () -> gssContext.acceptSecContext(base64decodedTicket, 0, base64decodedTicket.length));
+ }
+
+ /**
+ * For acquiring SPNEGO mechanism credentials for service based on the subject
+ *
+ * @param gssManager {@link GSSManager}
+ * @param subject logged in {@link Subject}
+ * @return {@link GSSCredential} for particular mechanism
+ * @throws GSSException
+ * @throws PrivilegedActionException
+ */
+ private static GSSCredential createCredentials(final GSSManager gssManager,
+ final Subject subject) throws GSSException, PrivilegedActionException {
+ return doAsWrapper(subject, (PrivilegedExceptionAction) () -> gssManager
+ .createCredential(null, GSSCredential.DEFAULT_LIFETIME, SPNEGO_OID, GSSCredential.ACCEPT_ONLY));
+ }
+
+ /**
+ * Privileged Wrapper that invokes action with Subject.doAs to perform work as
+ * given subject.
+ *
+ * @param subject {@link Subject} to be used for this work
+ * @param action {@link PrivilegedExceptionAction} action for performing inside
+ * Subject.doAs
+ * @return the value returned by the PrivilegedExceptionAction's run method
+ * @throws PrivilegedActionException
+ */
+ private static T doAsWrapper(final Subject subject, final PrivilegedExceptionAction action) throws PrivilegedActionException {
+ try {
+ return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAs(subject, action));
+ } catch (PrivilegedActionException pae) {
+ if (pae.getCause() instanceof PrivilegedActionException) {
+ throw (PrivilegedActionException) pae.getCause();
+ }
+ throw pae;
+ }
+ }
+
+ /**
+ * Privileged wrapper for closing GSSContext, does not throw exceptions but logs
+ * them as a debug message.
+ *
+ * @param gssContext GSSContext to be disposed.
+ */
+ private static void privilegedDisposeNoThrow(final GSSContext gssContext) {
+ if (gssContext != null) {
+ try {
+ AccessController.doPrivileged((PrivilegedExceptionAction) () -> {
+ gssContext.dispose();
+ return null;
+ });
+ } catch (PrivilegedActionException e) {
+ LOGGER.debug("Could not dispose GSS Context", e.getCause());
+ }
+ }
+ }
+
+ /**
+ * Privileged wrapper for closing LoginContext, does not throw exceptions but
+ * logs them as a debug message.
+ *
+ * @param loginContext LoginContext to be closed
+ */
+ private static void privilegedLogoutNoThrow(final LoginContext loginContext) {
+ if (loginContext != null) {
+ try {
+ AccessController.doPrivileged((PrivilegedExceptionAction) () -> {
+ loginContext.logout();
+ return null;
+ });
+ } catch (PrivilegedActionException e) {
+ LOGGER.debug("Could not close LoginContext", e.getCause());
+ }
+ }
+ }
+
+ /**
+ * Performs authentication using provided keytab
+ *
+ * @param keytabFilePath Keytab file path
+ * @param krbDebug if {@code true} enables jaas krb5 login module debug logs.
+ * @return authenticated {@link LoginContext} instance. Note: This needs to be
+ * closed using {@link LoginContext#logout()} after usage.
+ * @throws PrivilegedActionException when privileged action threw exception
+ */
+ private static LoginContext serviceLogin(final String keytabFilePath, final boolean krbDebug)
+ throws PrivilegedActionException {
+ return AccessController.doPrivileged((PrivilegedExceptionAction) () -> {
+ final Subject subject = new Subject(false, Collections.emptySet(), Collections.emptySet(), Collections.emptySet());
+ final Configuration conf = new KeytabJaasConf(keytabFilePath, krbDebug);
+ final LoginContext loginContext = new LoginContext(KEY_TAB_CONF_NAME, subject, null, conf);
+ loginContext.login();
+ return loginContext;
+ });
+ }
+
+ /**
+ * Usually we would have a JAAS configuration file for login configuration. As
+ * we have static configuration except debug flag, we are constructing in memory. This
+ * avoid additional configuration required from the user.
+ *
+ * As we are using this instead of jaas.conf, this requires refresh of
+ * {@link Configuration} and requires appropriate security permissions to do so.
+ */
+ static class KeytabJaasConf extends Configuration {
+ private final String keytabFilePath;
+ private final boolean krbDebug;
+
+ KeytabJaasConf(final String keytabFilePath, final boolean krbDebug) {
+ this.keytabFilePath = keytabFilePath;
+ this.krbDebug = krbDebug;
+ }
+
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(final String name) {
+ final Map options = new HashMap<>();
+ options.put("keyTab", keytabFilePath);
+ /*
+ * As acceptor, we can have multiple SPNs, we do not want to use particular
+ * principal so it uses "*"
+ */
+ options.put("principal", "*");
+ options.put("useKeyTab", Boolean.TRUE.toString());
+ options.put("storeKey", Boolean.TRUE.toString());
+ options.put("doNotPrompt", Boolean.TRUE.toString());
+ options.put("renewTGT", Boolean.FALSE.toString());
+ options.put("refreshKrb5Config", Boolean.TRUE.toString());
+ options.put("isInitiator", Boolean.FALSE.toString());
+ options.put("debug", Boolean.toString(krbDebug));
+
+ return new AppConfigurationEntry[] { new AppConfigurationEntry(SUN_KRB5_LOGIN_MODULE,
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, Collections.unmodifiableMap(options)) };
+ }
+
+ }
+}
diff --git a/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy b/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy
index 857c2f6e472d5..8ce72be3ef9a7 100644
--- a/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy
+++ b/x-pack/plugin/security/src/main/plugin-metadata/plugin-security.policy
@@ -10,6 +10,21 @@ grant {
// needed for multiple server implementations used in tests
permission java.net.SocketPermission "*", "accept,connect";
+
+ // needed for Kerberos login
+ permission javax.security.auth.AuthPermission "modifyPrincipals";
+ permission javax.security.auth.AuthPermission "modifyPrivateCredentials";
+ permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosKey * \"*\"", "read";
+ permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KeyTab * \"*\"", "read";
+ permission javax.security.auth.PrivateCredentialPermission "javax.security.auth.kerberos.KerberosTicket * \"*\"", "read";
+ permission javax.security.auth.AuthPermission "doAs";
+ permission javax.security.auth.kerberos.ServicePermission "*","initiate,accept";
+
+ permission java.util.PropertyPermission "javax.security.auth.useSubjectCredsOnly","write";
+ permission java.util.PropertyPermission "java.security.krb5.conf","write";
+ permission java.util.PropertyPermission "sun.security.krb5.debug","write";
+ permission java.util.PropertyPermission "java.security.debug","write";
+ permission java.util.PropertyPermission "sun.security.spnego.debug","write";
};
grant codeBase "${codebase.xmlsec-2.0.8.jar}" {
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java
new file mode 100644
index 0000000000000..eaba796b41fe4
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationTokenTests.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.security.authc.kerberos;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.EqualsHashCodeTestUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.notNullValue;
+
+public class KerberosAuthenticationTokenTests extends ESTestCase {
+
+ private static final String UNAUTHENTICATED_PRINCIPAL_NAME = "";
+
+ public void testExtractTokenForValidAuthorizationHeader() throws IOException {
+ final String base64Token = Base64.getEncoder().encodeToString(randomAlphaOfLength(5).getBytes(StandardCharsets.UTF_8));
+ final String negotiate = randomBoolean() ? KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX : "negotiate ";
+ final String authzHeader = negotiate + base64Token;
+
+ final KerberosAuthenticationToken kerbAuthnToken = KerberosAuthenticationToken.extractToken(authzHeader);
+ assertNotNull(kerbAuthnToken);
+ assertEquals(UNAUTHENTICATED_PRINCIPAL_NAME, kerbAuthnToken.principal());
+ assertThat(kerbAuthnToken.credentials(), instanceOf((byte[].class)));
+ assertArrayEquals(Base64.getDecoder().decode(base64Token), (byte[]) kerbAuthnToken.credentials());
+ }
+
+ public void testExtractTokenForInvalidNegotiateAuthorizationHeaderShouldReturnNull() throws IOException {
+ final String header = randomFrom("negotiate", "Negotiate", " Negotiate", "NegotiateToken", "Basic ", " Custom ", null);
+ assertNull(KerberosAuthenticationToken.extractToken(header));
+ }
+
+ public void testExtractTokenForNegotiateAuthorizationHeaderWithNoTokenShouldThrowException() throws IOException {
+ final String header = randomFrom(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX, "negotiate ", "Negotiate ");
+ final ElasticsearchSecurityException e =
+ expectThrows(ElasticsearchSecurityException.class, () -> KerberosAuthenticationToken.extractToken(header));
+ assertThat(e.getMessage(),
+ equalTo("invalid negotiate authentication header value, expected base64 encoded token but value is empty"));
+ assertContainsAuthenticateHeader(e);
+ }
+
+ public void testExtractTokenForNotBase64EncodedTokenThrowsException() throws IOException {
+ final String notBase64Token = "[B@6499375d";
+
+ final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class,
+ () -> KerberosAuthenticationToken.extractToken(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + notBase64Token));
+ assertThat(e.getMessage(),
+ equalTo("invalid negotiate authentication header value, could not decode base64 token " + notBase64Token));
+ assertContainsAuthenticateHeader(e);
+ }
+
+ public void testKerberoAuthenticationTokenClearCredentials() {
+ byte[] inputBytes = randomByteArrayOfLength(5);
+ final String base64Token = Base64.getEncoder().encodeToString(inputBytes);
+ final KerberosAuthenticationToken kerbAuthnToken =
+ KerberosAuthenticationToken.extractToken(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX + base64Token);
+ kerbAuthnToken.clearCredentials();
+ Arrays.fill(inputBytes, (byte) 0);
+ assertArrayEquals(inputBytes, (byte[]) kerbAuthnToken.credentials());
+ }
+
+ public void testEqualsHashCode() {
+ final KerberosAuthenticationToken kerberosAuthenticationToken =
+ new KerberosAuthenticationToken("base64EncodedToken".getBytes(StandardCharsets.UTF_8));
+ EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> {
+ return new KerberosAuthenticationToken((byte[]) original.credentials());
+ });
+ EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> {
+ byte[] originalCreds = (byte[]) original.credentials();
+ return new KerberosAuthenticationToken(Arrays.copyOf(originalCreds, originalCreds.length));
+ });
+ EqualsHashCodeTestUtils.checkEqualsAndHashCode(kerberosAuthenticationToken, (original) -> {
+ return new KerberosAuthenticationToken((byte[]) original.credentials());
+ }, KerberosAuthenticationTokenTests::mutateTestItem);
+ }
+
+ private static KerberosAuthenticationToken mutateTestItem(KerberosAuthenticationToken original) {
+ switch (randomIntBetween(0, 2)) {
+ case 0:
+ return new KerberosAuthenticationToken(randomByteArrayOfLength(10));
+ case 1:
+ return new KerberosAuthenticationToken("base64EncodedToken".getBytes(StandardCharsets.UTF_16));
+ case 2:
+ return new KerberosAuthenticationToken("[B@6499375d".getBytes(StandardCharsets.UTF_8));
+ default:
+ throw new IllegalArgumentException("unknown option");
+ }
+ }
+
+ private static void assertContainsAuthenticateHeader(ElasticsearchSecurityException e) {
+ assertThat(e.status(), is(RestStatus.UNAUTHORIZED));
+ assertThat(e.getHeaderKeys(), hasSize(1));
+ assertThat(e.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE), notNullValue());
+ assertThat(e.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE), contains(KerberosAuthenticationToken.NEGOTIATE_SCHEME_NAME));
+ }
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java
new file mode 100644
index 0000000000000..876ebdc574136
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmSettingsTests.java
@@ -0,0 +1,43 @@
+/*
+ * 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 org.elasticsearch.xpack.security.authc.kerberos.support.KerberosTestCase;
+
+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 Settings settings = KerberosTestCase.buildKerberosRealmSettings(keytabPathConfig, maxUsers, cacheTTL, true);
+
+ 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(true));
+ }
+
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTestCase.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTestCase.java
new file mode 100644
index 0000000000000..9a9453a8064bd
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTestCase.java
@@ -0,0 +1,218 @@
+/*
+ * 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.support;
+
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.common.Randomness;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.logging.Loggers;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import javax.security.auth.Subject;
+
+/**
+ * Base Test class for Kerberos.
+ *
+ * Takes care of starting {@link SimpleKdcLdapServer} as Kdc server backed by
+ * Ldap Server.
+ *
+ * Also assists in building principal names, creation of principals and realm
+ * settings.
+ */
+public abstract class KerberosTestCase extends ESTestCase {
+
+ protected Settings globalSettings;
+ protected Settings settings;
+ protected List serviceUserNames;
+ protected List clientUserNames;
+ protected Path workDir = null;
+
+ protected SimpleKdcLdapServer simpleKdcLdapServer;
+
+ private static Locale restoreLocale;
+ private static Set unsupportedLocaleLanguages;
+ static {
+ unsupportedLocaleLanguages = new HashSet<>();
+ /*
+ * arabic and other languages have problem due to handling of GeneralizedTime in
+ * SimpleKdcServer For more look at :
+ * org.apache.kerby.asn1.type.Asn1GeneralizedTime#toBytes()
+ */
+ unsupportedLocaleLanguages.add("ar");
+ unsupportedLocaleLanguages.add("ja");
+ unsupportedLocaleLanguages.add("th");
+ unsupportedLocaleLanguages.add("hi");
+ }
+
+ @BeforeClass
+ public static void setupKerberos() throws Exception {
+ if (isLocaleUnsupported()) {
+ Logger logger = Loggers.getLogger(KerberosTestCase.class);
+ logger.warn("Attempting to run Kerberos test on {} locale, but that breaks SimpleKdcServer. Switching to English.",
+ Locale.getDefault());
+ restoreLocale = Locale.getDefault();
+ Locale.setDefault(Locale.ENGLISH);
+ }
+ }
+
+ @AfterClass
+ public static void restoreLocale() throws Exception {
+ if (restoreLocale != null) {
+ Locale.setDefault(restoreLocale);
+ restoreLocale = null;
+ }
+ }
+
+ private static boolean isLocaleUnsupported() {
+ return unsupportedLocaleLanguages.contains(Locale.getDefault().getLanguage());
+ }
+
+ @Before
+ public void startSimpleKdcLdapServer() throws Exception {
+ workDir = createTempDir();
+ globalSettings = Settings.builder().put("path.home", workDir).build();
+
+ final Path kdcLdiff = getDataPath("/kdc.ldiff");
+ simpleKdcLdapServer = new SimpleKdcLdapServer(workDir, "com", "example", kdcLdiff);
+
+ // Create SPNs and UPNs
+ serviceUserNames = new ArrayList<>();
+ Randomness.get().ints(randomIntBetween(1, 6)).forEach((i) -> {
+ serviceUserNames.add("HTTP/" + randomAlphaOfLength(8));
+ });
+ final Path ktabPathForService = createPrincipalKeyTab(workDir, serviceUserNames.toArray(new String[0]));
+ clientUserNames = new ArrayList<>();
+ Randomness.get().ints(randomIntBetween(1, 6)).forEach((i) -> {
+ String clientUserName = "client-" + randomAlphaOfLength(8);
+ clientUserNames.add(clientUserName);
+ try {
+ createPrincipal(clientUserName, "pwd".toCharArray());
+ } catch (Exception e) {
+ throw ExceptionsHelper.convertToRuntime(e);
+ }
+ });
+ settings = buildKerberosRealmSettings(ktabPathForService.toString());
+ }
+
+ @After
+ public void tearDownMiniKdc() throws IOException, PrivilegedActionException {
+ simpleKdcLdapServer.stop();
+ }
+
+ /**
+ * Creates principals and exports them to the keytab created in the directory.
+ *
+ * @param dir Directory where the key tab would be created.
+ * @param princNames principal names to be created
+ * @return {@link Path} to key tab file.
+ * @throws Exception
+ */
+ protected Path createPrincipalKeyTab(final Path dir, final String... princNames) throws Exception {
+ final Path path = dir.resolve(randomAlphaOfLength(10) + ".keytab");
+ simpleKdcLdapServer.createPrincipal(path, princNames);
+ return path;
+ }
+
+ /**
+ * Creates principal with given name and password.
+ *
+ * @param principalName Principal name
+ * @param password Password
+ * @throws Exception
+ */
+ protected void createPrincipal(final String principalName, final char[] password) throws Exception {
+ simpleKdcLdapServer.createPrincipal(principalName, new String(password));
+ }
+
+ /**
+ * Appends realm name to user to form principal name
+ *
+ * @param user user name
+ * @return principal name in the form user@REALM
+ */
+ protected String principalName(final String user) {
+ return user + "@" + simpleKdcLdapServer.getRealm();
+ }
+
+ /**
+ * Invokes Subject.doAs inside a doPrivileged block
+ *
+ * @param subject {@link Subject}
+ * @param action {@link PrivilegedExceptionAction} action for performing inside
+ * Subject.doAs
+ * @return Type of value as returned by PrivilegedAction
+ * @throws PrivilegedActionException
+ */
+ static T doAsWrapper(final Subject subject, final PrivilegedExceptionAction action) throws PrivilegedActionException {
+ return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAs(subject, action));
+ }
+
+ /**
+ * Write content to provided keytab file.
+ *
+ * @param keytabPath {@link Path} to keytab file.
+ * @param content Content for keytab
+ * @return key tab path
+ * @throws IOException
+ */
+ public static Path writeKeyTab(final Path keytabPath, final String content) throws IOException {
+ try (BufferedWriter bufferedWriter = Files.newBufferedWriter(keytabPath, StandardCharsets.US_ASCII)) {
+ bufferedWriter.write(Strings.isNullOrEmpty(content) ? "test-content" : content);
+ }
+ return keytabPath;
+ }
+
+ /**
+ * Build kerberos realm settings with default config and given keytab
+ *
+ * @param keytabPath key tab file path
+ * @return {@link Settings} for kerberos realm
+ */
+ public static Settings buildKerberosRealmSettings(final String keytabPath) {
+ return buildKerberosRealmSettings(keytabPath, 100, "10m", true);
+ }
+
+ /**
+ * 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
+ * @return {@link Settings} for kerberos realm
+ */
+ public static Settings buildKerberosRealmSettings(final String keytabPath, final int maxUsersInCache, final String cacheTTL,
+ final boolean enableDebugging) {
+ 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);
+ return builder.build();
+ }
+
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidatorTests.java
new file mode 100644
index 0000000000000..602bb1265fe11
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/KerberosTicketValidatorTests.java
@@ -0,0 +1,101 @@
+/*
+ * 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.support;
+
+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.ietf.jgss.GSSException;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.PrivilegedActionException;
+import java.util.Base64;
+
+import javax.security.auth.login.LoginException;
+
+import static org.hamcrest.Matchers.equalTo;
+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 GSSException gssException = expectThrows(GSSException.class,
+ () -> kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true));
+ 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));
+ final GSSException gssException = expectThrows(GSSException.class,
+ () -> kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true));
+ assertThat(gssException.getMajor(), equalTo(GSSException.DEFECTIVE_TOKEN));
+ }
+
+ 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 GSSException gssException = expectThrows(GSSException.class,
+ () -> kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true));
+ 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 Tuple userNameOutToken =
+ kerberosTicketValidator.validateTicket(Base64.getDecoder().decode(base64KerbToken), keytabPath, true);
+ assertThat(userNameOutToken, is(notNullValue()));
+ assertThat(userNameOutToken.v1(), equalTo(principalName(clientUserName)));
+ assertThat(userNameOutToken.v2(), is(notNullValue()));
+
+ final String outToken = spnegoClient.handleResponse(userNameOutToken.v2());
+ assertThat(outToken, is(nullValue()));
+ assertThat(spnegoClient.isEstablished(), is(true));
+ }
+ }
+
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServer.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServer.java
new file mode 100644
index 0000000000000..02bc236b8ec3d
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServer.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.security.authc.kerberos.support;
+
+import com.unboundid.ldap.listener.InMemoryDirectoryServer;
+import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
+
+import org.apache.kerby.kerberos.kerb.KrbException;
+import org.apache.kerby.kerberos.kerb.client.KrbConfig;
+import org.apache.kerby.kerberos.kerb.server.KdcConfigKey;
+import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer;
+import org.apache.kerby.util.NetworkUtil;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.common.SuppressForbidden;
+import org.elasticsearch.common.logging.Loggers;
+import org.elasticsearch.common.unit.TimeValue;
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.AccessController;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Utility wrapper around Apache {@link SimpleKdcServer} backed by Unboundid
+ * {@link InMemoryDirectoryServer}.
+ * Starts in memory Ldap server and then uses it as backend for Kdc Server.
+ */
+public class SimpleKdcLdapServer {
+ private static final Logger logger = Loggers.getLogger(SimpleKdcLdapServer.class);
+
+ private Path workDir = null;
+ private SimpleKdcServer simpleKdc;
+ private InMemoryDirectoryServer ldapServer;
+
+ // KDC properties
+ private String transport = ESTestCase.randomFrom("TCP", "UDP");
+ private int kdcPort = 0;
+ private String host;
+ private String realm;
+ private boolean krb5DebugBackupConfigValue;
+
+ // LDAP properties
+ private String baseDn;
+ private Path ldiff;
+ private int ldapPort;
+
+ /**
+ * Constructor for SimpleKdcLdapServer, creates instance of Kdc server and ldap
+ * backend server. Also initializes and starts them with provided configuration.
+ *
+ * To stop the KDC and ldap server use {@link #stop()}
+ *
+ * @param workDir Base directory for server, used to locate kdc.conf,
+ * backend.conf and kdc.ldiff
+ * @param orgName Org name for base dn
+ * @param domainName domain name for base dn
+ * @param ldiff for ldap directory.
+ * @throws Exception
+ */
+ public SimpleKdcLdapServer(final Path workDir, final String orgName, final String domainName, final Path ldiff) throws Exception {
+ this.workDir = workDir;
+ this.realm = domainName.toUpperCase(Locale.ROOT) + "." + orgName.toUpperCase(Locale.ROOT);
+ this.baseDn = "dc=" + domainName + ",dc=" + orgName;
+ this.ldiff = ldiff;
+ this.krb5DebugBackupConfigValue = AccessController.doPrivileged(new PrivilegedExceptionAction() {
+ @Override
+ @SuppressForbidden(reason = "set or clear system property krb5 debug in kerberos tests")
+ public Boolean run() throws Exception {
+ boolean oldDebugSetting = Boolean.parseBoolean(System.getProperty("sun.security.krb5.debug"));
+ System.setProperty("sun.security.krb5.debug", Boolean.TRUE.toString());
+ return oldDebugSetting;
+ }
+ });
+
+ AccessController.doPrivileged(new PrivilegedExceptionAction() {
+ @Override
+ public Void run() throws Exception {
+ init();
+ return null;
+ }
+ });
+ logger.info("SimpleKdcLdapServer started.");
+ }
+
+ @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer")
+ private void init() throws Exception {
+ // start ldap server
+ createLdapServiceAndStart();
+ // create ldap backend conf
+ createLdapBackendConf();
+ // Kdc Server
+ simpleKdc = new SimpleKdcServer(this.workDir.toFile(), new KrbConfig());
+ prepareKdcServerAndStart();
+ }
+
+ private void createLdapServiceAndStart() throws Exception {
+ InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(baseDn);
+ config.setSchema(null);
+ ldapServer = new InMemoryDirectoryServer(config);
+ ldapServer.importFromLDIF(true, this.ldiff.toString());
+ ldapServer.startListening();
+ ldapPort = ldapServer.getListenPort();
+ }
+
+ private void createLdapBackendConf() throws IOException {
+ String backendConf = KdcConfigKey.KDC_IDENTITY_BACKEND.getPropertyKey()
+ + " = org.apache.kerby.kerberos.kdc.identitybackend.LdapIdentityBackend\n" + "host=127.0.0.1\n" + "port=" + ldapPort + "\n"
+ + "admin_dn=uid=admin,ou=system," + baseDn + "\n" + "admin_pw=secret\n" + "base_dn=" + baseDn;
+ Files.write(this.workDir.resolve("backend.conf"), backendConf.getBytes(StandardCharsets.UTF_8));
+ assert Files.exists(this.workDir.resolve("backend.conf"));
+ }
+
+ @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer")
+ private void prepareKdcServerAndStart() throws Exception {
+ // transport
+ simpleKdc.setWorkDir(workDir.toFile());
+ simpleKdc.setKdcHost(host);
+ simpleKdc.setKdcRealm(realm);
+ if (kdcPort == 0) {
+ kdcPort = NetworkUtil.getServerPort();
+ }
+ if (transport != null) {
+ if (transport.trim().equals("TCP")) {
+ simpleKdc.setKdcTcpPort(kdcPort);
+ simpleKdc.setAllowUdp(false);
+ } else if (transport.trim().equals("UDP")) {
+ simpleKdc.setKdcUdpPort(kdcPort);
+ simpleKdc.setAllowTcp(false);
+ } else {
+ throw new IllegalArgumentException("Invalid transport: " + transport);
+ }
+ } else {
+ throw new IllegalArgumentException("Need to set transport!");
+ }
+ final TimeValue minimumTicketLifeTime = new TimeValue(1, TimeUnit.DAYS);
+ final TimeValue maxRenewableLifeTime = new TimeValue(7, TimeUnit.DAYS);
+ simpleKdc.getKdcConfig().setLong(KdcConfigKey.MINIMUM_TICKET_LIFETIME, minimumTicketLifeTime.getMillis());
+ simpleKdc.getKdcConfig().setLong(KdcConfigKey.MAXIMUM_RENEWABLE_LIFETIME, maxRenewableLifeTime.getMillis());
+ simpleKdc.init();
+ simpleKdc.start();
+ }
+
+ public String getRealm() {
+ return realm;
+ }
+
+ public int getLdapListenPort() {
+ return ldapPort;
+ }
+
+ public int getKdcPort() {
+ return kdcPort;
+ }
+
+ /**
+ * Creates a principal in the KDC with the specified user and password.
+ *
+ * @param principal principal name, do not include the domain.
+ * @param password password.
+ * @throws Exception thrown if the principal could not be created.
+ */
+ public synchronized void createPrincipal(final String principal, final String password) throws Exception {
+ simpleKdc.createPrincipal(principal, password);
+ }
+
+ /**
+ * Creates multiple principals in the KDC and adds them to a keytab file.
+ *
+ * @param keytabFile keytab file to add the created principals. If keytab file
+ * exists and then always appends to it.
+ * @param principals principals to add to the KDC, do not include the domain.
+ * @throws Exception thrown if the principals or the keytab file could not be
+ * created.
+ */
+ @SuppressForbidden(reason = "Uses Apache Kdc which requires usage of java.io.File in order to create a SimpleKdcServer")
+ public synchronized void createPrincipal(final Path keytabFile, final String... principals) throws Exception {
+ simpleKdc.createPrincipals(principals);
+ for (String principal : principals) {
+ simpleKdc.getKadmin().exportKeytab(keytabFile.toFile(), principal);
+ }
+ }
+
+ /**
+ * Stop Simple Kdc Server
+ *
+ * @throws PrivilegedActionException
+ */
+ public synchronized void stop() throws PrivilegedActionException {
+ AccessController.doPrivileged(new PrivilegedExceptionAction() {
+
+ @Override
+ @SuppressForbidden(reason = "set or clear system property krb5 debug in kerberos tests")
+ public Void run() throws Exception {
+ if (simpleKdc != null) {
+ try {
+ simpleKdc.stop();
+ } catch (KrbException e) {
+ throw ExceptionsHelper.convertToRuntime(e);
+ } finally {
+ System.setProperty("sun.security.krb5.debug", Boolean.toString(krb5DebugBackupConfigValue));
+ }
+ }
+
+ if (ldapServer != null) {
+ ldapServer.shutDown(true);
+ }
+ return null;
+ }
+ });
+ logger.info("SimpleKdcServer stoppped.");
+ }
+
+}
\ No newline at end of file
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServerTests.java
new file mode 100644
index 0000000000000..c82d4e9502a4f
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SimpleKdcLdapServerTests.java
@@ -0,0 +1,75 @@
+/*
+ * 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.support;
+
+import com.unboundid.ldap.sdk.LDAPConnection;
+import com.unboundid.ldap.sdk.SearchResult;
+import com.unboundid.ldap.sdk.SearchScope;
+
+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.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 Tuple userNameOutToken =
+ new KerberosTicketValidator().validateTicket((byte[]) kerbAuthnToken.credentials(), keytabPath, true);
+ assertThat(userNameOutToken, is(notNullValue()));
+ assertThat(userNameOutToken.v1(), equalTo(principalName(clientUserName)));
+
+ // Authenticate service on client side.
+ final String outToken = spnegoClient.handleResponse(userNameOutToken.v2());
+ assertThat(outToken, is(nullValue()));
+ assertThat(spnegoClient.isEstablished(), is(true));
+ }
+ }
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SpnegoClient.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SpnegoClient.java
new file mode 100644
index 0000000000000..527953f8b2d46
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/support/SpnegoClient.java
@@ -0,0 +1,258 @@
+/*
+ * 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.support;
+
+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.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+
+import java.io.IOException;
+import java.security.AccessController;
+import java.security.Principal;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.kerberos.KerberosPrincipal;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+
+/**
+ * This class is used as a Spnego client during testing and handles SPNEGO
+ * interactions using GSS context negotiation.
+ * It is not advisable to share a SpnegoClient between threads as there is no
+ * synchronization in place, internally this depends on {@link GSSContext} for
+ * context negotiation which maintains sequencing for replay detections.
+ * Use {@link #close()} to release and dispose {@link LoginContext} and
+ * {@link GSSContext} after usage.
+ */
+class SpnegoClient implements AutoCloseable {
+ private static final Logger LOGGER = ESLoggerFactory.getLogger(SpnegoClient.class);
+
+ public static final String CRED_CONF_NAME = "PasswordConf";
+ private static final String SUN_KRB5_LOGIN_MODULE = "com.sun.security.auth.module.Krb5LoginModule";
+ private final GSSManager gssManager = GSSManager.getInstance();
+ private final LoginContext loginContext;
+ private final GSSContext gssContext;
+
+ /**
+ * Creates SpengoClient to interact with given service principal
+ * Use {@link #close()} to logout {@link LoginContext} and dispose
+ * {@link GSSContext} after usage.
+ * @param userPrincipalName User principal name for login as client
+ * @param password password for client
+ * @param servicePrincipalName Service principal name with whom this client
+ * interacts with.
+ * @throws PrivilegedActionException
+ * @throws GSSException
+ */
+ SpnegoClient(final String userPrincipalName, final SecureString password, final String servicePrincipalName)
+ throws PrivilegedActionException, GSSException {
+ String oldUseSubjectCredsOnlyFlag = null;
+ try {
+ oldUseSubjectCredsOnlyFlag = getAndSetUseSubjectCredsOnlySystemProperty("true");
+ LOGGER.info("SpnegoClient with userPrincipalName : {}", userPrincipalName);
+ final GSSName gssUserPrincipalName = gssManager.createName(userPrincipalName, GSSName.NT_USER_NAME);
+ final GSSName gssServicePrincipalName = gssManager.createName(servicePrincipalName, GSSName.NT_USER_NAME);
+ loginContext = AccessController
+ .doPrivileged((PrivilegedExceptionAction) () -> loginUsingPassword(userPrincipalName, password));
+ final GSSCredential userCreds = KerberosTestCase.doAsWrapper(loginContext.getSubject(),
+ (PrivilegedExceptionAction) () -> gssManager.createCredential(gssUserPrincipalName,
+ GSSCredential.DEFAULT_LIFETIME, KerberosTicketValidator.SPNEGO_OID, GSSCredential.INITIATE_ONLY));
+ gssContext = gssManager.createContext(gssServicePrincipalName.canonicalize(KerberosTicketValidator.SPNEGO_OID),
+ KerberosTicketValidator.SPNEGO_OID, userCreds, GSSCredential.DEFAULT_LIFETIME);
+ gssContext.requestMutualAuth(true);
+ } catch (PrivilegedActionException pve) {
+ LOGGER.error("privileged action exception, with root cause", pve.getException());
+ throw pve;
+ } finally {
+ getAndSetUseSubjectCredsOnlySystemProperty(oldUseSubjectCredsOnlyFlag);
+ }
+ }
+
+ /**
+ * GSSContext initiator side handling, initiates context establishment and returns the
+ * base64 encoded token to be sent to server.
+ *
+ * @return Base64 encoded token
+ * @throws PrivilegedActionException
+ */
+ String getBase64EncodedTokenForSpnegoHeader() throws PrivilegedActionException {
+ final byte[] outToken = KerberosTestCase.doAsWrapper(loginContext.getSubject(),
+ (PrivilegedExceptionAction) () -> gssContext.initSecContext(new byte[0], 0, 0));
+ return Base64.getEncoder().encodeToString(outToken);
+ }
+
+ /**
+ * Handles server response and returns new token if any to be sent to server.
+ *
+ * @param base64Token inToken received from server passed to initSecContext for
+ * gss negotiation
+ * @return Base64 encoded token to be sent to server. May return {@code null} if
+ * nothing to be sent.
+ * @throws PrivilegedActionException
+ */
+ String handleResponse(final String base64Token) throws PrivilegedActionException {
+ if (gssContext.isEstablished()) {
+ throw new IllegalStateException("GSS Context has already been established");
+ }
+ final byte[] token = Base64.getDecoder().decode(base64Token);
+ final byte[] outToken = KerberosTestCase.doAsWrapper(loginContext.getSubject(),
+ (PrivilegedExceptionAction) () -> gssContext.initSecContext(token, 0, token.length));
+ if (outToken == null || outToken.length == 0) {
+ return null;
+ }
+ return Base64.getEncoder().encodeToString(outToken);
+ }
+
+ /**
+ * Spnego Client after usage needs to be closed in order to logout from
+ * {@link LoginContext} and dispose {@link GSSContext}
+ */
+ public void close() throws LoginException, GSSException, PrivilegedActionException {
+ if (loginContext != null) {
+ AccessController.doPrivileged((PrivilegedExceptionAction) () -> {
+ loginContext.logout();
+ return null;
+ });
+ }
+ if (gssContext != null) {
+ AccessController.doPrivileged((PrivilegedExceptionAction) () -> {
+ gssContext.dispose();
+ return null;
+ });
+ }
+ }
+
+ /**
+ * @return {@code true} If the context was established
+ */
+ boolean isEstablished() {
+ return gssContext.isEstablished();
+ }
+
+ /**
+ * Performs authentication using provided principal name and password for client
+ *
+ * @param principal Principal name
+ * @param password {@link SecureString}
+ * @param settings {@link Settings}
+ * @return authenticated {@link LoginContext} instance. Note: This needs to be
+ * closed {@link LoginContext#logout()} after usage.
+ * @throws LoginException
+ */
+ private static LoginContext loginUsingPassword(final String principal, final SecureString password) throws LoginException {
+ final Set principals = Collections.singleton(new KerberosPrincipal(principal));
+
+ final Subject subject = new Subject(false, principals, Collections.emptySet(), Collections.emptySet());
+
+ final Configuration conf = new PasswordJaasConf(principal);
+ final CallbackHandler callback = new KrbCallbackHandler(principal, password);
+ final LoginContext loginContext = new LoginContext(CRED_CONF_NAME, subject, callback, conf);
+ loginContext.login();
+ return loginContext;
+ }
+
+ /**
+ * Usually we would have a JAAS configuration file for login configuration.
+ * Instead of an additional file setting as we do not want the options to be
+ * customizable we are constructing it in memory.
+ *
+ * As we are uing this instead of jaas.conf, this requires refresh of
+ * {@link Configuration} and reqires appropriate security permissions to do so.
+ */
+ static class PasswordJaasConf extends Configuration {
+ private final String principal;
+
+ PasswordJaasConf(final String principal) {
+ this.principal = principal;
+ }
+
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(final String name) {
+ final Map options = new HashMap<>();
+ options.put("principal", principal);
+ options.put("storeKey", Boolean.TRUE.toString());
+ options.put("useTicketCache", Boolean.FALSE.toString());
+ options.put("useKeyTab", Boolean.FALSE.toString());
+ options.put("renewTGT", Boolean.FALSE.toString());
+ options.put("refreshKrb5Config", Boolean.TRUE.toString());
+ options.put("isInitiator", Boolean.TRUE.toString());
+ options.put("debug", Boolean.TRUE.toString());
+
+ return new AppConfigurationEntry[] { new AppConfigurationEntry(SUN_KRB5_LOGIN_MODULE,
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, Collections.unmodifiableMap(options)) };
+ }
+ }
+
+ /**
+ * Jaas call back handler to provide credentials.
+ */
+ static class KrbCallbackHandler implements CallbackHandler {
+ private final String principal;
+ private final SecureString password;
+
+ KrbCallbackHandler(final String principal, final SecureString password) {
+ this.principal = principal;
+ this.password = password;
+ }
+
+ public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+ for (Callback callback : callbacks) {
+ if (callback instanceof PasswordCallback) {
+ PasswordCallback pc = (PasswordCallback) callback;
+ if (pc.getPrompt().contains(principal)) {
+ pc.setPassword(password.getChars());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ private static String getAndSetUseSubjectCredsOnlySystemProperty(final String value) {
+ String retVal = null;
+ try {
+ retVal = AccessController.doPrivileged(new PrivilegedExceptionAction() {
+
+ @Override
+ @SuppressForbidden(
+ reason = "For tests where we provide credentials, need to set and reset javax.security.auth.useSubjectCredsOnly")
+ public String run() throws Exception {
+ String oldValue = System.getProperty("javax.security.auth.useSubjectCredsOnly");
+ if (value != null) {
+ System.setProperty("javax.security.auth.useSubjectCredsOnly", value);
+ }
+ return oldValue;
+ }
+
+ });
+ } catch (PrivilegedActionException e) {
+ throw ExceptionsHelper.convertToRuntime(e);
+ }
+ return retVal;
+ }
+}
diff --git a/x-pack/plugin/security/src/test/resources/kdc.ldiff b/x-pack/plugin/security/src/test/resources/kdc.ldiff
new file mode 100644
index 0000000000000..e213048d6f578
--- /dev/null
+++ b/x-pack/plugin/security/src/test/resources/kdc.ldiff
@@ -0,0 +1,23 @@
+dn: dc=example,dc=com
+objectClass: top
+objectClass: domain
+dc: example
+
+dn: ou=system,dc=example,dc=com
+objectClass: organizationalUnit
+objectClass: top
+ou: system
+
+dn: ou=users,dc=example,dc=com
+objectClass: organizationalUnit
+objectClass: top
+ou: users
+
+dn: uid=admin,ou=system,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: inetOrgPerson
+cn: Admin
+sn: Admin
+uid: admin
+userPassword: secret
\ No newline at end of file
From 88999208e7237d3d3e08d0dbd794d7db083272d9 Mon Sep 17 00:00:00 2001
From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com>
Date: Wed, 27 Jun 2018 10:18:32 +1000
Subject: [PATCH 2/9] [Kerberos] Add bootstrap checks for kerberos realm
(#31548)
As there are some system properties like `java.security.krb5.kdc`
, `java.security.krb5.realm` which can specify values that are
applicable to whole JVM. This is the reason for having only one
instance of Kerberos realm.
Each ES node will have a Kerberos keytab with credentials. This
keytab must exist for Kerberos authentication to work.
`KerberosRealmBootstrapCheck` performs these checks for given
configuration.
---
.../xpack/security/Security.java | 4 +-
.../kerberos/KerberosRealmBootstrapCheck.java | 69 +++++++++++
.../KerberosRealmBootstrapCheckTests.java | 115 ++++++++++++++++++
3 files changed, 187 insertions(+), 1 deletion(-)
create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java
create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
index dbb50a92f1088..88d9e1dfc9d13 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
@@ -167,6 +167,7 @@
import org.elasticsearch.xpack.security.authc.TokenService;
import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm;
+import org.elasticsearch.xpack.security.authc.kerberos.KerberosRealmBootstrapCheck;
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
import org.elasticsearch.xpack.security.authz.AuthorizationService;
import org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener;
@@ -285,7 +286,8 @@ public Security(Settings settings, final Path configPath) {
checks.addAll(Arrays.asList(
new TokenSSLBootstrapCheck(),
new PkiRealmBootstrapCheck(settings, getSslService()),
- new TLSLicenseBootstrapCheck()));
+ new TLSLicenseBootstrapCheck(),
+ new KerberosRealmBootstrapCheck(env)));
checks.addAll(InternalRealms.getBootstrapChecks(settings, env));
this.bootstrapChecks = Collections.unmodifiableList(checks);
} else {
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java
new file mode 100644
index 0000000000000..bab899a866425
--- /dev/null
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheck.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.security.authc.kerberos;
+
+import org.elasticsearch.bootstrap.BootstrapCheck;
+import org.elasticsearch.bootstrap.BootstrapContext;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.xpack.core.security.authc.RealmSettings;
+import org.elasticsearch.xpack.core.security.authc.kerberos.KerberosRealmSettings;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/**
+ * This class is used to perform bootstrap checks for kerberos realm.
+ *
+ * We use service keytabs for validating incoming kerberos tickets and is a
+ * required configuration. Due to JVM wide system properties for Kerberos we
+ * cannot support multiple Kerberos realms. This class adds checks for node to
+ * fail if service keytab does not exist or multiple kerberos realms have been
+ * configured.
+ */
+public class KerberosRealmBootstrapCheck implements BootstrapCheck {
+ private final Environment env;
+
+ public KerberosRealmBootstrapCheck(final Environment env) {
+ this.env = env;
+ }
+
+ @Override
+ public BootstrapCheckResult check(final BootstrapContext context) {
+ final Map realmsSettings = RealmSettings.getRealmSettings(context.settings);
+ boolean isKerberosRealmConfigured = false;
+ for (final Entry entry : realmsSettings.entrySet()) {
+ final String name = entry.getKey();
+ final Settings realmSettings = entry.getValue();
+ final String type = realmSettings.get("type");
+ if (Strings.hasText(type) == false) {
+ return BootstrapCheckResult.failure("missing realm type for [" + name + "] realm");
+ }
+ if (KerberosRealmSettings.TYPE.equals(type)) {
+ if (isKerberosRealmConfigured) {
+ return BootstrapCheckResult.failure(
+ "multiple [" + type + "] realms are configured. [" + type + "] can only have one such realm configured");
+ }
+ isKerberosRealmConfigured = true;
+
+ final Path keytabPath = env.configFile().resolve(KerberosRealmSettings.HTTP_SERVICE_KEYTAB_PATH.get(realmSettings));
+ if (Files.exists(keytabPath) == false) {
+ return BootstrapCheckResult.failure("configured service key tab file [" + keytabPath + "] does not exist");
+ }
+ }
+ }
+ return BootstrapCheckResult.success();
+ }
+
+ @Override
+ public boolean alwaysEnforce() {
+ return true;
+ }
+}
diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java
new file mode 100644
index 0000000000000..d2a40f0f6162f
--- /dev/null
+++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosRealmBootstrapCheckTests.java
@@ -0,0 +1,115 @@
+/*
+ * 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 org.elasticsearch.xpack.security.authc.kerberos.support.KerberosTestCase;
+
+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;
+ }
+}
From 52367f271e6522b2e5ea582ed618741ac2431b22 Mon Sep 17 00:00:00 2001
From: Yogesh Gaikwad <902768+bizybot@users.noreply.github.com>
Date: Tue, 3 Jul 2018 10:55:28 +1000
Subject: [PATCH 3/9] [Kerberos] Add support for list of auth challenge
(#31594)
Till now we had support for 'Basic', 'Bearer' auth schemes and
this was sufficient for us to reply `WWW-Authenticate` header
with one value either for `Basic` or `Bearer` for unauthorized
access.
After introducing Kerberos we will be supporting `Negotiate` scheme.
As per [RFC7235](https://tools.ietf.org/html/rfc7235#section-4.1),
we may respond with the list of challenges. This list is of auth
schemes supported by the server. We can also have custom Realms
defining their own response header value for 'WWW-Authenticate'
header. This commit introduces a `getWWWAuthenticateHeaderValue`
in `Realm` to identify the scheme which it wants to use. By default
it uses 'Basic' auth scheme. This can be overriden
by realms like KerberosRealm to specify 'Negotiate' scheme or OAuth
to specify 'Bearer' or custom realms added by security extensions to
specify their own scheme.
SAML specifications do not specify anything related to the header but
unofficially many have used 'SAML' as auth scheme or used 'Bearer'
auth scheme for passing SAML tokens.
But most of the realms would use the existing schemes
like 'Basic', 'Digest', 'Bearer', 'Negotiate' etc.
At the startup, `Security#createComponents` will take care of
creating `DefaultAuthenticationFailureHandler` with default
response header values for 'WWW-Authenticate' as a list of configured
and enabled auth schemes.
---
.../DefaultAuthenticationFailureHandler.java | 95 +++++++++++++----
.../xpack/core/security/authc/Realm.java | 15 +++
...aultAuthenticationFailureHandlerTests.java | 100 ++++++++++++++++++
.../xpack/security/Security.java | 58 +++++++---
.../kerberos/support/KerberosTestCase.java | 2 +
5 files changed, 230 insertions(+), 40 deletions(-)
create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java
index 8b31e77f9f8b7..f547a9cf2072f 100644
--- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandler.java
@@ -10,60 +10,109 @@
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.transport.TransportMessage;
+import org.elasticsearch.xpack.core.XPackField;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import static org.elasticsearch.xpack.core.security.support.Exceptions.authenticationError;
/**
- * The default implementation of a {@link AuthenticationFailureHandler}. This handler will return an exception with a
- * RestStatus of 401 and the WWW-Authenticate header with a Basic challenge.
+ * The default implementation of a {@link AuthenticationFailureHandler}. This
+ * handler will return an exception with a RestStatus of 401 and default failure
+ * response headers like 'WWW-Authenticate'
*/
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler {
+ private final Map> defaultFailureResponseHeaders;
+
+ /**
+ * Constructs default authentication failure handler
+ *
+ * @deprecated replaced by {@link #DefaultAuthenticationFailureHandler(Map)}
+ */
+ @Deprecated
+ public DefaultAuthenticationFailureHandler() {
+ this(null);
+ }
+
+ /**
+ * Constructs default authentication failure handler with provided default
+ * response headers.
+ *
+ * @param failureResponseHeaders Map of header key and list of header values to
+ * be sent as failure response.
+ * @see Realm#getAuthenticationFailureHeaders()
+ */
+ public DefaultAuthenticationFailureHandler(Map> failureResponseHeaders) {
+ if (failureResponseHeaders == null || failureResponseHeaders.isEmpty()) {
+ failureResponseHeaders = Collections.singletonMap("WWW-Authenticate",
+ Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""));
+ }
+ this.defaultFailureResponseHeaders = Collections.unmodifiableMap(failureResponseHeaders);
+ }
@Override
- public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token,
- ThreadContext context) {
- return authenticationError("unable to authenticate user [{}] for REST request [{}]", token.principal(), request.uri());
+ public ElasticsearchSecurityException failedAuthentication(RestRequest request, AuthenticationToken token, ThreadContext context) {
+ return createAuthenticationError("unable to authenticate user [{}] for REST request [{}]", null, token.principal(), request.uri());
}
@Override
public ElasticsearchSecurityException failedAuthentication(TransportMessage message, AuthenticationToken token, String action,
- ThreadContext context) {
- return authenticationError("unable to authenticate user [{}] for action [{}]", token.principal(), action);
+ ThreadContext context) {
+ return createAuthenticationError("unable to authenticate user [{}] for action [{}]", null, token.principal(), action);
}
@Override
public ElasticsearchSecurityException exceptionProcessingRequest(RestRequest request, Exception e, ThreadContext context) {
- if (e instanceof ElasticsearchSecurityException) {
- assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
- assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
- return (ElasticsearchSecurityException) e;
- }
- return authenticationError("error attempting to authenticate request", e);
+ return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
}
@Override
public ElasticsearchSecurityException exceptionProcessingRequest(TransportMessage message, String action, Exception e,
- ThreadContext context) {
- if (e instanceof ElasticsearchSecurityException) {
- assert ((ElasticsearchSecurityException) e).status() == RestStatus.UNAUTHORIZED;
- assert ((ElasticsearchSecurityException) e).getHeader("WWW-Authenticate").size() == 1;
- return (ElasticsearchSecurityException) e;
- }
- return authenticationError("error attempting to authenticate request", e);
+ ThreadContext context) {
+ return createAuthenticationError("error attempting to authenticate request", e, (Object[]) null);
}
@Override
public ElasticsearchSecurityException missingToken(RestRequest request, ThreadContext context) {
- return authenticationError("missing authentication token for REST request [{}]", request.uri());
+ return createAuthenticationError("missing authentication token for REST request [{}]", null, request.uri());
}
@Override
public ElasticsearchSecurityException missingToken(TransportMessage message, String action, ThreadContext context) {
- return authenticationError("missing authentication token for action [{}]", action);
+ return createAuthenticationError("missing authentication token for action [{}]", null, action);
}
@Override
public ElasticsearchSecurityException authenticationRequired(String action, ThreadContext context) {
- return authenticationError("action [{}] requires authentication", action);
+ return createAuthenticationError("action [{}] requires authentication", null, action);
+ }
+
+ /**
+ * Creates an instance of {@link ElasticsearchSecurityException} with
+ * {@link RestStatus#UNAUTHORIZED} status.
+ *
+ * Also adds default failure response headers as configured for this {@link DefaultAuthenticationFailureHandler}
+ *
+ * @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;
+ if (t instanceof ElasticsearchSecurityException) {
+ assert ((ElasticsearchSecurityException) t).status() == RestStatus.UNAUTHORIZED;
+ ese = (ElasticsearchSecurityException) t;
+ } else {
+ ese = authenticationError(message, t, args);
+ }
+ // If it is already present then it will replace the existing header.
+ defaultFailureResponseHeaders.entrySet().stream().forEach((e) -> ese.addHeader(e.getKey(), e.getValue()));
+ return ese;
}
}
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java
index 3e92be2ef904d..2c63ca95eb980 100644
--- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java
@@ -8,9 +8,12 @@
import org.apache.logging.log4j.Logger;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.xpack.core.XPackField;
import org.elasticsearch.xpack.core.security.user.User;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
/**
@@ -56,6 +59,18 @@ public int order() {
return config.order;
}
+ /**
+ * Each realm can define response headers to be sent on failure.
+ *
+ * By default it adds 'WWW-Authenticate' header with auth scheme 'Basic'.
+ *
+ * @return Map of authentication failure response headers.
+ */
+ public Map> getAuthenticationFailureHeaders() {
+ return Collections.singletonMap("WWW-Authenticate",
+ Collections.singletonList("Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\""));
+ }
+
@Override
public int compareTo(Realm other) {
int result = Integer.compare(config.order, other.config.order);
diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java
new file mode 100644
index 0000000000000..44d186dce0d2f
--- /dev/null
+++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/DefaultAuthenticationFailureHandlerTests.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.core.security.authc;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.XPackField;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.sameInstance;
+
+public class DefaultAuthenticationFailureHandlerTests extends ESTestCase {
+
+ public void testAuthenticationRequired() {
+ final boolean testDefault = randomBoolean();
+ final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"";
+ final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\"";
+ final DefaultAuthenticationFailureHandler failuerHandler;
+ if (testDefault) {
+ failuerHandler = new DefaultAuthenticationFailureHandler();
+ } else {
+ final Map> failureResponeHeaders = new HashMap<>();
+ failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme));
+ failuerHandler = new DefaultAuthenticationFailureHandler(failureResponeHeaders);
+ }
+ assertThat(failuerHandler, is(notNullValue()));
+ final ElasticsearchSecurityException ese =
+ failuerHandler.authenticationRequired("someaction", new ThreadContext(Settings.builder().build()));
+ assertThat(ese, is(notNullValue()));
+ assertThat(ese.getMessage(), equalTo("action [someaction] requires authentication"));
+ assertThat(ese.getHeader("WWW-Authenticate"), is(notNullValue()));
+ if (testDefault) {
+ assertThat(ese.getHeader("WWW-Authenticate").size(), is(1));
+ assertThat(ese.getHeader("WWW-Authenticate"), contains(Arrays.asList(equalTo(basicAuthScheme))));
+ } else {
+ assertThat(ese.getHeader("WWW-Authenticate").size(), is(2));
+ assertThat(ese.getHeader("WWW-Authenticate"), contains(Arrays.asList(equalTo(basicAuthScheme), equalTo(bearerAuthScheme))));
+ }
+ }
+
+ public void testExceptionProcessingRequest() {
+ final String basicAuthScheme = "Basic realm=\"" + XPackField.SECURITY + "\" charset=\"UTF-8\"";
+ final String bearerAuthScheme = "Bearer realm=\"" + XPackField.SECURITY + "\"";
+ final Map> failureResponeHeaders = new HashMap<>();
+ failureResponeHeaders.put("WWW-Authenticate", Arrays.asList(basicAuthScheme, bearerAuthScheme));
+ 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");
+ eseCause.addHeader("WWW-Authenticate", randomFrom(Arrays.asList(null, ""), Collections.singletonList(basicAuthScheme)));
+
+ 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)));
+ assertThat(ese.getHeader("WWW-Authenticate").size(), is(2));
+ assertThat(ese.getHeader("WWW-Authenticate"), contains(Arrays.asList(equalTo(basicAuthScheme), equalTo(bearerAuthScheme))));
+ 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"));
+ assertThat(ese.getHeader("WWW-Authenticate").size(), is(2));
+ assertThat(ese.getHeader("WWW-Authenticate"), contains(Arrays.asList(equalTo(basicAuthScheme), equalTo(bearerAuthScheme))));
+ }
+
+ }
+}
diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
index 5eaf23713b314..f17d4d6849acd 100644
--- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
+++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
@@ -77,6 +77,7 @@
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;
@@ -429,23 +430,7 @@ Collection