From f3457502a62145c8212866c920363bbd97c6835b Mon Sep 17 00:00:00 2001 From: Chris Eager Date: Wed, 23 Feb 2022 15:23:37 -0800 Subject: [PATCH] Support different v2 captcha actions --- .../recaptcha/EnterpriseRecaptchaClient.java | 23 ++++-- .../TransitionalRecaptchaClient.java | 27 ++++++- .../TransitionalRecaptchaClientTest.java | 79 +++++++++++++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClientTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClient.java index 32a09fa0f..6bb5416b2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/EnterpriseRecaptchaClient.java @@ -11,13 +11,14 @@ import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceClient; import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSettings; import com.google.recaptchaenterprise.v1.Assessment; import com.google.recaptchaenterprise.v1.Event; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Objects; -import javax.annotation.Nonnull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class EnterpriseRecaptchaClient implements RecaptchaClient { private static final Logger logger = LoggerFactory.getLogger(EnterpriseRecaptchaClient.class); @@ -47,12 +48,20 @@ public class EnterpriseRecaptchaClient implements RecaptchaClient { @Override public boolean verify(final String token, final String ip) { - Event event = Event.newBuilder() - .setExpectedAction("challenge") + return verify(token, ip, null); + } + + public boolean verify(final String token, final String ip, @Nullable final String expectedAction) { + Event.Builder eventBuilder = Event.newBuilder() .setSiteKey(siteKey) .setToken(token) - .setUserIpAddress(ip) - .build(); + .setUserIpAddress(ip); + + if (expectedAction != null) { + eventBuilder.setExpectedAction(expectedAction); + } + + final Event event = eventBuilder.build(); final Assessment assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build()); return assessment.getTokenProperties().getValid() && assessment.getRiskAnalysis().getScore() >= scoreFloor; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClient.java index 9def44cb9..4ca8a8158 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClient.java @@ -5,12 +5,16 @@ package org.whispersystems.textsecuregcm.recaptcha; +import com.google.common.annotations.VisibleForTesting; import java.util.Objects; import javax.annotation.Nonnull; public class TransitionalRecaptchaClient implements RecaptchaClient { - private static final String V2_PREFIX = "signal-recaptcha-v2:"; + @VisibleForTesting + static final String SEPARATOR = "."; + @VisibleForTesting + static final String V2_PREFIX = "signal-recaptcha-v2" + SEPARATOR; private final LegacyRecaptchaClient legacyRecaptchaClient; private final EnterpriseRecaptchaClient enterpriseRecaptchaClient; @@ -25,9 +29,28 @@ public class TransitionalRecaptchaClient implements RecaptchaClient { @Override public boolean verify(@Nonnull final String token, @Nonnull final String ip) { if (token.startsWith(V2_PREFIX)) { - return enterpriseRecaptchaClient.verify(token.substring(V2_PREFIX.length()), ip); + final String[] actionAndToken = parseV2ActionAndToken(token.substring(V2_PREFIX.length())); + return enterpriseRecaptchaClient.verify(actionAndToken[1], ip, actionAndToken[0]); } else { return legacyRecaptchaClient.verify(token, ip); } } + + /** + * Parses the token and action (if any) from {@code input}. The expected input format is: {@code [action:]token}. + *

+ * For action to be optional, there is a strong assumption that the token will never contain a {@value SEPARATOR}. + * Observation suggests {@code token} is base-64 encoded. In practice, an action should always be present, but we + * don’t need to be strict. + */ + static String[] parseV2ActionAndToken(final String input) { + String[] actionAndToken = input.split("\\" + SEPARATOR, 2); + + if (actionAndToken.length == 1) { + // there was no ":" delimiter; assume we only have a token + return new String[]{null, actionAndToken[0]}; + } + + return actionAndToken; + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClientTest.java new file mode 100644 index 000000000..1e2862da9 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/recaptcha/TransitionalRecaptchaClientTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.recaptcha; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.whispersystems.textsecuregcm.recaptcha.TransitionalRecaptchaClient.SEPARATOR; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TransitionalRecaptchaClientTest { + + private TransitionalRecaptchaClient transitionalRecaptchaClient; + private EnterpriseRecaptchaClient enterpriseRecaptchaClient; + private LegacyRecaptchaClient legacyRecaptchaClient; + + private static final String PREFIX = TransitionalRecaptchaClient.V2_PREFIX.substring(0, + TransitionalRecaptchaClient.V2_PREFIX.lastIndexOf(SEPARATOR)); + private static final String TOKEN = "some-token"; + private static final String IP_ADDRESS = "127.0.0.1"; + + @BeforeEach + void setup() { + enterpriseRecaptchaClient = mock(EnterpriseRecaptchaClient.class); + legacyRecaptchaClient = mock(LegacyRecaptchaClient.class); + transitionalRecaptchaClient = new TransitionalRecaptchaClient(legacyRecaptchaClient, enterpriseRecaptchaClient); + } + + @ParameterizedTest + @MethodSource + void testVerify(final String inputToken, final boolean expectLegacy, final String expectedToken, + final String expectedAction) { + + transitionalRecaptchaClient.verify(inputToken, IP_ADDRESS); + + if (expectLegacy) { + verifyNoInteractions(enterpriseRecaptchaClient); + verify(legacyRecaptchaClient).verify(expectedToken, IP_ADDRESS); + } else { + verifyNoInteractions(legacyRecaptchaClient); + verify(enterpriseRecaptchaClient).verify(expectedToken, IP_ADDRESS, expectedAction); + } + + } + + static Stream testVerify() { + return Stream.of( + Arguments.of( + TOKEN, + true, + TOKEN, + null), + Arguments.of( + String.join(SEPARATOR, PREFIX, TOKEN), + false, + TOKEN, + null), + Arguments.of( + String.join(SEPARATOR, PREFIX, "an-action", TOKEN), + false, + TOKEN, + "an-action"), + Arguments.of( + String.join(SEPARATOR, PREFIX, "an-action", TOKEN, "something-else"), + false, + TOKEN + SEPARATOR + "something-else", + "an-action") + ); + } + +}