Get captcha clients from spam-filter module

This commit is contained in:
Ameya Lokare
2024-10-15 08:39:44 -07:00
parent cacd4afbbb
commit dbb9a8dcf6
16 changed files with 40 additions and 579 deletions

View File

@@ -37,7 +37,6 @@ import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
@@ -202,11 +201,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
@Valid
@NotNull
@JsonProperty
private HCaptchaClientFactory hCaptcha;
@Valid
@NotNull
@JsonProperty
@@ -379,10 +373,6 @@ public class WhisperServerConfiguration extends Configuration {
return dynamoDbTables;
}
public HCaptchaClientFactory getHCaptchaConfiguration() {
return hCaptcha;
}
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
return shortCode;
}

View File

@@ -100,7 +100,7 @@ import org.whispersystems.textsecuregcm.calls.routing.CallRoutingTableManager;
import org.whispersystems.textsecuregcm.calls.routing.DynamicConfigTurnRouter;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
import org.whispersystems.textsecuregcm.captcha.CaptchaClient;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -499,8 +499,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.scheduledExecutorService(name(getClass(), "secureValueRecoveryServiceRetry-%d")).threads(1).build();
ScheduledExecutorService storageServiceRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "storageServiceRetry-%d")).threads(1).build();
ScheduledExecutorService hcaptchaRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "hCaptchaRetry-%d")).threads(1).build();
ScheduledExecutorService remoteStorageRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "remoteStorageRetry-%d")).threads(1).build();
ScheduledExecutorService registrationIdentityTokenRefreshExecutor = environment.lifecycle()
@@ -551,14 +549,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.maxThreads(8)
.build();
// unbounded executor (same as cachedThreadPool)
ExecutorService hcaptchaHttpExecutor = environment.lifecycle()
.executorService(name(getClass(), "hcaptcha-%d"))
.minThreads(0)
.maxThreads(Integer.MAX_VALUE)
.workQueue(new SynchronousQueue<>())
.keepAliveTime(io.dropwizard.util.Duration.seconds(60L))
.build();
// unbounded executor (same as cachedThreadPool)
ExecutorService remoteStorageHttpExecutor = environment.lifecycle()
.executorService(name(getClass(), "remoteStorage-%d"))
.minThreads(0)
@@ -706,13 +696,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
"message_byte_limit",
config.getMessageByteLimitCardinalityEstimator().period());
HCaptchaClient hCaptchaClient = config.getHCaptchaConfiguration()
.build(hcaptchaRetryExecutor, hcaptchaHttpExecutor, dynamicConfigurationManager);
HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10)).build();
ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());
CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, List.of(hCaptchaClient));
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
pushChallengeDynamoDb);
@@ -765,7 +748,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.lifecycle().manage(virtualThreadPinEventMonitor);
environment.lifecycle().manage(accountsManager);
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker);
AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();
S3Client cdnS3Client = S3Client.builder()
@@ -1075,10 +1057,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
log.warn("No registration-recovery-checkers found; using default (no-op) provider as a default");
return RegistrationRecoveryChecker.noop();
});
final List<CaptchaClient> captchaClients = spamFilter
.map(SpamFilter::getCaptchaClients)
.orElseGet(() -> {
log.warn("No captcha clients found; using default (no-op) client as default");
return List.of(CaptchaClient.noop());
});
spamFilter.map(SpamFilter::getReportedMessageListener).ifPresent(reportMessageManager::addListener);
final HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10)).build();
final ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());
final CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, captchaClients);
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker);
final RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
captchaChecker, rateLimiters, spamFilter.map(SpamFilter::getRateLimitChallengeListener).stream().toList());

View File

@@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.captcha;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
@@ -40,4 +41,24 @@ public interface CaptchaClient {
final String token,
final String ip,
final String userAgent) throws IOException;
static CaptchaClient noop() {
return new CaptchaClient() {
@Override
public String scheme() {
return "noop";
}
@Override
public Set<String> validSiteKeys(final Action action) {
return Set.of("noop");
}
@Override
public AssessmentResult verify(final String siteKey, final Action action, final String token, final String ip,
final String userAgent) throws IOException {
return AssessmentResult.alwaysValid();
}
};
}
}

View File

