Skip to content

Commit

Permalink
OnBehalfOf authenticator and token generator
Browse files Browse the repository at this point in the history
Signed-off-by: Ryan Liang <jiallian@amazon.com>
  • Loading branch information
RyanL1997 committed Aug 25, 2023
1 parent ed61646 commit fa0199d
Show file tree
Hide file tree
Showing 29 changed files with 1,949 additions and 70 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.security.http;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.message.BasicHeader;
import org.junit.Assert;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.opensearch.test.framework.OnBehalfOfConfig;
import org.opensearch.test.framework.TestSecurityConfig;
import org.opensearch.test.framework.cluster.ClusterManager;
import org.opensearch.test.framework.cluster.LocalCluster;
import org.opensearch.test.framework.cluster.TestRestClient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasKey;
import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX;
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED;
import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL;
import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS;

@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class)
@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public class OnBehalfOfJwtAuthenticationTest {

public static final String POINTER_USERNAME = "/user_name";

static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS);

private static Boolean oboEnabled = true;
private static final String signingKey = Base64.getEncoder()
.encodeToString(
"jwt signing key for an on behalf of token authentication backend for testing of OBO authentication".getBytes(
StandardCharsets.UTF_8
)
);
private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey".getBytes(StandardCharsets.UTF_8));
public static final String ADMIN_USER_NAME = "admin";
public static final String OBO_USER_NAME_WITH_PERM = "obo_user";
public static final String OBO_USER_NAME_NO_PERM = "obo_user_no_perm";
public static final String DEFAULT_PASSWORD = "secret";
public static final String NEW_PASSWORD = "testPassword123!!";
public static final String OBO_TOKEN_REASON = "{\"reason\":\"Test generation\"}";
public static final String OBO_ENDPOINT_PREFIX = "_plugins/_security/api/generateonbehalfoftoken";
public static final String OBO_DESCRIPTION = "{\"description\":\"Testing\", \"service\":\"self-issued\"}";
public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \""
+ DEFAULT_PASSWORD
+ "\", \"password\": \""
+ NEW_PASSWORD
+ "\" }";

protected final static TestSecurityConfig.User OBO_USER = new TestSecurityConfig.User(OBO_USER_NAME_WITH_PERM).roles(
new TestSecurityConfig.Role("obo_access_role").clusterPermissions("security:obo/create")
);

protected final static TestSecurityConfig.User OBO_USER_NO_PERM = new TestSecurityConfig.User(OBO_USER_NAME_NO_PERM).roles(
new TestSecurityConfig.Role("obo_user_no_perm")
);

@ClassRule
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
.anonymousAuth(false)
.users(ADMIN_USER, OBO_USER, OBO_USER_NO_PERM)
.nodeSettings(
Map.of(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true, SECURITY_RESTAPI_ROLES_ENABLED, List.of("user_admin__all_access"))
)
.authc(AUTHC_HTTPBASIC_INTERNAL)
.onBehalfOf(new OnBehalfOfConfig().oboEnabled(oboEnabled).signingKey(signingKey).encryptionKey(encryptionKey))
.build();

@Test
public void shouldAuthenticateWithOBOTokenEndPoint() {
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
authenticateWithOboToken(adminOboAuthHeader, ADMIN_USER_NAME, 200);
}

@Test
public void shouldNotAuthenticateWithATemperedOBOToken() {
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
oboToken = oboToken.substring(0, oboToken.length() - 1); // tampering the token
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
authenticateWithOboToken(adminOboAuthHeader, ADMIN_USER_NAME, 401);
}

@Test
public void shouldNotAuthenticateForUsingOBOTokenToAccessOBOEndpoint() {
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);

try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) {
TestRestClient.HttpResponse response = client.getOnBehalfOfToken(OBO_DESCRIPTION, adminOboAuthHeader);
response.assertStatusCode(401);
}
}

@Test
public void shouldNotAuthenticateForUsingOBOTokenToAccessAccountEndpoint() {
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);

try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) {
TestRestClient.HttpResponse response = client.changeInternalUserPassword(CURRENT_AND_NEW_PASSWORDS, adminOboAuthHeader);
response.assertStatusCode(401);
}
}

@Test
public void shouldAuthenticateForNonAdminUserWithOBOPermission() {
String oboToken = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD);
Header oboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
authenticateWithOboToken(oboAuthHeader, OBO_USER_NAME_WITH_PERM, 200);
}

@Test
public void shouldNotAuthenticateForNonAdminUserWithoutOBOPermission() {
try (TestRestClient client = cluster.getRestClient(OBO_USER_NO_PERM)) {
assertThat(client.post(OBO_ENDPOINT_PREFIX).getStatusCode(), equalTo(HttpStatus.SC_UNAUTHORIZED));
}
}

private String generateOboToken(String username, String password) {
try (TestRestClient client = cluster.getRestClient(username, password)) {
client.assertCorrectCredentials(username);
TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_TOKEN_REASON);
response.assertStatusCode(200);
Map<String, Object> oboEndPointResponse = response.getBodyAs(Map.class);
assertThat(
oboEndPointResponse,
allOf(aMapWithSize(3), hasKey("user"), hasKey("authenticationToken"), hasKey("durationSeconds"))
);
return oboEndPointResponse.get("authenticationToken").toString();
}
}

