From 26c1798a861cd19921c263754d00c36045a482ef Mon Sep 17 00:00:00 2001 From: Lukasz Soszynski Date: Sat, 15 Oct 2022 18:19:59 +0200 Subject: [PATCH 1/4] Test cases related to JWT authentication. Signed-off-by: Lukasz Soszynski --- .../security/SearchOperationTest.java | 10 +- .../java/org/opensearch/security/Song.java | 40 +-- .../security/http/JwtAuthenticationTests.java | 253 ++++++++++++++++++ .../http/JwtAuthorizationHeaderFactory.java | 141 ++++++++++ .../test/framework/JwtConfigBuilder.java | 62 +++++ .../test/framework/TestSecurityConfig.java | 17 +- .../cluster/OpenSearchClientProvider.java | 22 +- .../framework/cluster/TestRestClient.java | 19 +- 8 files changed, 528 insertions(+), 36 deletions(-) create mode 100644 src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java create mode 100644 src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java create mode 100644 src/integrationTest/java/org/opensearch/test/framework/JwtConfigBuilder.java diff --git a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java index a6702344b8..8efaeff648 100644 --- a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java +++ b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java @@ -221,15 +221,15 @@ public class SearchOperationTest { public static final String UNUSED_SNAPSHOT_REPOSITORY_NAME = "unused-snapshot-repository"; public static final String RESTORED_SONG_INDEX_NAME = "restored_" + WRITE_SONG_INDEX_NAME; - + public static final String UPDATE_DELETE_OPERATION_INDEX_NAME = "update_delete_index"; public static final String DOCUMENT_TO_UPDATE_ID = "doc_to_update"; - public static final String ID_P4 = "4"; - public static final String ID_S3 = "3"; - public static final String ID_S2 = "2"; - public static final String ID_S1 = "1"; + private static final String ID_P4 = "4"; + private static final String ID_S3 = "3"; + private static final String ID_S2 = "2"; + private static final String ID_S1 = "1"; static final User ADMIN_USER = new User("admin").roles(ALL_ACCESS); diff --git a/src/integrationTest/java/org/opensearch/security/Song.java b/src/integrationTest/java/org/opensearch/security/Song.java index b5ca1895b5..cf585e5dc7 100644 --- a/src/integrationTest/java/org/opensearch/security/Song.java +++ b/src/integrationTest/java/org/opensearch/security/Song.java @@ -14,20 +14,20 @@ public class Song { - static final String FIELD_TITLE = "title"; - static final String FIELD_ARTIST = "artist"; - static final String FIELD_LYRICS = "lyrics"; - static final String FIELD_STARS = "stars"; + public static final String FIELD_TITLE = "title"; + public static final String FIELD_ARTIST = "artist"; + public static final String FIELD_LYRICS = "lyrics"; + public static final String FIELD_STARS = "stars"; - static final String FIELD_GENRE = "genre"; - static final String ARTIST_FIRST = "First artist"; - static final String ARTIST_STRING = "String"; - static final String ARTIST_TWINS = "Twins"; - static final String TITLE_MAGNUM_OPUS = "Magnum Opus"; - static final String TITLE_SONG_1_PLUS_1 = "Song 1+1"; - static final String TITLE_NEXT_SONG = "Next song"; - static final String ARTIST_NO = "No!"; - static final String TITLE_POISON = "Poison"; + public static final String FIELD_GENRE = "genre"; + public static final String ARTIST_FIRST = "First artist"; + public static final String ARTIST_STRING = "String"; + public static final String ARTIST_TWINS = "Twins"; + public static final String TITLE_MAGNUM_OPUS = "Magnum Opus"; + public static final String TITLE_SONG_1_PLUS_1 = "Song 1+1"; + public static final String TITLE_NEXT_SONG = "Next song"; + public static final String ARTIST_NO = "No!"; + public static final String TITLE_POISON = "Poison"; public static final String ARTIST_YES = "yes"; @@ -43,15 +43,15 @@ public class Song { public static final String LYRICS_5 = "Little to little"; public static final String LYRICS_6 = "confidential secret classified"; - static final String GENRE_ROCK = "rock"; - static final String GENRE_JAZZ = "jazz"; - static final String GENRE_BLUES = "blues"; + public static final String GENRE_ROCK = "rock"; + public static final String GENRE_JAZZ = "jazz"; + public static final String GENRE_BLUES = "blues"; - static final String QUERY_TITLE_NEXT_SONG = FIELD_TITLE + ":" + "\"" + TITLE_NEXT_SONG + "\""; - static final String QUERY_TITLE_POISON = FIELD_TITLE + ":" + TITLE_POISON; - static final String QUERY_TITLE_MAGNUM_OPUS = FIELD_TITLE + ":" + TITLE_MAGNUM_OPUS; + public static final String QUERY_TITLE_NEXT_SONG = FIELD_TITLE + ":" + "\"" + TITLE_NEXT_SONG + "\""; + public static final String QUERY_TITLE_POISON = FIELD_TITLE + ":" + TITLE_POISON; + public static final String QUERY_TITLE_MAGNUM_OPUS = FIELD_TITLE + ":" + TITLE_MAGNUM_OPUS; - static final Map[] SONGS = { + public static final Map[] SONGS = { new Song(ARTIST_FIRST, TITLE_MAGNUM_OPUS ,LYRICS_1, 1, GENRE_ROCK).asMap(), new Song(ARTIST_STRING, TITLE_SONG_1_PLUS_1, LYRICS_2, 2, GENRE_BLUES).asMap(), new Song(ARTIST_TWINS, TITLE_NEXT_SONG, LYRICS_3, 3, GENRE_JAZZ).asMap(), diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java new file mode 100644 index 0000000000..1730efe017 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java @@ -0,0 +1,253 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.security.http; + +import java.io.IOException; +import java.security.KeyPair; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader ; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.client.Client; +import org.opensearch.client.RestHighLevelClient; +import org.opensearch.test.framework.JwtConfigBuilder; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.TestSecurityConfig.Role; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.apache.http.HttpHeaders.AUTHORIZATION; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.opensearch.action.support.WriteRequest.RefreshPolicy.IMMEDIATE; +import static org.opensearch.client.RequestOptions.DEFAULT; +import static org.opensearch.rest.RestStatus.FORBIDDEN; +import static org.opensearch.security.Song.FIELD_TITLE; +import static org.opensearch.security.Song.QUERY_TITLE_MAGNUM_OPUS; +import static org.opensearch.security.Song.SONGS; +import static org.opensearch.security.Song.TITLE_MAGNUM_OPUS; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.BASIC_AUTH_DOMAIN_ORDER; +import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS; +import static org.opensearch.test.framework.cluster.SearchRequestFactory.queryStringQueryRequest; +import static org.opensearch.test.framework.matcher.ExceptionMatcherAssert.assertThatThrownBy; +import static org.opensearch.test.framework.matcher.OpenSearchExceptionMatchers.statusException; +import static org.opensearch.test.framework.matcher.SearchResponseMatchers.isSuccessfulSearchResponse; +import static org.opensearch.test.framework.matcher.SearchResponseMatchers.numberOfTotalHitsIsEqualTo; +import static org.opensearch.test.framework.matcher.SearchResponseMatchers.searchHitContainsFieldWithValue; +import static org.opensearch.test.framework.matcher.SearchResponseMatchers.searchHitsContainDocumentWithId; + +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class JwtAuthenticationTests { + + public static final String CLAIM_USERNAME = "preferred-username"; + public static final String CLAIM_ROLES = "backend-user-roles"; + + public static final String USER_SUPERHERO = "superhero"; + public static final String USERNAME_ROOT = "root"; + public static final String ROLE_ADMIN = "role_admin"; + public static final String ROLE_DEVELOPER = "role_developer"; + public static final String ROLE_QA = "role_qa"; + public static final String ROLE_CTO = "role_cto"; + public static final String ROLE_CEO = "role_ceo"; + public static final String ROLE_VP = "role_vp"; + public static final String POINTER_BACKEND_ROLES = "/backend_roles"; + public static final String POINTER_USERNAME = "/user_name"; + + public static final String QA_DEPARTMENT = "qa-department"; + + public static final String CLAIM_DEPARTMENT = "department"; + + public static final String DEPARTMENT_SONG_INDEX_PATTERN = String.format("song_lyrics_${attr.jwt.%s}", CLAIM_DEPARTMENT); + + public static final String QA_SONG_INDEX_NAME = String.format("song_lyrics_%s", QA_DEPARTMENT); + + private static final KeyPair KEY_PAIR = Keys.keyPairFor(SignatureAlgorithm.RS256); + private static final String PUBLIC_KEY = new String(Base64.getEncoder().encode(KEY_PAIR.getPublic().getEncoded())); + + static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); + + private static final String JWT_AUTH_HEADER = "jwt-auth"; + + private static final JwtAuthorizationHeaderFactory tokenFactory = new JwtAuthorizationHeaderFactory( + KEY_PAIR.getPrivate(), + CLAIM_USERNAME, + CLAIM_ROLES, + JWT_AUTH_HEADER); + + public static final TestSecurityConfig.AuthcDomain JWT_AUTH_DOMAIN = new TestSecurityConfig + .AuthcDomain("jwt", BASIC_AUTH_DOMAIN_ORDER - 1) + .jwtHttpAuthenticator(new JwtConfigBuilder().jwtHeader(JWT_AUTH_HEADER).signingKey(PUBLIC_KEY).subjectKey(CLAIM_USERNAME) + .rolesKey(CLAIM_ROLES)) + .backend("noop"); + public static final String SONG_ID_1 = "song-id-01"; + + public static final Role DEPARTMENT_SONG_LISTENER_ROLE = new Role("department-song-listener-role") + .indexPermissions("indices:data/read/search").on(DEPARTMENT_SONG_INDEX_PATTERN); + + @ClassRule + public static final LocalCluster cluster = new LocalCluster.Builder() + .clusterManager(ClusterManager.SINGLENODE).anonymousAuth(false) + .nodeSettings(Map.of("plugins.security.restapi.roles_enabled", List.of("user_" + ADMIN_USER.getName() +"__" + ALL_ACCESS.getName()))) + .authc(AUTHC_HTTPBASIC_INTERNAL).users(ADMIN_USER).roles(DEPARTMENT_SONG_LISTENER_ROLE) + .authc(JWT_AUTH_DOMAIN) + .build(); + + @BeforeClass + public static void createTestData() { + try (Client client = cluster.getInternalNodeClient()) { + client.prepareIndex(QA_SONG_INDEX_NAME).setId(SONG_ID_1).setRefreshPolicy(IMMEDIATE).setSource(SONGS[0]).get(); + } + try(TestRestClient client = cluster.getRestClient(ADMIN_USER)){ + client.createRoleMapping(ROLE_VP, DEPARTMENT_SONG_LISTENER_ROLE.getName()); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_positive() { + try(TestRestClient client = cluster.getRestClient(tokenFactory.validToken(USER_SUPERHERO))){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + String username = response.getTextFromJsonBody(POINTER_USERNAME); + assertThat(username, equalTo(username)); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_positiveWithAnotherUsername() { + try(TestRestClient client = cluster.getRestClient(tokenFactory.validToken(USERNAME_ROOT))){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + String username = response.getTextFromJsonBody(POINTER_USERNAME); + assertThat(username, equalTo(USERNAME_ROOT)); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_failureLackingUserName() { + try(TestRestClient client = cluster.getRestClient(tokenFactory.tokenWithoutPreferredUsername(USER_SUPERHERO))){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(401); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_failureExpiredToken() { + try(TestRestClient client = cluster.getRestClient(tokenFactory.expiredToken(USER_SUPERHERO))){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(401); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_failureIncorrectFormatOfToken() { + Header header = new BasicHeader(AUTHORIZATION, "not.a.token"); + try(TestRestClient client = cluster.getRestClient(header)){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(401); + } + } + + @Test + public void shouldAuthenticateWithJwtToken_failureIncorrectSignature() { + KeyPair incorrectKeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); + Header header = tokenFactory.tokenSignedWithKey(incorrectKeyPair.getPrivate(), USER_SUPERHERO); + try(TestRestClient client = cluster.getRestClient(header)){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(401); + } + } + + @Test + public void shouldReadRolesFromToken_positiveFirstRoleSet() { + Header header = tokenFactory.validToken(USER_SUPERHERO, ROLE_ADMIN, ROLE_DEVELOPER, ROLE_QA); + try(TestRestClient client = cluster.getRestClient(header)){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + List roles = response.getTextArrayFromJsonBody(POINTER_BACKEND_ROLES); + assertThat(roles, hasSize(3)); + assertThat(roles, containsInAnyOrder(ROLE_ADMIN, ROLE_DEVELOPER, ROLE_QA)); + } + } + + @Test + public void shouldReadRolesFromToken_positiveSecondRoleSet() { + Header header = tokenFactory.validToken(USER_SUPERHERO, ROLE_CTO, ROLE_CEO, ROLE_VP); + try(TestRestClient client = cluster.getRestClient(header)){ + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + List roles = response.getTextArrayFromJsonBody(POINTER_BACKEND_ROLES); + assertThat(roles, hasSize(3)); + assertThat(roles, containsInAnyOrder(ROLE_CTO, ROLE_CEO, ROLE_VP)); + } + } + + @Test + public void shouldExposeTokenClaimsAsUserAttributes_positive() throws IOException { + String[] roles = { ROLE_VP }; + Map additionalClaims = Map.of(CLAIM_DEPARTMENT, QA_DEPARTMENT); + Header header = tokenFactory.validTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); + try(RestHighLevelClient client = cluster.getRestHighLevelClient(List.of(header))){ + SearchRequest searchRequest = queryStringQueryRequest(QA_SONG_INDEX_NAME, QUERY_TITLE_MAGNUM_OPUS); + + SearchResponse response = client.search(searchRequest, DEFAULT); + + assertThat(response, isSuccessfulSearchResponse()); + assertThat(response, numberOfTotalHitsIsEqualTo(1)); + assertThat(response, searchHitsContainDocumentWithId(0, QA_SONG_INDEX_NAME, SONG_ID_1)); + assertThat(response, searchHitContainsFieldWithValue(0, FIELD_TITLE, TITLE_MAGNUM_OPUS)); + } + } + + @Test + public void shouldExposeTokenClaimsAsUserAttributes_negative() throws IOException { + String[] roles = { ROLE_VP }; + Map additionalClaims = Map.of(CLAIM_DEPARTMENT, "department-without-access-to-qa-song-index"); + Header header = tokenFactory.validTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); + try(RestHighLevelClient client = cluster.getRestHighLevelClient(List.of(header))){ + SearchRequest searchRequest = queryStringQueryRequest(QA_SONG_INDEX_NAME, QUERY_TITLE_MAGNUM_OPUS); + + assertThatThrownBy(() -> client.search(searchRequest, DEFAULT), statusException(FORBIDDEN)); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java new file mode 100644 index 0000000000..1fe57eb216 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java @@ -0,0 +1,141 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.security.http; + +import java.security.PrivateKey; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableMap; +import io.jsonwebtoken.Jwts; +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader; + +import static io.jsonwebtoken.SignatureAlgorithm.RS256; +import static java.util.Objects.requireNonNull; + +class JwtAuthorizationHeaderFactory { + public static final String AUDIENCE = "OpenSearch"; + public static final String ISSUER = "test-code"; + private final PrivateKey privateKey; + + private final String usernameClaimName; + + private final String rolesClaimName; + + private final String headerName; + + public JwtAuthorizationHeaderFactory(PrivateKey privateKey, String usernameClaimName, String rolesClaimName, String headerName) { + this.privateKey = requireNonNull(privateKey, "Private key is required"); + this.usernameClaimName = requireNonNull(usernameClaimName, "Username claim name is required"); + this.rolesClaimName = requireNonNull(rolesClaimName, "Roles claim name is required."); + this.headerName = requireNonNull(headerName, "Header name is required"); + } + + Header validToken(String username, String...roles) { + requireNonNull(username, "Username is required"); + Date now = new Date(); + String token = Jwts.builder() + .setClaims(customClaimsMap(username, roles)) + .setIssuer(ISSUER) + .setSubject(subject(username)) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 3600 * 1000)) + .signWith(privateKey, RS256) + .compact(); + return toHeader(token); + } + + private Map customClaimsMap(String username, String[] roles) { + ImmutableMap.Builder builder = new ImmutableMap.Builder(); + if(StringUtils.isNoneEmpty(username)) { + builder.put(usernameClaimName, username); + } + if((roles != null) && (roles.length > 0)) { + builder.put(rolesClaimName, Arrays.stream(roles).collect(Collectors.joining(","))); + } + return builder.build(); + } + + Header validTokenWithCustomClaims(String username, String[] roles, Map additionalClaims) { + requireNonNull(username, "Username is required"); + requireNonNull(additionalClaims, "Custom claims are required"); + Map claims = new HashMap<>(customClaimsMap(username, roles)); + claims.putAll(additionalClaims); + Date now = new Date(); + String token = Jwts.builder() + .setClaims(claims) + .setIssuer(ISSUER) + .setSubject(subject(username)) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 3600 * 1000)) + .signWith(privateKey, RS256) + .compact(); + return toHeader(token); + } + + private BasicHeader toHeader(String token) { + return new BasicHeader(headerName, token); + } + + Header tokenWithoutPreferredUsername(String username) { + requireNonNull(username, "Username is required"); + Date now = new Date(); + String token = Jwts.builder() + .setIssuer(ISSUER) + .setSubject(username) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 3600 * 1000)) + .signWith(privateKey, RS256) + .compact(); + return toHeader(token); + } + + public Header expiredToken(String username) { + requireNonNull(username, "Username is required"); + Date now = new Date(1000); + String token = Jwts.builder() + .setClaims(Map.of(usernameClaimName, username)) + .setIssuer(ISSUER) + .setSubject(subject(username)) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 3600 * 1000)) + .signWith(privateKey, RS256) + .compact(); + return toHeader(token); + } + + public Header tokenSignedWithKey(PrivateKey key, String username) { + requireNonNull(key, "Private key is required"); + requireNonNull(username, "Username is required"); + Date now = new Date(); + String token = Jwts.builder() + .setClaims(Map.of(usernameClaimName, username)) + .setIssuer(ISSUER) + .setSubject(subject(username)) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 3600 * 1000)) + .signWith(key, RS256) + .compact(); + return toHeader(token); + } + + private static String subject(String username) { + return "subject-" + username; + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/JwtConfigBuilder.java b/src/integrationTest/java/org/opensearch/test/framework/JwtConfigBuilder.java new file mode 100644 index 0000000000..5b1ea2c678 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/JwtConfigBuilder.java @@ -0,0 +1,62 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.test.framework; + +import java.util.Map; +import java.util.Objects; + +import com.google.common.collect.ImmutableMap.Builder; + +import static org.apache.commons.lang3.StringUtils.isNoneBlank; + +public class JwtConfigBuilder { + private String jwtHeader; + private String signingKey; + private String subjectKey; + private String rolesKey; + + public JwtConfigBuilder jwtHeader(String jwtHeader) { + this.jwtHeader = jwtHeader; + return this; + } + + public JwtConfigBuilder signingKey(String signingKey) { + this.signingKey = signingKey; + return this; + } + + public JwtConfigBuilder subjectKey(String subjectKey) { + this.subjectKey = subjectKey; + return this; + } + + public JwtConfigBuilder rolesKey(String rolesKey) { + this.rolesKey = rolesKey; + return this; + } + + public Map build() { + Builder builder = new Builder<>(); + if(Objects.isNull(signingKey)) { + throw new IllegalStateException("Signing key is required."); + } + builder.put("signing_key", signingKey); + if(isNoneBlank(jwtHeader)) { + builder.put("jwt_header", jwtHeader); + } + if(isNoneBlank(subjectKey)) { + builder.put("subject_key", subjectKey); + } + if(isNoneBlank(rolesKey)) { + builder.put("roles_key", rolesKey); + } + return builder.build(); + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index 300604d53e..d86bfe7d48 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -42,7 +42,6 @@ import java.util.Set; import java.util.stream.Collectors; -import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.crypto.generators.OpenBSDBCrypt; @@ -62,6 +61,8 @@ import org.opensearch.security.securityconf.impl.CType; import org.opensearch.test.framework.cluster.OpenSearchClientProvider.UserCredentialsHolder; +import static org.apache.http.HttpHeaders.AUTHORIZATION; + /** * This class allows the declarative specification of the security configuration; in particular: * @@ -350,18 +351,20 @@ public static class AuthcDomain implements ToXContentObject { private static String PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoqZbjLUAWc+DZTkinQAdvy1GFjPHPnxheU89hSiWoDD3NOW76H3u3T7cCDdOah2msdxSlBmCBH6wik8qLYkcV8owWukQg3PQmbEhrdPaKo0QCgomWs4nLgtmEYqcZ+QQldd82MdTlQ1QmoQmI9Uxqs1SuaKZASp3Gy19y8su5CV+FZ6BruUw9HELK055sAwl3X7j5ouabXGbcib2goBF3P52LkvbJLuWr5HDZEOeSkwIeqSeMojASM96K5SdotD+HwEyjaTjzRPL2Aa1BEQFWOQ6CFJLyLH7ZStDuPM1mJU1VxIVfMbZrhsUBjAnIhRynmWxML7YlNqkP9j6jyOIYQIDAQAB"; - public final static AuthcDomain AUTHC_HTTPBASIC_INTERNAL = new TestSecurityConfig.AuthcDomain("basic", 0) + public static final int BASIC_AUTH_DOMAIN_ORDER = 0; + public final static AuthcDomain AUTHC_HTTPBASIC_INTERNAL = new TestSecurityConfig.AuthcDomain("basic", BASIC_AUTH_DOMAIN_ORDER) .httpAuthenticatorWithChallenge("basic").backend("internal"); - public final static AuthcDomain AUTHC_HTTPBASIC_INTERNAL_WITHOUT_CHALLENGE = new TestSecurityConfig.AuthcDomain("basic", 0) + public final static AuthcDomain AUTHC_HTTPBASIC_INTERNAL_WITHOUT_CHALLENGE = new TestSecurityConfig.AuthcDomain("basic", + BASIC_AUTH_DOMAIN_ORDER) .httpAuthenticator("basic").backend("internal"); public final static AuthcDomain DISABLED_AUTHC_HTTPBASIC_INTERNAL = new TestSecurityConfig - .AuthcDomain("basic", 0, false).httpAuthenticator("basic").backend("internal"); + .AuthcDomain("basic", BASIC_AUTH_DOMAIN_ORDER, false).httpAuthenticator("basic").backend("internal"); public final static AuthcDomain JWT_AUTH_DOMAIN = new TestSecurityConfig .AuthcDomain("jwt", 1) - .jwtHttpAuthenticator("Authorization", PUBLIC_KEY).backend("noop"); + .jwtHttpAuthenticator(new JwtConfigBuilder().jwtHeader(AUTHORIZATION).signingKey(PUBLIC_KEY)).backend("noop"); private final String id; private boolean enabled = true; @@ -385,9 +388,9 @@ public AuthcDomain httpAuthenticator(String type) { return this; } - public AuthcDomain jwtHttpAuthenticator(String headerName, String signingKey) { + public AuthcDomain jwtHttpAuthenticator(JwtConfigBuilder builder) { this.httpAuthenticator = new HttpAuthenticator("jwt") - .challenge(false).config(ImmutableMap.of("jwt_header", headerName, "signing_key", signingKey)); + .challenge(false).config(builder.build()); return this; } diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index 3c6c375c38..5679788d39 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -35,6 +35,8 @@ import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -99,9 +101,20 @@ default TestRestClient getRestClient(UserCredentialsHolder user, Header... heade } default RestHighLevelClient getRestHighLevelClient(UserCredentialsHolder user) { - InetSocketAddress httpAddress = getHttpAddress(); + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials(new AuthScope(null, -1), new UsernamePasswordCredentials(user.getName(), user.getPassword().toCharArray())); + + return getRestHighLevelClient(credentialsProvider, Collections.emptySet()); + } + + default RestHighLevelClient getRestHighLevelClient(Collection defaultHeaders) { + + + return getRestHighLevelClient(null, defaultHeaders); + } + + private RestHighLevelClient getRestHighLevelClient(BasicCredentialsProvider credentialsProvider, Collection defaultHeaders) { RestClientBuilder.HttpClientConfigCallback configCallback = httpClientBuilder -> { TlsStrategy tlsStrategy = ClientTlsStrategyBuilder .create() @@ -120,15 +133,18 @@ public TlsDetails create(final SSLEngine sslEngine) { .setTlsStrategy(tlsStrategy) .build(); - httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + if(credentialsProvider != null) { + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } + httpClientBuilder.setDefaultHeaders(defaultHeaders); httpClientBuilder.setConnectionManager(cm); return httpClientBuilder; }; + InetSocketAddress httpAddress = getHttpAddress(); RestClientBuilder builder = RestClient.builder(new HttpHost("https", httpAddress.getHostString(), httpAddress.getPort())) .setHttpClientConfigCallback(configCallback); - return new RestHighLevelClient(builder); } diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index aac11008d6..092723f9dd 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -39,6 +39,8 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import javax.net.ssl.SSLContext; @@ -199,7 +201,16 @@ public HttpResponse executeRequest(HttpUriRequest uriRequest, Header... requestS } } - public final String getHttpServerUri() { + public void createRoleMapping(String backendRoleName, String roleName) { + requireNonNull(backendRoleName, "Backend role name is required"); + requireNonNull(roleName, "Role name is required"); + String path = "_plugins/_security/api/rolesmapping/" + roleName; + String body = String.format("{\"backend_roles\": [\"%s\"]}", backendRoleName); + HttpResponse response = putJson(path, body); + response.assertStatusCode(201); + } + + protected final String getHttpServerUri() { return "http" + (enableHTTPClientSSL ? "s" : "") + "://" + nodeHttpAddress.getHostString() + ":" + nodeHttpAddress.getPort(); } @@ -299,6 +310,12 @@ public List
getHeaders() { public String getTextFromJsonBody(String jsonPointer) { return getJsonNodeAt(jsonPointer).asText(); } + + public List getTextArrayFromJsonBody(String jsonPointer) { + return StreamSupport.stream(getJsonNodeAt(jsonPointer).spliterator(), false) + .map(JsonNode::textValue) + .collect(Collectors.toList()); + } public int getIntFromJsonBody(String jsonPointer) { return getJsonNodeAt(jsonPointer).asInt(); From 57a8671aebc9193bb6cea7d38b44db3869126fd1 Mon Sep 17 00:00:00 2001 From: Lukasz Soszynski Date: Thu, 20 Oct 2022 16:10:11 +0200 Subject: [PATCH 2/4] JWT authentication tests extended to verify unauthorized response reason. Signed-off-by: Lukasz Soszynski --- .../security/http/JwtAuthenticationTests.java | 9 +++ .../framework/log/LogCapturingAppender.java | 81 +++++++++++++++++++ .../test/framework/log/LogsRule.java | 53 ++++++++++++ .../resources/log4j2-test.properties | 14 +++- 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java create mode 100644 src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java index 1730efe017..497cd16835 100644 --- a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java @@ -22,6 +22,7 @@ import org.apache.hc.core5.http.message.BasicHeader ; import org.junit.BeforeClass; import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -36,6 +37,7 @@ import org.opensearch.test.framework.cluster.LocalCluster; import org.opensearch.test.framework.cluster.TestRestClient; import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; +import org.opensearch.test.framework.log.LogsRule; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.hamcrest.MatcherAssert.assertThat; @@ -117,6 +119,9 @@ public class JwtAuthenticationTests { .authc(JWT_AUTH_DOMAIN) .build(); + @Rule + public LogsRule logsRule = new LogsRule("com.amazon.dlic.auth.http.jwt.HTTPJwtAuthenticator"); + @BeforeClass public static void createTestData() { try (Client client = cluster.getInternalNodeClient()) { @@ -158,6 +163,7 @@ public void shouldAuthenticateWithJwtToken_failureLackingUserName() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); + logsRule.assertThatContain("No subject found in JWT token"); } } @@ -168,6 +174,7 @@ public void shouldAuthenticateWithJwtToken_failureExpiredToken() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); + logsRule.assertThatContain("Invalid or expired JWT token."); } } @@ -179,6 +186,7 @@ public void shouldAuthenticateWithJwtToken_failureIncorrectFormatOfToken() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); + logsRule.assertThatContain(String.format("No JWT token found in '%s' header header", JWT_AUTH_HEADER)); } } @@ -191,6 +199,7 @@ public void shouldAuthenticateWithJwtToken_failureIncorrectSignature() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); + logsRule.assertThatContain("Invalid or expired JWT token."); } } diff --git a/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java b/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java new file mode 100644 index 0000000000..83baf031c7 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java @@ -0,0 +1,81 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.test.framework.log; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.collections.Buffer; +import org.apache.commons.collections.BufferUtils; +import org.apache.commons.collections.buffer.CircularFifoBuffer; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.Core; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.Layout; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; + +import static org.opensearch.test.framework.log.LogCapturingAppender.PLUGIN_NAME; + +@Plugin(name = PLUGIN_NAME, category = Core.CATEGORY_NAME, elementType = Appender.ELEMENT_TYPE, printObject = true) +public class LogCapturingAppender extends AbstractAppender { + + public final static String PLUGIN_NAME = "LogCapturingAppender"; + public static final int MAX_SIZE = 100; + private static final Buffer messages = BufferUtils.synchronizedBuffer(new CircularFifoBuffer(MAX_SIZE)); + + private static final Set activeLoggers = Collections.synchronizedSet(new HashSet<>()); + + protected LogCapturingAppender(String name, Filter filter, Layout layout, boolean ignoreExceptions, Property[] properties) { + super(name, filter, layout, ignoreExceptions, properties); + } + + @PluginFactory + public static LogCapturingAppender createAppender(@PluginAttribute(value = "name", defaultString = "logCapturingAppender") String name) { + return new LogCapturingAppender(name, null, null, true, Property.EMPTY_ARRAY); + } + + @Override + public void append(LogEvent event) { + String loggerName = event.getLoggerName(); + boolean loggable = activeLoggers.contains(loggerName); + if(loggable) { + messages.add(event.getMessage().getFormattedMessage()); + } + } + + public static void enable(String...loggerNames) { + disable(); + activeLoggers.addAll(Arrays.asList(loggerNames)); + } + + public static void disable() { + activeLoggers.clear(); + messages.clear(); + } + + public static List getLogMessages() { + return new ArrayList<>(messages); + } + + @Override + public String toString() { + return "LogCapturingAppender{}"; + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java b/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java new file mode 100644 index 0000000000..4a0b35dd36 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java @@ -0,0 +1,53 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.test.framework.log; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.junit.rules.ExternalResource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; + +public class LogsRule extends ExternalResource { + + private final String[] loggerNames; + + public LogsRule(String...loggerNames) { + this.loggerNames = Objects.requireNonNull(loggerNames, "Logger names are required"); + } + + @Override + protected void before() { + LogCapturingAppender.enable(loggerNames); + } + + @Override + protected void after() { + LogCapturingAppender.disable(); + } + + public void assertThatContain(String expectedLogMessage) { + List messages = LogCapturingAppender.getLogMessages(); + String reason = reasonMessage(expectedLogMessage, messages); + assertThat(reason, messages, hasItem(expectedLogMessage)); + } + + private static String reasonMessage(String expectedLogMessage, List messages) { + String concatenatedLogMessages = messages.stream() + .map(message -> String.format("'%s'", message)) + .collect(Collectors.joining(", ")); + return String.format("Expected message '%s' has not been found in logs. All captured log messages: %s", + expectedLogMessage, + concatenatedLogMessages); + } +} diff --git a/src/integrationTest/resources/log4j2-test.properties b/src/integrationTest/resources/log4j2-test.properties index 1925c087a6..d9a4d672e0 100644 --- a/src/integrationTest/resources/log4j2-test.properties +++ b/src/integrationTest/resources/log4j2-test.properties @@ -1,7 +1,6 @@ status = info name = Integration test logging configuration - - +packages = org.opensearch.test.framework.log appender.console.type = Console appender.console.name = consoleAppender @@ -12,13 +11,20 @@ appender.console.filter.prerelease.regex=.+\\Qis a pre-release version of OpenSe appender.console.filter.prerelease.onMatch=DENY appender.console.filter.prerelease.onMismatch=NEUTRAL -rootLogger.level = warn +appender.capturing.type = LogCapturingAppender +appender.capturing.name = logCapturingAppender + +rootLogger.level = info rootLogger.appenderRef.stdout.ref = consoleAppender -logger.testsecconfig.name=org.opensearch.test.framework.TestSecurityConfig +logger.testsecconfig.name = org.opensearch.test.framework.TestSecurityConfig logger.testsecconfig.level = info logger.localopensearchcluster.name=org.opensearch.test.framework.cluster.LocalOpenSearchCluster logger.localopensearchcluster.level = info logger.auditlogs.name=org.opensearch.test.framework.audit logger.auditlogs.level = info +# Logger required by test org.opensearch.security.http.JwtAuthenticationTests +logger.httpjwtauthenticator.name = com.amazon.dlic.auth.http.jwt.HTTPJwtAuthenticator +logger.httpjwtauthenticator.level = debug +logger.httpjwtauthenticator.appenderRef.capturing.ref = logCapturingAppender From 72c5e17522bb25db81365b1ca8c2b4ed32a24141 Mon Sep 17 00:00:00 2001 From: Lukasz Soszynski Date: Thu, 3 Nov 2022 11:58:54 +0100 Subject: [PATCH 3/4] Methods in class JwtAuthorizationHeaderFactory renamed. Signed-off-by: Lukasz Soszynski --- .../security/http/JwtAuthenticationTests.java | 20 +++++++++---------- .../http/JwtAuthorizationHeaderFactory.java | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java index 497cd16835..7f5d21ea58 100644 --- a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java @@ -134,7 +134,7 @@ public static void createTestData() { @Test public void shouldAuthenticateWithJwtToken_positive() { - try(TestRestClient client = cluster.getRestClient(tokenFactory.validToken(USER_SUPERHERO))){ + try(TestRestClient client = cluster.getRestClient(tokenFactory.generateValidToken(USER_SUPERHERO))){ HttpResponse response = client.getAuthInfo(); @@ -146,7 +146,7 @@ public void shouldAuthenticateWithJwtToken_positive() { @Test public void shouldAuthenticateWithJwtToken_positiveWithAnotherUsername() { - try(TestRestClient client = cluster.getRestClient(tokenFactory.validToken(USERNAME_ROOT))){ + try(TestRestClient client = cluster.getRestClient(tokenFactory.generateValidToken(USERNAME_ROOT))){ HttpResponse response = client.getAuthInfo(); @@ -158,7 +158,7 @@ public void shouldAuthenticateWithJwtToken_positiveWithAnotherUsername() { @Test public void shouldAuthenticateWithJwtToken_failureLackingUserName() { - try(TestRestClient client = cluster.getRestClient(tokenFactory.tokenWithoutPreferredUsername(USER_SUPERHERO))){ + try(TestRestClient client = cluster.getRestClient(tokenFactory.generateTokenWithoutPreferredUsername(USER_SUPERHERO))){ HttpResponse response = client.getAuthInfo(); @@ -169,7 +169,7 @@ public void shouldAuthenticateWithJwtToken_failureLackingUserName() { @Test public void shouldAuthenticateWithJwtToken_failureExpiredToken() { - try(TestRestClient client = cluster.getRestClient(tokenFactory.expiredToken(USER_SUPERHERO))){ + try(TestRestClient client = cluster.getRestClient(tokenFactory.generateExpiredToken(USER_SUPERHERO))){ HttpResponse response = client.getAuthInfo(); @@ -186,14 +186,14 @@ public void shouldAuthenticateWithJwtToken_failureIncorrectFormatOfToken() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); - logsRule.assertThatContain(String.format("No JWT token found in '%s' header header", JWT_AUTH_HEADER)); + logsRule.assertThatContain(String.format("No JWT token found in '%s' header.", JWT_AUTH_HEADER)); } } @Test public void shouldAuthenticateWithJwtToken_failureIncorrectSignature() { KeyPair incorrectKeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256); - Header header = tokenFactory.tokenSignedWithKey(incorrectKeyPair.getPrivate(), USER_SUPERHERO); + Header header = tokenFactory.generateTokenSignedWithKey(incorrectKeyPair.getPrivate(), USER_SUPERHERO); try(TestRestClient client = cluster.getRestClient(header)){ HttpResponse response = client.getAuthInfo(); @@ -205,7 +205,7 @@ public void shouldAuthenticateWithJwtToken_failureIncorrectSignature() { @Test public void shouldReadRolesFromToken_positiveFirstRoleSet() { - Header header = tokenFactory.validToken(USER_SUPERHERO, ROLE_ADMIN, ROLE_DEVELOPER, ROLE_QA); + Header header = tokenFactory.generateValidToken(USER_SUPERHERO, ROLE_ADMIN, ROLE_DEVELOPER, ROLE_QA); try(TestRestClient client = cluster.getRestClient(header)){ HttpResponse response = client.getAuthInfo(); @@ -219,7 +219,7 @@ public void shouldReadRolesFromToken_positiveFirstRoleSet() { @Test public void shouldReadRolesFromToken_positiveSecondRoleSet() { - Header header = tokenFactory.validToken(USER_SUPERHERO, ROLE_CTO, ROLE_CEO, ROLE_VP); + Header header = tokenFactory.generateValidToken(USER_SUPERHERO, ROLE_CTO, ROLE_CEO, ROLE_VP); try(TestRestClient client = cluster.getRestClient(header)){ HttpResponse response = client.getAuthInfo(); @@ -235,7 +235,7 @@ public void shouldReadRolesFromToken_positiveSecondRoleSet() { public void shouldExposeTokenClaimsAsUserAttributes_positive() throws IOException { String[] roles = { ROLE_VP }; Map additionalClaims = Map.of(CLAIM_DEPARTMENT, QA_DEPARTMENT); - Header header = tokenFactory.validTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); + Header header = tokenFactory.generateValidTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); try(RestHighLevelClient client = cluster.getRestHighLevelClient(List.of(header))){ SearchRequest searchRequest = queryStringQueryRequest(QA_SONG_INDEX_NAME, QUERY_TITLE_MAGNUM_OPUS); @@ -252,7 +252,7 @@ public void shouldExposeTokenClaimsAsUserAttributes_positive() throws IOExceptio public void shouldExposeTokenClaimsAsUserAttributes_negative() throws IOException { String[] roles = { ROLE_VP }; Map additionalClaims = Map.of(CLAIM_DEPARTMENT, "department-without-access-to-qa-song-index"); - Header header = tokenFactory.validTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); + Header header = tokenFactory.generateValidTokenWithCustomClaims(USER_SUPERHERO, roles, additionalClaims); try(RestHighLevelClient client = cluster.getRestHighLevelClient(List.of(header))){ SearchRequest searchRequest = queryStringQueryRequest(QA_SONG_INDEX_NAME, QUERY_TITLE_MAGNUM_OPUS); diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java index 1fe57eb216..61d87b173f 100644 --- a/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthorizationHeaderFactory.java @@ -43,7 +43,7 @@ public JwtAuthorizationHeaderFactory(PrivateKey privateKey, String usernameClaim this.headerName = requireNonNull(headerName, "Header name is required"); } - Header validToken(String username, String...roles) { + Header generateValidToken(String username, String...roles) { requireNonNull(username, "Username is required"); Date now = new Date(); String token = Jwts.builder() @@ -69,7 +69,7 @@ private Map customClaimsMap(String username, String[] roles) { return builder.build(); } - Header validTokenWithCustomClaims(String username, String[] roles, Map additionalClaims) { + Header generateValidTokenWithCustomClaims(String username, String[] roles, Map additionalClaims) { requireNonNull(username, "Username is required"); requireNonNull(additionalClaims, "Custom claims are required"); Map claims = new HashMap<>(customClaimsMap(username, roles)); @@ -91,7 +91,7 @@ private BasicHeader toHeader(String token) { return new BasicHeader(headerName, token); } - Header tokenWithoutPreferredUsername(String username) { + Header generateTokenWithoutPreferredUsername(String username) { requireNonNull(username, "Username is required"); Date now = new Date(); String token = Jwts.builder() @@ -104,7 +104,7 @@ Header tokenWithoutPreferredUsername(String username) { return toHeader(token); } - public Header expiredToken(String username) { + public Header generateExpiredToken(String username) { requireNonNull(username, "Username is required"); Date now = new Date(1000); String token = Jwts.builder() @@ -119,7 +119,7 @@ public Header expiredToken(String username) { return toHeader(token); } - public Header tokenSignedWithKey(PrivateKey key, String username) { + public Header generateTokenSignedWithKey(PrivateKey key, String username) { requireNonNull(key, "Private key is required"); requireNonNull(username, "Username is required"); Date now = new Date(); From 397b730e04dceadbedaf53d191d06dfaa2578a28 Mon Sep 17 00:00:00 2001 From: Lukasz Soszynski Date: Thu, 3 Nov 2022 13:03:49 +0100 Subject: [PATCH 4/4] Javadocs for classes LogCapturingAppender, LogsRule. Signed-off-by: Lukasz Soszynski --- .../security/http/JwtAuthenticationTests.java | 5 ++- .../framework/log/LogCapturingAppender.java | 40 +++++++++++++++++++ .../test/framework/log/LogsRule.java | 14 +++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java index 7f5d21ea58..bf8af72854 100644 --- a/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java +++ b/src/integrationTest/java/org/opensearch/security/http/JwtAuthenticationTests.java @@ -39,6 +39,7 @@ import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; import org.opensearch.test.framework.log.LogsRule; +import static java.nio.charset.StandardCharsets.US_ASCII; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -89,7 +90,7 @@ public class JwtAuthenticationTests { public static final String QA_SONG_INDEX_NAME = String.format("song_lyrics_%s", QA_DEPARTMENT); private static final KeyPair KEY_PAIR = Keys.keyPairFor(SignatureAlgorithm.RS256); - private static final String PUBLIC_KEY = new String(Base64.getEncoder().encode(KEY_PAIR.getPublic().getEncoded())); + private static final String PUBLIC_KEY = new String(Base64.getEncoder().encode(KEY_PAIR.getPublic().getEncoded()), US_ASCII); static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS); @@ -186,7 +187,7 @@ public void shouldAuthenticateWithJwtToken_failureIncorrectFormatOfToken() { HttpResponse response = client.getAuthInfo(); response.assertStatusCode(401); - logsRule.assertThatContain(String.format("No JWT token found in '%s' header.", JWT_AUTH_HEADER)); + logsRule.assertThatContain(String.format("No JWT token found in '%s' header header", JWT_AUTH_HEADER)); } } diff --git a/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java b/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java index 83baf031c7..11a59f470d 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java +++ b/src/integrationTest/java/org/opensearch/test/framework/log/LogCapturingAppender.java @@ -33,24 +33,53 @@ import static org.opensearch.test.framework.log.LogCapturingAppender.PLUGIN_NAME; +/** +*

The class acts as Log4j2 appender with a special purpose. The appender is used to capture logs which are generated during tests and +* then test can examine logs. To use the appender it is necessary to:

+*
    +*
  1. Add package with appender to log4j2 package scan in Log4j2 configuration file
  2. +*
  3. Create appender in log4j2 configuration
  4. +*
  5. Assign required loggers to appender
  6. +*
  7. Enable appender for certain classes with method {@link #enable(String...)}. Each test can enable appender for distinct classes
  8. +*
+*/ @Plugin(name = PLUGIN_NAME, category = Core.CATEGORY_NAME, elementType = Appender.ELEMENT_TYPE, printObject = true) public class LogCapturingAppender extends AbstractAppender { public final static String PLUGIN_NAME = "LogCapturingAppender"; + /** + * Appender stores only last MAX_SIZE messages to avoid excessive RAM memory usage. + */ public static final int MAX_SIZE = 100; + + /** + * Buffer for captured log messages + */ private static final Buffer messages = BufferUtils.synchronizedBuffer(new CircularFifoBuffer(MAX_SIZE)); + /** + * Log messages are stored in buffer {@link #messages} only for classes which are added to the {@link #activeLoggers} set. + */ private static final Set activeLoggers = Collections.synchronizedSet(new HashSet<>()); protected LogCapturingAppender(String name, Filter filter, Layout layout, boolean ignoreExceptions, Property[] properties) { super(name, filter, layout, ignoreExceptions, properties); } + /** + * Method used by Log4j2 to create appender + * @param name appender name from Log4j2 configuration + * @return newly created appender + */ @PluginFactory public static LogCapturingAppender createAppender(@PluginAttribute(value = "name", defaultString = "logCapturingAppender") String name) { return new LogCapturingAppender(name, null, null, true, Property.EMPTY_ARRAY); } + /** + * Method invoked by Log4j2 to append log events + * @param event The LogEvent, represents log message. + */ @Override public void append(LogEvent event) { String loggerName = event.getLoggerName(); @@ -60,16 +89,27 @@ public void append(LogEvent event) { } } + /** + * To collect log messages form given logger the logger name must be passed to {@link #enable(String...)} method. + * @param loggerNames logger names + */ public static void enable(String...loggerNames) { disable(); activeLoggers.addAll(Arrays.asList(loggerNames)); } + /** + * Invocation cause that appender stops collecting log messages. Additionally, memory used by collected messages so far is released. + */ public static void disable() { activeLoggers.clear(); messages.clear(); } + /** + * Is used to obtain gathered log messages + * @return Log messages + */ public static List getLogMessages() { return new ArrayList<>(messages); } diff --git a/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java b/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java index 4a0b35dd36..34fe6f4455 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java +++ b/src/integrationTest/java/org/opensearch/test/framework/log/LogsRule.java @@ -18,10 +18,20 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasItem; +/** +* The class is a JUnit 4 rule and enables developers to write assertion related to log messages generated in the course of test. To use +* {@link LogsRule} appender {@link LogCapturingAppender} must be properly configured. The rule also manages {@link LogCapturingAppender} +* so that memory occupied by gathered log messages is released after each test. +*/ public class LogsRule extends ExternalResource { private final String[] loggerNames; + /** + * Constructor used to start gathering log messages from certain loggers + * @param loggerNames Loggers names. Log messages are collected only if the log message is associated with the logger with a name which + * is present in loggerNames parameter. + */ public LogsRule(String...loggerNames) { this.loggerNames = Objects.requireNonNull(loggerNames, "Logger names are required"); } @@ -36,6 +46,10 @@ protected void after() { LogCapturingAppender.disable(); } + /** + * Check if during the tests certain log message was logged + * @param expectedLogMessage expected log message + */ public void assertThatContain(String expectedLogMessage) { List messages = LogCapturingAppender.getLogMessages(); String reason = reasonMessage(expectedLogMessage, messages);