diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e854f22ecd4..eeb2f53090923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - [Identity] Prototype Internal IdP ([#4659](https://github.com/opensearch-project/OpenSearch/pull/4659)) - [Identity] Strategy for Delegated Authority using Tokens ([#4826](https://github.com/opensearch-project/OpenSearch/pull/4826)) - [Identity] User operations: create update delete ([#4741](https://github.com/opensearch-project/OpenSearch/pull/4741)) +- [Identity] Adds Basic Auth mechanism via Internal IdP ([#4798](https://github.com/opensearch-project/OpenSearch/pull/4798)) ### Dependencies - Bumps `log4j-core` from 2.18.0 to 2.19.0 diff --git a/modules/reindex/src/test/java/org/opensearch/index/reindex/ReindexFromRemoteWithAuthTests.java b/modules/reindex/src/test/java/org/opensearch/index/reindex/ReindexFromRemoteWithAuthTests.java index dd7eb977bbe48..673bbe773c4ff 100644 --- a/modules/reindex/src/test/java/org/opensearch/index/reindex/ReindexFromRemoteWithAuthTests.java +++ b/modules/reindex/src/test/java/org/opensearch/index/reindex/ReindexFromRemoteWithAuthTests.java @@ -32,6 +32,7 @@ package org.opensearch.index.reindex; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.apache.lucene.util.SetOnce; import org.opensearch.OpenSearchSecurityException; import org.opensearch.OpenSearchStatusException; @@ -82,6 +83,7 @@ import static org.opensearch.index.reindex.ReindexTestCase.matcher; import static org.hamcrest.Matchers.containsString; +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) public class ReindexFromRemoteWithAuthTests extends OpenSearchSingleNodeTestCase { private TransportAddress address; @@ -144,6 +146,7 @@ public void testReindexSendsHeaders() throws Exception { ReindexRequestBuilder request = new ReindexRequestBuilder(client(), ReindexAction.INSTANCE).source("source") .destination("dest") .setRemoteInfo(newRemoteInfo(null, null, singletonMap(TestFilter.EXAMPLE_HEADER, "doesn't matter"))); + OpenSearchStatusException e = expectThrows(OpenSearchStatusException.class, () -> request.get()); assertEquals(RestStatus.BAD_REQUEST, e.status()); assertThat(e.getMessage(), containsString("Hurray! Sent the header!")); @@ -164,7 +167,8 @@ public void testReindexWithBadAuthentication() throws Exception { .destination("dest") .setRemoteInfo(newRemoteInfo("junk", "auth", emptyMap())); OpenSearchStatusException e = expectThrows(OpenSearchStatusException.class, () -> request.get()); - assertThat(e.getMessage(), containsString("\"reason\":\"Bad Authorization\"")); + assertThat(e.getMessage(), containsString("\"error\":\"junk does not exist in internal realm.\"")); // Due to native auth + // implementation } /** diff --git a/sandbox/libs/authn/build.gradle b/sandbox/libs/authn/build.gradle index d3a3eb37c0e1b..44ca4dc52f615 100644 --- a/sandbox/libs/authn/build.gradle +++ b/sandbox/libs/authn/build.gradle @@ -18,7 +18,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}" implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" - implementation 'org.apache.shiro:shiro-core:1.9.1' + api 'org.apache.shiro:shiro-core:1.9.1' // Needed for shiro implementation "org.slf4j:slf4j-api:${versions.slf4j}" implementation "org.bouncycastle:bcprov-jdk15on:${versions.bouncycastle}" @@ -276,3 +276,7 @@ thirdPartyAudit.ignoreMissingClasses( 'org.yaml.snakeyaml.parser.ParserImpl', 'org.yaml.snakeyaml.resolver.Resolver', ) + +tasks.register("integTest", Test) { + include '**/*IT.class' +} diff --git a/server/src/main/java/org/opensearch/identity/AccessTokenManager.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessTokenManager.java similarity index 87% rename from server/src/main/java/org/opensearch/identity/AccessTokenManager.java rename to sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessTokenManager.java index e1412b5efaee7..cc96006e05214 100644 --- a/server/src/main/java/org/opensearch/identity/AccessTokenManager.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessTokenManager.java @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.opensearch.identity; +package org.opensearch.authn; -import org.opensearch.authn.AccessToken; +import org.opensearch.authn.tokens.AccessToken; /** * Vends out access tokens diff --git a/server/src/main/java/org/opensearch/identity/AuthenticationManager.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationManager.java similarity index 86% rename from server/src/main/java/org/opensearch/identity/AuthenticationManager.java rename to sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationManager.java index 5b1c4a753f646..cac309bdbec30 100644 --- a/server/src/main/java/org/opensearch/identity/AuthenticationManager.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationManager.java @@ -3,9 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.opensearch.identity; - -import org.opensearch.authn.Subject; +package org.opensearch.authn; /** * Authentication management for OpenSearch. diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationTokenHandler.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationTokenHandler.java new file mode 100644 index 0000000000000..4a4cb6f061780 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationTokenHandler.java @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.shiro.authc.AuthenticationToken; +import org.apache.shiro.authc.UsernamePasswordToken; +import org.opensearch.authn.tokens.BasicAuthToken; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Extracts Shiro's {@link AuthenticationToken} from different types of auth headers + * + * @opensearch.experimental + */ +public class AuthenticationTokenHandler { + + private static final Logger logger = LogManager.getLogger(AuthenticationTokenHandler.class); + + /** + * Extracts shiro auth token from the given header token + * @param authenticationToken the token from which to extract + * @return the extracted shiro auth token to be used to perform login + */ + public static AuthenticationToken extractShiroAuthToken(org.opensearch.authn.tokens.AuthenticationToken authenticationToken) { + AuthenticationToken authToken = null; + + if (authenticationToken instanceof BasicAuthToken) { + authToken = handleBasicAuth((BasicAuthToken) authenticationToken); + } + // TODO: check for other type of HeaderTokens + return authToken; + } + + /** + * Returns auth token extracted from basic auth header + * @param token the basic auth token + * @return the extracted auth token + */ + private static AuthenticationToken handleBasicAuth(final BasicAuthToken token) { + + final byte[] decodedAuthHeader = Base64.getDecoder().decode(token.getHeaderValue().substring("Basic".length()).trim()); + String decodedHeader = new String(decodedAuthHeader, StandardCharsets.UTF_8); + + final int firstColonIndex = decodedHeader.indexOf(':'); + + String username = null; + String password = null; + + if (firstColonIndex > 0) { + username = decodedHeader.substring(0, firstColonIndex); + + if (decodedHeader.length() - 1 != firstColonIndex) { + password = decodedHeader.substring(firstColonIndex + 1); + } else { + // blank password + password = ""; + } + } + + if (username == null || password == null) { + logger.warn("Invalid 'Authorization' header, send 401 and 'WWW-Authenticate Basic'"); + return null; + } + + logger.info("Logging in as: " + username); + + return new UsernamePasswordToken(username, password); + } +} diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/DefaultObjectMapper.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/DefaultObjectMapper.java index 8490d869cee97..70d584a57110a 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/DefaultObjectMapper.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/DefaultObjectMapper.java @@ -9,7 +9,9 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import java.io.IOException; @@ -18,14 +20,18 @@ * @opensearch.experimental */ public class DefaultObjectMapper { - public static final ObjectMapper objectMapper = new ObjectMapper(); + public static ObjectMapper objectMapper; public final static ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); private static final ObjectMapper defaulOmittingObjectMapper = new ObjectMapper(); static { + objectMapper = JsonMapper.builder() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) // to prevent access denied exception by Jackson + .build(); + objectMapper.setSerializationInclusion(Include.NON_NULL); // objectMapper.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); - objectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); defaulOmittingObjectMapper.setSerializationInclusion(Include.NON_DEFAULT); defaulOmittingObjectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); YAML_MAPPER.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/Principals.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/Principals.java index 8f749474ed8e0..3ad3e4cfc53a9 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/Principals.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/Principals.java @@ -21,7 +21,7 @@ public enum Principals { private final Principal principal; - private Principals(final Principal principal) { + Principals(final Principal principal) { this.principal = principal; } diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/Subject.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/Subject.java index 1a6fcf0462959..80414402e48c6 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/Subject.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/Subject.java @@ -5,13 +5,13 @@ package org.opensearch.authn; +import org.opensearch.authn.tokens.AuthenticationToken; + import java.security.Principal; /** * An individual, process, or device that causes information to flow among objects or change to the system state. * - * Used to authorize activities inside of the OpenSearch ecosystem. - * * @opensearch.experimental */ public interface Subject { @@ -22,7 +22,7 @@ public interface Subject { Principal getPrincipal(); /** - * Authentications from a token + * Authentication check via the token * throws UnsupportedAuthenticationMethod * throws InvalidAuthenticationToken * throws SubjectNotFound diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/User.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/User.java index 6da13f7f58077..ccc6943fd09a5 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/User.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/User.java @@ -10,6 +10,12 @@ import java.util.Collections; import java.util.Map; +/** + * A non-volatile and immutable object in the storage. + * + * @opensearch.experimental + */ + public class User { @JsonProperty(value = "primary_principal") @@ -17,6 +23,8 @@ public class User { @JsonProperty(value = "hash") private String bcryptHash; + + @JsonProperty(value = "attributes") private Map attributes = Collections.emptyMap(); @JsonProperty(value = "primary_principal") @@ -39,10 +47,12 @@ public void setBcryptHash(String bcryptHash) { this.bcryptHash = bcryptHash; } + @JsonProperty(value = "attributes") public Map getAttributes() { return attributes; } + @JsonProperty(value = "attributes") public void setAttributes(Map attributes) { this.attributes = attributes; } diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAccessTokenManager.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAccessTokenManager.java new file mode 100644 index 0000000000000..e65a54ae83641 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAccessTokenManager.java @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.authn.internal; + +import org.opensearch.authn.AccessTokenManager; +import org.opensearch.authn.tokens.AccessToken; + +/** + * Implementation of access token manager that does not enforce authentication + * + * This class and related classes in this package will not return nulls or fail permissions checks + * + * @opensearch.internal + */ +public class InternalAccessTokenManager implements AccessTokenManager { + + @Override + public void expireAllTokens() { + // Tokens cannot be expired + } + + @Override + public AccessToken generate() { + return new AccessToken(); + } + + @Override + public AccessToken refresh(final AccessToken token) { + return new AccessToken(); + } + +} diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAuthenticationManager.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAuthenticationManager.java new file mode 100644 index 0000000000000..8e08a4564bfd9 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalAuthenticationManager.java @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn.internal; + +import org.opensearch.authn.AccessTokenManager; +import org.opensearch.authn.AuthenticationManager; +import org.opensearch.authn.realm.InternalRealm; +import org.opensearch.authn.Subject; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.mgt.SecurityManager; + +/** + * Implementation of authentication manager that enforces authentication against internal idp + * + * This class and related classes in this package will not return nulls or fail permissions checks + * + * This class manages the subjects loaded via the realm, and provides current subject + * when authenticating the incoming request + * Checkout + * and how the internal Identity system uses auth manager to get current subject to use for authentication + * + * @opensearch.internal + */ +public class InternalAuthenticationManager implements AuthenticationManager { + + /** + * Security manager is loaded with default user set, + * and this instantiation uses the default security manager + */ + public InternalAuthenticationManager() { + final SecurityManager securityManager = new DefaultSecurityManager(InternalRealm.INSTANCE); + SecurityUtils.setSecurityManager(securityManager); + } + + /** + * Instantiates this Auth manager by setting the custom security Manager that is passed as an argument + * @param securityManager the custom security manager (with realm instantiated in it) + */ + public InternalAuthenticationManager(SecurityManager securityManager) { + SecurityUtils.setSecurityManager(securityManager); + } + + @Override + public Subject getSubject() { + return new InternalSubject(SecurityUtils.getSubject()); + } + + @Override + public AccessTokenManager getAccessTokenManager() { + return null; + } +} diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalSubject.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalSubject.java new file mode 100644 index 0000000000000..5874439ebdcc9 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/InternalSubject.java @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn.internal; + +import java.security.Principal; +import java.util.Objects; + +import org.opensearch.authn.AuthenticationTokenHandler; +import org.opensearch.authn.tokens.AuthenticationToken; +import org.opensearch.authn.Subject; + +/** + * Implementation of subject that is always authenticated + * + * This class and related classes in this package will not return nulls or fail permissions checks + * + * @opensearch.internal + */ +public class InternalSubject implements Subject { + private final org.apache.shiro.subject.Subject shiroSubject; + + public InternalSubject(org.apache.shiro.subject.Subject subject) { + shiroSubject = subject; + } + + @Override + public Principal getPrincipal() { + final Object o = shiroSubject.getPrincipal(); + + if (o == null) { + return null; + } + + if (o instanceof Principal) { + return (Principal) o; + } + + return () -> o.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Subject that = (Subject) obj; + return Objects.equals(getPrincipal(), that.getPrincipal()); + } + + @Override + public int hashCode() { + return Objects.hash(getPrincipal()); + } + + @Override + public String toString() { + return "InternalSubject (principal=" + getPrincipal() + ")"; + } + + /** + * Logs the user in via authenticating the user against current Shiro realm + */ + public void login(AuthenticationToken authenticationToken) { + org.apache.shiro.authc.AuthenticationToken authToken = AuthenticationTokenHandler.extractShiroAuthToken(authenticationToken); + // Login via shiro realm. + shiroSubject.login(authToken); + } +} diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/package-info.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/package-info.java new file mode 100644 index 0000000000000..fb5cf0444c1b9 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/internal/package-info.java @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Classes for the internal authentication in OpenSearch */ +package org.opensearch.authn.internal; diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalRealm.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalRealm.java index a0e7b0b4e1d2c..8009fea26390e 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalRealm.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalRealm.java @@ -36,6 +36,8 @@ public class InternalRealm extends AuthenticatingRealm { public static final String INVALID_ARGUMENTS_MESSAGE = "primaryPrincipal or hash can't be null or empty"; + public static final String INCORRECT_CREDENTIALS_MESSAGE = "Incorrect credentials"; + private static final String DEFAULT_REALM_NAME = "internal"; private static final String DEFAULT_INTERNAL_USERS_FILE = "example/example_internal_users.yml"; @@ -109,7 +111,7 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) return sai; } else { // Bad password - throw new IncorrectCredentialsException(); + throw new IncorrectCredentialsException(INCORRECT_CREDENTIALS_MESSAGE); } } // Don't know what to do with this token @@ -240,7 +242,7 @@ public User removeUser(String primaryPrincipal) { } /** - * Generates an Exception message String + * Generates an Exception message * @param primaryPrincipal to be added to this message * @return the exception message string */ diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalUsersStore.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalUsersStore.java index 68f4975af8a65..51c30b3660637 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalUsersStore.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/realm/InternalUsersStore.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.net.URL; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.util.Iterator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -37,7 +39,27 @@ public static ConcurrentMap readUsersAsMap(String pathToInternalUs ObjectNode o = (ObjectNode) subjectNode; o.put("primary_principal", primaryPrincipal); String subjectNodeString = DefaultObjectMapper.writeValueAsString((JsonNode) o, false); - User user = DefaultObjectMapper.readValue(subjectNodeString, User.class); + + /** + * Reflects access permissions to prevent jackson databind from throwing InvalidDefinitionException + * Counter-part is added in security.policy to grant jackson-databind ReflectPermission + * + * {@code + * com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot access public org.opensearch.authn.User() + * (from class org.opensearch.authn.User; failed to set access: access denied + * ("java.lang.reflect.ReflectPermission" "suppressAccessChecks") + * } + * + * TODO: Check if there is a better way around this + */ + User user = AccessController.doPrivileged((PrivilegedAction) () -> { + try { + return DefaultObjectMapper.readValue(subjectNodeString, User.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + internalUsersMap.put(primaryPrincipal, user); } } catch (IOException e) { diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessToken.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AccessToken.java similarity index 92% rename from sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessToken.java rename to sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AccessToken.java index 323410e044704..225700f01d438 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/AccessToken.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AccessToken.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.opensearch.authn; +package org.opensearch.authn.tokens; /** * Tamperproof encapsulation the identity of a subject diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationToken.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AuthenticationToken.java similarity index 89% rename from sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationToken.java rename to sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AuthenticationToken.java index 3233e1d04bbdd..6ea2b05a4c9db 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/AuthenticationToken.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/AuthenticationToken.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.opensearch.authn; +package org.opensearch.authn.tokens; /** * Generic interface for all token formats to support to authenticate user, such as UserName/Password tokens, Access tokens, and more. diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BasicAuthToken.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BasicAuthToken.java new file mode 100644 index 0000000000000..03229fc2dc476 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BasicAuthToken.java @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn.tokens; + +/** + * Basic (Base64 encoded) Authentication Token in a http request header + */ +public final class BasicAuthToken extends HttpHeaderToken { + + private String headerValue; + + public BasicAuthToken(String headerValue) { + this.headerValue = headerValue; + } + + @Override + public String getHeaderValue() { + return headerValue; + } +} diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/HttpHeaderToken.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/HttpHeaderToken.java new file mode 100644 index 0000000000000..e6a4f75a84828 --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/HttpHeaderToken.java @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn.tokens; + +/** + * An abstraction of types of header tokens to be supported for authentication for a http Request + */ +public abstract class HttpHeaderToken implements AuthenticationToken { + + public final static String HEADER_NAME = "Authorization"; + + /** + * Returns the value for this authentication header + * @return the header (e.g. "Basic `base64-encoded-string`", "Bearer `arbitrary-string`" ) + */ + public abstract String getHeaderValue(); +} diff --git a/sandbox/libs/authn/src/main/resources/example/example_internal_users.yml b/sandbox/libs/authn/src/main/resources/example/example_internal_users.yml index 96453a6aed8f5..ca25a00d915c0 100644 --- a/sandbox/libs/authn/src/main/resources/example/example_internal_users.yml +++ b/sandbox/libs/authn/src/main/resources/example/example_internal_users.yml @@ -30,3 +30,14 @@ readall: snapshotrestore: hash: "$2y$12$DpwmetHKwgYnorbgdvORCenv4NAK8cPUg8AI6pxLCuWf/ALc0.v7W" + +# used by ReIndexFromRemote +Aladdin: + hash: "$2y$12$UxTDJyVmn/LIQBEGRt6Mq.9RF6GvC34sFPT6HjK.K0kmT3ANm1CZS" # open sesame + +# Two semi-colon password +test: + hash: "$2y$12$fG1vNbK3X73j9eujLfh6We43fbKdy8O8RP5tGnLjg/CWMot48kAwO" # te:st + +test_user: + hash: "$2y$12$BSFKmk3/FTTTH3eijILyxuVUGAoe4F.SPMoYHyrOfL.H8PagCqcZO" diff --git a/sandbox/libs/authn/src/test/java/org/opensearch/authn/AuthenticationTokenHandlerTests.java b/sandbox/libs/authn/src/test/java/org/opensearch/authn/AuthenticationTokenHandlerTests.java new file mode 100644 index 0000000000000..03dcf6b26a53f --- /dev/null +++ b/sandbox/libs/authn/src/test/java/org/opensearch/authn/AuthenticationTokenHandlerTests.java @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn; + +import org.apache.shiro.authc.UsernamePasswordToken; +import org.hamcrest.MatcherAssert; +import org.opensearch.authn.tokens.AuthenticationToken; +import org.opensearch.authn.tokens.BasicAuthToken; +import org.opensearch.test.OpenSearchTestCase; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class AuthenticationTokenHandlerTests extends OpenSearchTestCase { + + public void testShouldExtractBasicAuthTokenSuccessfully() { + + // The auth header that is part of the request + String authHeader = "Basic YWRtaW46YWRtaW4="; // admin:admin + + AuthenticationToken authToken = new BasicAuthToken(authHeader); + + UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) AuthenticationTokenHandler.extractShiroAuthToken(authToken); + + MatcherAssert.assertThat(usernamePasswordToken, notNullValue()); + MatcherAssert.assertThat(usernamePasswordToken.getUsername(), equalTo("admin")); + MatcherAssert.assertThat(new String(usernamePasswordToken.getPassword()), equalTo("admin")); + } + + public void testShouldExtractBasicAuthTokenSuccessfully_twoSemiColonPassword() { + + // The auth header that is part of the request + String authHeader = "Basic dGVzdDp0ZTpzdA=="; // test:te:st + + AuthenticationToken authToken = new BasicAuthToken(authHeader); + + UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) AuthenticationTokenHandler.extractShiroAuthToken(authToken); + + MatcherAssert.assertThat(usernamePasswordToken, notNullValue()); + MatcherAssert.assertThat(usernamePasswordToken.getUsername(), equalTo("test")); + MatcherAssert.assertThat(new String(usernamePasswordToken.getPassword()), equalTo("te:st")); + } + + public void testShouldReturnNullWhenExtractingInvalidToken() { + String authHeader = "Basic Nah"; + + AuthenticationToken authToken = new BasicAuthToken(authHeader); + + UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) AuthenticationTokenHandler.extractShiroAuthToken(authToken); + + MatcherAssert.assertThat(usernamePasswordToken, nullValue()); + } + + public void testShouldReturnNullWhenExtractingNullToken() { + + org.apache.shiro.authc.AuthenticationToken shiroAuthToken = AuthenticationTokenHandler.extractShiroAuthToken(null); + + MatcherAssert.assertThat(shiroAuthToken, nullValue()); + } +} diff --git a/sandbox/libs/authn/src/test/java/org/opensearch/authn/type/BasicAuthenticationIT.java b/sandbox/libs/authn/src/test/java/org/opensearch/authn/type/BasicAuthenticationIT.java new file mode 100644 index 0000000000000..46729b08877b1 --- /dev/null +++ b/sandbox/libs/authn/src/test/java/org/opensearch/authn/type/BasicAuthenticationIT.java @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.authn.type; + +import org.hamcrest.MatcherAssert; +import org.opensearch.client.Request; +import org.opensearch.client.RequestOptions; +import org.opensearch.client.Response; +import org.opensearch.client.ResponseException; +import org.opensearch.test.rest.OpenSearchRestTestCase; + +import java.io.IOException; +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; + +// Testing authentication against remote cluster +public class BasicAuthenticationIT extends OpenSearchRestTestCase { + + public void testClusterHealthWithValidAuthenticationHeader() throws IOException { + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Basic YWRtaW46YWRtaW4=").build(); // admin:admin + request.setOptions(options); + Response response = client().performRequest(request); + + assertOK(response); + + // Standard cluster health response + MatcherAssert.assertThat(entityAsMap(response).size(), equalTo(17)); + MatcherAssert.assertThat(entityAsMap(response).get("status"), equalTo("green")); + + } + + public void testClusterHealthWithValidAuthenticationHeader_twoSemiColonPassword() throws IOException { + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Basic dGVzdDp0ZTpzdA==").build(); // test:te:st + request.setOptions(options); + Response response = client().performRequest(request); + + assertOK(response); + + // Standard cluster health response + MatcherAssert.assertThat(entityAsMap(response).size(), equalTo(17)); + MatcherAssert.assertThat(entityAsMap(response).get("status"), equalTo("green")); + + } + + public void testClusterHealthWithNoHeader() throws IOException { + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().build(); // admin:admin + request.setOptions(options); + Response response = client().performRequest(request); + + // Should not fail, because current implementation allows a request with missing header to pass + // TODO: Update this test to check for missing-header response, once that is implemented + assertOK(response); + + // Standard cluster health response + MatcherAssert.assertThat(entityAsMap(response).size(), equalTo(17)); + MatcherAssert.assertThat(entityAsMap(response).get("status"), equalTo("green")); + } + + public void testClusterHealthWithInvalidAuthenticationHeader() throws IOException { + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Basic bWFydmluOmdhbGF4eQ==").build(); // marvin:galaxy + request.setOptions(options); + try { + client().performRequest(request); + } catch (ResponseException re) { + Map responseMap = entityAsMap(re.getResponse()); + MatcherAssert.assertThat(responseMap.size(), equalTo(2)); + MatcherAssert.assertThat(responseMap.get("status"), equalTo(401)); + MatcherAssert.assertThat(responseMap.get("error"), equalTo("marvin does not exist in internal realm.")); + } + } + + public void testClusterHealthWithCorruptAuthenticationHeader() throws IOException { + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "Basic bleh").build(); + try { + client().performRequest(request); + } catch (ResponseException e) { + MatcherAssert.assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(405)); + } + + } +} diff --git a/server/src/main/java/org/opensearch/action/ActionModule.java b/server/src/main/java/org/opensearch/action/ActionModule.java index 84bc9b395c5dc..cea1bf35641d7 100644 --- a/server/src/main/java/org/opensearch/action/ActionModule.java +++ b/server/src/main/java/org/opensearch/action/ActionModule.java @@ -504,6 +504,7 @@ public ActionModule( Stream.of(new RestHeaderDefinition(Task.X_OPAQUE_ID, false)) ).collect(Collectors.toSet()); UnaryOperator restWrapper = null; + // Only one plugin is allowed to have a rest wrapper. i.e. Security plugin for (ActionPlugin plugin : actionPlugins) { UnaryOperator newRestWrapper = plugin.getRestHandlerWrapper(threadPool.getThreadContext()); if (newRestWrapper != null) { @@ -514,6 +515,7 @@ public ActionModule( restWrapper = newRestWrapper; } } + mappingRequestValidators = new RequestValidators<>( actionPlugins.stream().flatMap(p -> p.mappingRequestValidators().stream()).collect(Collectors.toList()) ); diff --git a/server/src/main/java/org/opensearch/identity/Identity.java b/server/src/main/java/org/opensearch/identity/Identity.java index eec460f5918af..e4a519f605379 100644 --- a/server/src/main/java/org/opensearch/identity/Identity.java +++ b/server/src/main/java/org/opensearch/identity/Identity.java @@ -5,6 +5,8 @@ package org.opensearch.identity; +import org.opensearch.authn.AuthenticationManager; + /** * Application wide access for identity systems * diff --git a/server/src/main/java/org/opensearch/identity/noop/NoopAccessTokenManager.java b/server/src/main/java/org/opensearch/identity/noop/NoopAccessTokenManager.java index f21e3ee249cbd..1eca63c8ffdd5 100644 --- a/server/src/main/java/org/opensearch/identity/noop/NoopAccessTokenManager.java +++ b/server/src/main/java/org/opensearch/identity/noop/NoopAccessTokenManager.java @@ -4,8 +4,8 @@ */ package org.opensearch.identity.noop; -import org.opensearch.authn.AccessToken; -import org.opensearch.identity.AccessTokenManager; +import org.opensearch.authn.tokens.AccessToken; +import org.opensearch.authn.AccessTokenManager; /** * Implementation of access token manager that does not enforce authentication diff --git a/server/src/main/java/org/opensearch/identity/noop/NoopAuthenticationManager.java b/server/src/main/java/org/opensearch/identity/noop/NoopAuthenticationManager.java index 4eb6f8dadc3b3..ac1bf43a84770 100644 --- a/server/src/main/java/org/opensearch/identity/noop/NoopAuthenticationManager.java +++ b/server/src/main/java/org/opensearch/identity/noop/NoopAuthenticationManager.java @@ -5,8 +5,8 @@ package org.opensearch.identity.noop; -import org.opensearch.identity.AccessTokenManager; -import org.opensearch.identity.AuthenticationManager; +import org.opensearch.authn.AccessTokenManager; +import org.opensearch.authn.AuthenticationManager; import org.opensearch.authn.Subject; /** diff --git a/server/src/main/java/org/opensearch/identity/noop/NoopSubject.java b/server/src/main/java/org/opensearch/identity/noop/NoopSubject.java index 986827e11a126..7bba6249b19b4 100644 --- a/server/src/main/java/org/opensearch/identity/noop/NoopSubject.java +++ b/server/src/main/java/org/opensearch/identity/noop/NoopSubject.java @@ -8,8 +8,8 @@ import java.security.Principal; import java.util.Objects; +import org.opensearch.authn.tokens.AuthenticationToken; import org.opensearch.authn.Subject; -import org.opensearch.authn.AuthenticationToken; import org.opensearch.authn.Principals; /** @@ -44,8 +44,11 @@ public String toString() { return "NoopSubject(principal=" + getPrincipal() + ")"; } + /** + * Logs the user in + */ @Override - public void login(final AuthenticationToken token) { - // Noop subject is always logged in, and all authentication tokens are accepted + public void login(AuthenticationToken authenticationToken) { + // Do nothing as noop subject is always logged in } } diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java index 4a7943a92ae8f..546ee8f8028d0 100644 --- a/server/src/main/java/org/opensearch/node/Node.java +++ b/server/src/main/java/org/opensearch/node/Node.java @@ -130,9 +130,9 @@ import org.opensearch.gateway.MetaStateService; import org.opensearch.gateway.PersistedClusterStateService; import org.opensearch.http.HttpServerTransport; -import org.opensearch.identity.AuthenticationManager; +import org.opensearch.authn.AuthenticationManager; import org.opensearch.identity.Identity; -import org.opensearch.identity.noop.NoopAuthenticationManager; +import org.opensearch.authn.internal.InternalAuthenticationManager; import org.opensearch.index.IndexSettings; import org.opensearch.index.analysis.AnalysisRegistry; import org.opensearch.index.engine.EngineFactory; @@ -458,7 +458,8 @@ protected Node( resourcesToClose.add(nodeEnvironment); localNodeFactory = new LocalNodeFactory(settings, nodeEnvironment.nodeId()); - final AuthenticationManager authManager = new NoopAuthenticationManager(); + // TODO: revisit this + final AuthenticationManager authManager = new InternalAuthenticationManager(); Identity.setAuthManager(authManager); final List> executorBuilders = pluginsService.getExecutorBuilders(settings); diff --git a/server/src/main/java/org/opensearch/rest/RestController.java b/server/src/main/java/org/opensearch/rest/RestController.java index 78bebcb9a0af1..697c0850d8fdc 100644 --- a/server/src/main/java/org/opensearch/rest/RestController.java +++ b/server/src/main/java/org/opensearch/rest/RestController.java @@ -35,7 +35,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.message.ParameterizedMessage; +import org.apache.shiro.authc.AuthenticationException; import org.opensearch.OpenSearchException; +import org.opensearch.authn.tokens.AuthenticationToken; +import org.opensearch.authn.tokens.BasicAuthToken; +import org.opensearch.authn.tokens.HttpHeaderToken; +import org.opensearch.authn.Subject; import org.opensearch.client.node.NodeClient; import org.opensearch.common.Nullable; import org.opensearch.common.Strings; @@ -50,6 +55,7 @@ import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.internal.io.Streams; import org.opensearch.http.HttpServerTransport; +import org.opensearch.identity.Identity; import org.opensearch.indices.breaker.CircuitBreakerService; import org.opensearch.usage.UsageService; @@ -57,11 +63,13 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @@ -395,6 +403,9 @@ private void tryAllHandlers(final RestRequest request, final RestChannel channel return; } } else { + // Authenticate incoming request + if (!authenticate(request, channel)) return; + dispatchRequest(request, channel, handler); return; } @@ -588,4 +599,72 @@ private static CircuitBreaker inFlightRequestsBreaker(CircuitBreakerService circ // We always obtain a fresh breaker to reflect changes to the breaker configuration. return circuitBreakerService.getBreaker(CircuitBreaker.IN_FLIGHT_REQUESTS); } + + /** + * Authenticates the subject of the incoming REST request based on the auth header + * @param request the request whose subject is to be authenticated + * @param channel the channel to send the response on + * @return true if authentication was successful, false otherwise + * @throws IOException when an exception is raised writing response to channel + */ + private boolean authenticate(RestRequest request, RestChannel channel) throws IOException { + + final Optional authHeader = request.getHeaders() + .getOrDefault(HttpHeaderToken.HEADER_NAME, Collections.emptyList()) + .stream() + .findFirst(); + + Subject subject = null; + + AuthenticationToken headerToken = null; + + if (authHeader.isPresent()) { + try { + headerToken = tokenType(authHeader.get()); + subject = Identity.getAuthManager().getSubject(); + subject.login(headerToken); + logger.info("Authentication successful"); + return true; + } catch (final AuthenticationException ae) { + logger.info("Authentication finally failed: {}", ae.getMessage()); + + final BytesRestResponse bytesRestResponse = BytesRestResponse.createSimpleErrorResponse( + channel, + RestStatus.UNAUTHORIZED, + ae.getMessage() + ); + channel.sendResponse(bytesRestResponse); + return false; + } + } + + // TODO: Handle anonymous Auth - Allowed or Disallowed (set by the user of the system) - 401 or Login-redirect ?? + + /* + TODO: Uncomment this once it is decided to proceed with this workflow + logger.info("Authentication unsuccessful: Missing Authentication Header"); + final BytesRestResponse bytesRestResponse = BytesRestResponse.createSimpleErrorResponse( + channel, + RestStatus.BAD_REQUEST, + "Missing Authentication Header" + ); + channel.sendResponse(bytesRestResponse); + */ + + // This is allowing headers without Auth header to pass through. + // At the time of writing this, all rest-tests would fail if this is set to false + // TODO: Change this to false once there is a decision on what to do with requests that don't have auth Headers + return true; + } + + /** + * Identifies the token type and return the correct instance + * @param authHeader from which to identify the correct token class + * @return the instance of the token type + */ + private AuthenticationToken tokenType(String authHeader) { + if (authHeader.contains("Basic")) return new BasicAuthToken(authHeader); + // support other type of header tokens + return null; + } } diff --git a/server/src/main/resources/org/opensearch/bootstrap/security.policy b/server/src/main/resources/org/opensearch/bootstrap/security.policy index c0580cadc8014..cc040177f96a5 100644 --- a/server/src/main/resources/org/opensearch/bootstrap/security.policy +++ b/server/src/main/resources/org/opensearch/bootstrap/security.policy @@ -41,6 +41,11 @@ grant codeBase "${codebase.opensearch-secure-sm}" { permission java.security.AllPermission; }; +grant codeBase "${codebase.opensearch-authn}" { + // this is required by jackson databind to access declared methods for javax.security.Principal class via StringPrincipal.java + permission java.security.AllPermission; +}; + //// Opensearch core: //// These are only allowed inside the server jar, not in plugins grant codeBase "${codebase.opensearch}" { diff --git a/server/src/test/java/org/opensearch/identity/IdentityTests.java b/server/src/test/java/org/opensearch/identity/IdentityTests.java index 21d94a9a54824..7ac09eb72e93f 100644 --- a/server/src/test/java/org/opensearch/identity/IdentityTests.java +++ b/server/src/test/java/org/opensearch/identity/IdentityTests.java @@ -5,6 +5,7 @@ package org.opensearch.identity; +import org.opensearch.authn.AuthenticationManager; import org.opensearch.test.OpenSearchTestCase; import static org.mockito.Mockito.mock; diff --git a/server/src/test/java/org/opensearch/rest/RestControllerTests.java b/server/src/test/java/org/opensearch/rest/RestControllerTests.java index bd4c7c9a4f824..d8cfd87a11b50 100644 --- a/server/src/test/java/org/opensearch/rest/RestControllerTests.java +++ b/server/src/test/java/org/opensearch/rest/RestControllerTests.java @@ -32,6 +32,7 @@ package org.opensearch.rest; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.opensearch.client.node.NodeClient; import org.opensearch.common.breaker.CircuitBreaker; import org.opensearch.common.bytes.BytesArray; @@ -52,6 +53,9 @@ import org.opensearch.http.HttpResponse; import org.opensearch.http.HttpServerTransport; import org.opensearch.http.HttpStats; +import org.opensearch.authn.AuthenticationManager; +import org.opensearch.identity.Identity; +import org.opensearch.authn.internal.InternalAuthenticationManager; import org.opensearch.indices.breaker.HierarchyCircuitBreakerService; import org.opensearch.test.OpenSearchTestCase; import org.opensearch.test.client.NoOpNodeClient; @@ -84,6 +88,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) /* otherwise {@code testRestRequestAuthentication} cause thread to be leaked */ public class RestControllerTests extends OpenSearchTestCase { private static final ByteSizeValue BREAKER_LIMIT = new ByteSizeValue(20); @@ -638,6 +643,42 @@ public Exception getInboundException() { ); } + // Tests to check authenticate(...) method + public void testRestRequestAuthenticationSuccess() { + final AuthenticationManager authManager = new InternalAuthenticationManager(); + Identity.setAuthManager(authManager); + + final ThreadContext threadContext = client.threadPool().getThreadContext(); + + final FakeRestRequest fakeRestRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withHeaders( + Collections.singletonMap("Authorization", Collections.singletonList("Basic YWRtaW46YWRtaW4=")) + ) // admin:admin + .build(); + final AssertingChannel channel = new AssertingChannel(fakeRestRequest, true, RestStatus.OK); + restController.dispatchRequest(fakeRestRequest, channel, threadContext); + + assertTrue(channel.getSendResponseCalled()); + } + + public void testRestRequestAuthenticationFailure() { + final AuthenticationManager authManager = new InternalAuthenticationManager(); + Identity.setAuthManager(authManager); + + final ThreadContext threadContext = client.threadPool().getThreadContext(); + + final FakeRestRequest fakeRestRequest = new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withHeaders( + Collections.singletonMap("Authorization", Collections.singletonList("Basic bWFydmluOmdhbGF4eQ==")) + ) // marvin:galaxy + .build(); + + // RestStatus is OK even though the authn information is incorrect. This is because we, yet, don't fail the request + // if it was unauthorized. The status should be changed to UNAUTHORIZED once the flow is updated. + final AssertingChannel channel = new AssertingChannel(fakeRestRequest, true, RestStatus.UNAUTHORIZED); + restController.dispatchRequest(fakeRestRequest, channel, threadContext); + + assertTrue(channel.getSendResponseCalled()); + } + private static final class TestHttpServerTransport extends AbstractLifecycleComponent implements HttpServerTransport { TestHttpServerTransport() {}