Added Oauth2 functionality for REST APIs (#2509)
* Added Oauth2 functionality for REST APIs * Encrypted response * Missed file * Review comments and tests * Removed broken test
This commit is contained in:
parent
6b8e3d7dd5
commit
e6a0b00a25
|
|
@ -3,6 +3,7 @@ package com.appsmith.external.constants;
|
|||
public class FieldName {
|
||||
public static final String CLIENT_SECRET = "clientSecret";
|
||||
public static final String TOKEN = "token";
|
||||
public static final String TOKEN_RESPONSE = "tokenResponse";
|
||||
|
||||
public static final String PASSWORD = "password";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public class AuthenticationDTO {
|
|||
// class and fails.
|
||||
|
||||
@JsonIgnore
|
||||
private Boolean isEncrypted;
|
||||
private Boolean isEncrypted = false;
|
||||
|
||||
@JsonIgnore
|
||||
public Map<String, String> getEncryptionFields() {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ public class OAuth2 extends AuthenticationDTO {
|
|||
|
||||
Type authType;
|
||||
|
||||
Boolean isHeader;
|
||||
|
||||
String clientId;
|
||||
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
|
|
@ -37,11 +39,19 @@ public class OAuth2 extends AuthenticationDTO {
|
|||
|
||||
String accessTokenUrl;
|
||||
|
||||
String scope;
|
||||
Set<String> scope;
|
||||
|
||||
String headerPrefix = "Bearer";
|
||||
|
||||
@JsonIgnore
|
||||
Object tokenResponse;
|
||||
|
||||
@JsonIgnore
|
||||
String token;
|
||||
|
||||
@JsonIgnore
|
||||
Instant issuedAt;
|
||||
|
||||
@JsonIgnore
|
||||
Instant expiresAt;
|
||||
|
||||
|
|
@ -54,6 +64,9 @@ public class OAuth2 extends AuthenticationDTO {
|
|||
if (this.token != null) {
|
||||
map.put(FieldName.TOKEN, this.token);
|
||||
}
|
||||
if (this.tokenResponse != null) {
|
||||
map.put(FieldName.TOKEN_RESPONSE, String.valueOf(this.tokenResponse));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +79,9 @@ public class OAuth2 extends AuthenticationDTO {
|
|||
if (encryptedFields.containsKey(FieldName.TOKEN)) {
|
||||
this.token = encryptedFields.get(FieldName.TOKEN);
|
||||
}
|
||||
if (encryptedFields.containsKey(FieldName.TOKEN_RESPONSE)) {
|
||||
this.tokenResponse = encryptedFields.get(FieldName.TOKEN_RESPONSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +94,9 @@ public class OAuth2 extends AuthenticationDTO {
|
|||
if (this.token == null || this.token.isEmpty()) {
|
||||
set.add(FieldName.TOKEN);
|
||||
}
|
||||
if (this.tokenResponse == null || (String.valueOf(this.token)).isEmpty()) {
|
||||
set.add(FieldName.TOKEN_RESPONSE);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
package com.appsmith.external.models;
|
||||
|
||||
public interface UpdatableConnection {
|
||||
void updateDatasource(DatasourceConfiguration datasourceConfiguration);
|
||||
|
||||
default boolean isUpdated() {
|
||||
return false;
|
||||
}
|
||||
public AuthenticationDTO getAuthenticationDTO(AuthenticationDTO authenticationDTO);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,28 @@
|
|||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-module-junit4 -->
|
||||
<dependency>
|
||||
<groupId>org.powermock</groupId>
|
||||
<artifactId>powermock-module-junit4</artifactId>
|
||||
<version>2.0.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.powermock/powermock-api-mockito2 -->
|
||||
<dependency>
|
||||
<groupId>org.powermock</groupId>
|
||||
<artifactId>powermock-api-mockito2</artifactId>
|
||||
<version>2.0.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -1,28 +1,175 @@
|
|||
package com.external.connections;
|
||||
|
||||
import com.appsmith.external.models.AuthenticationDTO;
|
||||
import com.appsmith.external.models.OAuth2;
|
||||
import com.appsmith.external.models.UpdatableConnection;
|
||||
import com.appsmith.external.pluginExceptions.StaleConnectionException;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.BodyExtractors;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||
import org.springframework.web.reactive.function.client.ExchangeStrategies;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class OAuth2Connection extends APIConnection {
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public class OAuth2Connection extends APIConnection implements UpdatableConnection {
|
||||
|
||||
private final Clock clock = Clock.systemUTC();
|
||||
private String token;
|
||||
private String headerPrefix;
|
||||
private boolean isHeader;
|
||||
private Instant expiresAt;
|
||||
private static final int MAX_IN_MEMORY_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
private OAuth2Connection() {
|
||||
}
|
||||
|
||||
public static Mono<OAuth2Connection> create(OAuth2 oAuth2) {
|
||||
// if (oAuth2.getToken() == null || !isValid(oAuth2)) {
|
||||
//
|
||||
// }
|
||||
if (oAuth2 == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
// Create OAuth2Connection
|
||||
OAuth2Connection connection = new OAuth2Connection();
|
||||
|
||||
return null;
|
||||
return Mono.just(oAuth2)
|
||||
// Validate existing token
|
||||
.filter(x -> x.getToken() != null && !x.getToken().isBlank())
|
||||
.filter(x -> x.getExpiresAt() != null)
|
||||
.filter(x -> {
|
||||
Instant now = connection.clock.instant();
|
||||
Instant expiresAt = x.getExpiresAt();
|
||||
return now.isBefore(expiresAt.minus(Duration.ofMinutes(1)));
|
||||
})
|
||||
// If invalid, regenerate token
|
||||
.switchIfEmpty(connection.generateOAuth2Token(oAuth2))
|
||||
// Store valid token
|
||||
.flatMap(token -> {
|
||||
connection.setToken(token.getToken());
|
||||
connection.setHeader(token.getIsHeader());
|
||||
connection.setHeaderPrefix(token.getHeaderPrefix());
|
||||
connection.setExpiresAt(token.getExpiresAt());
|
||||
return Mono.just(connection);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<OAuth2> generateOAuth2Token(OAuth2 oAuth2) {
|
||||
// Webclient
|
||||
WebClient webClient = WebClient.builder()
|
||||
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
||||
.exchangeStrategies(ExchangeStrategies
|
||||
.builder()
|
||||
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_IN_MEMORY_SIZE))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// Send oauth2 generic request
|
||||
return webClient
|
||||
.method(HttpMethod.POST)
|
||||
.uri(oAuth2.getAccessTokenUrl())
|
||||
.body(clientCredentialsTokenBody(oAuth2))
|
||||
.exchange()
|
||||
.flatMap(response -> response.body(BodyExtractors.toMono(Map.class)))
|
||||
// Receive and parse response
|
||||
.map(mappedResponse -> {
|
||||
// Store received response as is for reference
|
||||
oAuth2.setTokenResponse(mappedResponse);
|
||||
// Parse useful fields for quick access
|
||||
Object issuedAtResponse = mappedResponse.get("issued_at");
|
||||
// Default issuedAt to current time
|
||||
Instant issuedAt = Instant.now();
|
||||
if (issuedAtResponse != null) {
|
||||
issuedAt = Instant.ofEpochMilli(Long.parseLong((String) issuedAtResponse));
|
||||
}
|
||||
|
||||
// We expect at least one of the following to be present
|
||||
Object expiresAtResponse = mappedResponse.get("expires_at");
|
||||
Object expiresInResponse = mappedResponse.get("expires_in");
|
||||
Instant expiresAt = null;
|
||||
if (expiresAtResponse != null) {
|
||||
expiresAt = Instant.ofEpochMilli(Long.parseLong((String) expiresAtResponse));
|
||||
} else if (expiresInResponse != null) {
|
||||
expiresAt = issuedAt.plusMillis(Long.parseLong((String) expiresInResponse));
|
||||
}
|
||||
oAuth2.setExpiresAt(expiresAt);
|
||||
oAuth2.setIssuedAt(issuedAt);
|
||||
oAuth2.setToken(String.valueOf(mappedResponse.get("access_token")));
|
||||
System.out.println("Entered token generation...");
|
||||
return oAuth2;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ClientResponse> filter(ClientRequest clientRequest, ExchangeFunction exchangeFunction) {
|
||||
// return getValidToken().map(token -> bearer(clientRequest, token)).flatMap(exchangeFunction::exchange)
|
||||
// .switchIfEmpty(exchangeFunction.exchange(clientRequest));
|
||||
return null;
|
||||
// Validate token before execution
|
||||
Instant now = this.clock.instant();
|
||||
Instant expiresAt = this.expiresAt;
|
||||
if (this.expiresAt != null && now.isAfter(expiresAt.minus(Duration.ofMinutes(1)))) {
|
||||
return Mono.error(new StaleConnectionException("The access token has expired"));
|
||||
}
|
||||
// Pick the token that has been created/retrieved
|
||||
return addTokenToRequest(clientRequest)
|
||||
// Carry on to next exchange function
|
||||
.flatMap(exchangeFunction::exchange)
|
||||
// Default to next exchange function if something went wrong
|
||||
.switchIfEmpty(exchangeFunction.exchange(clientRequest));
|
||||
}
|
||||
|
||||
private Mono<ClientRequest> addTokenToRequest(ClientRequest clientRequest) {
|
||||
// Check to see where the token needs to be added
|
||||
if (this.isHeader()) {
|
||||
final String finalHeaderPrefix = this.getHeaderPrefix() != null && !this.getHeaderPrefix().isBlank() ?
|
||||
this.getHeaderPrefix() + " "
|
||||
: "Bearer ";
|
||||
return Mono.justOrEmpty(ClientRequest.from(clientRequest)
|
||||
.headers(headers -> headers.set("Authorization", finalHeaderPrefix + this.getToken()))
|
||||
.build());
|
||||
} else {
|
||||
final URI url = UriComponentsBuilder.fromUri(clientRequest.url())
|
||||
.queryParam("access_token", this.getToken())
|
||||
.build()
|
||||
.toUri();
|
||||
return Mono.justOrEmpty(ClientRequest.from(clientRequest)
|
||||
.url(url)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
private BodyInserters.FormInserter<String> clientCredentialsTokenBody(OAuth2 oAuth2) {
|
||||
BodyInserters.FormInserter<String> body = BodyInserters
|
||||
.fromFormData("grant_type", "client_credentials")
|
||||
.with("client_id", oAuth2.getClientId())
|
||||
.with("client_secret", oAuth2.getClientSecret());
|
||||
// Optionally add scope, if applicable
|
||||
if (!CollectionUtils.isEmpty(oAuth2.getScope())) {
|
||||
body.with("scope", StringUtils.collectionToDelimitedString(oAuth2.getScope(), " "));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationDTO getAuthenticationDTO(AuthenticationDTO authenticationDTO) {
|
||||
OAuth2 oAuth2 = (OAuth2) authenticationDTO;
|
||||
oAuth2.setToken(this.token);
|
||||
oAuth2.setHeaderPrefix(this.headerPrefix);
|
||||
oAuth2.setIsHeader(this.isHeader);
|
||||
|
||||
return oAuth2;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import org.springframework.util.LinkedCaseInsensitiveMap;
|
|||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
|
||||
import org.springframework.web.reactive.function.client.ExchangeStrategies;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
|
@ -169,7 +170,7 @@ public class RestApiPlugin extends BasePlugin {
|
|||
webClientBuilder.filter(apiConnection);
|
||||
}
|
||||
|
||||
WebClient client = webClientBuilder.exchangeStrategies(EXCHANGE_STRATEGIES).build();
|
||||
WebClient client = webClientBuilder.exchangeStrategies(EXCHANGE_STRATEGIES).filter(logRequest()).build();
|
||||
|
||||
// Triggering the actual REST API call
|
||||
return httpCall(client, httpMethod, uri, requestBodyAsString, 0, reqContentType)
|
||||
|
|
@ -238,6 +239,14 @@ public class RestApiPlugin extends BasePlugin {
|
|||
});
|
||||
}
|
||||
|
||||
private static ExchangeFilterFunction logRequest() {
|
||||
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
|
||||
log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
|
||||
clientRequest.headers().forEach((name, values) -> values.forEach(value -> System.out.println(name + "=" + value)));
|
||||
return Mono.just(clientRequest);
|
||||
});
|
||||
}
|
||||
|
||||
private String getSignatureKey(DatasourceConfiguration datasourceConfiguration) throws AppsmithPluginException {
|
||||
if (!CollectionUtils.isEmpty(datasourceConfiguration.getProperties())) {
|
||||
boolean isSendSessionEnabled = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package com.external.connections;
|
||||
|
||||
import com.appsmith.external.models.OAuth2;
|
||||
import com.appsmith.external.pluginExceptions.StaleConnectionException;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.powermock.api.mockito.PowerMockito;
|
||||
import org.powermock.core.classloader.annotations.PrepareForTest;
|
||||
import org.powermock.modules.junit4.PowerMockRunner;
|
||||
import org.springframework.web.reactive.function.client.ClientRequest;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.ExchangeFunction;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
@RunWith(PowerMockRunner.class)
|
||||
@PrepareForTest(OAuth2Connection.class)
|
||||
public class OAuth2ConnectionTest {
|
||||
|
||||
@Test
|
||||
public void testNullConnection() {
|
||||
APIConnection connection = OAuth2Connection.create(null).block(Duration.ofMillis(100));
|
||||
assertThat(connection).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidConnection() {
|
||||
OAuth2 oAuth2 = new OAuth2();
|
||||
oAuth2.setIsHeader(true);
|
||||
oAuth2.setToken("SomeToken");
|
||||
oAuth2.setIsEncrypted(false);
|
||||
oAuth2.setExpiresAt(Instant.now().plusSeconds(1200));
|
||||
OAuth2Connection connection = OAuth2Connection.create(oAuth2).block(Duration.ofMillis(100));
|
||||
assertThat(connection).isNotNull();
|
||||
assertThat(connection.getExpiresAt()).isEqualTo(oAuth2.getExpiresAt());
|
||||
assertThat(connection.getHeaderPrefix()).isEqualTo("Bearer");
|
||||
assertThat(connection.getToken()).isEqualTo("SomeToken");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStaleFilter() {
|
||||
OAuth2 oAuth2 = new OAuth2();
|
||||
oAuth2.setIsHeader(true);
|
||||
oAuth2.setToken("SomeToken");
|
||||
oAuth2.setIsEncrypted(false);
|
||||
oAuth2.setExpiresAt(Instant.now().plusSeconds(1200));
|
||||
OAuth2Connection connection = OAuth2Connection.create(oAuth2).block(Duration.ofMillis(100));
|
||||
connection.setExpiresAt(Instant.now());
|
||||
|
||||
Mono<ClientResponse> response = connection.filter(Mockito.mock(ClientRequest.class), Mockito.mock(ExchangeFunction.class));
|
||||
|
||||
StepVerifier.create(response)
|
||||
.expectError(StaleConnectionException.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.appsmith.server.services;
|
||||
|
||||
import com.appsmith.external.models.AuthenticationDTO;
|
||||
import com.appsmith.external.models.UpdatableConnection;
|
||||
import com.appsmith.external.pluginExceptions.StaleConnectionException;
|
||||
import com.appsmith.external.plugins.PluginExecutor;
|
||||
import com.appsmith.server.domains.Datasource;
|
||||
|
|
@ -12,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
|
@ -60,7 +62,7 @@ public class DatasourceContextServiceImpl implements DatasourceContextService {
|
|||
// the reactive flow interrupts, resulting in the destroy operation not completing.
|
||||
&& datasourceContextMap.get(datasourceId).getConnection() != null
|
||||
&& !isStale) {
|
||||
log.debug("resource context exists. Returning the same.");
|
||||
log.debug("Resource context exists. Returning the same.");
|
||||
return Mono.just(datasourceContextMap.get(datasourceId));
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +117,19 @@ public class DatasourceContextServiceImpl implements DatasourceContextService {
|
|||
|
||||
Mono<Object> connectionMono = pluginExecutor.datasourceCreate(datasource1.getDatasourceConfiguration());
|
||||
return connectionMono
|
||||
.flatMap(connection -> {
|
||||
Mono<Datasource> datasourceMono1 = Mono.just(datasource1);
|
||||
if (connection instanceof UpdatableConnection) {
|
||||
datasource1.setUpdatedAt(Instant.now());
|
||||
datasource1
|
||||
.getDatasourceConfiguration()
|
||||
.setAuthentication(
|
||||
((UpdatableConnection) connection).getAuthenticationDTO(
|
||||
datasource1.getDatasourceConfiguration().getAuthentication()));
|
||||
datasourceMono1 = datasourceService.update(datasource1.getId(), datasource1);
|
||||
}
|
||||
return datasourceMono1.thenReturn(connection);
|
||||
})
|
||||
.map(connection -> {
|
||||
// When a connection object exists and makes sense for the plugin, we put it in the
|
||||
// context. Example, DB plugins.
|
||||
|
|
@ -174,6 +189,7 @@ public class DatasourceContextServiceImpl implements DatasourceContextService {
|
|||
public AuthenticationDTO decryptSensitiveFields(AuthenticationDTO authentication) {
|
||||
if (authentication != null && authentication.isEncrypted()) {
|
||||
Map<String, String> decryptedFields = authentication.getEncryptionFields().entrySet().stream()
|
||||
.filter(e -> e.getValue() != null)
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
e -> encryptionService.decryptString(e.getValue())));
|
||||
|
|
|
|||
|
|
@ -159,14 +159,16 @@ public class DatasourceServiceImpl extends BaseService<DatasourceRepository, Dat
|
|||
@Override
|
||||
public AuthenticationDTO encryptAuthenticationFields(AuthenticationDTO authentication) {
|
||||
if (authentication != null
|
||||
&& CollectionUtils.isEmpty(authentication.getEmptyEncryptionFields())
|
||||
&& !authentication.isEncrypted()) {
|
||||
Map<String, String> encryptedFields = authentication.getEncryptionFields().entrySet().stream()
|
||||
.filter(e -> e.getValue() != null)
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
e -> encryptionService.encryptString(e.getValue())));
|
||||
authentication.setEncryptionFields(encryptedFields);
|
||||
authentication.setIsEncrypted(true);
|
||||
if (!encryptedFields.isEmpty()) {
|
||||
authentication.setEncryptionFields(encryptedFields);
|
||||
authentication.setIsEncrypted(true);
|
||||
}
|
||||
}
|
||||
return authentication;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,9 @@ public class DatasourceStructureSolution {
|
|||
// If datasource has encrypted fields, decrypt and set it in the datasource.
|
||||
if (datasource.getDatasourceConfiguration() != null) {
|
||||
AuthenticationDTO authentication = datasource.getDatasourceConfiguration().getAuthentication();
|
||||
if (authentication != null && authentication.getEmptyEncryptionFields().isEmpty() && authentication.isEncrypted()) {
|
||||
if (authentication != null && authentication.isEncrypted()) {
|
||||
Map<String, String> decryptedFields = authentication.getEncryptionFields().entrySet().stream()
|
||||
.filter(e -> e.getValue() != null)
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
e -> encryptionService.decryptString(e.getValue())));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user