diff --git a/service/config/sample.yml b/service/config/sample.yml index c2446bf91..c8bcc5cfe 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -3,12 +3,14 @@ twilio: # Twilio gateway configuration accountToken: nanpaMessagingServiceSid: # Twilio SID for the messaging service to use for NANPA. messagingServiceSid: # Twilio SID for the message service to use for non-NANPA. + verifyServiceSid: # Twilio SID for a Verify service localDomain: # Domain Twilio can connect back to for calls. Should be domain of your service. iosVerificationText: # Text to use for the verification message on iOS. Will be passed to String.format with the verification code as argument 1. androidNgVerificationText: # Text to use for the verification message on android-ng client types. Will be passed to String.format with the verification code as argument 1. android202001VerificationText: # Text to use for the verification message on android-2020-01 client types. Will be passed to String.format with the verification code as argument 1. android202103VerificationText: # Text to use for the verification message on android-2021-03 client types. Will be passed to String.format with the verification code as argument 1. genericVerificationText: # Text to use when the client type is unrecognized. Will be passed to String.format with the verification code as argument 1. + androidAppHash: # Hash appended to Android push: queueSize: # Size of push pending queue diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TwilioConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TwilioConfiguration.java index 5beb8a7d9..80fe17d76 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TwilioConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TwilioConfiguration.java @@ -26,6 +26,9 @@ public class TwilioConfiguration { @NotEmpty private String nanpaMessagingServiceSid; + @NotEmpty + private String verifyServiceSid; + @NotNull @Valid private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); @@ -49,6 +52,9 @@ public class TwilioConfiguration { @NotEmpty private String genericVerificationText; + @NotEmpty + private String androidAppHash; + public String getAccountId() { return accountId; } @@ -93,6 +99,15 @@ public class TwilioConfiguration { this.nanpaMessagingServiceSid = nanpaMessagingServiceSid; } + public String getVerifyServiceSid() { + return verifyServiceSid; + } + + @VisibleForTesting + public void setVerifyServiceSid(String verifyServiceSid) { + this.verifyServiceSid = verifyServiceSid; + } + public CircuitBreakerConfiguration getCircuitBreaker() { return circuitBreaker; } @@ -155,4 +170,12 @@ public class TwilioConfiguration { public void setGenericVerificationText(String genericVerificationText) { this.genericVerificationText = genericVerificationText; } + + public String getAndroidAppHash() { + return androidAppHash; + } + + public void setAndroidAppHash(String androidAppHash) { + this.androidAppHash = androidAppHash; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySender.java b/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySender.java new file mode 100644 index 000000000..adcfc2e27 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySender.java @@ -0,0 +1,256 @@ +package org.whispersystems.textsecuregcm.sms; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Locale.LanguageRange; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import javax.validation.constraints.NotEmpty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher; +import org.whispersystems.textsecuregcm.util.Base64; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.Util; + +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +class TwilioVerifySender { + + private static final Logger logger = LoggerFactory.getLogger(TwilioVerifySender.class); + + static final Set TWILIO_VERIFY_LANGUAGES = Set.of( + "af", + "ar", + "ca", + "zh", + "zh-CN", + "zh-HK", + "hr", + "cs", + "da", + "nl", + "en", + "en-GB", + "fi", + "fr", + "de", + "el", + "he", + "hi", + "hu", + "id", + "it", + "ja", + "ko", + "ms", + "nb", + "pl", + "pt", + "pt-BR", + "ro", + "ru", + "es", + "sv", + "tl", + "th", + "tr", + "vi"); + + private final String accountId; + private final String accountToken; + + private final URI verifyServiceUri; + private final URI verifyApprovalBaseUri; + private final String androidAppHash; + private final FaultTolerantHttpClient httpClient; + + TwilioVerifySender(String baseUri, FaultTolerantHttpClient httpClient, TwilioConfiguration twilioConfiguration) { + + this.accountId = twilioConfiguration.getAccountId(); + this.accountToken = twilioConfiguration.getAccountToken(); + + this.verifyServiceUri = URI + .create(baseUri + "/v2/Services/" + twilioConfiguration.getVerifyServiceSid() + "/Verifications"); + this.verifyApprovalBaseUri = URI + .create(baseUri + "/v2/Services/" + twilioConfiguration.getVerifyServiceSid() + "/Verifications/"); + + this.androidAppHash = twilioConfiguration.getAndroidAppHash(); + + this.httpClient = httpClient; + } + + CompletableFuture> deliverSmsVerificationWithVerify(String destination, Optional clientType, + String verificationCode, List languageRanges) { + + HttpRequest request = buildVerifyRequest("sms", destination, verificationCode, findBestLocale(languageRanges), + clientType); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(this::parseResponse) + .handle(this::extractVerifySid); + } + + private Optional findBestLocale(List priorityList) { + return Util.findBestLocale(priorityList, TwilioVerifySender.TWILIO_VERIFY_LANGUAGES); + } + + private TwilioVerifyResponse parseResponse(HttpResponse response) { + ObjectMapper mapper = SystemMapper.getMapper(); + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) { + return new TwilioVerifyResponse(TwilioVerifyResponse.SuccessResponse.fromBody(mapper, response.body())); + } else { + return new TwilioVerifyResponse(new TwilioVerifyResponse.SuccessResponse()); + } + } + + if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) { + return new TwilioVerifyResponse(TwilioVerifyResponse.FailureResponse.fromBody(mapper, response.body())); + } else { + return new TwilioVerifyResponse(new TwilioVerifyResponse.FailureResponse()); + } + } + + CompletableFuture> deliverVoxVerificationWithVerify(String destination, String verificationCode, + List languageRanges) { + + HttpRequest request = buildVerifyRequest("call", destination, verificationCode, findBestLocale(languageRanges), + Optional.empty()); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(this::parseResponse) + .handle(this::extractVerifySid); + } + + private Optional extractVerifySid(TwilioVerifyResponse twilioVerifyResponse, Throwable throwable) { + + if (throwable != null) { + return Optional.empty(); + } + + if (twilioVerifyResponse.isFailure()) { + return Optional.empty(); + } + + return Optional.ofNullable(twilioVerifyResponse.successResponse.getSid()); + } + + private HttpRequest buildVerifyRequest(String channel, String destination, String verificationCode, + Optional locale, Optional clientType) { + + final Map requestParameters = new HashMap<>(); + requestParameters.put("To", destination); + requestParameters.put("CustomCode", verificationCode); + requestParameters.put("Channel", channel); + locale.ifPresent(loc -> requestParameters.put("Locale", loc)); + clientType.filter(client -> client.startsWith("android")) + .ifPresent(ignored -> requestParameters.put("AppHash", androidAppHash)); + + return HttpRequest.newBuilder() + .uri(verifyServiceUri) + .POST(FormDataBodyPublisher.of(requestParameters)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic " + Base64.encodeBytes((accountId + ":" + accountToken).getBytes())) + .build(); + } + + public CompletableFuture reportVerificationSucceeded(String verificationSid) { + + final Map requestParameters = new HashMap<>(); + requestParameters.put("Status", "approved"); + + HttpRequest request = HttpRequest.newBuilder() + .uri(verifyApprovalBaseUri.resolve(verificationSid)) + .POST(FormDataBodyPublisher.of(requestParameters)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic " + Base64.encodeBytes((accountId + ":" + accountToken).getBytes())) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(this::parseResponse) + .handle((response, throwable) -> throwable == null + && response.isSuccess() + && "approved".equals(response.successResponse.getStatus())); + } + + public static class TwilioVerifyResponse { + + private SuccessResponse successResponse; + private FailureResponse failureResponse; + + TwilioVerifyResponse(SuccessResponse successResponse) { + this.successResponse = successResponse; + } + + TwilioVerifyResponse(FailureResponse failureResponse) { + this.failureResponse = failureResponse; + } + + boolean isSuccess() { + return successResponse != null; + } + + boolean isFailure() { + return failureResponse != null; + } + + private static class SuccessResponse { + + @NotEmpty + public String sid; + + @NotEmpty + public String status; + + static SuccessResponse fromBody(ObjectMapper mapper, String body) { + try { + return mapper.readValue(body, SuccessResponse.class); + } catch (IOException e) { + logger.warn("Error parsing twilio success response: " + e); + return new SuccessResponse(); + } + } + + public String getSid() { + return sid; + } + + public String getStatus() { + return status; + } + } + + private static class FailureResponse { + + @JsonProperty + private int status; + + @JsonProperty + private String message; + + @JsonProperty + private int code; + + static FailureResponse fromBody(ObjectMapper mapper, String body) { + try { + return mapper.readValue(body, FailureResponse.class); + } catch (IOException e) { + logger.warn("Error parsing twilio response: " + e); + return new FailureResponse(); + } + } + + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java index ebfe74d1f..d0b22c9b2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java @@ -14,8 +14,8 @@ import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; import java.time.temporal.ChronoField; -import java.util.Arrays; -import java.util.Map; +import java.util.*; +import java.util.Locale.LanguageRange; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -188,4 +188,8 @@ public class Util { final long currentTimeSeconds = offset.addTo(clock.instant()).getLong(ChronoField.INSTANT_SECONDS); return TimeUnit.DAYS.toMillis(TimeUnit.SECONDS.toDays(currentTimeSeconds)); } + + public static Optional findBestLocale(List priorityList, Collection supportedLocales) { + return Optional.ofNullable(Locale.lookupTag(priorityList, supportedLocales)); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySenderTest.java new file mode 100644 index 000000000..9c173a2a0 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/sms/TwilioVerifySenderTest.java @@ -0,0 +1,214 @@ +package org.whispersystems.textsecuregcm.sms; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.Assert.assertEquals; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Locale.LanguageRange; +import java.util.Optional; +import javax.annotation.Nullable; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.util.ExecutorUtils; + +@SuppressWarnings("OptionalGetWithoutIsPresent") +@RunWith(JUnitParamsRunner.class) +public class TwilioVerifySenderTest { + + private static final String ACCOUNT_ID = "test_account_id"; + private static final String ACCOUNT_TOKEN = "test_account_token"; + private static final String MESSAGING_SERVICE_SID = "test_messaging_services_id"; + private static final String NANPA_MESSAGING_SERVICE_SID = "nanpa_test_messaging_service_id"; + private static final String VERIFY_SERVICE_SID = "verify_service_sid"; + private static final String LOCAL_DOMAIN = "test.com"; + private static final String ANDROID_APP_HASH = "someHash"; + + private static final String VERIFICATION_SID = "verification"; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(options().dynamicPort().dynamicHttpsPort()); + + private TwilioVerifySender sender; + + @Before + public void setup() { + final TwilioConfiguration twilioConfiguration = createTwilioConfiguration(); + + final FaultTolerantHttpClient httpClient = FaultTolerantHttpClient.newBuilder() + .withCircuitBreaker(twilioConfiguration.getCircuitBreaker()) + .withRetry(twilioConfiguration.getRetry()) + .withVersion(HttpClient.Version.HTTP_2) + .withConnectTimeout(Duration.ofSeconds(10)) + .withRedirect(HttpClient.Redirect.NEVER) + .withExecutor(ExecutorUtils.newFixedThreadBoundedQueueExecutor(10, 100)) + .withName("twilio") + .build(); + + sender = new TwilioVerifySender("http://localhost:" + wireMockRule.port(), httpClient, twilioConfiguration); + } + + private TwilioConfiguration createTwilioConfiguration() { + + TwilioConfiguration configuration = new TwilioConfiguration(); + + configuration.setAccountId(ACCOUNT_ID); + configuration.setAccountToken(ACCOUNT_TOKEN); + configuration.setMessagingServiceSid(MESSAGING_SERVICE_SID); + configuration.setNanpaMessagingServiceSid(NANPA_MESSAGING_SERVICE_SID); + configuration.setVerifyServiceSid(VERIFY_SERVICE_SID); + configuration.setLocalDomain(LOCAL_DOMAIN); + configuration.setAndroidAppHash(ANDROID_APP_HASH); + + return configuration; + } + + private void setupSuccessStubForVerify() { + wireMockRule.stubFor(post(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withBasicAuth(ACCOUNT_ID, ACCOUNT_TOKEN) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"sid\": \"" + VERIFICATION_SID + "\", \"status\": \"pending\"}"))); + } + + @Test + @Parameters(method = "argumentsForDeliverSmsVerificationWithVerify") + public void deliverSmsVerificationWithVerify(@Nullable final String client, @Nullable final String languageRange, + final boolean expectAppHash, @Nullable final String expectedLocale) throws Exception { + + setupSuccessStubForVerify(); + + List languageRanges = Optional.ofNullable(languageRange) + .map(LanguageRange::parse) + .orElse(Collections.emptyList()); + + final Optional verificationSid = sender + .deliverSmsVerificationWithVerify("+14153333333", Optional.ofNullable(client), "123456", + languageRanges).get(); + + assertEquals(VERIFICATION_SID, verificationSid.get()); + + verify(1, postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded")) + .withRequestBody(equalTo( + (expectedLocale == null ? "" : "Locale=" + expectedLocale + "&") + + "Channel=sms&To=%2B14153333333&CustomCode=123456" + + (expectAppHash ? "&AppHash=" + ANDROID_APP_HASH : "") + ))); + } + + private static Object argumentsForDeliverSmsVerificationWithVerify() { + return new Object[][]{ + // client, languageRange, expectAppHash, expectedLocale + {"ios", "fr-CA, en", false, "fr"}, + {"android-2021-03", "zh-HK, it", true, "zh-HK"}, + {null, null, false, null} + }; + } + + @Test + @Parameters(method = "argumentsForDeliverVoxVerificationWithVerify") + public void deliverVoxVerificationWithVerify(@Nullable final String languageRange, + @Nullable final String expectedLocale) throws Exception { + + setupSuccessStubForVerify(); + + final List languageRanges = Optional.ofNullable(languageRange) + .map(LanguageRange::parse) + .orElse(Collections.emptyList()); + + final Optional verificationSid = sender + .deliverVoxVerificationWithVerify("+14153333333", "123456", languageRanges).get(); + + assertEquals(VERIFICATION_SID, verificationSid.get()); + + verify(1, postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded")) + .withRequestBody(equalTo( + (expectedLocale == null ? "" : "Locale=" + expectedLocale + "&") + + "Channel=call&To=%2B14153333333&CustomCode=123456"))); + } + + private static Object argumentsForDeliverVoxVerificationWithVerify() { + return new Object[][]{ + // languageRange, expectedLocale + {"fr-CA, en", "fr"}, + {"zh-HK, it", "zh-HK"}, + {"en-CAA, en", "en"}, + {null, null} + }; + } + + @Test + public void testSmsFiveHundred() throws Exception { + wireMockRule.stubFor(post(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withBasicAuth(ACCOUNT_ID, ACCOUNT_TOKEN) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Server error!\"}"))); + + final Optional verificationSid = sender + .deliverSmsVerificationWithVerify("+14153333333", Optional.empty(), "123456", Collections.emptyList()).get(); + + assertThat(verificationSid).isEmpty(); + + verify(3, postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded")) + .withRequestBody(equalTo("Channel=sms&To=%2B14153333333&CustomCode=123456"))); + } + + @Test + public void testVoxFiveHundred() throws Exception { + wireMockRule.stubFor(post(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withBasicAuth(ACCOUNT_ID, ACCOUNT_TOKEN) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Server error!\"}"))); + + final Optional verificationSid = sender + .deliverVoxVerificationWithVerify("+14153333333", "123456", Collections.emptyList()).get(); + + assertThat(verificationSid).isEmpty(); + + verify(3, postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded")) + .withRequestBody(equalTo("Channel=call&To=%2B14153333333&CustomCode=123456"))); + } + + @Test + public void reportVerificationSucceeded() throws Exception { + + wireMockRule.stubFor(post(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications/" + VERIFICATION_SID)) + .withBasicAuth(ACCOUNT_ID, ACCOUNT_TOKEN) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\": \"approved\", \"sid\": \"" + VERIFICATION_SID + "\"}"))); + + final Boolean success = sender.reportVerificationSucceeded(VERIFICATION_SID).get(); + + assertThat(success).isTrue(); + + verify(1, postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications/" + VERIFICATION_SID)) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded")) + .withRequestBody(equalTo("Status=approved"))); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java new file mode 100644 index 000000000..c428d5821 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java @@ -0,0 +1,45 @@ +package org.whispersystems.textsecuregcm.tests.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; +import java.util.List; +import java.util.Locale.LanguageRange; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.util.Util; + +class LocaleTest { + + private static final Set SUPPORTED_LOCALES = Set.of("es", "en", "zh", "zh-HK"); + + @ParameterizedTest + @MethodSource + void testFindBestLocale(@Nullable final String languageRange, @Nullable final String expectedLocale) { + + final List languageRanges = Optional.ofNullable(languageRange) + .map(LanguageRange::parse) + .orElse(Collections.emptyList()); + + assertEquals(Optional.ofNullable(expectedLocale), Util.findBestLocale(languageRanges, SUPPORTED_LOCALES)); + } + + static Stream testFindBestLocale() { + return Stream.of( + // languageRange, expectedLocale + Arguments.of("en-US, fr", "en"), + Arguments.of("es-ES", "es"), + Arguments.of("zh-Hant-HK, zh-HK", "zh"), + // zh-HK is supported, but Locale#lookup truncates from the end, per RFC-4647 + Arguments.of("zh-Hant-HK", "zh"), + Arguments.of("zh-HK", "zh-HK"), + Arguments.of("de", null), + Arguments.of(null, null) + ); + } +}