diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 5d9df8b10..90d9e5d8e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -427,7 +427,7 @@ public class WhisperServerService extends Application getExperimentEnrollmentConfiguration( final String experimentName) { return Optional.ofNullable(experiments.get(experimentName)); @@ -80,4 +83,7 @@ public class DynamicConfiguration { this.twilio = twilioConfiguration; } + public DynamicSignupCaptchaConfiguration getSignupCaptchaConfiguration() { + return signupCaptcha; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicSignupCaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicSignupCaptchaConfiguration.java new file mode 100644 index 000000000..894b7f370 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicSignupCaptchaConfiguration.java @@ -0,0 +1,23 @@ +package org.whispersystems.textsecuregcm.configuration.dynamic; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; +import java.util.Collections; +import java.util.Set; +import javax.validation.constraints.NotNull; + +public class DynamicSignupCaptchaConfiguration { + + @JsonProperty + @NotNull + private Set countryCodes = Collections.emptySet(); + + public Set getCountryCodes() { + return countryCodes; + } + + @VisibleForTesting + public void setCountryCodes(Set numbers) { + this.countryCodes = numbers; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 34adab367..11bb31155 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -16,7 +16,6 @@ import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import java.security.SecureRandom; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -48,6 +47,7 @@ import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicSignupCaptchaConfiguration; import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountCreationResult; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; @@ -70,6 +70,7 @@ import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.MessagesManager; import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; import org.whispersystems.textsecuregcm.storage.UsernamesManager; @@ -111,6 +112,7 @@ public class AccountController { private final SmsSender smsSender; private final DirectoryQueue directoryQueue; private final MessagesManager messagesManager; + private final DynamicConfigurationManager dynamicConfigurationManager; private final TurnTokenGenerator turnTokenGenerator; private final Map testDevices; private final RecaptchaClient recaptchaClient; @@ -126,6 +128,7 @@ public class AccountController { SmsSender smsSenderFactory, DirectoryQueue directoryQueue, MessagesManager messagesManager, + DynamicConfigurationManager dynamicConfigurationManager, TurnTokenGenerator turnTokenGenerator, Map testDevices, RecaptchaClient recaptchaClient, @@ -141,6 +144,7 @@ public class AccountController { this.smsSender = smsSenderFactory; this.directoryQueue = directoryQueue; this.messagesManager = messagesManager; + this.dynamicConfigurationManager = dynamicConfigurationManager; this.testDevices = testDevices; this.turnTokenGenerator = turnTokenGenerator; this.recaptchaClient = recaptchaClient; @@ -590,9 +594,10 @@ public class AccountController { } } + final String countryCode = Util.getCountryCode(number); { final List tags = new ArrayList<>(); - tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number))); + tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode)); try { if (pushChallenge.isPresent()) { @@ -650,6 +655,11 @@ public class AccountController { return new CaptchaRequirement(true, true); } + DynamicSignupCaptchaConfiguration signupCaptchaConfig = dynamicConfigurationManager.getConfiguration().getSignupCaptchaConfiguration(); + if (signupCaptchaConfig.getCountryCodes().contains(countryCode)) { + return new CaptchaRequirement(true, false); + } + return new CaptchaRequirement(false, false); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java index c21b261ae..daf088a4e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java @@ -276,4 +276,28 @@ class DynamicConfigurationTest { assertEquals(Set.of("44"), config.getAllowedCountryCodes()); } } + + @Test + public void testParseSignupCaptchaConfiguration() throws JsonProcessingException { + { + final String emptyConfigYaml = "test: true"; + final DynamicConfiguration emptyConfig = DynamicConfigurationManager.OBJECT_MAPPER + .readValue(emptyConfigYaml, DynamicConfiguration.class); + + assertTrue(emptyConfig.getSignupCaptchaConfiguration().getCountryCodes().isEmpty()); + } + + { + final String signupCaptchaConfig = + "signupCaptcha:\n" + + " countryCodes:\n" + + " - 1"; + + final DynamicSignupCaptchaConfiguration config = DynamicConfigurationManager.OBJECT_MAPPER + .readValue(signupCaptchaConfig, DynamicConfiguration.class) + .getSignupCaptchaConfiguration(); + + assertEquals(Set.of("1"), config.getCountryCodes()); + } + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java index 87fe99912..c9d8c3263 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java @@ -32,8 +32,10 @@ import java.util.Collections; import java.util.HashMap; import java.util.Locale; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -43,7 +45,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials; @@ -52,6 +56,8 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicSignupCaptchaConfiguration; import org.whispersystems.textsecuregcm.controllers.AccountController; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.entities.AccountAttributes; @@ -75,6 +81,7 @@ import org.whispersystems.textsecuregcm.storage.AbusiveHostRule; import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.MessagesManager; import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; import org.whispersystems.textsecuregcm.storage.UsernamesManager; @@ -130,6 +137,8 @@ class AccountControllerTest { private static APNSender apnSender = mock(APNSender.class); private static UsernamesManager usernamesManager = mock(UsernamesManager.class); + private static DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); + private byte[] registration_lock_key = new byte[32]; private static ExternalServiceCredentialGenerator storageCredentialGenerator = new ExternalServiceCredentialGenerator(new byte[32], new byte[32], false); @@ -148,6 +157,7 @@ class AccountControllerTest { smsSender, directoryQueue, storedMessages, + dynamicConfigurationManager, turnTokenGenerator, new HashMap<>(), recaptchaClient, @@ -207,6 +217,15 @@ class AccountControllerTest { when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("n00bkiller"))).thenReturn(true); when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("takenusername"))).thenReturn(false); + { + DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); + when(dynamicConfigurationManager.getConfiguration()) + .thenReturn(dynamicConfiguration); + + DynamicSignupCaptchaConfiguration signupCaptchaConfig = new DynamicSignupCaptchaConfiguration(); + + when(dynamicConfiguration.getSignupCaptchaConfiguration()).thenReturn(signupCaptchaConfig); + } when(abusiveHostRules.getAbusiveHostRulesFor(eq(ABUSIVE_HOST))).thenReturn(Collections.singletonList(new AbusiveHostRule(ABUSIVE_HOST, true, Collections.emptyList()))); when(abusiveHostRules.getAbusiveHostRulesFor(eq(RESTRICTED_HOST))).thenReturn(Collections.singletonList(new AbusiveHostRule(RESTRICTED_HOST, false, Collections.singletonList("+123")))); when(abusiveHostRules.getAbusiveHostRulesFor(eq(NICE_HOST))).thenReturn(Collections.emptyList()); @@ -1275,4 +1294,38 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(204); verify(accountsManager).delete(AuthHelper.VALID_ACCOUNT, AccountsManager.DeletionReason.USER_REQUEST); } + + @ParameterizedTest + @MethodSource + void testSignupCaptcha(final String message, final boolean enforced, final Set countryCodes, final int expectedResponseStatusCode) { + DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); + when(dynamicConfigurationManager.getConfiguration()) + .thenReturn(dynamicConfiguration); + + DynamicSignupCaptchaConfiguration signupCaptchaConfig = new DynamicSignupCaptchaConfiguration(); + signupCaptchaConfig.setCountryCodes(countryCodes); + when(dynamicConfiguration.getSignupCaptchaConfiguration()) + .thenReturn(signupCaptchaConfig); + + Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/sms/code/%s", SENDER)) + .queryParam("challenge", "1234-push") + .request() + .header("X-Forwarded-For", NICE_HOST) + .get(); + + assertThat(response.getStatus()).isEqualTo(expectedResponseStatusCode); + + verify(smsSender, 200 == expectedResponseStatusCode ? times(1) : never()) + .deliverSmsVerification(eq(SENDER), eq(Optional.empty()), anyString()); + } + + static Stream testSignupCaptcha() { + return Stream.of( + Arguments.of("captcha not enforced", false, Collections.emptySet(), 200), + Arguments.of("no enforced country codes", true, Collections.emptySet(), 200), + Arguments.of("captcha enforced", true, Set.of("1"), 402) + ); + } }