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..07dd64f4af --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -0,0 +1,236 @@ +/* + * 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.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.apache.http.Header; +import org.apache.http.HttpStatus; +import org.apache.http.message.BasicHeader; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; +import org.opensearch.test.framework.OnBehalfOfConfig; +import org.opensearch.test.framework.RolesMapping; +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.junit.Assert.assertTrue; +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 = "{\"description\":\"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 OBO_DESCRIPTION_WITH_INVALID_DURATIONSECONDS = + "{\"description\":\"Testing\", \"service\":\"self-issued\", \"durationSeconds\":\"invalid-seconds\"}"; + + public static final String OBO_DESCRIPTION_WITH_INVALID_PARAMETERS = + "{\"description\":\"Testing\", \"service\":\"self-issued\", \"invalidParameter\":\"invalid-parameter\"}"; + + public static final String HOST_MAPPING_IP = "127.0.0.1"; + public static final String OBO_USER_NAME_WITH_HOST_MAPPING = "obo_user_with_ip_role_mapping"; + public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \"" + + DEFAULT_PASSWORD + + "\", \"password\": \"" + + NEW_PASSWORD + + "\" }"; + + private static final TestSecurityConfig.Role ROLE_WITH_OBO_PERM = new TestSecurityConfig.Role("obo_access_role").clusterPermissions( + "security:obo/create" + ); + + private static final TestSecurityConfig.Role ROLE_WITH_NO_OBO_PERM = new TestSecurityConfig.Role("obo_user_no_perm"); + + protected final static TestSecurityConfig.User OBO_USER = new TestSecurityConfig.User(OBO_USER_NAME_WITH_PERM).roles( + ROLE_WITH_OBO_PERM + ); + + protected final static TestSecurityConfig.User OBO_USER_NO_PERM = new TestSecurityConfig.User(OBO_USER_NAME_NO_PERM).roles( + ROLE_WITH_NO_OBO_PERM + ); + + private static final TestSecurityConfig.Role HOST_MAPPING_ROLE = new TestSecurityConfig.Role("host_mapping_role"); + + protected final static TestSecurityConfig.User HOST_MAPPING_OBO_USER = new TestSecurityConfig.User(OBO_USER_NAME_WITH_HOST_MAPPING) + .roles(HOST_MAPPING_ROLE, ROLE_WITH_OBO_PERM); + + @ClassRule + public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .anonymousAuth(false) + .users(ADMIN_USER, OBO_USER, OBO_USER_NO_PERM, HOST_MAPPING_OBO_USER) + .nodeSettings( + Map.of(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true, SECURITY_RESTAPI_ROLES_ENABLED, List.of("user_admin__all_access")) + ) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .rolesMapping(new RolesMapping(HOST_MAPPING_ROLE).hostIPs(HOST_MAPPING_IP)) + .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, HttpStatus.SC_OK); + } + + @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, HttpStatus.SC_UNAUTHORIZED); + } + + @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(HttpStatus.SC_UNAUTHORIZED); + } + } + + @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(HttpStatus.SC_UNAUTHORIZED); + } + } + + @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, HttpStatus.SC_OK); + } + + @Test + public void shouldNotAuthenticateForNonAdminUserWithoutOBOPermission() { + try (TestRestClient client = cluster.getRestClient(OBO_USER_NO_PERM)) { + assertThat(client.post(OBO_ENDPOINT_PREFIX).getStatusCode(), equalTo(HttpStatus.SC_UNAUTHORIZED)); + } + } + + @Test + public void shouldNotIncludeRolesFromHostMappingInOBOToken() { + String oboToken = generateOboToken(OBO_USER_NAME_WITH_HOST_MAPPING, DEFAULT_PASSWORD); + + Claims claims = Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(oboToken).getBody(); + + Object er = claims.get("er"); + EncryptionDecryptionUtil encryptionDecryptionUtil = new EncryptionDecryptionUtil(encryptionKey); + String rolesClaim = encryptionDecryptionUtil.decrypt(er.toString()); + List roles = Arrays.stream(rolesClaim.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableList()); + + Assert.assertFalse(roles.contains("host_mapping_role")); + } + + @Test + public void shouldNotAuthenticateWithInvalidDurationSeconds() { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) { + client.assertCorrectCredentials(ADMIN_USER_NAME); + TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_DESCRIPTION_WITH_INVALID_DURATIONSECONDS); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + Map oboEndPointResponse = (Map) response.getBodyAs(Map.class); + assertTrue(oboEndPointResponse.containsValue("durationSeconds must be an integer.")); + } + } + + @Test + public void shouldNotAuthenticateWithInvalidAPIParameter() { + try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) { + client.assertCorrectCredentials(ADMIN_USER_NAME); + TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_DESCRIPTION_WITH_INVALID_PARAMETERS); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + Map oboEndPointResponse = (Map) response.getBodyAs(Map.class); + assertTrue(oboEndPointResponse.containsValue("Unrecognized parameter: invalidParameter")); + } + } + + 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(HttpStatus.SC_OK); + Map oboEndPointResponse = (Map) 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); + assertThat(response.getStatusCode(), equalTo(expectedStatusCode)); + if (expectedStatusCode == HttpStatus.SC_OK) { + String username = response.getTextFromJsonBody(POINTER_USERNAME); + assertThat(username, equalTo(expectedUsername)); + } + } + } +} 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/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 434899dca9..87fdfaee65 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 @@ -15,7 +15,6 @@ 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; @@ -31,8 +30,6 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParserBuilder; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.WeakKeyException; import org.apache.http.HttpStatus; @@ -47,6 +44,7 @@ import org.opensearch.security.filter.SecurityRequest; import org.opensearch.security.filter.SecurityResponse; import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.KeyUtils; public class HTTPJwtAuthenticator implements HTTPAuthenticator { @@ -67,45 +65,7 @@ public class HTTPJwtAuthenticator implements HTTPAuthenticator { public HTTPJwtAuthenticator(final Settings settings, final Path configPath) { super(); - final JwtParserBuilder _jwtParserBuilder = Jwts.parserBuilder(); - - 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) { - _jwtParserBuilder.setSigningKey(key); - } else { - _jwtParserBuilder.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", AUTHORIZATION); isDefaultAuthHeader = AUTHORIZATION.equalsIgnoreCase(jwtHeaderName); @@ -114,28 +74,20 @@ public HTTPJwtAuthenticator(final Settings settings, final Path configPath) { requireAudience = settings.get("required_audience"); requireIssuer = settings.get("required_issuer"); - if (requireAudience != null) { - _jwtParserBuilder.requireAudience(requireAudience); - } - - if (requireIssuer != null) { - _jwtParserBuilder.requireIssuer(requireIssuer); - } - - final SecurityManager sm = System.getSecurityManager(); - - if (sm != null) { - sm.checkPermission(new SpecialPermission()); - } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + if (jwtParserBuilder == null) { + jwtParser = null; + } else { + if (requireAudience != null) { + jwtParserBuilder = jwtParserBuilder.require("aud", requireAudience); + } - JwtParser parser = AccessController.doPrivileged(new PrivilegedAction() { - @Override - public JwtParser run() { - return _jwtParserBuilder.build(); + if (requireIssuer != null) { + jwtParserBuilder = jwtParserBuilder.require("iss", requireIssuer); } - }); - jwtParser = parser; + jwtParser = jwtParserBuilder.build(); + } } @Override diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 667f7f9a28..2c046f324e 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -117,6 +117,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; @@ -222,6 +223,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; @@ -548,6 +550,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, @@ -968,7 +973,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); @@ -1059,7 +1064,7 @@ public Collection createComponents( 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..fe58e2adb1 --- /dev/null +++ b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java @@ -0,0 +1,216 @@ +/* + * 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.Arrays; +import java.util.HashSet; +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.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +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"; + + protected final Logger log = LogManager.getLogger(this.getClass()); + + private static final Set RECOGNIZED_PARAMS = new HashSet<>( + Arrays.asList("durationSeconds", "description", "roleSecurityMode", "service") + ); + + @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(); + + validateRequestParameters(requestBody); + + Integer tokenDuration = parseAndValidateDurationSeconds(requestBody.get("durationSeconds")); + tokenDuration = Math.min(tokenDuration, OBO_MAX_EXPIRY_SECONDS); + + final String description = (String) requestBody.getOrDefault("description", null); + + 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 (IllegalArgumentException iae) { + builder.startObject().field("error", iae.getMessage()).endObject(); + response = new BytesRestResponse(RestStatus.BAD_REQUEST, builder); + } catch (final Exception exception) { + log.error("Unexpected error occurred: ", exception); + + builder.startObject().field("error", "An unexpected error occurred. Please check the input and try again.").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); + } + + private void validateRequestParameters(Map requestBody) throws IllegalArgumentException { + for (String key : requestBody.keySet()) { + if (!RECOGNIZED_PARAMS.contains(key)) { + throw new IllegalArgumentException("Unrecognized parameter: " + key); + } + } + } + + private Integer parseAndValidateDurationSeconds(Object durationObj) throws IllegalArgumentException { + if (durationObj == null) { + return OBO_DEFAULT_EXPIRY_SECONDS; + } + + if (durationObj instanceof Integer) { + return (Integer) durationObj; + } else if (durationObj instanceof String) { + try { + return Integer.parseInt((String) durationObj); + } catch (NumberFormatException ignored) {} + } + throw new IllegalArgumentException("durationSeconds must be an integer."); + } +} diff --git a/src/main/java/org/opensearch/security/auth/BackendRegistry.java b/src/main/java/org/opensearch/security/auth/BackendRegistry.java index 980dc64094..db898d7b49 100644 --- a/src/main/java/org/opensearch/security/auth/BackendRegistry.java +++ b/src/main/java/org/opensearch/security/auth/BackendRegistry.java @@ -614,6 +614,9 @@ private User impersonate(final SecurityRequest request, final User originalUser) // loop over all http/rest auth domains for (final AuthDomain authDomain : restAuthDomains) { final AuthenticationBackend authenticationBackend = authDomain.getBackend(); + if (!authDomain.getHttpAuthenticator().supportsImpersonation()) { + continue; + } final User impersonatedUser = checkExistsAndAuthz( restImpersonationCache, new User(impersonatedUserHeader), diff --git a/src/main/java/org/opensearch/security/auth/HTTPAuthenticator.java b/src/main/java/org/opensearch/security/auth/HTTPAuthenticator.java index f5a4df34b5..c79576ef5f 100644 --- a/src/main/java/org/opensearch/security/auth/HTTPAuthenticator.java +++ b/src/main/java/org/opensearch/security/auth/HTTPAuthenticator.java @@ -83,4 +83,13 @@ public interface HTTPAuthenticator { * @return Optional response if is not supported/necessary, response object otherwise. */ Optional reRequestAuthentication(final SecurityRequest request, AuthCredentials credentials); + + /** + * Indicates whether this authenticator supports user impersonation. + * + * @return true if impersonation is supported, false otherwise. + */ + default boolean supportsImpersonation() { + return true; + } } 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..e68a5ef2d7 --- /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 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; + +import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; + +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 (isKeyNull(settings, "encryption_key")) { + 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 { + if (!isKeyNull(settings, "signing_key")) { + String signingKey = settings.get("signing_key"); + + 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 78448fb732..b5eb1c64a8 100644 --- a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java +++ b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java @@ -49,6 +49,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) { @@ -121,4 +126,8 @@ private static boolean clusterHas6xIndices(ClusterState state) { } return 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..88f806418c --- /dev/null +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -0,0 +1,261 @@ +/* + * 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.Optional; +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.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.security.auth.HTTPAuthenticator; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +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; +import static org.opensearch.security.util.AuthTokenUtils.isAccessToRestrictedEndpoints; + +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); + + 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"); + + final SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + jwtParser = AccessController.doPrivileged(new PrivilegedAction() { + @Override + public JwtParser run() { + JwtParserBuilder builder = initParserBuilder(settings.get("signing_key")); + return 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(final SecurityRequest request, final 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 SecurityRequest request) { + if (!oboEnabled) { + log.error("On-behalf-of authentication is disabled"); + 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); + return null; + } 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(SecurityRequest 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() || !jwtToken.toLowerCase().contains(BEARER_PREFIX)) { + logDebug("No Bearer scheme found in header"); + return null; + } + + jwtToken = jwtToken.substring(jwtToken.toLowerCase().indexOf(BEARER_PREFIX) + BEARER_PREFIX.length()); + + return jwtToken; + } + + private void logDebug(String message, Object... args) { + if (log.isDebugEnabled()) { + log.debug(message, args); + } + } + + public Boolean isRequestAllowed(final SecurityRequest request) { + Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path()); + final String suffix = matcher.matches() ? matcher.group(2) : null; + if (isAccessToRestrictedEndpoints(request, suffix)) { + final OpenSearchException exception = ExceptionUtils.invalidUsageOfOBOTokenException(); + log.error(exception.toString()); + return false; + } + return true; + } + + @Override + public Optional reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) { + return Optional.empty(); + } + + @Override + public String getType() { + return "onbehalfof_jwt"; + } + + @Override + public boolean supportsImpersonation() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 106bd7956b..f6dc7a6736 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -127,6 +127,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; @@ -142,6 +143,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 { @@ -278,7 +280,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 f6bbcc2161..483e3bcbd3 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -54,6 +54,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; @@ -61,6 +64,8 @@ import org.opensearch.security.securityconf.impl.v7.ConfigV7.AuthzDomain; import org.opensearch.security.support.ReflectionHelper; +import static org.opensearch.security.util.AuthTokenUtils.isKeyNull; + public class DynamicConfigModelV7 extends DynamicConfigModel { private final ConfigV7 config; @@ -77,13 +82,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(); } @@ -207,6 +220,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<>(); @@ -355,6 +375,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 (!isKeyNull(oboSettings, "signing_key") && !isKeyNull(oboSettings, "encryption_key")) { + 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..79a745c54e 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 "OnBehalfOfSettings [ 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..8fb2199ddf 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 "OnBehalfOfSettings [ 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/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/AuthTokenUtils.java b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java new file mode 100644 index 0000000000..3884bf75fe --- /dev/null +++ b/src/main/java/org/opensearch/security/util/AuthTokenUtils.java @@ -0,0 +1,41 @@ +/* + * 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 org.opensearch.common.settings.Settings; +import org.opensearch.security.filter.SecurityRequest; + +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.rest.RestRequest.Method.PUT; + +public class AuthTokenUtils { + private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; + private static final String ACCOUNT_SUFFIX = "api/account"; + + public static Boolean isAccessToRestrictedEndpoints(final SecurityRequest request, final String suffix) { + if (suffix == null) { + return false; + } + switch (suffix) { + case ON_BEHALF_OF_SUFFIX: + return request.method() == POST; + case ACCOUNT_SUFFIX: + return request.method() == PUT; + default: + return false; + } + } + + public static Boolean isKeyNull(Settings settings, String key) { + return settings.get(key) == null; + } +} 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..4b3aafbbc6 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/KeyUtils.java @@ -0,0 +1,93 @@ +/* + * 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.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.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/AuthTokenUtilsTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java new file mode 100644 index 0000000000..4072d94436 --- /dev/null +++ b/src/test/java/org/opensearch/security/authtoken/jwt/AuthTokenUtilsTest.java @@ -0,0 +1,79 @@ +/* + * 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.opensearch.common.settings.Settings; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.filter.SecurityRequestFactory; +import org.opensearch.security.util.AuthTokenUtils; +import org.opensearch.test.rest.FakeRestRequest; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AuthTokenUtilsTest { + + @Test + public void testIsAccessToRestrictedEndpointsForOnBehalfOfToken() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/generateonbehalfoftoken") + .withMethod(RestRequest.Method.POST) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/generateonbehalfoftoken")); + } + + @Test + public void testIsAccessToRestrictedEndpointsForAccount() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/account") + .withMethod(RestRequest.Method.PUT) + .build(); + + assertTrue(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/account")); + } + + @Test + public void testIsAccessToRestrictedEndpointsFalseCase() { + NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList()); + + FakeRestRequest request = new FakeRestRequest.Builder(namedXContentRegistry).withPath("/api/someotherendpoint") + .withMethod(RestRequest.Method.GET) + .build(); + + assertFalse(AuthTokenUtils.isAccessToRestrictedEndpoints(SecurityRequestFactory.from(request), "api/someotherendpoint")); + } + + @Test + public void testIsKeyNullWithNullValue() { + Settings settings = Settings.builder().put("someKey", (String) null).build(); + assertTrue(AuthTokenUtils.isKeyNull(settings, "someKey")); + } + + @Test + public void testIsKeyNullWithNonNullValue() { + Settings settings = Settings.builder().put("someKey", "value").build(); + assertFalse(AuthTokenUtils.isKeyNull(settings, "someKey")); + } + + @Test + public void testIsKeyNullWithAbsentKey() { + Settings settings = Settings.builder().build(); + assertTrue(AuthTokenUtils.isKeyNull(settings, "absentKey")); + } +} 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..03cbd20b42 --- /dev/null +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -0,0 +1,299 @@ +/* + * 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.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.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.support.ConfigConstants; + +import java.util.List; +import java.util.Optional; +import java.util.function.LongSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JwtVendorTest { + private Appender mockAppender; + private ArgumentCaptor logEventCaptor; + + @Test + public void testCreateJwkFromSettingsThrowsException() { + Settings faultySettings = Settings.builder().put("key.someProperty", "badValue").build(); + + Exception thrownException = assertThrows(Exception.class, () -> new JwtVendor(faultySettings, null)); + + String expectedMessagePart = "An error occurred during the creation of Jwk: "; + assertTrue(thrownException.getMessage().contains(expectedMessagePart)); + } + + @Test + public void testJsonWebKeyPropertiesSetFromJwkSettings() throws Exception { + Settings settings = Settings.builder().put("jwt.key.key1", "value1").put("jwt.key.key2", "value2").build(); + + JsonWebKey jwk = JwtVendor.createJwkFromSettings(settings); + + assertEquals("value1", jwk.getProperty("key1")); + assertEquals("value2", jwk.getProperty("key2")); + } + + @Test + public void testJsonWebKeyPropertiesSetFromSettings() { + Settings jwkSettings = Settings.builder().put("key1", "value1").put("key2", "value2").build(); + + JsonWebKey jwk = new JsonWebKey(); + for (String key : jwkSettings.keySet()) { + jwk.setProperty(key, jwkSettings.get(key)); + } + + assertEquals("value1", jwk.getProperty("key1")); + assertEquals("value2", jwk.getProperty("key2")); + } + + @Test + public void testCreateJwkFromSettings() throws Exception { + Settings settings = Settings.builder().put("signing_key", "abc123").build(); + + JsonWebKey jwk = JwtVendor.createJwkFromSettings(settings); + assertEquals("HS512", jwk.getAlgorithm()); + assertEquals("sig", jwk.getPublicKeyUse().toString()); + assertEquals("abc123", jwk.getProperty("k")); + } + + @Test + public void testCreateJwkFromSettingsWithoutSigningKey() { + Settings settings = Settings.builder().put("jwt", "").build(); + Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + JwtVendor.createJwkFromSettings(settings); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + 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(); + + assertEquals("cluster_0", jwt.getClaim("iss")); + assertEquals("admin", jwt.getClaim("sub")); + assertEquals("audience_0", jwt.getClaim("aud")); + assertNotNull(jwt.getClaim("iat")); + assertNotNull(jwt.getClaim("exp")); + assertEquals(expectedExp, jwt.getClaim("exp")); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + assertEquals(expectedRoles, encryptionUtil.decrypt(jwt.getClaim("er").toString())); + 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) + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + .put(ConfigConstants.EXTENSIONS_BWC_PLUGIN_MODE, "true") + // CS-ENFORCE-SINGLE + .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(); + + assertEquals("cluster_0", jwt.getClaim("iss")); + assertEquals("admin", jwt.getClaim("sub")); + assertEquals("audience_0", jwt.getClaim("aud")); + assertNotNull(jwt.getClaim("iat")); + assertNotNull(jwt.getClaim("exp")); + assertEquals(expectedExp, jwt.getClaim("exp")); + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + assertEquals(expectedRoles, encryptionUtil.decrypt(jwt.getClaim("er").toString())); + assertNotNull(jwt.getClaim("br")); + assertEquals(expectedBackendRoles, jwt.getClaim("br")); + } + + @Test + public void testCreateJwtWithNegativeExpiry() { + 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 = assertThrows(RuntimeException.class, () -> { + try { + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals("java.lang.Exception: The expiration time should be a positive integer", exception.getMessage()); + } + + @Test + public void testCreateJwtWithExceededExpiry() 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"); + int expirySeconds = 900; + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder().put("signing_key", "abc123").put("encryption_key", claimsEncryptionKey).build(); + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + + Throwable exception = assertThrows(RuntimeException.class, () -> { + try { + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals( + "java.lang.Exception: The provided expiration time exceeds the maximum allowed duration of 600 seconds", + 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 = 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); + } + }); + 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 = assertThrows(RuntimeException.class, () -> { + try { + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, List.of(), true); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals("java.lang.Exception: Roles cannot be null", exception.getMessage()); + } + + @Test + public void testCreateJwtLogsCorrectly() throws Exception { + mockAppender = mock(Appender.class); + logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); + when(mockAppender.getName()).thenReturn("MockAppender"); + when(mockAppender.isStarted()).thenReturn(true); + Logger logger = (Logger) LogManager.getLogger(JwtVendor.class); + logger.addAppender(mockAppender); + logger.setLevel(Level.DEBUG); + + // Mock settings and other required dependencies + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + Settings settings = Settings.builder().put("signing_key", "abc123").put("encryption_key", claimsEncryptionKey).build(); + + String issuer = "cluster_0"; + String subject = "admin"; + String audience = "audience_0"; + List roles = List.of("IT", "HR"); + List backendRoles = List.of("Sales", "Support"); + int expirySeconds = 300; + + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + + jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + + verify(mockAppender, times(1)).append(logEventCaptor.capture()); + + LogEvent logEvent = logEventCaptor.getValue(); + String logMessage = logEvent.getMessage().getFormattedMessage(); + assertTrue(logMessage.startsWith("Created JWT:")); + + String[] parts = logMessage.split("\\."); + assertTrue(parts.length >= 3); + } +} 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..752e7e1f7e --- /dev/null +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -0,0 +1,691 @@ +/* + * 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.lang.reflect.Field; +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 java.util.Set; +import java.util.List; +import java.util.HashSet; +import java.util.Arrays; +import java.util.Optional; + +import javax.crypto.SecretKey; + +import com.google.common.io.BaseEncoding; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.WeakKeyException; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.http.HttpHeaders; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.ErrorHandler; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.junit.Test; + +import org.mockito.ArgumentCaptor; +import org.opensearch.SpecialPermission; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; +import org.opensearch.security.filter.SecurityRequest; +import org.opensearch.security.filter.SecurityResponse; +import org.opensearch.security.user.AuthCredentials; +import org.opensearch.security.util.FakeRestRequest; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.rest.RestRequest.Method.PUT; + +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)); + + private static final String SECURITY_PREFIX = "/_plugins/_security/"; + private static final String ON_BEHALF_OF_SUFFIX = "api/generateonbehalfoftoken"; + private static final String ACCOUNT_SUFFIX = "api/account"; + + @Test + public void testReRequestAuthenticationReturnsEmptyOptional() { + OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Optional result = authenticator.reRequestAuthentication(null, null); + assertFalse(result.isPresent()); + } + + @Test + public void testGetTypeReturnsExpectedType() { + OnBehalfOfAuthenticator authenticator = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + String type = authenticator.getType(); + assertEquals("onbehalfof_jwt", type); + } + + @Test + public void testNoKey() { + Exception exception = assertThrows( + RuntimeException.class, + () -> extractCredentialsFromJwtHeader( + null, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), + false + ) + ); + assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + } + + @Test + public void testEmptyKey() { + Exception exception = assertThrows( + RuntimeException.class, + () -> extractCredentialsFromJwtHeader( + null, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy"), + false + ) + ); + assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + } + + @Test + public void testBadKey() { + Exception exception = 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 + ) + ); + assertTrue(exception.getMessage().contains("The specified key byte array is 80 bits")); + } + + @Test + public void testWeakKeyExceptionHandling() throws Exception { + Appender mockAppender = mock(Appender.class); + ErrorHandler mockErrorHandler = mock(ErrorHandler.class); + when(mockAppender.getHandler()).thenReturn(mockErrorHandler); + when(mockAppender.isStarted()).thenReturn(true); + + ArgumentCaptor logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); + when(mockAppender.getName()).thenReturn("MockAppender"); + doNothing().when(mockAppender).append(logEventCaptor.capture()); + + Logger logger = (Logger) LogManager.getLogger(OnBehalfOfAuthenticator.class); + logger.addAppender(mockAppender); + + JwtParser mockJwtParser = mock(JwtParser.class); + when(mockJwtParser.parseClaimsJws(anyString())).thenThrow(new WeakKeyException("Test Exception")); + + Settings settings = Settings.builder().put("signing_key", "testKey").put("encryption_key", claimsEncryptionKey).build(); + OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, "testCluster"); + + Field jwtParserField = OnBehalfOfAuthenticator.class.getDeclaredField("jwtParser"); + jwtParserField.setAccessible(true); + jwtParserField.set(auth, mockJwtParser); + + SecurityRequest mockedRequest = mock(SecurityRequest.class); + when(mockedRequest.header(anyString())).thenReturn("Bearer testToken"); + when(mockedRequest.path()).thenReturn("/some/sample/path"); + + auth.extractCredentials(mockedRequest, null); + + boolean foundLog = logEventCaptor.getAllValues() + .stream() + .anyMatch(event -> event.getMessage().getFormattedMessage().contains("Cannot authenticate user with JWT because of ")); + assertTrue(foundLog); + + logger.removeAppender(mockAppender); + } + + @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()).asSecurityRequest(), + null + ); + + 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()).asSecurityRequest(), + null + ); + 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()).asSecurityRequest(), + null + ); + assertNull(credentials); + } + + @Test + public void testInvalidTokenException() { + Appender mockAppender = mock(Appender.class); + ArgumentCaptor logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); + when(mockAppender.getName()).thenReturn("MockAppender"); + when(mockAppender.isStarted()).thenReturn(true); + Logger logger = (Logger) LogManager.getLogger(OnBehalfOfAuthenticator.class); + logger.addAppender(mockAppender); + logger.setLevel(Level.DEBUG); + doNothing().when(mockAppender).append(logEventCaptor.capture()); + + String invalidToken = "invalidToken"; + Settings settings = defaultSettings(); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(settings, clusterName); + + Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken); + + AuthCredentials credentials = jwtAuth.extractCredentials( + new FakeRestRequest(headers, Collections.emptyMap()).asSecurityRequest(), + null + ); + + assertNull(credentials); + + boolean foundLog = logEventCaptor.getAllValues() + .stream() + .anyMatch(event -> event.getMessage().getFormattedMessage().contains("Invalid or expired JWT token.")); + assertTrue(foundLog); + + logger.removeAppender(mockAppender); + } + + @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()).asSecurityRequest(), + null + ); + 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()).asSecurityRequest(), + null + ); + + assertNotNull(credentials); + assertEquals("Leonard McCoy", credentials.getUsername()); + assertEquals(0, credentials.getSecurityRoles().size()); + assertEquals(0, credentials.getBackendRoles().size()); + 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()).asSecurityRequest(), + null + ); + + assertNull(credentials); + } + + @Test + public void testSecurityManagerCheck() { + SecurityManager mockSecurityManager = mock(SecurityManager.class); + System.setSecurityManager(mockSecurityManager); + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer someToken"); + + try { + jwtAuth.extractCredentials(new FakeRestRequest(headers, new HashMap<>()).asSecurityRequest(), null); + } finally { + System.setSecurityManager(null); + } + + verify(mockSecurityManager, times(3)).checkPermission(any(SpecialPermission.class)); + } + + @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()).asSecurityRequest(), + null + ); + assertNull(credentials); + } + + @Test + public void testMissingBearerScheme() throws Exception { + Appender mockAppender = mock(Appender.class); + ArgumentCaptor logEventCaptor = ArgumentCaptor.forClass(LogEvent.class); + when(mockAppender.getName()).thenReturn("MockAppender"); + when(mockAppender.isStarted()).thenReturn(true); + Logger logger = (Logger) LogManager.getLogger(OnBehalfOfAuthenticator.class); + logger.addAppender(mockAppender); + logger.setLevel(Level.DEBUG); + doNothing().when(mockAppender).append(logEventCaptor.capture()); + + String craftedToken = "beaRerSomeActualToken"; // This token matches the BEARER pattern but doesn't contain the BEARER_PREFIX + + OnBehalfOfAuthenticator jwtAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + Map headers = Collections.singletonMap(HttpHeaders.AUTHORIZATION, craftedToken); + + AuthCredentials credentials = jwtAuth.extractCredentials( + new FakeRestRequest(headers, Collections.emptyMap()).asSecurityRequest(), + null + ); + + assertNull(credentials); + + boolean foundLog = logEventCaptor.getAllValues() + .stream() + .anyMatch(event -> event.getMessage().getFormattedMessage().contains("No Bearer scheme found in header")); + assertTrue(foundLog); + + logger.removeAppender(mockAppender); + } + + @Test + public void testMissingBearerPrefixInAuthHeader() { + 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, jwsToken); + + AuthCredentials credentials = jwtAuth.extractCredentials( + new FakeRestRequest(headers, Collections.emptyMap()).asSecurityRequest(), + null + ); + + assertNull(credentials); + } + + @Test + public void testPlainTextedRolesFromDrClaim() { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Leonard McCoy").claim("dr", "role1,role2").setAudience("svc1"), + true + ); + + assertNotNull(credentials); + assertEquals("Leonard McCoy", credentials.getUsername()); + assertEquals(2, credentials.getSecurityRoles().size()); + assertEquals(0, credentials.getBackendRoles().size()); + } + + @Test + public void testBackendRolesExtraction() { + String rolesString = "role1, role2 ,role3,role4 , role5"; + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Test User").setAudience("audience_0").claim("br", rolesString), + true + ); + + assertNotNull(credentials); + + Set expectedBackendRoles = new HashSet<>(Arrays.asList("role1", "role2", "role3", "role4", "role5")); + Set actualBackendRoles = credentials.getBackendRoles(); + + assertTrue(actualBackendRoles.containsAll(expectedBackendRoles)); + } + + @Test + public void testRolesDecryptionFromErClaim() { + EncryptionDecryptionUtil util = new EncryptionDecryptionUtil(claimsEncryptionKey); + String encryptedRole = util.encrypt("admin,developer"); + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Test User").setAudience("audience_0").claim("er", encryptedRole), + true + ); + + assertNotNull(credentials); + List expectedRoles = Arrays.asList("admin", "developer"); + assertTrue(credentials.getSecurityRoles().containsAll(expectedRoles)); + } + + @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 + ); + + assertNotNull(credentials); + assertEquals("Leonard McCoy", credentials.getUsername()); + 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 + ); + + assertNotNull(credentials); + assertEquals("Leonard McCoy", credentials.getUsername()); + assertEquals(1, credentials.getSecurityRoles().size()); + 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 + ); + + assertNotNull(credentials); + assertEquals("Leonard McCoy", credentials.getUsername()); + assertEquals(0, credentials.getSecurityRoles().size()); + 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 + ); + + assertNull(credentials); + } + + @Test + public void testMissingAudienceClaim() throws Exception { + + final AuthCredentials credentials = extractCredentialsFromJwtHeader( + signingKeyB64Encoded, + claimsEncryptionKey, + Jwts.builder().setIssuer(clusterName).setSubject("Test User").claim("roles", "role1,role2"), + false + ); + + 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 + ); + + 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 + ); + + 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); + + assertNotNull(credentials); + assertEquals("Cluster_0", credentials.getUsername()); + assertEquals(3, credentials.getSecurityRoles().size()); + assertTrue(credentials.getSecurityRoles().contains("a")); + assertTrue(credentials.getSecurityRoles().contains("b")); + 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()).asSecurityRequest(), + null + ); + + assertNull(credentials); + } + + @Test + public void testRequestNotAllowed() { + OnBehalfOfAuthenticator oboAuth = new OnBehalfOfAuthenticator(defaultSettings(), clusterName); + + // Test POST on generate on-behalf-of token endpoint + SecurityRequest mockedRequest1 = mock(SecurityRequest.class); + when(mockedRequest1.header(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer someToken"); + when(mockedRequest1.path()).thenReturn(SECURITY_PREFIX + ON_BEHALF_OF_SUFFIX); + when(mockedRequest1.method()).thenReturn(POST); + assertFalse(oboAuth.isRequestAllowed(mockedRequest1)); + assertNull(oboAuth.extractCredentials(mockedRequest1, null)); + + // Test PUT on password changing endpoint + SecurityRequest mockedRequest2 = mock(SecurityRequest.class); + when(mockedRequest2.header(HttpHeaders.AUTHORIZATION)).thenReturn("Bearer someToken"); + when(mockedRequest2.path()).thenReturn(SECURITY_PREFIX + ACCOUNT_SUFFIX); + when(mockedRequest2.method()).thenReturn(PUT); + assertFalse(oboAuth.isRequestAllowed(mockedRequest2)); + assertNull(oboAuth.extractCredentials(mockedRequest2, null)); + } + + /** 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<>()).asSecurityRequest(), 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 noSigningKeyOBOSettings() { + return Settings.builder().put("enabled", disableOBO).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==" + } } }