Rest endpoints for token based access (elastic/x-pack-elasticsearch#1235)

This commit adds rest endpoints for the creation of a new token and invalidation of an existing
token. This builds upon the functionality that was introduced in elastic/x-pack-elasticsearch#1029.

relates elastic/x-pack-elasticsearch#8

Original commit: elastic/x-pack-elasticsearch@d56611dfa3
This commit is contained in:
Jay Modi 2017-04-27 11:04:31 -04:00 committed by GitHub
parent f7fb02f21f
commit 1d08b4d1fb
10 changed files with 588 additions and 1 deletions

View File

@ -11,6 +11,7 @@ apply plugin: 'elasticsearch.docs-test'
buildRestTests.expectedUnconvertedCandidates = [
'en/ml/getting-started.asciidoc',
'en/rest-api/security/users.asciidoc',
'en/rest-api/security/tokens.asciidoc',
'en/rest-api/watcher/put-watch.asciidoc',
'en/rest-api/ml/post-data.asciidoc',
'en/security/authentication/user-cache.asciidoc',

View File

@ -5,9 +5,11 @@
* <<security-api-clear-cache>>
* <<security-api-users>>
* <<security-api-roles>>
* <<security-api-tokens>>
include::security/authenticate.asciidoc[]
include::security/change-password.asciidoc[]
include::security/clear-cache.asciidoc[]
include::security/users.asciidoc[]
include::security/roles.asciidoc[]
include::security/tokens.asciidoc[]

View File

@ -0,0 +1,91 @@
[[security-api-tokens]]
=== Token Management APIs
The `token` API enables you to create and invalidate bearer tokens for access
without requiring basic authentication. The get token API takes the same
parameters as a typical OAuth 2.0 token API except for the use of a JSON
request body.
[[security-api-get-token]]
To obtain a token, submit a POST request to the `/_xpack/security/oauth2/token`
endpoint.
[source,js]
--------------------------------------------------
POST /_xpack/security/oauth2/token
{
"grant_type" : "password",
"username" : "elastic",
"password" : "changeme"
}
--------------------------------------------------
// CONSOLE
.Token Request Fields
[cols="4,^2,10"]
|=======================
| Name | Required | Description
| `username` | yes | The username that identifies the user.
| `password` | yes | The user's password.
| `grant_type`| yes | The type of grant. Currently only the `password`
grant type is supported.
| `scope` | no | The scope of the token. Currently tokens are only
issued for a scope of `FULL` regardless of the value
sent with the request.
|=======================
A successful call returns a JSON structure that contains the access token, the
amount of time (seconds) that the token expires in, the type, and the scope if
available.
[source,js]
--------------------------------------------------
{
"access_token" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==",
"type" : "Bearer",
"expires_in" : 1200
}
--------------------------------------------------
// TESTRESPONSE[s/dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==/$body.access_token/]
A successful call returns a JSON structure that shows whether the user has been
created or updated.
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
by the value of the `access_token`.
[source,shell]
--------------------------------------------------
curl -H "Authorization: Bearer dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==" http://localhost:9200/_cluster/health
--------------------------------------------------
[[security-api-invalidate-token]]
The tokens returned from this API have a finite period of time for which they
are valid and after that time period, they can no longer be used. However, if
a token must be invalidated immediately, you can do so by submitting a DELETE
request to `/_xpack/security/oauth2/token`.
[source,js]
--------------------------------------------------
DELETE /_xpack/security/oauth2/token
{
"token" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ=="
}
--------------------------------------------------
// CONSOLE
// TEST[s/dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==/$body.access_token/]
// TEST[continued]
A successful call returns a JSON structure that indicates whether the token
has already been invalidated.
[source,js]
--------------------------------------------------
{
"created" : true <1>
}
--------------------------------------------------
// TESTRESPONSE
<1> When a token has already been invalidated, `created` is set to false.

View File

@ -123,6 +123,8 @@ import org.elasticsearch.xpack.security.bootstrap.DefaultPasswordBootstrapCheck;
import org.elasticsearch.xpack.security.crypto.CryptoService;
import org.elasticsearch.xpack.security.rest.SecurityRestFilter;
import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction;
import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction;
import org.elasticsearch.xpack.security.rest.action.realm.RestClearRealmCacheAction;
import org.elasticsearch.xpack.security.rest.action.role.RestClearRolesCacheAction;
import org.elasticsearch.xpack.security.rest.action.role.RestDeleteRoleAction;
@ -577,7 +579,9 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin {
new RestDeleteRoleAction(settings, restController),
new RestChangePasswordAction(settings, restController, securityContext.get()),
new RestSetEnabledAction(settings, restController),
new RestHasPrivilegesAction(settings, restController, securityContext.get()));
new RestHasPrivilegesAction(settings, restController, securityContext.get()),
new RestGetTokenAction(settings, licenseState, restController),
new RestInvalidateTokenAction(settings, licenseState, restController));
}
@Override

View File

@ -0,0 +1,194 @@
/*
* 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.rest.action.oauth2;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.client.node.NodeClient;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.ObjectParser.ValueType;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.license.LicenseUtils;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.rest.BaseRestHandler;
import org.elasticsearch.rest.BytesRestResponse;
import org.elasticsearch.rest.RestChannel;
import org.elasticsearch.rest.RestController;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.xpack.XPackPlugin;
import org.elasticsearch.xpack.security.action.token.CreateTokenAction;
import org.elasticsearch.xpack.security.action.token.CreateTokenRequest;
import org.elasticsearch.xpack.security.action.token.CreateTokenResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.Locale;
import static org.elasticsearch.rest.RestRequest.Method.POST;
/**
* An implementation of a OAuth2-esque API for retrieval of an access token.
* This API does not conform to the RFC completely as it uses XContent for the request body
* instead for form encoded data. This is a relatively common modification of the OAuth2
* specification as this aspect does not make the most sense since the response body is
* expected to be JSON
*/
public final class RestGetTokenAction extends BaseRestHandler {
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]));
static {
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("grant_type"));
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("username"));
PARSER.declareField(ConstructingObjectParser.optionalConstructorArg(), parser -> new SecureString(
Arrays.copyOfRange(parser.textCharacters(), parser.textOffset(), parser.textOffset() + parser.textLength())),
new ParseField("password"), ValueType.STRING);
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("scope"));
}
private final XPackLicenseState licenseState;
public RestGetTokenAction(Settings settings, XPackLicenseState xPackLicenseState, RestController controller) {
super(settings);
this.licenseState = xPackLicenseState;
controller.registerHandler(POST, "/_xpack/security/oauth2/token", this);
}
@Override
protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client)throws IOException {
// this API shouldn't be available if security is disabled by license
if (licenseState.isAuthAllowed() == false) {
return channel ->
channel.sendResponse(new BytesRestResponse(channel, LicenseUtils.newComplianceException(XPackPlugin.SECURITY)));
}
try (XContentParser parser = request.contentParser()) {
final CreateTokenRequest tokenRequest = PARSER.parse(parser, null);
return channel -> client.execute(CreateTokenAction.INSTANCE, tokenRequest,
// this doesn't use the RestBuilderListener since we need to override the
// handling of failures in some cases.
new CreateTokenResponseActionListener(channel, request, logger));
}
}
static class CreateTokenResponseActionListener implements ActionListener<CreateTokenResponse> {
private final RestChannel channel;
private final RestRequest request;
private final Logger logger;
CreateTokenResponseActionListener(RestChannel restChannel, RestRequest restRequest,
Logger logger) {
this.channel = restChannel;
this.request = restRequest;
this.logger = logger;
}
@Override
public void onResponse(CreateTokenResponse createTokenResponse) {
try (XContentBuilder builder = channel.newBuilder()) {
channel.sendResponse(new BytesRestResponse(RestStatus.OK, createTokenResponse.toXContent(builder, request)));
} catch (IOException e) {
onFailure(e);
}
}
@Override
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);
}
} else {
sendFailure(e);
}
}
void sendFailure(Exception e) {
try {
channel.sendResponse(new BytesRestResponse(channel, e));
} catch (Exception inner) {
inner.addSuppressed(e);
logger.error("failed to send failure response", inner);
}
}
}
// defined by https://tools.ietf.org/html/rfc6749#section-5.2
enum TokenRequestError {
/**
* The request is missing a required parameter, includes an unsupported
* parameter value (other than grant type), repeats a parameter,
* includes multiple credentials, utilizes more than one mechanism for
* authenticating the client, or is otherwise malformed.
*/
INVALID_REQUEST,
/**
* Client authentication failed (e.g., unknown client, no client
* authentication included, or unsupported authentication method). The
* authorization server MAY return an HTTP 401 (Unauthorized) status
* code to indicate which HTTP authentication schemes are supported. If
* the client attempted to authenticate via the "Authorization" request
* header field, the authorization server MUST respond with an HTTP 401
* (Unauthorized) status code and include the "WWW-Authenticate"
* response header field matching the authentication scheme used by the
* client.
*/
INVALID_CLIENT,
/**
* The provided authorization grant (e.g., authorization code, resource
* owner credentials) or refresh token is invalid, expired, revoked,
* does not match the redirection URI used in the authorization request,
* or was issued to another client.
*/
INVALID_GRANT,
/**
* The authenticated client is not authorized to use this authorization
* grant type.
*/
UNAUTHORIZED_CLIENT,
/**
* The authorization grant type is not supported by the authorization
* server.
*/
UNSUPPORTED_GRANT_TYPE,
/**
* The requested scope is invalid, unknown, malformed, or exceeds the
* scope granted by the resource owner.
*/
INVALID_SCOPE
}
}

