diff --git a/service/config/sample.yml b/service/config/sample.yml index 6b5f4ded7..4ddbfd9a0 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -234,6 +234,9 @@ recaptcha: projectPath: projects/example credentialConfigurationJson: "{ }" # service account configuration for backend authentication +hCaptcha: + apiKey: unset + storageService: uri: storage.example.com userAuthenticationTokenSharedSecret: 00000f diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index a4b272407..787aec628 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -28,6 +28,7 @@ import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguratio import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; +import org.whispersystems.textsecuregcm.configuration.HCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; @@ -193,6 +194,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private RecaptchaConfiguration recaptcha; + @Valid + @NotNull + @JsonProperty + private HCaptchaConfiguration hCaptcha; + @Valid @NotNull @JsonProperty @@ -281,6 +287,10 @@ public class WhisperServerConfiguration extends Configuration { return recaptcha; } + public HCaptchaConfiguration getHCaptchaConfiguration() { + return hCaptcha; + } + public VoiceVerificationConfiguration getVoiceVerificationConfiguration() { return voiceVerification; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index c911a705c..cdc3d7f10 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -83,6 +83,8 @@ import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener; import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator; +import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; +import org.whispersystems.textsecuregcm.captcha.HCaptchaClient; import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.controllers.AccountController; @@ -153,7 +155,7 @@ import org.whispersystems.textsecuregcm.push.ProvisioningManager; import org.whispersystems.textsecuregcm.push.PushLatencyManager; import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; +import org.whispersystems.textsecuregcm.captcha.RecaptchaClient; import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; @@ -518,13 +520,18 @@ public class WhisperServerService extends Application captchaClientMap; + + public CaptchaChecker(final List captchaClients) { + this.captchaClientMap = captchaClients.stream() + .collect(Collectors.toMap(CaptchaClient::scheme, Function.identity())); + } + + /** + * Check if a solved captcha should be accepted + *

