Add support for generating discriminators

- adds `PUT accounts/username` endpoint
- adds `GET accounts/username/{username}` to lookup aci by username
- deletes `PUT accounts/username/{username}`, `GET profile/username/{username}`
- adds randomized discriminator generation
This commit is contained in:
ravi-signal
2022-08-15 10:44:36 -05:00
committed by GitHub
parent 24d01f1ab2
commit a84a7dbc3d
27 changed files with 989 additions and 274 deletions

View File

@@ -80,8 +80,6 @@ import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.entities.BaseProfileResponse;
@@ -362,23 +360,6 @@ class ProfileControllerTest {
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void testProfileGetByUsername() throws RateLimitExceededException {
BaseProfileResponse profile = resources.getJerseyTest()
.target("/v1/profile/username/n00bkiller")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo("bar");
assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_UUID_TWO);
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
verify(accountsManager).getByUsername("n00bkiller");
verify(usernameRateLimiter, times(1)).validate(eq(AuthHelper.VALID_UUID));
}
@Test
void testProfileGetUnauthorized() {
Response response = resources.getJerseyTest()
@@ -389,31 +370,6 @@ class ProfileControllerTest {
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void testProfileGetByUsernameUnauthorized() {
Response response = resources.getJerseyTest()
.target("/v1/profile/username/n00bkiller")
.request()
.get();
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void testProfileGetByUsernameNotFound() throws RateLimitExceededException {
Response response = resources.getJerseyTest()
.target("/v1/profile/username/n00bkillerzzzzz")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get();
assertThat(response.getStatus()).isEqualTo(404);
verify(accountsManager).getByUsername("n00bkillerzzzzz");
verify(usernameRateLimiter).validate(eq(AuthHelper.VALID_UUID));
}
@Test
void testProfileGetDisabled() {

View File

@@ -27,11 +27,13 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
@@ -197,6 +199,8 @@ class AccountsManagerChangeNumberIntegrationTest {
secureStorageClient,
secureBackupClient,
clientPresenceManager,
mock(UsernameGenerator.class),
mock(ExperimentEnrollmentManager.class),
mock(Clock.class));
}
}

View File

@@ -42,6 +42,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
@@ -50,6 +51,7 @@ import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
import org.whispersystems.textsecuregcm.tests.util.JsonHelpers;
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
@@ -164,6 +166,8 @@ class AccountsManagerConcurrentModificationIntegrationTest {
mock(SecureStorageClient.class),
mock(SecureBackupClient.class),
mock(ClientPresenceManager.class),
mock(UsernameGenerator.class),
mock(ExperimentEnrollmentManager.class),
mock(Clock.class)
);
}

View File

@@ -10,10 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.AdditionalMatchers.and;
import static org.mockito.AdditionalMatchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
@@ -43,11 +46,14 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentMatcher;
import org.mockito.stubbing.Answer;
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
@@ -55,6 +61,7 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
class AccountsManagerTest {
@@ -65,6 +72,7 @@ class AccountsManagerTest {
private MessagesManager messagesManager;
private ProfilesManager profilesManager;
private ReservedUsernames reservedUsernames;
private ExperimentEnrollmentManager enrollmentManager;
private Map<String, UUID> phoneNumberIdentifiersByE164;
@@ -129,6 +137,10 @@ class AccountsManagerTest {
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
enrollmentManager = mock(ExperimentEnrollmentManager.class);
when(enrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME))).thenReturn(true);
when(accounts.usernameAvailable(any())).thenReturn(true);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
@@ -143,6 +155,8 @@ class AccountsManagerTest {
storageClient,
backupClient,
mock(ClientPresenceManager.class),
new UsernameGenerator(new UsernameConfiguration()),
enrollmentManager,
mock(Clock.class));
}
@@ -716,45 +730,82 @@ class AccountsManagerTest {
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
void testSetUsername() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "test";
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
verify(accounts).setUsername(account, username);
final String nickname = "test";
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@Test
void testSetUsernameSameUsername() throws UsernameNotAvailableException {
void testSetUsernameSameUsername() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "test";
account.setUsername(username);
final String nickname = "test";
account.setUsername(nickname + "#123");
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
// should be treated as a replayed request
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
verify(accounts, never()).setUsername(eq(account), any());
}
@Test
void testSetUsernameNotAvailable() throws UsernameNotAvailableException {
void testSetUsernameReroll() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "test";
final String nickname = "test";
final String username = nickname + "#ZZZ";
account.setUsername(username);
doThrow(new UsernameNotAvailableException()).when(accounts).setUsername(account, username);
// given the correct old username, should reroll discriminator even if the nick matches
accountsManager.setUsername(account, nickname, username);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), not(eq(username))));
}
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
verify(accounts).setUsername(account, username);
@Test
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
ArgumentMatcher<String> isWide = (String username) -> {
String[] spl = username.split(UsernameGenerator.SEPARATOR);
assertEquals(spl.length, 2);
int discriminator = Integer.parseInt(spl[1]);
// require a 7 digit discriminator
return discriminator > 1_000_000;
};
when(accounts.usernameAvailable(any())).thenReturn(false);
when(accounts.usernameAvailable(argThat(isWide))).thenReturn(true);
accountsManager.setUsername(account, nickname, null);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
}
@Test
void testChangeUsername() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
account.setUsername("old#123");
accountsManager.setUsername(account, nickname, "old#123");
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@Test
void testSetUsernameNotAvailable() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "unavailable";
when(accounts.usernameAvailable(startsWith(nickname))).thenReturn(false);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
verify(accounts, never()).setUsername(any(), any());
assertTrue(account.getUsername().isEmpty());
}
@Test
void testSetUsernameReserved() {
final String username = "reserved";
when(reservedUsernames.isReserved(eq(username), any())).thenReturn(true);
final String nickname = "reserved";
when(reservedUsernames.isReserved(eq(nickname), any())).thenReturn(true);
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
assertTrue(account.getUsername().isEmpty());
}
@@ -765,6 +816,13 @@ class AccountsManagerTest {
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsername("test")));
}
@Test
void testSetUsernameDisabled() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
when(enrollmentManager.isEnrolled(account.getUuid(), AccountsManager.USERNAME_EXPERIMENT_NAME)).thenReturn(false);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
}
private static Device generateTestDevice(final long lastSeen) {
final Device device = new Device();
device.setId(Device.MASTER_ID);

View File

@@ -0,0 +1,238 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import software.amazon.awssdk.services.dynamodb.model.*;
import java.time.Clock;
import java.util.*;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class AccountsManagerUsernameIntegrationTest {
private static final String ACCOUNTS_TABLE_NAME = "accounts_test";
private static final String NUMBERS_TABLE_NAME = "numbers_test";
private static final String PNI_ASSIGNMENT_TABLE_NAME = "pni_assignment_test";
private static final String USERNAMES_TABLE_NAME = "usernames_test";
private static final String PNI_TABLE_NAME = "pni_test";
private static final int SCAN_PAGE_SIZE = 1;
@RegisterExtension
static DynamoDbExtension ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder()
.tableName(ACCOUNTS_TABLE_NAME)
.hashKey(Accounts.KEY_ACCOUNT_UUID)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(Accounts.KEY_ACCOUNT_UUID)
.attributeType(ScalarAttributeType.B)
.build())
.build();
@RegisterExtension
static DynamoDbExtension PNI_DYNAMO_EXTENSION = DynamoDbExtension.builder()
.tableName(PNI_TABLE_NAME)
.hashKey(PhoneNumberIdentifiers.KEY_E164)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(PhoneNumberIdentifiers.KEY_E164)
.attributeType(ScalarAttributeType.S)
.build())
.build();
@RegisterExtension
static RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
private AccountsManager accountsManager;
private Accounts accounts;
@BeforeEach
void setup() throws InterruptedException {
CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder()
.tableName(NUMBERS_TABLE_NAME)
.keySchema(KeySchemaElement.builder()
.attributeName(Accounts.ATTR_ACCOUNT_E164)
.keyType(KeyType.HASH)
.build())
.attributeDefinitions(AttributeDefinition.builder()
.attributeName(Accounts.ATTR_ACCOUNT_E164)
.attributeType(ScalarAttributeType.S)
.build())
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createNumbersTableRequest);
CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.keySchema(KeySchemaElement.builder()
.attributeName(Accounts.ATTR_USERNAME)
.keyType(KeyType.HASH)
.build())
.attributeDefinitions(AttributeDefinition.builder()
.attributeName(Accounts.ATTR_USERNAME)
.attributeType(ScalarAttributeType.S)
.build())
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createUsernamesTableRequest);
CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder()
.tableName(PNI_ASSIGNMENT_TABLE_NAME)
.keySchema(KeySchemaElement.builder()
.attributeName(Accounts.ATTR_PNI_UUID)
.keyType(KeyType.HASH)
.build())
.attributeDefinitions(AttributeDefinition.builder()
.attributeName(Accounts.ATTR_PNI_UUID)
.attributeType(ScalarAttributeType.B)
.build())
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
DynamicConfiguration dynamicConfiguration = new DynamicConfiguration();
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
accounts = Mockito.spy(new Accounts(
dynamicConfigurationManager,
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(),
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbAsyncClient(),
ACCOUNTS_DYNAMO_EXTENSION.getTableName(),
NUMBERS_TABLE_NAME,
PNI_ASSIGNMENT_TABLE_NAME,
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE));
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
doAnswer((final InvocationOnMock invocationOnMock) -> {
@SuppressWarnings("unchecked")
Consumer<Optional<UUID>> consumer = invocationOnMock.getArgument(1, Consumer.class);
consumer.accept(Optional.empty());
return null;
}).when(deletedAccountsManager).lockAndTake(any(), any());
final PhoneNumberIdentifiers phoneNumberIdentifiers =
new PhoneNumberIdentifiers(PNI_DYNAMO_EXTENSION.getDynamoDbClient(), PNI_TABLE_NAME);
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
.thenReturn(true);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
CACHE_CLUSTER_EXTENSION.getRedisCluster(),
deletedAccountsManager,
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),
mock(SecureBackupClient.class),
mock(ClientPresenceManager.class),
new UsernameGenerator(1, 2, 10),
experimentEnrollmentManager,
mock(Clock.class));
}
private static int discriminator(String username) {
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
}
@Test
void testSetClearUsername() throws UsernameNotAvailableException, InterruptedException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
account = accountsManager.setUsername(account, "n00bkiller", null);
assertThat(account.getUsername()).isPresent();
assertThat(account.getUsername().get()).startsWith("n00bkiller");
int discriminator = discriminator(account.getUsername().get());
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
account.getUuid());
// reroll
account = accountsManager.setUsername(account, "n00bkiller", account.getUsername().get());
final String newUsername = account.getUsername().orElseThrow();
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
// clear
account = accountsManager.clearUsername(account);
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
void testNoUsernames() throws InterruptedException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
for (int i = 1; i <= 99; i++) {
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
.build());
}
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
void testUsernameSnatched() throws InterruptedException, UsernameNotAvailableException {
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
for (int i = 1; i <= 9; i++) {
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
.build());
}
// first time this is called lie and say the username is available
// this simulates seeing an available username and then it being taken
// by someone before the write
doReturn(true).doCallRealMethod().when(accounts).usernameAvailable(any());
final String username = accountsManager
.setUsername(account, "n00bkiller", null)
.getUsername().orElseThrow();
assertThat(username).startsWith("n00bkiller");
assertThat(discriminator(username)).isGreaterThanOrEqualTo(10).isLessThan(100);
// 1 attempt on first try (returns true),
// 10 (attempts per width) on width=2 discriminators (all taken)
verify(accounts, times(11)).usernameAvailable(argThat(un -> discriminator(un) < 10));
// 1 final attempt on width=3 discriminators
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
}
}

View File

@@ -722,7 +722,7 @@ class AccountsTest {
assertThat(maybeAccount).isPresent();
verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), maybeAccount.get(), firstAccount);
assertThatExceptionOfType(UsernameNotAvailableException.class)
assertThatExceptionOfType(ContestedOptimisticLockException.class)
.isThrownBy(() -> accounts.setUsername(secondAccount, username));
assertThat(secondAccount.getUsername()).isEmpty();

View File

@@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.anyLong;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
@@ -23,6 +24,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.collect.ImmutableSet;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
@@ -66,6 +68,7 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
@@ -74,6 +77,8 @@ import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
@@ -138,6 +143,7 @@ class AccountControllerTest {
private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class);
private static RateLimiter autoBlockLimiter = mock(RateLimiter.class);
private static RateLimiter usernameSetLimiter = mock(RateLimiter.class);
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
private static SmsSender smsSender = mock(SmsSender.class);
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
private static Account senderPinAccount = mock(Account.class);
@@ -201,6 +207,7 @@ class AccountControllerTest {
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
@@ -246,7 +253,7 @@ class AccountControllerTest {
return account;
});
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername"))
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername", null))
.thenThrow(new UsernameNotAvailableException());
when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>) invocation -> {
@@ -311,6 +318,7 @@ class AccountControllerTest {
smsVoicePrefixLimiter,
autoBlockLimiter,
usernameSetLimiter,
usernameLookupLimiter,
smsSender,
turnTokenGenerator,
senderPinAccount,
@@ -1660,25 +1668,29 @@ class AccountControllerTest {
}
@Test
void testSetUsername() {
void testSetUsername() throws UsernameNotAvailableException {
Account account = mock(Account.class);
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
when(accountsManager.setUsername(any(), eq("n00bkiller"), isNull()))
.thenReturn(account);
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/n00bkiller")
.target("/v1/accounts/username")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.text(""));
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
}
@Test
void testSetTakenUsername() {
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/takenusername")
.target("/v1/accounts/username/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.text(""));
.put(Entity.json(new UsernameRequest("takenusername", null)));
assertThat(response.getStatus()).isEqualTo(409);
}
@@ -1687,35 +1699,34 @@ class AccountControllerTest {
void testSetInvalidUsername() {
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/pаypal")
.target("/v1/accounts/username")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.text(""));
// contains non-ascii character
.put(Entity.json(new UsernameRequest("pаypal", null)));
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getStatus()).isEqualTo(422);
}
@Test
void testSetInvalidPrefixUsername() {
void testSetInvalidPrefixUsername() throws JsonProcessingException {
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/0n00bkiller")
.target("/v1/accounts/username")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.text(""));
assertThat(response.getStatus()).isEqualTo(400);
.put(Entity.json(new UsernameRequest("0n00bkiller", null)));
assertThat(response.getStatus()).isEqualTo(422);
}
@Test
void testSetUsernameBadAuth() {
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/n00bkiller")
.target("/v1/accounts/username")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
.put(Entity.text(""));
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
assertThat(response.getStatus()).isEqualTo(401);
}
@@ -1935,4 +1946,43 @@ class AccountControllerTest {
.head()
.getStatus()).isEqualTo(400);
}
@Test
void testLookupUsername() {
final Account account = mock(Account.class);
final UUID uuid = UUID.randomUUID();
when(account.getUuid()).thenReturn(uuid);
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.of(account));
Response response = resources.getJerseyTest()
.target("v1/accounts/username/n00bkiller#1234")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get();
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(AccountIdentifierResponse.class).uuid()).isEqualTo(uuid);
}
@Test
void testLookupUsernameDoesNotExist() {
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.empty());
assertThat(resources.getJerseyTest()
.target("v1/accounts/username/n00bkiller#1234")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get().getStatus()).isEqualTo(404);
}
@Test
void testLookupUsernameRateLimited() throws RateLimitExceededException {
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(usernameLookupLimiter).validate("127.0.0.1");
final Response response = resources.getJerseyTest()
.target("/v1/accounts/username/test#123")
.request()
.header("X-Forwarded-For", "127.0.0.1")
.get();
assertThat(response.getStatus()).isEqualTo(413);
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));
}
}

