From 467645241d42c8ae02166c4d41d3e7aa5edafdc7 Mon Sep 17 00:00:00 2001 From: Stephen Crawford <65832608+scrawfor99@users.noreply.github.com> Date: Wed, 1 Nov 2023 12:04:40 -0400 Subject: [PATCH] Implement IdentityPlugin in Security plugin (#3538) Signed-off-by: Stephen Crawford Signed-off-by: Stephen Crawford <65832608+scrawfor99@users.noreply.github.com> Signed-off-by: Peter Nied Co-authored-by: Peter Nied --- .../http/OnBehalfOfJwtAuthenticationTest.java | 89 ++++++- .../security/OpenSearchSecurityPlugin.java | 26 +- .../onbehalf/CreateOnBehalfOfTokenAction.java | 92 ++----- .../jwt/ExpiringBearerAuthToken.java | 40 +++ .../security/authtoken/jwt/JwtVendor.java | 52 ++-- .../ConfigurationRepository.java | 2 +- .../dlic/rest/api/InternalUsersApiAction.java | 6 +- .../http/OnBehalfOfAuthenticator.java | 36 ++- .../identity/SecurityTokenManager.java | 141 ++++++++++ .../opensearch/security/user/UserService.java | 6 +- .../security/authtoken/jwt/JwtVendorTest.java | 26 +- .../http/OnBehalfOfAuthenticatorTest.java | 19 +- .../identity/SecurityTokenManagerTest.java | 247 ++++++++++++++++++ 13 files changed, 627 insertions(+), 155 deletions(-) create mode 100644 src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java create mode 100644 src/main/java/org/opensearch/security/identity/SecurityTokenManager.java create mode 100644 src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java diff --git a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java index 2f8437af69..1233e23341 100644 --- a/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java +++ b/src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java @@ -11,10 +11,10 @@ package org.opensearch.security.http; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -29,10 +29,12 @@ import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpStatus; import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; - +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil; import org.opensearch.test.framework.OnBehalfOfConfig; import org.opensearch.test.framework.RolesMapping; @@ -47,6 +49,7 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.contains; import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; 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; @@ -67,6 +70,11 @@ public class OnBehalfOfJwtAuthenticationTest { StandardCharsets.UTF_8 ) ); + private static final String alternativeSigningKey = Base64.getEncoder() + .encodeToString( + "alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".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"; @@ -110,18 +118,36 @@ public class OnBehalfOfJwtAuthenticationTest { 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); + private static OnBehalfOfConfig defaultOnBehalfOfConfig() { + return new OnBehalfOfConfig().oboEnabled(oboEnabled).signingKey(signingKey).encryptionKey(encryptionKey); + } + @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")) + Map.of( + SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, + true, + SECURITY_RESTAPI_ROLES_ENABLED, + ADMIN_USER.getRoleNames(), + SECURITY_RESTAPI_ADMIN_ENABLED, + true, + "plugins.security.unsupported.restapi.allow_securityconfig_modification", + true + ) ) .authc(AUTHC_HTTPBASIC_INTERNAL) .rolesMapping(new RolesMapping(HOST_MAPPING_ROLE).hostIPs(HOST_MAPPING_IP)) - .onBehalfOf(new OnBehalfOfConfig().oboEnabled(oboEnabled).signingKey(signingKey).encryptionKey(encryptionKey)) + .onBehalfOf(defaultOnBehalfOfConfig()) .build(); + @Before + public void before() { + patchOnBehalfOfConfig(defaultOnBehalfOfConfig()); + } + @Test public void shouldAuthenticateWithOBOTokenEndPoint() { String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD); @@ -196,7 +222,7 @@ public void shouldNotAuthenticateWithInvalidDurationSeconds() { client.confirmCorrectCredentials(ADMIN_USER_NAME); TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_DESCRIPTION_WITH_INVALID_DURATIONSECONDS); response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); - assertThat(response.getTextFromJsonBody("/error"), equalTo("durationSeconds must be an integer.")); + assertThat(response.getTextFromJsonBody("/error"), equalTo("durationSeconds must be a number.")); } } @@ -210,6 +236,44 @@ public void shouldNotAuthenticateWithInvalidAPIParameter() { } } + @Test + public void shouldNotAllowTokenWhenOboIsDisabled() { + final String oboToken = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeader = new BasicHeader("Authorization", "Bearer " + oboToken); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Disable OBO via config and see that the authenticator doesn't authorize + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().oboEnabled(false)); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + + // Reenable OBO via config and see that the authenticator is working again + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().oboEnabled(true)); + authenticateWithOboToken(oboHeader, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + } + + @Test + public void oboSigningCheckChangeIsDetected() { + final String oboTokenOrignalKey = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeaderOriginalKey = new BasicHeader("Authorization", "Bearer " + oboTokenOrignalKey); + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Change the signing key + patchOnBehalfOfConfig(defaultOnBehalfOfConfig().signingKey(alternativeSigningKey)); + + // Original key should no longer work + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + + // Generate new key, check that it is valid + final String oboTokenOtherKey = generateOboToken(OBO_USER_NAME_WITH_PERM, DEFAULT_PASSWORD); + final Header oboHeaderOtherKey = new BasicHeader("Authorization", "Bearer " + oboTokenOtherKey); + authenticateWithOboToken(oboHeaderOtherKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + + // Change back to the original signing key and the original key still works, and the new key doesn't + patchOnBehalfOfConfig(defaultOnBehalfOfConfig()); + authenticateWithOboToken(oboHeaderOriginalKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_OK); + authenticateWithOboToken(oboHeaderOtherKey, OBO_USER_NAME_WITH_PERM, HttpStatus.SC_UNAUTHORIZED); + } + private String generateOboToken(String username, String password) { try (TestRestClient client = cluster.getRestClient(username, password)) { client.confirmCorrectCredentials(username); @@ -233,4 +297,19 @@ private void authenticateWithOboToken(Header authHeader, String expectedUsername } } } + + private void patchOnBehalfOfConfig(final OnBehalfOfConfig oboConfig) { + try (final TestRestClient adminClient = cluster.getRestClient(cluster.getAdminCertificate())) { + final XContentBuilder configBuilder = XContentFactory.jsonBuilder(); + configBuilder.value(oboConfig); + + final String patchBody = "[{ \"op\": \"replace\", \"path\": \"/config/dynamic/on_behalf_of\", \"value\":" + + configBuilder.toString() + + "}]"; + final var response = adminClient.patch("_plugins/_security/api/securityconfig", patchBody); + response.assertStatusCode(HttpStatus.SC_OK); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } } diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index b61c9db74f..de7693e393 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -29,6 +29,7 @@ // CS-SUPPRESS-SINGLE: RegexpSingleline Extensions manager used to allow/disallow TLS connections to extensions import com.google.common.collect.Lists; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.lucene.search.QueryCachingPolicy; @@ -75,12 +76,15 @@ import org.opensearch.extensions.ExtensionsManager; import org.opensearch.http.HttpServerTransport; import org.opensearch.http.HttpServerTransport.Dispatcher; +import org.opensearch.identity.Subject; +import org.opensearch.identity.noop.NoopSubject; import org.opensearch.index.IndexModule; import org.opensearch.index.cache.query.QueryCache; import org.opensearch.indices.IndicesService; import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.plugins.ClusterPlugin; import org.opensearch.plugins.ExtensionAwarePlugin; +import org.opensearch.plugins.IdentityPlugin; import org.opensearch.plugins.MapperPlugin; import org.opensearch.repositories.RepositoriesService; import org.opensearch.rest.RestController; @@ -121,6 +125,7 @@ import org.opensearch.security.http.SecurityHttpServerTransport; import org.opensearch.security.http.SecurityNonSslHttpServerTransport; import org.opensearch.security.http.XFFResolver; +import org.opensearch.security.identity.SecurityTokenManager; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.PrivilegesInterceptor; import org.opensearch.security.privileges.RestLayerPrivilegesEvaluator; @@ -207,7 +212,8 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin ClusterPlugin, MapperPlugin, // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings - ExtensionAwarePlugin + ExtensionAwarePlugin, + IdentityPlugin // CS-ENFORCE-SINGLE { @@ -234,6 +240,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile SslExceptionHandler sslExceptionHandler; private volatile Client localClient; private final boolean disabled; + private volatile SecurityTokenManager tokenManager; private volatile DynamicConfigFactory dcf; private final List demoCertHashes = new ArrayList(3); private volatile SecurityFilter sf; @@ -561,9 +568,7 @@ public List getRestHandlers( principalExtractor ) ); - CreateOnBehalfOfTokenAction cobot = new CreateOnBehalfOfTokenAction(settings, threadPool, Objects.requireNonNull(cs)); - dcf.registerDCFListener(cobot); - handlers.add(cobot); + handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); handlers.addAll( SecurityRestApiActions.getHandler( settings, @@ -1035,6 +1040,7 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool); + tokenManager = new SecurityTokenManager(cs, threadPool, userService); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); @@ -1084,6 +1090,7 @@ public Collection createComponents( dcf.registerDCFListener(evaluator); dcf.registerDCFListener(restLayerEvaluator); dcf.registerDCFListener(securityRestHandler); + dcf.registerDCFListener(tokenManager); if (!(auditLog instanceof NullAuditLog)) { // Don't register if advanced modules is disabled in which case auditlog is instance of NullAuditLog dcf.registerDCFListener(auditLog); @@ -1935,6 +1942,17 @@ private static String handleKeyword(final String field) { return field; } + @Override + public Subject getSubject() { + // Not supported + return new NoopSubject(); + } + + @Override + public SecurityTokenManager getTokenManager() { + return tokenManager; + } + public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; diff --git a/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java index a885a42ab2..0863fee552 100644 --- a/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java +++ b/src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java @@ -15,31 +15,21 @@ import java.util.Arrays; 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.identity.tokens.OnBehalfOfClaims; 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 org.opensearch.security.identity.SecurityTokenManager; import static org.opensearch.rest.RestRequest.Method.POST; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; @@ -51,42 +41,16 @@ public class CreateOnBehalfOfTokenAction extends BaseRestHandler { "/_plugins/_security/api" ); - private JwtVendor vendor; - private final ThreadPool threadPool; - private final ClusterService clusterService; - - private ConfigModel configModel; - - public static final Integer OBO_DEFAULT_EXPIRY_SECONDS = 5 * 60; - public static final Integer OBO_MAX_EXPIRY_SECONDS = 10 * 60; - + public static final long OBO_DEFAULT_EXPIRY_SECONDS = 5 * 60; + public static final long 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 Logger LOG = LogManager.getLogger(CreateOnBehalfOfTokenAction.class); - @Subscribe - public void onConfigModelChanged(final ConfigModel configModel) { - this.configModel = configModel; - } + private final SecurityTokenManager securityTokenManager; - @Subscribe - public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { - final Settings settings = dcm.getDynamicOnBehalfOfSettings(); - - final Boolean enabled = Boolean.parseBoolean(settings.get("enabled")); - final String signingKey = settings.get("signing_key"); - final 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; + public CreateOnBehalfOfTokenAction(final SecurityTokenManager securityTokenManager) { + this.securityTokenManager = securityTokenManager; } @Override @@ -116,45 +80,31 @@ public void accept(final RestChannel channel) throws Exception { final XContentBuilder builder = channel.newBuilder(); BytesRestResponse response; try { - if (vendor == null) { + if (!securityTokenManager.issueOnBehalfOfTokenAllowed()) { channel.sendResponse( new BytesRestResponse( - RestStatus.SERVICE_UNAVAILABLE, + RestStatus.BAD_REQUEST, "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(InputParameters.DURATION.paramName)); + long tokenDuration = parseAndValidateDurationSeconds(requestBody.get(InputParameters.DURATION.paramName)); tokenDuration = Math.min(tokenDuration, OBO_MAX_EXPIRY_SECONDS); final String description = (String) requestBody.getOrDefault(InputParameters.DESCRIPTION.paramName, null); - final String service = (String) requestBody.getOrDefault(InputParameters.SERVICE.paramName, DEFAULT_SERVICE); - final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); - final Set mappedRoles = mapRoles(user); + final var token = securityTokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims(service, tokenDuration)); 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()), - false - ); - builder.field("authenticationToken", token); - builder.field("durationSeconds", tokenDuration); + builder.field("user", token.getSubject()); + builder.field("authenticationToken", token.getCompleteToken()); + builder.field("durationSeconds", token.getExpiresInSeconds()); builder.endObject(); response = new BytesRestResponse(RestStatus.OK, builder); @@ -162,7 +112,7 @@ public void accept(final RestChannel channel) throws Exception { builder.startObject().field("error", iae.getMessage()).endObject(); response = new BytesRestResponse(RestStatus.BAD_REQUEST, builder); } catch (final Exception exception) { - log.error("Unexpected error occurred: ", exception); + LOG.error("Unexpected error occurred: ", exception); builder.startObject().field("error", "An unexpected error occurred. Please check the input and try again.").endObject(); @@ -186,10 +136,6 @@ private InputParameters(final String paramName) { } } - private Set mapRoles(final User user) { - return this.configModel.mapSecurityRoles(user, null); - } - private void validateRequestParameters(final Map requestBody) throws IllegalArgumentException { for (final String key : requestBody.keySet()) { Arrays.stream(InputParameters.values()) @@ -199,7 +145,7 @@ private void validateRequestParameters(final Map requestBody) th } } - private Integer parseAndValidateDurationSeconds(final Object durationObj) throws IllegalArgumentException { + private long parseAndValidateDurationSeconds(final Object durationObj) throws IllegalArgumentException { if (durationObj == null) { return OBO_DEFAULT_EXPIRY_SECONDS; } @@ -208,9 +154,9 @@ private Integer parseAndValidateDurationSeconds(final Object durationObj) throws return (Integer) durationObj; } else if (durationObj instanceof String) { try { - return Integer.parseInt((String) durationObj); + return Long.parseLong((String) durationObj); } catch (final NumberFormatException ignored) {} } - throw new IllegalArgumentException("durationSeconds must be an integer."); + throw new IllegalArgumentException("durationSeconds must be a number."); } } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java new file mode 100644 index 0000000000..a0879cd4da --- /dev/null +++ b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.authtoken.jwt; + +import java.util.Date; + +import org.opensearch.identity.tokens.BearerAuthToken; + +public class ExpiringBearerAuthToken extends BearerAuthToken { + private final String subject; + private final Date expiry; + private final long expiresInSeconds; + + public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry, final long expiresInSeconds) { + super(serializedToken); + this.subject = subject; + this.expiry = expiry; + this.expiresInSeconds = expiresInSeconds; + } + + public String getSubject() { + return subject; + } + + public Date getExpiry() { + return expiry; + } + + public long getExpiresInSeconds() { + return expiresInSeconds; + } +} diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index 754d961883..6340688607 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -46,7 +46,6 @@ public class JwtVendor { private final JWSSigner signer; private final LongSupplier timeProvider; private final EncryptionDecryptionUtil encryptionDecryptionUtil; - private static final Integer DEFAULT_EXPIRY_SECONDS = 300; private static final Integer MAX_EXPIRY_SECONDS = 600; public JwtVendor(final Settings settings, final Optional timeProvider) { @@ -59,11 +58,7 @@ public JwtVendor(final Settings settings, final Optional timeProvi } else { this.encryptionDecryptionUtil = new EncryptionDecryptionUtil(settings.get("encryption_key")); } - if (timeProvider.isPresent()) { - this.timeProvider = timeProvider.get(); - } else { - this.timeProvider = () -> System.currentTimeMillis(); - } + this.timeProvider = timeProvider.orElse(System::currentTimeMillis); } /* @@ -72,15 +67,15 @@ public JwtVendor(final Settings settings, final Optional timeProvi * PublicKeyUse: SIGN * Encryption Algorithm: HS512 * */ - static Tuple createJwkFromSettings(Settings settings) { + static Tuple createJwkFromSettings(final Settings settings) { final OctetSequenceKey key; if (!isKeyNull(settings, "signing_key")) { - String signingKey = settings.get("signing_key"); + final String signingKey = settings.get("signing_key"); key = new OctetSequenceKey.Builder(Base64.getDecoder().decode(signingKey)).algorithm(JWSAlgorithm.HS512) .keyUse(KeyUse.SIGNATURE) .build(); } else { - Settings jwkSettings = settings.getAsSettings("jwt").getAsSettings("key"); + final Settings jwkSettings = settings.getAsSettings("jwt").getAsSettings("key"); if (jwkSettings.isEmpty()) { throw new OpenSearchException( @@ -88,7 +83,7 @@ static Tuple createJwkFromSettings(Settings settings) { ); } - String signingKey = jwkSettings.get("k"); + final String signingKey = jwkSettings.get("k"); key = new OctetSequenceKey.Builder(Base64.getDecoder().decode(signingKey)).algorithm(JWSAlgorithm.HS512) .keyUse(KeyUse.SIGNATURE) .build(); @@ -96,21 +91,22 @@ static Tuple createJwkFromSettings(Settings settings) { try { return new Tuple<>(key, new MACSigner(key)); - } catch (KeyLengthException kle) { + } catch (final KeyLengthException kle) { throw new OpenSearchException(kle); } } - public String createJwt( - String issuer, - String subject, - String audience, - Integer expirySeconds, - List roles, - List backendRoles, - boolean includeBackendRoles + public ExpiringBearerAuthToken createJwt( + final String issuer, + final String subject, + final String audience, + final long requestedExpirySeconds, + final List roles, + final List backendRoles, + final boolean includeBackendRoles ) throws JOSEException, ParseException { - final Date now = new Date(timeProvider.getAsLong()); + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); claimsBuilder.issuer(issuer); @@ -119,28 +115,22 @@ public String createJwt( claimsBuilder.audience(audience); claimsBuilder.notBeforeTime(now); - if (expirySeconds > MAX_EXPIRY_SECONDS) { - throw new IllegalArgumentException( - "The provided expiration time exceeds the maximum allowed duration of " + MAX_EXPIRY_SECONDS + " seconds" - ); - } - - expirySeconds = (expirySeconds == null) ? DEFAULT_EXPIRY_SECONDS : Math.min(expirySeconds, MAX_EXPIRY_SECONDS); + final long expirySeconds = Math.min(requestedExpirySeconds, MAX_EXPIRY_SECONDS); if (expirySeconds <= 0) { throw new IllegalArgumentException("The expiration time should be a positive integer"); } - final Date expiryTime = new Date(timeProvider.getAsLong() + expirySeconds * 1000); + final Date expiryTime = new Date(currentTimeMs + expirySeconds * 1000); claimsBuilder.expirationTime(expiryTime); if (roles != null) { - String listOfRoles = String.join(",", roles); + final String listOfRoles = String.join(",", roles); claimsBuilder.claim("er", encryptionDecryptionUtil.encrypt(listOfRoles)); } else { throw new IllegalArgumentException("Roles cannot be null"); } if (includeBackendRoles && backendRoles != null) { - String listOfBackendRoles = String.join(",", backendRoles); + final String listOfBackendRoles = String.join(",", backendRoles); claimsBuilder.claim("br", listOfBackendRoles); } @@ -156,6 +146,6 @@ public String createJwt( ); } - return signedJwt.serialize(); + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); } } diff --git a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java index 17ea48f46c..04ad8f7420 100644 --- a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java +++ b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java @@ -461,7 +461,7 @@ public Map> getConfigurationsFromIndex( throw new OpenSearchException(e); } - if (logComplianceEvent && auditLog.getComplianceConfig().isEnabled()) { + if (logComplianceEvent && auditLog.getComplianceConfig() != null && auditLog.getComplianceConfig().isEnabled()) { CType configurationType = configTypes.iterator().next(); Map fields = new HashMap(); fields.put(configurationType.toLCString(), Strings.toString(MediaTypeRegistry.JSON, retVal.get(configurationType))); diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java index 5253504bb8..449762c8ff 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java @@ -166,11 +166,7 @@ void generateAuthToken(final RestChannel channel, final SecurityConfiguration se try { final var username = securityConfiguration.entityName(); final var authToken = userService.generateAuthToken(username); - if (!Strings.isNullOrEmpty(authToken)) { - ok(channel, "'" + username + "' authtoken generated " + authToken); - } else { - badRequest(channel, "'" + username + "' authtoken failed to be created."); - } + ok(channel, "'" + username + "' authtoken generated " + authToken); } catch (final UserServiceException e) { badRequest(channel, e.getMessage()); } diff --git a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java index a3e0e1ba4e..f493b7c919 100644 --- a/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java +++ b/src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java @@ -11,25 +11,24 @@ package org.opensearch.security.http; +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; + 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.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.JwtParserBuilder; -import io.jsonwebtoken.security.WeakKeyException; import org.apache.hc.core5.http.HttpHeaders; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; - import org.opensearch.OpenSearchException; import org.opensearch.OpenSearchSecurityException; import org.opensearch.SpecialPermission; @@ -43,12 +42,14 @@ 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; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.security.WeakKeyException; public class OnBehalfOfAuthenticator implements HTTPAuthenticator { + private static final int MINIMUM_SIGNING_KEY_BIT_LENGTH = 512; private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)"; private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX); @@ -80,17 +81,26 @@ public JwtParser run() { return builder.build(); } }); - this.clusterName = clusterName; this.encryptionUtil = new EncryptionDecryptionUtil(encryptionKey); } private JwtParserBuilder initParserBuilder(final String signingKey) { - JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); + if (signingKey == null) { + throw new OpenSearchSecurityException("Unable to find on behalf of authenticator signing_key"); + } - if (jwtParserBuilder == null) { - throw new OpenSearchSecurityException("Unable to find on behalf of authenticator signing key"); + final int signingKeyLengthBits = signingKey.length() * 8; + if (signingKeyLengthBits < MINIMUM_SIGNING_KEY_BIT_LENGTH) { + throw new OpenSearchSecurityException( + "Signing key size was " + + signingKeyLengthBits + + " bits, which is not secure enough. Please use a signing_key with a size >= " + + MINIMUM_SIGNING_KEY_BIT_LENGTH + + " bits." + ); } + JwtParserBuilder jwtParserBuilder = KeyUtils.createJwtParserBuilderFromSigningKey(signingKey, log); return jwtParserBuilder; } diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java new file mode 100644 index 0000000000..9f4ffecf57 --- /dev/null +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -0,0 +1,141 @@ +/* + * 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.identity; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import joptsimple.internal.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.greenrobot.eventbus.Subscribe; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.identity.Subject; +import org.opensearch.identity.noop.NoopSubject; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.OnBehalfOfClaims; +import org.opensearch.identity.tokens.TokenManager; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +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.security.user.UserService; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class is the Security Plugin's implementation of the TokenManager used by all Identity Plugins. + * It handles the issuance of both Service Account Tokens and On Behalf Of tokens. + */ +public class SecurityTokenManager implements TokenManager { + private static final Logger logger = LogManager.getLogger(SecurityTokenManager.class); + + private final ClusterService cs; + private final ThreadPool threadPool; + private final UserService userService; + + private JwtVendor jwtVendor = null; + private ConfigModel configModel = null; + + public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { + this.cs = cs; + this.threadPool = threadPool; + this.userService = userService; + } + + @Subscribe + public void onConfigModelChanged(final ConfigModel configModel) { + this.configModel = configModel; + } + + @Subscribe + public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { + final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); + final Boolean enabled = oboSettings.getAsBoolean("enabled", false); + if (enabled) { + jwtVendor = createJwtVendor(oboSettings); + } else { + jwtVendor = null; + } + } + + /** For testing */ + JwtVendor createJwtVendor(final Settings settings) { + try { + return new JwtVendor(settings, Optional.empty()); + } catch (final Exception ex) { + logger.error("Unable to create the JwtVendor instance", ex); + return null; + } + } + + public boolean issueOnBehalfOfTokenAllowed() { + return jwtVendor != null && configModel != null; + } + + @Override + public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final OnBehalfOfClaims claims) { + if (!issueOnBehalfOfTokenAllowed()) { + // TODO: link that doc! + throw new OpenSearchSecurityException( + "The OnBehalfOf token generation is not enabled, see {link to doc} for more information on this feature." + ); + } + + if (subject != null && !(subject instanceof NoopSubject)) { + logger.warn("Unsupported subject for OnBehalfOfToken token generation, {}", subject); + throw new IllegalArgumentException("Unsupported subject to generate OnBehalfOfToken"); + } + + if (Strings.isNullOrEmpty(claims.getAudience())) { + throw new IllegalArgumentException("Claims must be supplied with an audience value"); + } + + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + if (user == null) { + throw new OpenSearchSecurityException("Unsupported user to generate OnBehalfOfToken"); + } + + final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ + final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + + try { + return jwtVendor.createJwt( + cs.getClusterName().value(), + user.getName(), + claims.getAudience(), + claims.getExpiration(), + mappedRoles.stream().collect(Collectors.toList()), + user.getRoles().stream().collect(Collectors.toList()), + false + ); + } catch (final Exception ex) { + logger.error("Error creating OnBehalfOfToken for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate OnBehalfOfToken"); + } + } + + @Override + public AuthToken issueServiceAccountToken(final String serviceId) { + try { + return userService.generateAuthToken(serviceId); + } catch (final Exception e) { + logger.error("Error creating sevice final account auth token, service " + serviceId, e); + throw new OpenSearchSecurityException("Unable to issue service account token"); + } + } +} diff --git a/src/main/java/org/opensearch/security/user/UserService.java b/src/main/java/org/opensearch/security/user/UserService.java index 2d87bd8248..e7c30b97b0 100644 --- a/src/main/java/org/opensearch/security/user/UserService.java +++ b/src/main/java/org/opensearch/security/user/UserService.java @@ -41,6 +41,8 @@ import org.opensearch.common.settings.Settings; import org.opensearch.common.xcontent.XContentHelper; import org.opensearch.common.xcontent.XContentType; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.BasicAuthToken; import org.opensearch.security.DefaultObjectMapper; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.securityconf.DynamicConfigFactory; @@ -245,7 +247,7 @@ public static String generatePassword() { * @param accountName A string representing the name of the account * @return A string auth token */ - public String generateAuthToken(String accountName) throws IOException { + public AuthToken generateAuthToken(String accountName) throws IOException { final SecurityDynamicConfiguration internalUsersConfiguration = load(getUserConfigName(), false); @@ -286,7 +288,7 @@ public String generateAuthToken(String accountName) throws IOException { saveAndUpdateConfigs(getUserConfigName().toString(), client, CType.INTERNALUSERS, internalUsersConfiguration); authToken = Base64.getUrlEncoder().encodeToString((accountName + ":" + plainTextPassword).getBytes(StandardCharsets.UTF_8)); - return authToken; + return new BasicAuthToken(authToken); } catch (JsonProcessingException ex) { throw new UserServiceException(FAILED_ACCOUNT_RETRIEVAL_MESSAGE); diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index dd4dd19aa2..9c51dd714b 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -40,6 +40,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.core.IsNull.notNullValue; @@ -102,9 +103,9 @@ public void testCreateJwtWithRoles() throws Exception { Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, false); - SignedJWT signedJWT = SignedJWT.parse(encodedJwt); + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); @@ -139,9 +140,9 @@ public void testCreateJwtWithBackendRolesIncluded() throws Exception { // CS-ENFORCE-SINGLE .build(); final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final String encodedJwt = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - SignedJWT signedJWT = SignedJWT.parse(encodedJwt); + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo("cluster_0")); assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo("admin")); @@ -183,23 +184,16 @@ public void testCreateJwtWithExceededExpiry() throws Exception { String audience = "audience_0"; List roles = List.of("IT", "HR"); List backendRoles = List.of("Sales", "Support"); - int expirySeconds = 900; + int expirySeconds = 900_000; LongSupplier currentTime = () -> (long) 100; String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); - final Throwable exception = assertThrows(RuntimeException.class, () -> { - try { - jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - assertEquals( - "java.lang.IllegalArgumentException: The provided expiration time exceeds the maximum allowed duration of 600 seconds", - exception.getMessage() - ); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt(issuer, subject, audience, expirySeconds, roles, backendRoles, true); + // Expiry is a hint, the max value is controlled by the JwtVendor and reduced as is seen fit. + assertThat(authToken.getExpiresInSeconds(), not(equalTo(expirySeconds))); + assertThat(authToken.getExpiresInSeconds(), equalTo(600L)); } @Test diff --git a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java index 4215aac0ad..9f2c5ad48a 100644 --- a/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java +++ b/src/test/java/org/opensearch/security/http/OnBehalfOfAuthenticatorTest.java @@ -107,7 +107,7 @@ public void testNoKey() { false ) ); - assertTrue(exception.getMessage().contains("Unable to find on behalf of authenticator signing key")); + assertThat(exception.getMessage(), equalTo("Unable to find on behalf of authenticator signing_key")); } @Test @@ -115,13 +115,16 @@ 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")); + assertThat( + exception.getMessage(), + equalTo("Signing key size was 0 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } @Test @@ -135,7 +138,10 @@ public void testBadKey() { false ) ); - assertTrue(exception.getMessage().contains("The specified key byte array is 80 bits")); + assertThat( + exception.getMessage(), + equalTo("Signing key size was 128 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } @Test @@ -145,7 +151,10 @@ public void testWeakKeyExceptionHandling() throws Exception { OnBehalfOfAuthenticator auth = new OnBehalfOfAuthenticator(settings, "testCluster"); fail("Expected WeakKeyException"); } catch (OpenSearchSecurityException e) { - assertTrue("Expected error message to contain WeakKeyException", e.getMessage().contains("WeakKeyException")); + assertThat( + e.getMessage(), + equalTo("Signing key size was 56 bits, which is not secure enough. Please use a signing_key with a size >= 512 bits.") + ); } } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java new file mode 100644 index 0000000000..bc3f3f9732 --- /dev/null +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -0,0 +1,247 @@ +/* + * 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.identity; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.identity.Subject; +import org.opensearch.identity.tokens.AuthToken; +import org.opensearch.identity.tokens.OnBehalfOfClaims; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +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.security.user.UserService; +import org.opensearch.threadpool.ThreadPool; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class SecurityTokenManagerTest { + + private SecurityTokenManager tokenManager; + + @Mock + private JwtVendor jwtVendor; + @Mock + private ClusterService cs; + @Mock + private ThreadPool threadPool; + @Mock + private UserService userService; + + @Before + public void setup() { + tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService)); + } + + @After + public void after() { + verifyNoMoreInteractions(cs); + verifyNoMoreInteractions(threadPool); + verifyNoMoreInteractions(userService); + } + + public void onConfigModelChanged_oboNotSupported() { + final ConfigModel configModel = mock(ConfigModel.class); + + tokenManager.onConfigModelChanged(configModel); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); + verifyNoMoreInteractions(configModel); + } + + @Test + public void onDynamicConfigModelChanged_JwtVendorEnabled() { + final ConfigModel configModel = mock(ConfigModel.class); + final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(); + + tokenManager.onConfigModelChanged(configModel); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(true)); + verify(mockConfigModel).getDynamicOnBehalfOfSettings(); + verifyNoMoreInteractions(configModel); + } + + @Test + public void onDynamicConfigModelChanged_JwtVendorDisabled() { + final Settings settings = Settings.builder().put("enabled", false).build(); + final DynamicConfigModel dcm = mock(DynamicConfigModel.class); + when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + tokenManager.onDynamicConfigModelChanged(dcm); + + assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); + verify(dcm).getDynamicOnBehalfOfSettings(); + verify(tokenManager, never()).createJwtVendor(any()); + } + + /** Creates the jwt vendor and returns a mock for validation if needed */ + private DynamicConfigModel createMockJwtVendorInTokenManager() { + final Settings settings = Settings.builder().put("enabled", true).build(); + final DynamicConfigModel dcm = mock(DynamicConfigModel.class); + when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); + tokenManager.onDynamicConfigModelChanged(dcm); + return dcm; + } + + @Test + public void issueServiceAccountToken_error() throws Exception { + final String expectedAccountName = "abc-123"; + when(userService.generateAuthToken(expectedAccountName)).thenThrow(new IOException("foobar")); + + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueServiceAccountToken(expectedAccountName) + ); + assertThat(exception.getMessage(), equalTo("Unable to issue service account token")); + + verify(userService).generateAuthToken(expectedAccountName); + } + + @Test + public void issueServiceAccountToken_success() throws Exception { + final String expectedAccountName = "abc-123"; + final AuthToken authToken = mock(AuthToken.class); + when(userService.generateAuthToken(expectedAccountName)).thenReturn(authToken); + + final AuthToken token = tokenManager.issueServiceAccountToken(expectedAccountName); + + assertThat(token, equalTo(authToken)); + + verify(userService).generateAuthToken(expectedAccountName); + } + + @Test + public void issueOnBehalfOfToken_notEnabledOnCluster() { + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, null) + ); + assertThat( + exception.getMessage(), + equalTo("The OnBehalfOf token generation is not enabled, see {link to doc} for more information on this feature.") + ); + } + + @Test + public void issueOnBehalfOfToken_unsupportedSubjectType() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> tokenManager.issueOnBehalfOfToken(mock(Subject.class), null) + ); + assertThat(exception.getMessage(), equalTo("Unsupported subject to generate OnBehalfOfToken")); + } + + @Test + public void issueOnBehalfOfToken_missingAudience() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims(null, 450L)) + ); + assertThat(exception.getMessage(), equalTo("Claims must be supplied with an audience value")); + } + + @Test + public void issueOnBehalfOfToken_cannotFindUserInThreadContext() { + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) + ); + assertThat(exception.getMessage(), equalTo("Unsupported user to generate OnBehalfOfToken")); + + verify(threadPool).getThreadContext(); + } + + @Test + public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(); + + when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenThrow( + new RuntimeException("foobar") + ); + final OpenSearchSecurityException exception = assertThrows( + OpenSearchSecurityException.class, + () -> tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)) + ); + assertThat(exception.getMessage(), equalTo("Unable to generate OnBehalfOfToken")); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void issueOnBehalfOfToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + doAnswer(invocation -> true).when(tokenManager).issueOnBehalfOfTokenAllowed(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(any(), anyString(), anyString(), anyLong(), any(), any(), anyBoolean())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueOnBehalfOfToken(null, new OnBehalfOfClaims("elmo", 450L)); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } +}