Add the ability to refresh tokens obtained via the API (elastic/x-pack-elasticsearch#3468)

This commit adds the ability to refresh tokens that have been obtained by the API using a refresh
token. Refresh tokens are one time use tokens that are valid for 24 hours. The tokens may be used
to get a new access and refresh token if the refresh token has not been invalidated or
already refreshed.

relates elastic/x-pack-elasticsearch#2595

Original commit: elastic/x-pack-elasticsearch@23435eb815
This commit is contained in:
Jay Modi 2018-01-17 12:18:44 -07:00 committed by GitHub
parent f91631664a
commit 60d4b7e53e
27 changed files with 1723 additions and 289 deletions

View File

@ -74,10 +74,12 @@ seconds) that the token expires in, and the type:
{
"access_token" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==",
"type" : "Bearer",
"expires_in" : 1200
"expires_in" : 1200,
"refresh_token": "vLBPvmAB6KvwvJZr27cS"
}
--------------------------------------------------
// TESTRESPONSE[s/dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==/$body.access_token/]
// TESTRESPONSE[s/vLBPvmAB6KvwvJZr27cS/$body.refresh_token/]
The token returned by this API can be used by sending a request with a
`Authorization` header with a value having the prefix `Bearer ` followed
@ -88,6 +90,36 @@ by the value of the `access_token`.
curl -H "Authorization: Bearer dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==" http://localhost:9200/_cluster/health
--------------------------------------------------
[[security-api-refresh-token]]
To extend the life of an existing token, the token api may be called again with the refresh
token within 24 hours of the token's creation.
[source,js]
--------------------------------------------------
POST /_xpack/security/oauth2/token
{
"grant_type": "refresh_token",
"refresh_token": "vLBPvmAB6KvwvJZr27cS"
}
--------------------------------------------------
// CONSOLE
// TEST[s/vLBPvmAB6KvwvJZr27cS/$body.refresh_token/]
// TEST[continued]
The API will return a new token and refresh token. Each refresh token may only be used one time.
[source,js]
--------------------------------------------------
{
"access_token" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==",
"type" : "Bearer",
"expires_in" : 1200,
"refresh_token": "vLBPvmAB6KvwvJZr27cS"
}
--------------------------------------------------
// TESTRESPONSE[s/dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==/$body.access_token/]
// TESTRESPONSE[s/vLBPvmAB6KvwvJZr27cS/$body.refresh_token/]
[[security-api-invalidate-token]]
If a token must be invalidated immediately, you can do so by submitting a DELETE
request to `/_xpack/security/oauth2/token`. For example:

View File

@ -22,7 +22,7 @@ public final class CreateTokenAction extends Action<CreateTokenRequest, CreateTo
@Override
public CreateTokenRequestBuilder newRequestBuilder(ElasticsearchClient client) {
return new CreateTokenRequestBuilder(client);
return new CreateTokenRequestBuilder(client, INSTANCE);
}
@Override

View File