View File

@@ -0,0 +1,133 @@
package org.whispersystems.textsecuregcm.tests.util;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
public class UsernameGeneratorTest {
@ParameterizedTest(name = "[{index}]:{0} ({2})")
@MethodSource
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
assertThat(UsernameGenerator.isValidNickname(nickname)).isEqualTo(valid);
}
static Stream<Arguments> nicknameValidation() {
return Stream.of(
Arguments.of("Test", false, "upper case"),
Arguments.of("tesT", false, "upper case"),
Arguments.of("te-st", false, "illegal character"),
Arguments.of("ab\uD83D\uDC1B", false, "illegal character"),
Arguments.of("1test", false, "illegal start"),
Arguments.of("test#123", false, "illegal character"),
Arguments.of("ab", false, "too short"),
Arguments.of("", false, ""),
Arguments.of("_123456789_123456789_123456789123", false, "33 characters"),
Arguments.of("_test", true, ""),
Arguments.of("test", true, ""),
Arguments.of("test123", true, ""),
Arguments.of("abc", true, ""),
Arguments.of("_123456789_123456789_12345678912", true, "32 characters")
);
}
@ParameterizedTest(name="[{index}]: {0}")
@MethodSource
public void nonStandardUsernames(final String username, final boolean isStandard) {
assertThat(UsernameGenerator.isStandardFormat(username)).isEqualTo(isStandard);
}
static Stream<Arguments> nonStandardUsernames() {
return Stream.of(
Arguments.of("Test#123", false),
Arguments.of("test#-123", false),
Arguments.of("test#0", false),
Arguments.of("test#", false),
Arguments.of("test#1_00", false),
Arguments.of("test#1", true),
Arguments.of("abc#1234", true)
);
}
@Test
public void expectedWidth() throws UsernameNotAvailableException {
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
}
@Test
public void expandDiscriminator() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
}
@Test
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
}
@Test
public void exhaustDiscriminator() {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
// allow greater than our max width
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
});
}
@Test
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
}
// after 1K iterations, probability of a missed value is (9/10)^999
assertThat(seen.size()).isEqualTo(9);
assertThat(seen).allMatch(i -> i > 0 && i < 10);
}
@Test
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
}
// after 100K iterations, probability of a missed value is (99/100)^99999
assertThat(seen.size()).isEqualTo(90);
assertThat(seen).allMatch(i -> i >= 10 && i < 100);
}
private static Predicate<String> allowDiscriminator(Predicate<Integer> p) {
return username -> p.test(extractDiscriminator(username));
}
private static int extractDiscriminator(final String username) {
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
}
}

