Skip to content

Commit

Permalink
PKI realm authentication delegation (#45906)
Browse files Browse the repository at this point in the history
This commit introduces PKI realm delegation. This feature
supports the PKI authentication feature in Kibana.

In essence, this creates a new API endpoint which Kibana must
call to authenticate clients that use certificates in their TLS
connection to Kibana. The API call passes to Elasticsearch the client's
certificate chain. The response contains an access token to be further
used to authenticate as the client. The client's certificates are validated
by the PKI realms that have been explicitly configured to permit
certificates from the proxy (Kibana). The user calling the delegation
API must have the delegate_pki privilege.

Closes #34396
  • Loading branch information
albertzaharovits committed Aug 27, 2019
1 parent b249e25 commit 1ebee5b
Show file tree
Hide file tree
Showing 58 changed files with 2,875 additions and 66 deletions.
9 changes: 9 additions & 0 deletions client/rest-high-level/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ processTestResources {
from({ zipTree(configurations.restSpec.singleFile) }) {
include 'rest-api-spec/api/**'
}
from(project(':client:rest-high-level').file('src/test/resources'))
}

dependencyLicenses {
Expand All @@ -96,6 +97,7 @@ forbiddenApisMain {
}
File nodeCert = file("./testnode.crt")
File nodeTrustStore = file("./testnode.jks")
File pkiTrustCert = file("./src/test/resources/org/elasticsearch/client/security/delegate_pki/testRootCA.crt")

integTest.runner {
systemProperty 'tests.rest.cluster.username', System.getProperty('tests.rest.cluster.username', 'test_user')
Expand All @@ -113,11 +115,18 @@ testClusters.integTest {
// Truststore settings are not used since TLS is not enabled. Included for testing the get certificates API
setting 'xpack.security.http.ssl.certificate_authorities', 'testnode.crt'
setting 'xpack.security.transport.ssl.truststore.path', 'testnode.jks'
setting 'xpack.security.authc.realms.file.default_file.order', '0'
setting 'xpack.security.authc.realms.native.default_native.order', '1'
setting 'xpack.security.authc.realms.pki.pki1.order', '2'
setting 'xpack.security.authc.realms.pki.pki1.certificate_authorities', '[ "testRootCA.crt" ]'
setting 'xpack.security.authc.realms.pki.pki1.delegation.enabled', 'true'

setting 'indices.lifecycle.poll_interval', '1000ms'
keystore 'xpack.security.transport.ssl.truststore.secure_password', 'testnode'
user username: System.getProperty('tests.rest.cluster.username', 'test_user'),
password: System.getProperty('tests.rest.cluster.password', 'test-password')

extraConfigFile nodeCert.name, nodeCert
extraConfigFile nodeTrustStore.name, nodeTrustStore
extraConfigFile pkiTrustCert.name, pkiTrustCert
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import org.elasticsearch.client.security.CreateApiKeyResponse;
import org.elasticsearch.client.security.CreateTokenRequest;
import org.elasticsearch.client.security.CreateTokenResponse;
import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest;
import org.elasticsearch.client.security.DelegatePkiAuthenticationResponse;
import org.elasticsearch.client.security.DeletePrivilegesRequest;
import org.elasticsearch.client.security.DeletePrivilegesResponse;
import org.elasticsearch.client.security.DeleteRoleMappingRequest;
Expand Down Expand Up @@ -969,4 +971,38 @@ public void invalidateApiKeyAsync(final InvalidateApiKeyRequest request, final R
restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::invalidateApiKey, options,
InvalidateApiKeyResponse::fromXContent, listener, emptySet());
}

/**
* Get an Elasticsearch access token from an {@code X509Certificate} chain. The certificate chain is that of the client from a mutually
* authenticated TLS session, and it is validated by the PKI realms with {@code delegation.enabled} toggled to {@code true}.<br>
* See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delegate-pki-authentication.html"> the
* docs</a> for more details.
*
* @param request the request containing the certificate chain
* @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
* @return the response from the delegate-pki-authentication API key call
* @throws IOException in case there is a problem sending the request or parsing back the response
*/
public DelegatePkiAuthenticationResponse delegatePkiAuthentication(DelegatePkiAuthenticationRequest request, RequestOptions options)
throws IOException {
return restHighLevelClient.performRequestAndParseEntity(request, SecurityRequestConverters::delegatePkiAuthentication, options,
DelegatePkiAuthenticationResponse::fromXContent, emptySet());
}

/**
* Asynchronously get an Elasticsearch access token from an {@code X509Certificate} chain. The certificate chain is that of the client
* from a mutually authenticated TLS session, and it is validated by the PKI realms with {@code delegation.enabled} toggled to
* {@code true}.<br>
* See <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delegate-pki-authentication.html"> the
* docs</a> for more details.
*
* @param request the request containing the certificate chain
* @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
* @param listener the listener to be notified upon request completion
*/
public void delegatePkiAuthenticationAsync(DelegatePkiAuthenticationRequest request, RequestOptions options,
ActionListener<DelegatePkiAuthenticationResponse> listener) {
restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::delegatePkiAuthentication, options,
DelegatePkiAuthenticationResponse::fromXContent, listener, emptySet());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.elasticsearch.client.security.ClearRolesCacheRequest;
import org.elasticsearch.client.security.CreateApiKeyRequest;
import org.elasticsearch.client.security.CreateTokenRequest;
import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest;
import org.elasticsearch.client.security.DeletePrivilegesRequest;
import org.elasticsearch.client.security.DeleteRoleMappingRequest;
import org.elasticsearch.client.security.DeleteRoleRequest;
Expand Down Expand Up @@ -221,6 +222,12 @@ static Request createToken(CreateTokenRequest createTokenRequest) throws IOExcep
return request;
}

static Request delegatePkiAuthentication(DelegatePkiAuthenticationRequest delegatePkiAuthenticationRequest) throws IOException {
Request request = new Request(HttpPost.METHOD_NAME, "/_security/delegate_pki");
request.setEntity(createEntity(delegatePkiAuthenticationRequest, REQUEST_BODY_CONTENT_TYPE));
return request;
}

static Request invalidateToken(InvalidateTokenRequest invalidateTokenRequest) throws IOException {
Request request = new Request(HttpDelete.METHOD_NAME, "/_security/oauth2/token");
request.setEntity(createEntity(invalidateTokenRequest, REQUEST_BODY_CONTENT_TYPE));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.elasticsearch.client.security;

import org.elasticsearch.client.Validatable;
import org.elasticsearch.client.ValidationException;
import org.elasticsearch.common.xcontent.ToXContentObject;
import org.elasticsearch.common.xcontent.XContentBuilder;

import java.io.IOException;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static java.util.Collections.unmodifiableList;

public final class DelegatePkiAuthenticationRequest implements Validatable, ToXContentObject {

private final List<X509Certificate> x509CertificateChain;

public DelegatePkiAuthenticationRequest(final List<X509Certificate> x509CertificateChain) {
if (x509CertificateChain == null || x509CertificateChain.isEmpty()) {
throw new IllegalArgumentException("certificate chain must not be empty or null");
}
this.x509CertificateChain = unmodifiableList(x509CertificateChain);
}

@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject().startArray("x509_certificate_chain");
try {
for (X509Certificate cert : x509CertificateChain) {
builder.value(Base64.getEncoder().encodeToString(cert.getEncoded()));
}
} catch (CertificateEncodingException e) {
throw new IOException(e);
}
return builder.endArray().endObject();
}

public List<X509Certificate> getCertificateChain() {
return this.x509CertificateChain;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final DelegatePkiAuthenticationRequest that = (DelegatePkiAuthenticationRequest) o;
return Objects.equals(x509CertificateChain, that.x509CertificateChain);
}

@Override
public int hashCode() {
return Objects.hash(x509CertificateChain);
}

@Override
public Optional<ValidationException> validate() {
ValidationException validationException = new ValidationException();
if (false == isOrderedCertificateChain(x509CertificateChain)) {
validationException.addValidationError("certificates chain must be an ordered chain");
}
return validationException.validationErrors().isEmpty() ? Optional.empty() : Optional.of(validationException);
}

/**
* Checks that the {@code X509Certificate} list is ordered, such that the end-entity certificate is first and it is followed by any
* certificate authorities'. The check validates that the {@code issuer} of every certificate is the {@code subject} of the certificate
* in the next array position. No other certificate attributes are checked.
*/
private static boolean isOrderedCertificateChain(List<X509Certificate> chain) {
for (int i = 1; i < chain.size(); i++) {
X509Certificate cert = chain.get(i - 1);
X509Certificate issuer = chain.get(i);
if (false == cert.getIssuerX500Principal().equals(issuer.getSubjectX500Principal())) {
return false;
}
}
return true;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.elasticsearch.client.security;

import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.unit.TimeValue;
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
import org.elasticsearch.common.xcontent.XContentParser;

import java.io.IOException;
import java.util.Objects;

import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;

public final class DelegatePkiAuthenticationResponse {

private final String accessToken;
private final String type;
private final TimeValue expiresIn;

public DelegatePkiAuthenticationResponse(String accessToken, String type, TimeValue expiresIn) {
this.accessToken = accessToken;
this.type = type;
this.expiresIn = expiresIn;
}

public String getAccessToken() {
return accessToken;
}

public String getType() {
return type;
}

public TimeValue getExpiresIn() {
return expiresIn;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final DelegatePkiAuthenticationResponse that = (DelegatePkiAuthenticationResponse) o;
return Objects.equals(accessToken, that.accessToken) &&
Objects.equals(type, that.type) &&
Objects.equals(expiresIn, that.expiresIn);
}

@Override
public int hashCode() {
return Objects.hash(accessToken, type, expiresIn);
}

private static final ConstructingObjectParser<DelegatePkiAuthenticationResponse, Void> PARSER = new ConstructingObjectParser<>(
"delegate_pki_response", true,
args -> new DelegatePkiAuthenticationResponse((String) args[0], (String) args[1], TimeValue.timeValueSeconds((Long) args[2])));

static {
PARSER.declareString(constructorArg(), new ParseField("access_token"));
PARSER.declareString(constructorArg(), new ParseField("type"));
PARSER.declareLong(constructorArg(), new ParseField("expires_in"));
}

public static DelegatePkiAuthenticationResponse fromXContent(XContentParser parser) throws IOException {
return PARSER.parse(parser, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.elasticsearch.client.security.ChangePasswordRequest;
import org.elasticsearch.client.security.CreateApiKeyRequest;
import org.elasticsearch.client.security.CreateTokenRequest;
import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest;
import org.elasticsearch.client.security.DeletePrivilegesRequest;
import org.elasticsearch.client.security.DeleteRoleMappingRequest;
import org.elasticsearch.client.security.DeleteRoleRequest;
Expand Down Expand Up @@ -59,6 +60,7 @@
import org.elasticsearch.test.ESTestCase;

import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand All @@ -68,6 +70,8 @@

import static org.elasticsearch.client.RequestConvertersTests.assertToXContentBody;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class SecurityRequestConvertersTests extends ESTestCase {

Expand Down Expand Up @@ -305,6 +309,18 @@ public void testCreateTokenWithClientCredentialsGrant() throws Exception {
assertToXContentBody(createTokenRequest, request.getEntity());
}

public void testDelegatePkiAuthentication() throws Exception {
X509Certificate mockCertificate = mock(X509Certificate.class);
when(mockCertificate.getEncoded()).thenReturn(new byte[0]);
DelegatePkiAuthenticationRequest delegatePkiAuthenticationRequest = new DelegatePkiAuthenticationRequest(
Arrays.asList(mockCertificate));
Request request = SecurityRequestConverters.delegatePkiAuthentication(delegatePkiAuthenticationRequest);
assertEquals(HttpPost.METHOD_NAME, request.getMethod());
assertEquals("/_security/delegate_pki", request.getEndpoint());
assertEquals(0, request.getParameters().size());
assertToXContentBody(delegatePkiAuthenticationRequest, request.getEntity());
}

public void testGetApplicationPrivilege() throws Exception {
final String application = randomAlphaOfLength(6);
final String privilege = randomAlphaOfLength(4);
Expand Down
Loading

0 comments on commit 1ebee5b

Please sign in to comment.