@ -5,10 +5,13 @@
*/
package org.elasticsearch.xpack.security.action.token;
import org.elasticsearch.Version;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.settings.SecureString;
@ -21,7 +24,8 @@ import static org.elasticsearch.action.ValidateActions.addValidationError;
/**
* Represents a request to create a token based on the provided information. This class accepts the
* fields for an OAuth 2.0 access token request that uses the <code>password</code> grant type.
* fields for an OAuth 2.0 access token request that uses the <code>password</code> grant type or the
* <code>refresh_token</code> grant type.
*/
public final class CreateTokenRequest extends ActionRequest {
@ -29,27 +33,47 @@ public final class CreateTokenRequest extends ActionRequest {
private String username;
private SecureString password;
private String scope;
private String refreshToken;
CreateTokenRequest() {}
public CreateTokenRequest(String grantType, String username, SecureString password, @Nullable String scope) {
public CreateTokenRequest(String grantType, @Nullable String username, @Nullable SecureString password, @Nullable String scope,
@Nullable String refreshToken) {
this.grantType = grantType;
this.username = username;
this.password = password;
this.scope = scope;
this.refreshToken = refreshToken;
}
@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = null;
if ("password".equals(grantType) == false) {
validationException = addValidationError("only [password] grant_type is supported", validationException);
}
if (Strings.isNullOrEmpty(username)) {
validationException = addValidationError("username is missing", validationException);
}
if (password == null || password.getChars() == null || password.getChars().length == 0) {
validationException = addValidationError("password is missing", validationException);
if ("password".equals(grantType)) {
if (Strings.isNullOrEmpty(username)) {
validationException = addValidationError("username is missing", validationException);
}
if (password == null || password.getChars() == null || password.getChars().length == 0) {
validationException = addValidationError("password is missing", validationException);
}
if (refreshToken != null) {
validationException =
addValidationError("refresh_token is not supported with the password grant_type", validationException);
}
} else if ("refresh_token".equals(grantType)) {
if (username != null) {
validationException =
addValidationError("username is not supported with the refresh_token grant_type", validationException);
}
if (password != null) {
validationException =
addValidationError("password is not supported with the refresh_token grant_type", validationException);
}
if (refreshToken == null) {
validationException = addValidationError("refresh_token is missing", validationException);
}
} else {
validationException = addValidationError("grant_type only supports the values: [password, refresh_token]", validationException);
}
return validationException;
@ -59,11 +83,11 @@ public final class CreateTokenRequest extends ActionRequest {
this.grantType = grantType;
}
public void setUsername(String username) {
public void setUsername(@Nullable String username) {
this.username = username;
}
public void setPassword(SecureString password) {
public void setPassword(@Nullable SecureString password) {
this.password = password;
}
@ -71,14 +95,20 @@ public final class CreateTokenRequest extends ActionRequest {
this.scope = scope;
}
public void setRefreshToken(@Nullable String refreshToken) {
this.refreshToken = refreshToken;
}
public String getGrantType() {
return grantType;
}
@Nullable
public String getUsername() {
return username;
}
@Nullable
public SecureString getPassword() {
return password;
}
@ -88,16 +118,40 @@ public final class CreateTokenRequest extends ActionRequest {
return scope;
}
@Nullable
public String getRefreshToken() {
return refreshToken;
}
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(grantType);
out.writeString(username);
final byte[] passwordBytes = CharArrays.toUtf8Bytes(password.getChars());
try {
out.writeByteArray(passwordBytes);
} finally {
Arrays.fill(passwordBytes, (byte) 0);
if (out.getVersion().onOrAfter(Version.V_6_2_0)) {
out.writeOptionalString(username);
if (password == null) {
out.writeOptionalBytesReference(null);
} else {
final byte[] passwordBytes = CharArrays.toUtf8Bytes(password.getChars());
try {
out.writeOptionalBytesReference(new BytesArray(passwordBytes));
} finally {
Arrays.fill(passwordBytes, (byte) 0);
}
}
out.writeOptionalString(refreshToken);
} else {
if ("refresh_token".equals(grantType)) {
throw new UnsupportedOperationException("a refresh request cannot be sent to an older version");
} else {
out.writeString(username);
final byte[] passwordBytes = CharArrays.toUtf8Bytes(password.getChars());
try {
out.writeByteArray(passwordBytes);
} finally {
Arrays.fill(passwordBytes, (byte) 0);
}
}
}
out.writeOptionalString(scope);
}
@ -106,12 +160,28 @@ public final class CreateTokenRequest extends ActionRequest {
public void readFrom(StreamInput in) throws IOException {
super.readFrom(in);
grantType = in.readString();
username = in.readString();
final byte[] passwordBytes = in.readByteArray();
try {
password = new SecureString(CharArrays.utf8BytesToChars(passwordBytes));
} finally {
Arrays.fill(passwordBytes, (byte) 0);
if (in.getVersion().onOrAfter(Version.V_6_2_0)) {
username = in.readOptionalString();
BytesReference bytesRef = in.readOptionalBytesReference();
if (bytesRef != null) {
byte[] bytes = BytesReference.toBytes(bytesRef);
try {
password = new SecureString(CharArrays.utf8BytesToChars(bytes));
} finally {
Arrays.fill(bytes, (byte) 0);
}
} else {
password = null;
}
refreshToken = in.readOptionalString();
} else {
username = in.readString();
final byte[] passwordBytes = in.readByteArray();
try {
password = new SecureString(CharArrays.utf8BytesToChars(passwordBytes));
} finally {
Arrays.fill(passwordBytes, (byte) 0);
}
}
scope = in.readOptionalString();
}

View File

@ -5,6 +5,7 @@
*/
package org.elasticsearch.xpack.security.action.token;
import org.elasticsearch.action.Action;
import org.elasticsearch.action.ActionRequestBuilder;
import org.elasticsearch.client.ElasticsearchClient;
import org.elasticsearch.common.Nullable;
@ -16,8 +17,9 @@ import org.elasticsearch.common.settings.SecureString;
public final class CreateTokenRequestBuilder
extends ActionRequestBuilder<CreateTokenRequest, CreateTokenResponse, CreateTokenRequestBuilder> {
public CreateTokenRequestBuilder(ElasticsearchClient client) {
super(client, CreateTokenAction.INSTANCE, new CreateTokenRequest());
public CreateTokenRequestBuilder(ElasticsearchClient client,
Action<CreateTokenRequest, CreateTokenResponse, CreateTokenRequestBuilder> action) {
super(client, action, new CreateTokenRequest());
}
/**
@ -31,7 +33,7 @@ public final class CreateTokenRequestBuilder
/**
* Set the username to be used for authentication with a password grant
*/
public CreateTokenRequestBuilder setUsername(String username) {
public CreateTokenRequestBuilder setUsername(@Nullable String username) {
request.setUsername(username);
return this;
}
@ -40,7 +42,7 @@ public final class CreateTokenRequestBuilder
* Set the password credentials associated with the user. These credentials will be used for
* authentication and the resulting token will be for this user
*/
public CreateTokenRequestBuilder setPassword(SecureString password) {
public CreateTokenRequestBuilder setPassword(@Nullable SecureString password) {
request.setPassword(password);
return this;
}
@ -54,4 +56,9 @@ public final class CreateTokenRequestBuilder
request.setScope(scope);
return this;
}
public CreateTokenRequestBuilder setRefreshToken(@Nullable String refreshToken) {
request.setRefreshToken(refreshToken);
return this;
}
}

View File

@ -5,6 +5,7 @@
*/
package org.elasticsearch.xpack.security.action.token;
import org.elasticsearch.Version;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
@ -25,13 +26,15 @@ public final class CreateTokenResponse extends ActionResponse implements ToXCont
private String tokenString;
private TimeValue expiresIn;
private String scope;
private String refreshToken;
CreateTokenResponse() {}
public CreateTokenResponse(String tokenString, TimeValue expiresIn, String scope) {
public CreateTokenResponse(String tokenString, TimeValue expiresIn, String scope, String refreshToken) {
this.tokenString = Objects.requireNonNull(tokenString);
this.expiresIn = Objects.requireNonNull(expiresIn);
this.scope = scope;
this.refreshToken = refreshToken;
}
public String getTokenString() {
@ -46,12 +49,19 @@ public final class CreateTokenResponse extends ActionResponse implements ToXCont
return expiresIn;
}
public String getRefreshToken() {
return refreshToken;
}
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(tokenString);
expiresIn.writeTo(out);
out.writeOptionalString(scope);
if (out.getVersion().onOrAfter(Version.V_6_2_0)) {
out.writeString(refreshToken);
}
}
@Override
@ -60,6 +70,9 @@ public final class CreateTokenResponse extends ActionResponse implements ToXCont
tokenString = in.readString();
expiresIn = new TimeValue(in);
scope = in.readOptionalString();
if (in.getVersion().onOrAfter(Version.V_6_2_0)) {
refreshToken = in.readString();
}
}
@Override
@ -68,6 +81,9 @@ public final class CreateTokenResponse extends ActionResponse implements ToXCont
.field("access_token", tokenString)
.field("type", "Bearer")
.field("expires_in", expiresIn.seconds());
if (refreshToken != null) {
builder.field("refresh_token", refreshToken);
}
// only show the scope if it is not null
if (scope != null) {
builder.field("scope", scope);

View File

@ -5,6 +5,7 @@
*/
package org.elasticsearch.xpack.security.action.token;
import org.elasticsearch.Version;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.Strings;
@ -20,15 +21,22 @@ import static org.elasticsearch.action.ValidateActions.addValidationError;
*/
public final class InvalidateTokenRequest extends ActionRequest {
public enum Type {
ACCESS_TOKEN,
REFRESH_TOKEN
}
private String tokenString;
private Type tokenType;
InvalidateTokenRequest() {}
/**
* @param tokenString the string representation of the token
*/
public InvalidateTokenRequest(String tokenString) {
public InvalidateTokenRequest(String tokenString, Type type) {
this.tokenString = tokenString;
this.tokenType = type;
}
@Override
@ -37,6 +45,9 @@ public final class InvalidateTokenRequest extends ActionRequest {
if (Strings.isNullOrEmpty(tokenString)) {
validationException = addValidationError("token string must be provided", null);
}
if (tokenType == null) {
validationException = addValidationError("token type must be provided", validationException);
}
return validationException;
}
@ -48,15 +59,34 @@ public final class InvalidateTokenRequest extends ActionRequest {
this.tokenString = token;
}
Type getTokenType() {
return tokenType;
}
void setTokenType(Type tokenType) {
this.tokenType = tokenType;
}
@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeString(tokenString);
if (out.getVersion().onOrAfter(Version.V_6_2_0)) {
out.writeVInt(tokenType.ordinal());
} else if (tokenType == Type.REFRESH_TOKEN) {
throw new UnsupportedOperationException("refresh token invalidation cannot be serialized with version [" + out.getVersion() +
"]");
}
}
@Override
public void readFrom(StreamInput in) throws IOException {
super.readFrom(in);
tokenString = in.readString();
if (in.getVersion().onOrAfter(Version.V_6_2_0)) {
tokenType = Type.values()[in.readVInt()];
} else {
tokenType = Type.ACCESS_TOKEN;
}
}
}

View File

@ -26,4 +26,12 @@ public final class InvalidateTokenRequestBuilder
request.setTokenString(token);
return this;
}
/**
* Sets the type of the token that should be invalidated
*/
public InvalidateTokenRequestBuilder setType(InvalidateTokenRequest.Type type) {
request.setTokenType(type);
return this;
}
}

View File

@ -0,0 +1,29 @@
/*
* 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.action.Action;
import org.elasticsearch.client.ElasticsearchClient;
public final class RefreshTokenAction extends Action<CreateTokenRequest, CreateTokenResponse, CreateTokenRequestBuilder> {
public static final String NAME = "cluster:admin/xpack/security/token/refresh";
public static final RefreshTokenAction INSTANCE = new RefreshTokenAction();
private RefreshTokenAction() {
super(NAME);
}
@Override
public CreateTokenRequestBuilder newRequestBuilder(ElasticsearchClient client) {
return new CreateTokenRequestBuilder(client, INSTANCE);
}
@Override
public CreateTokenResponse newResponse() {
return new CreateTokenResponse();
}
}

View File

@ -9,12 +9,9 @@ import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.xpack.security.support.Validation;
import java.io.IOException;
import static org.elasticsearch.action.ValidateActions.addValidationError;
public class AuthenticateRequest extends ActionRequest implements UserRequest {
private String username;

View File

@ -106,8 +106,10 @@ import org.elasticsearch.xpack.security.action.rolemapping.TransportGetRoleMappi
import org.elasticsearch.xpack.security.action.rolemapping.TransportPutRoleMappingAction;
import org.elasticsearch.xpack.security.action.token.CreateTokenAction;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenAction;
import org.elasticsearch.xpack.security.action.token.RefreshTokenAction;
import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction;
import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction;
import org.elasticsearch.xpack.security.action.token.TransportRefreshTokenAction;
import org.elasticsearch.xpack.security.action.user.AuthenticateAction;
import org.elasticsearch.xpack.security.action.user.ChangePasswordAction;
import org.elasticsearch.xpack.security.action.user.DeleteUserAction;
@ -609,7 +611,8 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin, Clus
new ActionHandler<>(DeleteRoleMappingAction.INSTANCE, TransportDeleteRoleMappingAction.class),
new ActionHandler<>(CreateTokenAction.INSTANCE, TransportCreateTokenAction.class),
new ActionHandler<>(InvalidateTokenAction.INSTANCE, TransportInvalidateTokenAction.class),
new ActionHandler<>(GetCertificateInfoAction.INSTANCE, TransportGetCertificateInfoAction.class)
new ActionHandler<>(GetCertificateInfoAction.INSTANCE, TransportGetCertificateInfoAction.class),
new ActionHandler<>(RefreshTokenAction.INSTANCE, TransportRefreshTokenAction.class)
);
}

View File

@ -10,14 +10,15 @@ import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.action.support.HandledTransportAction;
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.xpack.security.authc.Authentication;
import org.elasticsearch.xpack.security.authc.AuthenticationService;
import org.elasticsearch.xpack.security.authc.TokenService;
import org.elasticsearch.xpack.security.authc.UserToken;
import java.util.Collections;
/**
* Transport action responsible for creating a token based on a request. Requests provide user
@ -43,27 +44,24 @@ public final class TransportCreateTokenAction extends HandledTransportAction<Cre
@Override
protected void doExecute(CreateTokenRequest request, ActionListener<CreateTokenResponse> listener) {
Authentication originatingAuthentication = Authentication.getAuthentication(threadPool.getThreadContext());
try (ThreadContext.StoredContext ignore = threadPool.getThreadContext().stashContext()) {
authenticationService.authenticate(CreateTokenAction.NAME, request,
request.getUsername(), request.getPassword(),
ActionListener.wrap(authentication -> {
try (SecureString ignore1 = request.getPassword()) {
final UserToken token = tokenService.createUserToken(authentication);
final String tokenStr = tokenService.getUserTokenString(token);
final String scope;
// the OAuth2.0 RFC requires the scope to be provided in the
// response if it differs from the user provided scope. If the
// scope was not provided then it does not need to be returned.
// if the scope is not supported, the value of the scope that the
// token is for must be returned
if (request.getScope() != null) {
scope = DEFAULT_SCOPE; // this is the only non-null value that is currently supported
} else {
scope = null;
}
request.getPassword().close();
tokenService.createUserToken(authentication, originatingAuthentication, ActionListener.wrap(tuple -> {
final String tokenStr = tokenService.getUserTokenString(tuple.v1());
final String scope = getResponseScopeValue(request.getScope());
listener.onResponse(new CreateTokenResponse(tokenStr, tokenService.getExpirationDelay(), scope));
}
final CreateTokenResponse response =
new CreateTokenResponse(tokenStr, tokenService.getExpirationDelay(), scope, tuple.v2());
listener.onResponse(response);
}, e -> {
// clear the request password
request.getPassword().close();
listener.onFailure(e);
}), Collections.emptyMap());
}, e -> {
// clear the request password
request.getPassword().close();
@ -71,4 +69,19 @@ public final class TransportCreateTokenAction extends HandledTransportAction<Cre
}));
}
}
static String getResponseScopeValue(String requestScope) {
final String scope;
// the OAuth2.0 RFC requires the scope to be provided in the
// response if it differs from the user provided scope. If the
// scope was not provided then it does not need to be returned.
// if the scope is not supported, the value of the scope that the
// token is for must be returned
if (requestScope != null) {
scope = DEFAULT_SCOPE; // this is the only non-null value that is currently supported
} else {
scope = null;
}
return scope;
}
}

View File

@ -32,10 +32,14 @@ public final class TransportInvalidateTokenAction extends HandledTransportAction
}
@Override
protected void doExecute(InvalidateTokenRequest request,
ActionListener<InvalidateTokenResponse> listener) {
tokenService.invalidateToken(request.getTokenString(), ActionListener.wrap(
created -> listener.onResponse(new InvalidateTokenResponse(created)),
listener::onFailure));
protected void doExecute(InvalidateTokenRequest request, ActionListener<InvalidateTokenResponse> listener) {
final ActionListener<Boolean> invalidateListener =
ActionListener.wrap(created -> listener.onResponse(new InvalidateTokenResponse(created)), listener::onFailure);
if (request.getTokenType() == InvalidateTokenRequest.Type.ACCESS_TOKEN) {
tokenService.invalidateAccessToken(request.getTokenString(), invalidateListener);
} else {
assert request.getTokenType() == InvalidateTokenRequest.Type.REFRESH_TOKEN;
tokenService.invalidateRefreshToken(request.getTokenString(), invalidateListener);
}
}
}

View File

@ -0,0 +1,44 @@
/*
* 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.action.ActionListener;
import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.action.support.HandledTransportAction;
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.xpack.security.authc.TokenService;
import static org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction.getResponseScopeValue;
public class TransportRefreshTokenAction extends HandledTransportAction<CreateTokenRequest, CreateTokenResponse> {
private final TokenService tokenService;
@Inject
public TransportRefreshTokenAction(Settings settings, ThreadPool threadPool, TransportService transportService,
ActionFilters actionFilters, IndexNameExpressionResolver indexNameExpressionResolver,
TokenService tokenService) {
super(settings, RefreshTokenAction.NAME, threadPool, transportService, actionFilters, indexNameExpressionResolver,
CreateTokenRequest::new);
this.tokenService = tokenService;
}
@Override
protected void doExecute(CreateTokenRequest request, ActionListener<CreateTokenResponse> listener) {
tokenService.refreshToken(request.getRefreshToken(), ActionListener.wrap(tuple -> {
final String tokenStr = tokenService.getUserTokenString(tuple.v1());
final String scope = getResponseScopeValue(request.getScope());
final CreateTokenResponse response =
new CreateTokenResponse(tokenStr, tokenService.getExpirationDelay(), scope, tuple.v2());
listener.onResponse(response);
}, listener::onFailure));
}
}

View File

@ -25,6 +25,7 @@ import org.elasticsearch.threadpool.ThreadPool.Names;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.elasticsearch.action.support.TransportActions.isShardNotAvailableException;
@ -50,25 +51,23 @@ final class ExpiredTokenRemover extends AbstractRunnable {
@Override
public void doRun() {
SearchRequest searchRequest = new SearchRequest(SecurityLifecycleService.SECURITY_INDEX_NAME);
DeleteByQueryRequest dbq = new DeleteByQueryRequest(searchRequest);
DeleteByQueryRequest expiredDbq = new DeleteByQueryRequest(searchRequest);
if (timeout != TimeValue.MINUS_ONE) {
dbq.setTimeout(timeout);
expiredDbq.setTimeout(timeout);
searchRequest.source().timeout(timeout);
}
final Instant now = Instant.now();
searchRequest.source()
.query(QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("doc_type", TokenService.DOC_TYPE))
.filter(QueryBuilders.rangeQuery("expiration_time").lte(Instant.now().toEpochMilli())));
executeAsyncWithOrigin(client, SECURITY_ORIGIN, DeleteByQueryAction.INSTANCE, dbq,
.filter(QueryBuilders.termsQuery("doc_type", TokenService.INVALIDATED_TOKEN_DOC_TYPE, "token"))
.filter(QueryBuilders.boolQuery()
.should(QueryBuilders.rangeQuery("expiration_time").lte(now.toEpochMilli()))
.should(QueryBuilders.rangeQuery("creation_time").lte(now.minus(24L, ChronoUnit.HOURS).toEpochMilli()))));
executeAsyncWithOrigin(client, SECURITY_ORIGIN, DeleteByQueryAction.INSTANCE, expiredDbq,
ActionListener.wrap(r -> {
debugDbqResponse(r);
markComplete();
}, e -> {
if (isShardNotAvailableException(e) == false) {
logger.error("failed to delete expired tokens", e);
}
markComplete();
}));
}, this::onFailure));
}
void submit(ThreadPool threadPool) {
@ -98,7 +97,11 @@ final class ExpiredTokenRemover extends AbstractRunnable {
@Override
public void onFailure(Exception e) {
logger.error("failed to delete expired tokens", e);
if (isShardNotAvailableException(e)) {
logger.debug("failed to delete expired tokens", e);
} else {
logger.error("failed to delete expired tokens", e);
}
markComplete();
}

View File

@ -12,15 +12,24 @@ import org.apache.lucene.util.IOUtils;
import org.apache.lucene.util.StringHelper;
import org.apache.lucene.util.UnicodeUtil;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.ExceptionsHelper;
import org.elasticsearch.Version;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.DocWriteRequest.OpType;
import org.elasticsearch.action.DocWriteResponse.Result;
import org.elasticsearch.action.get.GetRequest;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.get.MultiGetItemResponse;
import org.elasticsearch.action.get.MultiGetRequest;
import org.elasticsearch.action.get.MultiGetResponse;
import org.elasticsearch.action.index.IndexAction;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.support.TransportActions;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
import org.elasticsearch.action.support.master.AcknowledgedRequest;
import org.elasticsearch.action.update.UpdateRequest;
import org.elasticsearch.action.update.UpdateResponse;
import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.AckedClusterStateUpdateTask;
import org.elasticsearch.cluster.ClusterState;
@ -28,9 +37,12 @@ import org.elasticsearch.cluster.ack.AckedRequest;
import org.elasticsearch.cluster.ack.ClusterStateUpdateResponse;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.UUIDs;
import org.elasticsearch.common.cache.Cache;
import org.elasticsearch.common.cache.CacheBuilder;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.component.AbstractComponent;
import org.elasticsearch.common.hash.MessageDigests;
import org.elasticsearch.common.io.stream.InputStreamStreamInput;
import org.elasticsearch.common.io.stream.OutputStreamStreamOutput;
import org.elasticsearch.common.io.stream.StreamInput;
@ -44,7 +56,11 @@ import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.util.concurrent.AbstractRunnable;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.util.iterable.Iterables;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.index.engine.DocumentMissingException;
import org.elasticsearch.index.engine.VersionConflictEngineException;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.xpack.XPackSettings;
import org.elasticsearch.xpack.XpackField;
@ -74,6 +90,7 @@ import java.security.spec.InvalidKeySpecException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
@ -82,9 +99,12 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.elasticsearch.action.support.TransportActions.isShardNotAvailableException;
import static org.elasticsearch.gateway.GatewayService.STATE_NOT_RECOVERED_BLOCK;
import static org.elasticsearch.xpack.ClientHelper.SECURITY_ORIGIN;
import static org.elasticsearch.xpack.ClientHelper.executeAsyncWithOrigin;
@ -123,9 +143,9 @@ public final class TokenService extends AbstractComponent {
public static final Setting<TimeValue> DELETE_TIMEOUT = Setting.timeSetting("xpack.security.authc.token.delete.timeout",
TimeValue.MINUS_ONE, Property.NodeScope);
static final String DOC_TYPE = "invalidated-token";
static final String INVALIDATED_TOKEN_DOC_TYPE = "invalidated-token";
static final int MINIMUM_BYTES = VERSION_BYTES + SALT_BYTES + IV_BYTES + 1;
static final int MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * MINIMUM_BYTES) / 3)).intValue();
private static final int MINIMUM_BASE64_BYTES = Double.valueOf(Math.ceil((4 * MINIMUM_BYTES) / 3)).intValue();
private final SecureRandom secureRandom = new SecureRandom();
private final ClusterService clusterService;
@ -136,11 +156,9 @@ public final class TokenService extends AbstractComponent {
private final SecurityLifecycleService lifecycleService;
private final ExpiredTokenRemover expiredTokenRemover;
private final boolean enabled;
private final byte[] currentVersionBytes;
private volatile TokenKeys keyCache;
private volatile long lastExpirationRunMs;
private final AtomicLong createdTimeStamps = new AtomicLong(-1);
private static final Version TOKEN_SERVICE_VERSION = Version.CURRENT;
/**
* Creates a new token service
@ -163,9 +181,8 @@ public final class TokenService extends AbstractComponent {
this.deleteInterval = DELETE_INTERVAL.get(settings);
this.enabled = XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.get(settings);
this.expiredTokenRemover = new ExpiredTokenRemover(settings, client);
this.currentVersionBytes = ByteBuffer.allocate(4).putInt(TOKEN_SERVICE_VERSION.id).array();
ensureEncryptionCiphersSupported();
KeyAndCache keyAndCache = new KeyAndCache(new KeyAndTimestamp(tokenPassphrase.clone(), createdTimeStamps.incrementAndGet()),
KeyAndCache keyAndCache = new KeyAndCache(new KeyAndTimestamp(tokenPassphrase, createdTimeStamps.incrementAndGet()),
new BytesKey(saltArr));
keyCache = new TokenKeys(Collections.singletonMap(keyAndCache.getKeyHash(), keyAndCache), keyAndCache.getKeyHash());
this.clusterService = clusterService;
@ -175,17 +192,62 @@ public final class TokenService extends AbstractComponent {
/**
* Create a token based on the provided authentication
* Create a token based on the provided authentication and metadata.
* The created token will be stored in the security index.
*/
public UserToken createUserToken(Authentication authentication)
throws IOException, GeneralSecurityException {
public void createUserToken(Authentication authentication, Authentication originatingClientAuth,
ActionListener<Tuple<UserToken, String>> listener, Map<String, Object> metadata) throws IOException {
ensureEnabled();
final Instant expiration = getExpirationTime();
return new UserToken(authentication, expiration);
if (authentication == null) {
listener.onFailure(new IllegalArgumentException("authentication must be provided"));
} else {
final Instant created = clock.instant();
final Instant expiration = getExpirationTime(created);
final Version version = clusterService.state().nodes().getMinNodeVersion();
final Authentication matchingVersionAuth = version.equals(authentication.getVersion()) ? authentication :
new Authentication(authentication.getUser(), authentication.getAuthenticatedBy(), authentication.getLookedUpBy(),
version);
final UserToken userToken = new UserToken(version, matchingVersionAuth, expiration, metadata);
final String refreshToken = UUIDs.randomBase64UUID();
try (XContentBuilder builder = XContentFactory.jsonBuilder()) {
builder.startObject();
builder.field("doc_type", "token");
builder.field("creation_time", created.toEpochMilli());
builder.startObject("refresh_token")
.field("token", refreshToken)
.field("invalidated", false)
.field("refreshed", false)
.startObject("client")
.field("type", "unassociated_client")
.field("user", originatingClientAuth.getUser().principal())
.field("realm", originatingClientAuth.getAuthenticatedBy().getName())
.endObject()
.endObject();
builder.startObject("access_token")
.field("invalidated", false)
.field("user_token", userToken)
.endObject();
builder.endObject();
IndexRequest request =
client.prepareIndex(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, getTokenDocumentId(userToken))
.setOpType(OpType.CREATE)
.setSource(builder)
.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL)
.request();
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
executeAsyncWithOrigin(client, SECURITY_ORIGIN, IndexAction.INSTANCE, request,
ActionListener.wrap(indexResponse -> listener.onResponse(new Tuple<>(userToken, refreshToken)),
listener::onFailure))
);
}
}
}
/**
* Looks in the context to see if the request provided a header with a user token
* Looks in the context to see if the request provided a header with a user token and if so the
* token is validated, which includes authenticated decryption and verification that the token
* has not been revoked or is expired.
*/
void getAndValidateToken(ThreadContext ctx, ActionListener<UserToken> listener) {
if (enabled) {
@ -218,6 +280,13 @@ public final class TokenService extends AbstractComponent {
}
}
/**
* Asynchronously decodes the string representation of a {@link UserToken}. The process for
* this is asynchronous as we may need to compute a key, which can be computationally 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.
*/
void decodeToken(String token, ActionListener<UserToken> listener) throws IOException {
// We intentionally do not use try-with resources since we need to keep the stream open if we need to compute a key!
byte[] bytes = token.getBytes(StandardCharsets.UTF_8);
@ -228,36 +297,49 @@ public final class TokenService extends AbstractComponent {
} else {
// the token exists and the value is at least as long as we'd expect
final Version version = Version.readVersion(in);
in.setVersion(version);
final BytesKey decodedSalt = new BytesKey(in.readByteArray());
final BytesKey passphraseHash = new BytesKey(in.readByteArray());
KeyAndCache keyAndCache = keyCache.get(passphraseHash);
if (keyAndCache != null) {
final SecretKey decodeKey = keyAndCache.getKey(decodedSalt);
final byte[] iv = in.readByteArray();
if (decodeKey != null) {
getKeyAsync(decodedSalt, keyAndCache, ActionListener.wrap(decodeKey -> {
try {
final byte[] iv = in.readByteArray();
decryptToken(in, getDecryptionCipher(iv, decodeKey, version, decodedSalt), version, listener);
} catch (GeneralSecurityException e) {
// could happen with a token that is not ours
logger.warn("invalid token", e);
listener.onResponse(null);
} finally {
in.close();
}
} else {
/* As a measure of protected against DOS, we can pass requests requiring a key
* computation off to a single thread executor. For normal usage, the initial
* request(s) that require a key computation will be delayed and there will be
* some additional latency.
*/
client.threadPool().executor(THREAD_POOL_NAME)
.submit(new KeyComputingRunnable(in, iv, version, decodedSalt, listener, keyAndCache));
}
}, e -> {
IOUtils.closeWhileHandlingException(in);
listener.onFailure(e);
}));
} else {
IOUtils.closeWhileHandlingException(in);
logger.debug("invalid key {} key: {}", passphraseHash, keyCache.cache.keySet());
listener.onResponse(null);
}
}
}
private void getKeyAsync(BytesKey decodedSalt, KeyAndCache keyAndCache, ActionListener<SecretKey> listener) {
final SecretKey decodeKey = keyAndCache.getKey(decodedSalt);
if (decodeKey != null) {
listener.onResponse(decodeKey);
} else {
/* As a measure of protected against DOS, we can pass requests requiring a key
* computation off to a single thread executor. For normal usage, the initial
* request(s) that require a key computation will be delayed and there will be
* some additional latency.
*/
client.threadPool().executor(THREAD_POOL_NAME)
.submit(new KeyComputingRunnable(decodedSalt, listener, keyAndCache));
}
}
private static void decryptToken(StreamInput in, Cipher cipher, Version version, ActionListener<UserToken> listener) throws
IOException {
try (CipherInputStream cis = new CipherInputStream(in, cipher); StreamInput decryptedInput = new InputStreamStreamInput(cis)) {
@ -267,9 +349,14 @@ public final class TokenService extends AbstractComponent {
}
/**
* This method records an entry to indicate that a token with a given id has been expired.
* This method performs the steps necessary to invalidate a token so that it may no longer be
* used. The process of invalidation involves a step that is needed for backwards compatibility
* with versions prior to 6.2.0; this step records an entry to indicate that a token with a
* given id has been expired. The second step is to record the invalidation for tokens that
* have been created on versions on or after 6.2; this step involves performing an update to
* the token document and setting the <code>invalidated</code> field to <code>true</code>
*/
public void invalidateToken(String tokenString, ActionListener<Boolean> listener) {
public void invalidateAccessToken(String tokenString, ActionListener<Boolean> listener) {
ensureEnabled();
if (Strings.isNullOrEmpty(tokenString)) {
listener.onFailure(new IllegalArgumentException("token must be provided"));
@ -279,34 +366,9 @@ public final class TokenService extends AbstractComponent {
decodeToken(tokenString, ActionListener.wrap(userToken -> {
if (userToken == null) {
listener.onFailure(malformedTokenException());
} else if (userToken.getExpirationTime().isBefore(clock.instant())) {
// no need to invalidate - it's already expired
listener.onResponse(false);
} else {
final String id = getDocumentId(userToken);
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () -> {
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN,
client.prepareIndex(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, id)
.setOpType(OpType.CREATE)
.setSource("doc_type", DOC_TYPE, "expiration_time", getExpirationTime().toEpochMilli())
.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL).request(),
new ActionListener<IndexResponse>() {
@Override
public void onResponse(IndexResponse indexResponse) {
listener.onResponse(indexResponse.getResult() == Result.CREATED);
}
@Override
public void onFailure(Exception e) {
if (e instanceof VersionConflictEngineException) {
// doc already exists
listener.onResponse(false);
} else {
listener.onFailure(e);
}
}
}, client::index);
});
final long expirationEpochMilli = getExpirationTime().toEpochMilli();
indexBwcInvalidation(userToken, listener, new AtomicInteger(0), expirationEpochMilli);
}
}, listener::onFailure));
} catch (IOException e) {
@ -316,8 +378,344 @@ public final class TokenService extends AbstractComponent {
}
}
private static String getDocumentId(UserToken userToken) {
return DOC_TYPE + "_" + userToken.getId();
public void invalidateRefreshToken(String refreshToken, ActionListener<Boolean> listener) {
ensureEnabled();
if (Strings.isNullOrEmpty(refreshToken)) {
listener.onFailure(new IllegalArgumentException("refresh token must be provided"));
} else {
maybeStartTokenRemover();
findTokenFromRefreshToken(refreshToken,
ActionListener.wrap(tuple -> {
final String docId = tuple.v1().getHits().getAt(0).getId();
final long docVersion = tuple.v1().getHits().getAt(0).getVersion();
indexInvalidation(docId, Version.CURRENT, listener, tuple.v2(), "refresh_token", docVersion);
}, listener::onFailure), new AtomicInteger(0));
}
}
/**
* Performs the actual bwc invalidation of a token and then kicks off the new invalidation method
* @param userToken the token to invalidate
* @param listener the listener to notify upon completion
* @param attemptCount the number of attempts to invalidate that have already been tried
* @param expirationEpochMilli the expiration time as milliseconds since the epoch
*/
private void indexBwcInvalidation(UserToken userToken, ActionListener<Boolean> listener, AtomicInteger attemptCount,
long expirationEpochMilli) {
if (attemptCount.get() > 5) {
listener.onFailure(invalidGrantException("failed to invalidate token"));
} else {
final String invalidatedTokenId = getInvalidatedTokenDocumentId(userToken);
IndexRequest indexRequest = client.prepareIndex(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, invalidatedTokenId)
.setOpType(OpType.CREATE)
.setSource("doc_type", INVALIDATED_TOKEN_DOC_TYPE, "expiration_time", expirationEpochMilli)
.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL)
.request();
final String tokenDocId = getTokenDocumentId(userToken);
final Version version = userToken.getVersion();
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, indexRequest,
ActionListener.<IndexResponse>wrap(indexResponse -> {
ActionListener<Boolean> wrappedListener =
ActionListener.wrap(ignore -> listener.onResponse(true), listener::onFailure);
indexInvalidation(tokenDocId, version, wrappedListener, attemptCount, "access_token", 1L);
}, e -> {
Throwable cause = ExceptionsHelper.unwrapCause(e);
if (cause instanceof VersionConflictEngineException) {
// expected since something else could have invalidated
ActionListener<Boolean> wrappedListener =
ActionListener.wrap(ignore -> listener.onResponse(false), listener::onFailure);
indexInvalidation(tokenDocId, version, wrappedListener, attemptCount, "access_token", 1L);
} else if (isShardNotAvailableException(e)) {
attemptCount.incrementAndGet();
indexBwcInvalidation(userToken, listener, attemptCount, expirationEpochMilli);
} else {
listener.onFailure(e);
}
}), client::index));
}
}
/**
* Performs the actual invalidation of a token
* @param tokenDocId the id of the token doc to invalidate
* @param listener the listener to notify upon completion
* @param attemptCount the number of attempts to invalidate that have already been tried
* @param srcPrefix the prefix to use when constructing the doc to update
* @param documentVersion the expected version of the document we will update
*/
private void indexInvalidation(String tokenDocId, Version version, ActionListener<Boolean> listener, AtomicInteger attemptCount,
String srcPrefix, long documentVersion) {
if (attemptCount.get() > 5) {
listener.onFailure(invalidGrantException("failed to invalidate token"));
} else {
UpdateRequest request = client.prepareUpdate(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, tokenDocId)
.setDoc(srcPrefix, Collections.singletonMap("invalidated", true))
.setVersion(documentVersion)
.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL)
.request();
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, request,
ActionListener.<UpdateResponse>wrap(updateResponse -> {
if (updateResponse.getGetResult() != null
&& updateResponse.getGetResult().sourceAsMap().containsKey(srcPrefix)
&& ((Map<String, Object>) updateResponse.getGetResult().sourceAsMap().get(srcPrefix))
.containsKey("invalidated")) {
final boolean prevInvalidated = (boolean)
((Map<String, Object>) updateResponse.getGetResult().sourceAsMap().get(srcPrefix))
.get("invalidated");
listener.onResponse(prevInvalidated == false);
} else {
listener.onResponse(true);
}
}, e -> {
Throwable cause = ExceptionsHelper.unwrapCause(e);
if (cause instanceof DocumentMissingException) {
if (version.onOrAfter(Version.V_6_2_0)) {
// the document should always be there!
listener.onFailure(e);
} else {
listener.onResponse(false);
}
} else if (cause instanceof VersionConflictEngineException
|| isShardNotAvailableException(cause)) {
attemptCount.incrementAndGet();
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN,
client.prepareGet(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, tokenDocId).request(),
ActionListener.<GetResponse>wrap(getResult -> {
if (getResult.isExists()) {
Map<String, Object> source = getResult.getSource();
Map<String, Object> accessTokenSource =
(Map<String, Object>) source.get("access_token");
if (accessTokenSource == null) {
listener.onFailure(new IllegalArgumentException("token document is " +
"missing access_token field"));
} else {
Boolean invalidated = (Boolean) accessTokenSource.get("invalidated");
if (invalidated == null) {
listener.onFailure(new IllegalStateException(
"token document missing invalidated value"));
} else if (invalidated) {
listener.onResponse(false);
} else {
indexInvalidation(tokenDocId, version, listener, attemptCount, srcPrefix,
getResult.getVersion());
}
}
} else if (version.onOrAfter(Version.V_6_2_0)) {
logger.warn("could not find token document [{}] but there should " +
"be one as token has version [{}]", tokenDocId, version);
listener.onFailure(invalidGrantException("could not invalidate the token"));
} else {
listener.onResponse(false);
}
},
e1 -> {
if (isShardNotAvailableException(e1)) {
// don't increment count; call again
indexInvalidation(tokenDocId, version, listener, attemptCount, srcPrefix,
documentVersion);
} else {
listener.onFailure(e1);
}
}), client::get);
} else {
listener.onFailure(e);
}
}), client::update));
}
}
/**
* Uses the refresh token to refresh its associated token and returns the new token with an
* updated expiration date to the listener
*/
public void refreshToken(String refreshToken, ActionListener<Tuple<UserToken, String>> listener) {
ensureEnabled();
findTokenFromRefreshToken(refreshToken,
ActionListener.wrap(tuple -> {
final Authentication userAuth = Authentication.readFromContext(client.threadPool().getThreadContext());
final String tokenDocId = tuple.v1().getHits().getHits()[0].getId();
innerRefresh(tokenDocId, userAuth, listener, tuple.v2());
}, listener::onFailure),
new AtomicInteger(0));
}
private void findTokenFromRefreshToken(String refreshToken, ActionListener<Tuple<SearchResponse, AtomicInteger>> listener,
AtomicInteger attemptCount) {
if (attemptCount.get() > 5) {
listener.onFailure(invalidGrantException("could not refresh the requested token"));
} else {
SearchRequest request = client.prepareSearch(SecurityLifecycleService.SECURITY_INDEX_NAME)
.setQuery(QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("doc_type", "token"))
.filter(QueryBuilders.termQuery("refresh_token.token", refreshToken)))
.setVersion(true)
.request();
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, request,
ActionListener.<SearchResponse>wrap(searchResponse -> {
if (searchResponse.isTimedOut()) {
attemptCount.incrementAndGet();
findTokenFromRefreshToken(refreshToken, listener, attemptCount);
} else if (searchResponse.getHits().getHits().length < 1) {
logger.info("could not find token document with refresh_token [{}]", refreshToken);
listener.onFailure(invalidGrantException("could not refresh the requested token"));
} else if (searchResponse.getHits().getHits().length > 1) {
listener.onFailure(new IllegalStateException("multiple tokens share the same refresh token"));
} else {
listener.onResponse(new Tuple<>(searchResponse, attemptCount));
}
}, e -> {
if (isShardNotAvailableException(e)) {
logger.debug("failed to search for token document, retrying", e);
attemptCount.incrementAndGet();
findTokenFromRefreshToken(refreshToken, listener, attemptCount);
} else {
listener.onFailure(e);
}
}),
client::search));
}
}
/**
* Performs the actual refresh of the token with retries in case of certain exceptions that
* may be recoverable. The refresh involves retrieval of the token document and then
* updating the token document to indicate that the document has been refreshed.
*/
private void innerRefresh(String tokenDocId, Authentication userAuth, ActionListener<Tuple<UserToken, String>> listener,
AtomicInteger attemptCount) {
if (attemptCount.getAndIncrement() > 5) {
listener.onFailure(invalidGrantException("could not refresh the requested token"));
} else {
GetRequest getRequest = client.prepareGet(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, tokenDocId).request();
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, getRequest,
ActionListener.<GetResponse>wrap(response -> {
if (response.isExists()) {
final Map<String, Object> source = response.getSource();
final Optional<ElasticsearchSecurityException> invalidSource = checkTokenDocForRefresh(source, userAuth);
if (invalidSource.isPresent()) {
listener.onFailure(invalidSource.get());
} else {
final Map<String, Object> userTokenSource = (Map<String, Object>)
((Map<String, Object>) source.get("access_token")).get("user_token");
final String authString = (String) userTokenSource.get("authentication");
final Integer version = (Integer) userTokenSource.get("version");
final Map<String, Object> metadata = (Map<String, Object>) userTokenSource.get("metadata");
Version authVersion = Version.fromId(version);
try (StreamInput in = StreamInput.wrap(Base64.getDecoder().decode(authString))) {
in.setVersion(authVersion);
Authentication authentication = new Authentication(in);
UpdateRequest updateRequest =
client.prepareUpdate(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, tokenDocId)
.setVersion(response.getVersion())
.setDoc("refresh_token", Collections.singletonMap("refreshed", true))
.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL)
.request();
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, updateRequest,
ActionListener.<UpdateResponse>wrap(
updateResponse -> createUserToken(authentication, userAuth, listener, metadata),
e -> {
Throwable cause = ExceptionsHelper.unwrapCause(e);
if (cause instanceof VersionConflictEngineException ||
isShardNotAvailableException(e)) {
innerRefresh(tokenDocId, userAuth,
listener, attemptCount);
} else {
listener.onFailure(e);
}
}),
client::update);
}
}
} else {
logger.info("could not find token document [{}] for refresh", tokenDocId);
listener.onFailure(invalidGrantException("could not refresh the requested token"));
}
}, e -> {
if (isShardNotAvailableException(e)) {
innerRefresh(tokenDocId, userAuth, listener, attemptCount);
} else {
listener.onFailure(e);
}
}), client::get);
}
}
/**
* Performs checks on the retrieved source and returns an {@link Optional} with the exception
* if there is an issue
*/
private Optional<ElasticsearchSecurityException> checkTokenDocForRefresh(Map<String, Object> source, Authentication userAuth) {
final Map<String, Object> refreshTokenSrc = (Map<String, Object>) source.get("refresh_token");
final Map<String, Object> accessTokenSrc = (Map<String, Object>) source.get("access_token");
if (refreshTokenSrc == null || refreshTokenSrc.isEmpty()) {
return Optional.of(invalidGrantException("token document is missing the refresh_token object"));
} else if (accessTokenSrc == null || accessTokenSrc.isEmpty()) {
return Optional.of(invalidGrantException("token document is missing the access_token object"));
} else {
final Boolean refreshed = (Boolean) refreshTokenSrc.get("refreshed");
final Boolean invalidated = (Boolean) refreshTokenSrc.get("invalidated");
final Long creationEpochMilli = (Long) source.get("creation_time");
final Instant creationTime = creationEpochMilli == null ? null : Instant.ofEpochMilli(creationEpochMilli);
final Map<String, Object> userTokenSrc = (Map<String, Object>) accessTokenSrc.get("user_token");
if (refreshed == null) {
return Optional.of(invalidGrantException("token document is missing refreshed value"));
} else if (invalidated == null) {
return Optional.of(invalidGrantException("token document is missing invalidated value"));
} else if (creationEpochMilli == null) {
return Optional.of(invalidGrantException("token document is missing creation time value"));
} else if (refreshed) {
return Optional.of(invalidGrantException("token has already been refreshed"));
} else if (invalidated) {
return Optional.of(invalidGrantException("token has been invalidated"));
} else if (clock.instant().isAfter(creationTime.plus(24L, ChronoUnit.HOURS))) {
return Optional.of(invalidGrantException("refresh token is expired"));
} else if (userTokenSrc == null || userTokenSrc.isEmpty()) {
return Optional.of(invalidGrantException("token document is missing the user token info"));
} else if (userTokenSrc.get("authentication") == null) {
return Optional.of(invalidGrantException("token is missing authentication info"));
} else if (userTokenSrc.get("version") == null) {
return Optional.of(invalidGrantException("token is missing version value"));
} else if (userTokenSrc.get("metadata") == null) {
return Optional.of(invalidGrantException("token is missing metadata"));
} else {
return checkClient(refreshTokenSrc, userAuth);
}
}
}
private Optional<ElasticsearchSecurityException> checkClient(Map<String, Object> refreshTokenSource, Authentication userAuth) {
Map<String, Object> clientInfo = (Map<String, Object>) refreshTokenSource.get("client");
if (clientInfo == null) {
return Optional.of(invalidGrantException("token is missing client information"));
} else if (userAuth.getUser().principal().equals(clientInfo.get("user")) == false) {
return Optional.of(invalidGrantException("tokens must be refreshed by the creating client"));
} else if (userAuth.getAuthenticatedBy().getName().equals(clientInfo.get("realm")) == false) {
return Optional.of(invalidGrantException("tokens must be refreshed by the creating client"));
} else {
return Optional.empty();
}
}
private static String getInvalidatedTokenDocumentId(UserToken userToken) {
return getInvalidatedTokenDocumentId(userToken.getId());
}
private static String getInvalidatedTokenDocumentId(String id) {
return INVALIDATED_TOKEN_DOC_TYPE + "_" + id;
}
private static String getTokenDocumentId(UserToken userToken) {
return getTokenDocumentId(userToken.getId());
}
private static String getTokenDocumentId(String id) {
return "token_" + id;
}
private void ensureEnabled() {
@ -335,16 +733,39 @@ public final class TokenService extends AbstractComponent {
// index doesn't exist so the token is considered valid.
listener.onResponse(userToken);
} else {
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () ->
lifecycleService.prepareIndexIfNeededThenExecute(listener::onFailure, () -> {
MultiGetRequest mGetRequest = client.prepareMultiGet()
.add(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, getInvalidatedTokenDocumentId(userToken))
.add(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, getTokenDocumentId(userToken))
.request();
executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN,
client.prepareGet(SecurityLifecycleService.SECURITY_INDEX_NAME, TYPE, getDocumentId(userToken)).request(),
new ActionListener<GetResponse>() {
mGetRequest,
new ActionListener<MultiGetResponse>() {
@Override
public void onResponse(GetResponse response) {
if (response.isExists()) {
// this token is explicitly expired!
public void onResponse(MultiGetResponse response) {
MultiGetItemResponse[] itemResponse = response.getResponses();
if (itemResponse[0].isFailed()) {
onFailure(itemResponse[0].getFailure().getFailure());
} else if (itemResponse[0].getResponse().isExists()) {
listener.onFailure(expiredTokenException());
} else if (itemResponse[1].isFailed()) {
onFailure(itemResponse[1].getFailure().getFailure());
} else if (itemResponse[1].getResponse().isExists()) {
Map<String, Object> source = itemResponse[1].getResponse().getSource();
Map<String, Object> accessTokenSource = (Map<String, Object>) source.get("access_token");
if (accessTokenSource == null) {
listener.onFailure(new IllegalStateException("token document is missing access_token field"));
} else {
Boolean invalidated = (Boolean) accessTokenSource.get("invalidated");
if (invalidated == null) {
listener.onFailure(new IllegalStateException("token document is missing invalidated field"));
} else if (invalidated) {
listener.onFailure(expiredTokenException());
} else {
listener.onResponse(userToken);
}
}
} else {
listener.onResponse(userToken);
}
@ -354,7 +775,7 @@ public final class TokenService extends AbstractComponent {
public void onFailure(Exception e) {
// if the index or the shard is not there / available we assume that
// the token is not valid
if (TransportActions.isShardNotAvailableException(e)) {
if (isShardNotAvailableException(e)) {
logger.warn("failed to get token [{}] since index is not available", userToken.getId());
listener.onResponse(null);
} else {
@ -362,7 +783,8 @@ public final class TokenService extends AbstractComponent {
listener.onFailure(e);
}
}
}, client::get));
}, client::multiGet);
});
}
}
@ -371,7 +793,11 @@ public final class TokenService extends AbstractComponent {
}
private Instant getExpirationTime() {
return clock.instant().plusSeconds(expirationDelay.getSeconds());
return getExpirationTime(clock.instant());
}
private Instant getExpirationTime(Instant now) {
return now.plusSeconds(expirationDelay.getSeconds());
}
private void maybeStartTokenRemover() {
@ -387,7 +813,7 @@ public final class TokenService extends AbstractComponent {
* Gets the token from the <code>Authorization</code> header if the header begins with
* <code>Bearer </code>
*/
String getFromHeader(ThreadContext threadContext) {
private String getFromHeader(ThreadContext threadContext) {
String header = threadContext.getHeader("Authorization");
if (Strings.hasLength(header) && header.startsWith("Bearer ")
&& header.length() > "Bearer ".length()) {
@ -404,14 +830,17 @@ public final class TokenService extends AbstractComponent {
try (ByteArrayOutputStream os = new ByteArrayOutputStream(MINIMUM_BASE64_BYTES);
OutputStream base64 = Base64.getEncoder().wrap(os);
StreamOutput out = new OutputStreamStreamOutput(base64)) {
out.setVersion(userToken.getVersion());
KeyAndCache keyAndCache = keyCache.activeKeyCache;
Version.writeVersion(TOKEN_SERVICE_VERSION, out);
Version.writeVersion(userToken.getVersion(), out);
out.writeByteArray(keyAndCache.getSalt().bytes);
out.writeByteArray(keyAndCache.getKeyHash().bytes); // TODO this requires a BWC layer in 5.6
out.writeByteArray(keyAndCache.getKeyHash().bytes);
final byte[] initializationVector = getNewInitializationVector();
out.writeByteArray(initializationVector);
try (CipherOutputStream encryptedOutput = new CipherOutputStream(out, getEncryptionCipher(initializationVector, keyAndCache));
try (CipherOutputStream encryptedOutput =
new CipherOutputStream(out, getEncryptionCipher(initializationVector, keyAndCache, userToken.getVersion()));
StreamOutput encryptedStreamOutput = new OutputStreamStreamOutput(encryptedOutput)) {
encryptedStreamOutput.setVersion(userToken.getVersion());
userToken.writeTo(encryptedStreamOutput);
encryptedStreamOutput.close();
return new String(os.toByteArray(), StandardCharsets.UTF_8);
@ -424,7 +853,7 @@ public final class TokenService extends AbstractComponent {
SecretKeyFactory.getInstance(KDF_ALGORITHM);
}
private Cipher getEncryptionCipher(byte[] iv, KeyAndCache keyAndCache) throws GeneralSecurityException {
private Cipher getEncryptionCipher(byte[] iv, KeyAndCache keyAndCache, Version version) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(ENCRYPTION_CIPHER);
BytesKey salt = keyAndCache.getSalt();
try {
@ -432,7 +861,7 @@ public final class TokenService extends AbstractComponent {
} catch (ExecutionException e) {
throw new ElasticsearchSecurityException("Failed to compute secret key for active salt", e);
}
cipher.updateAAD(currentVersionBytes);
cipher.updateAAD(ByteBuffer.allocate(4).putInt(version.id).array());
cipher.updateAAD(salt.bytes);
return cipher;
}
@ -466,7 +895,8 @@ public final class TokenService extends AbstractComponent {
/**
* Creates an {@link ElasticsearchSecurityException} that indicates the token was expired. It
* is up to the client to re-authenticate and obtain a new token
* is up to the client to re-authenticate and obtain a new token. The format for this response
* is defined in <a href="https://tools.ietf.org/html/rfc6750#section-3.1"></a>
*/
private static ElasticsearchSecurityException expiredTokenException() {
ElasticsearchSecurityException e =
@ -477,7 +907,8 @@ public final class TokenService extends AbstractComponent {
/**
* Creates an {@link ElasticsearchSecurityException} that indicates the token was expired. It
* is up to the client to re-authenticate and obtain a new token
* is up to the client to re-authenticate and obtain a new token. The format for this response
* is defined in <a href="https://tools.ietf.org/html/rfc6750#section-3.1"></a>
*/
private static ElasticsearchSecurityException malformedTokenException() {
ElasticsearchSecurityException e =
@ -486,6 +917,16 @@ public final class TokenService extends AbstractComponent {
return e;
}
/**
* Creates an {@link ElasticsearchSecurityException} that indicates the request contained an invalid grant
*/
private static ElasticsearchSecurityException invalidGrantException(String detail) {
ElasticsearchSecurityException e =
new ElasticsearchSecurityException("invalid_grant", RestStatus.BAD_REQUEST);
e.addHeader("error_description", detail);
return e;
}
boolean isExpiredTokenException(ElasticsearchSecurityException e) {
final List<String> headers = e.getHeader("WWW-Authenticate");
return headers != null && headers.stream().anyMatch(EXPIRED_TOKEN_WWW_AUTH_VALUE::equals);
@ -497,20 +938,13 @@ public final class TokenService extends AbstractComponent {
private class KeyComputingRunnable extends AbstractRunnable {
private final StreamInput in;
private final Version version;
private final BytesKey decodedSalt;
private final ActionListener<UserToken> listener;
private final byte[] iv;
private final ActionListener<SecretKey> listener;
private final KeyAndCache keyAndCache;
KeyComputingRunnable(StreamInput input, byte[] iv, Version version, BytesKey decodedSalt, ActionListener<UserToken> listener,
KeyAndCache keyAndCache) {
this.in = input;
this.version = version;
KeyComputingRunnable(BytesKey decodedSalt, ActionListener<SecretKey> listener, KeyAndCache keyAndCache) {
this.decodedSalt = decodedSalt;
this.listener = listener;
this.iv = iv;
this.keyAndCache = keyAndCache;
}
@ -518,7 +952,7 @@ public final class TokenService extends AbstractComponent {
protected void doRun() {
try {
final SecretKey computedKey = keyAndCache.getOrComputeKey(decodedSalt);
decryptToken(in, getDecryptionCipher(iv, computedKey, version, decodedSalt), version, listener);
listener.onResponse(computedKey);
} catch (ExecutionException e) {
if (e.getCause() != null &&
(e.getCause() instanceof GeneralSecurityException || e.getCause() instanceof IOException
@ -530,9 +964,6 @@ public final class TokenService extends AbstractComponent {
} else {
listener.onFailure(e);
}
} catch (GeneralSecurityException | IOException e) {
logger.debug("unable to decode bearer token", e);
listener.onResponse(null);
}
}
@ -540,11 +971,6 @@ public final class TokenService extends AbstractComponent {
public void onFailure(Exception e) {
listener.onFailure(e);
}
@Override
public void onAfter() {
IOUtils.closeWhileHandlingException(in);
}
}
/**
@ -866,7 +1292,7 @@ public final class TokenService extends AbstractComponent {
}
@Override
public void close() throws IOException {
public void close() {
keyAndTimestamp.key.close();
}
@ -875,12 +1301,7 @@ public final class TokenService extends AbstractComponent {
}
private static BytesKey calculateKeyHash(SecureString key) {
MessageDigest messageDigest = null;
try {
messageDigest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
MessageDigest messageDigest = MessageDigests.sha256();
BytesRefBuilder b = new BytesRefBuilder();
try {
b.copyChars(key);

View File

@ -7,12 +7,17 @@ package org.elasticsearch.xpack.security.authc;
import org.elasticsearch.Version;
import org.elasticsearch.common.UUIDs;
import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;
import java.io.IOException;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
/**
@ -24,23 +29,28 @@ import java.util.Objects;
* used by an adversary to gain access. For this reason, TLS must be enabled for these tokens to
* be used.
*/
public final class UserToken implements Writeable {
public final class UserToken implements Writeable, ToXContentObject {
private final Version version;
private final String id;
private final Authentication authentication;
private final Instant expirationTime;
private final Map<String, Object> metadata;
/**
* Create a new token with an autogenerated id
*/
UserToken(Authentication authentication, Instant expirationTime) {
this.version = Version.CURRENT;
this(Version.CURRENT, authentication, expirationTime, Collections.emptyMap());
}
UserToken(Version version, Authentication authentication, Instant expirationTime, Map<String, Object> metadata) {
this.version = version;
this.id = UUIDs.base64UUID();
this.authentication = Objects.requireNonNull(authentication);
this.expirationTime = Objects.requireNonNull(expirationTime);
this.metadata = metadata;
}
/**
* Creates a new token based on the values from the stream
*/
@ -49,6 +59,11 @@ public final class UserToken implements Writeable {
this.id = input.readString();
this.authentication = new Authentication(input);
this.expirationTime = Instant.ofEpochSecond(input.readLong(), input.readInt());
if (version.before(Version.V_6_2_0)) {
this.metadata = Collections.emptyMap();
} else {
this.metadata = input.readMap();
}
}
@Override
@ -57,6 +72,9 @@ public final class UserToken implements Writeable {
authentication.writeTo(out);
out.writeLong(expirationTime.getEpochSecond());
out.writeInt(expirationTime.getNano());
if (out.getVersion().onOrAfter(Version.V_6_2_0)) {
out.writeMap(metadata);
}
}
/**
@ -76,7 +94,7 @@ public final class UserToken implements Writeable {
/**
* The ID of this token
*/
String getId() {
public String getId() {
return id;
}
@ -86,4 +104,26 @@ public final class UserToken implements Writeable {
Version getVersion() {
return version;
}
/**
* The metadata associated with this token
*/
public Map<String, Object> getMetadata() {
return metadata;
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject();
builder.field("id", id);
builder.field("expiration_time", expirationTime.toEpochMilli());
builder.field("version", version.id);
builder.field("metadata", metadata);
try (BytesStreamOutput output = new BytesStreamOutput()) {
output.setVersion(version);
authentication.writeTo(output);
builder.field("authentication", output.bytes().toBytesRef().bytes);
}
return builder.endObject();
}
}

View File

@ -46,6 +46,7 @@ import org.elasticsearch.xpack.security.action.token.InvalidateTokenAction;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenRequest;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenRequestBuilder;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenResponse;
import org.elasticsearch.xpack.security.action.token.RefreshTokenAction;
import org.elasticsearch.xpack.security.action.user.ChangePasswordAction;
import org.elasticsearch.xpack.security.action.user.ChangePasswordRequest;
import org.elasticsearch.xpack.security.action.user.ChangePasswordRequestBuilder;
@ -270,7 +271,7 @@ public class SecurityClient {
}
public CreateTokenRequestBuilder prepareCreateToken() {
return new CreateTokenRequestBuilder(client);
return new CreateTokenRequestBuilder(client, CreateTokenAction.INSTANCE);
}
public void createToken(CreateTokenRequest request, ActionListener<CreateTokenResponse> listener) {
@ -284,4 +285,14 @@ public class SecurityClient {
public void invalidateToken(InvalidateTokenRequest request, ActionListener<InvalidateTokenResponse> listener) {
client.execute(InvalidateTokenAction.INSTANCE, request, listener);
}
public CreateTokenRequestBuilder prepareRefreshToken(String refreshToken) {
return new CreateTokenRequestBuilder(client, RefreshTokenAction.INSTANCE)
.setRefreshToken(refreshToken)
.setGrantType("refresh_token");
}
public void refreshToken(CreateTokenRequest request, ActionListener<CreateTokenResponse> listener) {
client.execute(RefreshTokenAction.INSTANCE, request, listener);
}
}

View File

@ -6,6 +6,8 @@
package org.elasticsearch.xpack.security.rest.action.oauth2;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.Action;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.client.node.NodeClient;
@ -24,7 +26,9 @@ import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.xpack.security.action.token.CreateTokenAction;
import org.elasticsearch.xpack.security.action.token.CreateTokenRequest;
import org.elasticsearch.xpack.security.action.token.CreateTokenRequestBuilder;
import org.elasticsearch.xpack.security.action.token.CreateTokenResponse;
import org.elasticsearch.xpack.security.action.token.RefreshTokenAction;
import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
import java.io.IOException;
@ -43,7 +47,7 @@ import static org.elasticsearch.rest.RestRequest.Method.POST;
public final class RestGetTokenAction extends SecurityBaseRestHandler {
static final ConstructingObjectParser<CreateTokenRequest, Void> PARSER = new ConstructingObjectParser<>("token_request",
a -> new CreateTokenRequest((String) a[0], (String) a[1], (SecureString) a[2], (String) a[3]));
a -> new CreateTokenRequest((String) a[0], (String) a[1], (SecureString) a[2], (String) a[3], (String) a[4]));
static {
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("grant_type"));
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("username"));
@ -51,6 +55,7 @@ public final class RestGetTokenAction extends SecurityBaseRestHandler {
Arrays.copyOfRange(parser.textCharacters(), parser.textOffset(), parser.textOffset() + parser.textLength())),
new ParseField("password"), ValueType.STRING);
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("scope"));
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("refresh_token"));
}
public RestGetTokenAction(Settings settings, RestController controller, XPackLicenseState xPackLicenseState) {
@ -67,7 +72,9 @@ public final class RestGetTokenAction extends SecurityBaseRestHandler {
protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client)throws IOException {
try (XContentParser parser = request.contentParser()) {
final CreateTokenRequest tokenRequest = PARSER.parse(parser, null);
return channel -> client.execute(CreateTokenAction.INSTANCE, tokenRequest,
final Action<CreateTokenRequest, CreateTokenResponse, CreateTokenRequestBuilder> action =
"refresh_token".equals(tokenRequest.getGrantType()) ? RefreshTokenAction.INSTANCE : CreateTokenAction.INSTANCE;
return channel -> client.execute(action, tokenRequest,
// this doesn't use the RestBuilderListener since we need to override the
// handling of failures in some cases.
new CreateTokenResponseActionListener(channel, request, logger));
@ -100,32 +107,37 @@ public final class RestGetTokenAction extends SecurityBaseRestHandler {
public void onFailure(Exception e) {
if (e instanceof ActionRequestValidationException) {
ActionRequestValidationException validationException = (ActionRequestValidationException) e;
try (XContentBuilder builder = channel.newErrorBuilder()) {
final TokenRequestError error;
if (validationException.validationErrors().stream().anyMatch(s -> s.contains("grant_type"))) {
error = TokenRequestError.UNSUPPORTED_GRANT_TYPE;
} else {
error = TokenRequestError.INVALID_REQUEST;
}
// defined by https://tools.ietf.org/html/rfc6749#section-5.2
builder.startObject()
.field("error",
error.toString().toLowerCase(Locale.ROOT))
.field("error_description",
validationException.getMessage())
.endObject();
channel.sendResponse(
new BytesRestResponse(RestStatus.BAD_REQUEST, builder));
} catch (IOException ioe) {
ioe.addSuppressed(e);
sendFailure(ioe);
final TokenRequestError error;
if (validationException.validationErrors().stream().anyMatch(s -> s.contains("grant_type"))) {
error = TokenRequestError.UNSUPPORTED_GRANT_TYPE;
} else {
error = TokenRequestError.INVALID_REQUEST;
}
sendTokenErrorResponse(error, validationException.getMessage(), e);
} else if (e instanceof ElasticsearchSecurityException && "invalid_grant".equals(e.getMessage()) &&
((ElasticsearchSecurityException) e).getHeader("error_description").size() == 1) {
sendTokenErrorResponse(TokenRequestError.INVALID_GRANT,
((ElasticsearchSecurityException) e).getHeader("error_description").get(0), e);
} else {
sendFailure(e);
}
}
void sendTokenErrorResponse(TokenRequestError error, String description, Exception e) {
try (XContentBuilder builder = channel.newErrorBuilder()) {
// defined by https://tools.ietf.org/html/rfc6749#section-5.2
builder.startObject()
.field("error", error.toString().toLowerCase(Locale.ROOT))
.field("error_description", description)
.endObject();
channel.sendResponse(new BytesRestResponse(RestStatus.BAD_REQUEST, builder));
} catch (IOException ioe) {
ioe.addSuppressed(e);
sendFailure(e);
}
}
void sendFailure(Exception e) {
try {
channel.sendResponse(new BytesRestResponse(channel, e));

View File

@ -7,6 +7,8 @@ package org.elasticsearch.xpack.security.rest.action.oauth2;
import org.elasticsearch.client.node.NodeClient;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.XContentBuilder;
@ -32,10 +34,11 @@ import static org.elasticsearch.rest.RestRequest.Method.DELETE;
*/
public final class RestInvalidateTokenAction extends SecurityBaseRestHandler {
static final ConstructingObjectParser<String, Void> PARSER =
new ConstructingObjectParser<>("invalidate_token", a -> ((String) a[0]));
static final ConstructingObjectParser<Tuple<String, String>, Void> PARSER =
new ConstructingObjectParser<>("invalidate_token", a -> new Tuple<>((String) a[0], (String) a[1]));
static {
PARSER.declareString(ConstructingObjectParser.constructorArg(), new ParseField("token"));
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("token"));
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("refresh_token"));
}
public RestInvalidateTokenAction(Settings settings, RestController controller, XPackLicenseState xPackLicenseState) {
@ -51,8 +54,26 @@ public final class RestInvalidateTokenAction extends SecurityBaseRestHandler {
@Override
protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
try (XContentParser parser = request.contentParser()) {
final String token = PARSER.parse(parser, null);
final InvalidateTokenRequest tokenRequest = new InvalidateTokenRequest(token);
final Tuple<String, String> tuple = PARSER.parse(parser, null);
final String token = tuple.v1();
final String refreshToken = tuple.v2();
final String tokenString;
final InvalidateTokenRequest.Type type;
if (Strings.hasLength(token) && Strings.hasLength(refreshToken)) {
throw new IllegalArgumentException("only one of [token, refresh_token] may be sent per request");
} else if (Strings.hasLength(token)) {
tokenString = token;
type = InvalidateTokenRequest.Type.ACCESS_TOKEN;
} else if (Strings.hasLength(refreshToken)) {
tokenString = refreshToken;
type = InvalidateTokenRequest.Type.REFRESH_TOKEN;
} else {
tokenString = null;
type = null;
}
final InvalidateTokenRequest tokenRequest = new InvalidateTokenRequest(tokenString, type);
return channel -> client.execute(InvalidateTokenAction.INSTANCE, tokenRequest,
new RestBuilderListener<InvalidateTokenResponse>(channel) {
@Override

View File

@ -97,19 +97,81 @@
"run_as" : {
"type" : "keyword"
},
"doc_type": {
"doc_type" : {
"type" : "keyword"
},
"type": {
"type" : {
"type" : "keyword"
},
"expiration_time": {
"type": "date",
"format": "epoch_millis"
"expiration_time" : {
"type" : "date",
"format" : "epoch_millis"
},
"rules": {
"creation_time" : {
"type" : "date",
"format" : "epoch_millis"
},
"rules" : {
"type" : "object",
"dynamic" : true
},
"refresh_token" : {
"type" : "object",
"properties" : {
"token" : {
"type" : "keyword"
},
"refreshed" : {
"type" : "boolean"
},
"invalidated" : {
"type" : "boolean"
},
"client" : {
"type" : "object",
"properties" : {
"type" : {
"type" : "keyword"
},
"user" : {
"type" : "keyword"
},
"realm" : {
"type" : "keyword"
}
}
}
}
},
"access_token" : {
"type" : "object",
"properties" : {
"user_token" : {
"type" : "object",
"properties" : {
"id" : {
"type" : "keyword"
},
"expiration_time" : {
"type" : "date",
"format" : "epoch_millis"
},
"version" : {
"type" : "integer"
},
"metadata" : {
"type" : "object",
"dynamic" : true
},
"authentication" : {
"type" : "binary"
}
}
},
"invalidated" : {
"type" : "boolean"
}
}
}
}
}

View File

@ -9,15 +9,18 @@ import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.test.ESTestCase;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasItem;
public class CreateTokenRequestTests extends ESTestCase {
public void testRequestValidation() throws Exception {
public void testRequestValidation() {
CreateTokenRequest request = new CreateTokenRequest();
ActionRequestValidationException ve = request.validate();
assertNotNull(ve);
assertEquals(3, ve.validationErrors().size());
assertEquals(1, ve.validationErrors().size());
assertThat(ve.validationErrors().get(0), containsString("[password, refresh_token]"));
assertThat(ve.validationErrors().get(0), containsString("grant_type"));
request.setGrantType("password");
ve = request.validate();
@ -44,5 +47,29 @@ public class CreateTokenRequestTests extends ESTestCase {
request.setPassword(new SecureString(randomAlphaOfLengthBetween(1, 256).toCharArray()));
ve = request.validate();
assertNull(ve);
request.setRefreshToken(randomAlphaOfLengthBetween(1, 10));
ve = request.validate();
assertNotNull(ve);
assertEquals(1, ve.validationErrors().size());
assertThat(ve.validationErrors().get(0), containsString("refresh_token is not supported"));
request.setGrantType("refresh_token");
ve = request.validate();
assertNotNull(ve);
assertEquals(2, ve.validationErrors().size());
assertThat(ve.validationErrors(), hasItem(containsString("username is not supported")));
assertThat(ve.validationErrors(), hasItem(containsString("password is not supported")));
request.setUsername(null);
request.setPassword(null);
ve = request.validate();
assertNull(ve);
request.setRefreshToken(null);
ve = request.validate();
assertNotNull(ve);
assertEquals(1, ve.validationErrors().size());
assertThat(ve.validationErrors(), hasItem("refresh_token is missing"));
}
}

View File

@ -10,16 +10,26 @@ import org.apache.lucene.util.SetOnce;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.get.GetRequest;
import org.elasticsearch.action.get.GetRequestBuilder;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.get.MultiGetAction;
import org.elasticsearch.action.get.MultiGetItemResponse;
import org.elasticsearch.action.get.MultiGetRequest;
import org.elasticsearch.action.get.MultiGetRequestBuilder;
import org.elasticsearch.action.get.MultiGetResponse;
import org.elasticsearch.action.index.IndexAction;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexRequestBuilder;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.action.update.UpdateAction;
import org.elasticsearch.action.update.UpdateRequestBuilder;
import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.SuppressForbidden;
import org.elasticsearch.common.collect.MapBuilder;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.settings.ClusterSettings;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.TransportAddress;
@ -30,6 +40,7 @@ import org.elasticsearch.env.TestEnvironment;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.test.ClusterServiceUtils;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.rest.FakeRestRequest;
import org.elasticsearch.threadpool.FixedExecutorBuilder;
@ -141,9 +152,22 @@ public class AuthenticationServiceTests extends ESTestCase {
threadContext = threadPool.getThreadContext();
when(client.threadPool()).thenReturn(threadPool);
when(client.settings()).thenReturn(settings);
when(client.prepareIndex(any(String.class), any(String.class), any(String.class)))
.thenReturn(new IndexRequestBuilder(client, IndexAction.INSTANCE));
when(client.prepareUpdate(any(String.class), any(String.class), any(String.class)))
.thenReturn(new UpdateRequestBuilder(client, UpdateAction.INSTANCE));
doAnswer(invocationOnMock -> {
ActionListener<IndexResponse> responseActionListener = (ActionListener<IndexResponse>) invocationOnMock.getArguments()[2];
responseActionListener.onResponse(new IndexResponse());
return null;
}).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class));
lifecycleService = mock(SecurityLifecycleService.class);
ClusterService clusterService = new ClusterService(settings, new ClusterSettings(settings, ClusterSettings
.BUILT_IN_CLUSTER_SETTINGS), threadPool, Collections.emptyMap());
doAnswer(invocationOnMock -> {
Runnable runnable = (Runnable) invocationOnMock.getArguments()[1];
runnable.run();
return null;
}).when(lifecycleService).prepareIndexIfNeededThenExecute(any(Consumer.class), any(Runnable.class));
ClusterService clusterService = ClusterServiceUtils.createClusterService(threadPool);
tokenService = new TokenService(settings, Clock.systemUTC(), client, lifecycleService, clusterService);
service = new AuthenticationService(settings, realms, auditTrail,
new DefaultAuthenticationFailureHandler(), threadPool, new AnonymousUser(settings), tokenService);
@ -806,7 +830,12 @@ public class AuthenticationServiceTests extends ESTestCase {
User user = new User("_username", "r1");
final AtomicBoolean completed = new AtomicBoolean(false);
final Authentication expected = new Authentication(user, new RealmRef("realm", "custom", "node"), null);
String token = tokenService.getUserTokenString(tokenService.createUserToken(expected));
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
try (ThreadContext.StoredContext ctx = threadContext.stashContext()) {
Authentication originatingAuth = new Authentication(new User("creator"), new RealmRef("test", "test", "test"), null);
tokenService.createUserToken(expected, originatingAuth, tokenFuture, Collections.emptyMap());
}
String token = tokenService.getUserTokenString(tokenFuture.get().v1());
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
threadContext.putHeader("Authorization", "Bearer " + token);
service.authenticate("_action", message, null, ActionListener.wrap(result -> {
@ -863,20 +892,40 @@ public class AuthenticationServiceTests extends ESTestCase {
}
public void testExpiredToken() throws Exception {
when(lifecycleService.isSecurityIndexAvailable()).thenReturn(true);
when(lifecycleService.isSecurityIndexExisting()).thenReturn(true);
User user = new User("_username", "r1");
final Authentication expected = new Authentication(user, new RealmRef("realm", "custom", "node"), null);
String token = tokenService.getUserTokenString(tokenService.createUserToken(expected));
when(lifecycleService.isSecurityIndexExisting()).thenReturn(true);
GetRequestBuilder getRequestBuilder = mock(GetRequestBuilder.class);
when(client.prepareGet(eq(SecurityLifecycleService.SECURITY_INDEX_NAME), eq("doc"), any(String.class)))
.thenReturn(getRequestBuilder);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
try (ThreadContext.StoredContext ctx = threadContext.stashContext()) {
Authentication originatingAuth = new Authentication(new User("creator"), new RealmRef("test", "test", "test"), null);
tokenService.createUserToken(expected, originatingAuth, tokenFuture, Collections.emptyMap());
}
String token = tokenService.getUserTokenString(tokenFuture.get().v1());
when(client.prepareMultiGet()).thenReturn(new MultiGetRequestBuilder(client, MultiGetAction.INSTANCE));
doAnswer(invocationOnMock -> {
ActionListener<GetResponse> listener = (ActionListener<GetResponse>) invocationOnMock.getArguments()[1];
GetResponse response = mock(GetResponse.class);
when(response.isExists()).thenReturn(true);
ActionListener<MultiGetResponse> listener = (ActionListener<MultiGetResponse>) invocationOnMock.getArguments()[1];
MultiGetResponse response = mock(MultiGetResponse.class);
MultiGetItemResponse[] responses = new MultiGetItemResponse[2];
when(response.getResponses()).thenReturn(responses);
final boolean newExpired = randomBoolean();
GetResponse oldGetResponse = mock(GetResponse.class);
when(oldGetResponse.isExists()).thenReturn(newExpired == false);
responses[0] = new MultiGetItemResponse(oldGetResponse, null);
GetResponse getResponse = mock(GetResponse.class);
responses[1] = new MultiGetItemResponse(getResponse, null);
when(getResponse.isExists()).thenReturn(newExpired);
if (newExpired) {
Map<String, Object> source = MapBuilder.<String, Object>newMapBuilder()
.put("access_token", Collections.singletonMap("invalidated", true))
.immutableMap();
when(getResponse.getSource()).thenReturn(source);
}
listener.onResponse(response);
return Void.TYPE;
}).when(client).get(any(GetRequest.class), any(ActionListener.class));
}).when(client).multiGet(any(MultiGetRequest.class), any(ActionListener.class));
doAnswer(invocationOnMock -> {
((Runnable) invocationOnMock.getArguments()[1]).run();

View File

@ -16,6 +16,7 @@ import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.test.SecurityIntegTestCase;
import org.elasticsearch.test.SecuritySettingsSource;
@ -23,14 +24,16 @@ import org.elasticsearch.test.junit.annotations.TestLogging;
import org.elasticsearch.xpack.XPackSettings;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import org.elasticsearch.xpack.security.action.token.CreateTokenResponse;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenRequest;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenResponse;
import org.elasticsearch.xpack.security.action.user.AuthenticateAction;
import org.elasticsearch.xpack.security.action.user.AuthenticateRequest;
import org.elasticsearch.xpack.security.action.user.AuthenticateResponse;
import org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.security.client.SecurityClient;
import org.junit.After;
import org.junit.Before;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
@ -38,8 +41,8 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoTimeout;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
public class TokenAuthIntegTests extends SecurityIntegTestCase {
@ -49,7 +52,7 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase {
.put(super.nodeSettings(nodeOrdinal))
// crank up the deletion interval and set timeout for delete requests
.put(TokenService.DELETE_INTERVAL.getKey(), TimeValue.timeValueSeconds(1L))
.put(TokenService.DELETE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(2L))
.put(TokenService.DELETE_TIMEOUT.getKey(), TimeValue.timeValueSeconds(5L))
.put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), true)
.build();
}
@ -134,12 +137,16 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase {
Instant created = Instant.now();
InvalidateTokenResponse invalidateResponse = securityClient.prepareInvalidateToken(response.getTokenString()).get();
InvalidateTokenResponse invalidateResponse = securityClient
.prepareInvalidateToken(response.getTokenString())
.setType(InvalidateTokenRequest.Type.ACCESS_TOKEN)
.get();
assertTrue(invalidateResponse.isCreated());
AtomicReference<String> docId = new AtomicReference<>();
assertBusy(() -> {
SearchResponse searchResponse = client.prepareSearch(SecurityLifecycleService.SECURITY_INDEX_NAME)
.setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", TokenService.DOC_TYPE)))
.setSource(SearchSourceBuilder.searchSource()
.query(QueryBuilders.termQuery("doc_type", TokenService.INVALIDATED_TOKEN_DOC_TYPE)))
.setSize(1)
.setTerminateAfter(1)
.get();
@ -157,18 +164,21 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase {
AtomicBoolean deleteTriggered = new AtomicBoolean(false);
assertBusy(() -> {
assertTrue(Instant.now().isAfter(created.plusSeconds(1L).plusMillis(500L)));
if (deleteTriggered.compareAndSet(false, true)) {
// invalidate a invalid token... doesn't matter that it is bad... we just want this action to trigger the deletion
try {
securityClient.prepareInvalidateToken("fooobar").execute().actionGet();
securityClient.prepareInvalidateToken("fooobar")
.setType(randomFrom(InvalidateTokenRequest.Type.values()))
.execute()
.actionGet();
} catch (ElasticsearchSecurityException e) {
assertEquals("token malformed", e.getMessage());
}
}
client.admin().indices().prepareRefresh(SecurityLifecycleService.SECURITY_INDEX_NAME).get();
SearchResponse searchResponse = client.prepareSearch(SecurityLifecycleService.SECURITY_INDEX_NAME)
.setSource(SearchSourceBuilder.searchSource().query(QueryBuilders.termQuery("doc_type", TokenService.DOC_TYPE)))
.setSource(SearchSourceBuilder.searchSource()
.query(QueryBuilders.termQuery("doc_type", TokenService.INVALIDATED_TOKEN_DOC_TYPE)))
.setSize(0)
.setTerminateAfter(1)
.get();
@ -176,30 +186,156 @@ public class TokenAuthIntegTests extends SecurityIntegTestCase {
}, 30, TimeUnit.SECONDS);
}
public void testExpireMultipleTimes() throws Exception {
public void testExpireMultipleTimes() {
CreateTokenResponse response = securityClient().prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
InvalidateTokenResponse invalidateResponse = securityClient().prepareInvalidateToken(response.getTokenString()).get();
// if the token is expired then the API will return false for created so we need to handle that
final boolean correctResponse = invalidateResponse.isCreated() || isTokenExpired(response.getTokenString());
assertTrue(correctResponse);
assertFalse(securityClient().prepareInvalidateToken(response.getTokenString()).get().isCreated());
InvalidateTokenResponse invalidateResponse = securityClient()
.prepareInvalidateToken(response.getTokenString())
.setType(InvalidateTokenRequest.Type.ACCESS_TOKEN)
.get();
assertTrue(invalidateResponse.isCreated());
assertFalse(securityClient()
.prepareInvalidateToken(response.getTokenString())
.setType(InvalidateTokenRequest.Type.ACCESS_TOKEN)
.get()
.isCreated());
}
private static boolean isTokenExpired(String token) {
try {
TokenService tokenService = internalCluster().getInstance(TokenService.class);
PlainActionFuture<UserToken> tokenFuture = new PlainActionFuture<>();
tokenService.decodeToken(token, tokenFuture);
return tokenFuture.actionGet().getExpirationTime().isBefore(Instant.now());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
public void testRefreshingToken() {
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING)));
SecurityClient securityClient = new SecurityClient(client);
CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
assertNotNull(createTokenResponse.getRefreshToken());
// get cluster health with token
assertNoTimeout(client()
.filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + createTokenResponse.getTokenString()))
.admin().cluster().prepareHealth().get());
CreateTokenResponse refreshResponse = securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get();
assertNotNull(refreshResponse.getRefreshToken());
assertNotEquals(refreshResponse.getRefreshToken(), createTokenResponse.getRefreshToken());
assertNotEquals(refreshResponse.getTokenString(), createTokenResponse.getTokenString());
assertNoTimeout(client().filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + refreshResponse.getTokenString()))
.admin().cluster().prepareHealth().get());
}
public void testRefreshingInvalidatedToken() {
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING)));
SecurityClient securityClient = new SecurityClient(client);
CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
assertNotNull(createTokenResponse.getRefreshToken());
InvalidateTokenResponse invalidateResponse = securityClient
.prepareInvalidateToken(createTokenResponse.getRefreshToken())
.setType(InvalidateTokenRequest.Type.REFRESH_TOKEN)
.get();
assertTrue(invalidateResponse.isCreated());
ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class,
() -> securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get());
assertEquals("invalid_grant", e.getMessage());
assertEquals(RestStatus.BAD_REQUEST, e.status());
assertEquals("token has been invalidated", e.getHeader("error_description").get(0));
}
public void testRefreshingMultipleTimes() {
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING)));
SecurityClient securityClient = new SecurityClient(client);
CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
assertNotNull(createTokenResponse.getRefreshToken());
CreateTokenResponse refreshResponse = securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get();
assertNotNull(refreshResponse);
ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class,
() -> securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get());
assertEquals("invalid_grant", e.getMessage());
assertEquals(RestStatus.BAD_REQUEST, e.status());
assertEquals("token has already been refreshed", e.getHeader("error_description").get(0));
}
public void testRefreshAsDifferentUser() {
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING)));
SecurityClient securityClient = new SecurityClient(client);
CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
assertNotNull(createTokenResponse.getRefreshToken());
ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class,
() -> new SecurityClient(client()
.filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING))))
.prepareRefreshToken(createTokenResponse.getRefreshToken()).get());
assertEquals("invalid_grant", e.getMessage());
assertEquals(RestStatus.BAD_REQUEST, e.status());
assertEquals("tokens must be refreshed by the creating client", e.getHeader("error_description").get(0));
}
public void testCreateThenRefreshAsDifferentUser() {
Client client = client().filterWithHeader(Collections.singletonMap("Authorization",
UsernamePasswordToken.basicAuthHeaderValue(SecuritySettingsSource.TEST_SUPERUSER,
SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING)));
SecurityClient securityClient = new SecurityClient(client);
CreateTokenResponse createTokenResponse = securityClient.prepareCreateToken()
.setGrantType("password")
.setUsername(SecuritySettingsSource.TEST_USER_NAME)
.setPassword(new SecureString(SecuritySettingsSource.TEST_PASSWORD.toCharArray()))
.get();
assertNotNull(createTokenResponse.getRefreshToken());
CreateTokenResponse refreshResponse = securityClient.prepareRefreshToken(createTokenResponse.getRefreshToken()).get();
assertNotEquals(refreshResponse.getTokenString(), createTokenResponse.getTokenString());
assertNotEquals(refreshResponse.getRefreshToken(), createTokenResponse.getRefreshToken());
PlainActionFuture<AuthenticateResponse> authFuture = new PlainActionFuture<>();
AuthenticateRequest request = new AuthenticateRequest();
request.username(SecuritySettingsSource.TEST_SUPERUSER);
client.execute(AuthenticateAction.INSTANCE, request, authFuture);
AuthenticateResponse response = authFuture.actionGet();
assertEquals(SecuritySettingsSource.TEST_SUPERUSER, response.user().principal());
authFuture = new PlainActionFuture<>();
request = new AuthenticateRequest();
request.username(SecuritySettingsSource.TEST_USER_NAME);
client.filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + createTokenResponse.getTokenString()))
.execute(AuthenticateAction.INSTANCE, request, authFuture);
response = authFuture.actionGet();
assertEquals(SecuritySettingsSource.TEST_USER_NAME, response.user().principal());
authFuture = new PlainActionFuture<>();
request = new AuthenticateRequest();
request.username(SecuritySettingsSource.TEST_USER_NAME);
client.filterWithHeader(Collections.singletonMap("Authorization", "Bearer " + refreshResponse.getTokenString()))
.execute(AuthenticateAction.INSTANCE, request, authFuture);
response = authFuture.actionGet();
assertEquals(SecuritySettingsSource.TEST_USER_NAME, response.user().principal());
}
@Before

View File

@ -9,19 +9,31 @@ import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.NoShardAvailableActionException;
import org.elasticsearch.action.get.GetAction;
import org.elasticsearch.action.get.GetRequest;
import org.elasticsearch.action.get.GetRequestBuilder;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.get.MultiGetAction;
import org.elasticsearch.action.get.MultiGetItemResponse;
import org.elasticsearch.action.get.MultiGetRequest;
import org.elasticsearch.action.get.MultiGetRequestBuilder;
import org.elasticsearch.action.get.MultiGetResponse;
import org.elasticsearch.action.index.IndexAction;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexRequestBuilder;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.action.update.UpdateAction;
import org.elasticsearch.action.update.UpdateRequestBuilder;
import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.settings.ClusterSettings;
import org.elasticsearch.common.collect.MapBuilder;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.index.Index;
import org.elasticsearch.index.shard.ShardId;
import org.elasticsearch.node.Node;
import org.elasticsearch.test.ClusterServiceUtils;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.EqualsHashCodeTestUtils;
import org.elasticsearch.threadpool.FixedExecutorBuilder;
@ -37,10 +49,11 @@ import org.junit.Before;
import org.junit.BeforeClass;
import javax.crypto.SecretKey;
import java.security.GeneralSecurityException;
import java.io.IOException;
import java.time.Clock;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.function.Consumer;
import static java.time.Clock.systemUTC;
@ -71,27 +84,48 @@ public class TokenServiceTests extends ESTestCase {
client = mock(Client.class);
when(client.threadPool()).thenReturn(threadPool);
when(client.settings()).thenReturn(settings);
lifecycleService = mock(SecurityLifecycleService.class);
when(client.prepareMultiGet()).thenReturn(new MultiGetRequestBuilder(client, MultiGetAction.INSTANCE));
doAnswer(invocationOnMock -> {
ActionListener<GetResponse> listener = (ActionListener<GetResponse>) invocationOnMock.getArguments()[2];
GetResponse response = mock(GetResponse.class);
when(response.isExists()).thenReturn(false);
ActionListener<MultiGetResponse> listener = (ActionListener<MultiGetResponse>) invocationOnMock.getArguments()[1];
MultiGetResponse response = mock(MultiGetResponse.class);
MultiGetItemResponse[] responses = new MultiGetItemResponse[2];
when(response.getResponses()).thenReturn(responses);
GetResponse oldGetResponse = mock(GetResponse.class);
when(oldGetResponse.isExists()).thenReturn(false);
responses[0] = new MultiGetItemResponse(oldGetResponse, null);
GetResponse getResponse = mock(GetResponse.class);
responses[1] = new MultiGetItemResponse(getResponse, null);
when(getResponse.isExists()).thenReturn(false);
listener.onResponse(response);
return Void.TYPE;
}).when(client).get(any(GetRequest.class), any(ActionListener.class));
}).when(client).multiGet(any(MultiGetRequest.class), any(ActionListener.class));
when(client.prepareIndex(any(String.class), any(String.class), any(String.class)))
.thenReturn(new IndexRequestBuilder(client, IndexAction.INSTANCE));
when(client.prepareUpdate(any(String.class), any(String.class), any(String.class)))
.thenReturn(new UpdateRequestBuilder(client, UpdateAction.INSTANCE));
doAnswer(invocationOnMock -> {
((Runnable) invocationOnMock.getArguments()[1]).run();
ActionListener<IndexResponse> responseActionListener = (ActionListener<IndexResponse>) invocationOnMock.getArguments()[2];
responseActionListener.onResponse(new IndexResponse());
return null;
}).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class));
// setup lifecycle service
lifecycleService = mock(SecurityLifecycleService.class);
doAnswer(invocationOnMock -> {
Runnable runnable = (Runnable) invocationOnMock.getArguments()[1];
runnable.run();
return null;
}).when(lifecycleService).prepareIndexIfNeededThenExecute(any(Consumer.class), any(Runnable.class));
when(client.threadPool()).thenReturn(threadPool);
this.clusterService = new ClusterService(settings, new ClusterSettings(settings, ClusterSettings
.BUILT_IN_CLUSTER_SETTINGS), threadPool, Collections.emptyMap());
this.clusterService = ClusterServiceUtils.createClusterService(threadPool);
}
@BeforeClass
public static void startThreadPool() {
public static void startThreadPool() throws IOException {
threadPool = new ThreadPool(settings,
new FixedExecutorBuilder(settings, TokenService.THREAD_POOL_NAME, 1, 1000, "xpack.security.authc.token.thread_pool"));
new Authentication(new User("foo"), new RealmRef("realm", "type", "node"), null).writeToContext(threadPool.getThreadContext());
}
@AfterClass
@ -103,7 +137,9 @@ public class TokenServiceTests extends ESTestCase {
public void testAttachAndGetToken() throws Exception {
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -131,7 +167,9 @@ public class TokenServiceTests extends ESTestCase {
public void testRotateKey() throws Exception {
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -152,7 +190,9 @@ public class TokenServiceTests extends ESTestCase {
assertEquals(authentication, serialized.getAuthentication());
}
final UserToken newToken = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> newTokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, newTokenFuture, Collections.emptyMap());
final UserToken newToken = newTokenFuture.get().v1();
assertNotNull(newToken);
assertNotEquals(tokenService.getUserTokenString(newToken), tokenService.getUserTokenString(token));
@ -184,7 +224,9 @@ public class TokenServiceTests extends ESTestCase {
clusterService);
otherTokenService.refreshMetaData(tokenService.getTokenMetaData());
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -211,7 +253,9 @@ public class TokenServiceTests extends ESTestCase {
public void testPruneKeys() throws Exception {
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -238,7 +282,9 @@ public class TokenServiceTests extends ESTestCase {
assertEquals(authentication, serialized.getAuthentication());
}
final UserToken newToken = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> newTokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, newTokenFuture, Collections.emptyMap());
final UserToken newToken = newTokenFuture.get().v1();
assertNotNull(newToken);
assertNotEquals(tokenService.getUserTokenString(newToken), tokenService.getUserTokenString(token));
@ -267,7 +313,9 @@ public class TokenServiceTests extends ESTestCase {
public void testPassphraseWorks() throws Exception {
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -294,7 +342,9 @@ public class TokenServiceTests extends ESTestCase {
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
UserToken token = tokenFuture.get().v1();
assertThat(tokenService.getUserTokenString(token), notNullValue());
tokenService.clearActiveKeyCache();
@ -306,18 +356,33 @@ public class TokenServiceTests extends ESTestCase {
TokenService tokenService =
new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
GetRequestBuilder getRequestBuilder = mock(GetRequestBuilder.class);
when(client.prepareGet(SecurityLifecycleService.SECURITY_INDEX_NAME, "doc", TokenService.DOC_TYPE + "_" + token.getId()))
.thenReturn(getRequestBuilder);
doAnswer(invocationOnMock -> {
ActionListener<GetResponse> listener = (ActionListener<GetResponse>) invocationOnMock.getArguments()[1];
GetResponse response = mock(GetResponse.class);
when(response.isExists()).thenReturn(true);
ActionListener<MultiGetResponse> listener = (ActionListener<MultiGetResponse>) invocationOnMock.getArguments()[1];
MultiGetResponse response = mock(MultiGetResponse.class);
MultiGetItemResponse[] responses = new MultiGetItemResponse[2];
when(response.getResponses()).thenReturn(responses);
final boolean newExpired = randomBoolean();
GetResponse oldGetResponse = mock(GetResponse.class);
when(oldGetResponse.isExists()).thenReturn(newExpired == false);
responses[0] = new MultiGetItemResponse(oldGetResponse, null);
GetResponse getResponse = mock(GetResponse.class);
responses[1] = new MultiGetItemResponse(getResponse, null);
when(getResponse.isExists()).thenReturn(newExpired);
if (newExpired) {
Map<String, Object> source = MapBuilder.<String, Object>newMapBuilder()
.put("access_token", Collections.singletonMap("invalidated", true))
.immutableMap();
when(getResponse.getSource()).thenReturn(source);
}
listener.onResponse(response);
return Void.TYPE;
}).when(client).get(any(GetRequest.class), any(ActionListener.class));
}).when(client).multiGet(any(MultiGetRequest.class), any(ActionListener.class));
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token));
@ -344,7 +409,9 @@ public class TokenServiceTests extends ESTestCase {
ClockMock clock = ClockMock.frozen();
TokenService tokenService = new TokenService(tokenServiceEnabledSettings, clock, client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
requestContext.putHeader("Authorization", "Bearer " + tokenService.getUserTokenString(token));
@ -390,8 +457,8 @@ public class TokenServiceTests extends ESTestCase {
TokenService tokenService = new TokenService(Settings.builder()
.put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), false)
.build(),
systemUTC(), client, lifecycleService, clusterService);
IllegalStateException e = expectThrows(IllegalStateException.class, () -> tokenService.createUserToken(null));
Clock.systemUTC(), client, lifecycleService, clusterService);
IllegalStateException e = expectThrows(IllegalStateException.class, () -> tokenService.createUserToken(null, null, null, null));
assertEquals("tokens are not enabled", e.getMessage());
PlainActionFuture<UserToken> future = new PlainActionFuture<>();
@ -400,7 +467,7 @@ public class TokenServiceTests extends ESTestCase {
e = expectThrows(IllegalStateException.class, () -> {
PlainActionFuture<Boolean> invalidateFuture = new PlainActionFuture<>();
tokenService.invalidateToken(null, invalidateFuture);
tokenService.invalidateAccessToken(null, invalidateFuture);
invalidateFuture.actionGet();
});
assertEquals("tokens are not enabled", e.getMessage());
@ -448,7 +515,9 @@ public class TokenServiceTests extends ESTestCase {
TokenService tokenService =
new TokenService(tokenServiceEnabledSettings, systemUTC(), client, lifecycleService, clusterService);
Authentication authentication = new Authentication(new User("joe", "admin"), new RealmRef("native_realm", "native", "node1"), null);
final UserToken token = tokenService.createUserToken(authentication);
PlainActionFuture<Tuple<UserToken, String>> tokenFuture = new PlainActionFuture<>();
tokenService.createUserToken(authentication, authentication, tokenFuture, Collections.emptyMap());
final UserToken token = tokenFuture.get().v1();
assertNotNull(token);
ThreadContext requestContext = new ThreadContext(Settings.EMPTY);
@ -458,7 +527,7 @@ public class TokenServiceTests extends ESTestCase {
ActionListener<GetResponse> listener = (ActionListener<GetResponse>) invocationOnMock.getArguments()[1];
listener.onFailure(new NoShardAvailableActionException(new ShardId(new Index("foo", "uuid"), 0), "shard oh shard"));
return Void.TYPE;
}).when(client).get(any(GetRequest.class), any(ActionListener.class));
}).when(client).multiGet(any(MultiGetRequest.class), any(ActionListener.class));
when(client.prepareGet(anyString(), anyString(), anyString())).thenReturn(new GetRequestBuilder(client, GetAction.INSTANCE));
try (ThreadContext.StoredContext ignore = requestContext.newStoredContext(true)) {

View File

@ -42,7 +42,7 @@ public class RestGetTokenActionTests extends ESTestCase {
};
CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE);
ActionRequestValidationException ve = new CreateTokenRequest(null, null, null, null).validate();
ActionRequestValidationException ve = new CreateTokenRequest(null, null, null, null, null).validate();
listener.onFailure(ve);
RestResponse response = responseSetOnce.get();
assertNotNull(response);
@ -66,7 +66,7 @@ public class RestGetTokenActionTests extends ESTestCase {
};
CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE);
CreateTokenResponse createTokenResponse =
new CreateTokenResponse(randomAlphaOfLengthBetween(1, 256), TimeValue.timeValueHours(1L), null);
new CreateTokenResponse(randomAlphaOfLengthBetween(1, 256), TimeValue.timeValueHours(1L), null, randomAlphaOfLength(4));
listener.onResponse(createTokenResponse);
RestResponse response = responseSetOnce.get();
@ -78,7 +78,8 @@ public class RestGetTokenActionTests extends ESTestCase {
assertThat(map, hasEntry("type", "Bearer"));
assertThat(map, hasEntry("access_token", createTokenResponse.getTokenString()));
assertThat(map, hasEntry("expires_in", Math.toIntExact(createTokenResponse.getExpiresIn().seconds())));
assertEquals(3, map.size());
assertThat(map, hasEntry("refresh_token", createTokenResponse.getRefreshToken()));
assertEquals(4, map.size());
}
public void testParser() throws Exception {
@ -96,4 +97,21 @@ public class RestGetTokenActionTests extends ESTestCase {
assertTrue(SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING.equals(createTokenRequest.getPassword()));
}
}
public void testParserRefreshRequest() throws Exception {
final String token = randomAlphaOfLengthBetween(4, 32);
final String request = "{" +
"\"grant_type\": \"refresh_token\"," +
"\"refresh_token\": \"" + token + "\"," +
"\"scope\": \"FULL\"" +
"}";
try (XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, request)) {
CreateTokenRequest createTokenRequest = RestGetTokenAction.PARSER.parse(parser, null);
assertEquals("refresh_token", createTokenRequest.getGrantType());
assertEquals(token, createTokenRequest.getRefreshToken());
assertEquals("FULL", createTokenRequest.getScope());
assertNull(createTokenRequest.getUsername());
assertNull(createTokenRequest.getPassword());
}
}
}

View File

@ -14,7 +14,6 @@ dependencies {
testCompile project(path: ':x-pack-elasticsearch:plugin', configuration: 'testArtifacts')
}
Closure waitWithAuth = { NodeInfo node, AntBuilder ant ->
File tmpFile = new File(node.cwd, 'wait.success')
@ -62,11 +61,15 @@ Closure waitWithAuth = { NodeInfo node, AntBuilder ant ->
Project mainProject = project
compileTestJava.options.compilerArgs << "-Xlint:-cast,-deprecation,-rawtypes,-try,-unchecked"
/**
* Subdirectories of this project are test rolling upgrades with various
* configuration options based on their name.
*/
subprojects {
// TODO remove after backport
ext.bwc_tests_enabled = false
Matcher m = project.name =~ /with(out)?-system-key/
if (false == m.matches()) {
throw new InvalidUserDataException("Invalid project name [${project.name}]")
@ -268,6 +271,8 @@ subprojects {
testCompile project(path: ':x-pack-elasticsearch:plugin', configuration: 'testArtifacts')
}
compileTestJava.options.compilerArgs << "-Xlint:-cast,-deprecation,-rawtypes,-try,-unchecked"
// copy x-pack plugin info so it is on the classpath and security manager has the right permissions
task copyXPackRestSpec(type: Copy) {
dependsOn(project.configurations.restSpec, 'processTestResources')

View File

@ -0,0 +1,307 @@
/*
* 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.upgrades;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicHeader;
import org.elasticsearch.Version;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.ResponseException;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.test.SecuritySettingsSource;
import org.elasticsearch.test.rest.ESRestTestCase;
import org.elasticsearch.test.rest.yaml.ObjectPath;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import org.junit.Before;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.SECURITY_TEMPLATE_NAME;
import static org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
public class TokenBackwardsCompatibilityIT extends ESRestTestCase {
private static final String BASIC_AUTH_VALUE =
basicAuthHeaderValue("test_user", SecuritySettingsSource.TEST_PASSWORD_SECURE_STRING);
@Override
protected boolean preserveIndicesUponCompletion() {
return true;
}
@Override
protected boolean preserveReposUponCompletion() {
return true;
}
@Override
protected boolean preserveTemplatesUponCompletion() {
return true;
}
private enum CLUSTER_TYPE {
OLD,
MIXED,
UPGRADED;
public static CLUSTER_TYPE parse(String value) {
switch (value) {
case "old_cluster":
return OLD;
case "mixed_cluster":
return MIXED;
case "upgraded_cluster":
return UPGRADED;
default:
throw new AssertionError("unknown cluster type: " + value);
}
}
}
private final CLUSTER_TYPE clusterType = CLUSTER_TYPE.parse(System.getProperty("tests.rest.suite"));
@Override
protected Settings restClientSettings() {
return Settings.builder()
.put(ThreadContext.PREFIX + ".Authorization", BASIC_AUTH_VALUE)
.build();
}
@Before
public void setupForTests() throws Exception {
final String template = SecurityLifecycleService.SECURITY_TEMPLATE_NAME;
awaitBusy(() -> {
try {
return adminClient().performRequest("HEAD", "_template/" + template).getStatusLine().getStatusCode() == 200;
} catch (IOException e) {
logger.warn("error calling template api", e);
}
return false;
});
}
public void testGeneratingTokenInOldCluster() throws Exception {
assumeTrue("this test should only run against the old cluster", clusterType == CLUSTER_TYPE.OLD);
final StringEntity tokenPostBody = new StringEntity("{\n" +
" \"username\": \"test_user\",\n" +
" \"password\": \"x-pack-test-password\",\n" +
" \"grant_type\": \"password\"\n" +
"}", ContentType.APPLICATION_JSON);
Response response = client().performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenPostBody);
assertOK(response);
Map<String, Object> responseMap = entityAsMap(response);
String token = (String) responseMap.get("access_token");
assertNotNull(token);
assertTokenWorks(token);
StringEntity oldClusterToken = new StringEntity("{\n" +
" \"token\": \"" + token + "\"\n" +
"}", ContentType.APPLICATION_JSON);
Response indexResponse = client().performRequest("PUT", "token_backwards_compatibility_it/doc/old_cluster_token1",
Collections.emptyMap(), oldClusterToken);
assertOK(indexResponse);
response = client().performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenPostBody);
assertOK(response);
responseMap = entityAsMap(response);
token = (String) responseMap.get("access_token");
assertNotNull(token);
assertTokenWorks(token);
oldClusterToken = new StringEntity("{\n" +
" \"token\": \"" + token + "\"\n" +
"}", ContentType.APPLICATION_JSON);
indexResponse = client().performRequest("PUT", "token_backwards_compatibility_it/doc/old_cluster_token2",
Collections.emptyMap(), oldClusterToken);
assertOK(indexResponse);
}
public void testTokenWorksInMixedOrUpgradedCluster() throws Exception {
assumeTrue("this test should only run against the mixed or upgraded cluster",
clusterType == CLUSTER_TYPE.MIXED || clusterType == CLUSTER_TYPE.UPGRADED);
Response getResponse = client().performRequest("GET", "token_backwards_compatibility_it/doc/old_cluster_token1");
assertOK(getResponse);
Map<String, Object> source = (Map<String, Object>) entityAsMap(getResponse).get("_source");
assertTokenWorks((String) source.get("token"));
}
public void testMixedCluster() throws Exception {
assumeTrue("this test should only run against the mixed cluster", clusterType == CLUSTER_TYPE.MIXED);
assumeTrue("the master must be on the latest version before we can write", isMasterOnLatestVersion());
awaitIndexTemplateUpgrade();
Response getResponse = client().performRequest("GET", "token_backwards_compatibility_it/doc/old_cluster_token2");
assertOK(getResponse);
Map<String, Object> source = (Map<String, Object>) entityAsMap(getResponse).get("_source");
final String token = (String) source.get("token");
assertTokenWorks(token);
final StringEntity body = new StringEntity("{\"token\": \"" + token + "\"}", ContentType.APPLICATION_JSON);
Response invalidationResponse = client().performRequest("DELETE", "_xpack/security/oauth2/token", Collections.emptyMap(), body);
assertOK(invalidationResponse);
assertTokenDoesNotWork(token);
// create token and refresh on version that supports it
final StringEntity tokenPostBody = new StringEntity("{\n" +
" \"username\": \"test_user\",\n" +
" \"password\": \"x-pack-test-password\",\n" +
" \"grant_type\": \"password\"\n" +
"}", ContentType.APPLICATION_JSON);
try (RestClient client = getRestClientForCurrentVersionNodesOnly()) {
Response response = client.performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenPostBody);
assertOK(response);
Map<String, Object> responseMap = entityAsMap(response);
String accessToken = (String) responseMap.get("access_token");
String refreshToken = (String) responseMap.get("refresh_token");
assertNotNull(accessToken);
assertNotNull(refreshToken);
assertTokenWorks(accessToken);
final StringEntity tokenRefresh = new StringEntity("{\n" +
" \"refresh_token\": \"" + refreshToken + "\",\n" +
" \"grant_type\": \"refresh_token\"\n" +
"}", ContentType.APPLICATION_JSON);
response = client.performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenRefresh);
assertOK(response);
responseMap = entityAsMap(response);
String updatedAccessToken = (String) responseMap.get("access_token");
String updatedRefreshToken = (String) responseMap.get("refresh_token");
assertNotNull(updatedAccessToken);
assertNotNull(updatedRefreshToken);
assertTokenWorks(updatedAccessToken);
assertTokenWorks(accessToken);
assertNotEquals(accessToken, updatedAccessToken);
assertNotEquals(refreshToken, updatedRefreshToken);
}
}
public void testUpgradedCluster() throws Exception {
assumeTrue("this test should only run against the mixed cluster", clusterType == CLUSTER_TYPE.UPGRADED);
awaitIndexTemplateUpgrade();
Response getResponse = client().performRequest("GET", "token_backwards_compatibility_it/doc/old_cluster_token2");
assertOK(getResponse);
Map<String, Object> source = (Map<String, Object>) entityAsMap(getResponse).get("_source");
final String token = (String) source.get("token");
// invalidate again since this may not have been invalidated in the mixed cluster
final StringEntity body = new StringEntity("{\"token\": \"" + token + "\"}", ContentType.APPLICATION_JSON);
Response invalidationResponse = client().performRequest("DELETE", "_xpack/security/oauth2/token",
Collections.singletonMap("error_trace", "true"), body);
assertOK(invalidationResponse);
assertTokenDoesNotWork(token);
getResponse = client().performRequest("GET", "token_backwards_compatibility_it/doc/old_cluster_token1");
assertOK(getResponse);
source = (Map<String, Object>) entityAsMap(getResponse).get("_source");
final String workingToken = (String) source.get("token");
assertTokenWorks(workingToken);
final StringEntity tokenPostBody = new StringEntity("{\n" +
" \"username\": \"test_user\",\n" +
" \"password\": \"x-pack-test-password\",\n" +
" \"grant_type\": \"password\"\n" +
"}", ContentType.APPLICATION_JSON);
Response response = client().performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenPostBody);
assertOK(response);
Map<String, Object> responseMap = entityAsMap(response);
String accessToken = (String) responseMap.get("access_token");
String refreshToken = (String) responseMap.get("refresh_token");
assertNotNull(accessToken);
assertNotNull(refreshToken);
assertTokenWorks(accessToken);
final StringEntity tokenRefresh = new StringEntity("{\n" +
" \"refresh_token\": \"" + refreshToken + "\",\n" +
" \"grant_type\": \"refresh_token\"\n" +
"}", ContentType.APPLICATION_JSON);
response = client().performRequest("POST", "_xpack/security/oauth2/token", Collections.emptyMap(), tokenRefresh);
assertOK(response);
responseMap = entityAsMap(response);
String updatedAccessToken = (String) responseMap.get("access_token");
String updatedRefreshToken = (String) responseMap.get("refresh_token");
assertNotNull(updatedAccessToken);
assertNotNull(updatedRefreshToken);
assertTokenWorks(updatedAccessToken);
assertTokenWorks(accessToken);
assertNotEquals(accessToken, updatedAccessToken);
assertNotEquals(refreshToken, updatedRefreshToken);
}
private void assertTokenWorks(String token) throws IOException {
Response authenticateResponse = client().performRequest("GET", "_xpack/security/_authenticate", Collections.emptyMap(),
new BasicHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token));
assertOK(authenticateResponse);
assertEquals("test_user", entityAsMap(authenticateResponse).get("username"));
}
private void assertTokenDoesNotWork(String token) {
ResponseException e = expectThrows(ResponseException.class,
() -> client().performRequest("GET", "_xpack/security/_authenticate", Collections.emptyMap(),
new BasicHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token)));
assertEquals(401, e.getResponse().getStatusLine().getStatusCode());
Response response = e.getResponse();
assertEquals("Bearer realm=\"security\", error=\"invalid_token\", error_description=\"The access token expired\"",
response.getHeader("WWW-Authenticate"));
}
private boolean isMasterOnLatestVersion() throws Exception {
Response response = client().performRequest("GET", "_cluster/state");
assertOK(response);
final String masterNodeId = ObjectPath.createFromResponse(response).evaluate("master_node");
response = client().performRequest("GET", "_nodes");
assertOK(response);
ObjectPath objectPath = ObjectPath.createFromResponse(response);
return Version.CURRENT.equals(Version.fromString(objectPath.evaluate("nodes." + masterNodeId + ".version")));
}
private void awaitIndexTemplateUpgrade() throws Exception {
assertTrue(awaitBusy(() -> {
try {
Response response = client().performRequest("GET", "/_cluster/state/metadata");
assertOK(response);
ObjectPath objectPath = ObjectPath.createFromResponse(response);
final String mappingsPath = "metadata.templates." + SECURITY_TEMPLATE_NAME + "" +
".mappings";
Map<String, Object> mappings = objectPath.evaluate(mappingsPath);
assertNotNull(mappings);
assertThat(mappings.size(), greaterThanOrEqualTo(1));
String key = mappings.keySet().iterator().next();
String templateVersion = objectPath.evaluate(mappingsPath + "." + key + "" + "._meta.security-version");
final Version tVersion = Version.fromString(templateVersion);
return Version.CURRENT.equals(tVersion);
} catch (IOException e) {
logger.warn("caught exception checking template version", e);
return false;
}
}));
}
private RestClient getRestClientForCurrentVersionNodesOnly() throws IOException {
Response response = client().performRequest("GET", "_nodes");
assertOK(response);
ObjectPath objectPath = ObjectPath.createFromResponse(response);
Map<String, Object> nodesAsMap = objectPath.evaluate("nodes");
List<HttpHost> hosts = new ArrayList<>();
for (Map.Entry<String, Object> entry : nodesAsMap.entrySet()) {
Map<String, Object> nodeDetails = (Map<String, Object>) entry.getValue();
Version version = Version.fromString((String) nodeDetails.get("version"));
if (Version.CURRENT.equals(version)) {
Map<String, Object> httpInfo = (Map<String, Object>) nodeDetails.get("http");
hosts.add(HttpHost.create((String) httpInfo.get("publish_address")));
}
}
return buildClient(restClientSettings(), hosts.toArray(new HttpHost[0]));
}
}