Make the enterprise client canonical

This commit is contained in:
Chris Eager 2022-03-03 17:47:31 -08:00 committed by Chris Eager
parent 2a68d9095d
commit 1395dcc0be
7 changed files with 136 additions and 146 deletions

View File

@ -242,7 +242,7 @@ voiceVerification:
locales:
- en
recaptchaV2:
recaptcha:
projectPath: projects/example
credentialConfigurationJson: "{ }" # service account configuration for backend authentication

View File

@ -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() {

View File

@ -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<WhisperServerConfiguration
MessageSender messageSender = new MessageSender(apnFallbackManager, clientPresenceManager, messagesManager, gcmSender, apnSender, pushLatencyManager);
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender);
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(config.getTurnConfiguration());
EnterpriseRecaptchaClient enterpriseRecaptchaClient = new EnterpriseRecaptchaClient(
config.getRecaptchaV2Configuration().getProjectPath(),
config.getRecaptchaV2Configuration().getCredentialConfigurationJson(),
RecaptchaClient recaptchaClient = new RecaptchaClient(
config.getRecaptchaConfiguration().getProjectPath(),
config.getRecaptchaConfiguration().getCredentialConfigurationJson(),
dynamicConfigurationManager);
PushChallengeManager pushChallengeManager = new PushChallengeManager(apnSender, gcmSender, pushChallengeDynamoDb);
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
enterpriseRecaptchaClient, dynamicRateLimiters);
recaptchaClient, dynamicRateLimiters);
RateLimitChallengeOptionManager rateLimitChallengeOptionManager =
new RateLimitChallengeOptionManager(dynamicRateLimiters, dynamicConfigurationManager);
@ -615,7 +615,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
smsSender, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
enterpriseRecaptchaClient, gcmSender, apnSender, backupCredentialsGenerator,
recaptchaClient, gcmSender, apnSender, backupCredentialsGenerator,
verifyExperimentEnrollmentManager));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));

View File

@ -7,7 +7,7 @@ package org.whispersystems.textsecuregcm.configuration;
import javax.validation.constraints.NotEmpty;
public class RecaptchaV2Configuration {
public class RecaptchaConfiguration {
private String projectPath;
private String credentialConfigurationJson;

View File

@ -1,125 +0,0 @@
/*
* Copyright 2021-2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.recaptcha;
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 EnterpriseRecaptchaClient implements RecaptchaClient {
@VisibleForTesting
static final String SEPARATOR = ".";
@VisibleForTesting
static final String V2_PREFIX = "signal-recaptcha-v2" + EnterpriseRecaptchaClient.SEPARATOR;
private static final String ASSESSMENTS_COUNTER_NAME = name(EnterpriseRecaptchaClient.class, "assessments");
private static final String SCORE_DISTRIBUTION_NAME = name(EnterpriseRecaptchaClient.class, "scoreDistribution");
private final String projectPath;
private final RecaptchaEnterpriseServiceClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
public EnterpriseRecaptchaClient(
@Nonnull final String projectPath,
@Nonnull final String recaptchaCredentialConfigurationJson,
final DynamicConfigurationManager<DynamicConfiguration> 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}.
* <p>
* 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
* dont 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.01.0, which doesnt play well with distribution summary bucketing, so scale to 0100
.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;
}
}
}

View File

@ -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<DynamicConfiguration> dynamicConfigurationManager;
public RecaptchaClient(
@Nonnull final String projectPath,
@Nonnull final String recaptchaCredentialConfigurationJson,
final DynamicConfigurationManager<DynamicConfiguration> 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}.
* <p>
* 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
* dont 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.01.0, which doesnt play well with distribution summary bucketing, so scale to 0100
.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;
}
}
}

View File

@ -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);
});
}