Add a captcha short-code expander

This commit is contained in:
Ravi Khadiwala 2023-07-07 10:55:50 -05:00 committed by ravi-signal
parent 1dde612855
commit 3ac7aba6b2
7 changed files with 110 additions and 9 deletions

View File

@ -243,6 +243,9 @@ recaptcha:
hCaptcha:
apiKey: secret://hCaptcha.apiKey
shortCode:
baseUrl: https://example.com/shortcodes/
storageService:
uri: storage.example.com
userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret

View File

@ -50,6 +50,7 @@ import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
@ -202,6 +203,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private HCaptchaConfiguration hCaptcha;
@Valid
@NotNull
@JsonProperty
private ShortCodeExpanderConfiguration shortCode;
@Valid
@NotNull
@JsonProperty
@ -334,6 +340,10 @@ public class WhisperServerConfiguration extends Configuration {
return hCaptcha;
}
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
return shortCode;
}
public WebSocketConfiguration getWebSocketConfiguration() {
return webSocket;
}

View File

@ -83,6 +83,7 @@ import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;
@ -581,7 +582,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.connectTimeout(Duration.ofSeconds(10)).build();
HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey().value(), hcaptchaHttpClient,
dynamicConfigurationManager);
CaptchaChecker captchaChecker = new CaptchaChecker(List.of(recaptchaClient, hCaptchaClient));
HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10)).build();
ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());
CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, List.of(recaptchaClient, hCaptchaClient));
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
pushChallengeDynamoDb);

View File

@ -29,9 +29,15 @@ public class CaptchaChecker {
@VisibleForTesting
static final String SEPARATOR = ".";
private static final String SHORT_SUFFIX = "-short";
private final ShortCodeExpander shortCodeExpander;
private final Map<String, CaptchaClient> captchaClientMap;
public CaptchaChecker(final List<CaptchaClient> captchaClients) {
public CaptchaChecker(
final ShortCodeExpander shortCodeRetriever,
final List<CaptchaClient> captchaClients) {
this.shortCodeExpander = shortCodeRetriever;
this.captchaClientMap = captchaClients.stream()
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
}
@ -63,9 +69,17 @@ public class CaptchaChecker {
final String prefix = parts[0];
final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();
final String action = parts[2];
final String token = parts[3];
String token = parts[3];
final CaptchaClient client = this.captchaClientMap.get(prefix);
String provider = prefix;
if (prefix.endsWith(SHORT_SUFFIX)) {
// This is a "short" solution that points to the actual solution. We need to fetch the
// full solution before proceeding
provider = prefix.substring(0, prefix.length() - SHORT_SUFFIX.length());
token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException("invalid shortcode"));
}
final CaptchaClient client = this.captchaClientMap.get(provider);
if (client == null) {
throw new BadRequestException("invalid captcha scheme");
}
@ -92,7 +106,7 @@ public class CaptchaChecker {
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
"action", action,
"score", result.getScoreString(),
"provider", prefix)
"provider", provider)
.increment();
return result;
}

View File

@ -0,0 +1,49 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import io.micrometer.core.instrument.Metrics;
import org.apache.http.HttpStatus;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class ShortCodeExpander {
private static final String EXPAND_COUNTER_NAME = name(ShortCodeExpander.class, "expand");
private final HttpClient client;
private final URI shortenerHost;
public ShortCodeExpander(final HttpClient client, final String shortenerHost) {
this.client = client;
this.shortenerHost = URI.create(shortenerHost);
}
public Optional<String> retrieve(final String shortCode) throws IOException {
final URI uri = shortenerHost.resolve("/" + shortCode);
final HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build();
try {
final HttpResponse<String> response = this.client.send(request, HttpResponse.BodyHandlers.ofString());
Metrics.counter(EXPAND_COUNTER_NAME, "responseCode", Integer.toString(response.statusCode())).increment();
return switch (response.statusCode()) {
case HttpStatus.SC_OK -> Optional.of(response.body());
case HttpStatus.SC_NOT_FOUND -> Optional.empty();
default -> throw new IOException("Failed to look up shortcode");
};
} catch (InterruptedException e) {
throw new IOException(e);
}
}
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
public record ShortCodeExpanderConfiguration(String baseUrl) {
}

View File

@ -18,6 +18,7 @@ import static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import javax.ws.rs.BadRequestException;
import org.junit.jupiter.api.Test;
@ -77,7 +78,7 @@ public class CaptchaCheckerTest {
final String siteKey,
final Action expectedAction) throws IOException {
final CaptchaClient captchaClient = mockClient(PREFIX);
new CaptchaChecker(List.of(captchaClient)).verify(expectedAction, input, null);
new CaptchaChecker(null, List.of(captchaClient)).verify(expectedAction, input, null);
verify(captchaClient, times(1)).verify(eq(siteKey), eq(expectedAction), eq(expectedToken), any());
}
@ -105,10 +106,10 @@ public class CaptchaCheckerTest {
final CaptchaClient a = mockClient(PREFIX_A);
final CaptchaClient b = mockClient(PREFIX_B);
new CaptchaChecker(List.of(a, b)).verify(Action.CHALLENGE, ainput, null);
new CaptchaChecker(null, List.of(a, b)).verify(Action.CHALLENGE, ainput, null);
verify(a, times(1)).verify(any(), any(), any(), any());
new CaptchaChecker(List.of(a, b)).verify(Action.CHALLENGE, binput, null);
new CaptchaChecker(null, List.of(a, b)).verify(Action.CHALLENGE, binput, null);
verify(b, times(1)).verify(any(), any(), any(), any());
}
@ -130,7 +131,18 @@ public class CaptchaCheckerTest {
public void badArgs(final String input) throws IOException {
final CaptchaClient cc = mockClient(PREFIX);
assertThrows(BadRequestException.class,
() -> new CaptchaChecker(List.of(cc)).verify(Action.CHALLENGE, input, null));
() -> new CaptchaChecker(null, List.of(cc)).verify(Action.CHALLENGE, input, null));
}
@Test
public void testShortened() throws IOException {
final CaptchaClient captchaClient = mockClient(PREFIX);
final ShortCodeExpander retriever = mock(ShortCodeExpander.class);
when(retriever.retrieve("abc")).thenReturn(Optional.of(TOKEN));
final String input = String.join(SEPARATOR, PREFIX + "-short", REG_SITE_KEY, "registration", "abc");
new CaptchaChecker(retriever, List.of(captchaClient)).verify(Action.REGISTRATION, input, null);
verify(captchaClient, times(1)).verify(eq(REG_SITE_KEY), eq(Action.REGISTRATION), eq(TOKEN), any());
}
}