diff --git a/service/config/sample.yml b/service/config/sample.yml index d865c29af..4e2474609 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -242,7 +242,7 @@ voiceVerification: locales: - en -recaptchaV2: +recaptcha: projectPath: projects/example credentialConfigurationJson: "{ }" # service account configuration for backend authentication diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 2d4266037..891a412b3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -35,7 +35,7 @@ import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.PushConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; -import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration; +import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration; @@ -208,7 +208,7 @@ public class WhisperServerConfiguration extends Configuration { @Valid @NotNull @JsonProperty - private RecaptchaV2Configuration recaptchaV2; + private RecaptchaConfiguration recaptcha; @Valid @NotNull @@ -283,8 +283,8 @@ public class WhisperServerConfiguration extends Configuration { return dynamoDbTables; } - public RecaptchaV2Configuration getRecaptchaV2Configuration() { - return recaptchaV2; + public RecaptchaConfiguration getRecaptchaConfiguration() { + return recaptcha; } public VoiceVerificationConfiguration getVoiceVerificationConfiguration() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 85c359f88..0c82652c7 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -150,7 +150,7 @@ import org.whispersystems.textsecuregcm.push.GCMSender; import org.whispersystems.textsecuregcm.push.MessageSender; import org.whispersystems.textsecuregcm.push.ProvisioningManager; import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.recaptcha.EnterpriseRecaptchaClient; +import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; @@ -472,13 +472,13 @@ public class WhisperServerService extends Application dynamicConfigurationManager; - - public EnterpriseRecaptchaClient( - @Nonnull final String projectPath, - @Nonnull final String recaptchaCredentialConfigurationJson, - final DynamicConfigurationManager dynamicConfigurationManager) { - try { - this.projectPath = Objects.requireNonNull(projectPath); - this.client = RecaptchaEnterpriseServiceClient.create(RecaptchaEnterpriseServiceSettings.newBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(GoogleCredentials.fromStream( - new ByteArrayInputStream(recaptchaCredentialConfigurationJson.getBytes(StandardCharsets.UTF_8))))) - .build()); - - this.dynamicConfigurationManager = dynamicConfigurationManager; - } catch (IOException e) { - throw new AssertionError(e); - } - } - - /** - * Parses the token and action (if any) from {@code input}. The expected input format is: {@code [action:]token}. - *

