From de30a0e18e6ca14110f2b3adcccb204496753c47 Mon Sep 17 00:00:00 2001 From: Ioannis Kakavas Date: Thu, 16 Apr 2020 09:35:44 +0300 Subject: [PATCH] Fix responses for the token APIs (#54532) 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. Resolves: #53323 --- .../elasticsearch/client/SecurityClient.java | 8 +- .../security/InvalidateTokenResponse.java | 4 +- .../security/oidc-logout-api.asciidoc | 6 +- .../authentication/oidc-guide.asciidoc | 4 +- .../support/TokensInvalidationResult.java | 19 +- .../token/InvalidateTokenResponseTests.java | 8 +- .../security/authc/ExpiredTokenRemover.java | 1 + .../xpack/security/authc/TokenService.java | 197 +++++++++++------- .../oauth2/RestInvalidateTokenAction.java | 3 +- ...ansportOpenIdConnectLogoutActionTests.java | 1 + .../saml/TransportSamlLogoutActionTests.java | 1 + .../TransportInvalidateTokenActionTests.java | 148 +++++++++++++ .../authc/AuthenticationServiceTests.java | 2 + .../security/authc/TokenAuthIntegTests.java | 128 ++++++++++-- .../security/authc/TokenServiceTests.java | 4 + .../TokensInvalidationResultTests.java | 6 +- .../xpack/security/test/SecurityMocks.java | 1 + .../test/token/11_invalidation.yml | 143 +++++++++++++ 18 files changed, 571 insertions(+), 113 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 be84d790f229e..fba8c6ed2bd14 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)); } /** @@ -1013,7 +1013,7 @@ public Cancellable invalidateApiKeyAsync(final InvalidateApiKeyRequest request, * authenticated TLS session, and it is validated by the PKI realms with {@code delegation.enabled} toggled to {@code true}.
* See the * docs for more details. - * + * * @param request the request containing the certificate chain * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized * @return the response from the delegate-pki-authentication API key call @@ -1031,7 +1031,7 @@ public DelegatePkiAuthenticationResponse delegatePkiAuthentication(DelegatePkiAu * {@code true}.
* See the * docs for more details. - * + * * @param request the request containing the certificate chain * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized * @param listener the listener to be notified upon request completion 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 cc82a24aac230..2d063b6385deb 100644 --- a/x-pack/docs/en/security/authentication/oidc-guide.asciidoc +++ b/x-pack/docs/en/security/authentication/oidc-guide.asciidoc @@ -623,7 +623,7 @@ authenticate a user with OpenID Connect: . Make an HTTP POST request to `_security/oidc/prepare`, authenticating as the `facilitator` user, using the name of the OpenID Connect realm in the {es} configuration in the request body. For more -details, see +details, see <>. + [source,console] @@ -675,7 +675,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..14439761c5f6a 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_8_0_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_8_0_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 83cef1770d835..471c3e8fa0d07 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 78ac4e3745308..a757333a062c4 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 5600326c6e745..3c994ca80700a 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; @@ -191,7 +194,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; @@ -389,7 +392,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); @@ -422,7 +431,13 @@ public void authenticateToken(SecureString tokenString, ActionListener { + if (isShardNotAvailableException(e)) { + listener.onResponse(null); + } else { + listener.onFailure(e); + } + })); } /** @@ -431,14 +446,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 )); } @@ -448,17 +463,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(), - getTokenDocumentId(userTokenId)).request(); + 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"); @@ -484,12 +500,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) ); } @@ -501,6 +516,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); @@ -578,17 +594,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)); + } + })); } } @@ -623,10 +648,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)); + } + })); } } @@ -649,7 +688,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); } @@ -662,7 +701,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); } @@ -785,7 +824,7 @@ 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); + previouslyInvalidated, failedRequestResponses, RestStatus.OK); client.threadPool().schedule(() -> indexInvalidation(retryTokenDocIds, tokensIndexManager, backoff, srcPrefix, incompleteResult, listener), backoff.next(), GENERIC); } else { @@ -799,7 +838,7 @@ private void indexInvalidation(Collection tokenIds, SecurityIndexManager } } final TokensInvalidationResult result = new TokensInvalidationResult(invalidated, previouslyInvalidated, - failedRequestResponses); + failedRequestResponses, RestStatus.OK); listener.onResponse(result); } }, e -> { @@ -827,21 +866,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" @@ -853,26 +901,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); + } } } } @@ -884,7 +936,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()) { @@ -899,11 +951,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() @@ -917,13 +969,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)) { @@ -1618,10 +1665,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); @@ -1748,18 +1795,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 */ @@ -1770,6 +1805,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 2f56da1809d2e..50efb3549fddf 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 @@ -167,6 +167,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 cf41625d75762..8ef94f398566a 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 @@ -203,6 +203,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 c7e365ae82c9e..39d3be4a82a59 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 4389146326776..ad588d017b65b 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 @@ -138,11 +138,12 @@ public void testExpiredTokensDeletedAfterExpiration() throws Exception { final RestHighLevelClient restClient = new TestRestHighLevelClient(); CreateTokenResponse response = restClient.security().createToken(CreateTokenRequest.passwordGrant( SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()), SECURITY_REQUEST_OPTIONS); - + final String accessToken = response.getAccessToken(); + final String refreshToken = response.getRefreshToken(); Instant created = Instant.now(); InvalidateTokenResponse invalidateResponse = restClient.security().invalidateToken( - new InvalidateTokenRequest(response.getAccessToken(), null, null, null), SECURITY_REQUEST_OPTIONS); + new InvalidateTokenRequest(accessToken, null, null, null), SECURITY_REQUEST_OPTIONS); assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(1)); assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); assertThat(invalidateResponse.getErrors().size(), equalTo(0)); @@ -168,18 +169,31 @@ 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 - ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> - restClient.security().invalidateToken(new InvalidateTokenRequest("fooobar", null, null, null), - SECURITY_REQUEST_OPTIONS)); - assertThat(e.getMessage(), containsString("token malformed")); - assertThat(e.status(), equalTo(RestStatus.UNAUTHORIZED)); + InvalidateTokenResponse invalidateResponseTwo = restClient.security() + .invalidateToken(new InvalidateTokenRequest("fooobar", null, null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponseTwo.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponseTwo.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponseTwo.getErrors().size(), equalTo(0)); } restClient.indices().refresh(new RefreshRequest(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS), SECURITY_REQUEST_OPTIONS); SearchResponse searchResponse = restClient.search(new SearchRequest(RestrictedIndicesNames.SECURITY_TOKENS_ALIAS) - .source(SearchSourceBuilder.searchSource() - .query(QueryBuilders.termQuery("doc_type", "token")).terminateAfter(1)), SECURITY_REQUEST_OPTIONS); + .source(SearchSourceBuilder.searchSource() + .query(QueryBuilders.termQuery("doc_type", "token")).terminateAfter(1)), SECURITY_REQUEST_OPTIONS); 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 = restClient.security().invalidateToken( + new InvalidateTokenRequest(accessToken, null, null, null), SECURITY_REQUEST_OPTIONS); + assertThat(invalidateAccessTokenResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateAccessTokenResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateAccessTokenResponse.getErrors().size(), equalTo(0)); + InvalidateTokenResponse invalidateRefreshTokenResponse = restClient.security().invalidateToken( + new InvalidateTokenRequest(refreshToken, null, null, null), SECURITY_REQUEST_OPTIONS); + assertThat(invalidateRefreshTokenResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateRefreshTokenResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateRefreshTokenResponse.getErrors().size(), equalTo(0)); } public void testInvalidateAllTokensForUser() throws Exception { @@ -233,7 +247,7 @@ public void testInvalidateAllTokensForRealmThatHasNone() throws IOException { assertThat(invalidateResponse.getErrors().size(), equalTo(0)); } - public void testExpireMultipleTimes() throws IOException { + public void testInvalidateMultipleTimes() throws IOException { final RestHighLevelClient restClient = new TestRestHighLevelClient(); CreateTokenResponse response = restClient.security().createToken(CreateTokenRequest.passwordGrant( SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()), SECURITY_REQUEST_OPTIONS); @@ -250,6 +264,91 @@ public void testExpireMultipleTimes() throws IOException { assertThat(invalidateAgainResponse.getErrors().size(), equalTo(0)); } + public void testInvalidateNotValidAccessTokens() throws Exception { + final RestHighLevelClient restClient = new TestRestHighLevelClient(); + // Perform a request to invalidate a token, before the tokens index is created + ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> restClient.security() + .invalidateToken(new InvalidateTokenRequest(generateAccessToken(Version.CURRENT), null, null, null), + SECURITY_REQUEST_OPTIONS)); + assertThat(e.status(), equalTo(RestStatus.BAD_REQUEST)); + // Create a token to trigger index creation + restClient.security().createToken(CreateTokenRequest.passwordGrant( + SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()), SECURITY_REQUEST_OPTIONS); + InvalidateTokenResponse invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest("!this_is_not_a_base64_string_and_we_should_fail_decoding_it", null, null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + + invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest("10we+might+assume+this+is+valid+old+token", null, null, + null), SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + + invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest(generateInvalidShortAccessToken(Version.CURRENT), null, null, + null), SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.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 = restClient.security() + .invalidateToken(new InvalidateTokenRequest(generateAccessToken(Version.CURRENT), null, null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + + public void testInvalidateNotValidRefreshTokens() throws Exception { + final RestHighLevelClient restClient = new TestRestHighLevelClient(); + // Perform a request to invalidate a refresh token, before the tokens index is created + ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> restClient.security() + .invalidateToken(new InvalidateTokenRequest(null, + TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID()), null, null), + SECURITY_REQUEST_OPTIONS)); + assertThat(e.status(), equalTo(RestStatus.BAD_REQUEST)); + // Create a token to trigger index creation + restClient.security().createToken(CreateTokenRequest.passwordGrant( + SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD.toCharArray()), SECURITY_REQUEST_OPTIONS); + InvalidateTokenResponse invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest(null, "!this_is_not_a_base64_string_and_we_should_fail_decoding_it", null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + + invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest(null, "10we+might+assume+this+is+valid+old+token", null, + null), SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + + invalidateResponse = restClient.security() + .invalidateToken(new InvalidateTokenRequest(null, + TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, randomAlphaOfLength(32)), null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.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 = restClient.security() + .invalidateToken(new InvalidateTokenRequest(null, + TokenService.prependVersionAndEncodeRefreshToken(Version.CURRENT, UUIDs.randomBase64UUID()), null, null), + SECURITY_REQUEST_OPTIONS); + assertThat(invalidateResponse.getInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getPreviouslyInvalidatedTokens(), equalTo(0)); + assertThat(invalidateResponse.getErrors().size(), equalTo(0)); + } + public void testRefreshingToken() throws IOException { final RestHighLevelClient restClient = new TestRestHighLevelClient(); CreateTokenResponse response = restClient.security().createToken(CreateTokenRequest.passwordGrant( @@ -512,9 +611,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 531f672d0b8ee..461149c62572e 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; @@ -677,10 +678,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<>(); @@ -688,6 +691,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 c9bb7879147b6..71655aab31bea 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 @@ -86,6 +86,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 }