View File

@@ -12,14 +12,14 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class UsernameValidatorTest {
class NicknameValidatorTest {
@ParameterizedTest
@MethodSource
void isValid(final String username, final boolean expectValid) {
final UsernameValidator usernameValidator = new UsernameValidator();
final NicknameValidator nicknameValidator = new NicknameValidator();
assertEquals(expectValid, usernameValidator.isValid(username, null));
assertEquals(expectValid, nicknameValidator.isValid(username, null));
}
private static Stream<Arguments> isValid() {
@@ -28,8 +28,8 @@ class UsernameValidatorTest {
Arguments.of("_test", true),
Arguments.of("test123", true),
Arguments.of("a", false), // Too short
Arguments.of("thisIsAReallyReallyReallyLongUsernameThatWeWouldNotAllow", false),
Arguments.of("Illegal character", false),
Arguments.of("thisisareallyreallyreallylongusernamethatwewouldnotalllow", false),
Arguments.of("illegal character", false),
Arguments.of("0test", false), // Illegal first character
Arguments.of("pаypal", false), // Unicode confusable characters
Arguments.of("test\uD83D\uDC4E", false), // Emoji
@@ -38,19 +38,4 @@ class UsernameValidatorTest {
Arguments.of(null, false)
);
}
@ParameterizedTest
@MethodSource
void getCanonicalUsername(final String username, final String expectedCanonicalUsername) {
assertEquals(expectedCanonicalUsername, UsernameValidator.getCanonicalUsername(username));
}
private static Stream<Arguments> getCanonicalUsername() {
return Stream.of(
Arguments.of("test", "test"),
Arguments.of("TEst", "test"),
Arguments.of("t_e_S_T", "t_e_s_t"),
Arguments.of(null, null)
);
}
}