diff --git a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java new file mode 100644 index 0000000000..4fc5b059ea --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -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 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")); + } + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/OnBehalfOfConfig.java b/src/integrationTest/java/org/opensearch/test/framework/OnBehalfOfConfig.java new file mode 100644 index 0000000000..63e1544f98 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/OnBehalfOfConfig.java @@ -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; + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index 7b19d4f7f0..2fd3fc474d 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -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; @@ -170,6 +175,7 @@ public static class Config implements ToXContentObject { private Boolean doNotFailOnForbidden; private XffConfig xffConfig; + private OnBehalfOfConfig onBehalfOfConfig; private Map authcDomainMap = new LinkedHashMap<>(); private AuthFailureListeners authFailureListeners; @@ -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; @@ -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); diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java index 539e15fb57..64207ead5b 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java @@ -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; @@ -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; diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index 4a1cb4b6aa..eff2a1db9c 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -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()); diff --git a/src/integrationTest/resources/config.yml b/src/integrationTest/resources/config.yml index 5e929c0e2a..17aeb1881d 100644 --- a/src/integrationTest/resources/config.yml +++ b/src/integrationTest/resources/config.yml @@ -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==" diff --git a/src/main/java/com/amazon/dlic/auth/http/jwt/HTTPJwtAuthenticator.java b/src/main/java/com/amazon/dlic/auth/http/jwt/HTTPJwtAuthenticator.java index 4eee14a3fc..03e385d5c0 100644 --- a/src/main/java/com/amazon/dlic/auth/http/jwt/HTTPJwtAuthenticator.java +++ b/src/main/java/com/amazon/dlic/auth/http/jwt/HTTPJwtAuthenticator.java @@ -13,21 +13,14 @@ import java.nio.file.Path; import java.security.AccessController; -import java.security.Key; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; import java.security.PrivilegedAction; -import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; import java.util.Collection; import java.util.Map.Entry; import java.util.regex.Pattern; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.JwtParserBuilder; import io.jsonwebtoken.security.WeakKeyException; import org.apache.hc.core5.http.HttpHeaders; import org.apache.logging.log4j.LogManager; @@ -43,6 +36,7 @@ import org.opensearch.core.rest.RestStatus; import org.opensearch.security.auth.HTTPAuthenticator; import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; public class HTTPJwtAuthenticator implements HTTPAuthenticator { @@ -63,45 +57,7 @@ public class HTTPJwtAuthenticator implements HTTPAuthenticator { public HTTPJwtAuthenticator(final Settings settings, final Path configPath) { super(); - JwtParser _jwtParser = null; - - try { - String signingKey = settings.get("signing_key"); - - if (signingKey == null || signingKey.length() == 0) { - log.error("signingKey must not be null or empty. JWT authentication will not work"); - } else { - - signingKey = signingKey.replace("-----BEGIN PUBLIC KEY-----\n", ""); - signingKey = signingKey.replace("-----END PUBLIC KEY-----", ""); - - byte[] decoded = Decoders.BASE64.decode(signingKey); - Key key = null; - - try { - key = getPublicKey(decoded, "RSA"); - } catch (Exception e) { - log.debug("No public RSA key, try other algos ({})", e.toString()); - } - - try { - key = getPublicKey(decoded, "EC"); - } catch (Exception e) { - log.debug("No public ECDSA key, try other algos ({})", e.toString()); - } - - if (key != null) { - _jwtParser = Jwts.parser().setSigningKey(key); - } else { - _jwtParser = Jwts.parser().setSigningKey(decoded); - } - - } - } catch (Throwable e) { - log.error("Error creating JWT authenticator. JWT authentication will not work", e); - throw new RuntimeException(e); - } - + String signingKey = settings.get("signing_key"); jwtUrlParameter = settings.get("jwt_url_parameter"); jwtHeaderName = settings.get("jwt_header", HttpHeaders.AUTHORIZATION); isDefaultAuthHeader = HttpHeaders.AUTHORIZATION.equalsIgnoreCase(jwtHeaderName); @@ -110,15 +66,20 @@ public HTTPJwtAuthenticator(final Settings settings, final Path configPath) { requireAudience = settings.get("required_audience"); requireIssuer = settings.get("required_issuer"); - if (requireAudience != null) { - _jwtParser.requireAudience(requireAudience); - } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + if (jwtParserBuilder == null) { + jwtParser = null; + } else { + if (requireAudience != null) { + jwtParserBuilder = jwtParserBuilder.require("aud", requireAudience); + } - if (requireIssuer != null) { - _jwtParser.requireIssuer(requireIssuer); - } + if (requireIssuer != null) { + jwtParserBuilder = jwtParserBuilder.require("iss", requireIssuer); + } - jwtParser = _jwtParser; + jwtParser = jwtParserBuilder.build(); + } } @Override @@ -282,11 +243,4 @@ protected String[] extractRoles(final Claims claims, final RestRequest request) return roles; } - private static PublicKey getPublicKey(final byte[] keyBytes, final String algo) throws NoSuchAlgorithmException, - InvalidKeySpecException { - X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); - KeyFactory kf = KeyFactory.getInstance(algo); - return kf.generatePublic(spec); - } - } diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index c7c666bdaf..c34e3877f1 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -106,6 +106,7 @@ import org.opensearch.indices.IndicesService; import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.plugins.ClusterPlugin; +import org.opensearch.plugins.ExtensionAwarePlugin; import org.opensearch.plugins.MapperPlugin; import org.opensearch.repositories.RepositoriesService; import org.opensearch.rest.RestController; @@ -118,6 +119,7 @@ import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.TransportConfigUpdateAction; +import org.opensearch.security.action.onbehalf.CreateOnBehalfOfTokenAction; import org.opensearch.security.action.whoami.TransportWhoAmIAction; import org.opensearch.security.action.whoami.WhoAmIAction; import org.opensearch.security.auditlog.AuditLog; @@ -193,7 +195,15 @@ import org.opensearch.watcher.ResourceWatcherService; // CS-ENFORCE-SINGLE -public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin implements ClusterPlugin, MapperPlugin { +public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin + implements + ClusterPlugin, + MapperPlugin, + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + ExtensionAwarePlugin +// CS-ENFORCE-SINGLE + +{ private static final String KEYWORD = ".keyword"; private static final Logger actionTrace = LogManager.getLogger("opendistro_security_action_trace"); @@ -218,6 +228,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile SslExceptionHandler sslExceptionHandler; private volatile Client localClient; private final boolean disabled; + private volatile DynamicConfigFactory dcf; private final List demoCertHashes = new ArrayList(3); private volatile SecurityFilter sf; private volatile IndexResolverReplacer irr; @@ -534,6 +545,9 @@ public List getRestHandlers( principalExtractor ) ); + CreateOnBehalfOfTokenAction cobot = new CreateOnBehalfOfTokenAction(settings, threadPool, Objects.requireNonNull(cs)); + dcf.registerDCFListener(cobot); + handlers.add(cobot); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -945,7 +959,7 @@ public Collection createComponents( // Register opensearch dynamic settings transportPassiveAuthSetting.registerClusterSettingsChangeListener(clusterService.getClusterSettings()); - final ClusterInfoHolder cih = new ClusterInfoHolder(); + final ClusterInfoHolder cih = new ClusterInfoHolder(this.cs.getClusterName().value()); this.cs.addListener(cih); this.salt = Salt.from(settings); @@ -1035,8 +1049,7 @@ public Collection createComponents( configPath, compatConfig ); - - final DynamicConfigFactory dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih); + dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); dcf.registerDCFListener(irr); diff --git a/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java new file mode 100644 index 0000000000..2459d469df --- /dev/null +++ b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java @@ -0,0 +1,179 @@ +/* + * 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.action.onbehalf; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; +import org.greenrobot.eventbus.Subscribe; + +import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.NamedRoute; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.security.authtoken.jwt.JwtVendor; +import org.opensearch.security.securityconf.ConfigModel; +import org.opensearch.security.securityconf.DynamicConfigModel; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; + +public class CreateOnBehalfOfTokenAction extends BaseRestHandler { + + private static final List routes = addRoutesPrefix( + ImmutableList.of(new NamedRoute.Builder().method(POST).path("/generateonbehalfoftoken").uniqueName("security:obo/create").build()), + "/_plugins/_security/api" + ); + + private JwtVendor vendor; + private final ThreadPool threadPool; + private final ClusterService clusterService; + + private ConfigModel configModel; + + private DynamicConfigModel dcm; + + public static final Integer OBO_DEFAULT_EXPIRY_SECONDS = 5 * 60; + public static final Integer OBO_MAX_EXPIRY_SECONDS = 10 * 60; + + public static final String DEFAULT_SERVICE = "self-issued"; + + @Subscribe + public void onConfigModelChanged(ConfigModel configModel) { + this.configModel = configModel; + } + + @Subscribe + public void onDynamicConfigModelChanged(DynamicConfigModel dcm) { + this.dcm = dcm; + + Settings settings = dcm.getDynamicOnBehalfOfSettings(); + + Boolean enabled = Boolean.parseBoolean(settings.get("enabled")); + String signingKey = settings.get("signing_key"); + String encryptionKey = settings.get("encryption_key"); + + if (!Boolean.FALSE.equals(enabled) && signingKey != null && encryptionKey != null) { + this.vendor = new JwtVendor(settings, Optional.empty()); + } else { + this.vendor = null; + } + } + + public CreateOnBehalfOfTokenAction(final Settings settings, final ThreadPool threadPool, final ClusterService clusterService) { + this.threadPool = threadPool; + this.clusterService = clusterService; + } + + @Override + public String getName() { + return getClass().getSimpleName(); + } + + @Override + public List routes() { + return routes; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + switch (request.method()) { + case POST: + return handlePost(request, client); + default: + throw new IllegalArgumentException(request.method() + " not supported"); + } + } + + private RestChannelConsumer handlePost(RestRequest request, NodeClient client) throws IOException { + return new RestChannelConsumer() { + @Override + public void accept(RestChannel channel) throws Exception { + final XContentBuilder builder = channel.newBuilder(); + BytesRestResponse response; + try { + if (vendor == null) { + channel.sendResponse( + new BytesRestResponse( + RestStatus.SERVICE_UNAVAILABLE, + "The OnBehalfOf token generating API has been disabled, see {link to doc} for more information on this feature." /* TODO: Update the link to the documentation website */ + ) + ); + return; + } + + final String clusterIdentifier = clusterService.getClusterName().value(); + + final Map requestBody = request.contentOrSourceParamParser().map(); + final String description = (String) requestBody.getOrDefault("description", null); + + final Integer tokenDuration = Optional.ofNullable(requestBody.get("durationSeconds")) + .map(value -> (String) value) + .map(Integer::parseInt) + .map(value -> Math.min(value, OBO_MAX_EXPIRY_SECONDS)) // Max duration seconds are 600 + .orElse(OBO_DEFAULT_EXPIRY_SECONDS); // Fallback to default + + final Boolean roleSecurityMode = Optional.ofNullable(requestBody.get("roleSecurityMode")) + .map(value -> (Boolean) value) + .orElse(true); // Default to false if null + + final String service = (String) requestBody.getOrDefault("service", DEFAULT_SERVICE); + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + Set mappedRoles = mapRoles(user); + + builder.startObject(); + builder.field("user", user.getName()); + + final String token = vendor.createJwt( + clusterIdentifier, + user.getName(), + service, + tokenDuration, + mappedRoles.stream().collect(Collectors.toList()), + user.getRoles().stream().collect(Collectors.toList()), + roleSecurityMode + ); + builder.field("authenticationToken", token); + builder.field("durationSeconds", tokenDuration); + builder.endObject(); + + response = new BytesRestResponse(RestStatus.OK, builder); + } catch (final Exception exception) { + builder.startObject().field("error", exception.toString()).endObject(); + + response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder); + } + builder.close(); + channel.sendResponse(response); + } + }; + } + + private Set mapRoles(final User user) { + return this.configModel.mapSecurityRoles(user, null); + } + +} diff --git a/src/main/java/org/opensearch/security/auth/BackendRegistry.java b/src/main/java/org/opensearch/security/auth/BackendRegistry.java index 9721664c70..c16f90fb6a 100644 --- a/src/main/java/org/opensearch/security/auth/BackendRegistry.java +++ b/src/main/java/org/opensearch/security/auth/BackendRegistry.java @@ -59,6 +59,7 @@ import org.opensearch.security.auth.blocking.ClientBlockRegistry; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.http.XFFResolver; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.ssl.util.Utils; @@ -606,6 +607,12 @@ private User impersonate(final RestRequest request, final User originalUser) thr // loop over all http/rest auth domains for (final AuthDomain authDomain : restAuthDomains) { final AuthenticationBackend authenticationBackend = authDomain.getBackend(); + + // Skip over the OnBehalfOfAuthenticator since it is not compatible for user impersonation + if (authDomain.getHttpAuthenticator() instanceof OnBehalfOfAuthenticator) { + continue; + } + final User impersonatedUser = checkExistsAndAuthz( restImpersonationCache, new User(impersonatedUserHeader), diff --git a/src/main/java/org/opensearch/security/auth/internal/NoOpAuthenticationBackend.java b/src/main/java/org/opensearch/security/auth/internal/NoOpAuthenticationBackend.java index 1f149aabcf..299a1a4577 100644 --- a/src/main/java/org/opensearch/security/auth/internal/NoOpAuthenticationBackend.java +++ b/src/main/java/org/opensearch/security/auth/internal/NoOpAuthenticationBackend.java @@ -46,7 +46,9 @@ public String getType() { @Override public User authenticate(final AuthCredentials credentials) { - return new User(credentials.getUsername(), credentials.getBackendRoles(), credentials); + User user = new User(credentials.getUsername(), credentials.getBackendRoles(), credentials); + user.addSecurityRoles(credentials.getSecurityRoles()); + return user; } @Override diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java new file mode 100644 index 0000000000..2e11fed64a --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtil.java @@ -0,0 +1,72 @@ +/* + * 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.authtoken.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class EncryptionDecryptionUtil { + + private final Cipher encryptCipher; + private final Cipher decryptCipher; + + public EncryptionDecryptionUtil(final String secret) { + this.encryptCipher = createCipherFromSecret(secret, CipherMode.ENCRYPT); + this.decryptCipher = createCipherFromSecret(secret, CipherMode.DECRYPT); + } + + public String encrypt(final String data) { + byte[] encryptedBytes = processWithCipher(data.getBytes(StandardCharsets.UTF_8), encryptCipher); + return Base64.getEncoder().encodeToString(encryptedBytes); + } + + public String decrypt(final String encryptedString) { + byte[] decodedBytes = Base64.getDecoder().decode(encryptedString); + return new String(processWithCipher(decodedBytes, decryptCipher), StandardCharsets.UTF_8); + } + + private static Cipher createCipherFromSecret(final String secret, final CipherMode mode) { + try { + final byte[] decodedKey = Base64.getDecoder().decode(secret); + final Cipher cipher = Cipher.getInstance("AES"); + final SecretKey originalKey = new SecretKeySpec(Arrays.copyOf(decodedKey, 16), "AES"); + cipher.init(mode.opmode, originalKey); + return cipher; + } catch (final Exception e) { + throw new RuntimeException("Error creating cipher from secret in mode " + mode.name(), e); + } + } + + private static byte[] processWithCipher(final byte[] data, final Cipher cipher) { + try { + return cipher.doFinal(data); + } catch (final Exception e) { + throw new RuntimeException("Error processing data with cipher", e); + } + } + + private enum CipherMode { + ENCRYPT(Cipher.ENCRYPT_MODE), + DECRYPT(Cipher.DECRYPT_MODE); + + private final int opmode; + + private CipherMode(final int opmode) { + this.opmode = opmode; + } + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java new file mode 100644 index 0000000000..5d3262799f --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -0,0 +1,171 @@ +/* + * 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.authtoken.jwt; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.function.LongSupplier; + +import com.google.common.base.Strings; +import org.apache.cxf.jaxrs.json.basic.JsonMapObjectReaderWriter; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKey; +import org.apache.cxf.rs.security.jose.jwk.KeyType; +import org.apache.cxf.rs.security.jose.jwk.PublicKeyUse; +import org.apache.cxf.rs.security.jose.jws.JwsUtils; +import org.apache.cxf.rs.security.jose.jwt.JoseJwtProducer; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.cxf.rs.security.jose.jwt.JwtToken; +import org.apache.cxf.rs.security.jose.jwt.JwtUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.ssl.util.ExceptionUtils; + +public class JwtVendor { + private static final Logger logger = LogManager.getLogger(JwtVendor.class); + + private static JsonMapObjectReaderWriter jsonMapReaderWriter = new JsonMapObjectReaderWriter(); + + private final String claimsEncryptionKey; + private final JsonWebKey signingKey; + private final JoseJwtProducer jwtProducer; + private final LongSupplier timeProvider; + private final EncryptionDecryptionUtil encryptionDecryptionUtil; + private final Integer defaultExpirySeconds = 300; + private final Integer maxExpirySeconds = 600; + + public JwtVendor(final Settings settings, final Optional timeProvider) { + JoseJwtProducer jwtProducer = new JoseJwtProducer(); + try { + this.signingKey = createJwkFromSettings(settings); + } catch (Exception e) { + throw ExceptionUtils.createJwkCreationException(e); + } + this.jwtProducer = jwtProducer; + if (settings.get("encryption_key") == null) { + throw new IllegalArgumentException("encryption_key cannot be null"); + } else { + this.claimsEncryptionKey = settings.get("encryption_key"); + this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + } + if (timeProvider.isPresent()) { + this.timeProvider = timeProvider.get(); + } else { + this.timeProvider = () -> System.currentTimeMillis() / 1000; + } + } + + /* + * The default configuration of this web key should be: + * KeyType: OCTET + * PublicKeyUse: SIGN + * Encryption Algorithm: HS512 + * */ + static JsonWebKey createJwkFromSettings(Settings settings) throws Exception { + String signingKey = settings.get("signing_key"); + + if (!Strings.isNullOrEmpty(signingKey)) { + + JsonWebKey jwk = new JsonWebKey(); + + jwk.setKeyType(KeyType.OCTET); + jwk.setAlgorithm("HS512"); + jwk.setPublicKeyUse(PublicKeyUse.SIGN); + jwk.setProperty("k", signingKey); + + return jwk; + } else { + Settings jwkSettings = settings.getAsSettings("jwt").getAsSettings("key"); + + if (jwkSettings.isEmpty()) { + throw new Exception( + "Settings for signing key is missing. Please specify at least the option signing_key with a shared secret." + ); + } + + JsonWebKey jwk = new JsonWebKey(); + + for (String key : jwkSettings.keySet()) { + jwk.setProperty(key, jwkSettings.get(key)); + } + + return jwk; + } + } + + public String createJwt( + String issuer, + String subject, + String audience, + Integer expirySeconds, + List roles, + List backendRoles, + boolean roleSecurityMode + ) throws Exception { + final long nowAsMillis = timeProvider.getAsLong(); + final Instant nowAsInstant = Instant.ofEpochMilli(timeProvider.getAsLong()); + + jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(signingKey)); + JwtClaims jwtClaims = new JwtClaims(); + JwtToken jwt = new JwtToken(jwtClaims); + + jwtClaims.setIssuer(issuer); + + jwtClaims.setIssuedAt(nowAsMillis); + + jwtClaims.setSubject(subject); + + jwtClaims.setAudience(audience); + + jwtClaims.setNotBefore(nowAsMillis); + + if (expirySeconds > maxExpirySeconds) { + throw new Exception("The provided expiration time exceeds the maximum allowed duration of " + maxExpirySeconds + " seconds"); + } + + expirySeconds = (expirySeconds == null) ? defaultExpirySeconds : Math.min(expirySeconds, maxExpirySeconds); + if (expirySeconds <= 0) { + throw new Exception("The expiration time should be a positive integer"); + } + long expiryTime = timeProvider.getAsLong() + expirySeconds; + jwtClaims.setExpiryTime(expiryTime); + + if (roles != null) { + String listOfRoles = String.join(",", roles); + jwtClaims.setProperty("er", encryptionDecryptionUtil.encrypt(listOfRoles)); + } else { + throw new Exception("Roles cannot be null"); + } + + if (!roleSecurityMode && backendRoles != null) { + String listOfBackendRoles = String.join(",", backendRoles); + jwtClaims.setProperty("br", listOfBackendRoles); + } + + String encodedJwt = jwtProducer.processJwt(jwt); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + + encodedJwt + + "\n" + + jsonMapReaderWriter.toJson(jwt.getJwsHeaders()) + + "\n" + + JwtUtils.claimsToJson(jwt.getClaims()) + ); + } + + return encodedJwt; + } +} diff --git a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java index 00101d9a73..d7429c5d1d 100644 --- a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java +++ b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java @@ -40,6 +40,11 @@ public class ClusterInfoHolder implements ClusterStateListener { private volatile DiscoveryNodes nodes = null; private volatile Boolean isLocalNodeElectedClusterManager = null; private volatile boolean initialized; + private final String clusterName; + + public ClusterInfoHolder(String clusterName) { + this.clusterName = clusterName; + } @Override public void clusterChanged(ClusterChangedEvent event) { @@ -72,4 +77,8 @@ public Boolean hasNode(DiscoveryNode node) { return nodes.nodeExists(node) ? Boolean.TRUE : Boolean.FALSE; } + + public String getClusterName() { + return this.clusterName; + } } diff --git a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java new file mode 100644 index 0000000000..467edd8ac4 --- /dev/null +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -0,0 +1,255 @@ +/* + * 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.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchException; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.auth.HTTPAuthenticator; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; +import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; + +import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; + +public class OnBehalfOfAuthenticator implements HTTPAuthenticator { + + private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; + private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); + private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; + private static final String ACCOUNT_SUFFIX = "api/account"; + + protected final Logger log = LogManager.getLogger(this.getClass()); + + private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE); + private static final String BEARER_PREFIX = "bearer "; + + private final JwtParser jwtParser; + private final String encryptionKey; + private final Boolean oboEnabled; + private final String clusterName; + + private final EncryptionDecryptionUtil encryptionUtil; + + public OnBehalfOfAuthenticator(Settings settings, String clusterName) { + String oboEnabledSetting = settings.get("enabled", "true"); + oboEnabled = Boolean.parseBoolean(oboEnabledSetting); + encryptionKey = settings.get("encryption_key"); + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + jwtParser = builder.build(); + + this.clusterName = clusterName; + this.encryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + } + + private JwtParserBuilder initParserBuilder(final String signingKey) { + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + + if (jwtParserBuilder == null) { + throw new OpenSearchSecurityException("Unable to find on behalf of authenticator signing key"); + } + + return jwtParserBuilder; + } + + private List extractSecurityRolesFromClaims(Claims claims) { + Object er = claims.get("er"); + Object dr = claims.get("dr"); + String rolesClaim = ""; + + if (er != null) { + rolesClaim = encryptionUtil.decrypt(er.toString()); + } else if (dr != null) { + rolesClaim = dr.toString(); + } else { + log.warn("This is a malformed On-behalf-of Token"); + } + + List roles = Arrays.stream(rolesClaim.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableList()); + + return roles; + } + + private String[] extractBackendRolesFromClaims(Claims claims) { + Object backendRolesObject = claims.get("br"); + String[] backendRoles; + + if (backendRolesObject == null) { + backendRoles = new String[0]; + } else { + // Extracting roles based on the compatibility mode + backendRoles = Arrays.stream(backendRolesObject.toString().split(",")).map(String::trim).toArray(String[]::new); + } + + return backendRoles; + } + + @Override + @SuppressWarnings("removal") + public AuthCredentials extractCredentials(RestRequest request, ThreadContext context) throws OpenSearchSecurityException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + AuthCredentials creds = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public AuthCredentials run() { + return extractCredentials0(request); + } + }); + + return creds; + } + + private AuthCredentials extractCredentials0(final RestRequest request) { + if (!oboEnabled) { + log.error("On-behalf-of authentication is disabled"); + return null; + } + + if (jwtParser == null) { + log.error("Missing Signing Key. JWT authentication will not work"); + return null; + } + + String jwtToken = extractJwtFromHeader(request); + if (jwtToken == null) { + return null; + } + + if (!isRequestAllowed(request)) { + return null; + } + + try { + final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody(); + + final String subject = claims.getSubject(); + if (subject == null) { + log.error("Valid jwt on behalf of token with no subject"); + return null; + } + + final String audience = claims.getAudience(); + if (audience == null) { + log.error("Valid jwt on behalf of token with no audience"); + return null; + } + + final String issuer = claims.getIssuer(); + if (!clusterName.equals(issuer)) { + log.error("The issuer of this OBO does not match the current cluster identifier"); + return null; + } + + List roles = extractSecurityRolesFromClaims(claims); + String[] backendRoles = extractBackendRolesFromClaims(claims); + + final AuthCredentials ac = new AuthCredentials(subject, roles, backendRoles).markComplete(); + + for (Entry claim : claims.entrySet()) { + ac.addAttribute("attr.jwt." + claim.getKey(), String.valueOf(claim.getValue())); + } + + return ac; + + } catch (WeakKeyException e) { + log.error("Cannot authenticate user with JWT because of ", e); + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Invalid or expired JWT token.", e); + } + } + + // Return null for the authentication failure + return null; + } + + private String extractJwtFromHeader(RestRequest request) { + String jwtToken = request.header(HttpHeaders.AUTHORIZATION); + + if (jwtToken == null || jwtToken.isEmpty()) { + logDebug("No JWT token found in '{}' header", HttpHeaders.AUTHORIZATION); + return null; + } + + if (!BEARER.matcher(jwtToken).matches()) { + return null; + } + + if (jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + } else { + logDebug("No Bearer scheme found in header"); + return null; + } + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final RestRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (request.method() == RestRequest.Method.POST && ON_BEHALF_OF_SUFFIX.equals(suffix) + || request.method() == RestRequest.Method.PUT && ACCOUNT_SUFFIX.equals(suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfOBOTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public boolean reRequestAuthentication(final RestChannel channel, AuthCredentials creds) { + return false; + } + + @Override + public String getType() { + return "onbehalfof_jwt"; + } + +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 781b8a626c..bcbe3aef57 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -128,6 +128,7 @@ public final static SecurityDynamicConfiguration addStatics(SecurityDynamicCo private final Settings opensearchSettings; private final Path configPath; private final InternalAuthenticationBackend iab = new InternalAuthenticationBackend(); + private final ClusterInfoHolder cih; SecurityDynamicConfiguration config; @@ -143,6 +144,7 @@ public DynamicConfigFactory( this.cr = cr; this.opensearchSettings = opensearchSettings; this.configPath = configPath; + this.cih = cih; if (opensearchSettings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES, true)) { try { @@ -269,7 +271,7 @@ public void onChange(Map> typeToConfig) { ); // rebuild v7 Models - dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab); + dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); ium = new InternalUsersModelV7( (SecurityDynamicConfiguration) internalusers, (SecurityDynamicConfiguration) roles, diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java index 08976f2013..e3d10878da 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java @@ -38,6 +38,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.common.settings.Settings; import org.opensearch.security.auth.AuthDomain; import org.opensearch.security.auth.AuthFailureListener; import org.opensearch.security.auth.AuthorizationBackend; @@ -104,6 +105,8 @@ public abstract class DynamicConfigModel { public abstract Multimap> getAuthBackendClientBlockRegistries(); + public abstract Settings getDynamicOnBehalfOfSettings(); + protected final Map authImplMap = new HashMap<>(); public DynamicConfigModel() { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV6.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV6.java index 994989416b..e5308aa574 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV6.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV6.java @@ -207,6 +207,11 @@ public Multimap> getAuthBackendClientBlockRe return Multimaps.unmodifiableMultimap(authBackendClientBlockRegistries); } + @Override + public Settings getDynamicOnBehalfOfSettings() { + return Settings.EMPTY; + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 60637e4b8c..fcbf985f60 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -57,6 +57,9 @@ import org.opensearch.security.auth.HTTPAuthenticator; import org.opensearch.security.auth.blocking.ClientBlockRegistry; import org.opensearch.security.auth.internal.InternalAuthenticationBackend; +import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; +import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.http.OnBehalfOfAuthenticator; import org.opensearch.security.securityconf.impl.v7.ConfigV7; import org.opensearch.security.securityconf.impl.v7.ConfigV7.Authc; import org.opensearch.security.securityconf.impl.v7.ConfigV7.AuthcDomain; @@ -80,13 +83,21 @@ public class DynamicConfigModelV7 extends DynamicConfigModel { private Multimap authBackendFailureListeners; private List> ipClientBlockRegistries; private Multimap> authBackendClientBlockRegistries; - - public DynamicConfigModelV7(ConfigV7 config, Settings opensearchSettings, Path configPath, InternalAuthenticationBackend iab) { + private final ClusterInfoHolder cih; + + public DynamicConfigModelV7( + ConfigV7 config, + Settings opensearchSettings, + Path configPath, + InternalAuthenticationBackend iab, + ClusterInfoHolder cih + ) { super(); this.config = config; this.opensearchSettings = opensearchSettings; this.configPath = configPath; this.iab = iab; + this.cih = cih; buildAAA(); } @@ -210,6 +221,13 @@ public Multimap> getAuthBackendClientBlockRe return Multimaps.unmodifiableMultimap(authBackendClientBlockRegistries); } + @Override + public Settings getDynamicOnBehalfOfSettings() { + return Settings.builder() + .put(Settings.builder().loadFromSource(config.dynamic.on_behalf_of.configAsJson(), XContentType.JSON).build()) + .build(); + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); @@ -358,6 +376,23 @@ private void buildAAA() { } } + /* + * If the OnBehalfOf (OBO) authentication is configured: + * Add the OBO authbackend in to the auth domains + * Challenge: false - no need to iterate through the auth domains again when OBO authentication failed + * order: -1 - prioritize the OBO authentication when it gets enabled + */ + Settings oboSettings = getDynamicOnBehalfOfSettings(); + if (oboSettings.get("signing_key") != null && oboSettings.get("encryption_key") != null) { + final AuthDomain _ad = new AuthDomain( + new NoOpAuthenticationBackend(Settings.EMPTY, null), + new OnBehalfOfAuthenticator(getDynamicOnBehalfOfSettings(), this.cih.getClusterName()), + false, + -1 + ); + restAuthDomains0.add(_ad); + } + List originalDestroyableComponents = destroyableComponents; restAuthDomains = Collections.unmodifiableSortedSet(restAuthDomains0); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v6/ConfigV6.java b/src/main/java/org/opensearch/security/securityconf/impl/v6/ConfigV6.java index c85e69fb0d..1e92c5948a 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v6/ConfigV6.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v6/ConfigV6.java @@ -36,6 +36,7 @@ import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import org.opensearch.security.DefaultObjectMapper; @@ -356,4 +357,51 @@ public String toString() { } + public static class OnBehalfOfSettings { + @JsonProperty("enabled") + private Boolean oboEnabled = Boolean.TRUE; + @JsonProperty("signing_key") + private String signingKey; + @JsonProperty("encryption_key") + private String encryptionKey; + + @JsonIgnore + public String configAsJson() { + try { + return DefaultObjectMapper.writeValueAsString(this, false); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Boolean getOboEnabled() { + return oboEnabled; + } + + public void setOboEnabled(Boolean oboEnabled) { + this.oboEnabled = oboEnabled; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + public void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + } + + @Override + public String toString() { + return "OnBehalfOf [ enabled=" + oboEnabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + } + } + } diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 87de6a31b0..49fa3a13cc 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -37,6 +37,7 @@ import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import org.opensearch.security.DefaultObjectMapper; @@ -133,6 +134,7 @@ public static class Dynamic { public String hosts_resolver_mode = "ip-only"; public String transport_userrname_attribute; public boolean do_not_fail_on_forbidden_empty; + public OnBehalfOfSettings on_behalf_of = new OnBehalfOfSettings(); @Override public String toString() { @@ -478,4 +480,51 @@ public String toString() { } + public static class OnBehalfOfSettings { + @JsonProperty("enabled") + private Boolean oboEnabled = Boolean.TRUE; + @JsonProperty("signing_key") + private String signingKey; + @JsonProperty("encryption_key") + private String encryptionKey; + + @JsonIgnore + public String configAsJson() { + try { + return DefaultObjectMapper.writeValueAsString(this, false); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Boolean getOboEnabled() { + return oboEnabled; + } + + public void setOboEnabled(Boolean oboEnabled) { + this.oboEnabled = oboEnabled; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + public void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + } + + @Override + public String toString() { + return "OnBehalfOf [ enabled=" + oboEnabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + } + } + } diff --git a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java index 9d6d3dade8..83982239f0 100644 --- a/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java +++ b/src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java @@ -64,6 +64,18 @@ public static OpenSearchException createBadHeaderException() { ); } + public static OpenSearchException invalidUsageOfOBOTokenException() { + return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endpoint."); + } + + public static OpenSearchException createJwkCreationException() { + return new OpenSearchException("An error occurred during the creation of Jwk."); + } + + public static OpenSearchException createJwkCreationException(Throwable cause) { + return new OpenSearchException("An error occurred during the creation of Jwk: {}", cause, cause.getMessage()); + } + public static OpenSearchException createTransportClientNoLongerSupportedException() { return new OpenSearchException("Transport client authentication no longer supported."); } diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index ee04ff62f3..61962a61f7 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -320,6 +320,12 @@ public enum RolesMappingResolution { public static final String TENANCY_GLOBAL_TENANT_NAME = "global"; public static final String TENANCY_GLOBAL_TENANT_DEFAULT_NAME = ""; + // On-behalf-of endpoints settings + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + public static final String EXTENSIONS_BWC_PLUGIN_MODE = "bwcPluginMode"; + public static final boolean EXTENSIONS_BWC_PLUGIN_MODE_DEFAULT = false; + // CS-ENFORCE-SINGLE + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/main/java/org/opensearch/security/user/AuthCredentials.java b/src/main/java/org/opensearch/security/user/AuthCredentials.java index cab3eab6fd..beb3ae1733 100644 --- a/src/main/java/org/opensearch/security/user/AuthCredentials.java +++ b/src/main/java/org/opensearch/security/user/AuthCredentials.java @@ -32,6 +32,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; @@ -48,6 +49,7 @@ public final class AuthCredentials { private final String username; private byte[] password; private Object nativeCredentials; + private final Set securityRoles = new HashSet(); private final Set backendRoles = new HashSet(); private boolean complete; private final byte[] internalPasswordHash; @@ -94,6 +96,18 @@ public AuthCredentials(final String username, String... backendRoles) { this(username, null, null, backendRoles); } + /** + * Create new credentials with a username, a initial optional set of roles and empty password/native credentials + * @param username The username, must not be null or empty + * @param securityRoles The internal roles the user has been mapped to + * @param backendRoles set of roles this user is a member of + * @throws IllegalArgumentException if username is null or empty + */ + public AuthCredentials(final String username, List securityRoles, String... backendRoles) { + this(username, null, null, backendRoles); + this.securityRoles.addAll(securityRoles); + } + private AuthCredentials(final String username, byte[] password, Object nativeCredentials, String... backendRoles) { super(); @@ -203,6 +217,14 @@ public Set getBackendRoles() { return new HashSet(backendRoles); } + /** + * + * @return Defensive copy of the security roles this user is member of. + */ + public Set getSecurityRoles() { + return Set.copyOf(securityRoles); + } + public boolean isComplete() { return complete; } diff --git a/src/main/java/org/opensearch/security/util/KeyUtils.java b/src/main/java/org/opensearch/security/util/KeyUtils.java new file mode 100644 index 0000000000..4aebf0cb12 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/KeyUtils.java @@ -0,0 +1,88 @@ +/* + * 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.util; + +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.Jwts; +import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.SpecialPermission; +import org.opensearch.core.common.Strings; + +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Objects; + +public class KeyUtils { + + public static JwtParserBuilder createJwtParserBuilderFromSigningKey(final String signingKey, final Logger log) { + final SecurityManager sm = System.getSecurityManager(); + + JwtParserBuilder jwtParserBuilder = null; + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + jwtParserBuilder = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParserBuilder run() { + if (Strings.isNullOrEmpty(signingKey)) { + log.error("Unable to find signing key"); + return null; + } else { + try { + Key key = null; + + final String minimalKeyFormat = signingKey.replace("-----BEGIN PUBLIC KEY-----\n", "") + .replace("-----END PUBLIC KEY-----", ""); + + final byte[] decoded = Base64.getDecoder().decode(minimalKeyFormat); + + try { + key = getPublicKey(decoded, "RSA"); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.debug("No public RSA key, try other algos ({})", e.toString()); + } + + try { + key = getPublicKey(decoded, "EC"); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.debug("No public ECDSA key, try other algos ({})", e.toString()); + } + + if (Objects.nonNull(key)) { + return Jwts.parserBuilder().setSigningKey(key); + } + + return Jwts.parserBuilder().setSigningKey(decoded); + } catch (Throwable e) { + log.error("Error while creating JWT authenticator", e); + throw new OpenSearchSecurityException(e.toString(), e); + } + } + } + }); + + return jwtParserBuilder; + } + + private static PublicKey getPublicKey(final byte[] keyBytes, final String algo) throws NoSuchAlgorithmException, + InvalidKeySpecException { + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance(algo); + return kf.generatePublic(spec); + } + +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java new file mode 100644 index 0000000000..4890f380f9 --- /dev/null +++ b/src/test/java/org/opensearch/security/authtoken/jwt/EncryptionDecryptionUtilsTest.java @@ -0,0 +1,88 @@ +/* + * 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.authtoken.jwt; + +import org.junit.Assert; +import org.junit.Test; +import java.util.Base64; + +public class EncryptionDecryptionUtilsTest { + + @Test + public void testEncryptDecrypt() { + String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String data = "Hello, OpenSearch!"; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + + String encryptedString = util.encrypt(data); + String decryptedString = util.decrypt(encryptedString); + + Assert.assertEquals(data, decryptedString); + } + + @Test + public void testDecryptingWithWrongKey() { + String secret1 = Base64.getEncoder().encodeToString("correctKey12345".getBytes()); + String secret2 = Base64.getEncoder().encodeToString("wrongKey1234567".getBytes()); + String data = "Hello, OpenSearch!"; + + EncryptionDecryptionUtil util1 = new EncryptionDecryptionUtil(secret1); + String encryptedString = util1.encrypt(data); + + EncryptionDecryptionUtil util2 = new EncryptionDecryptionUtil(secret2); + RuntimeException ex = Assert.assertThrows(RuntimeException.class, () -> util2.decrypt(encryptedString)); + + Assert.assertEquals("Error processing data with cipher", ex.getMessage()); + } + + @Test + public void testDecryptingCorruptedData() { + String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String corruptedEncryptedString = "corruptedData"; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + RuntimeException ex = Assert.assertThrows(RuntimeException.class, () -> util.decrypt(corruptedEncryptedString)); + + Assert.assertEquals("Last unit does not have enough valid bits", ex.getMessage()); + } + + @Test + public void testEncryptDecryptEmptyString() { + String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String data = ""; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + String encryptedString = util.encrypt(data); + String decryptedString = util.decrypt(encryptedString); + + Assert.assertEquals(data, decryptedString); + } + + @Test(expected = NullPointerException.class) + public void testEncryptNullValue() { + String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String data = null; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + util.encrypt(data); + } + + @Test(expected = NullPointerException.class) + public void testDecryptNullValue() { + String secret = Base64.getEncoder().encodeToString("mySecretKey12345".getBytes()); + String data = null; + + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(secret); + util.decrypt(data); + } +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java new file mode 100644 index 0000000000..1322777cac --- /dev/null +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -0,0 +1,185 @@ +/* + * 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.authtoken.jwt; + +import java.util.List; +import java.util.Optional; +import java.util.function.LongSupplier; + +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.cxf.rs.security.jose.jwk.JsonWebKey; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer; +import org.apache.cxf.rs.security.jose.jwt.JwtToken; +import org.junit.Assert; +import org.junit.Test; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.support.ConfigConstants; + +public class JwtVendorTest { + + @Test + public void testCreateJwkFromSettings() throws Exception { + Settings settings = Settings.builder().put("signing_key", "abc123").build(); + + JsonWebKey jwk = JwtVendor.createJwkFromSettings(settings); + Assert.assertEquals("HS512", jwk.getAlgorithm()); + Assert.assertEquals("sig", jwk.getPublicKeyUse().toString()); + Assert.assertEquals("abc123", jwk.getProperty("k")); + } + + @Test + public void testCreateJwkFromSettingsWithoutSigningKey() { + Settings settings = Settings.builder().put("jwt", "").build(); + Throwable exception = Assert.assertThrows(RuntimeException.class, () -> { + try { + JwtVendor.createJwkFromSettings(settings); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + Assert.assertEquals( + "java.lang.Exception: Settings for signing key is missing. Please specify at least the option signing_key with a shared secret.", + exception.getMessage() + ); + } + + @Test + public void testCreateJwtWithRoles() throws Exception { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = List.of("IT", "HR"); + List backendRoles = List.of("Sales", "Support"); + String expectedRoles = "IT,HR"; + int expirySeconds = 300; + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder().put("signing_key", "abc123").put("encryption_key", claimsEncryptionKey).build(); + Long expectedExp = currentTime.getAsLong() + expirySeconds; + + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(encodedJwt); + JwtToken jwt = jwtConsumer.getJwtToken(); + + Assert.assertEquals("cluster_0", jwt.getClaim("iss")); + Assert.assertEquals("admin", jwt.getClaim("sub")); + Assert.assertEquals("audience_0", jwt.getClaim("aud")); + Assert.assertNotNull(jwt.getClaim("iat")); + Assert.assertNotNull(jwt.getClaim("exp")); + Assert.assertEquals(expectedExp, jwt.getClaim("exp")); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + Assert.assertEquals(expectedRoles, encryptionUtil.decrypt(jwt.getClaim("er").toString())); + Assert.assertNull(jwt.getClaim("br")); + } + + @Test + public void testCreateJwtWithRoleSecurityMode() throws Exception { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = List.of("IT", "HR"); + List backendRoles = List.of("Sales", "Support"); + String expectedRoles = "IT,HR"; + String expectedBackendRoles = "Sales,Support"; + + int expirySeconds = 300; + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder() + .put("signing_key", "abc123") + .put("encryption_key", claimsEncryptionKey) + .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, true) + .build(); + Long expectedExp = currentTime.getAsLong() + expirySeconds; + + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(encodedJwt); + JwtToken jwt = jwtConsumer.getJwtToken(); + + Assert.assertEquals("cluster_0", jwt.getClaim("iss")); + Assert.assertEquals("admin", jwt.getClaim("sub")); + Assert.assertEquals("audience_0", jwt.getClaim("aud")); + Assert.assertNotNull(jwt.getClaim("iat")); + Assert.assertNotNull(jwt.getClaim("exp")); + Assert.assertEquals(expectedExp, jwt.getClaim("exp")); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + Assert.assertEquals(expectedRoles, encryptionUtil.decrypt(jwt.getClaim("er").toString())); + Assert.assertNotNull(jwt.getClaim("br")); + Assert.assertEquals(expectedBackendRoles, jwt.getClaim("br")); + } + + @Test + public void testCreateJwtWithBadExpiry() { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = List.of("admin"); + Integer expirySeconds = -300; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder().put("signing_key", "abc123").put("encryption_key", claimsEncryptionKey).build(); + JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); + + Throwable exception = Assert.assertThrows(RuntimeException.class, () -> { + try { + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + Assert.assertEquals("java.lang.Exception: The expiration time should be a positive integer", exception.getMessage()); + } + + @Test + public void testCreateJwtWithBadEncryptionKey() { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = List.of("admin"); + Integer expirySeconds = 300; + + Settings settings = Settings.builder().put("signing_key", "abc123").build(); + + Throwable exception = Assert.assertThrows(RuntimeException.class, () -> { + try { + new JwtVendor(settings, Optional.empty()).createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + Assert.assertEquals("java.lang.IllegalArgumentException: encryption_key cannot be null", exception.getMessage()); + } + + @Test + public void testCreateJwtWithBadRoles() { + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = null; + Integer expirySeconds = 300; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder().put("signing_key", "abc123").put("encryption_key", claimsEncryptionKey).build(); + JwtVendor jwtVendor = new JwtVendor(settings, Optional.empty()); + + Throwable exception = Assert.assertThrows(RuntimeException.class, () -> { + try { + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + Assert.assertEquals("java.lang.Exception: Roles cannot be null", exception.getMessage()); + } +} diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java new file mode 100644 index 0000000000..fbb03bf7c3 --- /dev/null +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -0,0 +1,400 @@ +/* + * 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.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.SecretKey; + +import com.google.common.io.BaseEncoding; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.Assert; +import org.junit.Test; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.FakeRestRequest; + +import static org.hamcrest.Matchers.equalTo; + +public class OnBehalfOfAuthenticatorTest { + final static String clusterName = "cluster_0"; + final static String enableOBO = "true"; + final static String disableOBO = "false"; + final static String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + + final static String signingKey = + "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; + final static String signingKeyB64Encoded = BaseEncoding.base64().encode(signingKey.getBytes(StandardCharsets.UTF_8)); + final static SecretKey secretKey = Keys.hmacShaKeyFor(signingKeyB64Encoded.getBytes(StandardCharsets.UTF_8)); + + @Test + public void testNoKey() { + Exception exception = Assert.assertThrows( + RuntimeException.class, + () -> extractCredentialsFromJwtHeader( + null, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), + false + ) + ); + Assert.assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + } + + @Test + public void testEmptyKey() { + Exception exception = Assert.assertThrows( + RuntimeException.class, + () -> extractCredentialsFromJwtHeader( + null, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), + false + ) + ); + Assert.assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + } + + @Test + public void testBadKey() { + Exception exception = Assert.assertThrows( + RuntimeException.class, + () -> extractCredentialsFromJwtHeader( + BaseEncoding.base64().encode(new byte[] { 1, 3, 3, 4, 3, 6, 7, 8, 3, 10 }), + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), + false + ) + ); + Assert.assertTrue(exception.getMessage().contains("The specified key byte array is 80 bits")); + } + + @Test + public void testTokenMissing() throws Exception { + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = new HashMap(); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + + Assert.assertNull(credentials); + } + + @Test + public void testInvalid() throws Exception { + + String jwsToken = "123invalidtoken.."; + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = new HashMap(); + headers.put("Authorization", "Bearer " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + Assert.assertNull(credentials); + } + + @Test + public void testDisabled() throws Exception { + String jwsToken = Jwts.builder() + .setIssuer(clusterName) + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .compact(); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(disableOBOSettings(), clusterName); + Map headers = new HashMap(); + headers.put("Authorization", "Bearer " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + Assert.assertNull(credentials); + } + + @Test + public void testNonSpecifyOBOSetting() throws Exception { + String jwsToken = Jwts.builder() + .setIssuer(clusterName) + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .compact(); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(nonSpecifyOBOSetting(), clusterName); + Map headers = new HashMap(); + headers.put("Authorization", "Bearer " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + Assert.assertNotNull(credentials); + } + + @Test + public void testBearer() throws Exception { + Map expectedAttributes = new HashMap<>(); + expectedAttributes.put("attr.jwt.iss", "cluster_0"); + expectedAttributes.put("attr.jwt.sub", "Leonard McCoy"); + expectedAttributes.put("attr.jwt.aud", "ext_0"); + + String jwsToken = Jwts.builder() + .setIssuer(clusterName) + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .compact(); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = new HashMap(); + headers.put("Authorization", "Bearer " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Leonard McCoy", credentials.getUsername()); + Assert.assertEquals(0, credentials.getSecurityRoles().size()); + Assert.assertEquals(0, credentials.getBackendRoles().size()); + Assert.assertThat(credentials.getAttributes(), equalTo(expectedAttributes)); + } + + @Test + public void testBearerWrongPosition() throws Exception { + + String jwsToken = Jwts.builder() + .setIssuer(clusterName) + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + + Map headers = new HashMap(); + headers.put("Authorization", jwsToken + "Bearer " + " 123"); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + + Assert.assertNull(credentials); + } + + @Test + public void testBasicAuthHeader() throws Exception { + String jwsToken = Jwts.builder() + .setIssuer(clusterName) + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + + Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, Collections.emptyMap()), null); + Assert.assertNull(credentials); + } + + @Test + public void testRoles() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("dr", "role1,role2").setAudience("svc1"), + true + ); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Leonard McCoy", credentials.getUsername()); + Assert.assertEquals(2, credentials.getSecurityRoles().size()); + Assert.assertEquals(0, credentials.getBackendRoles().size()); + } + + @Test + public void testNullClaim() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("dr", null).setAudience("svc1"), + false + ); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Leonard McCoy", credentials.getUsername()); + Assert.assertEquals(0, credentials.getBackendRoles().size()); + } + + @Test + public void testNonStringClaim() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("dr", 123L).setAudience("svc1"), + true + ); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Leonard McCoy", credentials.getUsername()); + Assert.assertEquals(1, credentials.getSecurityRoles().size()); + Assert.assertTrue(credentials.getSecurityRoles().contains("123")); + } + + @Test + public void testRolesMissing() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").setAudience("svc1"), + false + ); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Leonard McCoy", credentials.getUsername()); + Assert.assertEquals(0, credentials.getSecurityRoles().size()); + Assert.assertEquals(0, credentials.getBackendRoles().size()); + } + + @Test + public void testWrongSubjectKey() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).claim("roles", "role1,role2").claim("asub", "Dr. Who").setAudience("svc1"), + false + ); + + Assert.assertNull(credentials); + } + + @Test + public void testExp() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Expired").setExpiration(new Date(100)), + false + ); + + Assert.assertNull(credentials); + } + + @Test + public void testNbf() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Expired").setNotBefore(new Date(System.currentTimeMillis() + (1000 * 36000))), + false + ); + + Assert.assertNull(credentials); + } + + @Test + public void testRolesArray() throws Exception { + + JwtBuilder builder = Jwts.builder() + .setPayload( + "{" + + "\"iss\": \"cluster_0\"," + + "\"typ\": \"obo\"," + + "\"sub\": \"Cluster_0\"," + + "\"aud\": \"ext_0\"," + + "\"dr\": \"a,b,3rd\"" + + "}" + ); + + final AuthCredentials credentials = extractCredentialsFromJwtHeader(signingKeyB64Encoded, claimsEncryptionKey, builder, true); + + Assert.assertNotNull(credentials); + Assert.assertEquals("Cluster_0", credentials.getUsername()); + Assert.assertEquals(3, credentials.getSecurityRoles().size()); + Assert.assertTrue(credentials.getSecurityRoles().contains("a")); + Assert.assertTrue(credentials.getSecurityRoles().contains("b")); + Assert.assertTrue(credentials.getSecurityRoles().contains("3rd")); + } + + @Test + public void testDifferentIssuer() throws Exception { + + String jwsToken = Jwts.builder() + .setIssuer("Wrong Cluster Identifier") + .setSubject("Leonard McCoy") + .setAudience("ext_0") + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), SignatureAlgorithm.HS512) + .compact(); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = new HashMap(); + headers.put("Authorization", "Bearer " + jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap()), null); + + Assert.assertNull(credentials); + } + + /** extracts a default user credential from a request header */ + private AuthCredentials extractCredentialsFromJwtHeader( + final String signingKeyB64Encoded, + final String encryptionKey, + final JwtBuilder jwtBuilder, + final Boolean bwcPluginCompatibilityMode + ) { + final OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator( + Settings.builder() + .put("enabled", enableOBO) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", encryptionKey) + .build(), + clusterName + ); + + final String jwsToken = jwtBuilder.signWith( + Keys.hmacShaKeyFor(Base64.getDecoder().decode(signingKeyB64Encoded)), + SignatureAlgorithm.HS512 + ).compact(); + final Map headers = Map.of("Authorization", "Bearer " + jwsToken); + return jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()), null); + } + + private Settings defaultSettings() { + return Settings.builder() + .put("enabled", enableOBO) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", claimsEncryptionKey) + .build(); + } + + private Settings disableOBOSettings() { + return Settings.builder() + .put("enabled", disableOBO) + .put("signing_key", signingKeyB64Encoded) + .put("encryption_key", claimsEncryptionKey) + .build(); + } + + private Settings nonSpecifyOBOSetting() { + return Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + } +} diff --git a/src/test/resources/restapi/securityconfig_nondefault.json b/src/test/resources/restapi/securityconfig_nondefault.json index 6fb297be37..a5660c6496 100644 --- a/src/test/resources/restapi/securityconfig_nondefault.json +++ b/src/test/resources/restapi/securityconfig_nondefault.json @@ -170,6 +170,11 @@ "do_not_fail_on_forbidden" : false, "multi_rolespan_enabled" : true, "hosts_resolver_mode" : "ip-only", - "do_not_fail_on_forbidden_empty" : false + "do_not_fail_on_forbidden_empty" : false, + "on_behalf_of": { + "enabled": true, + "signing_key": "VGhpcyBpcyB0aGUgand0IHNpZ25pbmcga2V5IGZvciBhbiBvbiBiZWhhbGYgb2YgdG9rZW4gYXV0aGVudGljYXRpb24gYmFja2VuZCBmb3IgdGVzdGluZyBvZiBleHRlbnNpb25z", + "encryption_key": "ZW5jcnlwdGlvbktleQ==" + } } }