+ * + * @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The expected + * format is {@code version-prefix.sitekey.[action.]token} + * @param ip IP of the solver + * @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be + * used for metrics + * @throws IOException if there is an error validating the captcha with the underlying service + * @throws BadRequestException if input is not in the expected format + */ + public AssessmentResult verify(final String input, final String ip) throws IOException { + /* + * 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. + */ + final String[] parts = input.split("\\" + SEPARATOR, 4); + + // we allow missing actions, if we're missing 1 part, assume it's the action + if (parts.length < 3) { + throw new BadRequestException("too few parts"); + } + + int idx = 0; + final String prefix = parts[idx++]; + final String siteKey = parts[idx++]; + final String action = parts.length == 3 ? null : parts[idx++]; + final String token = parts[idx]; + + final CaptchaClient client = this.captchaClientMap.get(prefix); + if (client == null) { + throw new BadRequestException("invalid captcha scheme"); + } + final AssessmentResult result = client.verify(siteKey, action, token, ip); + Metrics.counter(ASSESSMENTS_COUNTER_NAME, + "action", String.valueOf(action), + "valid", String.valueOf(result.valid()), + "score", result.score(), + "provider", prefix) + .increment(); + return result; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java new file mode 100644 index 000000000..3c8fd5a2a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.captcha; + +import javax.annotation.Nullable; +import java.io.IOException; + +public interface CaptchaClient { + + /** + * @return the identifying captcha scheme that this CaptchaClient handles + */ + String scheme(); + + /** + * Verify a provided captcha solution + * + * @param siteKey identifying string for the captcha service + * @param action an optional action indicating the purpose of the captcha + * @param token the captcha solution that will be verified + * @param ip the ip of the captcha solve + * @return An {@link AssessmentResult} indicating whether the solution should be accepted + * @throws IOException if the underlying captcha provider returns an error + */ + AssessmentResult verify( + final String siteKey, + final @Nullable String action, + final String token, + final String ip) throws IOException; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java new file mode 100644 index 000000000..3f2583ff7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java @@ -0,0 +1,114 @@ +/* + * Copyright 2021-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.captcha; + +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + +import io.micrometer.core.instrument.Metrics; +import java.io.IOException; +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 javax.annotation.Nullable; +import javax.ws.rs.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +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 HttpClient client; + private final DynamicConfigurationManager dynamicConfigurationManager; + + public HCaptchaClient( + final String apiKey, + final HttpClient client, + final DynamicConfigurationManager dynamicConfigurationManager) { + this.apiKey = apiKey; + this.client = client; + this.dynamicConfigurationManager = dynamicConfigurationManager; + } + + @Override + public String scheme() { + return PREFIX; + } + + @Override + public AssessmentResult verify(final String siteKey, final @Nullable String action, final String token, + final String ip) + throws IOException { + + final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration(); + if (!config.isAllowHCaptcha()) { + logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled"); + return AssessmentResult.invalid(); + } + + 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); + 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(); + + HttpResponse response; + try { + response = this.client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + throw new IOException(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.getMapper() + .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, + "action", String.valueOf(action), + "reason", errorCode).increment(); + } + return AssessmentResult.invalid(); + } + + // hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky) + 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 String scoreString = AssessmentResult.scoreString(score); + + for (String reason : hCaptchaResponse.scoreReasons) { + Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, + "action", String.valueOf(action), + "reason", reason, + "score", scoreString).increment(); + } + return new AssessmentResult(score >= config.getScoreFloor().floatValue(), scoreString); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java new file mode 100644 index 000000000..39db8c755 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java @@ -0,0 +1,57 @@ +/* + * 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.Duration; +import java.util.Collections; +import java.util.List; + +/** + * Verify response returned by hcaptcha + *

+ * see ... + */ +public class HCaptchaResponse { + + @JsonProperty + boolean success; + + @JsonProperty(value = "challenge-ts") + Duration challengeTs; + + @JsonProperty + String hostname; + + @JsonProperty + boolean credit; + + @JsonProperty(value = "error-codes") + List errorCodes = Collections.emptyList(); + + @JsonProperty + float score; + + @JsonProperty(value = "score-reasons") + List scoreReasons = Collections.emptyList(); + + public HCaptchaResponse() { + } + + @Override + public String toString() { + return "HCaptchaResponse{" + + "success=" + success + + ", challengeTs=" + challengeTs + + ", hostname='" + hostname + '\'' + + ", credit=" + credit + + ", errorCodes=" + errorCodes + + ", score=" + score + + ", scoreReasons=" + scoreReasons + + '}'; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java similarity index 52% rename from service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java rename to service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java index c62fe5e64..d53931b33 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.whispersystems.textsecuregcm.recaptcha; +package org.whispersystems.textsecuregcm.captcha; import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.rpc.ApiException; 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 com.google.recaptchaenterprise.v1.RiskAnalysis; @@ -21,22 +21,18 @@ 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 javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -public class RecaptchaClient { +public class RecaptchaClient implements CaptchaClient { + private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class); - @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 V2_PREFIX = "signal-recaptcha-v2"; private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason"); private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason"); @@ -61,57 +57,20 @@ public class RecaptchaClient { } } - /** - * 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; + @Override + public String scheme() { + return V2_PREFIX; } - /** - * A captcha assessment - * - * @param valid whether the captcha was passed - * @param score string representation of the risk level - */ - public record AssessmentResult(boolean valid, String score) { - public static AssessmentResult invalid() { - return new AssessmentResult(false, ""); + @Override + public org.whispersystems.textsecuregcm.captcha.AssessmentResult verify(final String sitekey, + final @Nullable String expectedAction, + final String token, final String ip) throws IOException { + final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration(); + if (!config.isAllowRecaptcha()) { + log.warn("Received request to verify a recaptcha, but recaptcha is not enabled"); + return AssessmentResult.invalid(); } - } - - /* - * recaptcha enterprise scores are from [0.0, 1.0] in increments of .1 - * map to [0, 100] for easier interpretation - */ - @VisibleForTesting - static String scoreString(final float score) { - return Integer.toString((int) (score * 100)); - } - - - public AssessmentResult 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) @@ -123,32 +82,30 @@ public class RecaptchaClient { } 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(); - + final Assessment assessment; + try { + assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build()); + } catch (ApiException e) { + throw new IOException(e); + } if (assessment.getTokenProperties().getValid()) { final float score = assessment.getRiskAnalysis().getScore(); log.debug("assessment for {} was valid, score: {}", expectedAction, score); for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) { Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, - "action", String.valueOf(expectedAction), - "score", scoreString(score), - "reason", reason.name()) + "action", String.valueOf(expectedAction), + "score", AssessmentResult.scoreString(score), + "reason", reason.name()) .increment(); } return new AssessmentResult( - score >= - dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue(), - scoreString(score)); + score >= config.getScoreFloor().floatValue(), + AssessmentResult.scoreString(score)); } else { Metrics.counter(INVALID_REASON_COUNTER_NAME, - "action", String.valueOf(expectedAction), - "reason", assessment.getTokenProperties().getInvalidReason().name()) + "action", String.valueOf(expectedAction), + "reason", assessment.getTokenProperties().getInvalidReason().name()) .increment(); return AssessmentResult.invalid(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java new file mode 100644 index 000000000..1156b253d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java @@ -0,0 +1,11 @@ +/* + * Copyright 2021-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import javax.validation.constraints.NotBlank; + +public record HCaptchaConfiguration(@NotBlank String apiKey) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java index db7cb43b3..fe0751af4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java @@ -1,3 +1,8 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.whispersystems.textsecuregcm.configuration.dynamic; import com.fasterxml.jackson.annotation.JsonProperty; @@ -17,6 +22,12 @@ public class DynamicCaptchaConfiguration { @NotNull private BigDecimal scoreFloor; + @JsonProperty + private boolean allowHCaptcha = false; + + @JsonProperty + private boolean allowRecaptcha = true; + @JsonProperty @NotNull private Set signupCountryCodes = Collections.emptySet(); @@ -46,4 +57,22 @@ public class DynamicCaptchaConfiguration { public Set getSignupRegions() { return signupRegions; } + + public boolean isAllowHCaptcha() { + return allowHCaptcha; + } + + public boolean isAllowRecaptcha() { + return allowRecaptcha; + } + + @VisibleForTesting + public void setAllowHCaptcha(final boolean allowHCaptcha) { + this.allowHCaptcha = allowHCaptcha; + } + + @VisibleForTesting + public void setScoreFloor(final BigDecimal scoreFloor) { + this.scoreFloor = scoreFloor; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 3ce6641e0..31539acb2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -19,6 +19,7 @@ import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; +import java.io.IOException; import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; @@ -63,6 +64,8 @@ import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; +import org.whispersystems.textsecuregcm.captcha.AssessmentResult; +import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.entities.AccountAttributes; @@ -87,7 +90,6 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.push.ClientPresenceManager; import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; @@ -100,8 +102,8 @@ import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Hex; import org.whispersystems.textsecuregcm.util.HeaderUtils; +import org.whispersystems.textsecuregcm.util.Hex; import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; import org.whispersystems.textsecuregcm.util.Optionals; @@ -152,7 +154,7 @@ public class AccountController { private final DynamicConfigurationManager dynamicConfigurationManager; private final TurnTokenGenerator turnTokenGenerator; private final Map testDevices; - private final RecaptchaClient recaptchaClient; + private final CaptchaChecker captchaChecker; private final PushNotificationManager pushNotificationManager; private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator; @@ -172,7 +174,7 @@ public class AccountController { DynamicConfigurationManager dynamicConfigurationManager, TurnTokenGenerator turnTokenGenerator, Map testDevices, - RecaptchaClient recaptchaClient, + CaptchaChecker captchaChecker, PushNotificationManager pushNotificationManager, ChangeNumberManager changeNumberManager, ExternalServiceCredentialGenerator backupServiceCredentialGenerator, @@ -186,7 +188,7 @@ public class AccountController { this.dynamicConfigurationManager = dynamicConfigurationManager; this.testDevices = testDevices; this.turnTokenGenerator = turnTokenGenerator; - this.recaptchaClient = recaptchaClient; + this.captchaChecker = captchaChecker; this.pushNotificationManager = pushNotificationManager; this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; this.changeNumberManager = changeNumberManager; @@ -203,13 +205,13 @@ public class AccountController { DynamicConfigurationManager dynamicConfigurationManager, TurnTokenGenerator turnTokenGenerator, Map testDevices, - RecaptchaClient recaptchaClient, + CaptchaChecker captchaChecker, PushNotificationManager pushNotificationManager, ChangeNumberManager changeNumberManager, ExternalServiceCredentialGenerator backupServiceCredentialGenerator ) { this(pendingAccounts, accounts, rateLimiters, - registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, testDevices, recaptchaClient, + registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, testDevices, captchaChecker, pushNotificationManager, changeNumberManager, backupServiceCredentialGenerator, null, Clock.systemUTC()); } @@ -255,7 +257,7 @@ public class AccountController { @QueryParam("client") Optional client, @QueryParam("captcha") Optional captcha, @QueryParam("challenge") Optional pushChallenge) - throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException { + throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException { Util.requireNormalizedNumber(number); @@ -266,8 +268,9 @@ public class AccountController { final String region = Util.getRegion(number); // if there's a captcha, assess it, otherwise check if we need a captcha - final Optional assessmentResult = captcha - .map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost)); + final Optional assessmentResult = captcha.isPresent() + ? Optional.of(captchaChecker.verify(captcha.get(), sourceHost)) + : Optional.empty(); assessmentResult.ifPresent(result -> Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java index 2320442a7..9b8954a5e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java @@ -12,6 +12,7 @@ import com.google.common.net.HttpHeaders; import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tags; +import java.io.IOException; import java.util.NoSuchElementException; import javax.validation.Valid; import javax.ws.rs.Consumes; @@ -50,7 +51,7 @@ public class ChallengeController { public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth, @Valid final AnswerChallengeRequest answerRequest, @HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException { + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException { Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java index 446b9d8b4..2dcc1f596 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java @@ -5,22 +5,22 @@ import static com.codahale.metrics.MetricRegistry.name; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.whispersystems.textsecuregcm.abuse.RateLimitChallengeListener; +import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.util.Util; public class RateLimitChallengeManager { private final PushChallengeManager pushChallengeManager; - private final RecaptchaClient recaptchaClient; - + private final CaptchaChecker captchaChecker; private final DynamicRateLimiters rateLimiters; private final List rateLimitChallengeListeners = @@ -34,11 +34,11 @@ public class RateLimitChallengeManager { public RateLimitChallengeManager( final PushChallengeManager pushChallengeManager, - final RecaptchaClient recaptchaClient, + final CaptchaChecker captchaChecker, final DynamicRateLimiters rateLimiters) { this.pushChallengeManager = pushChallengeManager; - this.recaptchaClient = recaptchaClient; + this.captchaChecker = captchaChecker; this.rateLimiters = rateLimiters; } @@ -58,11 +58,11 @@ public class RateLimitChallengeManager { } public void answerRecaptchaChallenge(final Account account, final String captcha, final String mostRecentProxyIp, final String userAgent) - throws RateLimitExceededException { + throws RateLimitExceededException, IOException { rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid()); - final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp).valid(); + final boolean challengeSuccess = captchaChecker.verify(captcha, mostRecentProxyIp).valid(); final Tags tags = Tags.of( Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java new file mode 100644 index 000000000..83d932d9f --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.ws.rs.BadRequestException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class CaptchaCheckerTest { + + private static final String SITE_KEY = "site-key"; + private static final String TOKEN = "some-token"; + private static final String PREFIX = "prefix"; + private static final String PREFIX_A = "prefix-a"; + private static final String PREFIX_B = "prefix-b"; + + static Stream parseInputToken() { + return Stream.of( + Arguments.of( + String.join(SEPARATOR, PREFIX, SITE_KEY, TOKEN), + TOKEN, + SITE_KEY, + null), + Arguments.of( + String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN), + TOKEN, + SITE_KEY, + "an-action"), + Arguments.of( + String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN, "something-else"), + TOKEN + SEPARATOR + "something-else", + SITE_KEY, + "an-action") + ); + } + + private static CaptchaClient mockClient(final String prefix) throws IOException { + final CaptchaClient captchaClient = mock(CaptchaClient.class); + when(captchaClient.scheme()).thenReturn(prefix); + when(captchaClient.verify(any(), any(), any(), any())).thenReturn(AssessmentResult.invalid()); + return captchaClient; + } + + + @ParameterizedTest + @MethodSource + void parseInputToken(final String input, final String expectedToken, final String siteKey, + @Nullable final String expectedAction) throws IOException { + final CaptchaClient captchaClient = mockClient(PREFIX); + new CaptchaChecker(List.of(captchaClient)).verify(input, null); + verify(captchaClient, times(1)).verify(eq(siteKey), eq(expectedAction), eq(expectedToken), any()); + } + + @ParameterizedTest + @MethodSource + void scoreString(float score, String expected) { + assertThat(AssessmentResult.scoreString(score)).isEqualTo(expected); + } + + + static Stream scoreString() { + return Stream.of( + Arguments.of(0.3f, "30"), + Arguments.of(0.0f, "0"), + Arguments.of(0.333f, "30"), + Arguments.of(0.29f, "30"), + Arguments.of(Float.NaN, "0") + ); + } + + @Test + public void choose() throws IOException { + String ainput = String.join(SEPARATOR, PREFIX_A, SITE_KEY, TOKEN); + String binput = String.join(SEPARATOR, PREFIX_B, SITE_KEY, TOKEN); + final CaptchaClient a = mockClient(PREFIX_A); + final CaptchaClient b = mockClient(PREFIX_B); + + new CaptchaChecker(List.of(a, b)).verify(ainput, null); + verify(a, times(1)).verify(any(), any(), any(), any()); + + new CaptchaChecker(List.of(a, b)).verify(binput, null); + verify(b, times(1)).verify(any(), any(), any(), any()); + } + + static Stream badToken() { + return Stream.of( + Arguments.of(String.join(SEPARATOR, "invalid", SITE_KEY, "action", TOKEN)), + Arguments.of(String.join(SEPARATOR, PREFIX, TOKEN)), + Arguments.of(String.join(SEPARATOR, SITE_KEY, PREFIX, "action", TOKEN)) + ); + } + + @ParameterizedTest + @MethodSource + public void badToken(final String input) throws IOException { + final CaptchaClient cc = mockClient(PREFIX); + assertThrows(BadRequestException.class, () -> new CaptchaChecker(List.of(cc)).verify(input, null)); + + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java new file mode 100644 index 000000000..e293b2346 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java @@ -0,0 +1,117 @@ +package org.whispersystems.textsecuregcm.captcha; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +public class HCaptchaClientTest { + + private static final String SITE_KEY = "site-key"; + private static final String TOKEN = "token"; + + + static Stream captchaProcessed() { + return Stream.of( + Arguments.of(true, 0.6f, true), + Arguments.of(false, 0.6f, false), + Arguments.of(true, 0.4f, false), + Arguments.of(false, 0.4f, false) + ); + } + + @ParameterizedTest + @MethodSource + public void captchaProcessed(final boolean success, final float score, final boolean expectedResult) + throws IOException, InterruptedException { + + final HttpClient client = mockResponder(200, String.format(""" + { + "success": %b, + "score": %f, + "score-reasons": ["great job doing this captcha"] + } + """, + success, 1 - score)); // hCaptcha scores are inverted compared to recaptcha scores. (low score is good) + + final AssessmentResult result = new HCaptchaClient("fake", client, mockConfig(true, 0.5)) + .verify(SITE_KEY, "whatever", TOKEN, null); + if (!success) { + assertThat(result).isEqualTo(AssessmentResult.invalid()); + } else { + assertThat(result) + .isEqualTo(new AssessmentResult(expectedResult, AssessmentResult.scoreString(score))); + } + } + + @Test + public void errorResponse() throws IOException, InterruptedException { + final HttpClient httpClient = mockResponder(503, ""); + final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); + assertThrows(IOException.class, () -> client.verify(SITE_KEY, "whatever", TOKEN, null)); + } + + @Test + public void invalidScore() throws IOException, InterruptedException { + final HttpClient httpClient = mockResponder(200, """ + {"success" : true, "score": 1.1} + """); + final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); + assertThat(client.verify(SITE_KEY, "whatever", TOKEN, null)).isEqualTo(AssessmentResult.invalid()); + } + + @Test + public void badBody() throws IOException, InterruptedException { + final HttpClient httpClient = mockResponder(200, """ + {"success" : true, + """); + final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); + assertThrows(IOException.class, () -> client.verify(SITE_KEY, "whatever", TOKEN, null)); + } + + @Test + public void disabled() throws IOException { + final HCaptchaClient hc = new HCaptchaClient("fake", null, mockConfig(false, 0.5)); + assertThat(hc.verify(SITE_KEY, null, TOKEN, null)).isEqualTo(AssessmentResult.invalid()); + } + + private static HttpClient mockResponder(final int statusCode, final String jsonBody) + throws IOException, InterruptedException { + HttpClient httpClient = mock(HttpClient.class); + @SuppressWarnings("unchecked") final HttpResponse httpResponse = mock(HttpResponse.class); + + when(httpResponse.body()).thenReturn(jsonBody); + when(httpResponse.statusCode()).thenReturn(statusCode); + + when(httpClient.send(any(), any())).thenReturn(httpResponse); + return httpClient; + } + + private static DynamicConfigurationManager mockConfig(boolean enabled, double scoreFloor) { + final DynamicCaptchaConfiguration config = new DynamicCaptchaConfiguration(); + config.setAllowHCaptcha(enabled); + config.setScoreFloor(BigDecimal.valueOf(scoreFloor)); + + @SuppressWarnings("unchecked") final DynamicConfigurationManager m = mock( + DynamicConfigurationManager.class); + final DynamicConfiguration d = mock(DynamicConfiguration.class); + when(m.getConfiguration()).thenReturn(d); + when(d.getCaptchaConfiguration()).thenReturn(config); + return m; + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index c5f0911b6..551f5cee1 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -33,6 +33,7 @@ import com.google.i18n.phonenumbers.Phonenumber; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.time.Duration; @@ -69,6 +70,8 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; +import org.whispersystems.textsecuregcm.captcha.AssessmentResult; +import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.entities.AccountAttributes; @@ -95,7 +98,6 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper import org.whispersystems.textsecuregcm.push.ClientPresenceManager; import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; @@ -159,7 +161,7 @@ class AccountControllerTest { private static Account senderRegLockAccount = mock(Account.class); private static Account senderHasStorage = mock(Account.class); private static Account senderTransfer = mock(Account.class); - private static RecaptchaClient recaptchaClient = mock(RecaptchaClient.class); + private static CaptchaChecker captchaChecker = mock(CaptchaChecker.class); private static PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); private static ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class); private static ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class); @@ -188,7 +190,7 @@ class AccountControllerTest { dynamicConfigurationManager, turnTokenGenerator, Map.of(TEST_NUMBER, 123456), - recaptchaClient, + captchaChecker, pushNotificationManager, changeNumberManager, storageCredentialGenerator, @@ -297,10 +299,10 @@ class AccountControllerTest { when(dynamicConfiguration.getCaptchaConfiguration()).thenReturn(signupCaptchaConfig); } - when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())) - .thenReturn(RecaptchaClient.AssessmentResult.invalid()); - when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString())) - .thenReturn(new RecaptchaClient.AssessmentResult(true, "")); + when(captchaChecker.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())) + .thenReturn(AssessmentResult.invalid()); + when(captchaChecker.verify(eq(VALID_CAPTCHA_TOKEN), anyString())) + .thenReturn(new AssessmentResult(true, "")); doThrow(new RateLimitExceededException(Duration.ZERO)).when(pinLimiter).validate(eq(SENDER_OVER_PIN)); @@ -328,7 +330,7 @@ class AccountControllerTest { senderRegLockAccount, senderHasStorage, senderTransfer, - recaptchaClient, + captchaChecker, pushNotificationManager, changeNumberManager, clientPresenceManager); @@ -631,7 +633,7 @@ class AccountControllerTest { } @Test - void testSendWithValidCaptcha() throws NumberParseException { + void testSendWithValidCaptcha() throws NumberParseException, IOException { when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) .thenReturn(CompletableFuture.completedFuture(new byte[16])); @@ -648,12 +650,12 @@ class AccountControllerTest { final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(SENDER, null); - verify(recaptchaClient).verify(eq(VALID_CAPTCHA_TOKEN), eq(NICE_HOST)); + verify(captchaChecker).verify(eq(VALID_CAPTCHA_TOKEN), eq(NICE_HOST)); verify(registrationServiceClient).sendRegistrationCode(phoneNumber, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); } @Test - void testSendWithInvalidCaptcha() { + void testSendWithInvalidCaptcha() throws IOException { Response response = resources.getJerseyTest() @@ -665,7 +667,7 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(402); - verify(recaptchaClient).verify(eq(INVALID_CAPTCHA_TOKEN), eq(NICE_HOST)); + verify(captchaChecker).verify(eq(INVALID_CAPTCHA_TOKEN), eq(NICE_HOST)); verifyNoInteractions(registrationServiceClient); } @@ -681,7 +683,7 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(402); - verifyNoInteractions(recaptchaClient); + verifyNoInteractions(captchaChecker); verifyNoInteractions(registrationServiceClient); } @@ -698,7 +700,7 @@ class AccountControllerTest { assertThat(response.getStatus()).isEqualTo(402); - verifyNoInteractions(recaptchaClient); + verifyNoInteractions(captchaChecker); verifyNoInteractions(registrationServiceClient); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java index 5f29a32f9..ea3d0e4f3 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java @@ -19,6 +19,7 @@ import com.google.common.net.HttpHeaders; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; +import java.io.IOException; import java.time.Duration; import java.util.Set; import javax.ws.rs.client.Entity; @@ -97,7 +98,7 @@ class ChallengeControllerTest { } @Test - void testHandleRecaptcha() throws RateLimitExceededException { + void testHandleRecaptcha() throws RateLimitExceededException, IOException { final String recaptchaChallengeJson = """ { "type": "recaptcha", @@ -117,7 +118,7 @@ class ChallengeControllerTest { } @Test - void testHandleRecaptchaRateLimited() throws RateLimitExceededException { + void testHandleRecaptchaRateLimited() throws RateLimitExceededException, IOException { final String recaptchaChallengeJson = """ { "type": "recaptcha", diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java index bb3aaa9e2..239f7a774 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java @@ -7,19 +7,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.IOException; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.whispersystems.textsecuregcm.abuse.RateLimitChallengeListener; +import org.whispersystems.textsecuregcm.captcha.AssessmentResult; +import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; +import org.whispersystems.textsecuregcm.captcha.RecaptchaClient; import org.whispersystems.textsecuregcm.storage.Account; class RateLimitChallengeManagerTest { private PushChallengeManager pushChallengeManager; - private RecaptchaClient recaptchaClient; + private CaptchaChecker captchaChecker; private DynamicRateLimiters rateLimiters; private RateLimitChallengeListener rateLimitChallengeListener; @@ -28,13 +31,13 @@ class RateLimitChallengeManagerTest { @BeforeEach void setUp() { pushChallengeManager = mock(PushChallengeManager.class); - recaptchaClient = mock(RecaptchaClient.class); + captchaChecker = mock(CaptchaChecker.class); rateLimiters = mock(DynamicRateLimiters.class); rateLimitChallengeListener = mock(RateLimitChallengeListener.class); rateLimitChallengeManager = new RateLimitChallengeManager( pushChallengeManager, - recaptchaClient, + captchaChecker, rateLimiters); rateLimitChallengeManager.addListener(rateLimitChallengeListener); @@ -63,15 +66,15 @@ class RateLimitChallengeManagerTest { @ParameterizedTest @ValueSource(booleans = {true, false}) - void answerRecaptchaChallenge(final boolean successfulChallenge) throws RateLimitExceededException { + void answerRecaptchaChallenge(final boolean successfulChallenge) throws RateLimitExceededException, IOException { final Account account = mock(Account.class); when(account.getNumber()).thenReturn("+18005551234"); when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(recaptchaClient.verify(any(), any())) + when(captchaChecker.verify(any(), any())) .thenReturn(successfulChallenge - ? new RecaptchaClient.AssessmentResult(true, "") - : RecaptchaClient.AssessmentResult.invalid()); + ? new AssessmentResult(true, "") + : AssessmentResult.invalid()); when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class)); when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class)); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java deleted file mode 100644 index c50702c6b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/RecaptchaClientTest.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.recaptcha; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient.SEPARATOR; - -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.BadRequestException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class RecaptchaClientTest { - - 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"; - - @ParameterizedTest - @MethodSource - void parseInputToken(final String input, final String expectedToken, final String siteKey, - @Nullable final String expectedAction) { - - final String[] parts = RecaptchaClient.parseInputToken(input); - - assertEquals(siteKey, parts[0]); - assertEquals(expectedAction, parts[1]); - assertEquals(expectedToken, parts[2]); - } - - @Test - void parseInputTokenBadRequest() { - assertThrows(BadRequestException.class, () -> { - RecaptchaClient.parseInputToken(TOKEN); - }); - } - - @ParameterizedTest - @MethodSource - void scoreString(float score, String expected) { - assertThat(RecaptchaClient.scoreString(score)).isEqualTo(expected); - } - - static Stream scoreString() { - return Stream.of( - Arguments.of(0.3f, "30"), - Arguments.of(0.0f, "0"), - Arguments.of(0.333f, "33"), - Arguments.of(Float.NaN, "0") - ); - } - - static Stream parseInputToken() { - return Stream.of( - Arguments.of( - String.join(SEPARATOR, SITE_KEY, TOKEN), - TOKEN, - SITE_KEY, - null), - Arguments.of( - String.join(SEPARATOR, SITE_KEY, "an-action", TOKEN), - TOKEN, - SITE_KEY, - "an-action"), - Arguments.of( - String.join(SEPARATOR, SITE_KEY, "an-action", TOKEN, "something-else"), - TOKEN + SEPARATOR + "something-else", - SITE_KEY, - "an-action"), - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, TOKEN), - TOKEN, - SITE_KEY, - null), - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN), - TOKEN, - SITE_KEY, - "an-action"), - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN, "something-else"), - TOKEN + SEPARATOR + "something-else", - SITE_KEY, - "an-action") - ); - } -}