diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java index 56ca0ddfe82f1..5ace37c4c30f9 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/UsernamePasswordToken.java @@ -126,7 +126,7 @@ public static void putTokenHeader(ThreadContext context, UsernamePasswordToken t /** * Like String.indexOf for for an array of chars */ - private static int indexOfColon(char[] array) { + public static int indexOfColon(char[] array) { for (int i = 0; (i < array.length); i++) { if (array[i] == ':') { return i; diff --git a/x-pack/plugin/security/qa/service-account/build.gradle b/x-pack/plugin/security/qa/service-account/build.gradle new file mode 100644 index 0000000000000..a2875d6f0bfc9 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'elasticsearch.java-rest-test' + +dependencies { + javaRestTestImplementation project(':x-pack:plugin:core') + javaRestTestImplementation project(':client:rest-high-level') + javaRestTestImplementation project(':x-pack:plugin:security') + // let the javaRestTest see the classpath of main + javaRestTestImplementation project.sourceSets.main.runtimeClasspath +} + +testClusters.javaRestTest { + testDistribution = 'DEFAULT' + numberOfNodes = 2 + + extraConfigFile 'node.key', file('src/javaRestTest/resources/ssl/node.key') + extraConfigFile 'node.crt', file('src/javaRestTest/resources/ssl/node.crt') + extraConfigFile 'ca.crt', file('src/javaRestTest/resources/ssl/ca.crt') + extraConfigFile 'service_tokens', file('src/javaRestTest/resources/service_tokens') + + setting 'xpack.ml.enabled', 'false' + setting 'xpack.license.self_generated.type', 'trial' + + setting 'xpack.security.enabled', 'true' + setting 'xpack.security.authc.token.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' + + setting 'xpack.security.http.ssl.enabled', 'true' + setting 'xpack.security.http.ssl.certificate', 'node.crt' + setting 'xpack.security.http.ssl.key', 'node.key' + setting 'xpack.security.http.ssl.certificate_authorities', 'ca.crt' + + setting 'xpack.security.transport.ssl.enabled', 'true' + setting 'xpack.security.transport.ssl.certificate', 'node.crt' + setting 'xpack.security.transport.ssl.key', 'node.key' + setting 'xpack.security.transport.ssl.certificate_authorities', 'ca.crt' + setting 'xpack.security.transport.ssl.verification_mode', 'certificate' + + keystore 'bootstrap.password', 'x-pack-test-password' + keystore 'xpack.security.transport.ssl.secure_key_passphrase', 'node-password' + keystore 'xpack.security.http.ssl.secure_key_passphrase', 'node-password' + + user username: "test_admin", password: 'x-pack-test-password', role: "superuser" + user username: "elastic/fleet", password: 'x-pack-test-password', role: "superuser" +} diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java b/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java new file mode 100644 index 0000000000000..767fa5b0a9fb6 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.service; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.junit.BeforeClass; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; + +public class ServiceAccountIT extends ESRestTestCase { + + private static final String VALID_SERVICE_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"; + private static final String INVALID_SERVICE_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOjNhSkNMYVFXUk4yc1hsT2R0eEEwU1E"; + private static Path caPath; + + private static final String AUTHENTICATE_RESPONSE = "" + + "{\n" + + " \"username\": \"elastic/fleet\",\n" + + " \"roles\": [],\n" + + " \"full_name\": \"Service account - elastic/fleet\",\n" + + " \"email\": null,\n" + + " \"metadata\": {\n" + + " \"_elastic_service_account\": true\n" + + " },\n" + " \"enabled\": true,\n" + + " \"authentication_realm\": {\n" + + " \"name\": \"service_account\",\n" + + " \"type\": \"service_account\"\n" + + " },\n" + + " \"lookup_realm\": {\n" + + " \"name\": \"service_account\",\n" + + " \"type\": \"service_account\"\n" + + " },\n" + + " \"authentication_type\": \"token\"\n" + + "}\n"; + + @BeforeClass + public static void init() throws URISyntaxException, FileNotFoundException { + URL resource = ServiceAccountIT.class.getResource("/ssl/ca.crt"); + if (resource == null) { + throw new FileNotFoundException("Cannot find classpath resource /ssl/ca.crt"); + } + caPath = PathUtils.get(resource.toURI()); + } + + @Override + protected String getProtocol() { + // Because http.ssl.enabled = true + return "https"; + } + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); + return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token) + .put(CERTIFICATE_AUTHORITIES, caPath) + .build(); + } + + public void testAuthenticate() throws IOException { + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + VALID_SERVICE_TOKEN)); + final Response response = client().performRequest(request); + assertOK(response); + assertThat(responseAsMap(response), + equalTo(XContentHelper.convertToMap(new BytesArray(AUTHENTICATE_RESPONSE), false, XContentType.JSON).v2())); + } + + public void testAuthenticateShouldNotFallThroughInCaseOfFailure() throws IOException { + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + INVALID_SERVICE_TOKEN)); + final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(request)); + assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(401)); + assertThat(e.getMessage(), containsString("failed to authenticate service account [elastic/fleet] with token name [token1]")); + } + + public void testAuthenticateShouldWorkWithOAuthBearerToken() throws IOException { + final Request oauthTokenRequest = new Request("POST", "_security/oauth2/token"); + oauthTokenRequest.setJsonEntity("{\"grant_type\":\"password\",\"username\":\"test_admin\",\"password\":\"x-pack-test-password\"}"); + final Response oauthTokenResponse = client().performRequest(oauthTokenRequest); + assertOK(oauthTokenResponse); + final Map oauthTokenResponseMap = responseAsMap(oauthTokenResponse); + final String accessToken = (String) oauthTokenResponseMap.get("access_token"); + + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Bearer " + accessToken)); + final Response response = client().performRequest(request); + assertOK(response); + final Map responseMap = responseAsMap(response); + assertThat(responseMap.get("username"), equalTo("test_admin")); + assertThat(responseMap.get("authentication_type"), equalTo("token")); + + final String refreshToken = (String) oauthTokenResponseMap.get("refresh_token"); + final Request refreshTokenRequest = new Request("POST", "_security/oauth2/token"); + refreshTokenRequest.setJsonEntity("{\"grant_type\":\"refresh_token\",\"refresh_token\":\"" + refreshToken + "\"}"); + final Response refreshTokenResponse = client().performRequest(refreshTokenRequest); + assertOK(refreshTokenResponse); + } + + public void testAuthenticateShouldDifferentiateBetweenNormalUserAndServiceAccount() throws IOException { + final Request request = new Request("GET", "_security/_authenticate"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader( + "Authorization", basicAuthHeaderValue("elastic/fleet", new SecureString("x-pack-test-password".toCharArray())) + )); + final Response response = client().performRequest(request); + assertOK(response); + final Map responseMap = responseAsMap(response); + + assertThat(responseMap.get("username"), equalTo("elastic/fleet")); + assertThat(responseMap.get("authentication_type"), equalTo("realm")); + assertThat(responseMap.get("roles"), equalTo(List.of("superuser"))); + Map authRealm = (Map) responseMap.get("authentication_realm"); + assertThat(authRealm, hasEntry("type", "file")); + } +} diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens new file mode 100644 index 0000000000000..e3bc027f4e6d3 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/service_tokens @@ -0,0 +1 @@ +elastic/fleet/token1:{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk= diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc new file mode 100644 index 0000000000000..d91e5653cdef9 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/README.asciidoc @@ -0,0 +1,37 @@ += Keystore Details +This document details the steps used to create the certificate and keystore files in this directory. + +== Instructions on generating certificates + +The certificates in this directory have been generated using elasticsearch-certutil (8.0.0 SNAPSHOT) + +=== Certificates for security the HTTP server +[source,shell] +----------------------------------------------------------------------------------------------------------- +elasticsearch-certutil ca --pem --out=${PWD}/ca.zip --pass="ca-password" --days=3500 +unzip ca.zip +mv ca/ca.crt ./ca.crt +mv ca/ca.key ./ca.key + +rm ca.zip +rmdir ca +----------------------------------------------------------------------------------------------------------- + +[source,shell] +----------------------------------------------------------------------------------------------------------- +elasticsearch-certutil cert --pem --name=node --out=${PWD}/node.zip --pass="node-password" --days=3500 \ + --ca-cert=${PWD}/ca.crt --ca-key=${PWD}/ca.key --ca-pass="ca-password" \ + --dns=localhost --dns=localhost.localdomain --dns=localhost4 --dns=localhost4.localdomain4 --dns=localhost6 --dns=localhost6.localdomain6 \ + --ip=127.0.0.1 --ip=0:0:0:0:0:0:0:1 + +unzip node.zip +mv node/node.* ./ + +rm node.zip +rmdir node +----------------------------------------------------------------------------------------------------------- + +[source,shell] +----------------------------------------------------------------------------------------------------------- +keytool -importcert -file ca.crt -keystore ca.p12 -storetype PKCS12 -storepass "password" -alias ca +----------------------------------------------------------------------------------------------------------- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt new file mode 100644 index 0000000000000..ccfdadcab6d14 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSTCCAjGgAwIBAgIUG4Vi/zqBSBJT7DgRTFDQwh4ShlQwDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l +cmF0ZWQgQ0EwHhcNMjEwMzE4MDIyNjAyWhcNMzAxMDE3MDIyNjAyWjA0MTIwMAYD +VQQDEylFbGFzdGljIENlcnRpZmljYXRlIFRvb2wgQXV0b2dlbmVyYXRlZCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIfrBgvsv/i4v6bAtfZTCIBY ++OdhW6d2aF5LSPClruryqmp2vNWhGTEkcqe6EcFe+JRc+E+CnW0nXWslWf6kLxOJ +VR5kjuT7LZ1tGbm70joh5V1t79NXu+BC0B/ET6T/BDzjnrDlt+AsFmR+F348UftY +Y04NZRy+gRh9SxS0Y4riDGj0pWWJkPBK314JXf8rJe1RiYGfNl5OgAljGrs7sHAn +1AO2nEH8Ihad3V55dtMIMXHGQTWkIx+QK25cGpySB78CXR432BmRMieMHZ5z1ELL +A658Kco22HDmbNk4o51r/2AXs1fxcPTVZwK3n5tvC2hABXuILE7ck9A3LyGRZGMC +AwEAAaNTMFEwHQYDVR0OBBYEFNlY6G4x4gG5/lRF8fO6knZaOzzlMB8GA1UdIwQY +MBaAFNlY6G4x4gG5/lRF8fO6knZaOzzlMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAD4e1fOX00AT3bxXHyJd4tT6g40bxAmZhrtFDkoxX86Vp2bp +h+XfUfr54ziVzIbVynYMJ759mdB4BN8oZNTbOpmz/hNbz5skd2wIdAw/oZqAsOiW +l+OZLaaQYVfLesuBUJfxU7JvZeF0rB2F0ODc8BJz0Q6Mjbvj8fyCbSIQS01PjATN +0zeFQYuwJaQgTLVTU9jQYIbNBgCUuVmOW6IDF6QULtbCuH1Wtyr3u2I2nWfpyDhF +u7PY5Qh/O13rRy5o6NJofxaa3nU1PJalQzIA6ExA8ajol4ywiFtAyCVLYuJMKDt9 +HN0WWGAbhCPc/6i5KzNv6vW8EaWAOlAt2t1/7LU= +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key new file mode 100644 index 0000000000000..4438c4e59b247 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.key @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,AD07A96A73827285800BF6F4C8C37988 + +9F4L3SRxQaSkcmW72PaiPDDPNUW9zdoWy2VvSaKUp7cWCupUpF3gqvIwdpr/sHj5 +Jh4gfWCzASy2yb+Q/OAbeq2Nl5P7p6klDjBtDFVlLXmIearRiXBUgi7i55lic2nB +3zpUzBeXiqWxAiFTl1vhBB0DVexy0Ob7Hf3A7Zp669UQiMquplaGg+KtNVh2IxvJ +vZmV+danHJpTqd4CnC93J4l/4tH3/ZYHPydqe1a7Bhe0BwMurOqtoosuzF0BQMam +BcDVpyeRzg7C+ST1sZq+D/F1OpNvOOCE0hBjHg4NWdqyhiRLLwcbyEUutsyWo5zJ +QCnBiznVzeEobwFdglCLoe+fVFWVNe2fddX541kfcHRXozDvNbRMrkPwqWHzLLBc +bFn9PV3QSYoWE6Pee4/ibX4TYwe8yfxBBg5BpQQV+zjyBaXDQM6NNHMPxSE7YoD1 +TGAjQXwajse4uG0WRwOMgNHU9mzkMBLkv8s03PYmPXbnJkxd2jZSQoZ8FZrHQDXQ +oiMh6zMRDCiQRVrz7NwYN9uS5dwnj7fQDex5uyegIw7He57LuFJ92s7fqYAoaOtO +9QDRD5ky+q9+XN4T/3mOIaHTKNF5/kuN0eXH0vGVGWlNo2h+MBXGn+aA1p/97Cym +tZzmyAqDiXg9DhNMdHJor7DOQa9CCp5YxYYO5rzMa5ElvKIcOEmYkf1MTLq0Al/t +hYC5bL07aQ0sVhA+QW8kfxLkFT+u14rMlp6PJ9/KMLVBRoQWswwBMTBnocSwejkx +lZaGWjzpptQ3VqgSBOtEDjamItSFiZeN2ntwOckauVSRJZDig/q5yLgIlwrqxtDH +Sqh3u6JysIcBCcGg9U1q9AzxzFD8I4P8DwzUd56mbp3eR5iMvGsKcXbwlLvx/dSX +HVs0S7bEUr5WavmSIGwwrHtRO/l3STJNC1W7YxVKhBCxgz46DqADXbHuMvt8ZB13 +Zs94eEDA0vmPQnOilIG200V4OP3Uz8UP9HcNrhkLGuyCe+RIvv6NOwtq/O9YmazR +tmlcyrXEkvb5wubVg0zDlPpMBlYHGBEVl2vnVlNFHbsLkc55WahEbdpynnx3gYid +o4a5I/ywqaMou6ZTtOXZXc+0WuqjsLFAKmytZJtnktScGwJ+3JPWR51pi9j9q9W7 +oTnsyO4/a0nSZTNSGI2hxrmss5Y75bN/ydFuMhwd/GEiupKG40ZF+9hcGrqZRddM +uf0WoRvD5n611Bg8s9nwBMUjN7BFzu+a91s1W8LwwXUTZwkkyhkg/VUCKYbOH329 +Q6lZLb5nvvzEN/1HH/w0Bkl1jKBJSskw/R6zUGyviP1Sr3ZGkvUSvwXhrRHqI8MN +83t5AzZ6hivzy7rzCI/UsKoUx2/ef63TcvgLb/Vf85anuRR08Xcv/XIl775UvibQ +fAA0PE07sbYbO7vwRbv1bLhcPmA3wMsu0v/6Ohcv15uFFgUr/e9zhv5seP0tHdeR +ZKSbqlwfGRgp0smXPWJzIGG3g+lkadrfwTBuzgdjI8V/C+nEMk1eYy8SJd/CmfdG +IgZYMUWhc6GCcaq+eJ9VGVdgFkQU6aGTm4wNpmWPuDk/YDFo7ik48OrMvx67j1kz +-----END RSA PRIVATE KEY----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 new file mode 100644 index 0000000000000..e79ddffd71981 Binary files /dev/null and b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/ca.p12 differ diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt new file mode 100644 index 0000000000000..7b1bc7a5f5586 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDszCCApugAwIBAgIVAO2bFGZI6jJKeo1hea8Yc+RvY1J7MA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMB4XDTIxMDMxODAyMjYzMloXDTMwMTAxNzAyMjYzMlowDzENMAsG +A1UEAxMEbm9kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ7uDRl +d/wKlUkesl1jegzQcFw9po54Mt2O3UTOYBkFWg6amAIyA8Izkavkoh/kQZAR2gqa +O65jqO/rNRrNBlyX2x+IOm0XmDC1ZmHoOBIxaCZUGVqwkeHNxcb5TmVFbYAcRGOJ +b54v42SEarVoqJS9iQaGb7ScKTeQ7XWyPGImReVNwE7SJNWwuABTXMe9c6VtvZpY +xu1SX+gYVk7aWQ0p3ukHKJXrPfXYXSgozF3tKtFQvUrL1VjHEVWqWoBqjIbhl3X8 +eqkzxwC1y+8Zbp3Os9Y8PzHQ4etXG7UAPFRopy5MivlDxZ2u5DpVW/6Yy1B7i6Mp +9Leu2NPNZ7ul/iECAwEAAaOB4DCB3TAdBgNVHQ4EFgQUYVaPvntroOl+zfW5vDFg +Kvmmj1MwHwYDVR0jBBgwFoAU2VjobjHiAbn+VEXx87qSdlo7POUwgY8GA1UdEQSB +hzCBhIIJbG9jYWxob3N0ghdsb2NhbGhvc3Q2LmxvY2FsZG9tYWluNocEfwAAAYcQ +AAAAAAAAAAAAAAAAAAAAAYIKbG9jYWxob3N0NIIKbG9jYWxob3N0NoIVbG9jYWxo +b3N0LmxvY2FsZG9tYWlughdsb2NhbGhvc3Q0LmxvY2FsZG9tYWluNDAJBgNVHRME +AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAdP/Z/tDOWkM5Eob+6FwIJuM9Pe9+NOwUL ++0qrHNHDt5ITyUf/C/l6yfXgbkvoLRa9QefN0cxy0ru8ew3nUUn7US0EfWF0yrza +M8BwznKKh6cs4AiFUdDliBgyqAzYubcZ6G6Trm3Tdh334bAQKM7M1TOvZa8jwXXb +6T1PUs/2RCWE7nLxBooDTik86phUm65oVtTqoO0c4XbQzzTfRrF7Oy3kmqpKsrzv +UDB4G4TAfGyybdystyEqPPVX3KESV9PDcxpO01R2/BWi49E4YmdL4PitIA/v7iAk +SH0UISQNjDpncRz9mGrt8LrA+O2Canqiq3xXeHJEhU5/KPCPcsrm +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key new file mode 100644 index 0000000000000..3ec434b717a99 --- /dev/null +++ b/x-pack/plugin/security/qa/service-account/src/javaRestTest/resources/ssl/node.key @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,4A5CF28950363F663AA997154AC331F9 + +oHO/8oRnLXHTCljeqW90XBa/pDmLHUwRo82hy6732bSUTrXuBwUopuzcj6r8QzbQ +1ZyCbja0wwWaQ5TuNX3ehseiaBUKWgCLYYjd2IfFsfyFmvVAcPqnltyKMRvpLkFj +NeGyKFXmfxT3rmvzrmId4KkRYCHvH+j3RKfJ0wuhduzv9sH3xfmEe521l2F8Vukq +zVNMRPT9FHlSYhM1h26WpBlzx6Wq7EfP7KdyUtmIZ5/RFJjELG5rUyLgZHDqfKCy +LdNPpOuBdpYuBC+Oy97p2YuaFSLPkkKhiI4MG4MYsOnCmEFBNup9OhF3U/t/ffXh +knTjXh2fX7h8RJ9pH/8czG+O6cZoe5O/1/Ympo+ghS7QYDUtDrNS5M4MI+eP+WiA +X3cev3VkugDw4dDSPq3i3E0oCRZesMpst2W6AtVcpa5EWRM75PVuUws0XY/V/ca0 +CdUO6CPVIAAT3urmJWC1reiNhkEMDrskOL1PnsrseGvOmCLava9xYjiAS6JGawm/ +kWN3unJ6BwlU0NkIEbj8OGHdiKAjNWr0HLR34Xa2gqup5pGVD8EoC20ZPjeDXZ2j +oEfuLo2ZaF5CWDt0CEcdN7v/JtXC9QJjf0BAMHKiULhPzv9rNfqj6xZKkNxgVrW/ +D2/Jpyn5qt6BDiyzG0jaO7AzIk3BTBksdf+5myc5/0NA+kdC9aKZKmeLAazCAK1G +CwtfTs1xF4tMj1P+GRD4DOwypml1OK528BSl+Ydubt2uc37hRsA2EctEEjy+vy2r +pR0akSVs2a4d00p26lWt5RP/h85KJdWwNj/YwRmRxWWMpNd/C4NrGgB5Ehs8CHFk +uQZOaAKXWuy/bPGKG+JdXqEM5CiasNqoJn0UBle2dOpG08Ezb19vHFgNSOuvrxEv +oxkklXzyw+JMyskmD67MxQBsHcxW4g+501OMhIb2J36LNsOMQxzjIpS2jia/P1lh +9R4WohPxKf7Hi0Ui6oQRC7i2USmisuHIlVAmv14AjiISJdhXVOFtu+hVWrCHqHEg +GWRj560G1WwT5EHZr4KN+6IRX6mCKJAO1XjSz5rPfDpet5OQGIr7N+lJwWE03kJs +6Pd8K0OYW+2rbwqFd4YugF18HQlA1T5aok4fj2P+BTOuCNfvf0ZZXFeBn45zgtZI +G/puduRwRRyUzB+XTzhN8o6dfuBjasq6U0/ZFDRKKJnAOAq/fmVxr51+zKvZ0T5B +NSPbD9wUdnABqGCR+y9AL63QP0iVrkLlKzjgUYdlb1lw4TnmLGadmfYaZoOtWH2c +FOucH3VVfinY7Q9EE5/EF5EHeG3pe3I3UHXTbAvcxvuhCByFZd6qe3Vz4AGcQLoT +ProWJzmjeElfziX4e4Ol6tNSAxwL+vhjn4KmvF4mFx6n+QMAyp8lEmPsYgnsT/n9 +pkdnk0VdLGQmp8eKExvvDfiDTagDnh6wr7Nys1VLBADIthsRW4Gdft02q3tFOyae +WpeZent5x28yRPbNgDtoStjqc0yQPdXVFuAsLzA6NT8ujlOhJCnmiPYOurGis0Ch +hQLV+kr5EybbUHGjMB01elqTXy2VTMEqQ/7TQdsy6vIDYeBq5t491t9P/TeeS5Om +-----END RSA PRIVATE KEY----- diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java index b360325ab34d7..884cfeb798af4 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java @@ -214,6 +214,10 @@ protected String configOperatorUsers() { return SECURITY_DEFAULT_SETTINGS.configOperatorUsers(); } + protected String configServiceTokens() { + return SECURITY_DEFAULT_SETTINGS.configServiceTokens(); + } + /** * Allows to override the node client username */ @@ -261,6 +265,11 @@ protected String configOperatorUsers() { return SecuritySingleNodeTestCase.this.configOperatorUsers(); } + @Override + protected String configServiceTokens() { + return SecuritySingleNodeTestCase.this.configServiceTokens(); + } + @Override protected String nodeClientUsername() { return SecuritySingleNodeTestCase.this.nodeClientUsername(); diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java new file mode 100644 index 0000000000000..22dd5ce5d5fca --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountSingleNodeTests.java @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.service; + +import org.elasticsearch.Version; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.Strings; +import org.elasticsearch.node.Node; +import org.elasticsearch.test.SecuritySingleNodeTestCase; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateAction; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateRequest; +import org.elasticsearch.xpack.core.security.action.user.AuthenticateResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.User; + +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; + +public class ServiceAccountSingleNodeTests extends SecuritySingleNodeTestCase { + + private static final String BEARER_TOKEN = "AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"; + + @Override + protected String configServiceTokens() { + return super.configServiceTokens() + + "elastic/fleet/token1:" + + "{PBKDF2_STRETCH}10000$8QN+eThJEaCd18sCP0nfzxJq2D9yhmSZgI20TDooYcE=$+0ELfqW4D2+/SlHvm/885dzv67qO2SMJg32Mv/9epXk="; + } + + public void testAuthenticateWithServiceFileToken() { + final AuthenticateRequest authenticateRequest = new AuthenticateRequest("elastic/fleet"); + final AuthenticateResponse authenticateResponse = + createServiceAccountClient().execute(AuthenticateAction.INSTANCE, authenticateRequest).actionGet(); + final String nodeName = node().settings().get(Node.NODE_NAME_SETTING.getKey()); + assertThat(authenticateResponse.authentication(), equalTo( + new Authentication( + new User("elastic/fleet", Strings.EMPTY_ARRAY, "Service account - elastic/fleet", null, + Map.of("_elastic_service_account", true), true), + new Authentication.RealmRef("service_account", "service_account", nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, Map.of("_token_name", "token1") + ) + )); + } + + private Client createServiceAccountClient() { + return client().filterWithHeader(Map.of("Authorization", "Bearer " + BEARER_TOKEN)); + } +} 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 dca1547f15890..0cd1aee8ded8a 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 @@ -200,6 +200,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.service.FileServiceAccountsTokenStore; import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; import org.elasticsearch.xpack.security.authc.service.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore; import org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator; @@ -491,8 +492,10 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste clusterService, cacheInvalidatorRegistry, threadPool); components.add(apiKeyService); - final ServiceAccountService serviceAccountService = - new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of())); + final ServiceAccountService serviceAccountService = new ServiceAccountService( + new CompositeServiceAccountsTokenStore( + List.of(new FileServiceAccountsTokenStore(environment, resourceWatcherService, threadPool)), + threadPool.getThreadContext())); final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, privilegeStore, rolesProviders, threadPool.getThreadContext(), getLicenseState(), fieldPermissionsCache, apiKeyService, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java index 96f94671f8481..0e5d05a298378 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/AuthenticationService.java @@ -19,6 +19,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; @@ -47,6 +48,7 @@ import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; +import org.elasticsearch.xpack.security.authc.service.ServiceAccountToken; import org.elasticsearch.xpack.security.authc.support.RealmUserLookup; import org.elasticsearch.xpack.security.operator.OperatorPrivileges.OperatorPrivilegesService; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -333,27 +335,44 @@ private void authenticateAsync() { logger.trace("Found existing authentication [{}] in request [{}]", authentication, request); listener.onResponse(authentication); } else { - tokenService.getAndValidateToken(threadContext, ActionListener.wrap(userToken -> { - if (userToken != null) { - writeAuthToContext(userToken.getAuthentication()); - } else { - checkForApiKey(); - } - }, e -> { - logger.debug(new ParameterizedMessage("Failed to validate token authentication for request [{}]", request), e); - if (e instanceof ElasticsearchSecurityException && - tokenService.isExpiredTokenException((ElasticsearchSecurityException) e) == false) { - // intentionally ignore the returned exception; we call this primarily - // for the auditing as we already have a purpose built exception - request.tamperedRequest(); - } - listener.onFailure(e); - })); + checkForBearerToken(); } }); } } + private void checkForBearerToken() { + final SecureString bearerString = tokenService.extractBearerTokenFromHeader(threadContext); + final ServiceAccountToken serviceAccountToken = ServiceAccountService.tryParseToken(bearerString); + if (serviceAccountToken != null) { + serviceAccountService.authenticateToken(serviceAccountToken, nodeName, ActionListener.wrap(authentication -> { + assert authentication != null : "service account authenticate should return either authentication or call onFailure"; + this.authenticatedBy = authentication.getAuthenticatedBy(); + writeAuthToContext(authentication); + }, e -> { + logger.debug(new ParameterizedMessage("Failed to validate service account token for request [{}]", request), e); + listener.onFailure(request.exceptionProcessingRequest(e, serviceAccountToken)); + })); + } else { + tokenService.tryAuthenticateToken(bearerString, ActionListener.wrap(userToken -> { + if (userToken != null) { + writeAuthToContext(userToken.getAuthentication()); + } else { + checkForApiKey(); + } + }, e -> { + logger.debug(new ParameterizedMessage("Failed to validate token authentication for request [{}]", request), e); + if (e instanceof ElasticsearchSecurityException + && false == tokenService.isExpiredTokenException((ElasticsearchSecurityException) e)) { + // intentionally ignore the returned exception; we call this primarily + // for the auditing as we already have a purpose built exception + request.tamperedRequest(); + } + listener.onFailure(e); + })); + } + } + private void checkForApiKey() { apiKeyService.authenticateWithApiKeyIfPresent(threadContext, ActionListener.wrap(authResult -> { if (authResult.isAuthenticated()) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index 4a2b9cc6cdf91..13f403fc5113a 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -200,6 +200,7 @@ public final class TokenService { static final Version VERSION_TOKENS_INDEX_INTRODUCED = Version.V_7_2_0; static final Version VERSION_ACCESS_TOKENS_AS_UUIDS = Version.V_7_2_0; static final Version VERSION_MULTIPLE_CONCURRENT_REFRESHES = Version.V_7_2_0; + private static final Logger logger = LogManager.getLogger(TokenService.class); private final SecureRandom secureRandom = new SecureRandom(); @@ -379,30 +380,12 @@ public static String hashTokenString(String accessTokenString) { } /** - * Looks in the context to see if the request provided a header with a user token and if so the - * token is validated, which might include authenticated decryption and verification that the token - * has not been revoked or is expired. + * If the token is non-null, then it is validated, which might include authenticated decryption and + * verification that the token has not been revoked or is expired. */ - void getAndValidateToken(ThreadContext ctx, ActionListener listener) { - if (isEnabled()) { - final String token = getFromHeader(ctx); - if (token == null) { - listener.onResponse(null); - } else { - decodeToken(token, ActionListener.wrap(userToken -> { - if (userToken != null) { - checkIfTokenIsValid(userToken, listener); - } else { - listener.onResponse(null); - } - }, e -> { - if (isShardNotAvailableException(e)) { - listener.onResponse(null); - } else { - listener.onFailure(e); - } - })); - } + void tryAuthenticateToken(SecureString token, ActionListener listener) { + if (isEnabled() && token != null) { + decodeAndValidateToken(token, listener); } else { listener.onResponse(null); } @@ -416,29 +399,13 @@ void getAndValidateToken(ThreadContext ctx, ActionListener listener) * {@code null} authentication object. */ public void authenticateToken(SecureString tokenString, ActionListener listener) { - ensureEnabled(); - decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { - if (userToken != null) { - checkIfTokenIsValid(userToken, ActionListener.wrap( - token -> { - if (token == null) { - // Typically this means that the index is unavailable, so _probably_ the token is invalid but the only - // this we can say for certain is that we couldn't validate it. The logs will be more explicit. - listener.onFailure(new IllegalArgumentException("Cannot validate access token")); - } else { - listener.onResponse(token.getAuthentication()); - } - }, - listener::onFailure - )); - } else { - listener.onFailure(new IllegalArgumentException("Cannot decode access token")); - } - }, e -> { - if (isShardNotAvailableException(e)) { - listener.onResponse(null); + decodeAndValidateToken(tokenString, listener.map(token -> { + if (token == null) { + // Typically this means that the index is unavailable, so _probably_ the token is invalid but the only + // this we can say for certain is that we couldn't validate it. The logs will be more explicit. + throw new IllegalArgumentException("Cannot validate access token"); } else { - listener.onFailure(e); + return token.getAuthentication(); } })); } @@ -513,6 +480,23 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action } } + private void decodeAndValidateToken(SecureString tokenString, ActionListener listener) { + ensureEnabled(); + decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { + if (userToken != null) { + checkIfTokenIsValid(userToken, listener); + } else { + listener.onResponse(null); + } + }, e -> { + if (isShardNotAvailableException(e)) { + listener.onResponse(null); + } else { + listener.onFailure(e); + } + })); + } + /** * If needed, for tokens that were created in a pre {@code #VERSION_ACCESS_TOKENS_UUIDS} cluster, it asynchronously decodes the token to * get the token document id. The process for this is asynchronous as we may need to compute a key, which can be computationally @@ -1711,24 +1695,24 @@ private void maybeStartTokenRemover() { * Gets the token from the Authorization header if the header begins with * Bearer */ - private String getFromHeader(ThreadContext threadContext) { + public SecureString extractBearerTokenFromHeader(ThreadContext threadContext) { String header = threadContext.getHeader("Authorization"); if (Strings.hasText(header) && header.regionMatches(true, 0, "Bearer ", 0, "Bearer ".length()) && header.length() > "Bearer ".length()) { - return header.substring("Bearer ".length()); + char[] chars = new char[header.length() - "Bearer ".length()]; + header.getChars("Bearer ".length(), header.length(), chars, 0); + return new SecureString(chars); } return null; } String prependVersionAndEncodeAccessToken(Version version, String accessToken) throws IOException, GeneralSecurityException { if (version.onOrAfter(VERSION_ACCESS_TOKENS_AS_UUIDS)) { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(MINIMUM_BASE64_BYTES); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput(MINIMUM_BASE64_BYTES)) { out.setVersion(version); Version.writeVersion(version, out); out.writeString(accessToken); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); } } else { // we know that the minimum length is larger than the default of the ByteArrayOutputStream so set the size to this explicitly @@ -1756,13 +1740,12 @@ String prependVersionAndEncodeAccessToken(Version version, String accessToken) t } public static String prependVersionAndEncodeRefreshToken(Version version, String payload) { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput()) { out.setVersion(version); Version.writeVersion(version, out); out.writeString(payload); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); + } catch (IOException e) { throw new RuntimeException("Unexpected exception when working with small in-memory streams", e); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java new file mode 100644 index 0000000000000..0d4cc958ec58a --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStore.java @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.cache.CacheBuilder; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.util.concurrent.ListenableFuture; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; + +import java.util.Collection; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +public abstract class CachingServiceAccountsTokenStore implements ServiceAccountsTokenStore, CacheInvalidatorRegistry.CacheInvalidator { + + private static final Logger logger = LogManager.getLogger(CachingServiceAccountsTokenStore.class); + + public static final Setting CACHE_HASH_ALGO_SETTING = Setting.simpleString("xpack.security.authc.service_token.cache.hash_algo", + "ssha256", Setting.Property.NodeScope); + + public static final Setting CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authc.service_token.cache.ttl", + TimeValue.timeValueMinutes(20), Setting.Property.NodeScope); + public static final Setting CACHE_MAX_TOKENS_SETTING = Setting.intSetting( + "xpack.security.authc.service_token.cache.max_tokens", 100_000, Setting.Property.NodeScope); + + private final ThreadPool threadPool; + private final Cache> cache; + private final Hasher hasher; + + CachingServiceAccountsTokenStore(Settings settings, ThreadPool threadPool) { + this.threadPool = threadPool; + final TimeValue ttl = CACHE_TTL_SETTING.get(settings); + if (ttl.getNanos() > 0) { + cache = CacheBuilder.>builder() + .setExpireAfterWrite(ttl) + .setMaximumWeight(CACHE_MAX_TOKENS_SETTING.get(settings)) + .build(); + } else { + cache = null; + } + hasher = Hasher.resolve(CACHE_HASH_ALGO_SETTING.get(settings)); + } + + @Override + public void authenticate(ServiceAccountToken token, ActionListener listener) { + try { + if (cache == null) { + doAuthenticate(token, listener); + } else { + authenticateWithCache(token, listener); + } + } catch (Exception e) { + listener.onFailure(e); + } + } + + private void authenticateWithCache(ServiceAccountToken token, ActionListener listener) { + assert cache != null; + try { + final AtomicBoolean valueAlreadyInCache = new AtomicBoolean(true); + final ListenableFuture listenableCacheEntry = cache.computeIfAbsent(token.getQualifiedName(), k -> { + valueAlreadyInCache.set(false); + return new ListenableFuture<>(); + }); + if (valueAlreadyInCache.get()) { + listenableCacheEntry.addListener(ActionListener.wrap(result -> { + if (result.success) { + listener.onResponse(result.verify(token)); + } else if (result.verify(token)) { + // same wrong token + listener.onResponse(false); + } else { + cache.invalidate(token.getQualifiedName(), listenableCacheEntry); + authenticateWithCache(token, listener); + } + }, listener::onFailure), threadPool.generic(), threadPool.getThreadContext()); + } else { + doAuthenticate(token, ActionListener.wrap(success -> { + logger.trace("cache service token [{}] authentication result", token.getQualifiedName()); + listenableCacheEntry.onResponse(new CachedResult(hasher, success, token)); + listener.onResponse(success); + }, e -> { + // In case of failure, evict the cache entry and notify all listeners + cache.invalidate(token.getQualifiedName(), listenableCacheEntry); + listenableCacheEntry.onFailure(e); + listener.onFailure(e); + })); + } + } catch (final ExecutionException e) { + listener.onFailure(e); + } + } + + @Override + public final void invalidate(Collection qualifiedTokenNames) { + if (cache != null) { + logger.trace("invalidating cache for service token [{}]", + Strings.collectionToCommaDelimitedString(qualifiedTokenNames)); + qualifiedTokenNames.forEach(cache::invalidate); + } + } + + @Override + public final void invalidateAll() { + if (cache != null) { + logger.trace("invalidating cache for all service tokens"); + cache.invalidateAll(); + } + } + + protected ThreadPool getThreadPool() { + return threadPool; + } + + abstract void doAuthenticate(ServiceAccountToken token, ActionListener listener); + + // package private for testing + Cache> getCache() { + return cache; + } + + static class CachedResult { + + private final boolean success; + private final char[] hash; + + private CachedResult(Hasher hasher, boolean success, ServiceAccountToken token) { + this.success = success; + this.hash = hasher.hash(token.getSecret()); + } + + private boolean verify(ServiceAccountToken token) { + return hash != null && Hasher.verifyHash(token.getSecret(), hash); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java index 647d6b6f17e7d..ffa56efb606ff 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStore.java @@ -10,9 +10,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.Nullable; import org.elasticsearch.common.util.Maps; import org.elasticsearch.env.Environment; +import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.watcher.FileWatcher; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.core.XPackPlugin; @@ -26,19 +28,22 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; -public class FileServiceAccountsTokenStore implements ServiceAccountsTokenStore { +public class FileServiceAccountsTokenStore extends CachingServiceAccountsTokenStore { private static final Logger logger = LogManager.getLogger(FileServiceAccountsTokenStore.class); private final Path file; - private final CopyOnWriteArrayList listeners; + private final CopyOnWriteArrayList refreshListeners; private volatile Map tokenHashes; - public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService) { + public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService resourceWatcherService, ThreadPool threadPool) { + super(env.settings(), threadPool); file = resolveFile(env); FileWatcher watcher = new FileWatcher(file.getParent()); watcher.addListener(new FileReloadListener(file, this::tryReload)); @@ -52,20 +57,24 @@ public FileServiceAccountsTokenStore(Environment env, ResourceWatcherService res } catch (IOException e) { throw new IllegalStateException("Failed to load service_tokens file [" + file + "]", e); } - listeners = new CopyOnWriteArrayList<>(); + refreshListeners = new CopyOnWriteArrayList<>(List.of(this::invalidateAll)); } @Override - public boolean authenticate(ServiceAccountToken token) { - return false; + public void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + // This is done on the current thread instead of using a dedicated thread pool like API key does + // because it is not expected to have a large number of service tokens. + listener.onResponse(Optional.ofNullable(tokenHashes.get(token.getQualifiedName())) + .map(hash -> Hasher.verifyHash(token.getSecret(), hash)) + .orElse(false)); } public void addListener(Runnable listener) { - listeners.add(listener); + refreshListeners.add(listener); } private void notifyRefresh() { - listeners.forEach(Runnable::run); + refreshListeners.forEach(Runnable::run); } private void tryReload() { @@ -133,4 +142,5 @@ static void writeFile(Path path, Map tokenHashes) { SecurityFiles.writeFileAtomically( path, tokenHashes, e -> String.format(Locale.ROOT, "%s:%s", e.getKey(), new String(e.getValue()))); } + } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java index 8dff080b7cb50..07abb3d3f22a2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/FileTokensTool.java @@ -15,9 +15,7 @@ import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.UserException; import org.elasticsearch.common.Strings; -import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.collect.Tuple; -import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.env.Environment; import org.elasticsearch.xpack.core.XPackSettings; @@ -26,9 +24,9 @@ import org.elasticsearch.xpack.security.support.FileAttributesChecker; import java.nio.file.Path; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; public class FileTokensTool extends LoggingAwareMultiCommand { @@ -73,15 +71,16 @@ protected void execute(Terminal terminal, OptionSet options, Environment env) th throw new UserException(ExitCodes.NO_USER, "Unknown service account principal: [" + principal + "]. Must be one of [" + Strings.collectionToDelimitedString(ServiceAccountService.getServiceAccountPrincipals(), ",") + "]"); } + if (false == ServiceAccountToken.isValidTokenName(tokenName)) { + throw new UserException(ExitCodes.CODE_ERROR, ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE); + } final Hasher hasher = Hasher.resolve(XPackSettings.SERVICE_TOKEN_HASHING_ALGORITHM.get(env.settings())); final Path serviceTokensFile = FileServiceAccountsTokenStore.resolveFile(env); FileAttributesChecker attributesChecker = new FileAttributesChecker(serviceTokensFile); - final Map tokenHashes = new HashMap<>(FileServiceAccountsTokenStore.parseFile(serviceTokensFile, null)); + final Map tokenHashes = new TreeMap<>(FileServiceAccountsTokenStore.parseFile(serviceTokensFile, null)); - try (SecureString tokenString = UUIDs.randomBase64UUIDSecureString()) { - final ServiceAccountToken token = - new ServiceAccountToken(ServiceAccountId.fromPrincipal(principal), tokenName, tokenString); + try (ServiceAccountToken token = ServiceAccountToken.newToken(ServiceAccountId.fromPrincipal(principal), tokenName)) { if (tokenHashes.containsKey(token.getQualifiedName())) { throw new UserException(ExitCodes.CODE_ERROR, "Service token [" + token.getQualifiedName() + "] already exists"); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java index 56639b8f87b67..c5373fde6626a 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountService.java @@ -9,25 +9,15 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.message.ParameterizedMessage; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; -import org.elasticsearch.common.CharArrays; -import org.elasticsearch.common.hash.MessageDigests; -import org.elasticsearch.common.io.stream.InputStreamStreamInput; -import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.settings.SecureString; -import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.user.User; -import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; -import org.elasticsearch.xpack.security.authc.support.SecurityTokenType; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.util.Base64; import java.util.Collection; import java.util.Map; @@ -37,7 +27,6 @@ public class ServiceAccountService { public static final String REALM_TYPE = "service_account"; public static final String REALM_NAME = "service_account"; - public static final Version VERSION_MINIMUM = Version.V_8_0_0; private static final Logger logger = LogManager.getLogger(ServiceAccountService.class); @@ -59,63 +48,57 @@ public static Collection getServiceAccountPrincipals() { return ACCOUNTS.keySet(); } - // {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header. /** * Parses a token object from the content of a {@link ServiceAccountToken#asBearerString()} bearer string}. * This bearer string would typically be + * {@link org.elasticsearch.xpack.security.authc.TokenService#extractBearerTokenFromHeader extracted} from an HTTP authorization header. * *

* This method does not validate the credential, it simply parses it. * There is no guarantee that the {@link ServiceAccountToken#getSecret() secret} is valid, * or even that the {@link ServiceAccountToken#getAccountId() account} exists. *

- * @param token A raw token string (if this is from an HTTP header, then the "Bearer " prefix must be removed before + * @param bearerString A raw token string (if this is from an HTTP header, then the "Bearer " prefix must be removed before * calling this method. * @return An unvalidated token object. */ - public static ServiceAccountToken tryParseToken(SecureString token) { + public static ServiceAccountToken tryParseToken(SecureString bearerString) { try { - if (token == null) { + if (bearerString == null) { return null; } - return doParseToken(token); - } catch (IOException e) { - logger.debug("Cannot parse possible service account token", e); + return ServiceAccountToken.fromBearerString(bearerString); + } catch (Exception e) { + logger.trace("Cannot parse possible service account token", e); return null; } } - public void authenticateWithToken(ServiceAccountToken token, ThreadContext threadContext, String nodeName, - ActionListener listener) { - - if (ElasticServiceAccounts.NAMESPACE.equals(token.getAccountId().namespace()) == false) { - final ParameterizedMessage message = new ParameterizedMessage( - "only [{}] service accounts are supported, but received [{}]", - ElasticServiceAccounts.NAMESPACE, token.getAccountId().asPrincipal()); - logger.debug(message); - listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage())); + public void authenticateToken(ServiceAccountToken serviceAccountToken, String nodeName, ActionListener listener) { + logger.trace("attempt to authenticate service account token [{}]", serviceAccountToken.getQualifiedName()); + if (ElasticServiceAccounts.NAMESPACE.equals(serviceAccountToken.getAccountId().namespace()) == false) { + logger.debug("only [{}] service accounts are supported, but received [{}]", + ElasticServiceAccounts.NAMESPACE, serviceAccountToken.getAccountId().asPrincipal()); + listener.onFailure(createAuthenticationException(serviceAccountToken)); return; } - final ServiceAccount account = ACCOUNTS.get(token.getAccountId().asPrincipal()); + final ServiceAccount account = ACCOUNTS.get(serviceAccountToken.getAccountId().asPrincipal()); if (account == null) { - final ParameterizedMessage message = new ParameterizedMessage( - "the [{}] service account does not exist", token.getAccountId().asPrincipal()); - logger.debug(message); - listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage())); + logger.debug("the [{}] service account does not exist", serviceAccountToken.getAccountId().asPrincipal()); + listener.onFailure(createAuthenticationException(serviceAccountToken)); return; } - if (serviceAccountsTokenStore.authenticate(token)) { - listener.onResponse(success(account, token, nodeName)); - } else { - final ParameterizedMessage message = new ParameterizedMessage( - "failed to authenticate service account [{}] with token name [{}]", - token.getAccountId().asPrincipal(), - token.getTokenName()); - logger.debug(message); - listener.onFailure(new ElasticsearchSecurityException(message.getFormattedMessage())); - } + serviceAccountsTokenStore.authenticate(serviceAccountToken, ActionListener.wrap(success -> { + if (success) { + listener.onResponse(createAuthentication(account, serviceAccountToken, nodeName)); + } else { + final ElasticsearchSecurityException e = createAuthenticationException(serviceAccountToken); + logger.debug(e.getMessage()); + listener.onFailure(e); + } + }, listener::onFailure)); } public void getRoleDescriptor(Authentication authentication, ActionListener listener) { @@ -125,44 +108,23 @@ public void getRoleDescriptor(Authentication authentication, ActionListenerThe {@link #getSecret() secret credential} for that token * */ -public class ServiceAccountToken { +public class ServiceAccountToken implements AuthenticationToken, Closeable { + + public static final String INVALID_TOKEN_NAME_MESSAGE = "service account token name must have at least 1 character " + + "and at most 256 characters that are alphanumeric (A-Z, a-z, 0-9) or hyphen (-) or underscore (_). " + + "It must not begin with an underscore (_)."; + + private static final Pattern VALID_TOKEN_NAME = Pattern.compile("^[a-zA-Z0-9-][a-zA-Z0-9_-]{0,255}$"); + + public static final byte MAGIC_BYTE = '\0'; + public static final byte TOKEN_TYPE = '\1'; + public static final byte RESERVED_BYTE = '\0'; + public static final byte FORMAT_VERSION = '\1'; + public static final byte[] PREFIX = new byte[] { MAGIC_BYTE, TOKEN_TYPE, RESERVED_BYTE, FORMAT_VERSION }; + + private static final Logger logger = LogManager.getLogger(ServiceAccountToken.class); + private final ServiceAccountId accountId; private final String tokenName; private final SecureString secret; - public ServiceAccountToken(ServiceAccountId accountId, String tokenName, SecureString secret) { - this.accountId = accountId; + // pkg private for testing + ServiceAccountToken(ServiceAccountId accountId, String tokenName, SecureString secret) { + this.accountId = Objects.requireNonNull(accountId, "service account ID cannot be null"); + if (false == isValidTokenName(tokenName)) { + throw new IllegalArgumentException(INVALID_TOKEN_NAME_MESSAGE); + } this.tokenName = tokenName; - this.secret = secret; + this.secret = Objects.requireNonNull(secret, "service account token secret cannot be null"); } public ServiceAccountId getAccountId() { @@ -54,20 +86,50 @@ public String getQualifiedName() { } public SecureString asBearerString() throws IOException { - try( - BytesStreamOutput out = new BytesStreamOutput()) { - Version.writeVersion(Version.CURRENT, out); - SecurityTokenType.SERVICE_ACCOUNT.write(out); - accountId.write(out); - out.writeString(tokenName); - out.writeSecureString(secret); - out.flush(); - - final String base64 = Base64.getEncoder().withoutPadding().encodeToString(out.bytes().toBytesRef().bytes); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + out.writeBytes(PREFIX); + out.write(getQualifiedName().getBytes(StandardCharsets.UTF_8)); + out.write(':'); + out.write(secret.toString().getBytes(StandardCharsets.UTF_8)); + final String base64 = Base64.getEncoder().withoutPadding().encodeToString(out.toByteArray()); return new SecureString(base64.toCharArray()); } } + public static ServiceAccountToken fromBearerString(SecureString bearerString) throws IOException { + final byte[] bytes = CharArrays.toUtf8Bytes(bearerString.getChars()); + logger.trace("parsing token bytes {}", MessageDigests.toHexString(bytes)); + try (InputStream in = Base64.getDecoder().wrap(new ByteArrayInputStream(bytes))) { + final byte[] prefixBytes = in.readNBytes(4); + if (prefixBytes.length != 4 || false == Arrays.equals(prefixBytes, PREFIX)) { + logger.trace(() -> new ParameterizedMessage( + "service account token expects the 4 leading bytes to be {}, got {}.", + Arrays.toString(PREFIX), Arrays.toString(prefixBytes))); + return null; + } + final char[] content = CharArrays.utf8BytesToChars(in.readAllBytes()); + final int i = UsernamePasswordToken.indexOfColon(content); + if (i < 0) { + logger.trace("failed to extract qualified service token name and secret, missing ':'"); + return null; + } + final String qualifiedName = new String(Arrays.copyOfRange(content, 0, i)); + final String[] split = Strings.delimitedListToStringArray(qualifiedName, "/"); + if (split == null || split.length != 3) { + logger.trace("The qualified name of a service token should take format of " + + "'namespace/service_name/token_name', got [{}]", qualifiedName); + return null; + } + return new ServiceAccountToken(new ServiceAccountId(split[0], split[1]), split[2], + new SecureString(Arrays.copyOfRange(content, i + 1, content.length))); + } + } + + @Override + public void close() { + secret.close(); + } + @Override public boolean equals(Object o) { if (this == o) @@ -82,4 +144,27 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(accountId, tokenName, secret); } + + public static ServiceAccountToken newToken(ServiceAccountId accountId, String tokenName) { + return new ServiceAccountToken(accountId, tokenName, UUIDs.randomBase64UUIDSecureString()); + } + + @Override + public String principal() { + return accountId.asPrincipal(); + } + + @Override + public Object credentials() { + return secret; + } + + @Override + public void clearCredentials() { + close(); + } + + public static boolean isValidTokenName(String name) { + return name != null && VALID_TOKEN_NAME.matcher(name).matches(); + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java index 13e210c8c9ec7..4252cfed8abec 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountsTokenStore.java @@ -7,7 +7,15 @@ package org.elasticsearch.xpack.security.authc.service; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.message.ParameterizedMessage; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.xpack.core.common.IteratingActionListener; + import java.util.List; +import java.util.function.Function; /** * The interface should be implemented by credential stores of different backends. @@ -17,19 +25,37 @@ public interface ServiceAccountsTokenStore { /** * Verify the given token for encapsulated service account and credential */ - boolean authenticate(ServiceAccountToken token); + void authenticate(ServiceAccountToken token, ActionListener listener); final class CompositeServiceAccountsTokenStore implements ServiceAccountsTokenStore { + private static final Logger logger = LogManager.getLogger(CompositeServiceAccountsTokenStore.class); + + private final ThreadContext threadContext; private final List stores; - public CompositeServiceAccountsTokenStore(List stores) { + public CompositeServiceAccountsTokenStore( + List stores, ThreadContext threadContext) { this.stores = stores; + this.threadContext = threadContext; } @Override - public boolean authenticate(ServiceAccountToken token) { - return stores.stream().anyMatch(store -> store.authenticate(token)); + public void authenticate(ServiceAccountToken token, ActionListener listener) { + final IteratingActionListener authenticatingListener = + new IteratingActionListener<>( + listener, + (store, successListener) -> store.authenticate(token, successListener), + stores, + threadContext, + Function.identity(), + success -> Boolean.FALSE == success); + try { + authenticatingListener.run(); + } catch (Exception e) { + logger.debug(new ParameterizedMessage("authentication of service token [{}] failed", token.getQualifiedName()), e); + listener.onFailure(e); + } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecurityTokenType.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecurityTokenType.java deleted file mode 100644 index 98efa29aa3609..0000000000000 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/SecurityTokenType.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -package org.elasticsearch.xpack.security.authc.support; - -import org.elasticsearch.common.io.stream.StreamInput; -import org.elasticsearch.common.io.stream.StreamOutput; - -import java.io.IOException; - -/** - * Represents the different "types" of tokens that Elasticsearch works with. - * Some (but not all) of these may be used in an Authorization: Bearer {value} header. - */ -public enum SecurityTokenType { - - // There enum values are written to streams. They cannot be reordered - ACCESS_TOKEN, - REFRESH_TOKEN, - SERVICE_ACCOUNT; - - public void write(StreamOutput out) throws IOException { - out.writeEnum(this); - } - - public static SecurityTokenType read(StreamInput in) throws IOException { - return in.readEnum(SecurityTokenType.class); - } -} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java index a534c09410453..30d3d5b5de74e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecuritySettingsSource.java @@ -131,6 +131,7 @@ public Settings nodeSettings(int nodeOrdinal) { writeFile(xpackConf, "users", configUsers()); writeFile(xpackConf, "users_roles", configUsersRoles()); writeFile(xpackConf, "operator_users.yml", configOperatorUsers()); + writeFile(xpackConf, "service_tokens", configServiceTokens()); Settings.Builder builder = Settings.builder() .put(Environment.PATH_HOME_SETTING.getKey(), home) @@ -186,6 +187,10 @@ protected String configOperatorUsers() { return ""; } + protected String configServiceTokens() { + return ""; + } + protected String nodeClientUsername() { return TEST_USER_NAME; } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java index 2f7b94fe3af49..d7d062744bc3c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java @@ -15,8 +15,7 @@ import org.elasticsearch.cluster.ClusterState; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.UUIDs; -import org.elasticsearch.common.io.stream.OutputStreamStreamOutput; -import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.Index; import org.elasticsearch.indices.IndexClosedException; @@ -38,9 +37,6 @@ import org.junit.After; import org.junit.Before; -import java.io.ByteArrayOutputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import java.time.Clock; import java.util.Base64; import java.util.Collections; @@ -132,13 +128,11 @@ public void testInvalidateTokensWhenIndexClosed() throws Exception { } private String generateAccessTokenString() throws Exception { - try (ByteArrayOutputStream os = new ByteArrayOutputStream(TokenService.MINIMUM_BASE64_BYTES); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + try (BytesStreamOutput out = new BytesStreamOutput(TokenService.MINIMUM_BASE64_BYTES)) { out.setVersion(Version.CURRENT); Version.writeVersion(Version.CURRENT, out); out.writeString(UUIDs.randomBase64UUID()); - return new String(os.toByteArray(), StandardCharsets.UTF_8); + return Base64.getEncoder().encodeToString(out.bytes().toBytesRef().bytes); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java index 875d34aa506f9..25e0e9e053735 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/AuthenticationServiceTests.java @@ -89,7 +89,6 @@ import org.elasticsearch.xpack.security.authc.AuthenticationService.Authenticator; import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; import org.elasticsearch.xpack.security.authc.service.ServiceAccountService; -import org.elasticsearch.xpack.security.authc.service.ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore; import org.elasticsearch.xpack.security.operator.OperatorPrivileges; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -111,6 +110,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -263,7 +263,13 @@ public void init() throws Exception { mock(CacheInvalidatorRegistry.class), threadPool); tokenService = new TokenService(settings, Clock.systemUTC(), client, licenseState, securityContext, securityIndex, securityIndex, clusterService); - serviceAccountService = new ServiceAccountService(new CompositeServiceAccountsTokenStore(List.of())); + serviceAccountService = mock(ServiceAccountService.class); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(null); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); operatorPrivilegesService = mock(OperatorPrivileges.OperatorPrivilegesService.class); service = new AuthenticationService(settings, realms, auditTrailService, @@ -1900,6 +1906,43 @@ public void testExpiredApiKey() { } } + public void testCanAuthenticateServiceAccount() throws ExecutionException, InterruptedException { + Mockito.reset(serviceAccountService); + final Authentication authentication = new Authentication( + new User("elastic/fleet"), + new RealmRef("service_account", "service_account", "foo"), null); + try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(authentication); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); + final PlainActionFuture future = new PlainActionFuture<>(); + service.authenticate("_action", transportRequest, false, future); + assertThat(future.get(), is(authentication)); + } + } + + public void testServiceAccountFailureWillNotFallthrough() { + Mockito.reset(serviceAccountService); + final RuntimeException bailOut = new RuntimeException("bail out"); + try (ThreadContext.StoredContext ignored = threadContext.newStoredContext(false)) { + threadContext.putHeader("Authorization", "Bearer AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnI1d2RiZGJvUVNlOXZHT0t3YUpHQXc"); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onFailure(bailOut); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); + final PlainActionFuture future = new PlainActionFuture<>(); + service.authenticate("_action", transportRequest, false, future); + final ExecutionException e = expectThrows(ExecutionException.class, () -> future.get()); + assertThat(e.getCause().getCause(), is(bailOut)); + } + } + private static class InternalRequest extends TransportRequest { @Override public void writeTo(StreamOutput out) {} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java index 49603d1bf1b4a..07fc8bff93548 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceTests.java @@ -46,6 +46,7 @@ import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -239,7 +240,8 @@ public void testAttachAndGetToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -249,7 +251,8 @@ public void testAttachAndGetToken() throws Exception { TokenService anotherService = createTokenService(tokenServiceEnabledSettings, systemUTC()); anotherService.refreshMetadata(tokenService.getTokenMetadata()); PlainActionFuture future = new PlainActionFuture<>(); - anotherService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = anotherService.extractBearerTokenFromHeader(requestContext); + anotherService.tryAuthenticateToken(bearerToken, future); UserToken fromOtherService = future.get(); assertAuthentication(authentication, fromOtherService.getAuthentication()); } @@ -264,7 +267,8 @@ public void testInvalidAuthorizationHeader() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertThat(serialized, nullValue()); } @@ -290,7 +294,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -298,7 +303,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -318,7 +324,8 @@ public void testRotateKey() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -356,7 +363,8 @@ public void testKeyExchange() throws Exception { storeTokenHeader(requestContext, accessToken); try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - otherTokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = otherTokenService.extractBearerTokenFromHeader(requestContext); + otherTokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(serialized.getAuthentication(), authentication); } @@ -367,7 +375,8 @@ public void testKeyExchange() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - otherTokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = otherTokenService.extractBearerTokenFromHeader(requestContext); + otherTokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(serialized.getAuthentication(), authentication); } @@ -393,7 +402,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -407,7 +417,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -426,7 +437,8 @@ public void testPruneKeys() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertNull(serialized); } @@ -436,7 +448,8 @@ public void testPruneKeys() throws Exception { mockGetTokenFromId(tokenService, newUserTokenId, authentication, false); try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -463,7 +476,8 @@ public void testPassphraseWorks() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); UserToken serialized = future.get(); assertAuthentication(authentication, serialized.getAuthentication()); } @@ -472,7 +486,8 @@ public void testPassphraseWorks() throws Exception { // verify a second separate token service with its own passphrase cannot verify TokenService anotherService = createTokenService(tokenServiceEnabledSettings, systemUTC()); PlainActionFuture future = new PlainActionFuture<>(); - anotherService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = anotherService.extractBearerTokenFromHeader(requestContext); + anotherService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -516,7 +531,8 @@ public void testInvalidatedToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); final String headerValue = e.getHeader("WWW-Authenticate").get(0); assertThat(headerValue, containsString("Bearer realm=")); @@ -632,7 +648,8 @@ public void testTokenExpiry() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { // the clock is still frozen, so the cookie should be valid PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -642,7 +659,8 @@ public void testTokenExpiry() throws Exception { // move the clock forward but don't go to expiry clock.fastForwardSeconds(fastForwardAmount); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -650,7 +668,8 @@ public void testTokenExpiry() throws Exception { // move to expiry, stripping nanoseconds, as we don't store them in the security-tokens index clock.setTime(userToken.getExpirationTime().truncatedTo(ChronoUnit.MILLIS).atZone(clock.getZone())); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(authentication, future.get().getAuthentication()); } @@ -658,7 +677,8 @@ public void testTokenExpiry() throws Exception { // move one second past expiry clock.fastForwardSeconds(1); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, future::actionGet); final String headerValue = e.getHeader("WWW-Authenticate").get(0); assertThat(headerValue, containsString("Bearer realm=")); @@ -679,7 +699,7 @@ public void testTokenServiceDisabled() throws Exception { assertThat(e.getMetadata(FeatureNotEnabledException.DISABLED_FEATURE_METADATA), contains("security_tokens")); PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(null, future); + tokenService.tryAuthenticateToken(null, future); assertNull(future.get()); PlainActionFuture invalidateFuture = new PlainActionFuture<>(); @@ -725,7 +745,8 @@ public void testMalformedToken() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -740,7 +761,8 @@ public void testNotValidPre72Tokens() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -755,7 +777,8 @@ public void testNotValidAfter72Tokens() throws Exception { try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertNull(future.get()); } } @@ -793,26 +816,30 @@ public void testIndexNotAvailable() throws Exception { } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken3 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken3, future); assertNull(future.get()); when(tokensIndex.isAvailable()).thenReturn(false); when(tokensIndex.getUnavailableReason()).thenReturn(new UnavailableShardsException(null, "unavailable")); when(tokensIndex.indexExists()).thenReturn(true); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken2 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken2, future); assertNull(future.get()); when(tokensIndex.indexExists()).thenReturn(false); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken1 = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken1, future); assertNull(future.get()); when(tokensIndex.isAvailable()).thenReturn(true); when(tokensIndex.indexExists()).thenReturn(true); mockGetTokenFromId(tokenService, userTokenId, authentication, false); future = new PlainActionFuture<>(); - tokenService.getAndValidateToken(requestContext, future); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(requestContext); + tokenService.tryAuthenticateToken(bearerToken, future); assertAuthentication(future.get().getAuthentication(), authentication); } } @@ -875,7 +902,8 @@ public void testCannotValidateTokenIfLicenseDoesNotAllowTokens() throws Exceptio PlainActionFuture authFuture = new PlainActionFuture<>(); when(licenseState.checkFeature(Feature.SECURITY_TOKEN_SERVICE)).thenReturn(false); - tokenService.getAndValidateToken(threadContext, authFuture); + final SecureString bearerToken = tokenService.extractBearerTokenFromHeader(threadContext); + tokenService.tryAuthenticateToken(bearerToken, authFuture); UserToken authToken = authFuture.actionGet(); assertThat(authToken, Matchers.nullValue()); } @@ -1064,5 +1092,4 @@ private String generateAccessToken(TokenService tokenService, Version version) t } return tokenService.prependVersionAndEncodeAccessToken(version, accessTokenString); } - } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java new file mode 100644 index 0000000000000..ad2adea1cfcf5 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CachingServiceAccountsTokenStoreTests.java @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.service; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ListenableFuture; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; +import org.junit.After; +import org.junit.Before; + +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; + +public class CachingServiceAccountsTokenStoreTests extends ESTestCase { + + private Settings globalSettings; + private ThreadPool threadPool; + + @Before + public void init() { + globalSettings = Settings.builder().put("path.home", createTempDir()).build(); + threadPool = new TestThreadPool("test"); + } + + @After + public void stop() { + if (threadPool != null) { + terminate(threadPool); + } + } + + public void testCache() throws ExecutionException, InterruptedException { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final SecureString validSecret = new SecureString("super-secret-value".toCharArray()); + final SecureString invalidSecret = new SecureString("some-fishy-value".toCharArray()); + final ServiceAccountToken token1Valid = new ServiceAccountToken(accountId, "token1", validSecret); + final ServiceAccountToken token1Invalid = new ServiceAccountToken(accountId, "token1", invalidSecret); + final ServiceAccountToken token2Valid = new ServiceAccountToken(accountId, "token2", validSecret); + final ServiceAccountToken token2Invalid = new ServiceAccountToken(accountId, "token2", invalidSecret); + final AtomicBoolean doAuthenticateInvoked = new AtomicBoolean(false); + + final CachingServiceAccountsTokenStore store = new CachingServiceAccountsTokenStore(globalSettings, threadPool) { + @Override + void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + doAuthenticateInvoked.set(true); + listener.onResponse(validSecret.equals(token.getSecret())); + } + }; + + final Cache> cache = store.getCache(); + assertThat(cache.count(), equalTo(0)); + + // 1st auth with the right token1 + final PlainActionFuture future1 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future1); + assertThat(future1.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(1)); + doAuthenticateInvoked.set(false); // reset + + // 2nd auth with the right token1 should use cache + final PlainActionFuture future2 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future2); + assertThat(future2.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 3rd auth with the wrong token1 that has the same qualified name should use cache + final PlainActionFuture future3 = new PlainActionFuture<>(); + store.authenticate(token1Invalid, future3); + assertThat(future3.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 4th auth with the wrong token2 + final PlainActionFuture future4 = new PlainActionFuture<>(); + store.authenticate(token2Invalid, future4); + assertThat(future4.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // 5th auth with the wrong token2 again should use cache + final PlainActionFuture future5 = new PlainActionFuture<>(); + store.authenticate(token2Invalid, future5); + assertThat(future5.get(), is(false)); + assertThat(doAuthenticateInvoked.get(), is(false)); + + // 6th auth with the right token2 + final PlainActionFuture future6 = new PlainActionFuture<>(); + store.authenticate(token2Valid, future6); + assertThat(future6.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // Invalidate token1 in the cache + store.invalidate(List.of(token1Valid.getQualifiedName())); + assertThat(cache.count(), equalTo(1)); + + // 7th auth with the right token1 + final PlainActionFuture future7 = new PlainActionFuture<>(); + store.authenticate(token1Valid, future7); + assertThat(future7.get(), is(true)); + assertThat(doAuthenticateInvoked.get(), is(true)); + assertThat(cache.count(), equalTo(2)); + doAuthenticateInvoked.set(false); // reset + + // Invalidate all items in the cache + store.invalidateAll(); + assertThat(cache.count(), equalTo(0)); + } + + public void testCacheCanBeDisabled() throws ExecutionException, InterruptedException { + final Settings settings = Settings.builder() + .put(globalSettings) + .put(CachingServiceAccountsTokenStore.CACHE_TTL_SETTING.getKey(), "0") + .build(); + + final boolean success = randomBoolean(); + + final CachingServiceAccountsTokenStore store = new CachingServiceAccountsTokenStore(settings, threadPool) { + @Override + void doAuthenticate(ServiceAccountToken token, ActionListener listener) { + listener.onResponse(success); + } + }; + assertThat(store.getCache(), nullValue()); + // authenticate should still work + final PlainActionFuture future = new PlainActionFuture<>(); + store.authenticate(mock(ServiceAccountToken.class), future); + assertThat(future.get(), is(success)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java index 788e766782a71..789fe4d04e6e0 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/CompositeServiceAccountsTokenStoreTests.java @@ -7,17 +7,34 @@ package org.elasticsearch.xpack.security.authc.service; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.ESTestCase; +import org.junit.Before; import java.util.List; +import java.util.concurrent.ExecutionException; import static org.hamcrest.Matchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; public class CompositeServiceAccountsTokenStoreTests extends ESTestCase { - public void testAuthenticate() { + private ThreadContext threadContext; + + @Before + public void init() { + threadContext = new ThreadContext(Settings.EMPTY); + } + + public void testAuthenticate() throws ExecutionException, InterruptedException { final ServiceAccountToken token = mock(ServiceAccountToken.class); final ServiceAccountsTokenStore store1 = mock(ServiceAccountsTokenStore.class); @@ -28,17 +45,52 @@ public void testAuthenticate() { final boolean store2Success = randomBoolean(); final boolean store3Success = randomBoolean(); - when(store1.authenticate(token)).thenReturn(store1Success); - when(store2.authenticate(token)).thenReturn(store2Success); - when(store3.authenticate(token)).thenReturn(store3Success); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store1Success); + return null; + }).when(store1).authenticate(eq(token), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store2Success); + return null; + }).when(store2).authenticate(eq(token), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(store3Success); + return null; + }).when(store3).authenticate(eq(token), any()); final ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore compositeStore = - new ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore(List.of(store1, store2, store3)); + new ServiceAccountsTokenStore.CompositeServiceAccountsTokenStore(List.of(store1, store2, store3), threadContext); + final PlainActionFuture future = new PlainActionFuture<>(); + compositeStore.authenticate(token, future); if (store1Success || store2Success || store3Success) { - assertThat(compositeStore.authenticate(token), is(true)); + assertThat(future.get(), is(true)); + if (store1Success) { + verify(store1).authenticate(eq(token), any()); + verifyZeroInteractions(store2); + verifyZeroInteractions(store3); + } else if (store2Success) { + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verifyZeroInteractions(store3); + } else { + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verify(store3).authenticate(eq(token), any()); + } } else { - assertThat(compositeStore.authenticate(token), is(false)); + assertThat(future.get(), is(false)); + verify(store1).authenticate(eq(token), any()); + verify(store2).authenticate(eq(token), any()); + verify(store3).authenticate(eq(token), any()); } } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java index 3c11521a95583..c7aed6940d745 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/FileServiceAccountsTokenStoreTests.java @@ -113,7 +113,7 @@ public void testAutoReload() throws Exception { try (ResourceWatcherService watcherService = new ResourceWatcherService(settings, threadPool)) { final CountDownLatch latch = new CountDownLatch(5); - FileServiceAccountsTokenStore store = new FileServiceAccountsTokenStore(env, watcherService); + FileServiceAccountsTokenStore store = new FileServiceAccountsTokenStore(env, watcherService, threadPool); store.addListener(latch::countDown); //Token name shares the hashing algorithm name for convenience String tokenName = settings.get("xpack.security.authc.service_token_hashing.algorithm"); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java index 3e8acd48c0583..e0c5a32e7e264 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountServiceTests.java @@ -7,24 +7,32 @@ package org.elasticsearch.xpack.security.authc.service; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.common.Strings; -import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.logging.Loggers; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.ESTestCase; -import org.elasticsearch.test.VersionUtils; +import org.elasticsearch.test.MockLogAppender; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.user.User; -import org.elasticsearch.xpack.security.authc.support.SecurityTokenType; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; import org.junit.Before; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; @@ -32,8 +40,10 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; public class ServiceAccountServiceTests extends ESTestCase { @@ -74,42 +84,156 @@ public void testGetServiceAccountPrincipals() { assertThat(ServiceAccountService.getServiceAccountPrincipals(), equalTo(Set.of("elastic/fleet"))); } - public void testTryParseToken() throws IOException { + public void testTryParseToken() throws IOException, IllegalAccessException { // Null for null assertNull(ServiceAccountService.tryParseToken(null)); - final ServiceAccount.ServiceAccountId accountId = - new ServiceAccount.ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); - final String tokenName = randomAlphaOfLengthBetween(3, 8); - final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); + final byte[] magicBytes = { 0, 1, 0, 1 }; - // Invalid version or token type - try (BytesStreamOutput out = new BytesStreamOutput()) { + final Logger satLogger = LogManager.getLogger(ServiceAccountToken.class); + Loggers.setLevel(satLogger, Level.TRACE); + final Logger sasLogger = LogManager.getLogger(ServiceAccountService.class); + Loggers.setLevel(sasLogger, Level.TRACE); + + final MockLogAppender appender = new MockLogAppender(); + Loggers.addAppender(satLogger, appender); + Loggers.addAppender(sasLogger, appender); + appender.start(); + + try { + // Less than 4 bytes + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "less than 4 bytes", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes") + ); + final SecureString bearerString0 = createBearerString(List.of(Arrays.copyOfRange(magicBytes, 0, randomIntBetween(0, 3)))); + assertNull(ServiceAccountService.tryParseToken(bearerString0)); + appender.assertAllExpectationsMatched(); + + // Prefix mismatch + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "prefix mismatch", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes" + )); + final SecureString bearerString1 = createBearerString(List.of( + new byte[] { randomValueOtherThan((byte) 0, ESTestCase::randomByte) }, + randomByteArrayOfLength(randomIntBetween(30, 50)))); + assertNull(ServiceAccountService.tryParseToken(bearerString1)); + appender.assertAllExpectationsMatched(); + + // No colon + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "no colon", ServiceAccountToken.class.getName(), Level.TRACE, + "failed to extract qualified service token name and secret, missing ':'" + )); + final SecureString bearerString2 = createBearerString(List.of( + magicBytes, + randomAlphaOfLengthBetween(30, 50).getBytes(StandardCharsets.UTF_8))); + assertNull(ServiceAccountService.tryParseToken(bearerString2)); + appender.assertAllExpectationsMatched(); + + // Invalid delimiter for qualified name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid delimiter for qualified name", ServiceAccountToken.class.getName(), Level.TRACE, + "The qualified name of a service token should take format of 'namespace/service_name/token_name'" + )); if (randomBoolean()) { - final Version invalidVersion = VersionUtils.randomVersionBetween(random(), - Version.V_7_0_0, - VersionUtils.getPreviousVersion(ServiceAccountService.VERSION_MINIMUM)); - Version.writeVersion(invalidVersion, out); - out.writeEnum(SecurityTokenType.SERVICE_ACCOUNT); + final SecureString bearerString3 = createBearerString(List.of( + magicBytes, + (randomAlphaOfLengthBetween(10, 20) + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString3)); } else { - Version.writeVersion( - VersionUtils.randomVersionBetween(random(), ServiceAccountService.VERSION_MINIMUM, Version.CURRENT), - out); - out.writeEnum(randomFrom(SecurityTokenType.ACCESS_TOKEN, SecurityTokenType.REFRESH_TOKEN)); + final SecureString bearerString3 = createBearerString(List.of( + magicBytes, + (randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8) + + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString3)); } - accountId.write(out); - out.writeString(tokenName); - out.writeSecureString(secret); - out.flush(); + appender.assertAllExpectationsMatched(); - final String base64 = Base64.getEncoder().withoutPadding().encodeToString(out.bytes().toBytesRef().bytes); - assertNull(ServiceAccountService.tryParseToken(new SecureString(base64.toCharArray()))); - } + // Invalid token name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid token name", ServiceAccountService.class.getName(), Level.TRACE, + "Cannot parse possible service account token" + )); + final SecureString bearerString4 = createBearerString(List.of( + magicBytes, + (randomAlphaOfLengthBetween(3, 8) + "/" + randomAlphaOfLengthBetween(3, 8) + + "/" + ServiceAccountTokenTests.randomInvalidTokenName() + + ":" + randomAlphaOfLengthBetween(10, 20)).getBytes(StandardCharsets.UTF_8) + )); + assertNull(ServiceAccountService.tryParseToken(bearerString4)); + appender.assertAllExpectationsMatched(); + + // Everything is good + final String namespace = randomAlphaOfLengthBetween(3, 8); + final String serviceName = randomAlphaOfLengthBetween(3, 8); + final String tokenName = ServiceAccountTokenTests.randomTokenName(); + final ServiceAccountId accountId = new ServiceAccountId(namespace, serviceName); + final String secret = randomAlphaOfLengthBetween(10, 20); + final SecureString bearerString5 = createBearerString(List.of( + magicBytes, + (namespace + "/" + serviceName + "/" + tokenName + ":" + secret).getBytes(StandardCharsets.UTF_8) + )); + final ServiceAccountToken serviceAccountToken1 = ServiceAccountService.tryParseToken(bearerString5); + final ServiceAccountToken serviceAccountToken2 = new ServiceAccountToken(accountId, tokenName, + new SecureString(secret.toCharArray())); + assertThat(serviceAccountToken1, equalTo(serviceAccountToken2)); + + // Serialise and de-serialise service account token + final ServiceAccountToken parsedToken = ServiceAccountService.tryParseToken(serviceAccountToken2.asBearerString()); + assertThat(parsedToken, equalTo(serviceAccountToken2)); - // Serialise and de-serialise service account token - final ServiceAccountToken serviceAccountToken = new ServiceAccountToken(accountId, tokenName, secret); - final ServiceAccountToken parsedToken = ServiceAccountService.tryParseToken(serviceAccountToken.asBearerString()); - assertThat(parsedToken, equalTo(serviceAccountToken)); + // Invalid magic byte + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid magic byte again", ServiceAccountToken.class.getName(), Level.TRACE, + "service account token expects the 4 leading bytes" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AQEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // No colon + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "no colon again", ServiceAccountToken.class.getName(), Level.TRACE, + "failed to extract qualified service token name and secret, missing ':'" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xX3N1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // Invalid qualified name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid delimiter for qualified name again", ServiceAccountToken.class.getName(), Level.TRACE, + "The qualified name of a service token should take format of 'namespace/service_name/token_name'" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXRfdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // Invalid token name + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "invalid token name again", ServiceAccountService.class.getName(), Level.TRACE, + "Cannot parse possible service account token" + )); + assertNull(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4hOnN1cGVyc2VjcmV0".toCharArray()))); + appender.assertAllExpectationsMatched(); + + // everything is fine + assertThat(ServiceAccountService.tryParseToken( + new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray())), + equalTo(new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), "token1", + new SecureString("supersecret".toCharArray())))); + } finally { + appender.stop(); + Loggers.setLevel(satLogger, Level.INFO); + Loggers.setLevel(sasLogger, Level.INFO); + Loggers.removeAppender(satLogger, appender); + Loggers.removeAppender(sasLogger, appender); + } } private Authentication.RealmRef randomRealmRef() { @@ -118,60 +242,124 @@ private Authentication.RealmRef randomRealmRef() { randomAlphaOfLengthBetween(3, 8)); } - public void testAuthenticateWithToken() throws ExecutionException, InterruptedException { - // Null for non-elastic service account - final ServiceAccount.ServiceAccountId accountId1 = new ServiceAccount.ServiceAccountId( - randomValueOtherThan(ElasticServiceAccounts.NAMESPACE, () -> randomAlphaOfLengthBetween(3, 8)), - randomAlphaOfLengthBetween(3, 8)); - final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); - final ServiceAccountToken token1 = new ServiceAccountToken(accountId1, randomAlphaOfLengthBetween(3, 8), secret); - final PlainActionFuture future1 = new PlainActionFuture<>(); - serviceAccountService.authenticateWithToken(token1, threadContext, randomAlphaOfLengthBetween(3, 8), future1); - final ExecutionException e1 = expectThrows(ExecutionException.class, future1::get); - assertThat(e1.getCause().getClass(), is(ElasticsearchSecurityException.class)); - assertThat(e1.getMessage(), containsString( - "only [" + ElasticServiceAccounts.NAMESPACE + "] service accounts are supported, " + - "but received [" + accountId1.asPrincipal() + "]")); - - // Null for unknown elastic service name - final ServiceAccount.ServiceAccountId accountId2 = new ServiceAccount.ServiceAccountId( - ElasticServiceAccounts.NAMESPACE, - randomValueOtherThan("fleet", () -> randomAlphaOfLengthBetween(3, 8))); - final ServiceAccountToken token2 = new ServiceAccountToken(accountId2, randomAlphaOfLengthBetween(3, 8), secret); - final PlainActionFuture future2 = new PlainActionFuture<>(); - serviceAccountService.authenticateWithToken(token2, threadContext, randomAlphaOfLengthBetween(3, 8), future2); - final ExecutionException e2 = expectThrows(ExecutionException.class, future2::get); - assertThat(e2.getCause().getClass(), is(ElasticsearchSecurityException.class)); - assertThat(e2.getMessage(), containsString( - "the [" + accountId2.asPrincipal() + "] service account does not exist")); - - // Success based on credential store - final ServiceAccount.ServiceAccountId accountId3 = new ServiceAccount.ServiceAccountId(ElasticServiceAccounts.NAMESPACE, "fleet"); - final ServiceAccountToken token3 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), secret); - final ServiceAccountToken token4 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), - new SecureString(randomAlphaOfLength(20).toCharArray())); + public void testTryAuthenticateBearerToken() throws ExecutionException, InterruptedException { + // Valid token + final PlainActionFuture future5 = new PlainActionFuture<>(); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(true); + return null; + }).when(serviceAccountsTokenStore).authenticate(any(), any()); final String nodeName = randomAlphaOfLengthBetween(3, 8); - when(serviceAccountsTokenStore.authenticate(token3)).thenReturn(true); - when(serviceAccountsTokenStore.authenticate(token4)).thenReturn(false); - - final PlainActionFuture future3 = new PlainActionFuture<>(); - serviceAccountService.authenticateWithToken(token3, threadContext, nodeName, future3); - final Authentication authentication = future3.get(); - assertThat(authentication, equalTo(new Authentication( - new User("elastic/fleet", Strings.EMPTY_ARRAY, - "Service account - elastic/fleet", null, Map.of("_elastic_service_account", true), - true), - new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), - null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, - Map.of("_token_name", token3.getTokenName()) - ))); - - final PlainActionFuture future4 = new PlainActionFuture<>(); - serviceAccountService.authenticateWithToken(token4, threadContext, nodeName, future4); - final ExecutionException e4 = expectThrows(ExecutionException.class, future4::get); - assertThat(e4.getCause().getClass(), is(ElasticsearchSecurityException.class)); - assertThat(e4.getMessage(), containsString("failed to authenticate service account [" - + token4.getAccountId().asPrincipal() + "] with token name [" + token4.getTokenName() + "]")); + serviceAccountService.authenticateToken( + new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), "token1", + new SecureString("super-secret-value".toCharArray())), + nodeName, future5); + assertThat(future5.get(), equalTo( + new Authentication( + new User("elastic/fleet", Strings.EMPTY_ARRAY, "Service account - elastic/fleet", null, + Map.of("_elastic_service_account", true), true), + new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + Map.of("_token_name", "token1") + ) + )); + } + + public void testAuthenticateWithToken() throws ExecutionException, InterruptedException, IllegalAccessException { + final Logger sasLogger = LogManager.getLogger(ServiceAccountService.class); + Loggers.setLevel(sasLogger, Level.TRACE); + + final MockLogAppender appender = new MockLogAppender(); + Loggers.addAppender(sasLogger, appender); + appender.start(); + + try { + // non-elastic service account + final ServiceAccountId accountId1 = new ServiceAccountId( + randomValueOtherThan(ElasticServiceAccounts.NAMESPACE, () -> randomAlphaOfLengthBetween(3, 8)), + randomAlphaOfLengthBetween(3, 8)); + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "only [elastic] service accounts are supported, but received [" + accountId1.asPrincipal() + "]" + )); + final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); + final ServiceAccountToken token1 = new ServiceAccountToken(accountId1, randomAlphaOfLengthBetween(3, 8), secret); + final PlainActionFuture future1 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token1, randomAlphaOfLengthBetween(3, 8), future1); + final ExecutionException e1 = expectThrows(ExecutionException.class, future1::get); + assertThat(e1.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e1.getMessage(), containsString("failed to authenticate service account [" + + token1.getAccountId().asPrincipal() + "] with token name [" + token1.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + + // Unknown elastic service name + final ServiceAccountId accountId2 = new ServiceAccountId( + ElasticServiceAccounts.NAMESPACE, + randomValueOtherThan("fleet", () -> randomAlphaOfLengthBetween(3, 8))); + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "the [" + accountId2.asPrincipal() + "] service account does not exist" + )); + final ServiceAccountToken token2 = new ServiceAccountToken(accountId2, randomAlphaOfLengthBetween(3, 8), secret); + final PlainActionFuture future2 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token2, randomAlphaOfLengthBetween(3, 8), future2); + final ExecutionException e2 = expectThrows(ExecutionException.class, future2::get); + assertThat(e2.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e2.getMessage(), containsString("failed to authenticate service account [" + + token2.getAccountId().asPrincipal() + "] with token name [" + token2.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + + // Success based on credential store + final ServiceAccountId accountId3 = new ServiceAccountId(ElasticServiceAccounts.NAMESPACE, "fleet"); + final ServiceAccountToken token3 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), secret); + final ServiceAccountToken token4 = new ServiceAccountToken(accountId3, randomAlphaOfLengthBetween(3, 8), + new SecureString(randomAlphaOfLength(20).toCharArray())); + final String nodeName = randomAlphaOfLengthBetween(3, 8); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(true); + return null; + }).when(serviceAccountsTokenStore).authenticate(eq(token3), any()); + + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[1]; + listener.onResponse(false); + return null; + }).when(serviceAccountsTokenStore).authenticate(eq(token4), any()); + + final PlainActionFuture future3 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token3, nodeName, future3); + final Authentication authentication = future3.get(); + assertThat(authentication, equalTo(new Authentication( + new User("elastic/fleet", Strings.EMPTY_ARRAY, + "Service account - elastic/fleet", null, Map.of("_elastic_service_account", true), + true), + new Authentication.RealmRef(ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, nodeName), + null, Version.CURRENT, Authentication.AuthenticationType.TOKEN, + Map.of("_token_name", token3.getTokenName()) + ))); + + appender.addExpectation(new MockLogAppender.SeenEventExpectation( + "non-elastic service account", ServiceAccountService.class.getName(), Level.DEBUG, + "failed to authenticate service account [" + token4.getAccountId().asPrincipal() + + "] with token name [" + token4.getTokenName() + "]" + )); + final PlainActionFuture future4 = new PlainActionFuture<>(); + serviceAccountService.authenticateToken(token4, nodeName, future4); + final ExecutionException e4 = expectThrows(ExecutionException.class, future4::get); + assertThat(e4.getCause().getClass(), is(ElasticsearchSecurityException.class)); + assertThat(e4.getMessage(), containsString("failed to authenticate service account [" + + token4.getAccountId().asPrincipal() + "] with token name [" + token4.getTokenName() + "]")); + appender.assertAllExpectationsMatched(); + } finally { + appender.stop(); + Loggers.setLevel(sasLogger, Level.INFO); + Loggers.removeAppender(sasLogger, appender); + } } public void testGetRoleDescriptor() throws ExecutionException, InterruptedException { @@ -213,4 +401,13 @@ ServiceAccountService.REALM_NAME, ServiceAccountService.REALM_TYPE, randomAlphaO assertThat(e.getMessage(), containsString( "cannot load role for service account [" + username + "] - no such service account")); } + + private SecureString createBearerString(List bytesList) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (byte[] bytes : bytesList) { + out.write(bytes); + } + return new SecureString(Base64.getEncoder().withoutPadding().encodeToString(out.toByteArray()).toCharArray()); + } + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java new file mode 100644 index 0000000000000..716c64c3405b6 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountTokenTests.java @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.service; + +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.security.authc.service.ServiceAccount.ServiceAccountId; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class ServiceAccountTokenTests extends ESTestCase { + + private static final Set VALID_TOKEN_NAME_CHARS = Set.of( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', + 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '-', '_' + ); + + private static final Set INVALID_TOKEN_NAME_CHARS = Set.of( + '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', + '\\', ']', '^', '`', '{', '|', '}', '~', ' ', '\t', '\n', '\r'); + + public void testIsValidTokenName() { + final String tokenName1 = randomTokenName(); + assertThat(ServiceAccountToken.isValidTokenName(tokenName1), is(true)); + + final String tokenName2 = "_" + randomTokenName().substring(1); + assertThat(ServiceAccountToken.isValidTokenName(tokenName2), is(false)); + + assertThat(ServiceAccountToken.isValidTokenName(null), is(false)); + + final String tokenName3 = randomInvalidTokenName(); + assertThat(ServiceAccountToken.isValidTokenName(tokenName3), is(false)); + } + + public void testNewToken() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + ServiceAccountToken.newToken(accountId, randomTokenName()); + + final IllegalArgumentException e1 = + expectThrows(IllegalArgumentException.class, () -> ServiceAccountToken.newToken(accountId, randomInvalidTokenName())); + assertThat(e1.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); + + final NullPointerException e2 = + expectThrows(NullPointerException.class, () -> ServiceAccountToken.newToken(null, randomTokenName())); + assertThat(e2.getMessage(), containsString("service account ID cannot be null")); + } + + public void testServiceAccountTokenNew() { + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final SecureString secret = new SecureString(randomAlphaOfLength(20).toCharArray()); + new ServiceAccountToken(accountId, randomTokenName(), secret); + + final NullPointerException e1 = + expectThrows(NullPointerException.class, () -> new ServiceAccountToken(null, randomTokenName(), secret)); + assertThat(e1.getMessage(), containsString("service account ID cannot be null")); + + final IllegalArgumentException e2 = + expectThrows(IllegalArgumentException.class, () -> new ServiceAccountToken(accountId, randomInvalidTokenName(), secret)); + assertThat(e2.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); + + final NullPointerException e3 = + expectThrows(NullPointerException.class, () -> new ServiceAccountToken(accountId, randomTokenName(), null)); + assertThat(e3.getMessage(), containsString("service account token secret cannot be null")); + } + + public void testBearerString() throws IOException { + final ServiceAccountToken serviceAccountToken = + new ServiceAccountToken(new ServiceAccountId("elastic", "fleet"), + "token1", new SecureString("supersecret".toCharArray())); + assertThat(serviceAccountToken.asBearerString(), equalTo("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0")); + + assertThat(ServiceAccountToken.fromBearerString(new SecureString("AAEAAWVsYXN0aWMvZmxlZXQvdG9rZW4xOnN1cGVyc2VjcmV0".toCharArray())), + equalTo(serviceAccountToken)); + + final ServiceAccountId accountId = new ServiceAccountId(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + final ServiceAccountToken serviceAccountToken1 = ServiceAccountToken.newToken(accountId, randomTokenName()); + assertThat(ServiceAccountToken.fromBearerString(serviceAccountToken1.asBearerString()), equalTo(serviceAccountToken1)); + } + + public static String randomTokenName() { + final Character[] chars = randomArray( + 1, + 256, + Character[]::new, + () -> randomFrom(VALID_TOKEN_NAME_CHARS)); + final String name = Arrays.stream(chars).map(String::valueOf).collect(Collectors.joining()); + return name.startsWith("_") ? "-" + name.substring(1) : name; + } + + public static String randomInvalidTokenName() { + if (randomBoolean()) { + final String tokenName = randomTokenName(); + final char[] chars = tokenName.toCharArray(); + IntStream.rangeClosed(1, randomIntBetween(1, chars.length)) + .forEach(i -> chars[randomIntBetween(0, chars.length - 1)] = randomFrom(INVALID_TOKEN_NAME_CHARS)); + return new String(chars); + } else { + return randomFrom("", " ", randomAlphaOfLength(257)); + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java index 2bb370b95ebfc..7981a205f4979 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/SecondaryAuthenticatorTests.java @@ -70,6 +70,8 @@ import static org.elasticsearch.xpack.security.authc.support.SecondaryAuthenticator.SECONDARY_AUTH_HEADER_NAME; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -127,8 +129,15 @@ public void setupMocks() throws Exception { final ApiKeyService apiKeyService = new ApiKeyService(settings, clock, client, licenseState, securityIndex, clusterService, mock(CacheInvalidatorRegistry.class),threadPool); + final ServiceAccountService serviceAccountService = mock(ServiceAccountService.class); + doAnswer(invocationOnMock -> { + @SuppressWarnings("unchecked") + final ActionListener listener = (ActionListener) invocationOnMock.getArguments()[2]; + listener.onResponse(null); + return null; + }).when(serviceAccountService).authenticateToken(any(), any(), any()); authenticationService = new AuthenticationService(settings, realms, auditTrail, failureHandler, threadPool, anonymous, - tokenService, apiKeyService, mock(ServiceAccountService.class), OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); + tokenService, apiKeyService, serviceAccountService, OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE); authenticator = new SecondaryAuthenticator(securityContext, authenticationService); } diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java index 4bd670912c3da..ae4ad44cf0784 100644 --- a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/authc/service/FileTokensToolTests.java @@ -128,13 +128,23 @@ public void testParsePrincipalAndTokenName() throws UserException { } public void testCreateToken() throws Exception { - execute("create", pathHomeParameter, "elastic/fleet", "server_42"); - assertServiceTokenExists("elastic/fleet/server_42"); - execute("create", pathHomeParameter, "elastic/fleet", "server_43"); - assertServiceTokenExists("elastic/fleet/server_43"); + final String tokenName1 = ServiceAccountTokenTests.randomTokenName(); + execute("create", pathHomeParameter, "elastic/fleet", tokenName1); + assertServiceTokenExists("elastic/fleet/" + tokenName1); + final String tokenName2 = ServiceAccountTokenTests.randomTokenName(); + execute("create", pathHomeParameter, "elastic/fleet", tokenName2); + assertServiceTokenExists("elastic/fleet/" + tokenName2); final String output = terminal.getOutput(); - assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/server_42 = ")); - assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/server_43 = ")); + assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/" + tokenName1 + " = ")); + assertThat(output, containsString("SERVICE_TOKEN elastic/fleet/" + tokenName2 + " = ")); + } + + public void testCreateTokenWithInvalidTokenName() throws Exception { + final String tokenName = ServiceAccountTokenTests.randomInvalidTokenName(); + final UserException e = expectThrows(UserException.class, + () -> execute("create", pathHomeParameter, "elastic/fleet", tokenName)); + assertServiceTokenNotExists("elastic/fleet/" + tokenName); + assertThat(e.getMessage(), containsString(ServiceAccountToken.INVALID_TOKEN_NAME_MESSAGE)); } public void testCreateTokenWithInvalidServiceAccount() throws Exception {