From 17047513c39c3635deb160a16445cc7c5968d7dc Mon Sep 17 00:00:00 2001 From: Ehren Kret Date: Mon, 10 May 2021 11:43:36 -0500 Subject: [PATCH] Create stripe api endpoint for apple pay donations --- service/config/sample.yml | 17 +++ .../WhisperServerConfiguration.java | 10 ++ .../configuration/DonationConfiguration.java | 79 ++++++++++++ .../controllers/DonationController.java | 117 +++++++++++++++++ .../ApplePayAuthorizationRequest.java | 42 ++++++ .../ApplePayAuthorizationResponse.java | 44 +++++++ .../controllers/DonationControllerTest.java | 122 ++++++++++++++++++ 7 files changed, 431 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/DonationConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationRequest.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationResponse.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java diff --git a/service/config/sample.yml b/service/config/sample.yml index 2a2c456ce..dee867d48 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -115,3 +115,20 @@ remoteConfig: paymentService: userAuthenticationTokenSharedSecret: # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users + +donation: + uri: # value + apiKey: # value + supportedCurrencies: + - # 1st supported currency + - # 2nd supported currency + - # ... + - # Nth supported currency + circuitBreaker: + failureRateThreshold: # value + ringBufferSizeInHalfOpenState: # value + ringBufferSizeInClosedState: # value + waitDurationInOpenStateInSeconds: # value + retry: + maxAttempts: # value + waitDuration: # value diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 40344035a..f9e81a1d3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -22,6 +22,7 @@ import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguratio import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.DatabaseConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; +import org.whispersystems.textsecuregcm.configuration.DonationConfiguration; import org.whispersystems.textsecuregcm.configuration.DynamoDbConfiguration; import org.whispersystems.textsecuregcm.configuration.GcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; @@ -250,6 +251,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private TorExitNodeConfiguration tor; + @Valid + @NotNull + @JsonProperty + private DonationConfiguration donation; + private Map transparentDataIndex = new HashMap<>(); public RecaptchaConfiguration getRecaptchaConfiguration() { @@ -429,4 +435,8 @@ public class WhisperServerConfiguration extends Configuration { public TorExitNodeConfiguration getTorExitNodeConfiguration() { return tor; } + + public DonationConfiguration getDonationConfiguration() { + return donation; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DonationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DonationConfiguration.java new file mode 100644 index 000000000..d137c43e2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DonationConfiguration.java @@ -0,0 +1,79 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import java.util.Set; +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +public class DonationConfiguration { + + private String uri; + private String apiKey; + private Set supportedCurrencies; + private CircuitBreakerConfiguration circuitBreaker; + private RetryConfiguration retry; + + @JsonProperty + @NotEmpty + public String getUri() { + return uri; + } + + @VisibleForTesting + public void setUri(final String uri) { + this.uri = uri; + } + + @JsonProperty + @NotEmpty + public String getApiKey() { + return apiKey; + } + + @VisibleForTesting + public void setApiKey(final String apiKey) { + this.apiKey = apiKey; + } + + @JsonProperty + @NotEmpty + public Set getSupportedCurrencies() { + return supportedCurrencies; + } + + @VisibleForTesting + public void setSupportedCurrencies(final Set supportedCurrencies) { + this.supportedCurrencies = supportedCurrencies; + } + + @JsonProperty + @NotNull + @Valid + public CircuitBreakerConfiguration getCircuitBreaker() { + return circuitBreaker; + } + + @VisibleForTesting + public void setCircuitBreaker(final CircuitBreakerConfiguration circuitBreaker) { + this.circuitBreaker = circuitBreaker; + } + + @JsonProperty + @NotNull + @Valid + public RetryConfiguration getRetry() { + return retry; + } + + @VisibleForTesting + public void setRetry(final RetryConfiguration retry) { + this.retry = retry; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java new file mode 100644 index 000000000..bd8c35445 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.dropwizard.auth.Auth; +import io.dropwizard.util.Strings; +import java.net.URI; +import java.net.http.HttpClient.Redirect; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.DonationConfiguration; +import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest; +import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +@Path("/v1/donation") +public class DonationController { + + private final Logger logger = LoggerFactory.getLogger(DonationController.class); + + private final URI uri; + private final String apiKey; + private final Set supportedCurrencies; + private final FaultTolerantHttpClient httpClient; + + public DonationController(final Executor executor, final DonationConfiguration configuration) { + this.uri = URI.create(configuration.getUri()); + this.apiKey = configuration.getApiKey(); + this.supportedCurrencies = configuration.getSupportedCurrencies(); + this.httpClient = FaultTolerantHttpClient.newBuilder() + .withCircuitBreaker(configuration.getCircuitBreaker()) + .withRetry(configuration.getRetry()) + .withVersion(Version.HTTP_2) + .withConnectTimeout(Duration.ofSeconds(10)) + .withRedirect(Redirect.NEVER) + .withExecutor(executor) + .withName("donation") + .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3) + .build(); + } + + @Timed + @POST + @Path("/authorize-apple-pay") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public CompletableFuture getApplePayAuthorization(@Auth Account account, @Valid ApplePayAuthorizationRequest request) { + if (!supportedCurrencies.contains(request.getCurrency())) { + return CompletableFuture.completedFuture(Response.status(422).build()); + } + + final HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(uri) + .POST(FormDataBodyPublisher.of(Map.of( + "amount", Long.toString(request.getAmount()), + "currency", request.getCurrency()))) + .header("Authorization", "Basic " + Base64.getEncoder().encodeToString( + (apiKey + ":").getBytes(StandardCharsets.UTF_8))) + .header("Content-Type", "application/x-www-form-urlencoded") + .build(); + return httpClient.sendAsync(httpRequest, BodyHandlers.ofString()) + .thenApply(this::processApplePayAuthorizationRemoteResponse); + } + + private Response processApplePayAuthorizationRemoteResponse(HttpResponse response) { + ObjectMapper mapper = SystemMapper.getMapper(); + + if (response.statusCode() >= 200 && response.statusCode() < 300 && + MediaType.APPLICATION_JSON.equalsIgnoreCase(response.headers().firstValue("Content-Type").orElse(null))) { + try { + final JsonNode jsonResponse = mapper.readTree(response.body()); + final String id = jsonResponse.get("id").asText(null); + final String clientSecret = jsonResponse.get("client_secret").asText(null); + if (Strings.isNullOrEmpty(id) || Strings.isNullOrEmpty(clientSecret)) { + logger.warn("missing fields in json response in donation controller"); + return Response.status(500).build(); + } + final String responseJson = mapper.writeValueAsString(new ApplePayAuthorizationResponse(id, clientSecret)); + return Response.ok(responseJson, MediaType.APPLICATION_JSON_TYPE).build(); + } catch (JsonProcessingException e) { + logger.warn("json processing error in donation controller", e); + return Response.status(500).build(); + } + } else { + logger.warn("unexpected response code returned to donation controller"); + return Response.status(500).build(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationRequest.java new file mode 100644 index 000000000..d1097b3ce --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +public class ApplePayAuthorizationRequest { + + private String currency; + private long amount; + + @JsonProperty + @NotEmpty + @Size(min=3, max=3) + @Pattern(regexp="[a-z]{3}") + public String getCurrency() { + return currency; + } + + public void setCurrency(final String currency) { + this.currency = currency; + } + + @JsonProperty + @Min(0) + public long getAmount() { + return amount; + } + + @VisibleForTesting + public void setAmount(final long amount) { + this.amount = amount; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationResponse.java new file mode 100644 index 000000000..30db2da48 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApplePayAuthorizationResponse.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.dropwizard.util.Strings; +import javax.validation.constraints.NotEmpty; + +public class ApplePayAuthorizationResponse { + + private final String id; + private final String clientSecret; + + @JsonCreator + public ApplePayAuthorizationResponse( + @JsonProperty("id") final String id, + @JsonProperty("client_secret") final String clientSecret) { + if (Strings.isNullOrEmpty(id)) { + throw new IllegalArgumentException("id cannot be empty"); + } + if (Strings.isNullOrEmpty(clientSecret)) { + throw new IllegalArgumentException("clientSecret cannot be empty"); + } + + this.id = id; + this.clientSecret = clientSecret; + } + + @JsonProperty("id") + @NotEmpty + public String getId() { + return id; + } + + @JsonProperty("client_secret") + @NotEmpty + public String getClientSecret() { + return clientSecret; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java new file mode 100644 index 000000000..f38518ed4 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.tests.controllers; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit5.ResourceExtension; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; +import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; +import org.whispersystems.textsecuregcm.configuration.DonationConfiguration; +import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; +import org.whispersystems.textsecuregcm.controllers.DonationController; +import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest; +import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +public class DonationControllerTest { + + private static final Executor executor = Executors.newSingleThreadExecutor(); + + @Rule + public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort()); + + private ResourceExtension resources; + + @Before + public void before() throws Throwable { + DonationConfiguration configuration = new DonationConfiguration(); + configuration.setApiKey("test-api-key"); + configuration.setUri("http://localhost:" + wireMockRule.port() + "/foo/bar"); + configuration.setCircuitBreaker(new CircuitBreakerConfiguration()); + configuration.setRetry(new RetryConfiguration()); + configuration.setSupportedCurrencies(Set.of("usd", "gbp")); + resources = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class))) + .setMapper(SystemMapper.getMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new DonationController(executor, configuration)) + .build(); + resources.before(); + } + + @After + public void after() throws Throwable { + resources.after(); + } + + @Test + public void testGetApplePayAuthorizationReturns200() { + wireMockRule.stubFor(post(urlEqualTo("/foo/bar")) + .withBasicAuth("test-api-key", "") + .willReturn(aResponse() + .withHeader("Content-Type", MediaType.APPLICATION_JSON) + .withBody("{\"id\":\"an_id\",\"client_secret\":\"some_secret\"}"))); + + ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest(); + request.setCurrency("usd"); + request.setAmount(1000); + Response response = resources.getJerseyTest() + .target("/v1/donation/authorize-apple-pay") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) + .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(200); + + ApplePayAuthorizationResponse responseObject = response.readEntity(ApplePayAuthorizationResponse.class); + assertThat(responseObject.getId()).isEqualTo("an_id"); + assertThat(responseObject.getClientSecret()).isEqualTo("some_secret"); + } + + @Test + public void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() { + ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest(); + request.setCurrency("usd"); + request.setAmount(1000); + Response response = resources.getJerseyTest() + .target("/v1/donation/authorize-apple-pay") + .request() + .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(401); + } + + @Test + public void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() { + ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest(); + request.setCurrency("zzz"); + request.setAmount(1000); + Response response = resources.getJerseyTest() + .target("/v1/donation/authorize-apple-pay") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) + .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(422); + } +}