From 975f753c2bed4ef4f6b43ef430d577516b497475 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Thu, 11 Nov 2021 12:32:11 -0500 Subject: [PATCH] Add an endpoint for testing whether an account with a given ACI or PNI exists --- .../RateLimitsConfiguration.java | 7 +++ .../controllers/AccountController.java | 32 +++++++++++++ .../textsecuregcm/limits/RateLimiters.java | 10 ++++ .../controllers/AccountControllerTest.java | 47 +++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java index d37fe35ee..da493bb46 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java @@ -63,6 +63,9 @@ public class RateLimitsConfiguration { @JsonProperty private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); + @JsonProperty + private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0); + public RateLimitConfiguration getAutoBlock() { return autoBlock; } @@ -135,6 +138,10 @@ public class RateLimitsConfiguration { return usernameSet; } + public RateLimitConfiguration getCheckAccountExistence() { + return checkAccountExistence; + } + public static class RateLimitConfiguration { @JsonProperty private int bucketSize; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index bed99e47d..66c8ef4bb 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -15,6 +15,7 @@ import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -22,13 +23,17 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; import javax.validation.Valid; +import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.HEAD; import javax.ws.rs.HeaderParam; import javax.ws.rs.PUT; import javax.ws.rs.Path; @@ -36,8 +41,11 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; @@ -627,6 +635,30 @@ public class AccountController { return Response.ok().build(); } + @HEAD + @Path("/account/{uuid}") + public Response accountExists( + @HeaderParam("X-Forwarded-For") final String forwardedFor, + @PathParam("uuid") final UUID uuid, + @Context HttpServletRequest request) throws RateLimitExceededException { + + // Disallow clients from making authenticated requests to this endpoint + if (StringUtils.isNotBlank(request.getHeader("Authorization"))) { + throw new BadRequestException(); + } + + final String mostRecentProxy = ForwardedIpUtil.getMostRecentProxy(forwardedFor) + .orElseThrow(() -> new RateLimitExceededException(Duration.ofHours(1))); + + rateLimiters.getCheckAccountExistenceLimiter().validate(mostRecentProxy); + + final Status status = accounts.getByAccountIdentifier(uuid) + .or(() -> accounts.getByPhoneNumberIdentifier(uuid)) + .isPresent() ? Status.OK : Status.NOT_FOUND; + + return Response.status(status).build(); + } + private void verifyRegistrationLock(final Account existingAccount, @Nullable final String clientRegistrationLock) throws RateLimitExceededException, WebApplicationException { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index b789b6c62..176f78b1d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -39,6 +39,8 @@ public class RateLimiters { private final RateLimiter usernameLookupLimiter; private final RateLimiter usernameSetLimiter; + private final RateLimiter checkAccountExistenceLimiter; + private final AtomicReference unsealedSenderCardinalityLimiter; private final AtomicReference unsealedIpLimiter; private final AtomicReference rateLimitResetLimiter; @@ -127,6 +129,10 @@ public class RateLimiters { config.getUsernameSet().getBucketSize(), config.getUsernameSet().getLeakRatePerMinute()); + this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence", + config.getCheckAccountExistence().getBucketSize(), + config.getCheckAccountExistence().getLeakRatePerMinute()); + this.dailyPreKeysLimiter = new AtomicReference<>(createDailyPreKeysLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getDailyPreKeys())); this.unsealedSenderCardinalityLimiter = new AtomicReference<>(createUnsealedSenderCardinalityLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getUnsealedSenderNumber())); @@ -287,6 +293,10 @@ public class RateLimiters { return usernameSetLimiter; } + public RateLimiter getCheckAccountExistenceLimiter() { + return checkAccountExistenceLimiter; + } + private CardinalityRateLimiter createUnsealedSenderCardinalityLimiter(FaultTolerantRedisCluster cacheCluster, CardinalityRateLimitConfiguration configuration) { return new CardinalityRateLimiter(cacheCluster, "unsealedSender", configuration.getTtl(), configuration.getMaxCardinality()); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java index 06778915b..a3ac8812f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java @@ -1749,4 +1749,51 @@ class AccountControllerTest { Arguments.of("captcha enforced", true, Set.of("1"), 402) ); } + + @Test + void testAccountExists() { + final Account account = mock(Account.class); + + final UUID accountIdentifier = UUID.randomUUID(); + final UUID phoneNumberIdentifier = UUID.randomUUID(); + + when(accountsManager.getByAccountIdentifier(any())).thenReturn(Optional.empty()); + when(accountsManager.getByAccountIdentifier(accountIdentifier)).thenReturn(Optional.of(account)); + when(accountsManager.getByPhoneNumberIdentifier(any())).thenReturn(Optional.empty()); + when(accountsManager.getByPhoneNumberIdentifier(phoneNumberIdentifier)).thenReturn(Optional.of(account)); + + when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(mock(RateLimiter.class)); + + assertThat(resources.getJerseyTest() + .target(String.format("/v1/accounts/account/%s", accountIdentifier)) + .request() + .header("X-Forwarded-For", "127.0.0.1") + .head() + .getStatus()).isEqualTo(200); + + assertThat(resources.getJerseyTest() + .target(String.format("/v1/accounts/account/%s", phoneNumberIdentifier)) + .request() + .header("X-Forwarded-For", "127.0.0.1") + .head() + .getStatus()).isEqualTo(200); + + assertThat(resources.getJerseyTest() + .target(String.format("/v1/accounts/account/%s", UUID.randomUUID())) + .request() + .header("X-Forwarded-For", "127.0.0.1") + .head() + .getStatus()).isEqualTo(404); + } + + @Test + void testAccountExistsAuthenticated() { + assertThat(resources.getJerseyTest() + .target(String.format("/v1/accounts/account/%s", UUID.randomUUID())) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .header("X-Forwarded-For", "127.0.0.1") + .head() + .getStatus()).isEqualTo(400); + } }