View File

@ -0,0 +1,76 @@
/*
* 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.rest.action.oauth2;
import org.elasticsearch.client.node.NodeClient;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.license.LicenseUtils;
import org.elasticsearch.license.XPackLicenseState;
import org.elasticsearch.rest.BaseRestHandler;
import org.elasticsearch.rest.BytesRestResponse;
import org.elasticsearch.rest.RestController;
import org.elasticsearch.rest.RestRequest;
import org.elasticsearch.rest.RestResponse;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.action.RestBuilderListener;
import org.elasticsearch.xpack.XPackPlugin;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenAction;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenRequest;
import org.elasticsearch.xpack.security.action.token.InvalidateTokenResponse;
import java.io.IOException;
import static org.elasticsearch.rest.RestRequest.Method.DELETE;
/**
* Rest handler for handling access token invalidation requests
*/
public final class RestInvalidateTokenAction extends BaseRestHandler {
static final ConstructingObjectParser<String, Void> PARSER =
new ConstructingObjectParser<>("invalidate_token", a -> ((String) a[0]));
static {
PARSER.declareString(ConstructingObjectParser.constructorArg(), new ParseField("token"));
}
private final XPackLicenseState licenseState;
public RestInvalidateTokenAction(Settings settings, XPackLicenseState xPackLicenseState,
RestController controller) {
super(settings);
this.licenseState = xPackLicenseState;
controller.registerHandler(DELETE, "/_xpack/security/oauth2/token", this);
}
@Override
protected RestChannelConsumer prepareRequest(RestRequest request,
NodeClient client)throws IOException {
// this API shouldn't be available if security is disabled by license
if (licenseState.isAuthAllowed() == false) {
return channel ->
channel.sendResponse(new BytesRestResponse(channel, LicenseUtils.newComplianceException(XPackPlugin.SECURITY)));
}
try (XContentParser parser = request.contentParser()) {
final String token = PARSER.parse(parser, null);
final InvalidateTokenRequest tokenRequest = new InvalidateTokenRequest(token);
return channel -> client.execute(InvalidateTokenAction.INSTANCE, tokenRequest,
new RestBuilderListener<InvalidateTokenResponse>(channel) {
@Override
public RestResponse buildResponse(InvalidateTokenResponse invalidateResp,
XContentBuilder builder) throws Exception {
return new BytesRestResponse(RestStatus.OK, builder.startObject()
.field("created", invalidateResp.isCreated())
.endObject());
}
});
}
}
}

View File