- * For action to be optional, there is a strong assumption that the token will never contain a {@value SEPARATOR}. - * Observation suggests {@code token} is base-64 encoded. In practice, an action should always be present, but we - * don’t need to be strict. - */ - static String[] parseInputToken(final String input) { - String[] keyActionAndToken = StringUtils.removeStart(input, V2_PREFIX).split("\\" + SEPARATOR, 3); - - if (keyActionAndToken.length == 1) { - throw new BadRequestException("too few parts"); - } - - if (keyActionAndToken.length == 2) { - // there was no ":" delimiter; assume we only have a token - return new String[]{keyActionAndToken[0], null, keyActionAndToken[1]}; - } - - return keyActionAndToken; - } - - @Override - public boolean verify(final String input, final String ip) { - final String[] parts = parseInputToken(input); - - final String sitekey = parts[0]; - final String expectedAction = parts[1]; - final String token = parts[2]; - - Event.Builder eventBuilder = Event.newBuilder() - .setSiteKey(sitekey) - .setToken(token) - .setUserIpAddress(ip); - - if (expectedAction != null) { - eventBuilder.setExpectedAction(expectedAction); - } - - final Event event = eventBuilder.build(); - final Assessment assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build()); - - Metrics.counter(ASSESSMENTS_COUNTER_NAME, - "action", String.valueOf(expectedAction), - "valid", String.valueOf(assessment.getTokenProperties().getValid())) - .increment(); - - if (assessment.getTokenProperties().getValid()) { - final float score = assessment.getRiskAnalysis().getScore(); - - final DistributionSummary.Builder distributionSummaryBuilder = DistributionSummary.builder( - SCORE_DISTRIBUTION_NAME) - // score is 0.0…1.0, which doesn’t play well with distribution summary bucketing, so scale to 0…100 - .scale(100) - .maximumExpectedValue(100.0d) - .tags("action", String.valueOf(expectedAction)); - - distributionSummaryBuilder.register(Metrics.globalRegistry).record(score); - - return score >= dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor() - .floatValue(); - - } else { - return false; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java index d38ea7ce5..56e477ee3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java @@ -1,10 +1,125 @@ /* - * Copyright 2021 Signal Messenger, LLC + * Copyright 2021-2022 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ package org.whispersystems.textsecuregcm.recaptcha; -public interface RecaptchaClient { - boolean verify(String token, String ip); +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceClient; +import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSettings; +import com.google.common.annotations.VisibleForTesting; +import com.google.recaptchaenterprise.v1.Assessment; +import com.google.recaptchaenterprise.v1.Event; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Metrics; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.ws.rs.BadRequestException; +import org.apache.commons.lang3.StringUtils; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +public class RecaptchaClient { + + @VisibleForTesting + static final String SEPARATOR = "."; + @VisibleForTesting + static final String V2_PREFIX = "signal-recaptcha-v2" + RecaptchaClient.SEPARATOR; + private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments"); + private static final String SCORE_DISTRIBUTION_NAME = name(RecaptchaClient.class, "scoreDistribution"); + + private final String projectPath; + private final RecaptchaEnterpriseServiceClient client; + private final DynamicConfigurationManager dynamicConfigurationManager; + + public RecaptchaClient( + @Nonnull final String projectPath, + @Nonnull final String recaptchaCredentialConfigurationJson, + final DynamicConfigurationManager dynamicConfigurationManager) { + try { + this.projectPath = Objects.requireNonNull(projectPath); + this.client = RecaptchaEnterpriseServiceClient.create(RecaptchaEnterpriseServiceSettings.newBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(GoogleCredentials.fromStream( + new ByteArrayInputStream(recaptchaCredentialConfigurationJson.getBytes(StandardCharsets.UTF_8))))) + .build()); + + this.dynamicConfigurationManager = dynamicConfigurationManager; + } catch (IOException e) { + throw new AssertionError(e); + } + } + + /** + * Parses the sitekey, token, and action (if any) from {@code input}. The expected input format is: {@code [version + * prefix.]sitekey.[action.]token}. + *

+ * For action to be optional, there is a strong assumption that the token will never contain a {@value SEPARATOR}. + * Observation suggests {@code token} is base-64 encoded. In practice, an action should always be present, but we + * don’t need to be strict. + */ + static String[] parseInputToken(final String input) { + String[] parts = StringUtils.removeStart(input, V2_PREFIX).split("\\" + SEPARATOR, 3); + + if (parts.length == 1) { + throw new BadRequestException("too few parts"); + } + + if (parts.length == 2) { + // we got some parts, assume it is action that is missing + return new String[]{parts[0], null, parts[1]}; + } + + return parts; + } + + public boolean verify(final String input, final String ip) { + final String[] parts = parseInputToken(input); + + final String sitekey = parts[0]; + final String expectedAction = parts[1]; + final String token = parts[2]; + + Event.Builder eventBuilder = Event.newBuilder() + .setSiteKey(sitekey) + .setToken(token) + .setUserIpAddress(ip); + + if (expectedAction != null) { + eventBuilder.setExpectedAction(expectedAction); + } + + final Event event = eventBuilder.build(); + final Assessment assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build()); + + Metrics.counter(ASSESSMENTS_COUNTER_NAME, + "action", String.valueOf(expectedAction), + "valid", String.valueOf(assessment.getTokenProperties().getValid())) + .increment(); + + if (assessment.getTokenProperties().getValid()) { + final float score = assessment.getRiskAnalysis().getScore(); + + final DistributionSummary.Builder distributionSummaryBuilder = DistributionSummary.builder( + SCORE_DISTRIBUTION_NAME) + // score is 0.0…1.0, which doesn’t play well with distribution summary bucketing, so scale to 0…100 + .scale(100) + .maximumExpectedValue(100.0d) + .tags("action", String.valueOf(expectedAction)); + + distributionSummaryBuilder.register(Metrics.globalRegistry).record(score); + + return score >= dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor() + .floatValue(); + + } else { + return false; + } + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java similarity index 83% rename from service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClientTest.java rename to service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java index 5a40c2c9c..8b188a304 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClientTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java @@ -7,7 +7,7 @@ package org.whispersystems.textsecuregcm.recaptcha; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.whispersystems.textsecuregcm.recaptcha.EnterpriseRecaptchaClient.SEPARATOR; +import static org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient.SEPARATOR; import java.util.stream.Stream; import javax.annotation.Nullable; @@ -17,10 +17,10 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -class EnterpriseRecaptchaClientTest { +class RecaptchaClientTest { - private static final String PREFIX = EnterpriseRecaptchaClient.V2_PREFIX.substring(0, - EnterpriseRecaptchaClient.V2_PREFIX.lastIndexOf(SEPARATOR)); + private static final String PREFIX = RecaptchaClient.V2_PREFIX.substring(0, + RecaptchaClient.V2_PREFIX.lastIndexOf(SEPARATOR)); private static final String SITE_KEY = "site-key"; private static final String TOKEN = "some-token"; @@ -29,7 +29,7 @@ class EnterpriseRecaptchaClientTest { void parseInputToken(final String input, final String expectedToken, final String siteKey, @Nullable final String expectedAction) { - final String[] parts = EnterpriseRecaptchaClient.parseInputToken(input); + final String[] parts = RecaptchaClient.parseInputToken(input); assertEquals(siteKey, parts[0]); assertEquals(expectedAction, parts[1]); @@ -39,7 +39,7 @@ class EnterpriseRecaptchaClientTest { @Test void parseInputTokenBadRequest() { assertThrows(BadRequestException.class, () -> { - EnterpriseRecaptchaClient.parseInputToken(TOKEN); + RecaptchaClient.parseInputToken(TOKEN); }); }