private void authenticateWithOboToken(Header authHeader, String expectedUsername, int expectedStatusCode) {
try (TestRestClient client = cluster.getRestClient(authHeader)) {
TestRestClient.HttpResponse response = client.getAuthInfo();
response.assertStatusCode(expectedStatusCode);
if (expectedStatusCode == 200) {
String username = response.getTextFromJsonBody(POINTER_USERNAME);
assertThat(username, equalTo(expectedUsername));
} else {
Assert.assertTrue(response.getBody().contains("Unauthorized"));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
*/
package org.opensearch.test.framework;

import java.io.IOException;

import org.apache.commons.lang3.StringUtils;

import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.core.xcontent.ToXContentObject;
import org.opensearch.core.xcontent.XContentBuilder;

public class OnBehalfOfConfig implements ToXContentObject {
private Boolean oboEnabled;
private String signing_key;
private String encryption_key;

public OnBehalfOfConfig oboEnabled(Boolean oboEnabled) {
this.oboEnabled = oboEnabled;
return this;
}

public OnBehalfOfConfig signingKey(String signing_key) {
this.signing_key = signing_key;
return this;
}

public OnBehalfOfConfig encryptionKey(String encryption_key) {
this.encryption_key = encryption_key;
return this;
}

@Override
public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Params params) throws IOException {
xContentBuilder.startObject();
xContentBuilder.field("enabled", oboEnabled);
xContentBuilder.field("signing_key", signing_key);
if (StringUtils.isNoneBlank(encryption_key)) {
xContentBuilder.field("encryption_key", encryption_key);
}
xContentBuilder.endObject();
return xContentBuilder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ public TestSecurityConfig xff(XffConfig xffConfig) {
return this;
}

public TestSecurityConfig onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) {
config.onBehalfOfConfig(onBehalfOfConfig);
return this;
}

public TestSecurityConfig authc(AuthcDomain authcDomain) {
config.authc(authcDomain);
return this;
Expand Down Expand Up @@ -170,6 +175,7 @@ public static class Config implements ToXContentObject {

private Boolean doNotFailOnForbidden;
private XffConfig xffConfig;
private OnBehalfOfConfig onBehalfOfConfig;
private Map<String, AuthcDomain> authcDomainMap = new LinkedHashMap<>();

private AuthFailureListeners authFailureListeners;
Expand All @@ -190,6 +196,11 @@ public Config xffConfig(XffConfig xffConfig) {
return this;
}

public Config onBehalfOfConfig(OnBehalfOfConfig onBehalfOfConfig) {
this.onBehalfOfConfig = onBehalfOfConfig;
return this;
}

public Config authc(AuthcDomain authcDomain) {
authcDomainMap.put(authcDomain.id, authcDomain);
return this;
Expand All @@ -210,6 +221,10 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params
xContentBuilder.startObject();
xContentBuilder.startObject("dynamic");

if (onBehalfOfConfig != null) {
xContentBuilder.field("on_behalf_of", onBehalfOfConfig);
}

if (anonymousAuth || (xffConfig != null)) {
xContentBuilder.startObject("http");
xContentBuilder.field("anonymous_auth_enabled", anonymousAuth);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import org.opensearch.test.framework.AuditConfiguration;
import org.opensearch.test.framework.AuthFailureListeners;
import org.opensearch.test.framework.AuthzDomain;
import org.opensearch.test.framework.OnBehalfOfConfig;
import org.opensearch.test.framework.RolesMapping;
import org.opensearch.test.framework.TestIndex;
import org.opensearch.test.framework.TestSecurityConfig;
Expand Down Expand Up @@ -471,6 +472,11 @@ public Builder xff(XffConfig xffConfig) {
return this;
}

public Builder onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) {
testSecurityConfig.onBehalfOf(onBehalfOfConfig);
return this;
}

public Builder loadConfigurationIntoIndex(boolean loadConfigurationIntoIndex) {
this.loadConfigurationIntoIndex = loadConfigurationIntoIndex;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ public HttpResponse getAuthInfo(Header... headers) {
return executeRequest(new HttpGet(getHttpServerUri() + "/_opendistro/_security/authinfo?pretty"), headers);
}

public HttpResponse getOnBehalfOfToken(String jsonData, Header... headers) {
try {
HttpPost httpPost = new HttpPost(
new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/generateonbehalfoftoken?pretty").build()
);
httpPost.setEntity(toStringEntity(jsonData));
return executeRequest(httpPost, mergeHeaders(CONTENT_TYPE_JSON, headers));
} catch (URISyntaxException ex) {
throw new RuntimeException("Incorrect URI syntax", ex);
}
}

public HttpResponse changeInternalUserPassword(String jsonData, Header... headers) {
try {
HttpPut httpPut = new HttpPut(new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/account?pretty").build());
httpPut.setEntity(toStringEntity(jsonData));
return executeRequest(httpPut, mergeHeaders(CONTENT_TYPE_JSON, headers));
} catch (URISyntaxException ex) {
throw new RuntimeException("Incorrect URI syntax", ex);
}
}

public void assertCorrectCredentials(String expectedUserName) {
HttpResponse response = getAuthInfo();
assertThat(response, notNullValue());
Expand Down
5 changes: 5 additions & 0 deletions src/integrationTest/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ config:
authentication_backend:
type: "internal"
config: {}
on_behalf_of:
# The decoded signing key is: This is the jwt signing key for an on behalf of token authentication backend for testing of extensions
# The decoded encryption key is: encryptionKey
signing_key: "VGhpcyBpcyB0aGUgand0IHNpZ25pbmcga2V5IGZvciBhbiBvbiBiZWhhbGYgb2YgdG9rZW4gYXV0aGVudGljYXRpb24gYmFja2VuZCBmb3IgdGVzdGluZyBvZiBleHRlbnNpb25z"
encryption_key: "ZW5jcnlwdGlvbktleQ=="
Loading

0 comments on commit fa0199d

Please sign in to comment.