Skip to content

Commit d643fb2

Browse files
authored
[Feature/Extension] Restrict OBO token's usage for certain endpoints (opensearch-project#3008)
Signed-off-by: Ryan Liang <jiallian@amazon.com>
1 parent 2319059 commit d643fb2

File tree

9 files changed

+134
-54
lines changed

9 files changed

+134
-54
lines changed

src/integrationTest/java/org/opensearch/security/http/OnBehalfOfJwtAuthenticationTest.java

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
2020
import org.apache.hc.core5.http.Header;
2121
import org.apache.hc.core5.http.message.BasicHeader;
22+
import org.junit.Assert;
2223
import org.junit.ClassRule;
2324
import org.junit.Test;
2425
import org.junit.runner.RunWith;
@@ -55,8 +56,15 @@ public class OnBehalfOfJwtAuthenticationTest {
5556
private static final String encryptionKey = Base64.getEncoder().encodeToString("encryptionKey".getBytes(StandardCharsets.UTF_8));
5657
public static final String ADMIN_USER_NAME = "admin";
5758
public static final String DEFAULT_PASSWORD = "secret";
59+
public static final String NEW_PASSWORD = "testPassword123!!";
5860
public static final String OBO_TOKEN_REASON = "{\"reason\":\"Test generation\"}";
5961
public static final String OBO_ENDPOINT_PREFIX = "_plugins/_security/api/user/onbehalfof";
62+
public static final String OBO_REASON = "{\"reason\":\"Testing\", \"service\":\"self-issued\"}";
63+
public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \""
64+
+ DEFAULT_PASSWORD
65+
+ "\", \"password\": \""
66+
+ NEW_PASSWORD
67+
+ "\" }";
6068

6169
@ClassRule
6270
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
@@ -76,60 +84,62 @@ public class OnBehalfOfJwtAuthenticationTest {
7684

7785
@Test
7886
public void shouldAuthenticateWithOBOTokenEndPoint() {
79-
Header adminOboAuthHeader;
80-
81-
try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) {
82-
83-
client.assertCorrectCredentials(ADMIN_USER_NAME);
84-
85-
TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_TOKEN_REASON);
86-
response.assertStatusCode(200);
87-
88-
Map<String, Object> oboEndPointResponse = response.getBodyAs(Map.class);
89-
assertThat(oboEndPointResponse, allOf(aMapWithSize(3), hasKey("user"), hasKey("onBehalfOfToken"), hasKey("duration")));
87+
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
88+
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
89+
authenticateWithOboToken(adminOboAuthHeader, ADMIN_USER_NAME, 200);
90+
}
9091

91-
String encodedOboTokenStr = oboEndPointResponse.get("onBehalfOfToken").toString();
92+
@Test
93+
public void shouldNotAuthenticateWithATemperedOBOToken() {
94+
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
95+
oboToken = oboToken.substring(0, oboToken.length() - 1); // tampering the token
96+
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
97+
authenticateWithOboToken(adminOboAuthHeader, ADMIN_USER_NAME, 401);
98+
}
9299

93-
adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + encodedOboTokenStr);
94-
}
100+
@Test
101+
public void shouldNotAuthenticateForUsingOBOTokenToAccessOBOEndpoint() {
102+
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
103+
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
95104

96105
try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) {
97-
98-
TestRestClient.HttpResponse response = client.getAuthInfo();
99-
response.assertStatusCode(200);
100-
101-
String username = response.getTextFromJsonBody(POINTER_USERNAME);
102-
assertThat(username, equalTo(ADMIN_USER_NAME));
106+
TestRestClient.HttpResponse response = client.getOBOTokenFromOboEndpoint(OBO_REASON, adminOboAuthHeader);
107+
response.assertStatusCode(401);
103108
}
104109
}
105110

106111
@Test
107-
public void shouldNotAuthenticateWithATemperedOBOToken() {
108-
Header adminOboAuthHeader;
112+
public void shouldNotAuthenticateForUsingOBOTokenToAccessAccountEndpoint() {
113+
String oboToken = generateOboToken(ADMIN_USER_NAME, DEFAULT_PASSWORD);
114+
Header adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + oboToken);
109115

110-
try (TestRestClient client = cluster.getRestClient(ADMIN_USER_NAME, DEFAULT_PASSWORD)) {
111-
112-
client.assertCorrectCredentials(ADMIN_USER_NAME);
116+
try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) {
117+
TestRestClient.HttpResponse response = client.changeInternalUserPassword(CURRENT_AND_NEW_PASSWORDS, adminOboAuthHeader);
118+
response.assertStatusCode(401);
119+
}
120+
}
113121