@ -0,0 +1,99 @@
/*
* 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.rest.action.oauth2;
import org.apache.lucene.util.SetOnce;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.rest.AbstractRestChannel;
import org.elasticsearch.rest.RestChannel;
import org.elasticsearch.rest.RestResponse;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.rest.FakeRestRequest;
import org.elasticsearch.xpack.security.action.token.CreateTokenRequest;
import org.elasticsearch.xpack.security.action.token.CreateTokenResponse;
import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction.CreateTokenResponseActionListener;
import org.elasticsearch.xpack.security.support.NoOpLogger;
import java.util.Map;
import static org.hamcrest.Matchers.hasEntry;
public class RestGetTokenActionTests extends ESTestCase {
public void testListenerHandlesExceptionProperly() {
FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY)
.build();
final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
@Override
public void sendResponse(RestResponse restResponse) {
responseSetOnce.set(restResponse);
}
};
CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE);
ActionRequestValidationException ve = new CreateTokenRequest(null, null, null, null).validate();
listener.onFailure(ve);
RestResponse response = responseSetOnce.get();
assertNotNull(response);
Map<String, Object> map = XContentHelper.convertToMap(response.content(), false,
XContentType.fromMediaType(response.contentType())).v2();
assertThat(map, hasEntry("error", "unsupported_grant_type"));
assertThat(map, hasEntry("error_description", ve.getMessage()));
assertEquals(2, map.size());
assertEquals(RestStatus.BAD_REQUEST, response.status());
}
public void testSendResponse() {
FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).build();
final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
@Override
public void sendResponse(RestResponse restResponse) {
responseSetOnce.set(restResponse);
}
};
CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE);
CreateTokenResponse createTokenResponse =
new CreateTokenResponse(randomAlphaOfLengthBetween(1, 256), TimeValue.timeValueHours(1L), null);
listener.onResponse(createTokenResponse);
RestResponse response = responseSetOnce.get();
assertNotNull(response);
Map<String, Object> map = XContentHelper.convertToMap(response.content(), false,
XContentType.fromMediaType(response.contentType())).v2();
assertEquals(RestStatus.OK, response.status());
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());
}
public void testParser() throws Exception {
final String request = "{" +
"\"grant_type\": \"password\"," +
"\"username\": \"user1\"," +
"\"password\": \"changeme\"," +
"\"scope\": \"FULL\"" +
"}";
try (XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, request)) {
CreateTokenRequest createTokenRequest = RestGetTokenAction.PARSER.parse(parser, null);
assertEquals("password", createTokenRequest.getGrantType());
assertEquals("user1", createTokenRequest.getUsername());
assertEquals("FULL", createTokenRequest.getScope());
assertTrue(new SecureString("changeme".toCharArray()).equals(createTokenRequest.getPassword()));
}
}
}

View File

@ -0,0 +1,16 @@
{
"xpack.security.get_token": {
"documentation": "https://www.elastic.co/guide/en/x-pack/master/security-api-tokens.html#security-api-get-token",
"methods": [ "POST" ],
"url": {
"path": "/_xpack/security/oauth2/token",
"paths": [ "/_xpack/security/oauth2/token" ],
"parts": {},
"params": {}
},
"body": {
"description" : "The token request to get",
"required" : true
}
}
}

View File

@ -0,0 +1,16 @@
{
"xpack.security.invalidate_token": {
"documentation": "https://www.elastic.co/guide/en/x-pack/master/security-api-tokens.html#security-api-invalidate-token",
"methods": [ "DELETE" ],
"url": {
"path": "/_xpack/security/oauth2/token",
"paths": [ "/_xpack/security/oauth2/token" ],
"parts": {},
"params": {}
},
"body": {
"description" : "The token to invalidate",
"required" : true
}
}
}

View File

@ -0,0 +1,88 @@
---
setup:
- skip:
features: headers
- do:
cluster.health:
wait_for_status: yellow
- do:
xpack.security.put_user:
username: "token_user"
body: >
{
"password" : "changeme",
"roles" : [ "superuser" ],
"full_name" : "Token User"
}
---
teardown:
- do:
xpack.security.delete_user:
username: "token_user"
ignore: 404
---
"Test get and use token":
- do:
xpack.security.get_token:
body:
grant_type: "password"
username: "token_user"
password: "changeme"
- match: { type: "Bearer" }
- is_true: access_token
- set: { access_token: token }
- match: { expires_in: 1200 }
- is_false: scope
- do:
headers:
Authorization: Bearer ${token}
xpack.security.authenticate: {}
- match: { username: "token_user" }
- match: { roles.0: "superuser" }
- match: { full_name: "Token User" }
---
"Test invalidate token":
- do:
xpack.security.get_token:
body:
grant_type: "password"
username: "token_user"
password: "changeme"
- match: { type: "Bearer" }
- is_true: access_token
- set: { access_token: token }
- match: { expires_in: 1200 }
- is_false: scope
- do:
headers:
Authorization: Bearer ${token}
xpack.security.authenticate: {}
- match: { username: "token_user" }
- match: { roles.0: "superuser" }
- match: { full_name: "Token User" }
- do:
xpack.security.invalidate_token:
body:
token: $token
- match: { created: true }
- do:
catch: unauthorized
headers:
Authorization: Bearer ${token}
xpack.security.authenticate: {}