Add a captcha short-code expander
This commit is contained in:
parent
1dde612855
commit
3ac7aba6b2
|
@ -243,6 +243,9 @@ recaptcha:
|
||||||
hCaptcha:
|
hCaptcha:
|
||||||
apiKey: secret://hCaptcha.apiKey
|
apiKey: secret://hCaptcha.apiKey
|
||||||
|
|
||||||
|
shortCode:
|
||||||
|
baseUrl: https://example.com/shortcodes/
|
||||||
|
|
||||||
storageService:
|
storageService:
|
||||||
uri: storage.example.com
|
uri: storage.example.com
|
||||||
userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret
|
userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret
|
||||||
|
|
|
@ -50,6 +50,7 @@ import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||||
|
@ -202,6 +203,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private HCaptchaConfiguration hCaptcha;
|
private HCaptchaConfiguration hCaptcha;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private ShortCodeExpanderConfiguration shortCode;
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
|
@ -334,6 +340,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
return hCaptcha;
|
return hCaptcha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
|
||||||
|
return shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
public WebSocketConfiguration getWebSocketConfiguration() {
|
public WebSocketConfiguration getWebSocketConfiguration() {
|
||||||
return webSocket;
|
return webSocket;
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,6 +83,7 @@ import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
|
||||||
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
||||||
import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
|
import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
|
||||||
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
||||||
|
import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore;
|
import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore;
|
||||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;
|
import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;
|
||||||
|
@ -581,7 +582,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||||
HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey().value(), hcaptchaHttpClient,
|
HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey().value(), hcaptchaHttpClient,
|
||||||
dynamicConfigurationManager);
|
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,
|
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
|
||||||
pushChallengeDynamoDb);
|
pushChallengeDynamoDb);
|
||||||
|
|
|
@ -29,9 +29,15 @@ public class CaptchaChecker {
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final String SEPARATOR = ".";
|
static final String SEPARATOR = ".";
|
||||||
|
|
||||||
|
private static final String SHORT_SUFFIX = "-short";
|
||||||
|
|
||||||
|
private final ShortCodeExpander shortCodeExpander;
|
||||||
private final Map<String, CaptchaClient> captchaClientMap;
|
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()
|
this.captchaClientMap = captchaClients.stream()
|
||||||
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
|
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
|
||||||
}
|
}
|
||||||
|
@ -63,9 +69,17 @@ public class CaptchaChecker {
|
||||||
final String prefix = parts[0];
|
final String prefix = parts[0];
|
||||||
final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();
|
final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();
|
||||||
final String action = parts[2];
|
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) {
|
if (client == null) {
|
||||||
throw new BadRequestException("invalid captcha scheme");
|
throw new BadRequestException("invalid captcha scheme");
|
||||||
}
|
}
|
||||||
|
@ -92,7 +106,7 @@ public class CaptchaChecker {
|
||||||
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
|
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
|
||||||
"action", action,
|
"action", action,
|
||||||
"score", result.getScoreString(),
|
"score", result.getScoreString(),
|
||||||
"provider", prefix)
|
"provider", provider)
|
||||||
.increment();
|
.increment();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
@ -77,7 +78,7 @@ public class CaptchaCheckerTest {
|
||||||
final String siteKey,
|
final String siteKey,
|
||||||
final Action expectedAction) throws IOException {
|
final Action expectedAction) throws IOException {
|
||||||
final CaptchaClient captchaClient = mockClient(PREFIX);
|
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());
|
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 a = mockClient(PREFIX_A);
|
||||||
final CaptchaClient b = mockClient(PREFIX_B);
|
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());
|
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());
|
verify(b, times(1)).verify(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +131,18 @@ public class CaptchaCheckerTest {
|
||||||
public void badArgs(final String input) throws IOException {
|
public void badArgs(final String input) throws IOException {
|
||||||
final CaptchaClient cc = mockClient(PREFIX);
|
final CaptchaClient cc = mockClient(PREFIX);
|
||||||
assertThrows(BadRequestException.class,
|
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());
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue