Add hCaptcha support
This commit is contained in:
parent
dcec90fc52
commit
65ad3fe623
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
* 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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
|
||||
* 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();
|
||||
}
|
|
@ -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) {
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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())),
|
||||
|
|
|
@ -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));
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue