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 80414402e48c6..ff48e5b8bd2d7 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 @@ -28,5 +28,5 @@ public interface Subject { * throws SubjectNotFound * throws SubjectDisabled */ - void login(final AuthenticationToken token); + void login(final AuthenticationToken token) throws RuntimeException; } 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 index a7f3f0cfd75c4..4647064ff4058 100644 --- 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 @@ -9,6 +9,7 @@ import java.util.Objects; import org.apache.shiro.SecurityUtils; +import org.apache.shiro.session.Session; import org.opensearch.authn.AuthenticationTokenHandler; import org.opensearch.authn.tokens.AuthenticationToken; import org.opensearch.authn.Subject; @@ -63,10 +64,29 @@ public String toString() { /** * Logs the user in via authenticating the user against current Shiro realm */ - public void login(AuthenticationToken authenticationToken) { + public void login(AuthenticationToken authenticationToken) throws RuntimeException { org.apache.shiro.authc.AuthenticationToken authToken = AuthenticationTokenHandler.extractShiroAuthToken(authenticationToken); // Login via shiro realm. - SecurityUtils.getSecurityManager().authenticate(authToken); - // shiroSubject.login(authToken); + ensureUserIsLoggedOut(); + shiroSubject.login(authToken); + } + + // Logout the user fully before continuing. + private void ensureUserIsLoggedOut() { + try { + // Get the user if one is logged in. + org.apache.shiro.subject.Subject currentUser = SecurityUtils.getSubject(); + if (currentUser == null) return; + + // Log the user out and kill their session if possible. + currentUser.logout(); + Session session = currentUser.getSession(false); + if (session == null) return; + + session.stop(); + } catch (Exception e) { + // Ignore all errors, as we're trying to silently + // log the user out. + } } } diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/jwt/JwtVendor.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/jwt/JwtVendor.java index fe11e38e33603..e1955d2411e31 100644 --- a/sandbox/libs/authn/src/main/java/org/opensearch/authn/jwt/JwtVendor.java +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/jwt/JwtVendor.java @@ -42,6 +42,16 @@ static JsonWebKey getDefaultJsonWebKey() { return jwk; } + static JsonWebKey getESJsonWebKey() { + JsonWebKey jwk = new JsonWebKey(); + jwk.setKeyType(KeyType.OCTET); + jwk.setAlgorithm("ES256"); + jwk.setPublicKeyUse(PublicKeyUse.SIGN); + String b64SigningKey = Base64.getEncoder().encodeToString("exchangeKey".getBytes(StandardCharsets.UTF_8)); + jwk.setProperty("k", b64SigningKey); + return jwk; + } + public static String createJwt(Map claims) { JoseJwtProducer jwtProducer = new JoseJwtProducer(); jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(getDefaultJsonWebKey())); @@ -79,4 +89,106 @@ public static String createJwt(Map claims) { return encodedJwt; } + + public static String createEarlyJwt(Map claims) { + JoseJwtProducer jwtProducer = new JoseJwtProducer(); + jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(getDefaultJsonWebKey())); + JwtClaims jwtClaims = new JwtClaims(); + JwtToken jwt = new JwtToken(jwtClaims); + + jwtClaims.setNotBefore(System.currentTimeMillis() / 1000 + 60 * 60 * 24 * 365); // Not valid until a year from creation + long expiryTime = System.currentTimeMillis() / 1000 + (60 * 60); + jwtClaims.setExpiryTime(expiryTime); + + if (claims.containsKey("sub")) { + jwtClaims.setProperty("sub", claims.get("sub")); + } else { + jwtClaims.setProperty("sub", "example_subject"); + } + + String encodedJwt = jwtProducer.processJwt(jwt); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + + encodedJwt + + "\n" + + jsonMapReaderWriter.toJson(jwt.getJwsHeaders()) + + "\n" + + JwtUtils.claimsToJson(jwt.getClaims()) + ); + } + + return encodedJwt; + } + + public static String createExpiredJwt(Map claims) { + JoseJwtProducer jwtProducer = new JoseJwtProducer(); + jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(getDefaultJsonWebKey())); + JwtClaims jwtClaims = new JwtClaims(); + JwtToken jwt = new JwtToken(jwtClaims); + + long expiryTime = System.currentTimeMillis() / 1000 - 1; // This means the token expired a second before it was made so should never + // be valid + jwtClaims.setExpiryTime(expiryTime); + + if (claims.containsKey("sub")) { + jwtClaims.setProperty("sub", claims.get("sub")); + } else { + jwtClaims.setProperty("sub", "example_subject"); + } + + String encodedJwt = jwtProducer.processJwt(jwt); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + + encodedJwt + + "\n" + + jsonMapReaderWriter.toJson(jwt.getJwsHeaders()) + + "\n" + + JwtUtils.claimsToJson(jwt.getClaims()) + ); + } + + return encodedJwt; + } + + public static String createInvalidJwt(Map claims) { + JoseJwtProducer jwtProducer = new JoseJwtProducer(); + jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(getESJsonWebKey())); + JwtClaims jwtClaims = new JwtClaims(); + JwtToken jwt = new JwtToken(jwtClaims); + + jwtClaims.setNotBefore(System.currentTimeMillis() / 1000); + long expiryTime = System.currentTimeMillis() / 1000 + (60 * 60); + jwtClaims.setExpiryTime(expiryTime); + + if (claims.containsKey("sub")) { + jwtClaims.setProperty("sub", claims.get("sub")); + } else { + jwtClaims.setProperty("sub", "example_subject"); + } + + if (claims.containsKey("iat")) { + jwtClaims.setProperty("iat", claims.get("iat")); + } else { + jwtClaims.setProperty("iat", Instant.now().toString()); + } + + String encodedJwt = jwtProducer.processJwt(jwt); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + + encodedJwt + + "\n" + + jsonMapReaderWriter.toJson(jwt.getJwsHeaders()) + + "\n" + + JwtUtils.claimsToJson(jwt.getClaims()) + ); + } + + return encodedJwt; + } } diff --git a/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BearerAuthToken.java b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BearerAuthToken.java new file mode 100644 index 0000000000000..da40b53c8d8be --- /dev/null +++ b/sandbox/libs/authn/src/main/java/org/opensearch/authn/tokens/BearerAuthToken.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.authn.tokens; + +public class BearerAuthToken extends HttpHeaderToken { + + private String headerValue; + + public BearerAuthToken(String headerValue) { + this.headerValue = headerValue; + } + + @Override + public String getHeaderValue() { + return headerValue; + } +} 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 index 03dcf6b26a53f..dad0a4fa245ad 100644 --- a/sandbox/libs/authn/src/test/java/org/opensearch/authn/AuthenticationTokenHandlerTests.java +++ b/sandbox/libs/authn/src/test/java/org/opensearch/authn/AuthenticationTokenHandlerTests.java @@ -17,7 +17,7 @@ public class AuthenticationTokenHandlerTests extends OpenSearchTestCase { - public void testShouldExtractBasicAuthTokenSuccessfully() { + public void testShouldExtractBasicAuthTokenSuccessfully() throws RuntimeException { // The auth header that is part of the request String authHeader = "Basic YWRtaW46YWRtaW4="; // admin:admin @@ -31,7 +31,7 @@ public void testShouldExtractBasicAuthTokenSuccessfully() { MatcherAssert.assertThat(new String(usernamePasswordToken.getPassword()), equalTo("admin")); } - public void testShouldExtractBasicAuthTokenSuccessfully_twoSemiColonPassword() { + public void testShouldExtractBasicAuthTokenSuccessfully_twoSemiColonPassword() throws RuntimeException { // The auth header that is part of the request String authHeader = "Basic dGVzdDp0ZTpzdA=="; // test:te:st @@ -45,7 +45,7 @@ public void testShouldExtractBasicAuthTokenSuccessfully_twoSemiColonPassword() { MatcherAssert.assertThat(new String(usernamePasswordToken.getPassword()), equalTo("te:st")); } - public void testShouldReturnNullWhenExtractingInvalidToken() { + public void testShouldReturnNullWhenExtractingInvalidToken() throws RuntimeException { String authHeader = "Basic Nah"; AuthenticationToken authToken = new BasicAuthToken(authHeader); @@ -55,10 +55,11 @@ public void testShouldReturnNullWhenExtractingInvalidToken() { MatcherAssert.assertThat(usernamePasswordToken, nullValue()); } - public void testShouldReturnNullWhenExtractingNullToken() { + public void testShouldReturnNullWhenExtractingNullToken() throws RuntimeException { 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/jwt/JwtVendorTests.java b/sandbox/libs/authn/src/test/java/org/opensearch/authn/jwt/JwtVendorTests.java index 881dd11c17141..a93677fc6bcc0 100644 --- a/sandbox/libs/authn/src/test/java/org/opensearch/authn/jwt/JwtVendorTests.java +++ b/sandbox/libs/authn/src/test/java/org/opensearch/authn/jwt/JwtVendorTests.java @@ -25,8 +25,8 @@ public void testCreateJwtWithClaims() { try { JwtToken token = JwtVerifier.getVerifiedJwtToken(encodedToken); assertTrue(token.getClaims().getClaim("sub").equals("testSubject")); - } catch (BadCredentialsException e) { - fail("Unexpected BadCredentialsException thrown"); + } catch (RuntimeException e) { + fail("Unexpected RuntimeException thrown"); } } } diff --git a/sandbox/modules/identity/build.gradle b/sandbox/modules/identity/build.gradle index d62f31e86e182..65b330c6e1b66 100644 --- a/sandbox/modules/identity/build.gradle +++ b/sandbox/modules/identity/build.gradle @@ -22,6 +22,11 @@ dependencies { testImplementation project(path: ':modules:transport-netty4') // for http testImplementation project(path: ':plugins:transport-nio') // for http + + + implementation('org.apache.cxf:cxf-rt-rs-security-jose:3.4.5') { + exclude(group: 'jakarta.activation', module: 'jakarta.activation-api') + } } //task integTest(type: RestIntegTestTask) { diff --git a/sandbox/modules/identity/src/main/java/org/opensearch/identity/SecurityRestFilter.java b/sandbox/modules/identity/src/main/java/org/opensearch/identity/SecurityRestFilter.java index 2044c6897945c..785ee0dcc2f16 100644 --- a/sandbox/modules/identity/src/main/java/org/opensearch/identity/SecurityRestFilter.java +++ b/sandbox/modules/identity/src/main/java/org/opensearch/identity/SecurityRestFilter.java @@ -16,6 +16,7 @@ import org.opensearch.authn.jwt.JwtVendor; import org.opensearch.authn.tokens.AuthenticationToken; import org.opensearch.authn.tokens.BasicAuthToken; +import org.opensearch.authn.tokens.BearerAuthToken; import org.opensearch.authn.tokens.HttpHeaderToken; import org.opensearch.client.node.NodeClient; import org.opensearch.common.settings.Settings; @@ -32,7 +33,6 @@ import java.time.Instant; import java.util.Collections; import java.util.HashMap; -import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -80,19 +80,16 @@ private boolean checkAndAuthenticateRequest(RestRequest request, RestChannel cha jwtClaims.put("sub", "subject"); jwtClaims.put("iat", Instant.now().toString()); String encodedJwt = JwtVendor.createJwt(jwtClaims); - String requestInfo = String.format( - Locale.ROOT, - "(nodeName=%s, requestId=%s, path=%s, jwtClaims=%s checkAndAuthenticateRequest)", - client.getLocalNodeId(), - request.getRequestId(), - request.getRequestId(), - jwtClaims - ); - if (log.isDebugEnabled()) { - log.debug(requestInfo); - String logMsg = String.format(Locale.ROOT, "Created internal access token %s", encodedJwt); - log.debug("{} {}", requestInfo, logMsg); - } + String prefix = "(nodeName=" + + client.getLocalNodeId() + + ", requestId=" + + request.getRequestId() + + ", path=" + + request.path() + + ", jwtClaims=" + + jwtClaims + + " checkAndAuthenticateRequest)"; + log.info(prefix + " Created internal access token " + encodedJwt); threadContext.putHeader(ThreadContextConstants.OPENSEARCH_AUTHENTICATION_TOKEN_HEADER, encodedJwt); } return true; @@ -126,6 +123,8 @@ private boolean authenticate(RestRequest request, RestChannel channel) throws IO } catch (final AuthenticationException ae) { log.info("Authentication finally failed: {}", ae.getMessage()); return false; + } catch (RuntimeException e) { + throw new RuntimeException(e); } } @@ -155,6 +154,7 @@ private boolean authenticate(RestRequest request, RestChannel channel) throws IO */ static AuthenticationToken tokenType(String authHeader) { if (authHeader.contains("Basic")) return new BasicAuthToken(authHeader); + if (authHeader.contains("Bearer")) return new BearerAuthToken(authHeader); // support other type of header tokens return null; } diff --git a/sandbox/modules/identity/src/test/java/org/opensearch/identity/BearerAuthTests.java b/sandbox/modules/identity/src/test/java/org/opensearch/identity/BearerAuthTests.java new file mode 100644 index 0000000000000..cb4722165f635 --- /dev/null +++ b/sandbox/modules/identity/src/test/java/org/opensearch/identity/BearerAuthTests.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.identity; + +import org.apache.cxf.rs.security.jose.jwt.JwtToken; +import org.apache.shiro.authc.AuthenticationToken; +import org.hamcrest.MatcherAssert; +import org.opensearch.authn.jwt.JwtVendor; +import org.opensearch.authn.jwt.JwtVerifier; +import org.opensearch.authn.tokens.BearerAuthToken; +import org.opensearch.client.Request; +import org.opensearch.client.RequestOptions; +import org.opensearch.client.Response; +import org.opensearch.client.ResponseException; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.test.rest.OpenSearchRestTestCase; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.authn.AuthenticationTokenHandler.extractShiroAuthToken; + +public class BearerAuthTests extends OpenSearchTestCase { + + public void testExpiredValidJwt() { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createExpiredJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + BearerAuthToken bearerAuthToken = new BearerAuthToken(headerBody); + + try { + AuthenticationToken extractedShiroToken = extractShiroAuthToken(bearerAuthToken); + } catch (RuntimeException ex) { + assertFalse(ex.getMessage().isEmpty()); + assertEquals("The token has expired", ex.getMessage()); + } + } + + public void testEarlyValidJwt() { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createEarlyJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + BearerAuthToken bearerAuthToken = new BearerAuthToken(headerBody); + + try { + AuthenticationToken extractedShiroToken = extractShiroAuthToken(bearerAuthToken); + } catch (RuntimeException ex) { + assertFalse(ex.getMessage().isEmpty()); + assertEquals("The token cannot be accepted yet", ex.getMessage()); + } + } + + public void testValidJwt() { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + BearerAuthToken bearerAuthToken = new BearerAuthToken(headerBody); + AuthenticationToken extractedShiroToken; + + try { + extractedShiroToken = extractShiroAuthToken(bearerAuthToken); // This should verify and then extract the shiro token for login + } catch (RuntimeException ex) { + throw new Error(ex); + } + if (extractedShiroToken == null) { + throw new Error("The value of the extracted token is null."); + } + } + + public void testInvalidJwt() { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + + String encodedToken = JwtVendor.createInvalidJwt(jwtClaims); + try { + JwtToken token = JwtVerifier.getVerifiedJwtToken(encodedToken); + } catch (RuntimeException ex) { + assertFalse(ex.getMessage().isEmpty()); + assertEquals("Invalid JWT signature", ex.getMessage()); + } + throw new Error("Function should have failed when trying to verify JWT with incorrect signature."); + } + + public void testClusterHealthWithValidBearerAuthenticationHeader() throws IOException { + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerBody).build(); + request.setOptions(options); + Response response = OpenSearchRestTestCase.client().performRequest(request); + OpenSearchRestTestCase.assertOK(response); + // Standard cluster health response + MatcherAssert.assertThat(OpenSearchRestTestCase.entityAsMap(response).size(), equalTo(17)); + MatcherAssert.assertThat(OpenSearchRestTestCase.entityAsMap(response).get("status"), equalTo("green")); + + } + + public void testClusterHealthWithExpiredBearerAuthenticationHeader() throws IOException { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createExpiredJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerBody).build(); + request.setOptions(options); + // Should be unauthorized since JWT is expired + try { + OpenSearchRestTestCase.client().performRequest(request); + } catch (ResponseException e) { + MatcherAssert.assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + } + } + + public void testClusterHealthWithInvalidBearerAuthenticationHeader() throws IOException { + + Map jwtClaims = new HashMap<>(); + jwtClaims.put("sub", "testSubject"); + String encodedToken = JwtVendor.createInvalidJwt(jwtClaims); + String headerBody = "Bearer " + encodedToken; + + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerBody).build(); + request.setOptions(options); + Response response = OpenSearchRestTestCase.client().performRequest(request); + // Should be unauthorized because created with a different signing algorithm + // Current implementation allows a unauthorized request to pass + try { + OpenSearchRestTestCase.client().performRequest(request); + } catch (ResponseException e) { + MatcherAssert.assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + } + } + + public void testClusterHealthWithCorruptBearerAuthenticationHeader() throws IOException { + + String headerBody = "Bearer NotAJWT"; + + Request request = new Request("GET", "/_cluster/health"); + RequestOptions options = RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerBody).build(); + request.setOptions(options); + try { + OpenSearchRestTestCase.client().performRequest(request); + } catch (ResponseException e) { + MatcherAssert.assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + } + } +}