Support for registration recaptcha

This commit is contained in:
Moxie Marlinspike 2019-03-29 11:59:37 -07:00
parent 3de3fc00ce
commit 9999321400
6 changed files with 192 additions and 38 deletions

View File

@ -146,8 +146,16 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private VoiceVerificationConfiguration voiceVerification;
@Valid
@NotNull
@JsonProperty
private RecaptchaConfiguration recaptcha;
private Map<String, String> transparentDataIndex = new HashMap<>();
public RecaptchaConfiguration getRecaptchaConfiguration() {
return recaptcha;
}
public VoiceVerificationConfiguration getVoiceVerificationConfiguration() {
return voiceVerification;

View File

@ -59,6 +59,7 @@ import org.whispersystems.textsecuregcm.push.GCMSender;
import org.whispersystems.textsecuregcm.push.PushSender;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.push.WebsocketSender;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
import org.whispersystems.textsecuregcm.sms.SmsSender;
@ -192,6 +193,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
PushSender pushSender = new PushSender(apnFallbackManager, gcmSender, apnSender, websocketSender, config.getPushConfiguration().getQueueSize());
ReceiptSender receiptSender = new ReceiptSender(accountsManager, pushSender);
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(config.getTurnConfiguration());
RecaptchaClient recaptchaClient = new RecaptchaClient(config.getRecaptchaConfiguration().getSecret());
DirectoryCredentialsGenerator directoryCredentialsGenerator = new DirectoryCredentialsGenerator(config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenSharedSecret(),
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenUserIdSecret());
@ -223,7 +225,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.buildAuthFilter()));
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(Account.class));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters, smsSender, directoryQueue, messagesManager, turnTokenGenerator, config.getTestDevices()));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters, smsSender, directoryQueue, messagesManager, turnTokenGenerator, config.getTestDevices(), recaptchaClient));
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, messagesManager, directoryQueue, rateLimiters, config.getMaxDevices()));
environment.jersey().register(new DirectoryController(rateLimiters, directory, directoryCredentialsGenerator));
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));

View File

@ -0,0 +1,16 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
public class RecaptchaConfiguration {
@JsonProperty
@NotEmpty
private String secret;
public String getSecret() {
return secret;
}
}

View File

@ -36,6 +36,7 @@ import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.sms.SmsSender;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.AbusiveHostRule;
@ -79,11 +80,13 @@ import io.dropwizard.auth.Auth;
@Path("/v1/accounts")
public class AccountController {
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter newUserMeter = metricRegistry.meter(name(AccountController.class, "brand_new_user"));
private final Meter blockedHostMeter = metricRegistry.meter(name(AccountController.class, "blocked_host"));
private final Meter filteredHostMeter = metricRegistry.meter(name(AccountController.class, "filtered_host"));
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter newUserMeter = metricRegistry.meter(name(AccountController.class, "brand_new_user" ));
private final Meter blockedHostMeter = metricRegistry.meter(name(AccountController.class, "blocked_host" ));
private final Meter filteredHostMeter = metricRegistry.meter(name(AccountController.class, "filtered_host" ));
private final Meter captchaSuccessMeter = metricRegistry.meter(name(AccountController.class, "captcha_success"));
private final Meter captchaFailureMeter = metricRegistry.meter(name(AccountController.class, "captcha_failure"));
private final PendingAccountsManager pendingAccounts;
private final AccountsManager accounts;
@ -94,6 +97,7 @@ public class AccountController {
private final MessagesManager messagesManager;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> testDevices;
private final RecaptchaClient recaptchaClient;
public AccountController(PendingAccountsManager pendingAccounts,
AccountsManager accounts,
@ -103,7 +107,8 @@ public class AccountController {
DirectoryQueue directoryQueue,
MessagesManager messagesManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> testDevices)
Map<String, Integer> testDevices,
RecaptchaClient recaptchaClient)
{
this.pendingAccounts = pendingAccounts;
this.accounts = accounts;
@ -114,6 +119,7 @@ public class AccountController {
this.messagesManager = messagesManager;
this.testDevices = testDevices;
this.turnTokenGenerator = turnTokenGenerator;
this.recaptchaClient = recaptchaClient;
}
@Timed
@ -123,7 +129,8 @@ public class AccountController {
@PathParam("number") String number,
@HeaderParam("X-Forwarded-For") String forwardedFor,
@HeaderParam("Accept-Language") Optional<String> locale,
@QueryParam("client") Optional<String> client)
@QueryParam("client") Optional<String> client,
@QueryParam("captcha") Optional<String> captcha)
throws IOException, RateLimitExceededException
{
if (!Util.isValidNumber(number)) {
@ -138,31 +145,8 @@ public class AccountController {
return Response.status(400).build();
}
for (String requester : requesters) {
List<AbusiveHostRule> abuseRules = abusiveHostRules.getAbusiveHostRulesFor(requester);
for (AbusiveHostRule abuseRule : abuseRules) {
if (abuseRule.isBlocked()) {
logger.info("Blocked host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
blockedHostMeter.mark();
return Response.ok().build();
}
if (!abuseRule.getRegions().isEmpty()) {
if (abuseRule.getRegions().stream().noneMatch(number::startsWith)) {
logger.info("Restricted host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
filteredHostMeter.mark();
return Response.ok().build();
}
}
}
try {
rateLimiters.getSmsVoiceIpLimiter().validate(requester);
} catch (RateLimitExceededException e) {
logger.info("Rate limited exceeded: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
return Response.ok().build();
}
if (requiresCaptcha(number, transport, forwardedFor, requesters, captcha)) {
return Response.status(402).build();
}
switch (transport) {
@ -398,6 +382,51 @@ public class AccountController {
accounts.update(account);
}
private boolean requiresCaptcha(String number, String transport, String forwardedFor,
List<String> requesters, Optional<String> captchaToken)
{
if (captchaToken.isPresent()) {
boolean validToken = recaptchaClient.verify(captchaToken.get());
if (validToken) {
captchaSuccessMeter.mark();
return false;
} else {
captchaFailureMeter.mark();
return true;
}
}
for (String requester : requesters) {
List<AbusiveHostRule> abuseRules = abusiveHostRules.getAbusiveHostRulesFor(requester);
for (AbusiveHostRule abuseRule : abuseRules) {
if (abuseRule.isBlocked()) {
logger.info("Blocked host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
blockedHostMeter.mark();
return true;
}
if (!abuseRule.getRegions().isEmpty()) {
if (abuseRule.getRegions().stream().noneMatch(number::startsWith)) {
logger.info("Restricted host: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
filteredHostMeter.mark();
return true;
}
}
}
try {
rateLimiters.getSmsVoiceIpLimiter().validate(requester);
} catch (RateLimitExceededException e) {
logger.info("Rate limited exceeded: " + transport + ", " + number + ", " + requester + " (" + forwardedFor + ")");
return true;
}
}
return false;
}
private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {
Device device = new Device();
device.setId(Device.MASTER_ID);

View File

@ -0,0 +1,56 @@
package org.whispersystems.textsecuregcm.recaptcha;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
import org.glassfish.jersey.client.ClientConfig;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap;
public class RecaptchaClient {
private final Client client;
private final String recaptchaSecret;
public RecaptchaClient(String recaptchaSecret) {
this.client = ClientBuilder.newClient(new ClientConfig(new JacksonJaxbJsonProvider().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)));
this.recaptchaSecret = recaptchaSecret;
}
public boolean verify(String captchaToken) {
MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
formData.add("secret", recaptchaSecret);
formData.add("response", captchaToken);
VerifyResponse response = client.target("https://www.google.com/recaptcha/api/siteverify")
.request()
.post(Entity.form(formData), VerifyResponse.class);
return response.success;
}
private static class VerifyResponse {
@JsonProperty
private boolean success;
@JsonProperty("error-codes")
private String[] errorCodes;
@JsonProperty
private String hostname;
@JsonProperty
private String challenge_ts;
@Override
public String toString() {
return "success: " + success + ", errorCodes: " + String.join(", ", errorCodes == null ? new String[0] : errorCodes) + ", hostname: " + hostname + ", challenge_ts: " + challenge_ts;
}
}
}

View File

@ -15,6 +15,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.providers.TimeProvider;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.sms.SmsSender;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.AbusiveHostRule;
@ -29,9 +30,9 @@ import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@ -52,6 +53,9 @@ public class AccountControllerTest {
private static final String RESTRICTED_HOST = "192.168.1.2";
private static final String NICE_HOST = "127.0.0.1";
private static final String VALID_CAPTCHA_TOKEN = "valid_token";
private static final String INVALID_CAPTCHA_TOKEN = "invalid_token";
private PendingAccountsManager pendingAccountsManager = mock(PendingAccountsManager.class);
private AccountsManager accountsManager = mock(AccountsManager.class );
private AbusiveHostRules abusiveHostRules = mock(AbusiveHostRules.class );
@ -65,6 +69,7 @@ public class AccountControllerTest {
private TimeProvider timeProvider = mock(TimeProvider.class );
private TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
private Account senderPinAccount = mock(Account.class);
private RecaptchaClient recaptchaClient = mock(RecaptchaClient.class);
@Rule
public final ResourceTestRule resources = ResourceTestRule.builder()
@ -81,7 +86,8 @@ public class AccountControllerTest {
directoryQueue,
storedMessages,
turnTokenGenerator,
new HashMap<>()))
new HashMap<>(),
recaptchaClient))
.build();
@ -112,6 +118,9 @@ public class AccountControllerTest {
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());
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN))).thenReturn(false);
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN))).thenReturn(true);
doThrow(new RateLimitExceededException(SENDER_OVER_PIN)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
}
@ -169,12 +178,46 @@ public class AccountControllerTest {
.header("X-Forwarded-For", ABUSIVE_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getStatus()).isEqualTo(402);
verify(abusiveHostRules).getAbusiveHostRulesFor(eq(ABUSIVE_HOST));
verifyNoMoreInteractions(smsSender);
}
@Test
public void testSendAbusiveHostWithValidCaptcha() throws IOException {
Response response =
resources.getJerseyTest()
.target(String.format("/v1/accounts/sms/code/%s", SENDER))
.queryParam("captcha", VALID_CAPTCHA_TOKEN)
.request()
.header("X-Forwarded-For", ABUSIVE_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(200);
verifyNoMoreInteractions(abusiveHostRules);
verify(recaptchaClient).verify(eq(VALID_CAPTCHA_TOKEN));
verify(smsSender).deliverSmsVerification(eq(SENDER), eq(Optional.empty()), anyString());
}
@Test
public void testSendAbusiveHostWithInvalidCaptcha() {
Response response =
resources.getJerseyTest()
.target(String.format("/v1/accounts/sms/code/%s", SENDER))
.queryParam("captcha", INVALID_CAPTCHA_TOKEN)
.request()
.header("X-Forwarded-For", ABUSIVE_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(402);
verifyNoMoreInteractions(abusiveHostRules);
verify(recaptchaClient).verify(eq(INVALID_CAPTCHA_TOKEN));
verifyNoMoreInteractions(smsSender);
}
@Test
public void testSendMultipleHost() {
Response response =
@ -184,7 +227,7 @@ public class AccountControllerTest {
.header("X-Forwarded-For", NICE_HOST + ", " + ABUSIVE_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getStatus()).isEqualTo(402);
verify(abusiveHostRules, times(1)).getAbusiveHostRulesFor(eq(ABUSIVE_HOST));
verify(abusiveHostRules, times(1)).getAbusiveHostRulesFor(eq(NICE_HOST));
@ -203,7 +246,7 @@ public class AccountControllerTest {
.header("X-Forwarded-For", RESTRICTED_HOST)
.get();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getStatus()).isEqualTo(402);
verify(abusiveHostRules).getAbusiveHostRulesFor(eq(RESTRICTED_HOST));
verifyNoMoreInteractions(smsSender);