mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 17:48:04 +01:00
/v1/backup/auth/check endpoint added
This commit is contained in:
@@ -89,6 +89,7 @@ import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
@@ -112,7 +113,7 @@ import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundExcep
|
||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.MockHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
|
||||
@@ -172,7 +173,7 @@ class AccountControllerTest {
|
||||
|
||||
private byte[] registration_lock_key = new byte[32];
|
||||
|
||||
private static final SecureStorageServiceConfiguration STORAGE_CFG = MockHelper.buildMock(
|
||||
private static final SecureStorageServiceConfiguration STORAGE_CFG = MockUtils.buildMock(
|
||||
SecureStorageServiceConfiguration.class,
|
||||
cfg -> when(cfg.decodeUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]));
|
||||
|
||||
@@ -188,6 +189,7 @@ class AccountControllerTest {
|
||||
.addProvider(new RateLimitExceededExceptionMapper())
|
||||
.addProvider(new ImpossiblePhoneNumberExceptionMapper())
|
||||
.addProvider(new NonNormalizedPhoneNumberExceptionMapper())
|
||||
.addProvider(new RateLimitByIpFilter(rateLimiters))
|
||||
.setMapper(SystemMapper.getMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new AccountController(pendingAccountsManager,
|
||||
@@ -1949,13 +1951,13 @@ class AccountControllerTest {
|
||||
|
||||
@Test
|
||||
void testAccountExistsRateLimited() throws RateLimitExceededException {
|
||||
final Duration expectedRetryAfter = Duration.ofSeconds(13);
|
||||
final Account account = mock(Account.class);
|
||||
final UUID accountIdentifier = UUID.randomUUID();
|
||||
when(accountsManager.getByAccountIdentifier(accountIdentifier)).thenReturn(Optional.of(account));
|
||||
|
||||
final RateLimiter checkAccountLimiter = mock(RateLimiter.class);
|
||||
when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(checkAccountLimiter);
|
||||
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(checkAccountLimiter).validate("127.0.0.1");
|
||||
MockUtils.updateRateLimiterResponseToFail(
|
||||
rateLimiters, RateLimiters.Handle.CHECK_ACCOUNT_EXISTENCE, "127.0.0.1", expectedRetryAfter);
|
||||
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target(String.format("/v1/accounts/account/%s", accountIdentifier))
|
||||
@@ -1964,7 +1966,7 @@ class AccountControllerTest {
|
||||
.head();
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));
|
||||
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2018,7 +2020,9 @@ class AccountControllerTest {
|
||||
|
||||
@Test
|
||||
void testLookupUsernameRateLimited() throws RateLimitExceededException {
|
||||
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(usernameLookupLimiter).validate("127.0.0.1");
|
||||
final Duration expectedRetryAfter = Duration.ofSeconds(13);
|
||||
MockUtils.updateRateLimiterResponseToFail(
|
||||
rateLimiters, RateLimiters.Handle.USERNAME_LOOKUP, "127.0.0.1", expectedRetryAfter);
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/test.123")
|
||||
.request()
|
||||
@@ -2026,7 +2030,7 @@ class AccountControllerTest {
|
||||
.get();
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));
|
||||
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds()));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.apache.commons.lang3.RandomUtils;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class SecureBackupControllerTest {
|
||||
|
||||
private static final UUID USER_1 = UUID.randomUUID();
|
||||
|
||||
private static final UUID USER_2 = UUID.randomUUID();
|
||||
|
||||
private static final UUID USER_3 = UUID.randomUUID();
|
||||
|
||||
private static final String E164_VALID = "+18005550123";
|
||||
|
||||
private static final String E164_INVALID = "1(800)555-0123";
|
||||
|
||||
private static final byte[] SECRET = RandomUtils.nextBytes(32);
|
||||
|
||||
private static final SecureBackupServiceConfiguration CFG = MockUtils.buildMock(
|
||||
SecureBackupServiceConfiguration.class,
|
||||
cfg -> Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(SECRET)
|
||||
);
|
||||
|
||||
private static final MutableClock CLOCK = MockUtils.mutableClock(0);
|
||||
|
||||
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
|
||||
SecureBackupController.credentialsGenerator(CFG, CLOCK);
|
||||
|
||||
private static final AccountsManager ACCOUNTS_MANAGER = Mockito.mock(AccountsManager.class);
|
||||
|
||||
private static final SecureBackupController CONTROLLER =
|
||||
new SecureBackupController(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER);
|
||||
|
||||
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
.setMapper(SystemMapper.getMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(CONTROLLER)
|
||||
.build();
|
||||
|
||||
@BeforeAll
|
||||
public static void before() throws Exception {
|
||||
Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOneMatch() throws Exception {
|
||||
validate(Map.of(
|
||||
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
|
||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
|
||||
), day(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoMatch() throws Exception {
|
||||
validate(Map.of(
|
||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
|
||||
), day(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmptyInput() throws Exception {
|
||||
validate(Collections.emptyMap(), day(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSomeInvalid() throws Exception {
|
||||
final String fakeToken = token(USER_3, day(1)).replaceAll(USER_3.toString(), USER_2.toString());
|
||||
validate(Map.of(
|
||||
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
|
||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
||||
fakeToken, AuthCheckResponse.Result.INVALID
|
||||
), day(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSomeExpired() throws Exception {
|
||||
validate(Map.of(
|
||||
token(USER_1, day(100)), AuthCheckResponse.Result.MATCH,
|
||||
token(USER_2, day(100)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID,
|
||||
token(USER_3, day(20)), AuthCheckResponse.Result.INVALID
|
||||
), day(110));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSomeHaveNewerVersions() throws Exception {
|
||||
validate(Map.of(
|
||||
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
||||
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
||||
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
|
||||
), day(25));
|
||||
}
|
||||
|
||||
private static void validate(
|
||||
final Map<String, AuthCheckResponse.Result> expected,
|
||||
final long nowMillis) throws Exception {
|
||||
CLOCK.setTimeMillis(nowMillis);
|
||||
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
||||
final AuthCheckResponse response = CONTROLLER.authCheck(request);
|
||||
assertEquals(expected, response.matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeSuccess() throws Exception {
|
||||
final Map<String, AuthCheckResponse.Result> expected = Map.of(
|
||||
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
||||
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
||||
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
|
||||
);
|
||||
|
||||
CLOCK.setTimeMillis(day(25));
|
||||
|
||||
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
||||
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
|
||||
assertEquals(200, response.getStatus());
|
||||
assertEquals(expected, res.matches());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
|
||||
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
assertEquals(422, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenTooManyTokens() throws Exception {
|
||||
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
|
||||
));
|
||||
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
|
||||
));
|
||||
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
|
||||
|
||||
final Response responseOkay = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
|
||||
|
||||
final Response responseError1 = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
|
||||
|
||||
final Response responseError2 = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
|
||||
|
||||
try (responseOkay; responseError1; responseError2) {
|
||||
assertEquals(200, responseOkay.getStatus());
|
||||
assertEquals(422, responseError1.getStatus());
|
||||
assertEquals(422, responseError2.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenPasswordsMissing() throws Exception {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity("""
|
||||
{
|
||||
"number": "123"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
assertEquals(422, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenNumberMissing() throws Exception {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity("""
|
||||
{
|
||||
"passwords": ["aaa:bbb"]
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
assertEquals(422, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenExtraFields() throws Exception {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity("""
|
||||
{
|
||||
"number": "+18005550123",
|
||||
"passwords": ["aaa:bbb"],
|
||||
"unexpected": "value"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
assertEquals(200, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHttpResponseCodeWhenNotAJson() throws Exception {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target("/v1/backup/auth/check")
|
||||
.request()
|
||||
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
|
||||
|
||||
try (response) {
|
||||
assertEquals(400, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private static String token(final UUID uuid, final long timeMillis) {
|
||||
CLOCK.setTimeMillis(timeMillis);
|
||||
final ExternalServiceCredentials credentials = CREDENTIAL_GENERATOR.generateForUuid(uuid);
|
||||
return credentials.username() + ":" + credentials.password();
|
||||
}
|
||||
|
||||
private static long day(final int n) {
|
||||
return TimeUnit.DAYS.toMillis(n);
|
||||
}
|
||||
|
||||
private static Account account(final UUID uuid) {
|
||||
final Account a = new Account();
|
||||
a.setUuid(uuid);
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mockito;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
public class RateLimitedByIpTest {
|
||||
|
||||
private static final String IP = "70.130.130.200";
|
||||
|
||||
private static final String VALID_X_FORWARDED_FOR = "1.1.1.1," + IP;
|
||||
|
||||
private static final String INVALID_X_FORWARDED_FOR = "1.1.1.1,";
|
||||
|
||||
private static final Duration RETRY_AFTER = Duration.ofSeconds(100);
|
||||
|
||||
private static final Duration RETRY_AFTER_INVALID_HEADER = RateLimitByIpFilter.INVALID_HEADER_EXCEPTION
|
||||
.getRetryDuration()
|
||||
.orElseThrow();
|
||||
|
||||
|
||||
@Path("/test")
|
||||
public static class Controller {
|
||||
@GET
|
||||
@Path("/strict")
|
||||
@RateLimitedByIp(RateLimiters.Handle.BACKUP_AUTH_CHECK)
|
||||
public Response strict() {
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/loose")
|
||||
@RateLimitedByIp(value = RateLimiters.Handle.BACKUP_AUTH_CHECK, failOnUnresolvedIp = false)
|
||||
public Response loose() {
|
||||
return Response.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
private static final RateLimiter RATE_LIMITER = Mockito.mock(RateLimiter.class);
|
||||
|
||||
private static final RateLimiters RATE_LIMITERS = MockUtils.buildMock(RateLimiters.class, rl ->
|
||||
Mockito.when(rl.byHandle(Mockito.eq(RateLimiters.Handle.BACKUP_AUTH_CHECK))).thenReturn(Optional.of(RATE_LIMITER)));
|
||||
|
||||
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
|
||||
.setMapper(SystemMapper.getMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new Controller())
|
||||
.addProvider(new RateLimitByIpFilter(RATE_LIMITERS))
|
||||
.build();
|
||||
|
||||
@Test
|
||||
public void testRateLimits() throws Exception {
|
||||
Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP));
|
||||
validateSuccess("/test/strict", VALID_X_FORWARDED_FOR);
|
||||
Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER)).when(RATE_LIMITER).validate(Mockito.eq(IP));
|
||||
validateFailure("/test/strict", VALID_X_FORWARDED_FOR, RETRY_AFTER);
|
||||
Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP));
|
||||
validateSuccess("/test/strict", VALID_X_FORWARDED_FOR);
|
||||
Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER)).when(RATE_LIMITER).validate(Mockito.eq(IP));
|
||||
validateFailure("/test/strict", VALID_X_FORWARDED_FOR, RETRY_AFTER);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidHeader() throws Exception {
|
||||
Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP));
|
||||
validateSuccess("/test/strict", VALID_X_FORWARDED_FOR);
|
||||
validateFailure("/test/strict", INVALID_X_FORWARDED_FOR, RETRY_AFTER_INVALID_HEADER);
|
||||
validateFailure("/test/strict", "", RETRY_AFTER_INVALID_HEADER);
|
||||
|
||||
validateSuccess("/test/loose", VALID_X_FORWARDED_FOR);
|
||||
validateSuccess("/test/loose", INVALID_X_FORWARDED_FOR);
|
||||
validateSuccess("/test/loose", "");
|
||||
|
||||
// also checking that even if rate limiter is failing -- it doesn't matter in the case of invalid IP
|
||||
Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER)).when(RATE_LIMITER).validate(Mockito.anyString());
|
||||
validateFailure("/test/loose", VALID_X_FORWARDED_FOR, RETRY_AFTER);
|
||||
validateSuccess("/test/loose", INVALID_X_FORWARDED_FOR);
|
||||
validateSuccess("/test/loose", "");
|
||||
}
|
||||
|
||||
private static void validateSuccess(final String path, final String xff) {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target(path)
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, xff)
|
||||
.get();
|
||||
|
||||
assertEquals(200, response.getStatus());
|
||||
}
|
||||
|
||||
private static void validateFailure(final String path, final String xff, final Duration expectedRetryAfter) {
|
||||
final Response response = RESOURCES.getJerseyTest()
|
||||
.target(path)
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, xff)
|
||||
.get();
|
||||
|
||||
assertEquals(413, response.getStatus());
|
||||
assertEquals("" + expectedRetryAfter.getSeconds(), response.getHeaderString(HttpHeaders.RETRY_AFTER));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
|
||||
@@ -5,24 +5,39 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.tests.auth;
|
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||
|
||||
class ExternalServiceCredentialsGeneratorTest {
|
||||
|
||||
private static final String E164 = "+14152222222";
|
||||
|
||||
private static final long TIME_SECONDS = 12345;
|
||||
|
||||
private static final long TIME_MILLIS = TimeUnit.SECONDS.toMillis(TIME_SECONDS);
|
||||
|
||||
private static final String TIME_SECONDS_STRING = Long.toString(TIME_SECONDS);
|
||||
|
||||
|
||||
@Test
|
||||
void testGenerateDerivedUsername() {
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.withUserDerivationKey(new byte[32])
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor("+14152222222");
|
||||
|
||||
assertThat(credentials.username()).isNotEqualTo("+14152222222");
|
||||
assertThat(credentials.password().startsWith("+14152222222")).isFalse();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
assertNotEquals(credentials.username(), E164);
|
||||
assertFalse(credentials.password().startsWith(E164));
|
||||
assertEquals(credentials.password().split(":").length, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -30,10 +45,71 @@ class ExternalServiceCredentialsGeneratorTest {
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor("+14152222222");
|
||||
|
||||
assertThat(credentials.username()).isEqualTo("+14152222222");
|
||||
assertThat(credentials.password().startsWith("+14152222222")).isTrue();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
assertEquals(credentials.username(), E164);
|
||||
assertTrue(credentials.password().startsWith(E164));
|
||||
assertEquals(credentials.password().split(":").length, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotPrependUsername() throws Exception {
|
||||
final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS);
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.prependUsername(false)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
assertEquals(credentials.username(), E164);
|
||||
assertTrue(credentials.password().startsWith(TIME_SECONDS_STRING));
|
||||
assertEquals(credentials.password().split(":").length, 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateValid() throws Exception {
|
||||
final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS);
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.withClock(clock)
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
assertEquals(generator.validateAndGetTimestamp(credentials).orElseThrow(), TIME_SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateInvalid() throws Exception {
|
||||
final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS);
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.withClock(clock)
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
|
||||
final ExternalServiceCredentials corruptedUsername = new ExternalServiceCredentials(
|
||||
credentials.username(), credentials.password().replace(E164, E164 + "0"));
|
||||
final ExternalServiceCredentials corruptedTimestamp = new ExternalServiceCredentials(
|
||||
credentials.username(), credentials.password().replace(TIME_SECONDS_STRING, TIME_SECONDS_STRING + "0"));
|
||||
final ExternalServiceCredentials corruptedPassword = new ExternalServiceCredentials(
|
||||
credentials.username(), credentials.password() + "0");
|
||||
|
||||
assertTrue(generator.validateAndGetTimestamp(corruptedUsername).isEmpty());
|
||||
assertTrue(generator.validateAndGetTimestamp(corruptedTimestamp).isEmpty());
|
||||
assertTrue(generator.validateAndGetTimestamp(corruptedPassword).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateWithExpiration() throws Exception {
|
||||
final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS);
|
||||
final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator
|
||||
.builder(new byte[32])
|
||||
.withClock(clock)
|
||||
.build();
|
||||
final ExternalServiceCredentials credentials = generator.generateFor(E164);
|
||||
|
||||
final long elapsedSeconds = 10000;
|
||||
clock.incrementSeconds(elapsedSeconds);
|
||||
|
||||
assertEquals(generator.validateAndGetTimestamp(credentials, elapsedSeconds + 1).orElseThrow(), TIME_SECONDS);
|
||||
assertTrue(generator.validateAndGetTimestamp(credentials, elapsedSeconds - 1).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ import org.whispersystems.textsecuregcm.controllers.ArtController;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class ArtControllerTest {
|
||||
|
||||
private static final ArtServiceConfiguration ART_SERVICE_CONFIGURATION = MockHelper.buildMock(
|
||||
private static final ArtServiceConfiguration ART_SERVICE_CONFIGURATION = MockUtils.buildMock(
|
||||
ArtServiceConfiguration.class,
|
||||
cfg -> {
|
||||
Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]);
|
||||
|
||||
@@ -23,13 +23,13 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class SecureStorageControllerTest {
|
||||
|
||||
private static final SecureStorageServiceConfiguration STORAGE_CFG = MockHelper.buildMock(
|
||||
private static final SecureStorageServiceConfiguration STORAGE_CFG = MockUtils.buildMock(
|
||||
SecureStorageServiceConfiguration.class,
|
||||
cfg -> when(cfg.decodeUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]));
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class E164Test {
|
||||
|
||||
private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
private static final String E164_VALID = "+18005550123";
|
||||
|
||||
private static final String E164_INVALID = "1(800)555-0123";
|
||||
|
||||
private static final String EMPTY = "";
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private static class Data {
|
||||
|
||||
@E164
|
||||
private final String number;
|
||||
|
||||
private Data(final String number) {
|
||||
this.number = number;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Methods {
|
||||
|
||||
public void foo(@E164 final String number) {
|
||||
// noop
|
||||
}
|
||||
|
||||
@E164
|
||||
public String bar() {
|
||||
return "nevermind";
|
||||
}
|
||||
}
|
||||
|
||||
private record Rec(@E164 String number) {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRecord() throws Exception {
|
||||
checkNoViolations(new Rec(E164_VALID));
|
||||
checkHasViolations(new Rec(E164_INVALID));
|
||||
checkHasViolations(new Rec(EMPTY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClassField() throws Exception {
|
||||
checkNoViolations(new Data(E164_VALID));
|
||||
checkHasViolations(new Data(E164_INVALID));
|
||||
checkHasViolations(new Data(EMPTY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParameters() throws Exception {
|
||||
final Methods m = new Methods();
|
||||
final Method foo = Methods.class.getMethod("foo", String.class);
|
||||
|
||||
final Set<ConstraintViolation<Methods>> violations1 =
|
||||
VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_VALID});
|
||||
final Set<ConstraintViolation<Methods>> violations2 =
|
||||
VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_INVALID});
|
||||
final Set<ConstraintViolation<Methods>> violations3 =
|
||||
VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {EMPTY});
|
||||
|
||||
assertTrue(violations1.isEmpty());
|
||||
assertFalse(violations2.isEmpty());
|
||||
assertFalse(violations3.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReturnValue() throws Exception {
|
||||
final Methods m = new Methods();
|
||||
final Method bar = Methods.class.getMethod("bar");
|
||||
|
||||
final Set<ConstraintViolation<Methods>> violations1 =
|
||||
VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_VALID);
|
||||
final Set<ConstraintViolation<Methods>> violations2 =
|
||||
VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_INVALID);
|
||||
final Set<ConstraintViolation<Methods>> violations3 =
|
||||
VALIDATOR.forExecutables().validateReturnValue(m, bar, EMPTY);
|
||||
|
||||
assertTrue(violations1.isEmpty());
|
||||
assertFalse(violations2.isEmpty());
|
||||
assertFalse(violations3.isEmpty());
|
||||
}
|
||||
|
||||
private static <T> void checkNoViolations(final T object) {
|
||||
final Set<ConstraintViolation<T>> violations = VALIDATOR.validate(object);
|
||||
assertTrue(violations.isEmpty());
|
||||
}
|
||||
|
||||
private static <T> void checkHasViolations(final T object) {
|
||||
final Set<ConstraintViolation<T>> violations = VALIDATOR.validate(object);
|
||||
assertFalse(violations.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import org.mockito.Mockito;
|
||||
|
||||
public final class MockHelper {
|
||||
|
||||
private MockHelper() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MockInitializer<T> {
|
||||
|
||||
void init(T mock) throws Exception;
|
||||
}
|
||||
|
||||
public static <T> T buildMock(final Class<T> clazz, final MockInitializer<T> initializer) throws RuntimeException {
|
||||
final T mock = Mockito.mock(clazz);
|
||||
try {
|
||||
initializer.init(mock);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import org.mockito.Mockito;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
|
||||
public final class MockUtils {
|
||||
|
||||
private MockUtils() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MockInitializer<T> {
|
||||
|
||||
void init(T mock) throws Exception;
|
||||
}
|
||||
|
||||
public static <T> T buildMock(final Class<T> clazz, final MockInitializer<T> initializer) throws RuntimeException {
|
||||
final T mock = Mockito.mock(clazz);
|
||||
try {
|
||||
initializer.init(mock);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
public static MutableClock mutableClock(final long timeMillis) {
|
||||
return new MutableClock(timeMillis);
|
||||
}
|
||||
|
||||
public static void updateRateLimiterResponseToAllow(
|
||||
final RateLimiters rateLimitersMock,
|
||||
final RateLimiters.Handle handle,
|
||||
final String input) {
|
||||
final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);
|
||||
doReturn(Optional.of(mockRateLimiter)).when(rateLimitersMock).byHandle(eq(handle));
|
||||
try {
|
||||
doNothing().when(mockRateLimiter).validate(eq(input));
|
||||
} catch (final RateLimitExceededException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void updateRateLimiterResponseToFail(
|
||||
final RateLimiters rateLimitersMock,
|
||||
final RateLimiters.Handle handle,
|
||||
final String input,
|
||||
final Duration retryAfter) {
|
||||
final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);
|
||||
doReturn(Optional.of(mockRateLimiter)).when(rateLimitersMock).byHandle(eq(handle));
|
||||
try {
|
||||
doThrow(new RateLimitExceededException(retryAfter)).when(mockRateLimiter).validate(eq(input));
|
||||
} catch (final RateLimitExceededException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class MutableClock extends Clock {
|
||||
|
||||
private final AtomicReference<Clock> delegate;
|
||||
|
||||
|
||||
public MutableClock(final long timeMillis) {
|
||||
this(fixedTimeMillis(timeMillis));
|
||||
}
|
||||
|
||||
public MutableClock(final Clock clock) {
|
||||
this.delegate = new AtomicReference<>(clock);
|
||||
}
|
||||
|
||||
public MutableClock() {
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
public MutableClock setTimeMillis(final long timeMillis) {
|
||||
delegate.set(fixedTimeMillis(timeMillis));
|
||||
return this;
|
||||
}
|
||||
|
||||
public MutableClock incrementMillis(final long incrementMillis) {
|
||||
return increment(incrementMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public MutableClock incrementSeconds(final long incrementSeconds) {
|
||||
return increment(incrementSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public MutableClock increment(final long increment, final TimeUnit timeUnit) {
|
||||
final long current = delegate.get().instant().toEpochMilli();
|
||||
delegate.set(fixedTimeMillis(current + timeUnit.toMillis(increment)));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return delegate.get().getZone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(final ZoneId zone) {
|
||||
return delegate.get().withZone(zone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return delegate.get().instant();
|
||||
}
|
||||
|
||||
private static Clock fixedTimeMillis(final long timeMillis) {
|
||||
return Clock.fixed(Instant.ofEpochMilli(timeMillis), ZoneId.of("Etc/UTC"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user