@@ -1,167 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import javax.ws.rs.core.Response;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
public class HCaptchaClient implements CaptchaClient {
private static final Logger logger = LoggerFactory.getLogger(HCaptchaClient.class);
private static final String PREFIX = "signal-hcaptcha";
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason");
private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason");
private final String apiKey;
private final FaultTolerantHttpClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
@VisibleForTesting
HCaptchaClient(final String apiKey,
final FaultTolerantHttpClient faultTolerantHttpClient,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.apiKey = apiKey;
this.client = faultTolerantHttpClient;
this.dynamicConfigurationManager = dynamicConfigurationManager;
}
public HCaptchaClient(
final String apiKey,
final ScheduledExecutorService retryExecutor,
final ExecutorService httpExecutor,
final CircuitBreakerConfiguration circuitBreakerConfiguration,
final RetryConfiguration retryConfiguration,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this(apiKey,
FaultTolerantHttpClient.newBuilder()
.withName("hcaptcha")
.withCircuitBreaker(circuitBreakerConfiguration)
.withExecutor(httpExecutor)
.withRetryExecutor(retryExecutor)
.withRetry(retryConfiguration)
.withRetryOnException(ex -> ex instanceof IOException)
.withConnectTimeout(Duration.ofSeconds(10))
.withVersion(HttpClient.Version.HTTP_2)
.build(),
dynamicConfigurationManager);
}
@Override
public String scheme() {
return PREFIX;
}
@Override
public Set<String> validSiteKeys(final Action action) {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
if (!config.isAllowHCaptcha()) {
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
return Collections.emptySet();
}
return Optional
.ofNullable(config.getHCaptchaSiteKeys().get(action))
.orElse(Collections.emptySet());
}
@Override
public AssessmentResult verify(
final String siteKey,
final Action action,
final String token,
final String ip,
final String userAgent)
throws IOException {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
final String body = String.format("response=%s&secret=%s&remoteip=%s",
URLEncoder.encode(token, StandardCharsets.UTF_8),
URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
ip);
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://hcaptcha.com/siteverify"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
final HttpResponse<String> response;
try {
response = this.client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
} catch (CompletionException e) {
logger.warn("failed to make http request to hCaptcha: {}", e.getMessage());
throw new IOException(ExceptionUtils.unwrap(e));
}
if (response.statusCode() != Response.Status.OK.getStatusCode()) {
logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response);
throw new IOException("hCaptcha http failure : " + response.statusCode());
}
final HCaptchaResponse hCaptchaResponse = SystemMapper.jsonMapper()
.readValue(response.body(), HCaptchaResponse.class);
logger.debug("received hCaptcha response: {}", hCaptchaResponse);
if (!hCaptchaResponse.success) {
for (String errorCode : hCaptchaResponse.errorCodes) {
Metrics.counter(INVALID_REASON_COUNTER_NAME, Tags.of(
Tag.of("action", action.getActionName()),
Tag.of("reason", errorCode),
UserAgentTagUtil.getPlatformTag(userAgent)
)).increment();
}
return AssessmentResult.invalid();
}
// hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
final float score = 1.0f - hCaptchaResponse.score;
if (score < 0.0f || score > 1.0f) {
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
return AssessmentResult.invalid();
}
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
for (String reason : hCaptchaResponse.scoreReasons) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", action.getActionName(),
"reason", reason,
"score", assessmentResult.getScoreString()).increment();
}
return assessmentResult;
}
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
/**
* Verify response returned by hcaptcha
* <p>
* see <a href="https://docs.hcaptcha.com/#verify-the-user-response-server-side">...</a>
*/
public class HCaptchaResponse {
@JsonProperty
boolean success;
@JsonProperty(value = "challenge_ts")
Instant challengeTs;
@JsonProperty
String hostname;
@JsonProperty(value = "error-codes")
List<String> errorCodes = Collections.emptyList();
@JsonProperty
float score;
@JsonProperty(value = "score_reason")
List<String> scoreReasons = Collections.emptyList();
public HCaptchaResponse() {
}
@Override
public String toString() {
return "HCaptchaResponse{" +
"success=" + success +
", challengeTs=" + challengeTs +
", hostname='" + hostname + '\'' +
", errorCodes=" + errorCodes +
", score=" + score +
", scoreReasons=" + scoreReasons +
'}';
}
}

View File

@@ -1,21 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.dropwizard.jackson.Discoverable;
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = HCaptchaConfiguration.class)
public interface HCaptchaClientFactory extends Discoverable {
HCaptchaClient build(ScheduledExecutorService retryExecutor, ExecutorService httpExecutor,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager);
}

View File

@@ -1,62 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import com.fasterxml.jackson.annotation.JsonTypeName;
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
@JsonTypeName("default")
public class HCaptchaConfiguration implements HCaptchaClientFactory {
@JsonProperty
@NotNull
SecretString apiKey;
@JsonProperty
@NotNull
@Valid
CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration();
@JsonProperty
@NotNull
@Valid
RetryConfiguration retry = new RetryConfiguration();
public SecretString getApiKey() {
return apiKey;
}
public CircuitBreakerConfiguration getCircuitBreaker() {
return circuitBreaker;
}
public RetryConfiguration getRetry() {
return retry;
}
@Override
public HCaptchaClient build(
final ScheduledExecutorService retryExecutor,
final ExecutorService httpExecutor,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
return new HCaptchaClient(
apiKey.value(),
retryExecutor,
httpExecutor,
circuitBreaker,
retry,
dynamicConfigurationManager);
}
}

View File

@@ -8,7 +8,10 @@ package org.whispersystems.textsecuregcm.spam;
import io.dropwizard.configuration.ConfigurationValidationException;
import io.dropwizard.lifecycle.Managed;
import java.io.IOException;
import java.util.List;
import javax.validation.Validator;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.CaptchaClient;
import org.whispersystems.textsecuregcm.storage.ReportedMessageListener;
/**
@@ -80,4 +83,6 @@ public interface SpamFilter extends Managed {
* @return a {@link RegistrationRecoveryChecker} controlled by the spam filter
*/
RegistrationRecoveryChecker getRegistrationRecoveryChecker();
List<CaptchaClient> getCaptchaClients();
}