Add reserve/confirm for usernames

This commit is contained in:
Ravi Khadiwala
2022-08-31 21:02:09 -05:00
committed by ravi-signal
parent 98c8dc05f1
commit 4032ddd4fd
26 changed files with 956 additions and 96 deletions

View File

@@ -193,7 +193,7 @@ class AccountsManagerChangeNumberIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
secureStorageClient,

View File

@@ -160,7 +160,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),

View File

@@ -71,7 +71,7 @@ class AccountsManagerTest {
private Keys keys;
private MessagesManager messagesManager;
private ProfilesManager profilesManager;
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
private ExperimentEnrollmentManager enrollmentManager;
private Map<String, UUID> phoneNumberIdentifiersByE164;
@@ -87,6 +87,8 @@ class AccountsManagerTest {
return null;
};
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
@BeforeEach
void setup() throws InterruptedException {
accounts = mock(Accounts.class);
@@ -95,7 +97,7 @@ class AccountsManagerTest {
keys = mock(Keys.class);
messagesManager = mock(MessagesManager.class);
profilesManager = mock(ProfilesManager.class);
reservedUsernames = mock(ReservedUsernames.class);
prohibitedUsernames = mock(ProhibitedUsernames.class);
//noinspection unchecked
commands = mock(RedisAdvancedClusterCommands.class);
@@ -149,7 +151,7 @@ class AccountsManagerTest {
directoryQueue,
keys,
messagesManager,
reservedUsernames,
prohibitedUsernames,
profilesManager,
mock(StoredVerificationCodeManager.class),
storageClient,
@@ -737,6 +739,65 @@ class AccountsManagerTest {
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "beethoven";
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), startsWith(nickname), any());
}
@Test
void testSetReservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "scooby#1234";
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN);
verify(accounts).confirmUsername(eq(account), eq(reserved), eq(RESERVATION_TOKEN));
}
@Test
void testSetReservedHashNameMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
setReservationHash(account, "pluto#1234");
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq("pluto#1234"))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "goofy#1234", RESERVATION_TOKEN));
}
@Test
void testSetReservedHashAciMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "toto#1234";
account.setReservedUsernameHash(Accounts.reservedUsernameHash(UUID.randomUUID(), reserved));
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
}
@Test
void testSetReservedLapsed() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "porkchop#1234";
// name was reserved, but the reservation lapsed and another account took it
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(false);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@Test
void testSetReservedRetry() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "santaslittlehelper#1234";
account.setUsername(username);
// reserved username already set, should be treated as a replay
accountsManager.confirmReservedUsername(account, username, RESERVATION_TOKEN);
verifyNoInteractions(accounts);
}
@Test
void testSetUsernameSameUsername() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
@@ -761,7 +822,29 @@ class AccountsManagerTest {
}
@Test
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
void testReserveUsernameReroll() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "clifford";
final String username = nickname + "#ZZZ";
account.setUsername(username);
// given the correct old username, should reroll discriminator even if the nick matches
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), not(eq(username))), any());
}
@Test
void testSetReservedUsernameWithNoReservation() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
new ArrayList<>(), new byte[16]);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "laika#1234", RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testUsernameExpandDiscriminator(boolean reserve) throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
@@ -775,8 +858,14 @@ class AccountsManagerTest {
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)));
if (reserve) {
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), argThat(isWide)), any());
} else {
accountsManager.setUsername(account, nickname, null);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
}
}
@Test
@@ -801,7 +890,7 @@ class AccountsManagerTest {
@Test
void testSetUsernameReserved() {
final String nickname = "reserved";
when(reservedUsernames.isReserved(eq(nickname), any())).thenReturn(true);
when(prohibitedUsernames.isProhibited(eq(nickname), any())).thenReturn(true);
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
@@ -823,6 +912,10 @@ class AccountsManagerTest {
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
}
private void setReservationHash(final Account account, final String reservedUsername) {
account.setReservedUsernameHash(Accounts.reservedUsernameHash(account.getUuid(), reservedUsername));
}
private static Device generateTestDevice(final long lastSeen) {
final Device device = new Device();
device.setId(Device.MASTER_ID);

View File

@@ -8,6 +8,8 @@ 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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -22,6 +24,8 @@ 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.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
@@ -110,7 +114,11 @@ class AccountsManagerUsernameIntegrationTest {
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
buildAccountsManager(1, 2, 10);
}
private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth)
throws InterruptedException {
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
@@ -127,6 +135,8 @@ class AccountsManagerUsernameIntegrationTest {
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE));
usernameGenerator = new UsernameGenerator(initialWidth, discriminatorMaxWidth, attemptsPerWidth,
Duration.ofDays(1));
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
doAnswer((final InvocationOnMock invocationOnMock) -> {
@SuppressWarnings("unchecked")
@@ -141,8 +151,6 @@ class AccountsManagerUsernameIntegrationTest {
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
.thenReturn(true);
usernameGenerator = new UsernameGenerator(1, 2, 10);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
@@ -151,7 +159,7 @@ class AccountsManagerUsernameIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),
@@ -190,19 +198,32 @@ class AccountsManagerUsernameIntegrationTest {
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
void testNoUsernames() throws InterruptedException {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testNoUsernames(boolean reserve) throws InterruptedException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
for (int i = 1; i <= 99; i++) {
final Map<String, AttributeValue> item = new HashMap<>(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))));
// half of these are taken usernames, half are only reservations (have a TTL)
if (i % 2 == 0) {
item.put(Accounts.ATTR_TTL,
AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));
}
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))))
.item(item)
.build());
}
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
assertThrows(UsernameNotAvailableException.class, () -> {
if (reserve) {
accountsManager.reserveUsername(account, "n00bkiller");
} else {
accountsManager.setUsername(account, "n00bkiller", null);
}
});
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@@ -237,4 +258,112 @@ class AccountsManagerUsernameIntegrationTest {
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
}
@Test
public void testReserveSetClear()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(reservation.reservedUsername()).startsWith("n00bkiller");
int discriminator = discriminator(reservation.reservedUsername());
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
assertThat(accountsManager.getByUsername(reservation.reservedUsername())).isEmpty();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
assertThat(account.getUsername().get()).startsWith("n00bkiller");
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
account.getUuid());
// reroll
reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
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
public void testReservationLapsed()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
// use a username generator that can retry a lot
buildAccountsManager(1, 1, 1000000);
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsername(account, "n00bkiller");
final String reservedUsername = reservation1.reservedUsername();
long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();
// force expiration
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString(reservedUsername)))
.updateExpression("SET #ttl = :ttl")
.expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL))
.expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past)))
.build());
int discriminator = discriminator(reservedUsername);
// use up all names except the reserved one
for (int i = 1; i <= 9; i++) {
if (i == discriminator) {
continue;
}
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());
}
// a different account should be able to reserve it
Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(),
new ArrayList<>());
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsername(account2, "n00bkiller"
);
assertThat(reservation2.reservedUsername()).isEqualTo(reservedUsername);
assertThrows(UsernameNotAvailableException.class,
() -> accountsManager.confirmReservedUsername(reservation1.account(), reservedUsername, reservation1.reservationToken()));
accountsManager.confirmReservedUsername(reservation2.account(), reservedUsername, reservation2.reservationToken());
}
@Test
void testUsernameReserveClearSetReserved()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
account = accountsManager.setUsername(account, "n00bkiller", null);
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "other");
account = reservation.account();
assertThat(reservation.reservedUsername()).startsWith("other");
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("n00bkiller"));
account = accountsManager.clearUsername(account);
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(account.getUsername()).isEmpty();
account = accountsManager.confirmReservedUsername(account, reservation.reservedUsername(), reservation.reservationToken());
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("other"));
}
}

View File

@@ -17,6 +17,9 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.uuid.UUIDComparator;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -26,6 +29,7 @@ import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -74,6 +78,7 @@ class AccountsTest {
.build())
.build();
private Clock mockClock;
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
private Accounts accounts;
@@ -130,7 +135,11 @@ class AccountsTest {
when(mockDynamicConfigManager.getConfiguration())
.thenReturn(new DynamicConfiguration());
mockClock = mock(Clock.class);
when(mockClock.instant()).thenReturn(Instant.EPOCH);
this.accounts = new Accounts(
mockClock,
mockDynamicConfigManager,
dynamoDbExtension.getDynamoDbClient(),
dynamoDbExtension.getDynamoDbAsyncClient(),
@@ -608,7 +617,7 @@ class AccountsTest {
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
void testSetUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -679,7 +688,7 @@ class AccountsTest {
}
@Test
void testClearUsername() throws UsernameNotAvailableException {
void testClearUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -704,7 +713,7 @@ class AccountsTest {
}
@Test
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
void testClearUsernameVersionMismatch() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -719,6 +728,154 @@ class AccountsTest {
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
}
@Test
void testReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
final UUID token = accounts.reserveUsername(account1, "garfield", Duration.ofDays(1));
assertThat(account1.getReservedUsernameHash()).get().isEqualTo(Accounts.reservedUsernameHash(account1.getUuid(), "garfield"));
assertThat(account1.getUsername()).isEmpty();
// account 2 shouldn't be able to reserve the username
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account2, "garfield", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account2, "garfield", UUID.randomUUID()));
assertThat(accounts.getByUsername("garfield")).isEmpty();
accounts.confirmUsername(account1, "garfield", token);
assertThat(account1.getReservedUsernameHash()).isEmpty();
assertThat(account1.getUsername()).get().isEqualTo("garfield");
assertThat(accounts.getByUsername("garfield").get().getUuid()).isEqualTo(account1.getUuid());
assertThat(dynamoDbExtension.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("garfield")))
.build())
.item())
.doesNotContainKey(Accounts.ATTR_TTL);
}
@Test
void testUsernameAvailable() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final String username = "unsinkablesam";
final UUID token = accounts.reserveUsername(account1, username, Duration.ofDays(1));
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isTrue();
accounts.confirmUsername(account1, username, token);
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isFalse();
}
@Test
void testReservedUsernameWrongToken() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "grumpy", Duration.ofDays(1));
assertThat(account.getReservedUsernameHash())
.get()
.isEqualTo(Accounts.reservedUsernameHash(account.getUuid(), "grumpy"));
assertThat(account.getUsername()).isEmpty();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account, "grumpy", UUID.randomUUID()));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "grumpy"));
}
@Test
void testReserveExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
Supplier<UUID> take = () -> accounts.reserveUsername(account2, "snowball#0002", Duration.ofDays(2));
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::get);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
final UUID token = take.get();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, "snowball#0002"));
accounts.confirmUsername(account2, "snowball#0002", token);
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testTakeExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
Runnable take = () -> accounts.setUsername(account2, "snowball#0002");
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::run);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
take.run();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, "snowball#0002"));
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testRetryReserveUsername() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "jorts", Duration.ofDays(2));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "jorts", Duration.ofDays(2)),
"Shouldn't be able to re-reserve same username (would extend ttl)");
}
@Test
void testReserveUsernameVersionConflict() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
account.setVersion(account.getVersion() + 12);
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "salem", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "salem"));
}
private Device generateDevice(long id) {
return DevicesHelper.createDevice(id);
}

View File

@@ -17,37 +17,37 @@ import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class ReservedUsernamesTest {
class ProhibitedUsernamesTest {
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(RESERVED_USERNAMES_TABLE_NAME)
.hashKey(ReservedUsernames.KEY_PATTERN)
.hashKey(ProhibitedUsernames.KEY_PATTERN)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ReservedUsernames.KEY_PATTERN)
.attributeName(ProhibitedUsernames.KEY_PATTERN)
.attributeType(ScalarAttributeType.S)
.build())
.build();
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
@BeforeEach
void setUp() {
reservedUsernames =
new ReservedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
prohibitedUsernames =
new ProhibitedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
}
@ParameterizedTest
@MethodSource
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
reservedUsernames.reserveUsername(".*myusername.*", RESERVED_FOR_UUID);
reservedUsernames.reserveUsername("^foobar$", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername(".*myusername.*", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername("^foobar$", RESERVED_FOR_UUID);
assertEquals(expectReserved, reservedUsernames.isReserved(username, uuid));
assertEquals(expectReserved, prohibitedUsernames.isProhibited(username, uuid));
}
private static Stream<Arguments> isReserved() {

View File

@@ -73,10 +73,13 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
@@ -99,6 +102,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Hex;
@@ -121,6 +125,7 @@ class AccountControllerTest {
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
private static final String ABUSIVE_HOST = "192.168.1.1";
private static final String NICE_HOST = "127.0.0.1";
@@ -144,6 +149,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 usernameReserveLimiter = mock(RateLimiter.class);
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
private static SmsSender smsSender = mock(SmsSender.class);
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
@@ -208,6 +214,7 @@ class AccountControllerTest {
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter);
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
@@ -319,6 +326,7 @@ class AccountControllerTest {
smsVoicePrefixLimiter,
autoBlockLimiter,
usernameSetLimiter,
usernameReserveLimiter,
usernameLookupLimiter,
smsSender,
turnTokenGenerator,
@@ -1733,6 +1741,63 @@ class AccountControllerTest {
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
when(accountsManager.reserveUsername(any(), eq("n00bkiller")))
.thenReturn(new AccountsManager.UsernameReservation(null, "n00bkiller#1234", RESERVATION_TOKEN));
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/reserved")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ReserveUsernameRequest("n00bkiller")));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(ReserveUsernameResponse.class))
.satisfies(r -> r.username().equals("n00bkiller#1234"))
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
}
@Test
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = mock(Account.class);
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
}
@Test
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameReservationNotFoundException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameNotAvailableException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(410);
}
@Test
void testSetTakenUsername() {
Response response =

View File

@@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
@@ -17,6 +18,8 @@ import static org.assertj.core.api.Assertions.assertThat;
public class UsernameGeneratorTest {
private static final Duration TTL = Duration.ofMinutes(5);
@ParameterizedTest(name = "[{index}]:{0} ({2})")
@MethodSource
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
@@ -64,7 +67,7 @@ public class UsernameGeneratorTest {
@Test
public void zeroPadDiscriminators() {
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1);
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1, TTL);
assertThat(generator.fromParts("test", 1)).isEqualTo("test#0001");
assertThat(generator.fromParts("test", 123)).isEqualTo("test#0123");
assertThat(generator.fromParts("test", 9999)).isEqualTo("test#9999");
@@ -73,16 +76,16 @@ public class UsernameGeneratorTest {
@Test
public void expectedWidth() throws UsernameNotAvailableException {
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
String username = new UsernameGenerator(1, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
username = new UsernameGenerator(2, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
}
@Test
public void expandDiscriminator() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
@@ -90,7 +93,7 @@ public class UsernameGeneratorTest {
@Test
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
@@ -98,7 +101,7 @@ public class UsernameGeneratorTest {
@Test
public void exhaustDiscriminator() {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
// allow greater than our max width
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
@@ -107,7 +110,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
@@ -120,7 +123,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
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))));