122+
private String generateOboToken(String username, String password) {
123+
try (TestRestClient client = cluster.getRestClient(username, password)) {
124+
client.assertCorrectCredentials(username);
114125
TestRestClient.HttpResponse response = client.postJson(OBO_ENDPOINT_PREFIX, OBO_TOKEN_REASON);
115126
response.assertStatusCode(200);
116-
117127
Map<String, Object> oboEndPointResponse = response.getBodyAs(Map.class);
118128
assertThat(oboEndPointResponse, allOf(aMapWithSize(3), hasKey("user"), hasKey("onBehalfOfToken"), hasKey("duration")));
119-
120-
String encodedOboTokenStr = oboEndPointResponse.get("onBehalfOfToken").toString();
121-
StringBuilder stringBuilder = new StringBuilder(encodedOboTokenStr);
122-
stringBuilder.deleteCharAt(encodedOboTokenStr.length() - 1);
123-
String temperedOboTokenStr = stringBuilder.toString();
124-
125-
adminOboAuthHeader = new BasicHeader("Authorization", "Bearer " + temperedOboTokenStr);
129+
return oboEndPointResponse.get("onBehalfOfToken").toString();
126130
}
131+
}
127132

128-
try (TestRestClient client = cluster.getRestClient(adminOboAuthHeader)) {
129-
133+
private void authenticateWithOboToken(Header authHeader, String expectedUsername, int expectedStatusCode) {
134+
try (TestRestClient client = cluster.getRestClient(authHeader)) {
130135
TestRestClient.HttpResponse response = client.getAuthInfo();
131-
response.assertStatusCode(401);
132-
response.getBody().contains("Unauthorized");
136+
response.assertStatusCode(expectedStatusCode);
137+
if (expectedStatusCode == 200) {
138+
String username = response.getTextFromJsonBody(POINTER_USERNAME);
139+
assertThat(username, equalTo(expectedUsername));
140+
} else {
141+
Assert.assertTrue(response.getBody().contains("Unauthorized"));
142+
}
133143
}
134144
}
135145
}

src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,26 @@ public HttpResponse getAuthInfo(Header... headers) {
135135
return executeRequest(new HttpGet(getHttpServerUri() + "/_opendistro/_security/authinfo?pretty"), headers);
136136
}
137137

138+
public HttpResponse getOBOTokenFromOboEndpoint(String jsonData, Header... headers) {
139+
try {
140+
HttpPost httpPost = new HttpPost(new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/user/onbehalfof?pretty").build());
141+
httpPost.setEntity(toStringEntity(jsonData));
142+
return executeRequest(httpPost, mergeHeaders(CONTENT_TYPE_JSON, headers));
143+
} catch (URISyntaxException ex) {
144+
throw new RuntimeException("Incorrect URI syntax", ex);
145+
}
146+
}
147+
148+
public HttpResponse changeInternalUserPassword(String jsonData, Header... headers) {
149+
try {
150+
HttpPut httpPut = new HttpPut(new URIBuilder(getHttpServerUri() + "/_plugins/_security/api/account?pretty").build());
151+
httpPut.setEntity(toStringEntity(jsonData));
152+
return executeRequest(httpPut, mergeHeaders(CONTENT_TYPE_JSON, headers));
153+
} catch (URISyntaxException ex) {
154+
throw new RuntimeException("Incorrect URI syntax", ex);
155+
}
156+
}
157+
138158
public void assertCorrectCredentials(String expectedUserName) {
139159
HttpResponse response = getAuthInfo();
140160
assertThat(response, notNullValue());

src/main/java/org/opensearch/security/action/onbehalf/CreateOnBehalfOfTokenAction.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import org.opensearch.client.node.NodeClient;
2525
import org.opensearch.cluster.service.ClusterService;
2626
import org.opensearch.common.settings.Settings;
27-
import org.opensearch.common.transport.TransportAddress;
27+
import org.opensearch.core.common.transport.TransportAddress;
2828
import org.opensearch.core.xcontent.XContentBuilder;
2929
import org.opensearch.rest.BaseRestHandler;
3030
import org.opensearch.rest.BytesRestResponse;

src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,16 @@ public String createJwt(
105105
List<String> roles,
106106
List<String> backendRoles
107107
) throws Exception {
108+
String tokenIdentifier = "obo";
108109
long timeMillis = timeProvider.getAsLong();
109110
Instant now = Instant.ofEpochMilli(timeProvider.getAsLong());
110111

111112
jwtProducer.setSignatureProvider(JwsUtils.getSignatureProvider(signingKey));
112113
JwtClaims jwtClaims = new JwtClaims();
113114
JwtToken jwt = new JwtToken(jwtClaims);
114115

116+
jwtClaims.setProperty("typ", tokenIdentifier);
117+
115118
jwtClaims.setIssuer(issuer);
116119

117120
jwtClaims.setIssuedAt(timeMillis);

src/main/java/org/opensearch/security/filter/SecurityRestFilter.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,6 @@ private boolean checkAndAuthenticateRequest(RestRequest request, RestChannel cha
258258
);
259259
}
260260
}
261-
262261
return false;
263262
}
264263

