diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateTokenResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateTokenResponse.java index 32d298d1a9bc0..dc71d49f4b770 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateTokenResponse.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateTokenResponse.java @@ -41,13 +41,16 @@ public final class CreateTokenResponse { private final TimeValue expiresIn; private final String scope; private final String refreshToken; + private final String kerberosAuthenticationResponseToken; - public CreateTokenResponse(String accessToken, String type, TimeValue expiresIn, String scope, String refreshToken) { + public CreateTokenResponse(String accessToken, String type, TimeValue expiresIn, String scope, String refreshToken, + String kerberosAuthenticationResponseToken) { this.accessToken = accessToken; this.type = type; this.expiresIn = expiresIn; this.scope = scope; this.refreshToken = refreshToken; + this.kerberosAuthenticationResponseToken = kerberosAuthenticationResponseToken; } public String getAccessToken() { @@ -70,6 +73,10 @@ public String getRefreshToken() { return refreshToken; } + public String getKerberosAuthenticationResponseToken() { + return kerberosAuthenticationResponseToken; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -83,17 +90,18 @@ public boolean equals(Object o) { Objects.equals(type, that.type) && Objects.equals(expiresIn, that.expiresIn) && Objects.equals(scope, that.scope) && - Objects.equals(refreshToken, that.refreshToken); + Objects.equals(refreshToken, that.refreshToken) && + Objects.equals(kerberosAuthenticationResponseToken, that.kerberosAuthenticationResponseToken); } @Override public int hashCode() { - return Objects.hash(accessToken, type, expiresIn, scope, refreshToken); + return Objects.hash(accessToken, type, expiresIn, scope, refreshToken, kerberosAuthenticationResponseToken); } private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( - "create_token_response", true, args -> new CreateTokenResponse( - (String) args[0], (String) args[1], TimeValue.timeValueSeconds((Long) args[2]), (String) args[3], (String) args[4])); + "create_token_response", true, args -> new CreateTokenResponse((String) args[0], (String) args[1], + TimeValue.timeValueSeconds((Long) args[2]), (String) args[3], (String) args[4], (String) args[5])); static { PARSER.declareString(constructorArg(), new ParseField("access_token")); @@ -101,6 +109,7 @@ public int hashCode() { PARSER.declareLong(constructorArg(), new ParseField("expires_in")); PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("scope")); PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("refresh_token")); + PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("kerberos_authentication_response_token")); } public static CreateTokenResponse fromXContent(XContentParser parser) throws IOException { diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateTokenResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateTokenResponseTests.java index f99ea668665dd..34a03647f6060 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateTokenResponseTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateTokenResponseTests.java @@ -37,6 +37,7 @@ public void testFromXContent() throws IOException { final String refreshToken = randomBoolean() ? null : randomAlphaOfLengthBetween(12, 24); final String scope = randomBoolean() ? null : randomAlphaOfLength(4); final String type = randomAlphaOfLength(6); + final String kerberosAuthenticationResponseToken = randomBoolean() ? null : randomAlphaOfLength(7); final XContentType xContentType = randomFrom(XContentType.values()); final XContentBuilder builder = XContentFactory.contentBuilder(xContentType); @@ -50,6 +51,9 @@ public void testFromXContent() throws IOException { if (scope != null || randomBoolean()) { builder.field("scope", scope); } + if (kerberosAuthenticationResponseToken != null) { + builder.field("kerberos_authentication_response_token", kerberosAuthenticationResponseToken); + } builder.endObject(); BytesReference xContent = BytesReference.bytes(builder); @@ -59,5 +63,6 @@ public void testFromXContent() throws IOException { assertThat(response.getScope(), equalTo(scope)); assertThat(response.getType(), equalTo(type)); assertThat(response.getExpiresIn(), equalTo(expiresIn)); + assertThat(response.getKerberosAuthenticationResponseToken(), equalTo(kerberosAuthenticationResponseToken)); } } diff --git a/x-pack/docs/en/rest-api/security/get-tokens.asciidoc b/x-pack/docs/en/rest-api/security/get-tokens.asciidoc index cba3b638bc10c..44450dd43c806 100644 --- a/x-pack/docs/en/rest-api/security/get-tokens.asciidoc +++ b/x-pack/docs/en/rest-api/security/get-tokens.asciidoc @@ -41,14 +41,21 @@ The following parameters can be specified in the body of a POST request and pertain to creating a token: `grant_type`:: -(string) The type of grant. Supported grant types are: `password`, -`client_credentials` and `refresh_token`. +(string) The type of grant. Supported grant types are: `password`, `_kerberos`, +`client_credentials` and `refresh_token`. The `_kerberos` grant type +is supported internally and implements SPNEGO based Kerberos support. The `_kerberos` +grant type may change from version to version. `password`:: (string) The user's password. If you specify the `password` grant type, this parameter is required. This parameter is not valid with any other supported grant type. +`kerberos_ticket`:: +(string) base64 encoded kerberos ticket. If you specify the `_kerberos` grant type, +this parameter is required. This parameter is not valid with any other supported +grant type. + `refresh_token`:: (string) If you specify the `refresh_token` grant type, this parameter is required. It contains the string that was returned when you created the token @@ -160,4 +167,34 @@ be used one time. } -------------------------------------------------- // TESTRESPONSE[s/dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==/$body.access_token/] -// TESTRESPONSE[s/vLBPvmAB6KvwvJZr27cS/$body.refresh_token/] \ No newline at end of file +// TESTRESPONSE[s/vLBPvmAB6KvwvJZr27cS/$body.refresh_token/] + +The following example obtains a access token and refresh token using the `kerberos` grant type, +which simply creates a token in exchange for the base64 encoded kerberos ticket: + +[source,js] +-------------------------------------------------- +POST /_security/oauth2/token +{ + "grant_type" : "_kerberos", + "kerberos_ticket" : "YIIB6wYJKoZIhvcSAQICAQBuggHaMIIB1qADAgEFoQMCAQ6iBtaDcp4cdMODwOsIvmvdX//sye8NDJZ8Gstabor3MOGryBWyaJ1VxI4WBVZaSn1WnzE06Xy2" +} +-------------------------------------------------- +// NOTCONSOLE + +The API will return a new token and refresh token if kerberos authentication is successful. +Each refresh token may only be used one time. When the mutual authentication is requested in the Spnego GSS context, + a base64 encoded token will be returned by the server in the `kerberos_authentication_response_token` + for clients to consume and finalize the authentication. + +[source,js] +-------------------------------------------------- +{ + "access_token" : "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==", + "type" : "Bearer", + "expires_in" : 1200, + "refresh_token": "vLBPvmAB6KvwvJZr27cS" + "kerberos_authentication_response_token": "YIIB6wYJKoZIhvcSAQICAQBuggHaMIIB1qADAg" +} +-------------------------------------------------- +// NOTCONSOLE \ No newline at end of file diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequest.java index edbfce3d17aa6..2467afe84ce05 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequest.java @@ -7,6 +7,7 @@ import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.CharArrays; import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesArray; @@ -14,12 +15,12 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.settings.SecureString; -import org.elasticsearch.common.CharArrays; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; +import java.util.Locale; import java.util.Set; import java.util.stream.Collectors; @@ -34,6 +35,7 @@ public final class CreateTokenRequest extends ActionRequest { public enum GrantType { PASSWORD("password"), + KERBEROS("_kerberos"), REFRESH_TOKEN("refresh_token"), AUTHORIZATION_CODE("authorization_code"), CLIENT_CREDENTIALS("client_credentials"); @@ -61,21 +63,23 @@ public static GrantType fromString(String grantType) { } private static final Set SUPPORTED_GRANT_TYPES = Collections.unmodifiableSet( - EnumSet.of(GrantType.PASSWORD, GrantType.REFRESH_TOKEN, GrantType.CLIENT_CREDENTIALS)); + EnumSet.of(GrantType.PASSWORD, GrantType.KERBEROS, GrantType.REFRESH_TOKEN, GrantType.CLIENT_CREDENTIALS)); private String grantType; private String username; private SecureString password; + private SecureString kerberosTicket; private String scope; private String refreshToken; public CreateTokenRequest() {} - public CreateTokenRequest(String grantType, @Nullable String username, @Nullable SecureString password, @Nullable String scope, - @Nullable String refreshToken) { + public CreateTokenRequest(String grantType, @Nullable String username, @Nullable SecureString password, + @Nullable SecureString kerberosTicket, @Nullable String scope, @Nullable String refreshToken) { this.grantType = grantType; this.username = username; this.password = password; + this.kerberosTicket = kerberosTicket; this.scope = scope; this.refreshToken = refreshToken; } @@ -87,43 +91,28 @@ public ActionRequestValidationException validate() { if (type != null) { switch (type) { case PASSWORD: - 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); - } + validationException = validateUnsupportedField(type, "kerberos_ticket", kerberosTicket, validationException); + validationException = validateUnsupportedField(type, "refresh_token", refreshToken, validationException); + validationException = validateRequiredField("username", username, validationException); + validationException = validateRequiredField("password", password, validationException); + break; + case KERBEROS: + validationException = validateUnsupportedField(type, "username", username, validationException); + validationException = validateUnsupportedField(type, "password", password, validationException); + validationException = validateUnsupportedField(type, "refresh_token", refreshToken, validationException); + validationException = validateRequiredField("kerberos_ticket", kerberosTicket, validationException); break; case REFRESH_TOKEN: - 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); - } + validationException = validateUnsupportedField(type, "username", username, validationException); + validationException = validateUnsupportedField(type, "password", password, validationException); + validationException = validateUnsupportedField(type, "kerberos_ticket", kerberosTicket, validationException); + validationException = validateRequiredField("refresh_token", refreshToken, validationException); break; case CLIENT_CREDENTIALS: - if (username != null) { - validationException = - addValidationError("username is not supported with the client_credentials grant_type", validationException); - } - if (password != null) { - validationException = - addValidationError("password is not supported with the client_credentials grant_type", validationException); - } - if (refreshToken != null) { - validationException = addValidationError("refresh_token is not supported with the client_credentials grant_type", - validationException); - } + validationException = validateUnsupportedField(type, "username", username, validationException); + validationException = validateUnsupportedField(type, "password", password, validationException); + validationException = validateUnsupportedField(type, "kerberos_ticket", kerberosTicket, validationException); + validationException = validateUnsupportedField(type, "refresh_token", refreshToken, validationException); break; default: validationException = addValidationError("grant_type only supports the values: [" + @@ -138,6 +127,32 @@ public ActionRequestValidationException validate() { return validationException; } + private static ActionRequestValidationException validateRequiredField(String field, String fieldValue, + ActionRequestValidationException validationException) { + if (Strings.isNullOrEmpty(fieldValue)) { + validationException = addValidationError(String.format(Locale.ROOT, "%s is missing", field), validationException); + } + return validationException; + } + + private static ActionRequestValidationException validateRequiredField(String field, SecureString fieldValue, + ActionRequestValidationException validationException) { + if (fieldValue == null || fieldValue.getChars() == null || fieldValue.length() == 0) { + validationException = addValidationError(String.format(Locale.ROOT, "%s is missing", field), validationException); + } + return validationException; + } + + private static ActionRequestValidationException validateUnsupportedField(GrantType grantType, String field, Object fieldValue, + ActionRequestValidationException validationException) { + if (fieldValue != null) { + validationException = addValidationError( + String.format(Locale.ROOT, "%s is not supported with the %s grant_type", field, grantType.getValue()), + validationException); + } + return validationException; + } + public void setGrantType(String grantType) { this.grantType = grantType; } @@ -150,6 +165,10 @@ public void setPassword(@Nullable SecureString password) { this.password = password; } + public void setKerberosTicket(@Nullable SecureString kerberosTicket) { + this.kerberosTicket = kerberosTicket; + } + public void setScope(@Nullable String scope) { this.scope = scope; } @@ -172,6 +191,11 @@ public SecureString getPassword() { return password; } + @Nullable + public SecureString getKerberosTicket() { + return kerberosTicket; + } + @Nullable public String getScope() { return scope; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponse.java index f3b094b1fd141..299d66d840c16 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponse.java @@ -26,14 +26,17 @@ public final class CreateTokenResponse extends ActionResponse implements ToXCont private TimeValue expiresIn; private String scope; private String refreshToken; + private String kerberosAuthenticationResponseToken; CreateTokenResponse() {} - public CreateTokenResponse(String tokenString, TimeValue expiresIn, String scope, String refreshToken) { + public CreateTokenResponse(String tokenString, TimeValue expiresIn, String scope, String refreshToken, + String kerberosAuthenticationResponseToken) { this.tokenString = Objects.requireNonNull(tokenString); this.expiresIn = Objects.requireNonNull(expiresIn); this.scope = scope; this.refreshToken = refreshToken; + this.kerberosAuthenticationResponseToken = kerberosAuthenticationResponseToken; } public String getTokenString() { @@ -52,6 +55,10 @@ public String getRefreshToken() { return refreshToken; } + public String getKerberosAuthenticationResponseToken() { + return kerberosAuthenticationResponseToken; + } + @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); @@ -59,6 +66,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeTimeValue(expiresIn); out.writeOptionalString(scope); out.writeOptionalString(refreshToken); + out.writeOptionalString(kerberosAuthenticationResponseToken); } @Override @@ -68,6 +76,7 @@ public void readFrom(StreamInput in) throws IOException { expiresIn = in.readTimeValue(); scope = in.readOptionalString(); refreshToken = in.readOptionalString(); + kerberosAuthenticationResponseToken = in.readOptionalString(); } @Override @@ -83,6 +92,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws if (scope != null) { builder.field("scope", scope); } + if (kerberosAuthenticationResponseToken != null) { + builder.field("kerberos_authentication_response_token", kerberosAuthenticationResponseToken); + } return builder.endObject(); } @@ -94,11 +106,12 @@ public boolean equals(Object o) { return Objects.equals(tokenString, that.tokenString) && Objects.equals(expiresIn, that.expiresIn) && Objects.equals(scope, that.scope) && - Objects.equals(refreshToken, that.refreshToken); + Objects.equals(refreshToken, that.refreshToken) && + Objects.equals(kerberosAuthenticationResponseToken, that.kerberosAuthenticationResponseToken); } @Override public int hashCode() { - return Objects.hash(tokenString, expiresIn, scope, refreshToken); + return Objects.hash(tokenString, expiresIn, scope, refreshToken, kerberosAuthenticationResponseToken); } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequestTests.java index 2d8782f0111e6..d5edbc8f1c34d 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequestTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenRequestTests.java @@ -19,7 +19,7 @@ public void testRequestValidation() { ActionRequestValidationException ve = request.validate(); assertNotNull(ve); assertEquals(1, ve.validationErrors().size()); - assertThat(ve.validationErrors().get(0), containsString("[password, refresh_token, client_credentials]")); + assertThat(ve.validationErrors().get(0), containsString("[password, _kerberos, refresh_token, client_credentials]")); assertThat(ve.validationErrors().get(0), containsString("grant_type")); request.setGrantType("password"); @@ -49,20 +49,24 @@ public void testRequestValidation() { assertNull(ve); request.setRefreshToken(randomAlphaOfLengthBetween(1, 10)); + request.setKerberosTicket(new SecureString(randomAlphaOfLengthBetween(1, 256).toCharArray())); ve = request.validate(); assertNotNull(ve); - assertEquals(1, ve.validationErrors().size()); - assertThat(ve.validationErrors().get(0), containsString("refresh_token is not supported")); + assertEquals(2, ve.validationErrors().size()); + assertThat(ve.validationErrors(), hasItem(containsString("kerberos_ticket is not supported"))); + assertThat(ve.validationErrors(), hasItem(containsString("refresh_token is not supported"))); request.setGrantType("refresh_token"); ve = request.validate(); assertNotNull(ve); - assertEquals(2, ve.validationErrors().size()); + assertEquals(3, ve.validationErrors().size()); assertThat(ve.validationErrors(), hasItem(containsString("username is not supported"))); assertThat(ve.validationErrors(), hasItem(containsString("password is not supported"))); + assertThat(ve.validationErrors(), hasItem(containsString("kerberos_ticket is not supported"))); request.setUsername(null); request.setPassword(null); + request.setKerberosTicket(null); ve = request.validate(); assertNull(ve); @@ -78,12 +82,28 @@ public void testRequestValidation() { request.setUsername(randomAlphaOfLengthBetween(1, 32)); request.setPassword(new SecureString(randomAlphaOfLengthBetween(1, 32).toCharArray())); + request.setKerberosTicket(new SecureString(randomAlphaOfLengthBetween(1, 256).toCharArray())); request.setRefreshToken(randomAlphaOfLengthBetween(1, 32)); ve = request.validate(); assertNotNull(ve); + assertEquals(4, ve.validationErrors().size()); + assertThat(ve.validationErrors(), hasItem(containsString("username is not supported"))); + assertThat(ve.validationErrors(), hasItem(containsString("password is not supported"))); + assertThat(ve.validationErrors(), hasItem(containsString("refresh_token is not supported"))); + assertThat(ve.validationErrors(), hasItem(containsString("kerberos_ticket is not supported"))); + + request.setGrantType("_kerberos"); + ve = request.validate(); + assertNotNull(ve); assertEquals(3, ve.validationErrors().size()); assertThat(ve.validationErrors(), hasItem(containsString("username is not supported"))); assertThat(ve.validationErrors(), hasItem(containsString("password is not supported"))); assertThat(ve.validationErrors(), hasItem(containsString("refresh_token is not supported"))); + + request.setUsername(null); + request.setPassword(null); + request.setRefreshToken(null); + ve = request.validate(); + assertNull(ve); } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponseTests.java index ed73ef0562b43..e89357698c708 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponseTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/token/CreateTokenResponseTests.java @@ -14,7 +14,7 @@ public class CreateTokenResponseTests extends ESTestCase { public void testSerialization() throws Exception { CreateTokenResponse response = new CreateTokenResponse(randomAlphaOfLengthBetween(1, 10), TimeValue.timeValueMinutes(20L), - randomBoolean() ? null : "FULL", randomAlphaOfLengthBetween(1, 10)); + randomBoolean() ? null : "FULL", randomAlphaOfLengthBetween(1, 10), randomBoolean() ? null :randomAlphaOfLengthBetween(1, 10)); try (BytesStreamOutput output = new BytesStreamOutput()) { response.writeTo(output); try (StreamInput input = output.bytes().streamInput()) { @@ -25,7 +25,7 @@ public void testSerialization() throws Exception { } response = new CreateTokenResponse(randomAlphaOfLengthBetween(1, 10), TimeValue.timeValueMinutes(20L), - randomBoolean() ? null : "FULL", null); + randomBoolean() ? null : "FULL", null, null); try (BytesStreamOutput output = new BytesStreamOutput()) { response.writeTo(output); try (StreamInput input = output.bytes().streamInput()) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenAction.java index 65456ccd2af51..252807779a1d6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenAction.java @@ -9,19 +9,25 @@ import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.tasks.Task; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.TransportService; import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction; import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequest; +import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequest.GrantType; import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.security.authc.AuthenticationService; import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken; +import java.util.Base64; import java.util.Collections; +import java.util.List; /** * Transport action responsible for creating a token based on a request. Requests provide user @@ -51,11 +57,12 @@ protected void doExecute(Task task, CreateTokenRequest request, ActionListener listener) { + private void authenticateAndCreateToken(GrantType grantType, CreateTokenRequest request, ActionListener listener) { Authentication originatingAuthentication = Authentication.getAuthentication(threadPool.getThreadContext()); try (ThreadContext.StoredContext ignore = threadPool.getThreadContext().stashContext()) { - final UsernamePasswordToken authToken = new UsernamePasswordToken(request.getUsername(), request.getPassword()); + final AuthenticationToken authToken = extractAuthenticationToken(grantType, request, listener); + if (authToken == null) { + listener.onFailure(new IllegalStateException( + "grant_type [" + request.getGrantType() + "] is not supported by the create token action")); + return; + } + authenticationService.authenticate(CreateTokenAction.NAME, request, authToken, ActionListener.wrap(authentication -> { - request.getPassword().close(); + clearCredentialsFromRequest(grantType, request); + if (authentication != null) { - createToken(request, authentication, originatingAuthentication, true, listener); + createToken(grantType, request, authentication, originatingAuthentication, true, listener); } else { listener.onFailure(new UnsupportedOperationException("cannot create token if authentication is not allowed")); } }, e -> { - // clear the request password - request.getPassword().close(); + clearCredentialsFromRequest(grantType, request); listener.onFailure(e); })); } } - private void createToken(CreateTokenRequest request, Authentication authentication, Authentication originatingAuth, + private AuthenticationToken extractAuthenticationToken(GrantType grantType, CreateTokenRequest request, + ActionListener listener) { + AuthenticationToken authToken = null; + if (grantType == GrantType.PASSWORD) { + authToken = new UsernamePasswordToken(request.getUsername(), request.getPassword()); + } else if (grantType == GrantType.KERBEROS) { + SecureString kerberosTicket = request.getKerberosTicket(); + String base64EncodedToken = kerberosTicket.toString(); + byte[] decodedKerberosTicket = null; + try { + decodedKerberosTicket = Base64.getDecoder().decode(base64EncodedToken); + } catch (IllegalArgumentException iae) { + listener.onFailure(new UnsupportedOperationException("could not decode base64 kerberos ticket " + base64EncodedToken)); + } + authToken = new KerberosAuthenticationToken(decodedKerberosTicket); + } + return authToken; + } + + private void clearCredentialsFromRequest(GrantType grantType, CreateTokenRequest request) { + if (grantType == GrantType.PASSWORD) { + request.getPassword().close(); + } else if (grantType == GrantType.KERBEROS) { + request.getKerberosTicket().close(); + } + } + + private void createToken(GrantType grantType, CreateTokenRequest request, Authentication authentication, Authentication originatingAuth, boolean includeRefreshToken, ActionListener listener) { tokenService.createOAuth2Tokens(authentication, originatingAuth, Collections.emptyMap(), includeRefreshToken, ActionListener.wrap(tuple -> { final String scope = getResponseScopeValue(request.getScope()); + final String base64AuthenticateResponse = (grantType == GrantType.KERBEROS) ? extractOutToken() : null; final CreateTokenResponse response = new CreateTokenResponse(tuple.v1(), tokenService.getExpirationDelay(), scope, - tuple.v2()); + tuple.v2(), base64AuthenticateResponse); listener.onResponse(response); }, listener::onFailure)); } + private String extractOutToken() { + List values = threadPool.getThreadContext().getResponseHeaders().get(KerberosAuthenticationToken.WWW_AUTHENTICATE); + if (values != null && values.size() == 1) { + final String wwwAuthenticateHeaderValue = values.get(0); + // it may contain base64 encoded token that needs to be sent to client if mutual auth was requested + if (wwwAuthenticateHeaderValue.startsWith(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX)) { + final String base64EncodedToken = wwwAuthenticateHeaderValue + .substring(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX.length()).trim(); + return base64EncodedToken; + } + } + threadPool.getThreadContext().getResponseHeaders().remove(KerberosAuthenticationToken.WWW_AUTHENTICATE); + return null; + } + static String getResponseScopeValue(String requestScope) { final String scope; // the OAuth2.0 RFC requires the scope to be provided in the diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportRefreshTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportRefreshTokenAction.java index 5c161d889cfb1..9a99e6020ea36 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportRefreshTokenAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/token/TransportRefreshTokenAction.java @@ -33,7 +33,7 @@ protected void doExecute(Task task, CreateTokenRequest request, ActionListener { final String scope = getResponseScopeValue(request.getScope()); final CreateTokenResponse response = - new CreateTokenResponse(tuple.v1(), tokenService.getExpirationDelay(), scope, tuple.v2()); + new CreateTokenResponse(tuple.v1(), tokenService.getExpirationDelay(), scope, tuple.v2(), null); listener.onResponse(response); }, listener::onFailure)); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenAction.java index 4734c39bc5af5..2c1adae7fb883 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenAction.java @@ -30,9 +30,11 @@ import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequest; import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse; import org.elasticsearch.xpack.core.security.action.token.RefreshTokenAction; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken; import java.io.IOException; import java.util.Arrays; +import java.util.List; import java.util.Locale; import static org.elasticsearch.rest.RestRequest.Method.POST; @@ -48,13 +50,17 @@ public final class RestGetTokenAction extends TokenBaseRestHandler { private static final DeprecationLogger deprecationLogger = new DeprecationLogger(LogManager.getLogger(RestGetTokenAction.class)); static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>("token_request", - a -> new CreateTokenRequest((String) a[0], (String) a[1], (SecureString) a[2], (String) a[3], (String) a[4])); + a -> new CreateTokenRequest((String) a[0], (String) a[1], (SecureString) a[2], (SecureString) a[3], (String) a[4], + (String) a[5])); 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.declareField(ConstructingObjectParser.optionalConstructorArg(), parser -> new SecureString( + Arrays.copyOfRange(parser.textCharacters(), parser.textOffset(), parser.textOffset() + parser.textLength())), + new ParseField("kerberos_ticket"), ValueType.STRING); PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("scope")); PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("refresh_token")); } @@ -124,11 +130,28 @@ public void onFailure(Exception e) { ((ElasticsearchSecurityException) e).getHeader("error_description").size() == 1) { sendTokenErrorResponse(TokenRequestError.INVALID_GRANT, ((ElasticsearchSecurityException) e).getHeader("error_description").get(0), e); + } else if (e instanceof ElasticsearchSecurityException + && "failed to authenticate user, gss context negotiation not complete".equals(e.getMessage())) { + sendTokenErrorResponse(TokenRequestError._UNAUTHORIZED, extractBase64EncodedToken((ElasticsearchSecurityException) e), e); } else { sendFailure(e); } } + private String extractBase64EncodedToken(ElasticsearchSecurityException e) { + String base64EncodedToken = null; + List values = e.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE); + if (values != null && values.size() == 1) { + final String wwwAuthenticateHeaderValue = values.get(0); + // it may contain base64 encoded token that needs to be sent to client if Spnego GSS context negotiation failed + if (wwwAuthenticateHeaderValue.startsWith(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX)) { + base64EncodedToken = wwwAuthenticateHeaderValue + .substring(KerberosAuthenticationToken.NEGOTIATE_AUTH_HEADER_PREFIX.length()).trim(); + } + } + return base64EncodedToken; + } + void sendTokenErrorResponse(TokenRequestError error, String description, Exception e) { try (XContentBuilder builder = channel.newErrorBuilder()) { // defined by https://tools.ietf.org/html/rfc6749#section-5.2 @@ -200,6 +223,17 @@ enum TokenRequestError { * The requested scope is invalid, unknown, malformed, or exceeds the * scope granted by the resource owner. */ - INVALID_SCOPE + INVALID_SCOPE, + + // Custom error code + /** + * When the request for authentication fails using custom grant type for given + * credentials. + * If the client attempted to authenticate via the "Authorization" request + * the authorization server MAY respond with an HTTP 401 + * (Unauthorized) status code and include the "WWW-Authenticate" + * response header field + */ + _UNAUTHORIZED, } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenActionTests.java index c2050c95fc354..b32ceee1a87d9 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/token/TransportCreateTokenActionTests.java @@ -6,6 +6,7 @@ package org.elasticsearch.xpack.security.action.token; +import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.get.GetAction; import org.elasticsearch.action.get.GetRequestBuilder; @@ -31,6 +32,7 @@ import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.node.Node; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.test.ClusterServiceUtils; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.threadpool.TestThreadPool; @@ -41,20 +43,25 @@ import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequest; import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationToken; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.authc.AuthenticationService; import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.junit.After; import org.junit.Before; +import java.nio.charset.StandardCharsets; import java.time.Clock; +import java.util.Base64; import java.util.Collections; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import static org.hamcrest.Matchers.is; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; @@ -128,15 +135,31 @@ public void setupClient() { }).when(securityIndex).prepareIndexIfNeededThenExecute(any(Consumer.class), any(Runnable.class)); doAnswer(invocationOnMock -> { - UsernamePasswordToken token = (UsernamePasswordToken) invocationOnMock.getArguments()[2]; - User user = new User(token.principal()); + AuthenticationToken authToken = (AuthenticationToken) invocationOnMock.getArguments()[2]; + ActionListener authListener = (ActionListener) invocationOnMock.getArguments()[3]; + User user = null; + if (authToken instanceof UsernamePasswordToken) { + UsernamePasswordToken token = (UsernamePasswordToken) invocationOnMock.getArguments()[2]; + user = new User(token.principal()); + } else if (authToken instanceof KerberosAuthenticationToken) { + KerberosAuthenticationToken token = (KerberosAuthenticationToken) invocationOnMock.getArguments()[2]; + if (token.credentials() instanceof byte[] + && new String((byte[]) token.credentials(), StandardCharsets.UTF_8).equals("fail")) { + String errorMessage = "failed to authenticate user, gss context negotiation not complete"; + ElasticsearchSecurityException ese = new ElasticsearchSecurityException(errorMessage, RestStatus.UNAUTHORIZED); + ese.addHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE, "Negotiate FAIL"); + authListener.onFailure(ese); + return Void.TYPE; + } + user = new User(token.principal()); + threadPool.getThreadContext().addResponseHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE, "Negotiate SUCCESS"); + } Authentication authentication = new Authentication(user, new Authentication.RealmRef("fake", "mock", "n1"), null); authentication.writeToContext(threadPool.getThreadContext()); - ActionListener authListener = (ActionListener) invocationOnMock.getArguments()[3]; authListener.onResponse(authentication); return Void.TYPE; }).when(authenticationService).authenticate(eq(CreateTokenAction.NAME), any(CreateTokenRequest.class), - any(UsernamePasswordToken.class), any(ActionListener.class)); + any(AuthenticationToken.class), any(ActionListener.class)); this.clusterService = ClusterServiceUtils.createClusterService(threadPool); @@ -202,4 +225,41 @@ public void testPasswordGrantTypeCreatesWithRefreshToken() throws Exception { assertNotNull(sourceMap.get("access_token")); assertNotNull(sourceMap.get("refresh_token")); } + + public void testKerberosGrantTypeCreatesWithRefreshToken() throws Exception { + final TokenService tokenService = new TokenService(SETTINGS, Clock.systemUTC(), client, license, + securityIndex, securityIndex, clusterService); + Authentication authentication = new Authentication(new User("joe"), new Authentication.RealmRef("realm", "type", "node"), null); + authentication.writeToContext(threadPool.getThreadContext()); + + final TransportCreateTokenAction action = new TransportCreateTokenAction(threadPool, + mock(TransportService.class), new ActionFilters(Collections.emptySet()), tokenService, + authenticationService); + final CreateTokenRequest createTokenRequest = new CreateTokenRequest(); + createTokenRequest.setGrantType("_kerberos"); + String failOrSuccess = randomBoolean() ? "fail" : "success"; + String kerbCredentialsBase64 = Base64.getEncoder().encodeToString(failOrSuccess.getBytes(StandardCharsets.UTF_8)); + createTokenRequest.setKerberosTicket(new SecureString(kerbCredentialsBase64.toCharArray())); + + PlainActionFuture tokenResponseFuture = new PlainActionFuture<>(); + action.doExecute(null, createTokenRequest, tokenResponseFuture); + if (failOrSuccess.equals("fail")) { + ElasticsearchSecurityException ese = expectThrows(ElasticsearchSecurityException.class, () -> tokenResponseFuture.actionGet()); + assertNotNull(ese.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE)); + assertThat(ese.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE).size(), is(1)); + assertThat(ese.getHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE).get(0), is("Negotiate FAIL")); + } else { + CreateTokenResponse createTokenResponse = tokenResponseFuture.get(); + assertNotNull(createTokenResponse.getRefreshToken()); + assertNotNull(createTokenResponse.getTokenString()); + assertNotNull(createTokenResponse.getKerberosAuthenticationResponseToken()); + assertThat(createTokenResponse.getKerberosAuthenticationResponseToken(), is("SUCCESS")); + + assertNotNull(idxReqReference.get()); + Map sourceMap = idxReqReference.get().sourceAsMap(); + assertNotNull(sourceMap); + assertNotNull(sourceMap.get("access_token")); + assertNotNull(sourceMap.get("refresh_token")); + } + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenActionTests.java index 9e99d0cf1563c..a3e875fec2c02 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenActionTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/oauth2/RestGetTokenActionTests.java @@ -6,6 +6,7 @@ package org.elasticsearch.xpack.security.rest.action.oauth2; import org.apache.lucene.util.SetOnce; +import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.xcontent.DeprecationHandler; @@ -23,8 +24,10 @@ import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequest; import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse; import org.elasticsearch.xpack.core.security.support.NoOpLogger; +import org.elasticsearch.xpack.security.authc.kerberos.KerberosAuthenticationToken; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction.CreateTokenResponseActionListener; +import java.util.Locale; import java.util.Map; import static org.hamcrest.Matchers.hasEntry; @@ -43,7 +46,7 @@ public void sendResponse(RestResponse restResponse) { }; CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE); - ActionRequestValidationException ve = new CreateTokenRequest(null, null, null, null, null).validate(); + ActionRequestValidationException ve = new CreateTokenRequest(null, null, null, null, null, null).validate(); listener.onFailure(ve); RestResponse response = responseSetOnce.get(); assertNotNull(response); @@ -67,7 +70,8 @@ public void sendResponse(RestResponse restResponse) { }; CreateTokenResponseActionListener listener = new CreateTokenResponseActionListener(restChannel, restRequest, NoOpLogger.INSTANCE); CreateTokenResponse createTokenResponse = - new CreateTokenResponse(randomAlphaOfLengthBetween(1, 256), TimeValue.timeValueHours(1L), null, randomAlphaOfLength(4)); + new CreateTokenResponse(randomAlphaOfLengthBetween(1, 256), TimeValue.timeValueHours(1L), null, randomAlphaOfLength(4), + randomAlphaOfLength(5)); listener.onResponse(createTokenResponse); RestResponse response = responseSetOnce.get(); @@ -80,7 +84,39 @@ public void sendResponse(RestResponse restResponse) { assertThat(map, hasEntry("access_token", createTokenResponse.getTokenString())); assertThat(map, hasEntry("expires_in", Math.toIntExact(createTokenResponse.getExpiresIn().seconds()))); assertThat(map, hasEntry("refresh_token", createTokenResponse.getRefreshToken())); - assertEquals(4, map.size()); + assertThat(map, hasEntry("kerberos_authentication_response_token", createTokenResponse.getKerberosAuthenticationResponseToken())); + assertEquals(5, map.size()); + } + + public void testSendResponseKerberosError() { + FakeRestRequest restRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).build(); + final SetOnce 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); + String errorMessage = "failed to authenticate user, gss context negotiation not complete"; + ElasticsearchSecurityException ese = new ElasticsearchSecurityException(errorMessage, RestStatus.UNAUTHORIZED); + boolean addBase64EncodedToken = randomBoolean(); + ese.addHeader(KerberosAuthenticationToken.WWW_AUTHENTICATE, "Negotiate" + ((addBase64EncodedToken) ? " FAIL" : "")); + listener.onFailure(ese); + + RestResponse response = responseSetOnce.get(); + assertNotNull(response); + + Map map = XContentHelper.convertToMap(response.content(), false, + XContentType.fromMediaType(response.contentType())).v2(); + assertThat(map, hasEntry("error", RestGetTokenAction.TokenRequestError._UNAUTHORIZED.name().toLowerCase(Locale.ROOT))); + if (addBase64EncodedToken) { + assertThat(map, hasEntry("error_description", "FAIL")); + } else { + assertThat(map, hasEntry("error_description", null)); + } + assertEquals(2, map.size()); + assertEquals(RestStatus.BAD_REQUEST, response.status()); } public void testParser() throws Exception { diff --git a/x-pack/qa/kerberos-tests/build.gradle b/x-pack/qa/kerberos-tests/build.gradle index 11ec73b943b8e..64029c34724af 100644 --- a/x-pack/qa/kerberos-tests/build.gradle +++ b/x-pack/qa/kerberos-tests/build.gradle @@ -28,6 +28,7 @@ testClusters.integTest { setting 'xpack.security.authc.realms.file.file1.order', '0' setting 'xpack.ml.enabled', 'false' setting 'xpack.security.audit.enabled', 'true' + setting 'xpack.security.authc.token.enabled', 'true' // Kerberos realm setting 'xpack.security.authc.realms.kerberos.kerberos.order', '1' setting 'xpack.security.authc.realms.kerberos.kerberos.keytab.path', 'es.keytab' @@ -41,6 +42,7 @@ testClusters.integTest { extraConfigFile "es.keytab", project(':test:fixtures:krb5kdc-fixture').ext.krb5Keytabs("peppa", "HTTP_localhost.keytab") user username: "test_admin", password: "x-pack-test-password" + user username: "test_kibana_user", password: "x-pack-test-password", role: "kibana_system" } String realm = "BUILD.ELASTIC.CO" diff --git a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java index a2aa559295a8c..64e73bd3650b1 100644 --- a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java +++ b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/KerberosAuthenticationIT.java @@ -22,9 +22,9 @@ import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.test.rest.ESRestTestCase; +import org.ietf.jgss.GSSException; import org.junit.Before; -import javax.security.auth.login.LoginContext; import java.io.IOException; import java.net.InetAddress; import java.net.UnknownHostException; @@ -35,11 +35,16 @@ import java.util.List; import java.util.Map; +import javax.security.auth.login.LoginContext; + import static org.elasticsearch.common.xcontent.XContentHelper.convertToMap; import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; /** * Integration test to demonstrate authentication against a real MIT Kerberos @@ -104,6 +109,37 @@ public void testLoginByUsernamePassword() throws IOException, PrivilegedActionEx executeRequestAndVerifyResponse(userPrincipalName, callbackHandler); } + public void testGetOauth2TokenInExchangeForKerberosTickets() throws PrivilegedActionException, GSSException, IOException { + final String userPrincipalName = System.getProperty(TEST_USER_WITH_PWD_KEY); + final String password = System.getProperty(TEST_USER_WITH_PWD_PASSWD_KEY); + final boolean enabledDebugLogs = Boolean.parseBoolean(System.getProperty(ENABLE_KERBEROS_DEBUG_LOGS_KEY)); + final SpnegoHttpClientConfigCallbackHandler callbackHandler = new SpnegoHttpClientConfigCallbackHandler(userPrincipalName, + new SecureString(password.toCharArray()), enabledDebugLogs); + final String host = getClusterHosts().get(0).getHostName(); + final String kerberosTicket = callbackHandler.getBase64EncodedTokenForSpnegoHeader(host); + + final Request request = new Request("POST", "/_security/oauth2/token"); + String json = "{" + + " \"grant_type\" : \"_kerberos\", " + + " \"kerberos_ticket\" : \"" + kerberosTicket + "\"" + + "}"; + request.setJsonEntity(json); + + try (RestClient client = buildClientForUser("test_kibana_user")) { + final Response response = client.performRequest(request); + assertOK(response); + final Map map = parseResponseAsMap(response.getEntity()); + assertThat(map.get("access_token"), notNullValue()); + assertThat(map.get("type"), is("Bearer")); + assertThat(map.get("refresh_token"), notNullValue()); + final Object base64OutToken = map.get("kerberos_authentication_response_token"); + assertThat(base64OutToken, notNullValue()); + final String outToken = callbackHandler.handleResponse((String) base64OutToken); + assertThat(outToken, is(nullValue())); + assertThat(callbackHandler.isEstablished(), is(true)); + } + } + @Override @SuppressForbidden(reason = "SPNEGO relies on hostnames and we need to ensure host isn't a IP address") protected HttpHost buildHttpHost(String host, int port) { @@ -139,6 +175,13 @@ private Map parseResponseAsMap(final HttpEntity entity) throws I return convertToMap(XContentType.JSON.xContent(), entity.getContent(), false); } + private RestClient buildClientForUser(String user) throws IOException { + final String token = basicAuthHeaderValue(user, new SecureString("x-pack-test-password".toCharArray())); + Settings settings = Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build(); + final HttpHost[] hosts = getClusterHosts().toArray(new HttpHost[getClusterHosts().size()]); + return buildClient(settings, hosts); + } + private RestClient buildRestClientForKerberos(final SpnegoHttpClientConfigCallbackHandler callbackHandler) throws IOException { final Settings settings = restAdminSettings(); final HttpHost[] hosts = getClusterHosts().toArray(new HttpHost[getClusterHosts().size()]); diff --git a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java index e5768d8f2e944..4694340047467 100644 --- a/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java +++ b/x-pack/qa/kerberos-tests/src/test/java/org/elasticsearch/xpack/security/authc/kerberos/SpnegoHttpClientConfigCallbackHandler.java @@ -19,6 +19,7 @@ import org.elasticsearch.ExceptionsHelper; import org.elasticsearch.client.RestClientBuilder.HttpClientConfigCallback; import org.elasticsearch.common.settings.SecureString; +import org.ietf.jgss.GSSContext; import org.ietf.jgss.GSSCredential; import org.ietf.jgss.GSSException; import org.ietf.jgss.GSSManager; @@ -28,11 +29,14 @@ import java.io.IOException; import java.security.AccessControlContext; import java.security.AccessController; +import java.security.Principal; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Set; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; @@ -43,6 +47,7 @@ import javax.security.auth.login.AppConfigurationEntry; import javax.security.auth.login.Configuration; import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; /** * This class implements {@link HttpClientConfigCallback} which allows for @@ -72,6 +77,7 @@ private static Oid getSpnegoOid() { private final String keytabPath; private final boolean enableDebugLogs; private LoginContext loginContext; + private GSSContext gssContext; /** * Constructs {@link SpnegoHttpClientConfigCallbackHandler} with given @@ -314,4 +320,74 @@ public AppConfigurationEntry[] getAppConfigurationEntry(final String name) { abstract void addOptions(Map options); } + + /** + * Initiates GSS context establishment and returns the + * base64 encoded token to be sent to server. + * + * @return Base64 encoded token + * @throws PrivilegedActionException when privileged action threw exception + * @throws GSSException when GSS context creation fails + */ + String getBase64EncodedTokenForSpnegoHeader(final String serviceHost) throws PrivilegedActionException, GSSException { + final GSSManager gssManager = GSSManager.getInstance(); + final GSSName gssServicePrincipalName = AccessController + .doPrivileged((PrivilegedExceptionAction) () -> gssManager.createName("HTTP/" + serviceHost, null)); + final GSSName gssUserPrincipalName = gssManager.createName(userPrincipalName, GSSName.NT_USER_NAME); + loginContext = AccessController + .doPrivileged((PrivilegedExceptionAction) () -> loginUsingPassword(userPrincipalName, password)); + final GSSCredential userCreds = doAsWrapper(loginContext.getSubject(), (PrivilegedExceptionAction) () -> gssManager + .createCredential(gssUserPrincipalName, GSSCredential.DEFAULT_LIFETIME, SPNEGO_OID, GSSCredential.INITIATE_ONLY)); + gssContext = gssManager.createContext(gssServicePrincipalName, SPNEGO_OID, userCreds, GSSCredential.DEFAULT_LIFETIME); + gssContext.requestMutualAuth(true); + + final byte[] outToken = doAsWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssContext.initSecContext(new byte[0], 0, 0)); + return Base64.getEncoder().encodeToString(outToken); + } + + private LoginContext loginUsingPassword(final String principal, final SecureString password) throws LoginException { + final Set principals = Collections.singleton(new KerberosPrincipal(principal)); + + final Subject subject = new Subject(false, principals, Collections.emptySet(), Collections.emptySet()); + + final Configuration conf = new PasswordJaasConf(principal, enableDebugLogs); + final CallbackHandler callback = new KrbCallbackHandler(principal, password); + final LoginContext loginContext = new LoginContext(CRED_CONF_NAME, subject, callback, conf); + loginContext.login(); + return loginContext; + } + + /** + * Handles server response and returns new token if any to be sent to server. + * + * @param base64Token inToken received from server passed to initSecContext for + * gss negotiation + * @return Base64 encoded token to be sent to server. May return {@code null} if + * nothing to be sent. + * @throws PrivilegedActionException when privileged action threw exception + */ + String handleResponse(final String base64Token) throws PrivilegedActionException { + if (gssContext.isEstablished()) { + throw new IllegalStateException("GSS Context has already been established"); + } + final byte[] token = Base64.getDecoder().decode(base64Token); + final byte[] outToken = doAsWrapper(loginContext.getSubject(), + (PrivilegedExceptionAction) () -> gssContext.initSecContext(token, 0, token.length)); + if (outToken == null || outToken.length == 0) { + return null; + } + return Base64.getEncoder().encodeToString(outToken); + } + + /** + * @return {@code true} If the gss security context was established + */ + boolean isEstablished() { + return gssContext.isEstablished(); + } + + static T doAsWrapper(final Subject subject, final PrivilegedExceptionAction action) throws PrivilegedActionException { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> Subject.doAs(subject, action)); + } }