Skip to content
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ dependencies {
runtimeOnly 'com.fasterxml.woodstox:woodstox-core:6.4.0'
runtimeOnly 'org.apache.ws.xmlschema:xmlschema-core:2.2.5'
runtimeOnly 'org.apache.santuario:xmlsec:2.2.3'
runtimeOnly 'com.github.luben:zstd-jni:1.5.2-1'
runtimeOnly 'com.github.luben:zstd-jni:1.5.5-3'
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the build break

runtimeOnly 'org.checkerframework:checker-qual:3.5.0'
runtimeOnly "org.bouncycastle:bcpkix-jdk15on:${versions.bouncycastle}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public class OnBehalfOfJwtAuthenticationTest {

@Test
public void shouldAuthenticateWithJwtToken_positive() {
// TODO: This integration test should use an endpoint to get an OnBehalfOf token, not generate it
try(TestRestClient client = cluster.getRestClient(tokenFactory.generateValidToken())){

TestRestClient.HttpResponse response = client.getAuthInfo();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
import org.opensearch.search.query.QuerySearchResult;
import org.opensearch.security.action.configupdate.ConfigUpdateAction;
import org.opensearch.security.action.configupdate.TransportConfigUpdateAction;
import org.opensearch.security.action.onbehalf.CreateOnBehalfOfToken;
import org.opensearch.security.action.whoami.TransportWhoAmIAction;
import org.opensearch.security.action.whoami.WhoAmIAction;
import org.opensearch.security.auditlog.AuditLog;
Expand All @@ -141,7 +142,7 @@
import org.opensearch.security.dlic.rest.validation.PasswordValidator;
import org.opensearch.security.filter.SecurityFilter;
import org.opensearch.security.filter.SecurityRestFilter;
import org.opensearch.security.http.HTTPOnBehalfOfJwtAuthenticator;
import org.opensearch.security.http.OnBehalfOfAuthenticator;
import org.opensearch.security.http.SecurityHttpServerTransport;
import org.opensearch.security.http.SecurityNonSslHttpServerTransport;
import org.opensearch.security.http.XFFResolver;
Expand Down Expand Up @@ -477,6 +478,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
Objects.requireNonNull(cs), Objects.requireNonNull(adminDns), Objects.requireNonNull(cr)));
handlers.add(new SecurityConfigUpdateAction(settings, restController, Objects.requireNonNull(threadPool), adminDns, configPath, principalExtractor));
handlers.add(new SecurityWhoAmIAction(settings, restController, Objects.requireNonNull(threadPool), adminDns, configPath, principalExtractor));
handlers.add(new CreateOnBehalfOfToken(settings, threadPool));
handlers.addAll(
SecurityRestApiActions.getHandler(
settings,
Expand Down Expand Up @@ -838,7 +840,6 @@ public Collection<Object> createComponents(Client localClient, ClusterService cl

securityRestHandler = new SecurityRestFilter(backendRegistry, auditLog, threadPool,
principalExtractor, settings, configPath, compatConfig);

final DynamicConfigFactory dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih);
dcf.registerDCFListener(backendRegistry);
dcf.registerDCFListener(compatConfig);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package org.opensearch.security.action.onbehalf;

import java.io.IOException;
import java.util.List;

import org.apache.cxf.rs.security.jose.jwt.JwtToken;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.opensearch.action.FailedNodeException;
import org.opensearch.action.support.ActionFilters;
import org.opensearch.action.support.nodes.TransportNodesAction;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.inject.Inject;
import org.opensearch.common.inject.Provider;
import org.opensearch.common.io.stream.StreamInput;
import org.opensearch.common.io.stream.StreamOutput;
import org.opensearch.common.settings.Settings;
import org.opensearch.security.auth.BackendRegistry;
import org.opensearch.security.authtoken.jwt.JwtVendor;
import org.opensearch.security.configuration.ConfigurationRepository;
import org.opensearch.security.securityconf.DynamicConfigFactory;
import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.threadpool.ThreadPool;
import org.opensearch.transport.TransportRequest;
import org.opensearch.transport.TransportService;
import org.opensearch.rest.BaseRestHandler;

import java.io.IOException;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.opensearch.client.Client;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.bytes.BytesReference;
import org.opensearch.common.settings.Settings;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.rest.BytesRestResponse;
import org.opensearch.rest.RestChannel;
import org.opensearch.rest.RestController;
import org.opensearch.rest.RestRequest;
import org.opensearch.rest.RestRequest.Method;
import org.opensearch.rest.RestStatus;
import org.opensearch.security.auditlog.AuditLog;
import org.opensearch.security.configuration.AdminDNs;
import org.opensearch.security.configuration.ConfigurationRepository;
import org.opensearch.security.dlic.rest.validation.AbstractConfigurationValidator;
import org.opensearch.security.privileges.PrivilegesEvaluator;
import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
import org.opensearch.security.ssl.SecurityKeyStore;
import org.opensearch.security.ssl.transport.PrincipalExtractor;
import org.opensearch.security.ssl.util.SSLConfigConstants;
import org.opensearch.security.support.ConfigConstants;
import org.opensearch.threadpool.ThreadPool;
import org.opensearch.client.node.NodeClient;
import org.opensearch.security.user.User;

import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix;

public class CreateOnBehalfOfToken extends BaseRestHandler {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class was hastily created, it should be re-written and compacted significately. I was also betting that we could use the AuthZ on REST requests so transport actions weren't generated. Might make sense to make this follow the transport actions more formally until that has been merged/backported


private final JwtVendor vendor;
private final ThreadPool threadPool;

public CreateOnBehalfOfToken(final Settings settings, final ThreadPool threadPool) {
Settings testSettings = Settings.builder()
.put("signing_key", "1234567890123456")
.put("encryption_key", "1234567890123456").build();

this.vendor = new JwtVendor(testSettings, Optional.empty());
this.threadPool = threadPool;
}

@Override
public String getName() {
return getClass().getSimpleName();
}

@Override
public List<Route> routes() {
return addRoutesPrefix(
ImmutableList.of(
new Route(Method.POST, "/user/onbehalfof")
)
);
}

@Override
protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException {
switch (request.method()) {
case POST:
return handlePost(request, client);
default:
throw new IllegalArgumentException(request.method() + " not supported");
}
}

private RestChannelConsumer handlePost(RestRequest request, NodeClient client) throws IOException {
return new RestChannelConsumer() {
@Override
public void accept(RestChannel channel) throws Exception {
final XContentBuilder builder = channel.newBuilder();
BytesRestResponse response;
try {
final Map<String, Object> requestBody = request.contentOrSourceParamParser().map();
final String reason = (String)requestBody.getOrDefault("reason", null);

final Integer tokenDuration = Optional.ofNullable(requestBody.get("duration"))
.map(value -> (String)value)
.map(Integer::parseInt)
.map(value -> Math.min(value, 72)) // Max duration is 72 hours
.orElse(24); // Fallback to default;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO 24 hours is too wide of a window. This value should match any currently configured REST Handler timeouts.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good called, we can change the defaults as needed


final String source = "self-issued";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is cluster name then this should be able to be obtained from the ClusterState which you can get in createComponents.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

source is a bit confusing of a name here. This parameter is the audience claim in the JWT which is intended to be a unique identifier of the service/extension that would receive this token. For an endpoint to request the creation of a token, it may make sense to have this as a param.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this REST API's case, the token was created by self-issued by user. If this was generated by a plugin or extension, then it should be marked with the identifier for traceability. This is good feedback around if we have the correct information in the token for the audit log or other use cases

final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER);

builder.startObject();
builder.field("user", user.getName());
final String token = vendor.createJwt(/* TODO: Update the issuer to represent the cluster */"OpenSearch",
user.getName(),
source,
tokenDuration,
user.getSecurityRoles().stream().collect(Collectors.toList()));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need more research into how these roles are populated, I'm not sure if this is correct

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For extensions the token will contain the mapped roles

builder.field("onBehalfOfToken", token);
builder.field("duration", tokenDuration);
builder.endObject();

response = new BytesRestResponse(RestStatus.OK, builder);
} catch (final Exception exception) {
System.out.println(exception.toString());
builder.startObject()
.field("error", exception.toString())
.endObject();

response = new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, builder);
}
builder.close();
channel.sendResponse(response);
}
};
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,43 @@
public class EncryptionDecryptionUtil {

public static String encrypt(final String secret, final String data) {
final Cipher cipher = createCipherFromSecret(secret, CipherMode.ENCRYPT);
final byte[] cipherText = createCipherText(cipher, data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(cipherText);
}

byte[] decodedKey = Base64.getDecoder().decode(secret);
public static String decrypt(final String secret, final String encryptedString) {
final Cipher cipher = createCipherFromSecret(secret, CipherMode.DECRYPT);
final byte[] cipherText = createCipherText(cipher, Base64.getDecoder().decode(encryptedString));
return new String(cipherText, StandardCharsets.UTF_8);
}

private static Cipher createCipherFromSecret(final String secret, final CipherMode mode) {
try {
Cipher cipher = Cipher.getInstance("AES");
// rebuild key using SecretKeySpec
SecretKey originalKey = new SecretKeySpec(Arrays.copyOf(decodedKey, 16), "AES");
cipher.init(Cipher.ENCRYPT_MODE, originalKey);
byte[] cipherText = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(cipherText);
} catch (Exception e) {
throw new RuntimeException(
"Error occured while encrypting data", e);
final byte[] decodedKey = Base64.getDecoder().decode(secret);
final Cipher cipher = Cipher.getInstance("AES");
final SecretKey originalKey = new SecretKeySpec(Arrays.copyOf(decodedKey, 16), "AES");
cipher.init(mode.opmode, originalKey);
return cipher;
} catch (final Exception e) {
throw new RuntimeException("Error creating cipher from secret in mode " + mode.name());
}
}

public static String decrypt(final String secret, final String encryptedString) {

byte[] decodedKey = Base64.getDecoder().decode(secret);


private static byte[] createCipherText(final Cipher cipher, final byte[] data) {
try {
Cipher cipher = Cipher.getInstance("AES");
// rebuild key using SecretKeySpec
SecretKey originalKey = new SecretKeySpec(Arrays.copyOf(decodedKey, 16), "AES");
cipher.init(Cipher.DECRYPT_MODE, originalKey);
byte[] cipherText = cipher.doFinal(Base64.getDecoder().decode(encryptedString));
return new String(cipherText, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Error occured while decrypting data", e);
return cipher.doFinal(data);
} catch (final Exception e) {
throw new RuntimeException("The cipher was unable to perform pass over data");
}
}

private enum CipherMode {
ENCRYPT(Cipher.ENCRYPT_MODE),
DECRYPT(Cipher.DECRYPT_MODE);
private final int opmode;
private CipherMode(final int opmode) {
this.opmode = opmode;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
package org.opensearch.security.authtoken.jwt;

import java.time.Instant;
import java.util.Optional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand All @@ -28,12 +29,12 @@
import org.apache.cxf.rs.security.jose.jwt.JwtClaims;
import org.apache.cxf.rs.security.jose.jwt.JwtToken;
import org.apache.cxf.rs.security.jose.jwt.JwtUtils;
import org.apache.kafka.common.utils.SystemTime;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.opensearch.common.settings.Settings;
import org.opensearch.common.transport.TransportAddress;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.security.securityconf.ConfigModel;
import org.opensearch.security.support.ConfigConstants;
import org.opensearch.security.user.User;
Expand All @@ -44,16 +45,15 @@ public class JwtVendor {

private static JsonMapObjectReaderWriter jsonMapReaderWriter = new JsonMapObjectReaderWriter();

private String claimsEncryptionKey;
private JsonWebKey signingKey;
private JoseJwtProducer jwtProducer;
private final String claimsEncryptionKey;
private final JsonWebKey signingKey;
private final JoseJwtProducer jwtProducer;
private final LongSupplier timeProvider;

//TODO: Relocate/Remove them at once we make the descisions about the `roles`
private ConfigModel configModel;
private ThreadContext threadContext;
private ConfigModel configModel; // This never gets assigned, how does this work at all?

public JwtVendor(Settings settings) {
public JwtVendor(final Settings settings, final Optional<LongSupplier> timeProvider) {
JoseJwtProducer jwtProducer = new JoseJwtProducer();
try {
this.signingKey = createJwkFromSettings(settings);
Expand All @@ -66,24 +66,8 @@ public JwtVendor(Settings settings) {
} else {
this.claimsEncryptionKey = settings.get("encryption_key");
}
timeProvider = System::currentTimeMillis;
}

//For testing the expiration in the future
public JwtVendor(Settings settings, final LongSupplier timeProvider) {
JoseJwtProducer jwtProducer = new JoseJwtProducer();
try {
this.signingKey = createJwkFromSettings(settings);
} catch (Exception e) {
throw new RuntimeException(e);
}
this.jwtProducer = jwtProducer;
if (settings.get("encryption_key") == null) {
throw new RuntimeException("encryption_key cannot be null");
} else {
this.claimsEncryptionKey = settings.get("encryption_key");
}
this.timeProvider = timeProvider;
this.timeProvider = timeProvider.orElseGet(() -> System::currentTimeMillis);
this.configModel = null;
}

/*
Expand Down Expand Up @@ -123,21 +107,6 @@ static JsonWebKey createJwkFromSettings(Settings settings) throws Exception {
}
}

//TODO:Getting roles from User
public Map<String, String> prepareClaimsForUser(User user, ThreadPool threadPool) {
Map<String, String> claims = new HashMap<>();
this.threadContext = threadPool.getThreadContext();
final TransportAddress caller = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS);
Set<String> mappedRoles = mapRoles(user, caller);
claims.put("sub", user.getName());
claims.put("roles", String.join(",", mappedRoles));
return claims;
}

public Set<String> mapRoles(final User user, final TransportAddress caller) {
return this.configModel.mapSecurityRoles(user, caller);
}

public String createJwt(String issuer, String subject, String audience, Integer expirySeconds, List<String> roles) throws Exception {
long timeMillis = timeProvider.getAsLong();
Instant now = Instant.ofEpochMilli(timeProvider.getAsLong());
Expand Down
Loading