Add hCaptcha support

This commit is contained in:
Ravi Khadiwala 2022-12-06 13:14:14 -06:00 committed by ravi-signal
parent dcec90fc52
commit 65ad3fe623
20 changed files with 689 additions and 215 deletions

View File

@ -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

View File

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

View File

@ -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<WhisperServerConfiguration
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
RecaptchaClient recaptchaClient = new RecaptchaClient(
config.getRecaptchaConfiguration().getProjectPath(),
config.getRecaptchaConfiguration().getCredentialConfigurationJson(),
dynamicConfigurationManager);
HttpClient hcaptchaHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey(), hcaptchaHttpClient, dynamicConfigurationManager);
CaptchaChecker captchaChecker = new CaptchaChecker(List.of(recaptchaClient, hCaptchaClient));
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
recaptchaClient, dynamicRateLimiters);
captchaChecker, dynamicRateLimiters);
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
@ -661,7 +668,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, rateLimiters,
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
recaptchaClient, pushNotificationManager, changeNumberManager, backupCredentialsGenerator,
captchaChecker, pushNotificationManager, changeNumberManager, backupCredentialsGenerator,
clientPresenceManager, clock));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));

View File

@ -0,0 +1,27 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
/**
* 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, "");
}
/**
* Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics
*/
static String scoreString(final float score) {
final int x = Math.round(score * 10); // [0, 10]
return Integer.toString(x * 10); // [0, 100] in increments of 10
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.ws.rs.BadRequestException;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class CaptchaChecker {
private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments");
@VisibleForTesting
static final String SEPARATOR = ".";
private final Map<String, CaptchaClient> captchaClientMap;
public CaptchaChecker(final List<CaptchaClient> captchaClients) {
this.captchaClientMap = captchaClients.stream()
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
}
/**
* Check if a solved captcha should be accepted
* <p>
*
* @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
* dont 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;
}
}

View File

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

View File

@ -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<DynamicConfiguration> dynamicConfigurationManager;
public HCaptchaClient(
final String apiKey,
final HttpClient client,
final DynamicConfigurationManager<DynamicConfiguration> 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<String> 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);
}
}

View File

@ -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
* <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")
Duration challengeTs;
@JsonProperty
String hostname;
@JsonProperty
boolean credit;
@JsonProperty(value = "error-codes")
List<String> errorCodes = Collections.emptyList();
@JsonProperty
float score;
@JsonProperty(value = "score-reasons")
List<String> 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 +
'}';
}
}

View File

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

View File

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

View File

@ -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<String> signupCountryCodes = Collections.emptySet();
@ -46,4 +57,22 @@ public class DynamicCaptchaConfiguration {
public Set<String> 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;
}
}

View File

@ -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<DynamicConfiguration> dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> 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<DynamicConfiguration> dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> 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<DynamicConfiguration> dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> 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<String> client,
@QueryParam("captcha") Optional<String> captcha,
@QueryParam("challenge") Optional<String> 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<RecaptchaClient.AssessmentResult> assessmentResult = captcha
.map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost));
final Optional<AssessmentResult> assessmentResult = captcha.isPresent()
? Optional.of(captchaChecker.verify(captcha.get(), sourceHost))
: Optional.empty();
assessmentResult.ifPresent(result ->
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(

View File

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

View File

@ -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<RateLimitChallengeListener> 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())),

View File

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

View File

@ -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<Arguments> 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<Object> 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<DynamicConfiguration> mockConfig(boolean enabled, double scoreFloor) {
final DynamicCaptchaConfiguration config = new DynamicCaptchaConfiguration();
config.setAllowHCaptcha(enabled);
config.setScoreFloor(BigDecimal.valueOf(scoreFloor));
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> m = mock(
DynamicConfigurationManager.class);
final DynamicConfiguration d = mock(DynamicConfiguration.class);
when(m.getConfiguration()).thenReturn(d);
when(d.getCaptchaConfiguration()).thenReturn(config);
return m;
}
}

View File

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

View File

@ -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",

View File

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

View File

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