Make the enterprise client canonical
This commit is contained in:
parent
2a68d9095d
commit
1395dcc0be
|
@ -242,7 +242,7 @@ voiceVerification:
|
|||
locales:
|
||||
- en
|
||||
|
||||
recaptchaV2:
|
||||
recaptcha:
|
||||
projectPath: projects/example
|
||||
credentialConfigurationJson: "{ }" # service account configuration for backend authentication
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue