Allow use of the token returned with spam challenges as auth for the challenge verification request
This commit is contained in:
parent
ef1a8fc50f
commit
098b177bd3
|
@ -61,6 +61,8 @@ cdn.accessSecret: test # AWS Access Secret
|
|||
unidentifiedDelivery.certificate: ABCD1234
|
||||
unidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
|
||||
|
||||
challengeToken.blindingSecret: c3VwZXIgc2VjcmV0IGtleQ==
|
||||
|
||||
hCaptcha.apiKey: unset
|
||||
|
||||
storageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
|
|
|
@ -230,6 +230,10 @@ unidentifiedDelivery:
|
|||
privateKey: secret://unidentifiedDelivery.privateKey
|
||||
expiresDays: 7
|
||||
|
||||
challenge:
|
||||
blindingSecret: secret://challengeToken.blindingSecret
|
||||
tokenTtl: PT10M
|
||||
|
||||
recaptcha:
|
||||
projectPath: projects/example
|
||||
credentialConfigurationJson: "{ }" # service account configuration for backend authentication
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguratio
|
|||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||
|
@ -186,6 +187,11 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
@JsonProperty
|
||||
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private ChallengeConfiguration challenge;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
|
@ -295,6 +301,10 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
return dynamoDbTables;
|
||||
}
|
||||
|
||||
public ChallengeConfiguration getChallengeConfiguration() {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
public RecaptchaConfiguration getRecaptchaConfiguration() {
|
||||
return recaptcha;
|
||||
}
|
||||
|
|
|
@ -185,6 +185,7 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
|||
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
|
@ -561,7 +562,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
|
||||
pushChallengeDynamoDb);
|
||||
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||
captchaChecker, rateLimiters);
|
||||
captchaChecker,
|
||||
rateLimiters);
|
||||
final ChallengeTokenBlinder challengeTokenBlinder = new ChallengeTokenBlinder(config.getChallengeConfiguration(), Clock.systemUTC());
|
||||
|
||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||
|
||||
|
@ -709,7 +712,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().domain(), config.getGcpAttachmentsConfiguration().email(), config.getGcpAttachmentsConfiguration().maxSizeInBytes(), config.getGcpAttachmentsConfiguration().pathPrefix(), config.getGcpAttachmentsConfiguration().rsaSigningKey().value()),
|
||||
new CallLinkController(rateLimiters, genericZkSecretParams),
|
||||
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), zkAuthOperations, genericZkSecretParams, clock),
|
||||
new ChallengeController(rateLimitChallengeManager),
|
||||
new ChallengeController(accounts, challengeTokenBlinder, rateLimitChallengeManager),
|
||||
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright 2021-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
|
||||
public record ChallengeConfiguration(SecretBytes blindingSecret, Duration tokenTtl) {
|
||||
}
|
|
@ -29,6 +29,7 @@ import javax.ws.rs.POST;
|
|||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
|
@ -38,18 +39,27 @@ import org.whispersystems.textsecuregcm.entities.AnswerRecaptchaChallengeRequest
|
|||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Accounts;
|
||||
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
@Path("/v1/challenge")
|
||||
@Tag(name = "Challenge")
|
||||
public class ChallengeController {
|
||||
|
||||
private final Accounts accounts;
|
||||
private final ChallengeTokenBlinder tokenBlinder;
|
||||
private final RateLimitChallengeManager rateLimitChallengeManager;
|
||||
|
||||
private static final String CHALLENGE_RESPONSE_COUNTER_NAME = name(ChallengeController.class, "challengeResponse");
|
||||
private static final String CHALLENGE_TYPE_TAG = "type";
|
||||
|
||||
public ChallengeController(final RateLimitChallengeManager rateLimitChallengeManager) {
|
||||
public ChallengeController(final Accounts accounts,
|
||||
final ChallengeTokenBlinder tokenBlinder,
|
||||
final RateLimitChallengeManager rateLimitChallengeManager) {
|
||||
this.accounts = accounts;
|
||||
this.tokenBlinder = tokenBlinder;
|
||||
this.rateLimitChallengeManager = rateLimitChallengeManager;
|
||||
}
|
||||
|
||||
|
@ -63,18 +73,20 @@ public class ChallengeController {
|
|||
Some server endpoints (the "send message" endpoint, for example) may return a 428 response indicating the client must complete a challenge before continuing.
|
||||
Clients may use this endpoint to provide proof of a completed challenge. If successful, the client may then
|
||||
continue their original operation.
|
||||
This endpoint permits unauthenticated calls if the `token` that was provided by the server with the original 428 response is supplied in the request body.
|
||||
""",
|
||||
requestBody = @RequestBody(content = {@Content(schema = @Schema(oneOf = {AnswerPushChallengeRequest.class,
|
||||
AnswerRecaptchaChallengeRequest.class}))})
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Indicates the challenge proof was accepted")
|
||||
@ApiResponse(responseCode = "401", description = "Indicates authentication or token from original challenge are required")
|
||||
@ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth,
|
||||
public Response handleChallengeResponse(@Auth final Optional<AuthenticatedAccount> maybeAuth,
|
||||
@Valid final AnswerChallengeRequest answerRequest,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
|
||||
|
@ -85,13 +97,20 @@ public class ChallengeController {
|
|||
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
|
||||
|
||||
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
|
||||
rateLimitChallengeManager.answerPushChallenge(
|
||||
maybeAuth.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)).getAccount(),
|
||||
pushChallengeRequest.getChallenge());
|
||||
} else if (answerRequest instanceof AnswerRecaptchaChallengeRequest recaptchaChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "recaptcha");
|
||||
|
||||
final Account account = maybeAuth
|
||||
.map(AuthenticatedAccount::getAccount)
|
||||
.or(() -> tokenBlinder.unblindAccountToken(recaptchaChallengeRequest.getToken()).flatMap(accounts::getByAccountIdentifier))
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(() -> new BadRequestException());
|
||||
boolean success = rateLimitChallengeManager.answerRecaptchaChallenge(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
recaptchaChallengeRequest.getCaptcha(),
|
||||
mostRecentProxy,
|
||||
userAgent);
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
|
@ -9,6 +10,7 @@ public class RateLimitChallenge {
|
|||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@Schema(description="An opaque token to be included along with the challenge result in the verification request")
|
||||
private final String token;
|
||||
|
||||
@JsonProperty
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.ProviderException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.AEADBadTagException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
|
||||
|
||||
public class ChallengeTokenBlinder {
|
||||
|
||||
private record Token(
|
||||
UUID uuid,
|
||||
Instant timestamp) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper mapper = SystemMapper.jsonMapper();
|
||||
private final Clock clock;
|
||||
private final Duration tokenTtl;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
private final SecretKey blindingKey;
|
||||
|
||||
public ChallengeTokenBlinder(final ChallengeConfiguration config, final Clock clock) {
|
||||
this.blindingKey = new SecretKeySpec(config.blindingSecret().value(), "AES");
|
||||
this.tokenTtl = config.tokenTtl();
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public String generateBlindedAccountToken(UUID aci) {
|
||||
|
||||
final Token token = new Token(aci, clock.instant());
|
||||
final byte[] serializedToken;
|
||||
try {
|
||||
serializedToken = mapper.writeValueAsBytes(token);
|
||||
} catch (IOException e) { // should really, really never happen
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
final byte[] iv = new byte[12];
|
||||
secureRandom.nextBytes(iv);
|
||||
final GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv);
|
||||
|
||||
try {
|
||||
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, blindingKey, parameterSpec);
|
||||
final byte[] ciphertext = cipher.doFinal(serializedToken);
|
||||
|
||||
final ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + ciphertext.length);
|
||||
byteBuffer.put(iv);
|
||||
byteBuffer.put(ciphertext);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(byteBuffer.array());
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<UUID> unblindAccountToken(String token) {
|
||||
final byte[] ciphertext;
|
||||
try {
|
||||
ciphertext = Base64.getUrlDecoder().decode(token);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final Token parsedToken;
|
||||
|
||||
try {
|
||||
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
final GCMParameterSpec parameterSpec = new GCMParameterSpec(128, ciphertext, 0, 12);
|
||||
cipher.init(Cipher.DECRYPT_MODE, blindingKey, parameterSpec);
|
||||
|
||||
parsedToken = mapper.readValue(cipher.doFinal(ciphertext, 12, ciphertext.length - 12), Token.class);
|
||||
} catch (ProviderException | AEADBadTagException | JsonProcessingException e) {
|
||||
// the token doesn't successfully decrypt with this key, it's bogus (or from an older server version or before a key rotation)
|
||||
return Optional.empty();
|
||||
} catch (IOException | GeneralSecurityException e) { // should never happen
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
Instant now = clock.instant();
|
||||
Instant intervalStart = now.minus(tokenTtl);
|
||||
Instant tokenTime = parsedToken.timestamp();
|
||||
if (tokenTime.isAfter(now) || tokenTime.isBefore(intervalStart)) {
|
||||
// expired or fraudulently-future token
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(parsedToken.uuid());
|
||||
}
|
||||
|
||||
}
|
|
@ -21,8 +21,13 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
|||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
|
@ -31,18 +36,30 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.configuration.ChallengeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.storage.Accounts;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.ChallengeTokenBlinder;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class ChallengeControllerTest {
|
||||
|
||||
private static final RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class);
|
||||
|
||||
private static final ChallengeController challengeController = new ChallengeController(rateLimitChallengeManager);
|
||||
private static final Accounts accounts = mock(Accounts.class);
|
||||
|
||||
private static final TestClock clock = TestClock.now();
|
||||
|
||||
private static final ChallengeTokenBlinder tokenBlinder = new ChallengeTokenBlinder(
|
||||
new ChallengeConfiguration(new SecretBytes("super secret key".getBytes()), Duration.ofMinutes(10)),
|
||||
clock);
|
||||
|
||||
private static final ChallengeController challengeController = new ChallengeController(accounts, tokenBlinder, rateLimitChallengeManager);
|
||||
|
||||
private static final ResourceExtension EXTENSION = ResourceExtension.builder()
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
|
@ -56,7 +73,9 @@ class ChallengeControllerTest {
|
|||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
reset(accounts);
|
||||
reset(rateLimitChallengeManager);
|
||||
clock.unpin();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -188,6 +207,181 @@ class ChallengeControllerTest {
|
|||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaWithTokenAuth() throws Exception {
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "%s",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
|
||||
|
||||
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(200, response.getStatus());
|
||||
|
||||
verify(rateLimitChallengeManager).answerRecaptchaChallenge(eq(AuthHelper.VALID_ACCOUNT), eq("The value of the solved captcha token"), eq("10.0.0.1"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaWithExpiredToken() throws Exception {
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "%s",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
|
||||
|
||||
clock.pin(clock.instant().plus(Duration.ofMinutes(20)));
|
||||
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaWithPostdatedToken() throws Exception {
|
||||
clock.pin(clock.instant());
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "%s",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""".formatted(tokenBlinder.generateBlindedAccountToken(AuthHelper.VALID_UUID));
|
||||
|
||||
clock.pin(clock.instant().minus(Duration.ofMinutes(1)));
|
||||
when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaNoAuthNonBase64Token() throws Exception {
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "This is not a valid auth token",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""";
|
||||
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaNoAuthValidBase64Token() throws Exception {
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "Y2x1Y2sgY2x1Y2ssIGknbSBhIHBhcnJvdAo=",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""";
|
||||
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaNoAuthTokenEncryptedWithWrongKey() throws Exception {
|
||||
final String badToken =
|
||||
new ChallengeTokenBlinder(
|
||||
new ChallengeConfiguration(new SecretBytes("oh no, wrong key".getBytes()), Duration.ofMinutes(10)),
|
||||
clock)
|
||||
.generateBlindedAccountToken(AuthHelper.VALID_UUID);
|
||||
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "%s",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""".formatted(badToken);
|
||||
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleRecaptchaWithTokenForBadAccount() throws Exception {
|
||||
final UUID badUUID = UUID.randomUUID();
|
||||
final String recaptchaChallengeJson = """
|
||||
{
|
||||
"type": "recaptcha",
|
||||
"token": "%s",
|
||||
"captcha": "The value of the solved captcha token"
|
||||
}
|
||||
""".formatted(tokenBlinder.generateBlindedAccountToken(badUUID));
|
||||
|
||||
when(accounts.getByAccountIdentifier(badUUID)).thenReturn(Optional.empty());
|
||||
when(rateLimitChallengeManager.answerRecaptchaChallenge(any(), any(), any(), any()))
|
||||
.thenReturn(true);
|
||||
|
||||
final Response response = EXTENSION.target("/v1/challenge")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1")
|
||||
.put(Entity.json(recaptchaChallengeJson));
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
|
||||
verifyNoInteractions(rateLimitChallengeManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleUnrecognizedAnswer() {
|
||||
final String unrecognizedJson = """
|
||||
|
|
Loading…
Reference in New Issue