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 a6be426d02f03..4b0077d0d4a23 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 }