From 5ed4e7cc86fc7d733852feec3824d7f24f6a0d13 Mon Sep 17 00:00:00 2001 From: Ioannis Kakavas Date: Thu, 14 May 2020 11:26:27 +0300 Subject: [PATCH] [7.7] [7.x] Fix responses for the token APIs (#56741) This commit fixes our behavior regarding the responses we return in various cases for the use of token related APIs. More concretely: - In the Get Token API with the `refresh` grant, when an invalid (already deleted, malformed, unknown) refresh token is used in the body of the request, we respond with `400` HTTP status code and an `error_description` header with the message "could not refresh the requested token". Previously we would return erroneously return a `401` with "token malformed" message. - In the Invalidate Token API, when using an invalid (already deleted, malformed, unknown) access or refresh token, we respond with `404` and a body that shows that no tokens were invalidated: ``` { "invalidated_tokens":0, "previously_invalidated_tokens":0, "error_count":0 } ``` The previous behavior would be to erroneously return a `400` or `401` ( depending on the case ). - In the Invalidate Token API, when the tokens index doesn't exist or is closed, we return `400` because we assume this is a user issue either because they tried to invalidate a token when there is no tokens index yet ( i.e. no tokens have been created yet or the tokens index has been deleted ) or the index is closed. - In the Invalidate Token API, when the tokens index is unavailable, we return a `503` status code because we want to signal to the caller of the API that the token they tried to invalidate was not invalidated and we can't be sure if it is still valid or not, and that they should try the request again. Backport of #54532 --- .../elasticsearch/client/SecurityClient.java | 4 +- .../security/InvalidateTokenResponse.java | 4 +- .../security/oidc-logout-api.asciidoc | 6 +- .../authentication/oidc-guide.asciidoc | 2 +- .../support/TokensInvalidationResult.java | 19 +- .../token/InvalidateTokenResponseTests.java | 8 +- .../security/authc/ExpiredTokenRemover.java | 1 + .../xpack/security/authc/TokenService.java | 201 ++++++++++------- .../oauth2/RestInvalidateTokenAction.java | 3 +- ...ansportOpenIdConnectLogoutActionTests.java | 1 + .../saml/TransportSamlLogoutActionTests.java | 1 + .../TransportInvalidateTokenActionTests.java | 148 +++++++++++++ .../authc/AuthenticationServiceTests.java | 2 + .../security/authc/TokenAuthIntegTests.java | 202 ++++++++++++++---- .../security/authc/TokenServiceTests.java | 4 + .../TokensInvalidationResultTests.java | 6 +- .../xpack/security/test/SecurityMocks.java | 1 + .../test/token/11_invalidation.yml | 143 +++++++++++++ 18 files changed, 618 insertions(+), 138 deletions(-) create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java create mode 100644 x-pack/plugin/src/test/resources/rest-api-spec/test/token/11_invalidation.yml diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java index ef2e9642c9899..dac92d0541503 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java @@ -765,7 +765,7 @@ public Cancellable createTokenAsync(CreateTokenRequest request, RequestOptions o */ public InvalidateTokenResponse invalidateToken(InvalidateTokenRequest request, RequestOptions options) throws IOException { return restHighLevelClient.performRequestAndParseEntity(request, SecurityRequestConverters::invalidateToken, options, - InvalidateTokenResponse::fromXContent, emptySet()); + InvalidateTokenResponse::fromXContent, singleton(404)); } /** @@ -780,7 +780,7 @@ public InvalidateTokenResponse invalidateToken(InvalidateTokenRequest request, R public Cancellable invalidateTokenAsync(InvalidateTokenRequest request, RequestOptions options, ActionListener listener) { return restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::invalidateToken, options, - InvalidateTokenResponse::fromXContent, listener, emptySet()); + InvalidateTokenResponse::fromXContent, listener, singleton(404)); } /** diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/InvalidateTokenResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/InvalidateTokenResponse.java index e70036ff3030d..0ca57a49c9340 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/InvalidateTokenResponse.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/InvalidateTokenResponse.java @@ -62,10 +62,10 @@ public final class InvalidateTokenResponse { PARSER.declareInt(constructorArg(), PREVIOUSLY_INVALIDATED_TOKENS); PARSER.declareInt(constructorArg(), ERROR_COUNT); PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> ElasticsearchException.fromXContent(p), ERRORS); + } - public InvalidateTokenResponse(int invalidatedTokens, int previouslyInvalidatedTokens, - @Nullable List errors) { + public InvalidateTokenResponse(int invalidatedTokens, int previouslyInvalidatedTokens, @Nullable List errors) { this.invalidatedTokens = invalidatedTokens; this.previouslyInvalidatedTokens = previouslyInvalidatedTokens; if (null == errors) { diff --git a/x-pack/docs/en/rest-api/security/oidc-logout-api.asciidoc b/x-pack/docs/en/rest-api/security/oidc-logout-api.asciidoc index 0b6ef70302626..45a91d2317468 100644 --- a/x-pack/docs/en/rest-api/security/oidc-logout-api.asciidoc +++ b/x-pack/docs/en/rest-api/security/oidc-logout-api.asciidoc @@ -3,7 +3,7 @@ === OpenID Connect logout API Submits a request to invalidate a refresh token and an access token that was -generated as a response to a call to `/_security/oidc/authenticate`. +generated as a response to a call to `/_security/oidc/authenticate`. [[security-api-oidc-logout-request]] ==== {api-request-title} @@ -48,7 +48,7 @@ POST /_security/oidc/logout "refresh_token": "vLBPvmAB6KvwvJZr27cS" } -------------------------------------------------- -// TEST[catch:unauthorized] +// TEST[catch:request] The following example output of the response contains the URI pointing to the End Session Endpoint of the OpenID Connect Provider with all the parameters of @@ -60,4 +60,4 @@ the Logout Request, as HTTP GET parameters: "redirect" : "https://op-provider.org/logout?id_token_hint=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c&post_logout_redirect_uri=http%3A%2F%2Foidc-kibana.elastic.co%2Floggedout&state=lGYK0EcSLjqH6pkT5EVZjC6eIW5YCGgywj2sxROO" } -------------------------------------------------- -// NOTCONSOLE \ No newline at end of file +// NOTCONSOLE diff --git a/x-pack/docs/en/security/authentication/oidc-guide.asciidoc b/x-pack/docs/en/security/authentication/oidc-guide.asciidoc index 7974ac49f6262..56feab8794008 100644 --- a/x-pack/docs/en/security/authentication/oidc-guide.asciidoc +++ b/x-pack/docs/en/security/authentication/oidc-guide.asciidoc @@ -672,7 +672,7 @@ POST /_security/oidc/logout "refresh_token": "vLBPvmAB6KvwvJZr27cS" } -------------------------------------------------- -// TEST[catch:unauthorized] +// TEST[catch:request] + If the realm is configured accordingly, this may result in a response with a `redirect` parameter indicating where the user needs to be redirected in the OP in order to complete the logout process. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/TokensInvalidationResult.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/TokensInvalidationResult.java index 3f2e9f5882ba1..162047cf9660e 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/TokensInvalidationResult.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/TokensInvalidationResult.java @@ -14,6 +14,7 @@ import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.rest.RestStatus; import java.io.IOException; import java.util.Collections; @@ -33,9 +34,10 @@ public class TokensInvalidationResult implements ToXContentObject, Writeable { private final List invalidatedTokens; private final List previouslyInvalidatedTokens; private final List errors; + private RestStatus restStatus; public TokensInvalidationResult(List invalidatedTokens, List previouslyInvalidatedTokens, - @Nullable List errors) { + @Nullable List errors, RestStatus restStatus) { Objects.requireNonNull(invalidatedTokens, "invalidated_tokens must be provided"); this.invalidatedTokens = invalidatedTokens; Objects.requireNonNull(previouslyInvalidatedTokens, "previously_invalidated_tokens must be provided"); @@ -45,6 +47,7 @@ public TokensInvalidationResult(List invalidatedTokens, List pre } else { this.errors = Collections.emptyList(); } + this.restStatus = restStatus; } public TokensInvalidationResult(StreamInput in) throws IOException { @@ -54,10 +57,13 @@ public TokensInvalidationResult(StreamInput in) throws IOException { if (in.getVersion().before(Version.V_7_2_0)) { in.readVInt(); } + if (in.getVersion().onOrAfter(Version.V_7_7_0)) { + this.restStatus = RestStatus.readFrom(in); + } } - public static TokensInvalidationResult emptyResult() { - return new TokensInvalidationResult(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + public static TokensInvalidationResult emptyResult(RestStatus restStatus) { + return new TokensInvalidationResult(Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), restStatus); } @@ -73,6 +79,10 @@ public List getErrors() { return errors; } + public RestStatus getRestStatus() { + return restStatus; + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject() @@ -100,5 +110,8 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getVersion().before(Version.V_7_2_0)) { out.writeVInt(5); } + if (out.getVersion().onOrAfter(Version.V_7_7_0)) { + RestStatus.writeTo(out, restStatus); + } } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/InvalidateTokenResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/InvalidateTokenResponseTests.java index 657799dc735bf..13cedb938b9c4 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/InvalidateTokenResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/InvalidateTokenResponseTests.java @@ -12,6 +12,7 @@ import org.elasticsearch.common.xcontent.ToXContent; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.core.security.authc.support.TokensInvalidationResult; @@ -29,7 +30,8 @@ public void testSerialization() throws IOException { TokensInvalidationResult result = new TokensInvalidationResult(Arrays.asList(generateRandomStringArray(20, 15, false)), Arrays.asList(generateRandomStringArray(20, 15, false)), Arrays.asList(new ElasticsearchException("foo", new IllegalArgumentException("this is an error message")), - new ElasticsearchException("bar", new IllegalArgumentException("this is an error message2")))); + new ElasticsearchException("bar", new IllegalArgumentException("this is an error message2"))), + RestStatus.OK); InvalidateTokenResponse response = new InvalidateTokenResponse(result); try (BytesStreamOutput output = new BytesStreamOutput()) { response.writeTo(output); @@ -45,7 +47,7 @@ public void testSerialization() throws IOException { } result = new TokensInvalidationResult(Arrays.asList(generateRandomStringArray(20, 15, false)), - Arrays.asList(generateRandomStringArray(20, 15, false)), Collections.emptyList()); + Arrays.asList(generateRandomStringArray(20, 15, false)), Collections.emptyList(), RestStatus.OK); response = new InvalidateTokenResponse(result); try (BytesStreamOutput output = new BytesStreamOutput()) { response.writeTo(output); @@ -64,7 +66,7 @@ public void testToXContent() throws IOException { List previouslyInvalidatedTokens = Arrays.asList(generateRandomStringArray(20, 15, false)); TokensInvalidationResult result = new TokensInvalidationResult(invalidatedTokens, previouslyInvalidatedTokens, Arrays.asList(new ElasticsearchException("foo", new IllegalArgumentException("this is an error message")), - new ElasticsearchException("bar", new IllegalArgumentException("this is an error message2")))); + new ElasticsearchException("bar", new IllegalArgumentException("this is an error message2"))), RestStatus.OK); InvalidateTokenResponse response = new InvalidateTokenResponse(result); XContentBuilder builder = XContentFactory.jsonBuilder(); response.toXContent(builder, ToXContent.EMPTY_PARAMS); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredTokenRemover.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredTokenRemover.java index 23e7bb2fe0fe5..549cfd62904eb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredTokenRemover.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ExpiredTokenRemover.java @@ -70,6 +70,7 @@ public void doRun() { indicesWithTokens.add(securityMainIndex.aliasName()); } if (indicesWithTokens.isEmpty()) { + markComplete(); return; } DeleteByQueryRequest expiredDbq = new DeleteByQueryRequest(indicesWithTokens.toArray(new String[0])); 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 dbc0707420691..f4ced90c18ba1 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 @@ -70,14 +70,17 @@ import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.index.engine.VersionConflictEngineException; import org.elasticsearch.index.query.BoolQueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.index.seqno.SequenceNumbers; +import org.elasticsearch.indices.IndexClosedException; import org.elasticsearch.license.LicenseUtils; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchHits; import org.elasticsearch.xpack.core.XPackField; import org.elasticsearch.xpack.core.XPackPlugin; import org.elasticsearch.xpack.core.XPackSettings; @@ -192,7 +195,7 @@ public final class TokenService { static final int LEGACY_MINIMUM_BYTES = VERSION_BYTES + SALT_BYTES + IV_BYTES + 1; static final int MINIMUM_BYTES = VERSION_BYTES + TOKEN_LENGTH + 1; static final int LEGACY_MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * LEGACY_MINIMUM_BYTES) / 3)).intValue(); - static final int MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * MINIMUM_BYTES) / 3)).intValue(); + public static final int MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * MINIMUM_BYTES) / 3)).intValue(); static final Version VERSION_HASHED_TOKENS = Version.V_7_2_0; 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; @@ -390,7 +393,13 @@ void getAndValidateToken(ThreadContext ctx, ActionListener listener) } else { listener.onResponse(null); } - }, listener::onFailure)); + }, e -> { + if (isShardNotAvailableException(e)) { + listener.onResponse(null); + } else { + listener.onFailure(e); + } + })); } } else { listener.onResponse(null); @@ -423,7 +432,13 @@ public void authenticateToken(SecureString tokenString, ActionListener { + if (isShardNotAvailableException(e)) { + listener.onResponse(null); + } else { + listener.onFailure(e); + } + })); } /** @@ -432,14 +447,14 @@ public void authenticateToken(SecureString tokenString, ActionListener>> listener) { decodeToken(token, ActionListener.wrap( - userToken -> { - if (userToken == null) { - listener.onFailure(new ElasticsearchSecurityException("supplied token is not valid")); - } else { - listener.onResponse(new Tuple<>(userToken.getAuthentication(), userToken.getMetadata())); - } - }, - listener::onFailure + userToken -> { + if (userToken == null) { + listener.onFailure(new ElasticsearchSecurityException("supplied token is not valid")); + } else { + listener.onResponse(new Tuple<>(userToken.getAuthentication(), userToken.getMetadata())); + } + }, + listener::onFailure )); } @@ -449,17 +464,18 @@ public void getAuthenticationAndMetaData(String token, ActionListener listener) { final SecurityIndexManager tokensIndex = getTokensIndexForVersion(tokenVersion); - if (tokensIndex.isAvailable() == false) { + final SecurityIndexManager frozenTokensIndex = tokensIndex.freeze(); + if (frozenTokensIndex.isAvailable() == false) { logger.warn("failed to get access token [{}] because index [{}] is not available", userTokenId, tokensIndex.aliasName()); - listener.onResponse(null); + listener.onFailure(frozenTokensIndex.getUnavailableReason()); } else { final GetRequest getRequest = client.prepareGet(tokensIndex.aliasName(), SINGLE_MAPPING_NAME, getTokenDocumentId(userTokenId)).request(); final Consumer onFailure = ex -> listener.onFailure(traceLog("get token from id", userTokenId, ex)); tokensIndex.checkIndexVersionThenExecute( - ex -> listener.onFailure(traceLog("prepare tokens index [" + tokensIndex.aliasName() +"]", userTokenId, ex)), + ex -> listener.onFailure(traceLog("prepare tokens index [" + tokensIndex.aliasName() + "]", userTokenId, ex)), () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, getRequest, - ActionListener.wrap(response -> { + ActionListener.wrap(response -> { if (response.isExists()) { Map accessTokenSource = (Map) response.getSource().get("access_token"); @@ -485,12 +501,11 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action // the token is not valid if (isShardNotAvailableException(e)) { logger.warn("failed to get access token [{}] because index [{}] is not available", userTokenId, - tokensIndex.aliasName()); - listener.onResponse(null); + tokensIndex.aliasName()); } else { logger.error(new ParameterizedMessage("failed to get access token [{}]", userTokenId), e); - listener.onFailure(e); } + listener.onFailure(e); }), client::get) ); } @@ -502,6 +517,7 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action * expensive so this should not block the current thread, which is typically a network thread. A second reason for being asynchronous is * that we can restrain the amount of resources consumed by the key computation to a single thread. For tokens created in an after * {@code #VERSION_ACCESS_TOKENS_UUIDS} cluster, the token is just the token document Id so this is used directly without decryption + * */ void decodeToken(String token, ActionListener listener) { final byte[] bytes = token.getBytes(StandardCharsets.UTF_8); @@ -579,17 +595,26 @@ void decodeToken(String token, ActionListener listener) { public void invalidateAccessToken(String accessToken, ActionListener listener) { ensureEnabled(); if (Strings.isNullOrEmpty(accessToken)) { - listener.onFailure(traceLog("no access token provided", new IllegalArgumentException("access token must be provided"))); + listener.onFailure(traceLog("invalidate access token", new IllegalArgumentException("access token must be provided"))); } else { maybeStartTokenRemover(); final Iterator backoff = DEFAULT_BACKOFF.iterator(); decodeToken(accessToken, ActionListener.wrap(userToken -> { if (userToken == null) { - listener.onFailure(traceLog("invalidate token", accessToken, malformedTokenException())); + // The chances of a random token string decoding to something that we can read is minimal, so + // we assume that this was a token we have created but is now expired/revoked and deleted + logger.trace("The access token [{}] is expired and already deleted", accessToken); + listener.onResponse(TokensInvalidationResult.emptyResult(RestStatus.NOT_FOUND)); } else { indexInvalidation(Collections.singleton(userToken), backoff, "access_token", null, listener); } - }, listener::onFailure)); + }, e -> { + if (e instanceof IndexNotFoundException || e instanceof IndexClosedException) { + listener.onFailure(new ElasticsearchSecurityException("failed to invalidate token", RestStatus.BAD_REQUEST)); + } else { + listener.onFailure(unableToPerformAction(e)); + } + })); } } @@ -624,10 +649,24 @@ public void invalidateRefreshToken(String refreshToken, ActionListener backoff = DEFAULT_BACKOFF.iterator(); findTokenFromRefreshToken(refreshToken, - backoff, ActionListener.wrap(searchResponse -> { - final Tuple parsedTokens = parseTokensFromDocument(searchResponse.getSourceAsMap(), null); - indexInvalidation(Collections.singletonList(parsedTokens.v1()), backoff, "refresh_token", null, listener); - }, listener::onFailure)); + backoff, ActionListener.wrap(searchHits -> { + if (searchHits.getHits().length < 1) { + logger.debug("could not find token document for refresh token"); + listener.onResponse(TokensInvalidationResult.emptyResult(RestStatus.NOT_FOUND)); + } else if (searchHits.getHits().length > 1) { + listener.onFailure(new IllegalStateException("multiple tokens share the same refresh token")); + } else { + final Tuple parsedTokens = + parseTokensFromDocument(searchHits.getAt(0).getSourceAsMap(), null); + indexInvalidation(Collections.singletonList(parsedTokens.v1()), backoff, "refresh_token", null, listener); + } + }, e -> { + if (e instanceof IndexNotFoundException || e instanceof IndexClosedException) { + listener.onFailure(new ElasticsearchSecurityException("failed to invalidate token", RestStatus.BAD_REQUEST)); + } else { + listener.onFailure(unableToPerformAction(e)); + } + })); } } @@ -650,7 +689,7 @@ public void invalidateActiveTokensForRealmAndUser(@Nullable String realmName, @N findActiveTokensForUser(username, ActionListener.wrap(tokenTuples -> { if (tokenTuples.isEmpty()) { logger.warn("No tokens to invalidate for realm [{}] and username [{}]", realmName, username); - listener.onResponse(TokensInvalidationResult.emptyResult()); + listener.onResponse(TokensInvalidationResult.emptyResult(RestStatus.OK)); } else { invalidateAllTokens(tokenTuples.stream().map(t -> t.v1()).collect(Collectors.toList()), listener); } @@ -663,7 +702,7 @@ public void invalidateActiveTokensForRealmAndUser(@Nullable String realmName, @N findActiveTokensForRealm(realmName, filter, ActionListener.wrap(tokenTuples -> { if (tokenTuples.isEmpty()) { logger.warn("No tokens to invalidate for realm [{}] and username [{}]", realmName, username); - listener.onResponse(TokensInvalidationResult.emptyResult()); + listener.onResponse(TokensInvalidationResult.emptyResult(RestStatus.OK)); } else { invalidateAllTokens(tokenTuples.stream().map(t -> t.v1()).collect(Collectors.toList()), listener); } @@ -786,11 +825,9 @@ private void indexInvalidation(Collection tokenIds, SecurityIndexManager logger.debug("failed to invalidate [{}] tokens out of [{}], retrying to invalidate these too", retryTokenDocIds.size(), tokenIds.size()); final TokensInvalidationResult incompleteResult = new TokensInvalidationResult(invalidated, - previouslyInvalidated, failedRequestResponses); - final Runnable retryWithContextRunnable = client.threadPool().getThreadContext() - .preserveContext(() -> indexInvalidation(retryTokenDocIds, tokensIndexManager, backoff, - srcPrefix, incompleteResult, listener)); - client.threadPool().schedule(retryWithContextRunnable, backoff.next(), GENERIC); + previouslyInvalidated, failedRequestResponses, RestStatus.OK); + client.threadPool().schedule(() -> indexInvalidation(retryTokenDocIds, tokensIndexManager, backoff, + srcPrefix, incompleteResult, listener), backoff.next(), GENERIC); } else { if (retryTokenDocIds.isEmpty() == false) { logger.warn("failed to invalidate [{}] tokens out of [{}] after all retries", retryTokenDocIds.size(), @@ -802,7 +839,7 @@ private void indexInvalidation(Collection tokenIds, SecurityIndexManager } } final TokensInvalidationResult result = new TokensInvalidationResult(invalidated, previouslyInvalidated, - failedRequestResponses); + failedRequestResponses, RestStatus.OK); listener.onResponse(result); } }, e -> { @@ -832,21 +869,30 @@ public void refreshToken(String refreshToken, ActionListener backoff = DEFAULT_BACKOFF.iterator(); + final Consumer onFailure = ex -> listener.onFailure(traceLog("find token by refresh token", refreshToken, ex)); findTokenFromRefreshToken(refreshToken, backoff, - ActionListener.wrap(tokenDocHit -> { - final Authentication clientAuth = securityContext.getAuthentication(); - innerRefresh(refreshToken, tokenDocHit.getId(), tokenDocHit.getSourceAsMap(), tokenDocHit.getSeqNo(), - tokenDocHit.getPrimaryTerm(), - clientAuth, backoff, refreshRequested, listener); - }, listener::onFailure)); + ActionListener.wrap(searchHits -> { + if (searchHits.getHits().length < 1) { + logger.warn("could not find token document for refresh token"); + onFailure.accept(invalidGrantException("could not refresh the requested token")); + } else if (searchHits.getHits().length > 1) { + onFailure.accept(new IllegalStateException("multiple tokens share the same refresh token")); + } else { + final SearchHit tokenDocHit = searchHits.getAt(0); + final Authentication clientAuth = securityContext.getAuthentication(); + innerRefresh(refreshToken, tokenDocHit.getId(), tokenDocHit.getSourceAsMap(), tokenDocHit.getSeqNo(), + tokenDocHit.getPrimaryTerm(), + clientAuth, backoff, refreshRequested, listener); + } + }, e -> listener.onFailure(invalidGrantException("could not refresh the requested token")))); } /** * Infers the format and version of the passed in {@code refreshToken}. Delegates the actual search of the token document to * {@code #findTokenFromRefreshToken(String, SecurityIndexManager, Iterator, ActionListener)} . */ - private void findTokenFromRefreshToken(String refreshToken, Iterator backoff, ActionListener listener) { + private void findTokenFromRefreshToken(String refreshToken, Iterator backoff, ActionListener listener) { if (refreshToken.length() == TOKEN_LENGTH) { // first check if token has the old format before the new version-prepended one logger.debug("Assuming an unversioned refresh token [{}], generated for node versions" @@ -858,26 +904,30 @@ private void findTokenFromRefreshToken(String refreshToken, Iterator findTokenFromRefreshToken(refreshToken, securityTokensIndex, backoff, listener); } else { logger.debug("Assuming a refresh token [{}] provided from a client", refreshToken); + final Version refreshTokenVersion; + final String unencodedRefreshToken; + final Tuple versionAndRefreshTokenTuple; try { - final Tuple versionAndRefreshTokenTuple = unpackVersionAndPayload(refreshToken); - final Version refreshTokenVersion = versionAndRefreshTokenTuple.v1(); - final String unencodedRefreshToken = versionAndRefreshTokenTuple.v2(); - if (refreshTokenVersion.before(VERSION_TOKENS_INDEX_INTRODUCED) || unencodedRefreshToken.length() != TOKEN_LENGTH) { - logger.debug("Decoded refresh token [{}] with version [{}] is invalid.", unencodedRefreshToken, - refreshTokenVersion); - listener.onFailure(malformedTokenException()); - } else { - // TODO Remove this conditional after backporting to 7.x - if (refreshTokenVersion.onOrAfter(VERSION_HASHED_TOKENS)) { - final String hashedRefreshToken = hashTokenString(unencodedRefreshToken); - findTokenFromRefreshToken(hashedRefreshToken, securityTokensIndex, backoff, listener); - } else { - findTokenFromRefreshToken(unencodedRefreshToken, securityTokensIndex, backoff, listener); - } - } + versionAndRefreshTokenTuple = unpackVersionAndPayload(refreshToken); + refreshTokenVersion = versionAndRefreshTokenTuple.v1(); + unencodedRefreshToken = versionAndRefreshTokenTuple.v2(); } catch (IOException e) { logger.debug(() -> new ParameterizedMessage("Could not decode refresh token [{}].", refreshToken), e); - listener.onFailure(malformedTokenException()); + listener.onResponse(SearchHits.empty()); + return; + } + if (refreshTokenVersion.before(VERSION_TOKENS_INDEX_INTRODUCED) || unencodedRefreshToken.length() != TOKEN_LENGTH) { + logger.debug("Decoded refresh token [{}] with version [{}] is invalid.", unencodedRefreshToken, + refreshTokenVersion); + listener.onResponse(SearchHits.empty()); + } else { + // TODO Remove this conditional after backporting to 7.x + if (refreshTokenVersion.onOrAfter(VERSION_HASHED_TOKENS)) { + final String hashedRefreshToken = hashTokenString(unencodedRefreshToken); + findTokenFromRefreshToken(hashedRefreshToken, securityTokensIndex, backoff, listener); + } else { + findTokenFromRefreshToken(unencodedRefreshToken, securityTokensIndex, backoff, listener); + } } } } @@ -889,7 +939,7 @@ private void findTokenFromRefreshToken(String refreshToken, Iterator * backoff policy. This method requires the tokens index where the token document, pointed to by the refresh token, resides. */ private void findTokenFromRefreshToken(String refreshToken, SecurityIndexManager tokensIndexManager, Iterator backoff, - ActionListener listener) { + ActionListener listener) { final Consumer onFailure = ex -> listener.onFailure(traceLog("find token by refresh token", refreshToken, ex)); final Consumer maybeRetryOnFailure = ex -> { if (backoff.hasNext()) { @@ -905,11 +955,11 @@ private void findTokenFromRefreshToken(String refreshToken, SecurityIndexManager }; final SecurityIndexManager frozenTokensIndex = tokensIndexManager.freeze(); if (frozenTokensIndex.indexExists() == false) { - logger.warn("index [{}] does not exist therefore refresh token cannot be validated", frozenTokensIndex.aliasName()); - listener.onFailure(invalidGrantException("could not refresh the requested token")); + logger.warn("index [{}] does not exist so we can't find token from refresh token", frozenTokensIndex.aliasName()); + listener.onFailure(frozenTokensIndex.getUnavailableReason()); } else if (frozenTokensIndex.isAvailable() == false) { logger.debug("index [{}] is not available to find token from refresh token, retrying", frozenTokensIndex.aliasName()); - maybeRetryOnFailure.accept(invalidGrantException("could not refresh the requested token")); + maybeRetryOnFailure.accept(frozenTokensIndex.getUnavailableReason()); } else { final SearchRequest request = client.prepareSearch(tokensIndexManager.aliasName()) .setQuery(QueryBuilders.boolQuery() @@ -923,13 +973,8 @@ private void findTokenFromRefreshToken(String refreshToken, SecurityIndexManager if (searchResponse.isTimedOut()) { logger.debug("find token from refresh token response timed out, retrying"); maybeRetryOnFailure.accept(invalidGrantException("could not refresh the requested token")); - } else if (searchResponse.getHits().getHits().length < 1) { - logger.warn("could not find token document for refresh token"); - onFailure.accept(invalidGrantException("could not refresh the requested token")); - } else if (searchResponse.getHits().getHits().length > 1) { - onFailure.accept(new IllegalStateException("multiple tokens share the same refresh token")); } else { - listener.onResponse(searchResponse.getHits().getAt(0)); + listener.onResponse(searchResponse.getHits()); } }, e -> { if (isShardNotAvailableException(e)) { @@ -1676,10 +1721,10 @@ String prependVersionAndEncodeAccessToken(Version version, String accessToken) t } } - static String prependVersionAndEncodeRefreshToken(Version version, String payload) { + public static String prependVersionAndEncodeRefreshToken(Version version, String payload) { try (ByteArrayOutputStream os = new ByteArrayOutputStream(); - OutputStream base64 = Base64.getEncoder().wrap(os); - StreamOutput out = new OutputStreamStreamOutput(base64)) { + OutputStream base64 = Base64.getEncoder().wrap(os); + StreamOutput out = new OutputStreamStreamOutput(base64)) { out.setVersion(version); Version.writeVersion(version, out); out.writeString(payload); @@ -1806,18 +1851,6 @@ private static ElasticsearchSecurityException expiredTokenException() { return e; } - /** - * Creates an {@link ElasticsearchSecurityException} that indicates the token was malformed. It - * is up to the client to re-authenticate and obtain a new token. The format for this response - * is defined in - */ - private static ElasticsearchSecurityException malformedTokenException() { - ElasticsearchSecurityException e = - new ElasticsearchSecurityException("token malformed", RestStatus.UNAUTHORIZED); - e.addHeader("WWW-Authenticate", MALFORMED_TOKEN_WWW_AUTH_VALUE); - return e; - } - /** * Creates an {@link ElasticsearchSecurityException} that indicates the request contained an invalid grant */ @@ -1828,6 +1861,10 @@ private static ElasticsearchSecurityException invalidGrantException(String detai return e; } + private static ElasticsearchSecurityException unableToPerformAction(@Nullable Throwable cause) { + return new ElasticsearchSecurityException("unable to perform requested action", RestStatus.SERVICE_UNAVAILABLE, cause); + } + /** * Logs an exception concerning a specific Token at TRACE level (if enabled) */ diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestInvalidateTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestInvalidateTokenAction.java index d9a16453229fd..befd4ce51c038 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestInvalidateTokenAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestInvalidateTokenAction.java @@ -16,7 +16,6 @@ import org.elasticsearch.rest.BytesRestResponse; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestResponse; -import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.action.RestBuilderListener; import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction; import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenRequest; @@ -93,7 +92,7 @@ protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClien public RestResponse buildResponse(InvalidateTokenResponse invalidateResp, XContentBuilder builder) throws Exception { invalidateResp.toXContent(builder, channel.request()); - return new BytesRestResponse(RestStatus.OK, builder); + return new BytesRestResponse(invalidateResp.getResult().getRestStatus(), builder); } }); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java index 8104ddb98c469..372a095c93433 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/oidc/TransportOpenIdConnectLogoutActionTests.java @@ -172,6 +172,7 @@ public void setup() throws Exception { return null; }).when(securityIndex).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); when(securityIndex.isAvailable()).thenReturn(true); + when(securityIndex.freeze()).thenReturn(securityIndex); final ClusterService clusterService = ClusterServiceUtils.createClusterService(threadPool); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/saml/TransportSamlLogoutActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/saml/TransportSamlLogoutActionTests.java index a6d13e85617b9..bcf1a4a3127a8 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/saml/TransportSamlLogoutActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/saml/TransportSamlLogoutActionTests.java @@ -204,6 +204,7 @@ public void setup() throws Exception { return null; }).when(securityIndex).checkIndexVersionThenExecute(any(Consumer.class), any(Runnable.class)); when(securityIndex.isAvailable()).thenReturn(true); + when(securityIndex.freeze()).thenReturn(securityIndex); final XPackLicenseState licenseState = mock(XPackLicenseState.class); when(licenseState.isTokenServiceAllowed()).thenReturn(true); 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 new file mode 100644 index 0000000000000..219376a0bcf8f --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportInvalidateTokenActionTests.java @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action.token; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.Version; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.client.Client; +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.settings.Settings; +import org.elasticsearch.index.Index; +import org.elasticsearch.indices.IndexClosedException; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.node.Node; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ClusterServiceUtils; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenRequest; +import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenResponse; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.support.SecurityIndexManager; +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; + +import static org.elasticsearch.xpack.core.security.action.token.InvalidateTokenRequest.Type.ACCESS_TOKEN; +import static org.elasticsearch.xpack.core.security.action.token.InvalidateTokenRequest.Type.REFRESH_TOKEN; +import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.INTERNAL_SECURITY_TOKENS_INDEX_7; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportInvalidateTokenActionTests extends ESTestCase { + + private static final Settings SETTINGS = Settings.builder().put(Node.NODE_NAME_SETTING.getKey(), "TokenServiceTests") + .put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true).build(); + + private ThreadPool threadPool; + private Client client; + private SecurityIndexManager securityIndex; + private ClusterService clusterService; + private XPackLicenseState license; + private SecurityContext securityContext; + + @Before + public void setup() { + threadPool = new TestThreadPool(getTestName()); + securityContext = new SecurityContext(Settings.EMPTY, threadPool.getThreadContext()); + client = mock(Client.class); + when(client.threadPool()).thenReturn(threadPool); + when(client.settings()).thenReturn(SETTINGS); + securityIndex = mock(SecurityIndexManager.class); + this.clusterService = ClusterServiceUtils.createClusterService(threadPool); + this.license = mock(XPackLicenseState.class); + when(license.isTokenServiceAllowed()).thenReturn(true); + } + + public void testInvalidateTokensWhenIndexUnavailable() throws Exception { + when(securityIndex.isAvailable()).thenReturn(false); + when(securityIndex.indexExists()).thenReturn(true); + when(securityIndex.freeze()).thenReturn(securityIndex); + final TokenService tokenService = new TokenService(SETTINGS, Clock.systemUTC(), client, license, securityContext, + securityIndex, securityIndex, clusterService); + final TransportInvalidateTokenAction action = new TransportInvalidateTokenAction(mock(TransportService.class), + new ActionFilters(Collections.emptySet()), tokenService); + + InvalidateTokenRequest request = new InvalidateTokenRequest(generateAccessTokenString(), ACCESS_TOKEN.getValue(), null, null); + PlainActionFuture accessTokenfuture = new PlainActionFuture<>(); + action.doExecute(null, request, accessTokenfuture); + ElasticsearchSecurityException ese = expectThrows(ElasticsearchSecurityException.class, accessTokenfuture::actionGet); + assertThat(ese.getMessage(), containsString("unable to perform requested action")); + assertThat(ese.status(), equalTo(RestStatus.SERVICE_UNAVAILABLE)); + + request = new InvalidateTokenRequest(TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID()), + REFRESH_TOKEN.getValue(), null, null); + PlainActionFuture refreshTokenfuture = new PlainActionFuture<>(); + action.doExecute(null, request, refreshTokenfuture); + ElasticsearchSecurityException ese2 = expectThrows(ElasticsearchSecurityException.class, refreshTokenfuture::actionGet); + assertThat(ese2.getMessage(), containsString("unable to perform requested action")); + assertThat(ese2.status(), equalTo(RestStatus.SERVICE_UNAVAILABLE)); + } + + public void testInvalidateTokensWhenIndexClosed() throws Exception { + when(securityIndex.isAvailable()).thenReturn(false); + when(securityIndex.indexExists()).thenReturn(true); + when(securityIndex.freeze()).thenReturn(securityIndex); + when(securityIndex.getUnavailableReason()).thenReturn(new IndexClosedException(new Index(INTERNAL_SECURITY_TOKENS_INDEX_7, + ClusterState.UNKNOWN_UUID))); + final TokenService tokenService = new TokenService(SETTINGS, Clock.systemUTC(), client, license, securityContext, + securityIndex, securityIndex, clusterService); + final TransportInvalidateTokenAction action = new TransportInvalidateTokenAction(mock(TransportService.class), + new ActionFilters(Collections.emptySet()), tokenService); + + InvalidateTokenRequest request = new InvalidateTokenRequest(generateAccessTokenString(), ACCESS_TOKEN.getValue(), null, null); + PlainActionFuture accessTokenfuture = new PlainActionFuture<>(); + action.doExecute(null, request, accessTokenfuture); + ElasticsearchSecurityException ese = expectThrows(ElasticsearchSecurityException.class, accessTokenfuture::actionGet); + assertThat(ese.getMessage(), containsString("failed to invalidate token")); + assertThat(ese.status(), equalTo(RestStatus.BAD_REQUEST)); + + request = new InvalidateTokenRequest(TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID()), + REFRESH_TOKEN.getValue(), null, null); + PlainActionFuture refreshTokenfuture = new PlainActionFuture<>(); + action.doExecute(null, request, refreshTokenfuture); + ElasticsearchSecurityException ese2 = expectThrows(ElasticsearchSecurityException.class, refreshTokenfuture::actionGet); + assertThat(ese2.getMessage(), containsString("failed to invalidate token")); + assertThat(ese2.status(), equalTo(RestStatus.BAD_REQUEST)); + } + + 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)) { + out.setVersion(Version.CURRENT); + Version.writeVersion(Version.CURRENT, out); + out.writeString(UUIDs.randomBase64UUID()); + return new String(os.toByteArray(), StandardCharsets.UTF_8); + } + } + + @After + public void stopThreadPool() throws Exception { + if (threadPool != null) { + terminate(threadPool); + } + } +} 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 cf20b2ceff6e4..22edca0793412 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 @@ -1260,6 +1260,7 @@ public void testAuthenticateWithToken() throws Exception { String token = tokenFuture.get().v1(); when(client.prepareMultiGet()).thenReturn(new MultiGetRequestBuilder(client, MultiGetAction.INSTANCE)); mockGetTokenFromId(tokenService, userTokenId, expected, false, client); + when(securityIndex.freeze()).thenReturn(securityIndex); when(securityIndex.isAvailable()).thenReturn(true); when(securityIndex.indexExists()).thenReturn(true); try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { @@ -1331,6 +1332,7 @@ public void testInvalidToken() throws Exception { } public void testExpiredToken() throws Exception { + when(securityIndex.freeze()).thenReturn(securityIndex); when(securityIndex.isAvailable()).thenReturn(true); when(securityIndex.indexExists()).thenReturn(true); User user = new User("_username", "r1"); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java index 049c00aeaaaf4..1ffdd6bf3bf08 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenAuthIntegTests.java @@ -149,21 +149,22 @@ public void testTokenServiceCanRotateKeys() throws Exception { public void testExpiredTokensDeletedAfterExpiration() throws Exception { final Client client = client().filterWithHeader(Collections.singletonMap("Authorization", - UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, - SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER, + SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); SecurityClient securityClient = new SecurityClient(client); CreateTokenResponse response = securityClient.prepareCreateToken() - .setGrantType("password") - .setUsername(SecuritySettingsSource.TEST_USER_NAME) - .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) - .get(); - + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) + .get(); + final String accessToken = response.getTokenString(); + final String refreshToken = response.getRefreshToken(); Instant created = Instant.now(); InvalidateTokenResponse invalidateResponse = securityClient - .prepareInvalidateToken(response.getTokenString()) - .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) - .get(); + .prepareInvalidateToken(response.getTokenString()) + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(1)); assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); @@ -191,24 +192,38 @@ public void testExpiredTokensDeletedAfterExpiration() throws Exception { assertBusy(() -> { if (deleteTriggered.compareAndSet(false, true)) { // invalidate a invalid token... doesn't matter that it is bad... we just want this action to trigger the deletion - try { - securityClient.prepareInvalidateToken("fooobar") - .setType(randomFrom(InvalidateTokenRequest.Type.values())) - .execute() - .actionGet(); - } catch (ElasticsearchSecurityException e) { - assertEquals("token malformed", e.getMessage()); - assertThat(e.status(), equalTo(RestStatus.UNAUTHORIZED)); - } + InvalidateTokenResponse invalidateResponseTwo = securityClient.prepareInvalidateToken("fooobar") + .setType(randomFrom(InvalidateTokenRequest.Type.values())) + .execute() + .actionGet(); + assertThat(invalidateResponseTwo.getResult().getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponseTwo.getResult().getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponseTwo.getResult().getErrors().size(), equalTo(0)); } client.admin().indices().prepareRefresh(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS).get(); SearchResponse searchResponse = client.prepareSearch(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS) - .setSource(SearchSourceBuilder.searchSource() - .query(QueryBuilders.termQuery("doc_type", "token"))) - .setTerminateAfter(1) - .get(); + .setSource(SearchSourceBuilder.searchSource() + .query(QueryBuilders.termQuery("doc_type", "token"))) + .setTerminateAfter(1) + .get(); assertThat(searchResponse.getHits().getTotalHits().value, equalTo(0L)); }, 30, TimeUnit.SECONDS); + + // Now the documents are deleted, try to invalidate the access token and refresh token again + InvalidateTokenResponse invalidateAccessTokenResponse = securityClient.prepareInvalidateToken(accessToken) + .setType(randomFrom(InvalidateTokenRequest.Type.values())) + .execute() + .actionGet(); + assertThat(invalidateAccessTokenResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateAccessTokenResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateAccessTokenResponse.getResult().getErrors().size(), equalTo(0)); + InvalidateTokenResponse invalidateRefreshTokenResponse = securityClient.prepareInvalidateToken(refreshToken) + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .execute() + .actionGet(); + assertThat(invalidateRefreshTokenResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateRefreshTokenResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateRefreshTokenResponse.getResult().getErrors().size(), equalTo(0)); } public void testInvalidateAllTokensForUser() throws Exception{ @@ -307,9 +322,9 @@ public void testInvalidateMultipleTimes() { .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) .get(); InvalidateTokenResponse invalidateResponse = securityClient() - .prepareInvalidateToken(response.getTokenString()) - .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) - .get(); + .prepareInvalidateToken(response.getTokenString()) + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(1)); assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); @@ -322,28 +337,136 @@ public void testInvalidateMultipleTimes() { assertThat(invalidateAgainResponse.getResult().getErrors().size(), equalTo(0)); } + public void testInvalidateNotValidAccessTokens() throws Exception { + // Perform a request to invalidate a token, before the tokens index is created + ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> securityClient() + .prepareInvalidateToken(generateAccessToken(Version.CURRENT)) + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get()); + assertThat(e.status(), equalTo(RestStatus.BAD_REQUEST)); + // Create a token to trigger index creation + securityClient().prepareCreateToken() + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) + .get(); + + InvalidateTokenResponse invalidateResponse = + securityClient() + .prepareInvalidateToken("!this_is_not_a_base64_string_and_we_should_fail_decoding_it") + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + invalidateResponse = + securityClient() + .prepareInvalidateToken("10we+might+assume+this+is+valid+old+token") + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + invalidateResponse = + securityClient() + .prepareInvalidateToken(generateInvalidShortAccessToken(Version.CURRENT)) + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + // Generate a token that could be a valid token string for the version we are on, and should decode fine, but is not found in our + // tokens index + invalidateResponse = + securityClient() + .prepareInvalidateToken(generateAccessToken(Version.CURRENT)) + .setType(InvalidateTokenRequest.Type.ACCESS_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + } + + public void testInvalidateNotValidRefreshTokens() throws Exception { + // Perform a request to invalidate a refresh token, before the tokens index is created + ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> securityClient() + .prepareInvalidateToken(TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID())) + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .get()); + assertThat(e.status(), equalTo(RestStatus.BAD_REQUEST)); + // Create a token to trigger index creation + // Create a token to trigger index creation + securityClient().prepareCreateToken() + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) + .get(); + + InvalidateTokenResponse invalidateResponse = + securityClient() + .prepareInvalidateToken("!this_is_not_a_base64_string_and_we_should_fail_decoding_it") + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + invalidateResponse = + securityClient() + .prepareInvalidateToken(TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, randomAlphaOfLength(32))) + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + invalidateResponse = + securityClient() + .prepareInvalidateToken("10we+might+assume+this+is+valid+old+token") + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + // Generate a token that could be a valid token string for the version we are on, and should decode fine, but is not found in our + // tokens index + invalidateResponse = + securityClient() + .prepareInvalidateToken(TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID())) + .setType(InvalidateTokenRequest.Type.REFRESH_TOKEN) + .get(); + assertThat(invalidateResponse.getResult().getInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getPreviouslyInvalidatedTokens().size(), equalTo(0)); + assertThat(invalidateResponse.getResult().getErrors().size(), equalTo(0)); + + } + public void testRefreshingToken() { Client client = client().filterWithHeader(Collections.singletonMap("Authorization", - UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME, - SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); + UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME, + SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING))); SecurityClient securityClient = new SecurityClient(client); CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken() - .setGrantType("password") - .setUsername(SecuritySettingsSource.TEST_USER_NAME) - .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) - .get(); + .setGrantType("password") + .setUsername(SecuritySettingsSource.TEST_USER_NAME) + .setPassword(new SecureString(SecuritySettingsSourceField.TEST_PASSWORD.toCharArray())) + .get(); assertNotNull(createTokenResponse.getRefreshToken()); // get cluster health with token assertNoTimeout(client() - .filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + createTokenResponse.getTokenString())) - .admin().cluster().prepareHealth().get()); + .filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + createTokenResponse.getTokenString())) + .admin().cluster().prepareHealth().get()); CreateTokenResponse refreshResponse = securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get(); assertNotNull(refreshResponse.getRefreshToken()); assertNotEquals(refreshResponse.getRefreshToken(), createTokenResponse.getRefreshToken()); assertNotEquals(refreshResponse.getTokenString(), createTokenResponse.getTokenString()); assertNoTimeout(client().filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + refreshResponse.getTokenString())) - .admin().cluster().prepareHealth().get()); + .admin().cluster().prepareHealth().get()); } public void testRefreshingInvalidatedToken() { @@ -649,9 +772,12 @@ public void testMetadataIsNotSentToClient() { private String generateAccessToken(Version version) throws Exception { TokenService tokenService = internalCluster().getInstance(TokenService.class); String accessTokenString = UUIDs.randomBase64UUID(); - if (version.onOrAfter(TokenService.VERSION_ACCESS_TOKENS_AS_UUIDS)) { - accessTokenString = TokenService.hashTokenString(accessTokenString); - } + return tokenService.prependVersionAndEncodeAccessToken(version, accessTokenString); + } + + private String generateInvalidShortAccessToken(Version version) throws Exception { + TokenService tokenService = internalCluster().getInstance(TokenService.class); + String accessTokenString = randomAlphaOfLength(32); // UUIDs are 36 return tokenService.prependVersionAndEncodeAccessToken(version, accessTokenString); } } 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 2c600a0366720..2f715ac2ca5e3 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 @@ -10,6 +10,7 @@ import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.NoShardAvailableActionException; +import org.elasticsearch.action.UnavailableShardsException; import org.elasticsearch.action.get.GetAction; import org.elasticsearch.action.get.GetRequest; import org.elasticsearch.action.get.GetRequestBuilder; @@ -678,10 +679,12 @@ public void testIndexNotAvailable() throws Exception { tokensIndex = securityMainIndex; when(securityTokensIndex.isAvailable()).thenReturn(false); when(securityTokensIndex.indexExists()).thenReturn(false); + when(securityTokensIndex.freeze()).thenReturn(securityTokensIndex); } else { tokensIndex = securityTokensIndex; when(securityMainIndex.isAvailable()).thenReturn(false); when(securityMainIndex.indexExists()).thenReturn(false); + when(securityMainIndex.freeze()).thenReturn(securityMainIndex); } try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) { PlainActionFuture future = new PlainActionFuture<>(); @@ -689,6 +692,7 @@ public void testIndexNotAvailable() throws Exception { 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); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/TokensInvalidationResultTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/TokensInvalidationResultTests.java index 55ae297ae4e01..c15b4dffd1c3b 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/TokensInvalidationResultTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/TokensInvalidationResultTests.java @@ -11,6 +11,7 @@ import org.elasticsearch.common.xcontent.ToXContent; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.core.security.authc.support.TokensInvalidationResult; @@ -25,7 +26,8 @@ public void testToXcontent() throws Exception{ TokensInvalidationResult result = new TokensInvalidationResult(Arrays.asList("token1", "token2"), Arrays.asList("token3", "token4"), Arrays.asList(new ElasticsearchException("foo", new IllegalStateException("bar")), - new ElasticsearchException("boo", new IllegalStateException("far")))); + new ElasticsearchException("boo", new IllegalStateException("far"))), + RestStatus.OK); try (XContentBuilder builder = JsonXContent.contentBuilder()) { result.toXContent(builder, ToXContent.EMPTY_PARAMS); @@ -56,7 +58,7 @@ public void testToXcontent() throws Exception{ public void testToXcontentWithNoErrors() throws Exception{ TokensInvalidationResult result = new TokensInvalidationResult(Arrays.asList("token1", "token2"), Collections.emptyList(), - Collections.emptyList()); + Collections.emptyList(), RestStatus.OK); try (XContentBuilder builder = JsonXContent.contentBuilder()) { result.toXContent(builder, ToXContent.EMPTY_PARAMS); assertThat(Strings.toString(builder), diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java index 8fd33f478721e..ca418c236a98f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java @@ -87,6 +87,7 @@ public static SecurityIndexManager mockSecurityIndexManager(String alias, boolea when(securityIndexManager.indexExists()).thenReturn(exists); when(securityIndexManager.isAvailable()).thenReturn(available); when(securityIndexManager.aliasName()).thenReturn(alias); + when(securityIndexManager.freeze()).thenReturn(securityIndexManager); return securityIndexManager; } diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/token/11_invalidation.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/token/11_invalidation.yml new file mode 100644 index 0000000000000..d1a8490a834de --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/token/11_invalidation.yml @@ -0,0 +1,143 @@ +--- +setup: + - skip: + features: headers + + - do: + cluster.health: + wait_for_status: yellow + + - do: + security.put_role: + name: "admin_role" + body: > + { + "cluster": ["manage_security"] + } + + - do: + security.put_user: + username: "token_user" + body: > + { + "password" : "x-pack-test-password", + "roles" : [ "admin_role" ], + "full_name" : "Token User" + } + +--- +teardown: + + - do: + security.delete_role: + name: "admin_role" + ignore: 404 + + - do: + security.delete_user: + username: "token_user" + ignore: 404 + +--- +"Test invalidate access token return statuses": + + - do: + catch: bad_request + security.invalidate_token: + body: + token: 46ToAxYzNUdPZWdYOFRQcWhjeHR3NWpvTmVB + + - do: + security.get_token: + body: + grant_type: "password" + username: "token_user" + password: "x-pack-test-password" + + - match: { type: "Bearer" } + - is_true: access_token + - set: { access_token: token } + - match: { expires_in: 1200 } + - is_false: scope + + - do: + catch: missing + security.invalidate_token: + body: + token: "!this_is_not_a_base64_string_and_we_should_fail_decoding_it" + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 } + + - do: + catch: missing + security.invalidate_token: + body: + token: "10we+might+assume+this+is+valid+old+token" + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 } + + - do: + catch: missing + security.invalidate_token: + body: + token: 46ToAxYzNUdPZWdYOFRQcWhjeHR3NWpvTmVB + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 } + +--- +"Test invalidate refresh token return statuses": + + - do: + catch: bad_request + security.invalidate_token: + body: + refresh_token: 46ToAxYzNUdPZWdYOFRQcWhjeHR3NWpvTmVB + + - do: + security.get_token: + body: + grant_type: "password" + username: "token_user" + password: "x-pack-test-password" + + - match: { type: "Bearer" } + - is_true: access_token + - set: { access_token: token } + - match: { expires_in: 1200 } + - is_false: scope + + - do: + catch: missing + security.invalidate_token: + body: + refresh_token: "!this_is_not_a_base64_string_and_we_should_fail_decoding_it" + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 } + + - do: + catch: missing + security.invalidate_token: + body: + refresh_token: "10we+might+assume+this+is+valid+old+token" + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 } + + - do: + catch: missing + security.invalidate_token: + body: + refresh_token: 46ToAxYzNUdPZWdYOFRQcWhjeHR3NWpvTmVB + + - match: { invalidated_tokens: 0 } + - match: { previously_invalidated_tokens: 0 } + - match: { error_count: 0 }