diff --git a/plugin/src/main/java/org/elasticsearch/xpack/XPackPlugin.java b/plugin/src/main/java/org/elasticsearch/xpack/XPackPlugin.java index 9f415947016..8f6ffa1f78b 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/XPackPlugin.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/XPackPlugin.java @@ -13,6 +13,7 @@ import org.elasticsearch.action.support.ActionFilter; import org.elasticsearch.bootstrap.BootstrapCheck; import org.elasticsearch.client.Client; import org.elasticsearch.client.transport.TransportClient; +import org.elasticsearch.cluster.ClusterState; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; import org.elasticsearch.cluster.metadata.IndexTemplateMetaData; import org.elasticsearch.cluster.node.DiscoveryNodes; @@ -41,6 +42,7 @@ import org.elasticsearch.license.LicenseService; import org.elasticsearch.license.Licensing; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.plugins.ActionPlugin; +import org.elasticsearch.plugins.ClusterPlugin; import org.elasticsearch.plugins.IngestPlugin; import org.elasticsearch.plugins.NetworkPlugin; import org.elasticsearch.plugins.Plugin; @@ -129,7 +131,7 @@ import java.util.stream.Stream; import static org.elasticsearch.xpack.watcher.Watcher.ENCRYPT_SENSITIVE_DATA_SETTING; -public class XPackPlugin extends Plugin implements ScriptPlugin, ActionPlugin, IngestPlugin, NetworkPlugin { +public class XPackPlugin extends Plugin implements ScriptPlugin, ActionPlugin, IngestPlugin, NetworkPlugin, ClusterPlugin { public static final String NAME = "x-pack"; @@ -599,4 +601,8 @@ public class XPackPlugin extends Plugin implements ScriptPlugin, ActionPlugin, I .collect(Collectors.toList())); } + @Override + public Map> getInitialClusterStateCustomSupplier() { + return security.getInitialClusterStateCustomSupplier(); + } } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java index ec50c8c8c8a..61a26fb5bf4 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -14,6 +14,9 @@ import org.elasticsearch.action.ActionResponse; import org.elasticsearch.action.support.ActionFilter; import org.elasticsearch.action.support.DestructiveOperations; import org.elasticsearch.bootstrap.BootstrapCheck; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.LocalNodeMasterListener; +import org.elasticsearch.cluster.NamedDiff; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; import org.elasticsearch.cluster.metadata.IndexTemplateMetaData; import org.elasticsearch.cluster.node.DiscoveryNodes; @@ -50,6 +53,7 @@ import org.elasticsearch.indices.breaker.CircuitBreakerService; import org.elasticsearch.ingest.Processor; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.plugins.ActionPlugin; +import org.elasticsearch.plugins.ClusterPlugin; import org.elasticsearch.plugins.IngestPlugin; import org.elasticsearch.plugins.NetworkPlugin; import org.elasticsearch.rest.RestController; @@ -115,6 +119,7 @@ import org.elasticsearch.xpack.security.authc.InternalRealms; import org.elasticsearch.xpack.security.authc.Realm; import org.elasticsearch.xpack.security.authc.RealmSettings; import org.elasticsearch.xpack.security.authc.Realms; +import org.elasticsearch.xpack.security.authc.TokenMetaData; import org.elasticsearch.xpack.security.authc.TokenService; import org.elasticsearch.xpack.security.authc.esnative.NativeRealm; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore; @@ -189,7 +194,7 @@ import static java.util.Collections.singletonList; import static org.elasticsearch.xpack.XPackSettings.HTTP_SSL_ENABLED; import static org.elasticsearch.xpack.security.SecurityLifecycleService.SECURITY_TEMPLATE_NAME; -public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { +public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin, ClusterPlugin { private static final Logger logger = Loggers.getLogger(XPackPlugin.class); @@ -218,6 +223,7 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { private final SetOnce auditTrailService = new SetOnce<>(); private final SetOnce securityContext = new SetOnce<>(); private final SetOnce threadContext = new SetOnce<>(); + private final SetOnce tokenService = new SetOnce<>(); private final List bootstrapChecks; public Security(Settings settings, Environment env, XPackLicenseState licenseState, SSLService sslService) @@ -332,7 +338,8 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { final SecurityLifecycleService securityLifecycleService = new SecurityLifecycleService(settings, clusterService, threadPool, client, indexAuditTrail); - final TokenService tokenService = new TokenService(settings, Clock.systemUTC(), client, securityLifecycleService); + final TokenService tokenService = new TokenService(settings, Clock.systemUTC(), client, securityLifecycleService, clusterService); + this.tokenService.set(tokenService); components.add(tokenService); // realms construction @@ -855,7 +862,11 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { } public static List getNamedWriteables() { - return Arrays.asList(ExpressionParser.NAMED_WRITEABLES); + List entries = new ArrayList<>(); + entries.add(new NamedWriteableRegistry.Entry(ClusterState.Custom.class, TokenMetaData.TYPE, TokenMetaData::new)); + entries.add(new NamedWriteableRegistry.Entry(NamedDiff.class, TokenMetaData.TYPE, TokenMetaData::readDiffFrom)); + entries.addAll(Arrays.asList(ExpressionParser.NAMED_WRITEABLES)); + return entries; } public List> getExecutorBuilders(final Settings settings) { @@ -882,4 +893,13 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { return templates; }; } + + @Override + public Map> getInitialClusterStateCustomSupplier() { + if (enabled) { + return Collections.singletonMap(TokenMetaData.TYPE, () -> tokenService.get().getTokenMetaData()); + } else { + return Collections.emptyMap(); + } + } } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheck.java b/plugin/src/main/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheck.java index f495cefcfc9..ef6150188fd 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheck.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheck.java @@ -24,14 +24,18 @@ final class TokenPassphraseBootstrapCheck implements BootstrapCheck { TokenPassphraseBootstrapCheck(Settings settings) { this.tokenServiceEnabled = XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.get(settings); - this.tokenPassphrase = TokenService.TOKEN_PASSPHRASE.get(settings); + + this.tokenPassphrase = TokenService.TOKEN_PASSPHRASE.exists(settings) ? TokenService.TOKEN_PASSPHRASE.get(settings) : null; } @Override public boolean check() { + if (tokenPassphrase == null) { // that's fine we bootstrap it ourself + return false; + } try (SecureString ignore = tokenPassphrase) { if (tokenServiceEnabled) { - return tokenPassphrase.length() < MINIMUM_PASSPHRASE_LENGTH || tokenPassphrase.equals(TokenService.DEFAULT_PASSPHRASE); + return tokenPassphrase.length() < MINIMUM_PASSPHRASE_LENGTH; } } // service is not enabled so no need to check @@ -41,7 +45,7 @@ final class TokenPassphraseBootstrapCheck implements BootstrapCheck { @Override public String errorMessage() { return "Please set a passphrase using the elasticsearch-keystore tool for the setting [" + TokenService.TOKEN_PASSPHRASE.getKey() + - "] that is at least " + MINIMUM_PASSPHRASE_LENGTH + " characters in length and does not match the default passphrase or " + + "] that is at least " + MINIMUM_PASSPHRASE_LENGTH + " characters in length or " + "disable the token service using the [" + XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey() + "] setting"; } } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenMetaData.java b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenMetaData.java new file mode 100644 index 00000000000..75453c3dfae --- /dev/null +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenMetaData.java @@ -0,0 +1,93 @@ +/* + * 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; + +import org.elasticsearch.Version; +import org.elasticsearch.cluster.AbstractNamedDiffable; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.NamedDiff; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public final class TokenMetaData extends AbstractNamedDiffable implements ClusterState.Custom { + + /** + * The type of {@link ClusterState} data. + */ + public static final String TYPE = "security_tokens"; + + final List keys; + final byte[] currentKeyHash; + + TokenMetaData(List keys, byte[] currentKeyHash) { + this.keys = keys; + this.currentKeyHash = currentKeyHash; + } + + public TokenMetaData(StreamInput input) throws IOException { + currentKeyHash = input.readByteArray(); + keys = Collections.unmodifiableList(input.readList(TokenService.KeyAndTimestamp::new)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeByteArray(currentKeyHash); + out.writeList(keys); + } + + public static NamedDiff readDiffFrom(StreamInput in) throws IOException { + return readDiffFrom(ClusterState.Custom.class, TYPE, in); + } + + @Override + public String getWriteableName() { + return TYPE; + } + + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + // never render this to the user + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TokenMetaData that = (TokenMetaData)o; + return keys.equals(that.keys) && currentKeyHash.equals(that.currentKeyHash); + } + + @Override + public int hashCode() { + int result = keys.hashCode(); + result = 31 * result + currentKeyHash.hashCode(); + return result; + } + + @Override + public String toString() { + return "TokenMetaData{ everything is secret }"; + } + + @Override + public Version getMinimalSupportedVersion() { + return Version.V_7_0_0_alpha1; + } + + @Override + public boolean isPrivate() { + // never sent this to a client + return true; + } +} diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index 055b41e8cae..39ffa3afba2 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -6,8 +6,12 @@ package org.elasticsearch.xpack.security.authc; import org.apache.logging.log4j.message.ParameterizedMessage; +import org.apache.lucene.util.ArrayUtil; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.BytesRefBuilder; import org.apache.lucene.util.IOUtils; import org.apache.lucene.util.StringHelper; +import org.apache.lucene.util.UnicodeUtil; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; @@ -17,6 +21,12 @@ import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.action.support.TransportActions; import org.elasticsearch.action.support.WriteRequest.RefreshPolicy; +import org.elasticsearch.action.support.master.AcknowledgedRequest; +import org.elasticsearch.cluster.AckedClusterStateUpdateTask; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.ack.AckedRequest; +import org.elasticsearch.cluster.ack.ClusterStateUpdateResponse; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.Strings; import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; @@ -25,6 +35,7 @@ import org.elasticsearch.common.io.stream.InputStreamStreamInput; import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.settings.SecureSetting; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; @@ -33,6 +44,7 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.concurrent.AbstractRunnable; import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.util.iterable.Iterables; import org.elasticsearch.index.engine.VersionConflictEngineException; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.xpack.XPackPlugin; @@ -51,21 +63,31 @@ import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicLong; + +import static org.elasticsearch.gateway.GatewayService.STATE_NOT_RECOVERED_BLOCK; /** * Service responsible for the creation, validation, and other management of {@link UserToken} @@ -83,6 +105,7 @@ public final class TokenService extends AbstractComponent { private static final int ITERATIONS = 100000; private static final String KDF_ALGORITHM = "PBKDF2withHMACSHA512"; private static final int SALT_BYTES = 32; + private static final int KEY_BYTES = 64; private static final int IV_BYTES = 12; private static final int VERSION_BYTES = 4; private static final String ENCRYPTION_CIPHER = "AES/GCM/NoPadding"; @@ -100,27 +123,25 @@ public final class TokenService extends AbstractComponent { TimeValue.timeValueMinutes(30L), Property.NodeScope); public static final Setting DELETE_TIMEOUT = Setting.timeSetting("xpack.security.authc.token.delete.timeout", TimeValue.MINUS_ONE, Property.NodeScope); - public static final String DEFAULT_PASSPHRASE = "changeme is a terrible password, so let's not use it anymore!"; static final String DOC_TYPE = "invalidated-token"; static final int MINIMUM_BYTES = VERSION_BYTES + SALT_BYTES + IV_BYTES + 1; static final int MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * MINIMUM_BYTES) / 3)).intValue(); private final SecureRandom secureRandom = new SecureRandom(); - private final Cache keyCache; - private final SecureString tokenPassphrase; + private final ClusterService clusterService; private final Clock clock; private final TimeValue expirationDelay; private final TimeValue deleteInterval; - private final BytesKey salt; private final InternalClient internalClient; private final SecurityLifecycleService lifecycleService; private final ExpiredTokenRemover expiredTokenRemover; private final boolean enabled; private final byte[] currentVersionBytes; - + private volatile TokenKeys keyCache; private volatile long lastExpirationRunMs; - + private final AtomicLong createdTimeStamps = new AtomicLong(-1); + private static final Version TOKEN_SERVICE_VERSION = Version.CURRENT; /** * Creates a new token service @@ -129,21 +150,17 @@ public final class TokenService extends AbstractComponent { * @param internalClient the client to use when checking for revocations */ public TokenService(Settings settings, Clock clock, InternalClient internalClient, - SecurityLifecycleService lifecycleService) throws GeneralSecurityException { + SecurityLifecycleService lifecycleService, ClusterService clusterService) throws GeneralSecurityException { super(settings); byte[] saltArr = new byte[SALT_BYTES]; secureRandom.nextBytes(saltArr); - this.salt = new BytesKey(saltArr); - this.keyCache = CacheBuilder.builder() - .setExpireAfterAccess(TimeValue.timeValueMinutes(60L)) - .setMaximumWeight(500L) - .build(); + final SecureString tokenPassphraseValue = TOKEN_PASSPHRASE.get(settings); + final SecureString tokenPassphrase; if (tokenPassphraseValue.length() == 0) { - // setting didn't exist - we should only be in a non-production mode for this - this.tokenPassphrase = new SecureString(DEFAULT_PASSPHRASE.toCharArray()); + tokenPassphrase = generateTokenKey(); } else { - this.tokenPassphrase = tokenPassphraseValue; + tokenPassphrase = tokenPassphraseValue; } this.clock = clock.withZone(ZoneOffset.UTC); @@ -154,13 +171,17 @@ public final class TokenService extends AbstractComponent { this.deleteInterval = DELETE_INTERVAL.get(settings); this.enabled = XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.get(settings); this.expiredTokenRemover = new ExpiredTokenRemover(settings, internalClient); - this.currentVersionBytes = ByteBuffer.allocate(4).putInt(Version.CURRENT.id).array(); + this.currentVersionBytes = ByteBuffer.allocate(4).putInt(TOKEN_SERVICE_VERSION.id).array(); ensureEncryptionCiphersSupported(); - try (SecureString closeableChars = tokenPassphrase.clone()) { - keyCache.put(salt, computeSecretKey(closeableChars.getChars(), salt.bytes)); - } + KeyAndCache keyAndCache = new KeyAndCache(new KeyAndTimestamp(tokenPassphrase.clone(), createdTimeStamps.incrementAndGet()), + new BytesKey(saltArr)); + keyCache = new TokenKeys(Collections.singletonMap(keyAndCache.getKeyHash(), keyAndCache), keyAndCache.getKeyHash()); + this.clusterService = clusterService; + initialize(clusterService); + getTokenMetaData(); } + /** * Create a token based on the provided authentication */ @@ -219,30 +240,43 @@ public final class TokenService extends AbstractComponent { listener.onResponse(null); } else { final BytesKey decodedSalt = new BytesKey(in.readByteArray()); - final SecretKey decodeKey = keyCache.get(decodedSalt); - final byte[] iv = in.readByteArray(); - if (decodeKey != null) { - try { - decryptToken(in, getDecryptionCipher(iv, decodeKey, version, decodedSalt), version, listener); - } catch (GeneralSecurityException e) { - // could happen with a token that is not ours - logger.debug("invalid token", e); - listener.onResponse(null); + final BytesKey passphraseHash; + if (version.onOrAfter(Version.V_7_0_0_alpha1)) { + passphraseHash = new BytesKey(in.readByteArray()); + } else { + passphraseHash = keyCache.currentTokenKeyHash; + } + KeyAndCache keyAndCache = keyCache.get(passphraseHash); + if (keyAndCache != null) { + final SecretKey decodeKey = keyAndCache.getKey(decodedSalt); + final byte[] iv = in.readByteArray(); + if (decodeKey != null) { + try { + decryptToken(in, getDecryptionCipher(iv, decodeKey, version, decodedSalt), version, listener); + } catch (GeneralSecurityException e) { + // could happen with a token that is not ours + logger.warn("invalid token", e); + listener.onResponse(null); + } + } else { + /* As a measure of protected against DOS, we can pass requests requiring a key + * computation off to a single thread executor. For normal usage, the initial + * request(s) that require a key computation will be delayed and there will be + * some additional latency. + */ + internalClient.threadPool().executor(THREAD_POOL_NAME) + .submit(new KeyComputingRunnable(in, iv, version, decodedSalt, listener, keyAndCache)); } } else { - /* As a measure of protected against DOS, we can pass requests requiring a key - * computation off to a single thread executor. For normal usage, the initial - * request(s) that require a key computation will be delayed and there will be - * some additional latency. - */ - internalClient.threadPool().executor(THREAD_POOL_NAME) - .submit(new KeyComputingRunnable(in, iv, version, decodedSalt, listener)); + logger.debug("invalid key {} key: {}", passphraseHash, keyCache.cache.keySet()); + listener.onResponse(null); } } } } - private void decryptToken(StreamInput in, Cipher cipher, Version version, ActionListener listener) throws IOException { + private static void decryptToken(StreamInput in, Cipher cipher, Version version, ActionListener listener) throws + IOException { try (CipherInputStream cis = new CipherInputStream(in, cipher); StreamInput decryptedInput = new InputStreamStreamInput(cis)) { decryptedInput.setVersion(version); listener.onResponse(new UserToken(decryptedInput)); @@ -384,7 +418,7 @@ public final class TokenService extends AbstractComponent { * Gets the token from the Authorization header if the header begins with * Bearer */ - private String getFromHeader(ThreadContext threadContext) { + String getFromHeader(ThreadContext threadContext) { String header = threadContext.getHeader("Authorization"); if (Strings.hasLength(header) && header.startsWith("Bearer ") && header.length() > "Bearer ".length()) { @@ -401,11 +435,13 @@ public final class TokenService extends AbstractComponent { try (ByteArrayOutputStream os = new ByteArrayOutputStream(MINIMUM_BASE64_BYTES); OutputStream base64 = Base64.getEncoder().wrap(os); StreamOutput out = new OutputStreamStreamOutput(base64)) { - Version.writeVersion(Version.CURRENT, out); - out.writeByteArray(salt.bytes); + KeyAndCache keyAndCache = keyCache.activeKeyCache; + Version.writeVersion(TOKEN_SERVICE_VERSION, out); + out.writeByteArray(keyAndCache.getSalt().bytes); + out.writeByteArray(keyAndCache.getKeyHash().bytes); // TODO this requires a BWC layer in 5.6 final byte[] initializationVector = getNewInitializationVector(); out.writeByteArray(initializationVector); - try (CipherOutputStream encryptedOutput = new CipherOutputStream(out, getEncryptionCipher(initializationVector)); + try (CipherOutputStream encryptedOutput = new CipherOutputStream(out, getEncryptionCipher(initializationVector, keyAndCache)); StreamOutput encryptedStreamOutput = new OutputStreamStreamOutput(encryptedOutput)) { userToken.writeTo(encryptedStreamOutput); encryptedStreamOutput.close(); @@ -419,9 +455,10 @@ public final class TokenService extends AbstractComponent { SecretKeyFactory.getInstance(KDF_ALGORITHM); } - private Cipher getEncryptionCipher(byte[] iv) throws GeneralSecurityException { + private Cipher getEncryptionCipher(byte[] iv, KeyAndCache keyAndCache) throws GeneralSecurityException { Cipher cipher = Cipher.getInstance(ENCRYPTION_CIPHER); - cipher.init(Cipher.ENCRYPT_MODE, keyCache.get(salt), new GCMParameterSpec(128, iv), secureRandom); + BytesKey salt = keyAndCache.getSalt(); + cipher.init(Cipher.ENCRYPT_MODE, keyAndCache.getKey(salt), new GCMParameterSpec(128, iv), secureRandom); cipher.updateAAD(currentVersionBytes); cipher.updateAAD(salt.bytes); return cipher; @@ -492,23 +529,22 @@ public final class TokenService extends AbstractComponent { private final BytesKey decodedSalt; private final ActionListener listener; private final byte[] iv; + private final KeyAndCache keyAndCache; - KeyComputingRunnable(StreamInput input, byte[] iv, Version version, BytesKey decodedSalt, ActionListener listener) { + KeyComputingRunnable(StreamInput input, byte[] iv, Version version, BytesKey decodedSalt, ActionListener listener, + KeyAndCache keyAndCache) { this.in = input; this.version = version; this.decodedSalt = decodedSalt; this.listener = listener; this.iv = iv; + this.keyAndCache = keyAndCache; } @Override protected void doRun() { try { - final SecretKey computedKey = keyCache.computeIfAbsent(decodedSalt, (salt) -> { - try (SecureString closeableChars = tokenPassphrase.clone()) { - return computeSecretKey(closeableChars.getChars(), decodedSalt.bytes); - } - }); + final SecretKey computedKey = keyAndCache.getOrComputeKey(decodedSalt); decryptToken(in, getDecryptionCipher(iv, computedKey, version, decodedSalt), version, listener); } catch (ExecutionException e) { if (e.getCause() != null && @@ -569,5 +605,337 @@ public final class TokenService extends AbstractComponent { BytesKey otherBytes = (BytesKey) other; return Arrays.equals(otherBytes.bytes, bytes); } + + @Override + public String toString() { + return new BytesRef(bytes).toString(); + } } + + /** + * Creates a new key unless present that is newer than the current active key and returns the corresponding metadata. Note: + * this method doesn't modify the metadata used in this token service. See {@link #refreshMetaData(TokenMetaData)} + */ + synchronized TokenMetaData generateSpareKey() { + KeyAndCache maxKey = keyCache.cache.values().stream().max(Comparator.comparingLong(v -> v.keyAndTimestamp.timestamp)).get(); + KeyAndCache currentKey = keyCache.activeKeyCache; + if (currentKey == maxKey) { + long timestamp = createdTimeStamps.incrementAndGet(); + while (true) { + byte[] saltArr = new byte[SALT_BYTES]; + secureRandom.nextBytes(saltArr); + SecureString tokenKey = generateTokenKey(); + KeyAndCache keyAndCache = new KeyAndCache(new KeyAndTimestamp(tokenKey, timestamp), new BytesKey(saltArr)); + if (keyCache.cache.containsKey(keyAndCache.getKeyHash())) { + continue; // collision -- generate a new key + } + return newTokenMetaData(keyCache.currentTokenKeyHash, Iterables.concat(keyCache.cache.values(), + Collections.singletonList(keyAndCache))); + } + } + return newTokenMetaData(keyCache.currentTokenKeyHash, keyCache.cache.values()); + } + + /** + * Rotate the current active key to the spare key created in the previous {@link #generateSpareKey()} call. + */ + synchronized TokenMetaData rotateToSpareKey() { + KeyAndCache maxKey = keyCache.cache.values().stream().max(Comparator.comparingLong(v -> v.keyAndTimestamp.timestamp)).get(); + if (maxKey == keyCache.activeKeyCache) { + throw new IllegalStateException("call generateSpareKey first"); + } + return newTokenMetaData(maxKey.getKeyHash(), keyCache.cache.values()); + } + + /** + * Prunes the keys and keeps up to the latest N keys around + * @param numKeysToKeep the number of keys to keep. + */ + synchronized TokenMetaData pruneKeys(int numKeysToKeep) { + if (keyCache.cache.size() <= numKeysToKeep) { + return getTokenMetaData(); // nothing to do + } + Map map = new HashMap<>(keyCache.cache.size() + 1); + KeyAndCache currentKey = keyCache.get(keyCache.currentTokenKeyHash); + ArrayList entries = new ArrayList<>(keyCache.cache.values()); + Collections.sort(entries, + (left, right) -> Long.compare(right.keyAndTimestamp.timestamp, left.keyAndTimestamp.timestamp)); + for (KeyAndCache value: entries) { + if (map.size() < numKeysToKeep || value.keyAndTimestamp.timestamp >= currentKey + .keyAndTimestamp.timestamp) { + logger.debug("keeping key {} ", value.getKeyHash()); + map.put(value.getKeyHash(), value); + } else { + logger.debug("prune key {} ", value.getKeyHash()); + } + } + assert map.isEmpty() == false; + assert map.containsKey(keyCache.currentTokenKeyHash); + return newTokenMetaData(keyCache.currentTokenKeyHash, map.values()); + } + + /** + * Returns the current in-use metdata of this {@link TokenService} + */ + public synchronized TokenMetaData getTokenMetaData() { + return newTokenMetaData(keyCache.currentTokenKeyHash, keyCache.cache.values()); + } + + private TokenMetaData newTokenMetaData(BytesKey activeTokenKey, Iterable iterable) { + List list = new ArrayList<>(); + for (KeyAndCache v : iterable) { + list.add(v.keyAndTimestamp); + } + return new TokenMetaData(list, activeTokenKey.bytes); + } + + /** + * Refreshes the current in-use metadata. + */ + synchronized void refreshMetaData(TokenMetaData metaData) { + BytesKey currentUsedKeyHash = new BytesKey(metaData.currentKeyHash); + byte[] saltArr = new byte[SALT_BYTES]; + Map map = new HashMap<>(metaData.keys.size()); + long maxTimestamp = createdTimeStamps.get(); + for (KeyAndTimestamp key : metaData.keys) { + secureRandom.nextBytes(saltArr); + KeyAndCache keyAndCache = new KeyAndCache(key, new BytesKey(saltArr)); + maxTimestamp = Math.max(keyAndCache.keyAndTimestamp.timestamp, maxTimestamp); + if (keyCache.cache.containsKey(keyAndCache.getKeyHash()) == false) { + map.put(keyAndCache.getKeyHash(), keyAndCache); + } else { + map.put(keyAndCache.getKeyHash(), keyCache.get(keyAndCache.getKeyHash())); // maintain the cache we already have + } + } + if (map.containsKey(currentUsedKeyHash) == false) { + // this won't leak any secrets it's only exposing the current set of hashes + throw new IllegalStateException("Current key is not in the map: " + map.keySet() + " key: " + currentUsedKeyHash); + } + createdTimeStamps.set(maxTimestamp); + keyCache = new TokenKeys(Collections.unmodifiableMap(map), currentUsedKeyHash); + logger.debug("refreshed keys current: {}, keys: {}", currentUsedKeyHash, keyCache.cache.keySet()); + } + + private SecureString generateTokenKey() { + byte[] keyBytes = new byte[KEY_BYTES]; + byte[] encode = new byte[0]; + char[] ref = new char[0]; + try { + secureRandom.nextBytes(keyBytes); + encode = Base64.getUrlEncoder().withoutPadding().encode(keyBytes); + ref = new char[encode.length]; + int len = UnicodeUtil.UTF8toUTF16(encode, 0, encode.length, ref); + return new SecureString(Arrays.copyOfRange(ref, 0, len)); + } finally { + Arrays.fill(keyBytes, (byte) 0x00); + Arrays.fill(encode, (byte) 0x00); + Arrays.fill(ref, (char) 0x00); + } + } + + synchronized String getActiveKeyHash() { + return new BytesRef(Base64.getUrlEncoder().withoutPadding().encode(this.keyCache.currentTokenKeyHash.bytes)).utf8ToString(); + } + + void rotateKeysOnMaster(ActionListener listener) { + logger.info("rotate keys on master"); + TokenMetaData tokenMetaData = generateSpareKey(); + clusterService.submitStateUpdateTask("publish next key to prepare key rotation", + new TokenMetadataPublishAction( + ActionListener.wrap((res) -> { + if (res.isAcknowledged()) { + TokenMetaData metaData = rotateToSpareKey(); + clusterService.submitStateUpdateTask("publish next key to prepare key rotation", + new TokenMetadataPublishAction(listener, metaData)); + } else { + listener.onFailure(new IllegalStateException("not acked")); + } + }, listener::onFailure), tokenMetaData)); + } + + private final class TokenMetadataPublishAction extends AckedClusterStateUpdateTask { + + private final TokenMetaData tokenMetaData; + + protected TokenMetadataPublishAction(ActionListener listener, TokenMetaData tokenMetaData) { + super(new AckedRequest() { + @Override + public TimeValue ackTimeout() { + return AcknowledgedRequest.DEFAULT_ACK_TIMEOUT; + } + + @Override + public TimeValue masterNodeTimeout() { + return AcknowledgedRequest.DEFAULT_MASTER_NODE_TIMEOUT; + } + }, listener); + this.tokenMetaData = tokenMetaData; + } + + @Override + public ClusterState execute(ClusterState currentState) throws Exception { + if (tokenMetaData.equals(currentState.custom(TokenMetaData.TYPE))) { + return currentState; + } + return ClusterState.builder(currentState).putCustom(TokenMetaData.TYPE, tokenMetaData).build(); + } + + @Override + protected ClusterStateUpdateResponse newResponse(boolean acknowledged) { + return new ClusterStateUpdateResponse(acknowledged); + } + + } + + private void initialize(ClusterService clusterService) { + clusterService.addListener(event -> { + ClusterState state = event.state(); + if (state.getBlocks().hasGlobalBlock(STATE_NOT_RECOVERED_BLOCK)) { + return; + } + + TokenMetaData custom = event.state().custom(TokenMetaData.TYPE); + if (custom != null && custom.equals(getTokenMetaData()) == false) { + logger.info("refresh keys"); + try { + refreshMetaData(custom); + } catch (Exception e) { + logger.warn(e); + } + logger.info("refreshed keys"); + } + }); + } + + static final class KeyAndTimestamp implements Writeable { + private final SecureString key; + private final long timestamp; + + private KeyAndTimestamp(SecureString key, long timestamp) { + this.key = key; + this.timestamp = timestamp; + } + + KeyAndTimestamp(StreamInput input) throws IOException { + timestamp = input.readVLong(); + byte[] keyBytes = input.readByteArray(); + final char[] ref = new char[keyBytes.length]; + int len = UnicodeUtil.UTF8toUTF16(keyBytes, 0, keyBytes.length, ref); + key = new SecureString(Arrays.copyOfRange(ref, 0, len)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeVLong(timestamp); + BytesRef bytesRef = new BytesRef(key); + out.writeVInt(bytesRef.length); + out.writeBytes(bytesRef.bytes, bytesRef.offset, bytesRef.length); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + KeyAndTimestamp that = (KeyAndTimestamp) o; + + if (timestamp != that.timestamp) return false; + return key.equals(that.key); + } + + @Override + public int hashCode() { + int result = key.hashCode(); + result = 31 * result + (int) (timestamp ^ (timestamp >>> 32)); + return result; + } + } + + static final class KeyAndCache implements Closeable { + private final KeyAndTimestamp keyAndTimestamp; + private final Cache keyCache; + private final BytesKey salt; + private final BytesKey keyHash; + + private KeyAndCache(KeyAndTimestamp keyAndTimestamp, BytesKey salt) { + this.keyAndTimestamp = keyAndTimestamp; + keyCache = CacheBuilder.builder() + .setExpireAfterAccess(TimeValue.timeValueMinutes(60L)) + .setMaximumWeight(500L) + .build(); + try { + SecretKey secretKey = computeSecretKey(keyAndTimestamp.key.getChars(), salt.bytes); + keyCache.put(salt, secretKey); + } catch (Exception e) { + throw new IllegalStateException(e); + } + this.salt = salt; + this.keyHash = calculateKeyHash(keyAndTimestamp.key); + } + + private SecretKey getKey(BytesKey salt) { + return keyCache.get(salt); + } + + public SecretKey getOrComputeKey(BytesKey decodedSalt) throws ExecutionException { + return keyCache.computeIfAbsent(decodedSalt, (salt) -> { + try (SecureString closeableChars = keyAndTimestamp.key.clone()) { + return computeSecretKey(closeableChars.getChars(), salt.bytes); + } + }); + } + + @Override + public void close() throws IOException { + keyAndTimestamp.key.close(); + } + + BytesKey getKeyHash() { + return keyHash; + } + + private static BytesKey calculateKeyHash(SecureString key) { + MessageDigest messageDigest = null; + try { + messageDigest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + BytesRefBuilder b = new BytesRefBuilder(); + try { + b.copyChars(key); + BytesRef bytesRef = b.toBytesRef(); + try { + messageDigest.update(bytesRef.bytes, bytesRef.offset, bytesRef.length); + return new BytesKey(Arrays.copyOfRange(messageDigest.digest(), 0, 8)); + } finally { + Arrays.fill(bytesRef.bytes, (byte) 0x00); + } + } finally { + Arrays.fill(b.bytes(), (byte) 0x00); + } + } + + BytesKey getSalt() { + return salt; + } + } + + + private static final class TokenKeys { + final Map cache; + final BytesKey currentTokenKeyHash; + final KeyAndCache activeKeyCache; + + private TokenKeys(Map cache, BytesKey currentTokenKeyHash) { + this.cache = cache; + this.currentTokenKeyHash = currentTokenKeyHash; + this.activeKeyCache = cache.get(currentTokenKeyHash); + } + + KeyAndCache get(BytesKey passphraseHash) { + return cache.get(passphraseHash); + } + } + } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/ml/integration/MlNativeAutodetectIntegTestCase.java b/plugin/src/test/java/org/elasticsearch/xpack/ml/integration/MlNativeAutodetectIntegTestCase.java index c2c0361ec92..f16936aadea 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/ml/integration/MlNativeAutodetectIntegTestCase.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/ml/integration/MlNativeAutodetectIntegTestCase.java @@ -57,6 +57,7 @@ import org.elasticsearch.xpack.persistent.PersistentTaskParams; import org.elasticsearch.xpack.persistent.PersistentTasksCustomMetaData; import org.elasticsearch.xpack.persistent.PersistentTasksNodeService; import org.elasticsearch.xpack.security.Security; +import org.elasticsearch.xpack.security.authc.TokenMetaData; import java.io.IOException; import java.util.ArrayList; @@ -279,6 +280,7 @@ abstract class MlNativeAutodetectIntegTestCase extends SecurityIntegTestCase { PersistentTasksNodeService.Status::new)); entries.add(new NamedWriteableRegistry.Entry(Task.Status.class, JobTaskStatus.NAME, JobTaskStatus::new)); entries.add(new NamedWriteableRegistry.Entry(Task.Status.class, DatafeedState.NAME, DatafeedState::fromStream)); + entries.add(new NamedWriteableRegistry.Entry(ClusterState.Custom.class, TokenMetaData.TYPE, TokenMetaData::new)); final NamedWriteableRegistry namedWriteableRegistry = new NamedWriteableRegistry(entries); ClusterState masterClusterState = client().admin().cluster().prepareState().all().get().getState(); byte[] masterClusterStateBytes = ClusterState.Builder.toBytes(masterClusterState); diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheckTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheckTests.java index 6e040a643a0..7c21d74e943 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheckTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/TokenPassphraseBootstrapCheckTests.java @@ -17,18 +17,15 @@ import static org.elasticsearch.xpack.security.TokenPassphraseBootstrapCheck.MIN public class TokenPassphraseBootstrapCheckTests extends ESTestCase { public void testTokenPassphraseCheck() throws Exception { - assertTrue(new TokenPassphraseBootstrapCheck(Settings.EMPTY).check()); + assertFalse(new TokenPassphraseBootstrapCheck(Settings.EMPTY).check()); MockSecureSettings secureSettings = new MockSecureSettings(); secureSettings.setString("foo", "bar"); // leniency in setSecureSettings... if its empty it's skipped Settings settings = Settings.builder().setSecureSettings(secureSettings).build(); - assertTrue(new TokenPassphraseBootstrapCheck(settings).check()); + assertFalse(new TokenPassphraseBootstrapCheck(settings).check()); secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), randomAlphaOfLengthBetween(MINIMUM_PASSPHRASE_LENGTH, 30)); assertFalse(new TokenPassphraseBootstrapCheck(settings).check()); - secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), TokenService.DEFAULT_PASSPHRASE); - assertTrue(new TokenPassphraseBootstrapCheck(settings).check()); - secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), randomAlphaOfLengthBetween(1, MINIMUM_PASSPHRASE_LENGTH - 1)); assertTrue(new TokenPassphraseBootstrapCheck(settings).check()); } @@ -44,8 +41,6 @@ public class TokenPassphraseBootstrapCheckTests extends ESTestCase { secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), randomAlphaOfLengthBetween(1, 30)); assertFalse(new TokenPassphraseBootstrapCheck(settings).check()); - secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), TokenService.DEFAULT_PASSPHRASE); - assertFalse(new TokenPassphraseBootstrapCheck(settings).check()); } public void testTokenPassphraseCheckAfterSecureSettingsClosed() throws Exception { @@ -53,7 +48,7 @@ public class TokenPassphraseBootstrapCheckTests extends ESTestCase { MockSecureSettings secureSettings = new MockSecureSettings(); secureSettings.setString("foo", "bar"); // leniency in setSecureSettings... if its empty it's skipped settings = Settings.builder().put(settings).setSecureSettings(secureSettings).build(); - secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), TokenService.DEFAULT_PASSPHRASE); + secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), randomAlphaOfLengthBetween(1, MINIMUM_PASSPHRASE_LENGTH - 1)); final TokenPassphraseBootstrapCheck check = new TokenPassphraseBootstrapCheck(settings); secureSettings.close(); assertTrue(check.check()); diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index a05c221a38b..973d7675f65 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -26,9 +26,11 @@ import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.SuppressForbidden; import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.transport.TransportAddress; @@ -133,7 +135,9 @@ public class AuthenticationServiceTests extends ESTestCase { threadContext = threadPool.getThreadContext(); InternalClient internalClient = new InternalClient(Settings.EMPTY, threadPool, client); lifecycleService = mock(SecurityLifecycleService.class); - tokenService = new TokenService(settings, Clock.systemUTC(), internalClient, lifecycleService); + ClusterService clusterService = new ClusterService(settings, new ClusterSettings(settings, ClusterSettings + .BUILT_IN_CLUSTER_SETTINGS), threadPool, Collections.emptyMap()); + tokenService = new TokenService(settings, Clock.systemUTC(), internalClient, lifecycleService, clusterService); service = new AuthenticationService(settings, realms, auditTrail, new DefaultAuthenticationFailureHandler(), threadPool, new AnonymousUser(settings), tokenService); } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java index 868b597c2de..6b7b4e731d2 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java @@ -6,14 +6,17 @@ package org.elasticsearch.xpack.security.authc; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.admin.cluster.state.ClusterStateResponse; import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.ack.ClusterStateUpdateResponse; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.builder.SearchSourceBuilder; +import org.elasticsearch.test.ESIntegTestCase; import org.elasticsearch.test.SecurityIntegTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.xpack.security.SecurityLifecycleService; @@ -45,6 +48,66 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase { .build(); } + public void testTokenServiceBootstrapOnNodeJoin() throws Exception { + final Client client = internalClient(); + SecurityClient securityClient = new SecurityClient(client); + CreateTokenResponse response = securityClient.prepareCreateToken() + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray())) + .get(); + for (TokenService tokenService : internalCluster().getInstances(TokenService.class)) { + PlainActionFuture userTokenFuture = new PlainActionFuture<>(); + tokenService.decodeToken(response.getTokenString(), userTokenFuture); + assertNotNull(userTokenFuture.actionGet()); + } + // start a new node and see if it can decrypt the token + String nodeName = internalCluster().startNode(); + for (TokenService tokenService : internalCluster().getInstances(TokenService.class)) { + PlainActionFuture userTokenFuture = new PlainActionFuture<>(); + tokenService.decodeToken(response.getTokenString(), userTokenFuture); + assertNotNull(userTokenFuture.actionGet()); + } + + TokenService tokenService = internalCluster().getInstance(TokenService.class, nodeName); + PlainActionFuture userTokenFuture = new PlainActionFuture<>(); + tokenService.decodeToken(response.getTokenString(), userTokenFuture); + assertNotNull(userTokenFuture.actionGet()); + } + + + public void testTokenServiceCanRotateKeys() throws Exception { + final Client client = internalClient(); + SecurityClient securityClient = new SecurityClient(client); + CreateTokenResponse response = securityClient.prepareCreateToken() + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray())) + .get(); + String masterName = internalCluster().getMasterName(); + TokenService masterTokenService = internalCluster().getInstance(TokenService.class, masterName); + String activeKeyHash = masterTokenService.getActiveKeyHash(); + for (TokenService tokenService : internalCluster().getInstances(TokenService.class)) { + PlainActionFuture userTokenFuture = new PlainActionFuture<>(); + tokenService.decodeToken(response.getTokenString(), userTokenFuture); + assertNotNull(userTokenFuture.actionGet()); + assertEquals(activeKeyHash, tokenService.getActiveKeyHash()); + } + client().admin().cluster().prepareHealth().execute().get(); + PlainActionFuture rotateActionFuture = new PlainActionFuture<>(); + logger.info("rotate on master: {}", masterName); + masterTokenService.rotateKeysOnMaster(rotateActionFuture); + assertTrue(rotateActionFuture.actionGet().isAcknowledged()); + assertNotEquals(activeKeyHash, masterTokenService.getActiveKeyHash()); + + for (TokenService tokenService : internalCluster().getInstances(TokenService.class)) { + PlainActionFuture userTokenFuture = new PlainActionFuture<>(); + tokenService.decodeToken(response.getTokenString(), userTokenFuture); + assertNotNull(userTokenFuture.actionGet()); + assertNotEquals(activeKeyHash, tokenService.getActiveKeyHash()); + } + } + public void testExpiredTokensDeletedAfterExpiration() throws Exception { final Client client = internalClient(); SecurityClient securityClient = new SecurityClient(client); @@ -53,6 +116,7 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase { .setUsername(SecuritySettingsSource.TEST_USER_NAME) .setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray())) .get(); + Instant created = Instant.now(); InvalidateTokenResponse invalidateResponse = securityClient.prepareInvalidateToken(response.getTokenString()).get(); @@ -126,4 +190,9 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase { assertTrue(done); } } + + public void testMetadataIsNotSentToClient() { + ClusterStateResponse clusterStateResponse = client().admin().cluster().prepareState().setCustoms(true).get(); + assertFalse(clusterStateResponse.getState().customs().containsKey(TokenMetaData.TYPE)); + } } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java index a25f99438c2..5b806b659de 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java @@ -6,12 +6,15 @@ package org.elasticsearch.xpack.security.authc; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.get.GetAction; import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.MockSecureSettings; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; @@ -29,11 +32,17 @@ import org.elasticsearch.xpack.security.authc.TokenService.BytesKey; import org.elasticsearch.xpack.security.user.User; import org.elasticsearch.xpack.support.clock.ClockMock; import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; import javax.crypto.SecretKey; +import java.io.IOException; +import java.security.GeneralSecurityException; import java.time.Clock; import java.util.Base64; +import java.util.Collections; +import java.util.concurrent.ExecutionException; import static org.elasticsearch.repositories.ESBlobStoreTestCase.randomBytes; import static org.hamcrest.Matchers.containsString; @@ -46,16 +55,16 @@ import static org.mockito.Mockito.when; public class TokenServiceTests extends ESTestCase { private InternalClient internalClient; - private ThreadPool threadPool; + private static ThreadPool threadPool; + private static final Settings settings = Settings.builder().put(Node.NODE_NAME_SETTING.getKey(), "TokenServiceTests").build(); + private Client client; private SecurityLifecycleService lifecycleService; + private ClusterService clusterService; @Before public void setupClient() { client = mock(Client.class); - Settings settings = Settings.builder().put(Node.NODE_NAME_SETTING.getKey(), "TokenServiceTests").build(); - threadPool = new ThreadPool(settings, - new FixedExecutorBuilder(settings, TokenService.THREAD_POOL_NAME, 1, 1000, "xpack.security.authc.token.thread_pool")); internalClient = new InternalClient(settings, threadPool, client); lifecycleService = mock(SecurityLifecycleService.class); when(lifecycleService.isSecurityIndexWriteable()).thenReturn(true); @@ -67,15 +76,24 @@ public class TokenServiceTests extends ESTestCase { return Void.TYPE; }).when(client).execute(eq(GetAction.INSTANCE), any(GetRequest.class), any(ActionListener.class)); when(client.threadPool()).thenReturn(threadPool); + this.clusterService = new ClusterService(settings, new ClusterSettings(settings, ClusterSettings + .BUILT_IN_CLUSTER_SETTINGS), threadPool, Collections.emptyMap()); } - @After - public void shutdownThreadpool() throws InterruptedException { + @BeforeClass + public static void startThreadPool() { + threadPool = new ThreadPool(settings, + new FixedExecutorBuilder(settings, TokenService.THREAD_POOL_NAME, 1, 1000, "xpack.security.authc.token.thread_pool")); + } + + @AfterClass + public static void shutdownThreadpool() throws InterruptedException { terminate(threadPool); + threadPool = null; } public void testAttachAndGetToken() throws Exception { - TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); final UserToken token = tokenService.createUserToken(authentication); assertNotNull(token); @@ -92,7 +110,9 @@ public class TokenServiceTests extends ESTestCase { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { // verify a second separate token service with its own salt can also verify - TokenService anotherService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService anotherService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService + , clusterService); + anotherService.refreshMetaData(tokenService.getTokenMetaData()); PlainActionFuture future = new PlainActionFuture<>(); anotherService.getAndValidateToken(requestContext, future); UserToken fromOtherService = future.get(); @@ -100,8 +120,144 @@ public class TokenServiceTests extends ESTestCase { } } + public void testRotateKey() throws Exception { + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); + Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); + final UserToken token = tokenService.createUserToken(authentication); + assertNotNull(token); + + ThreadContext requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + rotateKeys(tokenService); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + + final UserToken newToken = tokenService.createUserToken(authentication); + assertNotNull(newToken); + assertNotEquals(tokenService.getUserTokenString(newToken), tokenService.getUserTokenString(token)); + + requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(newToken)); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + } + + private void rotateKeys(TokenService tokenService) { + TokenMetaData tokenMetaData = tokenService.generateSpareKey(); + tokenService.refreshMetaData(tokenMetaData); + tokenMetaData = tokenService.rotateToSpareKey(); + tokenService.refreshMetaData(tokenMetaData); + } + + public void testKeyExchange() throws Exception { + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); + int numRotations = 0;randomIntBetween(1, 5); + for (int i = 0; i < numRotations; i++) { + rotateKeys(tokenService); + } + TokenService otherTokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, + clusterService); + otherTokenService.refreshMetaData(tokenService.getTokenMetaData()); + Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); + final UserToken token = tokenService.createUserToken(authentication); + assertNotNull(token); + + ThreadContext requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + otherTokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + + rotateKeys(tokenService); + + otherTokenService.refreshMetaData(tokenService.getTokenMetaData()); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + otherTokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + } + + public void testPruneKeys() throws Exception { + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); + Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); + final UserToken token = tokenService.createUserToken(authentication); + assertNotNull(token); + + ThreadContext requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token)); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + TokenMetaData metaData = tokenService.pruneKeys(randomIntBetween(0, 100)); + tokenService.refreshMetaData(metaData); + + int numIterations = scaledRandomIntBetween(1, 5); + for (int i = 0; i < numIterations; i++) { + rotateKeys(tokenService); + } + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + + final UserToken newToken = tokenService.createUserToken(authentication); + assertNotNull(newToken); + assertNotEquals(tokenService.getUserTokenString(newToken), tokenService.getUserTokenString(token)); + + metaData = tokenService.pruneKeys(1); + tokenService.refreshMetaData(metaData); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertNull(serialized); + } + + requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(newToken)); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.getAndValidateToken(requestContext, future); + UserToken serialized = future.get(); + assertEquals(authentication, serialized.getAuthentication()); + } + + } + public void testPassphraseWorks() throws Exception { - TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); final UserToken token = tokenService.createUserToken(authentication); assertNotNull(token); @@ -121,7 +277,7 @@ public class TokenServiceTests extends ESTestCase { MockSecureSettings secureSettings = new MockSecureSettings(); secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), randomAlphaOfLengthBetween(8, 30)); Settings settings = Settings.builder().setSecureSettings(secureSettings).build(); - TokenService anotherService = new TokenService(settings, Clock.systemUTC(), internalClient, lifecycleService); + TokenService anotherService = new TokenService(settings, Clock.systemUTC(), internalClient, lifecycleService, clusterService); PlainActionFuture future = new PlainActionFuture<>(); anotherService.getAndValidateToken(requestContext, future); assertNull(future.get()); @@ -130,7 +286,7 @@ public class TokenServiceTests extends ESTestCase { public void testInvalidatedToken() throws Exception { when(lifecycleService.isSecurityIndexAvailable()).thenReturn(true); - TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); final UserToken token = tokenService.createUserToken(authentication); assertNotNull(token); @@ -160,14 +316,14 @@ public class TokenServiceTests extends ESTestCase { public void testComputeSecretKeyIsConsistent() throws Exception { byte[] saltArr = new byte[32]; random().nextBytes(saltArr); - SecretKey key = TokenService.computeSecretKey(TokenService.DEFAULT_PASSPHRASE.toCharArray(), saltArr); - SecretKey key2 = TokenService.computeSecretKey(TokenService.DEFAULT_PASSPHRASE.toCharArray(), saltArr); + SecretKey key = TokenService.computeSecretKey("some random passphrase".toCharArray(), saltArr); + SecretKey key2 = TokenService.computeSecretKey("some random passphrase".toCharArray(), saltArr); assertArrayEquals(key.getEncoded(), key2.getEncoded()); } public void testTokenExpiry() throws Exception { ClockMock clock = ClockMock.frozen(); - TokenService tokenService = new TokenService(Settings.EMPTY, clock, internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, clock, internalClient, lifecycleService, clusterService); Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); final UserToken token = tokenService.createUserToken(authentication); @@ -215,7 +371,7 @@ public class TokenServiceTests extends ESTestCase { TokenService tokenService = new TokenService(Settings.builder() .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), false) .build(), - Clock.systemUTC(), internalClient, lifecycleService); + Clock.systemUTC(), internalClient, lifecycleService, clusterService); IllegalStateException e = expectThrows(IllegalStateException.class, () -> tokenService.createUserToken(null)); assertEquals("tokens are not enabled", e.getMessage()); @@ -257,7 +413,7 @@ public class TokenServiceTests extends ESTestCase { final int numBytes = randomIntBetween(1, TokenService.MINIMUM_BYTES + 32); final byte[] randomBytes = new byte[numBytes]; random().nextBytes(randomBytes); - TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); ThreadContext requestContext = new ThreadContext(Settings.EMPTY); requestContext.putHeader("Authorization", "Bearer " + Base64.getEncoder().encodeToString(randomBytes)); @@ -270,7 +426,7 @@ public class TokenServiceTests extends ESTestCase { } public void testIndexNotAvailable() throws Exception { - TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService); + TokenService tokenService = new TokenService(Settings.EMPTY, Clock.systemUTC(), internalClient, lifecycleService, clusterService); Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); final UserToken token = tokenService.createUserToken(authentication); assertNotNull(token); @@ -291,4 +447,27 @@ public class TokenServiceTests extends ESTestCase { assertNull(future.get()); } } + + + public void testDecodePre6xToken() throws GeneralSecurityException, ExecutionException, InterruptedException, IOException { + String token = "g+y0AiDWsbLNzUGTywPa3VCz053RUPW7wAx4xTAonlcqjOmO1AzMhQDTUku/+ZtdtMgDobKqIrNdNvchvFMX0pvZLY6i4nAG2OhkApSstPfQQP" + + "J1fxg/JZNQDPufePg1GxV/RAQm2Gr8mYAelijEVlWIdYaQ3R76U+P/w6Q1v90dGVZQn6DKMOfgmkfwAFNY"; + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(TokenService.TOKEN_PASSPHRASE.getKey(), "xpack_token_passpharse"); + Settings settings = Settings.builder().setSecureSettings(secureSettings).build(); + TokenService tokenService = new TokenService(settings, Clock.systemUTC(), internalClient, lifecycleService, clusterService); + Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null); + ThreadContext requestContext = new ThreadContext(Settings.EMPTY); + requestContext.putHeader("Authorization", "Bearer " + token); + + try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { + PlainActionFuture future = new PlainActionFuture<>(); + tokenService.decodeToken(tokenService.getFromHeader(requestContext), future); + UserToken serialized = future.get(); + assertNotNull(serialized); + assertEquals("joe", serialized.getAuthentication().getUser().principal()); + assertEquals(Version.V_5_6_0, serialized.getAuthentication().getVersion()); + assertArrayEquals(new String[] {"admin"}, serialized.getAuthentication().getUser().roles()); + } + } } diff --git a/qa/rolling-upgrade/build.gradle b/qa/rolling-upgrade/build.gradle index 6c6da27e0e3..929604d55e8 100644 --- a/qa/rolling-upgrade/build.gradle +++ b/qa/rolling-upgrade/build.gradle @@ -124,6 +124,8 @@ subprojects { setting 'xpack.security.transport.ssl.enabled', 'true' setting 'xpack.ssl.keystore.path', 'testnode.jks' setting 'xpack.ssl.keystore.password', 'testnode' + // this is needed until the token service changes are backported to 6.x + keystoreSetting 'xpack.security.authc.token.passphrase', 'xpack_token_passphrase' dependsOn copyTestNodeKeystore extraConfigFile 'testnode.jks', new File(outputDir + '/testnode.jks') if (withSystemKey) { @@ -160,6 +162,7 @@ subprojects { waitCondition = waitWithAuth setting 'xpack.ssl.keystore.path', 'testnode.jks' keystoreSetting 'xpack.ssl.keystore.secure_password', 'testnode' + keystoreSetting 'xpack.security.authc.token.passphrase', 'xpack_token_passphrase' setting 'node.attr.upgraded', 'first' dependsOn copyTestNodeKeystore extraConfigFile 'testnode.jks', new File(outputDir + '/testnode.jks') @@ -190,6 +193,7 @@ subprojects { waitCondition = waitWithAuth setting 'xpack.ssl.keystore.path', 'testnode.jks' keystoreSetting 'xpack.ssl.keystore.secure_password', 'testnode' + keystoreSetting 'xpack.security.authc.token.passphrase', 'xpack_token_passphrase' dependsOn copyTestNodeKeystore extraConfigFile 'testnode.jks', new File(outputDir + '/testnode.jks') if (withSystemKey) { diff --git a/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/50_token_auth.yml b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/50_token_auth.yml new file mode 100644 index 00000000000..093902f8d0a --- /dev/null +++ b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/mixed_cluster/50_token_auth.yml @@ -0,0 +1,50 @@ +--- +"Get the indexed token and use if to authenticate": + - skip: + features: headers + + - do: + get: + index: token_index + type: doc + id: "6" + + - match: { _index: token_index } + - match: { _type: doc } + - match: { _id: "6" } + - is_true: _source.token + - set: { _source.token : token } + + - do: + headers: + Authorization: Bearer ${token} + xpack.security.authenticate: {} + + - match: { username: "token_user" } + - match: { roles.0: "superuser" } + - match: { full_name: "Token User" } + + - do: + headers: + Authorization: Bearer ${token} + search: + index: token_index + + - match: { hits.total: 6 } + + - do: + headers: + Authorization: Bearer ${token} + search: + index: token_index + + - match: { hits.total: 6 } + + - do: + headers: + Authorization: Bearer ${token} + search: + index: token_index + + - match: { hits.total: 6 } + diff --git a/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/50_token_auth.yml b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/50_token_auth.yml new file mode 100644 index 00000000000..a2f916c272b --- /dev/null +++ b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/old_cluster/50_token_auth.yml @@ -0,0 +1,91 @@ +--- +"Create a token and reuse it across the upgrade": + - skip: + features: headers + + - do: + cluster.health: + wait_for_status: yellow + + - do: + xpack.security.put_user: + username: "token_user" + body: > + { + "password" : "x-pack-test-password", + "roles" : [ "superuser" ], + "full_name" : "Token User" + } + + - do: + xpack.security.get_token: + body: + grant_type: "password" + username: "token_user" + password: "x-pack-test-password" + + - match: { type: "Bearer" } + - is_true: access_token + - set: { access_token: token } + - match: { expires_in: 1200 } + - is_false: scope + + - do: + headers: + Authorization: Bearer ${token} + xpack.security.authenticate: {} + + - match: { username: "token_user" } + - match: { roles.0: "superuser" } + - match: { full_name: "Token User" } + + - do: + indices.create: + index: token_index + wait_for_active_shards : all + body: + settings: + index: + number_of_replicas: 1 + + - do: + headers: + Authorization: Bearer ${token} + bulk: + refresh: true + body: + - '{"index": {"_index": "token_index", "_type": "doc", "_id" : "1"}}' + - '{"f1": "v1_old", "f2": 0}' + - '{"index": {"_index": "token_index", "_type": "doc", "_id" : "2"}}' + - '{"f1": "v2_old", "f2": 1}' + - '{"index": {"_index": "token_index", "_type": "doc", "_id" : "3"}}' + - '{"f1": "v3_old", "f2": 2}' + - '{"index": {"_index": "token_index", "_type": "doc", "_id" : "4"}}' + - '{"f1": "v4_old", "f2": 3}' + - '{"index": {"_index": "token_index", "_type": "doc", "_id" : "5"}}' + - '{"f1": "v5_old", "f2": 4}' + + - do: + headers: + Authorization: Bearer ${token} + indices.flush: + index: token_index + + - do: + headers: + Authorization: Bearer ${token} + search: + index: token_index + + - match: { hits.total: 5 } + + # we do store the token in the index such that we can reuse it down the road once + # the cluster is upgraded + - do: + headers: + Authorization: Bearer ${token} + index: + index: token_index + type: doc + id: "6" + body: { "token" : "${token}"} diff --git a/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/50_token_auth.yml b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/50_token_auth.yml new file mode 100644 index 00000000000..9f576512fc7 --- /dev/null +++ b/qa/rolling-upgrade/src/test/resources/rest-api-spec/test/upgraded_cluster/50_token_auth.yml @@ -0,0 +1,40 @@ +--- +"Get the indexed token and use if to authenticate": + - skip: + features: headers + - do: + get: + index: token_index + type: doc + id: "6" + + - match: { _index: token_index } + - match: { _type: doc } + - match: { _id: "6" } + - is_true: _source.token + - set: { _source.token : token } + + - do: + headers: + Authorization: Bearer ${token} + xpack.security.authenticate: {} + + - match: { username: "token_user" } + - match: { roles.0: "superuser" } + - match: { full_name: "Token User" } + + - do: + headers: + Authorization: Bearer ${token} + search: + index: token_index + + - match: { hits.total: 6 } + + # counter example that we are really checking this + - do: + headers: + Authorization: Bearer boom + catch: /missing authentication token/ + search: + index: token_index