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:
Nidhi 2021-01-15 16:41:13 +05:30 committed by GitHub
parent 6b8e3d7dd5
commit e6a0b00a25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 297 additions and 21 deletions

View File

@ -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";
}

View File

@ -29,7 +29,7 @@ public class AuthenticationDTO {
// class and fails.
@JsonIgnore
private Boolean isEncrypted;
private Boolean isEncrypted = false;
@JsonIgnore
public Map<String, String> getEncryptionFields() {

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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>
<!--

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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())));

View File

@ -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;
}

View File

@ -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())));