src/main/java/org/opensearch/security/http/OnBehalfOfAuthenticator.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import java.util.List;
1818
import java.util.Map.Entry;
1919
import java.util.Objects;
20+
import java.util.regex.Matcher;
2021
import java.util.regex.Pattern;
2122
import java.util.stream.Collectors;
2223

@@ -28,6 +29,7 @@
2829
import org.apache.logging.log4j.LogManager;
2930
import org.apache.logging.log4j.Logger;
3031

32+
import org.opensearch.OpenSearchException;
3133
import org.opensearch.OpenSearchSecurityException;
3234
import org.opensearch.SpecialPermission;
3335
import org.opensearch.common.settings.Settings;
@@ -36,16 +38,26 @@
3638
import org.opensearch.rest.RestRequest;
3739
import org.opensearch.security.auth.HTTPAuthenticator;
3840
import org.opensearch.security.authtoken.jwt.EncryptionDecryptionUtil;
41+
import org.opensearch.security.ssl.util.ExceptionUtils;
3942
import org.opensearch.security.user.AuthCredentials;
4043
import org.opensearch.security.util.keyUtil;
4144

45+
import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX;
46+
import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX;
47+
4248
public class OnBehalfOfAuthenticator implements HTTPAuthenticator {
4349

50+
private static final String REGEX_PATH_PREFIX = "/(" + LEGACY_OPENDISTRO_PREFIX + "|" + PLUGINS_PREFIX + ")/" + "(.*)";
51+
private static final Pattern PATTERN_PATH_PREFIX = Pattern.compile(REGEX_PATH_PREFIX);
52+
private static final String ON_BEHALF_OF_SUFFIX = "api/user/onbehalfof";
53+
private static final String ACCOUNT_SUFFIX = "api/account";
54+
4455
protected final Logger log = LogManager.getLogger(this.getClass());
4556

4657
private static final Pattern BEARER = Pattern.compile("^\\s*Bearer\\s.*", Pattern.CASE_INSENSITIVE);
4758
private static final String BEARER_PREFIX = "bearer ";
48-
private static final String SUBJECT_CLAIM = "sub";
59+
private static final String TOKEN_TYPE_CLAIM = "typ";
60+
private static final String TOKEN_TYPE = "obo";
4961

5062
private final JwtParser jwtParser;
5163
private final String encryptionKey;
@@ -168,6 +180,15 @@ private AuthCredentials extractCredentials0(final RestRequest request) {
168180
}
169181

170182
try {
183+
Matcher matcher = PATTERN_PATH_PREFIX.matcher(request.path());
184+
final String suffix = matcher.matches() ? matcher.group(2) : null;
185+
if (request.method() == RestRequest.Method.POST && ON_BEHALF_OF_SUFFIX.equals(suffix)
186+
|| request.method() == RestRequest.Method.PUT && ACCOUNT_SUFFIX.equals(suffix)) {
187+
final OpenSearchException exception = ExceptionUtils.invalidUsageOfOBOTokenException();
188+
log.error(exception.toString());
189+
return null;
190+
}
191+
171192
final Claims claims = jwtParser.parseClaimsJws(jwtToken).getBody();
172193

173194
final String subject = claims.getSubject();
@@ -182,6 +203,12 @@ private AuthCredentials extractCredentials0(final RestRequest request) {
182203
return null;
183204
}
184205

206+
final String tokenType = claims.get(TOKEN_TYPE_CLAIM).toString();
207+
if (!tokenType.equals(TOKEN_TYPE)) {
208+
log.error("This toke is not verifying as an on-behalf-of token");
209+
return null;
210+
}
211+
185212
List<String> roles = extractSecurityRolesFromClaims(claims);
186213
String[] backendRoles = extractBackendRolesFromClaims(claims);
187214

src/main/java/org/opensearch/security/ssl/util/ExceptionUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public static OpenSearchException createBadHeaderException() {
6464
);
6565
}
6666

67+
public static OpenSearchException invalidUsageOfOBOTokenException() {
68+
return new OpenSearchException("On-Behalf-Of Token is not allowed to be used for accessing this endopoint.");
69+
}
70+
6771
public static OpenSearchException createTransportClientNoLongerSupportedException() {
6872
return new OpenSearchException("Transport client authentication no longer supported.");
6973
}

src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public void testCreateJwtWithRoles() throws Exception {
6262
JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(encodedJwt);
6363
JwtToken jwt = jwtConsumer.getJwtToken();
6464

65+
Assert.assertEquals("obo", jwt.getClaim("typ"));
6566
Assert.assertEquals("cluster_0", jwt.getClaim("iss"));
6667
Assert.assertEquals("admin", jwt.getClaim("sub"));
6768
Assert.assertEquals("audience_0", jwt.getClaim("aud"));

0 commit comments

Comments
 (0)