Migrate username storage from a relational database to DynamoDB

This commit is contained in:
Jon Chambers 2021-12-01 16:50:18 -05:00 committed by GitHub
parent 0d4a3b1ad4
commit d94e86781f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 664 additions and 764 deletions

@ -1 +1 @@
Subproject commit d20873c7d78eb0a33cb27d103ba6ee6807b09a88
Subproject commit 6a74e85e41d706e48865f45cfcb41208c28c7e44

View File

@ -137,6 +137,7 @@ accountsDynamoDb: # DynamoDB table configuration
tableName: Example_Accounts
phoneNumberTableName: Example_Accounts_PhoneNumbers
phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers
usernamesTableName: Example_Accounts_Usernames
deletedAccountsDynamoDb: # DynamoDb table configuration
region: us-west-2

View File

@ -202,8 +202,6 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Usernames;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.stripe.StripeManager;
import org.whispersystems.textsecuregcm.util.Constants;
@ -384,10 +382,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getAccountsDynamoDbConfiguration().getTableName(),
config.getAccountsDynamoDbConfiguration().getPhoneNumberTableName(),
config.getAccountsDynamoDbConfiguration().getPhoneNumberIdentifierTableName(),
config.getAccountsDynamoDbConfiguration().getUsernamesTableName(),
config.getAccountsDynamoDbConfiguration().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(phoneNumberIdentifiersDynamoDbClient,
config.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(reservedUsernamesDynamoDbClient,
config.getReservedUsernamesDynamoDbConfiguration().getTableName());
Profiles profiles = new Profiles(accountDatabase);
@ -472,7 +470,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
UsernamesManager usernamesManager = new UsernamesManager(usernames, reservedUsernames, cacheCluster);
ProfilesManager profilesManager = new ProfilesManager(profiles, profilesDynamoDb, cacheCluster, dynamicConfigurationManager);
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, keyspaceNotificationDispatchExecutor);
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
@ -481,7 +478,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
deletedAccountsLockDynamoDbClient, config.getDeletedAccountsLockDynamoDbConfiguration().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, usernamesManager, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
DeadLetterHandler deadLetterHandler = new DeadLetterHandler(accountsManager, messagesManager);
@ -660,7 +657,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
// these should be common, but use @Auth DisabledPermittedAccount, which isnt supported yet on websocket
environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, usernamesManager, abusiveHostRules, rateLimiters,
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
smsSender, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
transitionalRecaptchaClient, gcmSender, apnSender, backupCredentialsGenerator,
verifyExperimentEnrollmentManager));
@ -680,7 +677,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, messagesManager, unsealedSenderRateLimiter, apnFallbackManager,
rateLimitChallengeManager, reportMessageManager, multiRecipientMessageExecutor),
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, usernamesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations),
new ProvisioningController(rateLimiters, provisioningManager),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
new SecureBackupController(backupCredentialsGenerator),

View File

@ -11,6 +11,9 @@ public class AccountsDynamoDbConfiguration extends DynamoDbConfiguration {
@NotNull
private String phoneNumberIdentifierTableName;
@NotNull
private String usernamesTableName;
private int scanPageSize = 100;
@JsonProperty
@ -23,9 +26,13 @@ public class AccountsDynamoDbConfiguration extends DynamoDbConfiguration {
return phoneNumberIdentifierTableName;
}
@JsonProperty
public String getUsernamesTableName() {
return usernamesTableName;
}
@JsonProperty
public int getScanPageSize() {
return scanPageSize;
}
}

View File

@ -85,7 +85,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
import org.whispersystems.textsecuregcm.util.Hex;
@ -123,7 +123,6 @@ public class AccountController {
private final StoredVerificationCodeManager pendingAccounts;
private final AccountsManager accounts;
private final UsernamesManager usernames;
private final AbusiveHostRules abusiveHostRules;
private final RateLimiters rateLimiters;
private final SmsSender smsSender;
@ -139,7 +138,6 @@ public class AccountController {
public AccountController(StoredVerificationCodeManager pendingAccounts,
AccountsManager accounts,
UsernamesManager usernames,
AbusiveHostRules abusiveHostRules,
RateLimiters rateLimiters,
SmsSender smsSenderFactory,
@ -154,7 +152,6 @@ public class AccountController {
{
this.pendingAccounts = pendingAccounts;
this.accounts = accounts;
this.usernames = usernames;
this.abusiveHostRules = abusiveHostRules;
this.rateLimiters = rateLimiters;
this.smsSender = smsSenderFactory;
@ -614,7 +611,7 @@ public class AccountController {
@Path("/username")
@Produces(MediaType.APPLICATION_JSON)
public void deleteUsername(@Auth AuthenticatedAccount auth) {
usernames.delete(auth.getAccount().getUuid());
accounts.clearUsername(auth.getAccount());
}
@PUT
@ -634,7 +631,9 @@ public class AccountController {
return Response.status(Response.Status.BAD_REQUEST).build();
}
if (!usernames.put(auth.getAccount().getUuid(), username)) {
try {
accounts.setUsername(auth.getAccount(), username);
} catch (final UsernameNotAvailableException e) {
return Response.status(Response.Status.CONFLICT).build();
}

View File

@ -74,7 +74,6 @@ import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.ExactlySize;
import org.whispersystems.textsecuregcm.util.Pair;
@ -93,7 +92,6 @@ public class ProfileController {
private final RateLimiters rateLimiters;
private final ProfilesManager profilesManager;
private final AccountsManager accountsManager;
private final UsernamesManager usernamesManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final ProfileBadgeConverter profileBadgeConverter;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
@ -112,7 +110,6 @@ public class ProfileController {
RateLimiters rateLimiters,
AccountsManager accountsManager,
ProfilesManager profilesManager,
UsernamesManager usernamesManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
ProfileBadgeConverter profileBadgeConverter,
BadgesConfiguration badgesConfiguration,
@ -125,7 +122,6 @@ public class ProfileController {
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.usernamesManager = usernamesManager;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.profileBadgeConverter = profileBadgeConverter;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
@ -263,7 +259,7 @@ public class ProfileController {
assert(accountProfile.isPresent());
Optional<String> username = usernamesManager.get(accountProfile.get().getUuid());
Optional<String> username = accountProfile.flatMap(Account::getUsername);
Optional<VersionedProfile> profile = profilesManager.get(uuid, version);
String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName());
@ -315,35 +311,26 @@ public class ProfileController {
username = username.toLowerCase();
Optional<UUID> uuid = usernamesManager.get(username);
final Account accountProfile = accountsManager.getByUsername(username)
.orElseThrow(() -> new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build()));
if (uuid.isEmpty()) {
throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
}
final boolean isSelf = auth.getAccount().getUuid().equals(uuid.get());
Optional<Account> accountProfile = accountsManager.getByAccountIdentifier(uuid.get());
if (accountProfile.isEmpty()) {
throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
}
final boolean isSelf = auth.getAccount().getUuid().equals(accountProfile.getUuid());
return new Profile(
accountProfile.get().getProfileName(),
accountProfile.getProfileName(),
null,
null,
accountProfile.get().getAvatar(),
accountProfile.getAvatar(),
null,
accountProfile.get().getIdentityKey(),
UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
accountProfile.get().isUnrestrictedUnidentifiedAccess(),
UserCapabilities.createForAccount(accountProfile.get()),
accountProfile.getIdentityKey(),
UnidentifiedAccessChecksum.generateFor(accountProfile.getUnidentifiedAccessKey()),
accountProfile.isUnrestrictedUnidentifiedAccess(),
UserCapabilities.createForAccount(accountProfile),
username,
accountProfile.get().getUuid(),
accountProfile.getUuid(),
profileBadgeConverter.convert(
getAcceptableLanguagesForRequest(containerRequestContext),
accountProfile.get().getBadges(),
accountProfile.getBadges(),
isSelf),
null);
}
@ -410,7 +397,7 @@ public class ProfileController {
Optional<Account> accountProfile = accountsManager.getByAccountIdentifier(identifier);
OptionalAccess.verify(auth.map(AuthenticatedAccount::getAccount), accessKey, accountProfile);
Optional<String> username = usernamesManager.get(accountProfile.get().getUuid());
Optional<String> username = accountProfile.flatMap(Account::getUsername);
return new Profile(
accountProfile.get().getProfileName(),

View File

@ -23,6 +23,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.util.Util;
import javax.annotation.Nullable;
public class Account {
@ -38,6 +39,10 @@ public class Account {
@JsonProperty
private String number;
@JsonProperty
@Nullable
private String username;
@JsonProperty
private Set<Device> devices = new HashSet<>();
@ -134,6 +139,18 @@ public class Account {
this.phoneNumberIdentifier = phoneNumberIdentifier;
}
public Optional<String> getUsername() {
requireNotStale();
return Optional.ofNullable(username);
}
public void setUsername(final String username) {
requireNotStale();
this.username = username;
}
public void addDevice(Device device) {
requireNotStale();

View File

@ -19,6 +19,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -57,19 +58,25 @@ public class Accounts extends AbstractDynamoDbStore {
static final String ATTR_VERSION = "V";
// canonically discoverable
static final String ATTR_CANONICALLY_DISCOVERABLE = "C";
// username; string
static final String ATTR_USERNAME = "N";
private final DynamoDbClient client;
private final String phoneNumberConstraintTableName;
private final String phoneNumberIdentifierConstraintTableName;
private final String usernamesConstraintTableName;
private final String accountsTableName;
private final int scanPageSize;
private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, "create"));
private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "changeNumber"));
private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "setUsername"));
private static final Timer CLEAR_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "clearUsername"));
private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update"));
private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
private static final Timer GET_BY_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "getByUsername"));
private static final Timer GET_BY_PNI_TIMER = Metrics.timer(name(Accounts.class, "getByPni"));
private static final Timer GET_BY_UUID_TIMER = Metrics.timer(name(Accounts.class, "getByUuid"));
private static final Timer GET_ALL_FROM_START_TIMER = Metrics.timer(name(Accounts.class, "getAllFrom"));
@ -79,7 +86,8 @@ public class Accounts extends AbstractDynamoDbStore {
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
public Accounts(DynamoDbClient client, String accountsTableName, String phoneNumberConstraintTableName,
String phoneNumberIdentifierConstraintTableName, final int scanPageSize) {
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
final int scanPageSize) {
super(client);
@ -87,6 +95,7 @@ public class Accounts extends AbstractDynamoDbStore {
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
this.phoneNumberIdentifierConstraintTableName = phoneNumberIdentifierConstraintTableName;
this.accountsTableName = accountsTableName;
this.usernamesConstraintTableName = usernamesConstraintTableName;
this.scanPageSize = scanPageSize;
}
@ -304,6 +313,141 @@ public class Accounts extends AbstractDynamoDbStore {
});
}
public void setUsername(final Account account, final String username)
throws ContestedOptimisticLockException, UsernameNotAvailableException {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
account.setUsername(username);
boolean succeeded = false;
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
ATTR_USERNAME, AttributeValues.fromString(username)))
.conditionExpression("attribute_not_exists(#username)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
writeItems.add(
TransactWriteItem.builder()
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data, #username = :username ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
"#username", ATTR_USERNAME,
"#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
":username", AttributeValues.fromString(username),
":version", AttributeValues.fromInt(account.getVersion()),
":version_increment", AttributeValues.fromInt(1)))
.build())
.build());
maybeOriginalUsername.ifPresent(originalUsername -> writeItems.add(TransactWriteItem.builder()
.delete(Delete.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(originalUsername)))
.build())
.build()));
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
.build();
client.transactWriteItems(request);
account.setVersion(account.getVersion() + 1);
succeeded = true;
} catch (final JsonProcessingException e) {
throw new IllegalArgumentException(e);
} catch (final TransactionCanceledException e) {
if ("ConditionalCheckFailed".equals(e.cancellationReasons().get(0).code())) {
throw new UsernameNotAvailableException();
} else if ("ConditionalCheckFailed".equals(e.cancellationReasons().get(1).code())) {
throw new ContestedOptimisticLockException();
}
throw e;
} finally {
if (!succeeded) {
account.setUsername(maybeOriginalUsername.orElse(null));
}
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
}
public void clearUsername(Account account) {
account.getUsername().ifPresent(username -> {
CLEAR_USERNAME_TIMER.record(() -> {
account.setUsername(null);
boolean succeeded = false;
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
writeItems.add(
TransactWriteItem.builder()
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data REMOVE #username ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
"#username", ATTR_USERNAME,
"#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
":version", AttributeValues.fromInt(account.getVersion()),
":version_increment", AttributeValues.fromInt(1)))
.build())
.build());
writeItems.add(TransactWriteItem.builder()
.delete(Delete.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build())
.build());
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
.build();
client.transactWriteItems(request);
account.setVersion(account.getVersion() + 1);
succeeded = true;
} catch (final JsonProcessingException e) {
throw new IllegalArgumentException(e);
} catch (final TransactionCanceledException e) {
if ("ConditionalCheckFailed".equals(e.cancellationReasons().get(0).code())) {
throw new ContestedOptimisticLockException();
}
throw e;
} finally {
if (!succeeded) {
account.setUsername(username);
}
}
});
});
}
public void update(Account account) throws ContestedOptimisticLockException {
UPDATE_TIMER.record(() -> {
final UpdateItemRequest updateItemRequest;
@ -358,6 +502,21 @@ public class Accounts extends AbstractDynamoDbStore {
});
}
public Optional<Account> getByUsername(final String username) {
return GET_BY_USERNAME_TIMER.record(() -> {
final GetItemResponse response = client.getItem(GetItemRequest.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build());
return Optional.ofNullable(response.item())
.map(item -> item.get(KEY_ACCOUNT_UUID))
.map(this::accountByUuid)
.map(Accounts::fromItem);
});
}
public Optional<Account> getByPhoneNumberIdentifier(final UUID phoneNumberIdentifier) {
return GET_BY_PNI_TIMER.record(() -> {
@ -416,6 +575,13 @@ public class Accounts extends AbstractDynamoDbStore {
.build())
.build());
account.getUsername().ifPresent(username -> transactWriteItems.add(TransactWriteItem.builder()
.delete(Delete.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build())
.build()));
TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(transactWriteItems).build();
@ -480,6 +646,7 @@ public class Accounts extends AbstractDynamoDbStore {
account.setNumber(item.get(ATTR_ACCOUNT_E164).s(), phoneNumberIdentifierFromAttribute);
account.setUuid(accountIdentifier);
account.setUsername(AttributeValues.getString(item, ATTR_USERNAME, null));
account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n()));
account.setCanonicallyDiscoverable(Optional.ofNullable(item.get(ATTR_CANONICALLY_DISCOVERABLE)).map(av -> av.bool()).orElse(false));

View File

@ -44,18 +44,20 @@ import org.whispersystems.textsecuregcm.util.Util;
public class AccountsManager {
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private static final Timer createTimer = metricRegistry.timer(name(AccountsManager.class, "create" ));
private static final Timer updateTimer = metricRegistry.timer(name(AccountsManager.class, "update" ));
private static final Timer getByNumberTimer = metricRegistry.timer(name(AccountsManager.class, "getByNumber"));
private static final Timer getByUuidTimer = metricRegistry.timer(name(AccountsManager.class, "getByUuid" ));
private static final Timer deleteTimer = metricRegistry.timer(name(AccountsManager.class, "delete"));
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private static final Timer createTimer = metricRegistry.timer(name(AccountsManager.class, "create"));
private static final Timer updateTimer = metricRegistry.timer(name(AccountsManager.class, "update"));
private static final Timer getByNumberTimer = metricRegistry.timer(name(AccountsManager.class, "getByNumber"));
private static final Timer getByUsernameTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsername"));
private static final Timer getByUuidTimer = metricRegistry.timer(name(AccountsManager.class, "getByUuid"));
private static final Timer deleteTimer = metricRegistry.timer(name(AccountsManager.class, "delete"));
private static final Timer redisSetTimer = metricRegistry.timer(name(AccountsManager.class, "redisSet" ));
private static final Timer redisSetTimer = metricRegistry.timer(name(AccountsManager.class, "redisSet"));
private static final Timer redisNumberGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisNumberGet"));
private static final Timer redisPniGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisPniGet"));
private static final Timer redisUuidGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUuidGet" ));
private static final Timer redisDeleteTimer = metricRegistry.timer(name(AccountsManager.class, "redisDelete" ));
private static final Timer redisUsernameGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameGet"));
private static final Timer redisPniGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisPniGet"));
private static final Timer redisUuidGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUuidGet"));
private static final Timer redisDeleteTimer = metricRegistry.timer(name(AccountsManager.class, "redisDelete"));
private static final String CREATE_COUNTER_NAME = name(AccountsManager.class, "createCounter");
private static final String DELETE_COUNTER_NAME = name(AccountsManager.class, "deleteCounter");
@ -71,7 +73,7 @@ public class AccountsManager {
private final DirectoryQueue directoryQueue;
private final Keys keys;
private final MessagesManager messagesManager;
private final UsernamesManager usernamesManager;
private final ReservedUsernames reservedUsernames;
private final ProfilesManager profilesManager;
private final StoredVerificationCodeManager pendingAccounts;
private final SecureStorageClient secureStorageClient;
@ -86,6 +88,11 @@ public class AccountsManager {
// the owner.
private static final long CACHE_TTL_SECONDS = Duration.ofDays(2).toSeconds();
@FunctionalInterface
private interface AccountPersister {
void persistAccount(Account account) throws UsernameNotAvailableException;
}
public enum DeletionReason {
ADMIN_DELETED("admin"),
EXPIRED ("expired"),
@ -105,7 +112,7 @@ public class AccountsManager {
final DirectoryQueue directoryQueue,
final Keys keys,
final MessagesManager messagesManager,
final UsernamesManager usernamesManager,
final ReservedUsernames reservedUsernames,
final ProfilesManager profilesManager,
final StoredVerificationCodeManager pendingAccounts,
final SecureStorageClient secureStorageClient,
@ -119,12 +126,12 @@ public class AccountsManager {
this.directoryQueue = directoryQueue;
this.keys = keys;
this.messagesManager = messagesManager;
this.usernamesManager = usernamesManager;
this.profilesManager = profilesManager;
this.pendingAccounts = pendingAccounts;
this.secureStorageClient = secureStorageClient;
this.secureBackupClient = secureBackupClient;
this.clientPresenceManager = clientPresenceManager;
this.reservedUsernames = reservedUsernames;
this.mapper = SystemMapper.getMapper();
this.clock = Objects.requireNonNull(clock);
}
@ -236,11 +243,18 @@ public class AccountsManager {
final UUID uuid = account.getUuid();
final UUID phoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(number);
final Account numberChangedAccount = updateWithRetries(
account,
a -> true,
a -> accounts.changeNumber(a, number, phoneNumberIdentifier),
() -> accounts.getByAccountIdentifier(uuid).orElseThrow());
final Account numberChangedAccount;
try {
numberChangedAccount = updateWithRetries(
account,
a -> true,
a -> accounts.changeNumber(a, number, phoneNumberIdentifier),
() -> accounts.getByAccountIdentifier(uuid).orElseThrow());
} catch (UsernameNotAvailableException e) {
// This should never happen when changing numbers
throw new RuntimeException(e);
}
updatedAccount.set(numberChangedAccount);
directoryQueue.changePhoneNumber(numberChangedAccount, originalNumber, number);
@ -251,13 +265,51 @@ public class AccountsManager {
return updatedAccount.get();
}
public Account setUsername(final Account account, final String username) throws UsernameNotAvailableException {
if (account.getUsername().map(username::equals).orElse(false)) {
return account;
}
if (reservedUsernames.isReserved(username, account.getUuid())) {
throw new UsernameNotAvailableException();
}
redisDelete(account);
return updateWithRetries(
account,
a -> true,
a -> accounts.setUsername(a, username),
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow());
}
public Account clearUsername(final Account account) {
redisDelete(account);
try {
return updateWithRetries(
account,
a -> true,
accounts::clearUsername,
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow());
} catch (UsernameNotAvailableException e) {
// This should never happen
throw new RuntimeException(e);
}
}
public Account update(Account account, Consumer<Account> updater) {
return update(account, a -> {
updater.accept(a);
// assume that all updaters passed to the public method actually modify the account
return true;
});
try {
return update(account, a -> {
updater.accept(a);
// assume that all updaters passed to the public method actually modify the account
return true;
});
} catch (UsernameNotAvailableException e) {
// This should never happen for general-purpose, public account updates
throw new RuntimeException(e);
}
}
/**
@ -266,28 +318,33 @@ public class AccountsManager {
*/
public Account updateDeviceLastSeen(Account account, Device device, final long lastSeen) {
return update(account, a -> {
try {
return update(account, a -> {
final Optional<Device> maybeDevice = a.getDevice(device.getId());
final Optional<Device> maybeDevice = a.getDevice(device.getId());
return maybeDevice.map(d -> {
if (d.getLastSeen() >= lastSeen) {
return false;
}
return maybeDevice.map(d -> {
if (d.getLastSeen() >= lastSeen) {
return false;
}
d.setLastSeen(lastSeen);
d.setLastSeen(lastSeen);
return true;
return true;
}).orElse(false);
});
}).orElse(false);
});
} catch (UsernameNotAvailableException e) {
// This should never happen when updating last-seen timestamps
throw new RuntimeException(e);
}
}
/**
* @param account account to update
* @param updater must return {@code true} if the account was actually updated
*/
private Account update(Account account, Function<Account, Boolean> updater) {
private Account update(Account account, Function<Account, Boolean> updater) throws UsernameNotAvailableException {
final boolean wasVisibleBeforeUpdate = account.shouldBeVisibleInDirectory();
@ -300,6 +357,7 @@ public class AccountsManager {
final UUID uuid = account.getUuid();
final String originalNumber = account.getNumber();
final UUID originalPhoneNumberIdentifier = account.getPhoneNumberIdentifier();
final Optional<String> originalUsername = account.getUsername();
updatedAccount = updateWithRetries(account,
updater,
@ -320,6 +378,13 @@ public class AccountsManager {
new RuntimeException());
}
assert updatedAccount.getUsername().equals(originalUsername);
if (!updatedAccount.getUsername().equals(originalUsername)) {
logger.error("Username changed via \"normal\" update; usernames must be changed via setUsername method",
new RuntimeException());
}
redisSet(updatedAccount);
}
@ -332,8 +397,8 @@ public class AccountsManager {
return updatedAccount;
}
private Account updateWithRetries(Account account, Function<Account, Boolean> updater, Consumer<Account> persister,
Supplier<Account> retriever) {
private Account updateWithRetries(Account account, Function<Account, Boolean> updater, AccountPersister persister,
Supplier<Account> retriever) throws UsernameNotAvailableException {
if (!updater.apply(account)) {
return account;
@ -345,7 +410,7 @@ public class AccountsManager {
while (tries < maxTries) {
try {
persister.accept(account);
persister.persistAccount(account);
final Account updatedAccount;
try {
@ -373,11 +438,16 @@ public class AccountsManager {
}
public Account updateDevice(Account account, long deviceId, Consumer<Device> deviceUpdater) {
return update(account, a -> {
a.getDevice(deviceId).ifPresent(deviceUpdater);
// assume that all updaters passed to the public method actually modify the device
return true;
});
try {
return update(account, a -> {
a.getDevice(deviceId).ifPresent(deviceUpdater);
// assume that all updaters passed to the public method actually modify the device
return true;
});
} catch (UsernameNotAvailableException e) {
// This should never happen when updating devices
throw new RuntimeException(e);
}
}
public Optional<Account> getByE164(String number) {
@ -406,6 +476,19 @@ public class AccountsManager {
}
}
public Optional<Account> getByUsername(final String username) {
try (final Timer.Context ignored = getByUsernameTimer.time()) {
Optional<Account> account = redisGetByUsername(username);
if (account.isEmpty()) {
account = accounts.getByUsername(username);
account.ifPresent(this::redisSet);
}
return account;
}
}
public Optional<Account> getByAccountIdentifier(UUID uuid) {
try (Timer.Context ignored = getByUuidTimer.time()) {
Optional<Account> account = redisGetByAccountIdentifier(uuid);
@ -450,7 +533,6 @@ public class AccountsManager {
final CompletableFuture<Void> deleteStorageServiceDataFuture = secureStorageClient.deleteStoredData(account.getUuid());
final CompletableFuture<Void> deleteBackupServiceDataFuture = secureBackupClient.deleteBackups(account.getUuid());
usernamesManager.delete(account.getUuid());
profilesManager.deleteAll(account.getUuid());
keys.delete(account.getUuid());
keys.delete(account.getPhoneNumberIdentifier());
@ -486,6 +568,9 @@ public class AccountsManager {
commands.setex(getAccountMapKey(account.getPhoneNumberIdentifier().toString()), CACHE_TTL_SECONDS, account.getUuid().toString());
commands.setex(getAccountMapKey(account.getNumber()), CACHE_TTL_SECONDS, account.getUuid().toString());
commands.setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson);
account.getUsername().ifPresent(username ->
commands.setex(getAccountMapKey(username), CACHE_TTL_SECONDS, account.getUuid().toString()));
});
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
@ -500,6 +585,10 @@ public class AccountsManager {
return redisGetBySecondaryKey(e164, redisNumberGetTimer);
}
private Optional<Account> redisGetByUsername(String username) {
return redisGetBySecondaryKey(username, redisUsernameGetTimer);
}
private Optional<Account> redisGetBySecondaryKey(String secondaryKey, Timer timer) {
try (Timer.Context ignored = timer.time()) {
final String uuid = cacheCluster.withCluster(connection -> connection.sync().get(getAccountMapKey(secondaryKey)));
@ -542,10 +631,14 @@ public class AccountsManager {
private void redisDelete(final Account account) {
try (final Timer.Context ignored = redisDeleteTimer.time()) {
cacheCluster.useCluster(connection -> connection.sync().del(
getAccountMapKey(account.getNumber()),
getAccountMapKey(account.getPhoneNumberIdentifier().toString()),
getAccountEntityKey(account.getUuid())));
cacheCluster.useCluster(connection -> {
connection.sync().del(
getAccountMapKey(account.getNumber()),
getAccountMapKey(account.getPhoneNumberIdentifier().toString()),
getAccountEntityKey(account.getUuid()));
account.getUsername().ifPresent(username -> connection.sync().del(getAccountMapKey(username)));
});
}
}
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
public class UsernameNotAvailableException extends Exception {
}

View File

@ -1,90 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static com.codahale.metrics.MetricRegistry.name;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import org.jdbi.v3.core.JdbiException;
import org.whispersystems.textsecuregcm.util.Constants;
public class Usernames {
public static final String ID = "id";
public static final String UID = "uuid";
public static final String USERNAME = "username";
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Timer createTimer = metricRegistry.timer(name(Usernames.class, "create" ));
private final Timer deleteTimer = metricRegistry.timer(name(Usernames.class, "delete" ));
private final Timer getByUsernameTimer = metricRegistry.timer(name(Usernames.class, "getByUsername"));
private final Timer getByUuidTimer = metricRegistry.timer(name(Usernames.class, "getByUuid" ));
private final FaultTolerantDatabase database;
public Usernames(FaultTolerantDatabase database) {
this.database = database;
}
public boolean put(UUID uuid, String username) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = createTimer.time()) {
int modified = handle.createUpdate("INSERT INTO usernames (" + UID + ", " + USERNAME + ") VALUES (:uuid, :username) ON CONFLICT (" + UID + ") DO UPDATE SET " + USERNAME + " = EXCLUDED.username")
.bind("uuid", uuid)
.bind("username", username)
.execute();
return modified > 0;
} catch (JdbiException e) {
if (e.getCause() instanceof SQLException) {
if (((SQLException)e.getCause()).getSQLState().equals("23505")) {
return false;
}
}
throw e;
}
}));
}
public void delete(UUID uuid) {
database.use(jdbi -> jdbi.useHandle(handle -> {
try (Timer.Context ignored = deleteTimer.time()) {
handle.createUpdate("DELETE FROM usernames WHERE " + UID + " = :uuid")
.bind("uuid", uuid)
.execute();
}
}));
}
public Optional<UUID> get(String username) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = getByUsernameTimer.time()) {
return handle.createQuery("SELECT " + UID + " FROM usernames WHERE " + USERNAME + " = :username")
.bind("username", username)
.mapTo(UUID.class)
.findFirst();
}
}));
}
public Optional<String> get(UUID uuid) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = getByUuidTimer.time()) {
return handle.createQuery("SELECT " + USERNAME + " FROM usernames WHERE " + UID + " = :uuid")
.bind("uuid", uuid)
.mapTo(String.class)
.findFirst();
}
}));
}
}

View File

@ -1,181 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
import org.whispersystems.textsecuregcm.util.Constants;
import java.util.Optional;
import java.util.UUID;
import static com.codahale.metrics.MetricRegistry.name;
public class UsernamesManager {
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private static final Timer createTimer = metricRegistry.timer(name(UsernamesManager.class, "create" ));
private static final Timer deleteTimer = metricRegistry.timer(name(UsernamesManager.class, "delete" ));
private static final Timer getByUuidTimer = metricRegistry.timer(name(UsernamesManager.class, "getByUuid" ));
private static final Timer getByUsernameTimer = metricRegistry.timer(name(UsernamesManager.class, "getByUsername" ));
private static final Timer redisSetTimer = metricRegistry.timer(name(UsernamesManager.class, "redisSet" ));
private static final Timer redisUuidGetTimer = metricRegistry.timer(name(UsernamesManager.class, "redisUuidGet" ));
private static final Timer redisUsernameGetTimer = metricRegistry.timer(name(UsernamesManager.class, "redisUsernameGet"));
private final Logger logger = LoggerFactory.getLogger(UsernamesManager.class);
private final Usernames usernames;
private final ReservedUsernames reservedUsernames;
private final FaultTolerantRedisCluster cacheCluster;
public UsernamesManager(Usernames usernames, ReservedUsernames reservedUsernames, FaultTolerantRedisCluster cacheCluster) {
this.usernames = usernames;
this.reservedUsernames = reservedUsernames;
this.cacheCluster = cacheCluster;
}
public boolean put(UUID uuid, String username) {
try (Timer.Context ignored = createTimer.time()) {
if (reservedUsernames.isReserved(username, uuid)) {
return false;
}
if (databasePut(uuid, username)) {
redisSet(uuid, username, true);
return true;
}
return false;
}
}
public Optional<UUID> get(String username) {
try (Timer.Context ignored = getByUsernameTimer.time()) {
Optional<UUID> uuid = redisGet(username);
if (uuid.isPresent()) {
return uuid;
}
Optional<UUID> retrieved = databaseGet(username);
retrieved.ifPresent(retrievedUuid -> redisSet(retrievedUuid, username, false));
return retrieved;
}
}
public Optional<String> get(UUID uuid) {
try (Timer.Context ignored = getByUuidTimer.time()) {
Optional<String> username = redisGet(uuid);
if (username.isPresent()) {
return username;
}
Optional<String> retrieved = databaseGet(uuid);
retrieved.ifPresent(retrievedUsername -> redisSet(uuid, retrievedUsername, false));
return retrieved;
}
}
public void delete(UUID uuid) {
try (Timer.Context ignored = deleteTimer.time()) {
redisDelete(uuid);
databaseDelete(uuid);
}
}
private boolean databasePut(UUID uuid, String username) {
return usernames.put(uuid, username);
}
private Optional<UUID> databaseGet(String username) {
return usernames.get(username);
}
private void databaseDelete(UUID uuid) {
usernames.delete(uuid);
}
private Optional<String> databaseGet(UUID uuid) {
return usernames.get(uuid);
}
private void redisSet(UUID uuid, String username, boolean required) {
final String uuidMapKey = getUuidMapKey(uuid);
final String usernameMapKey = getUsernameMapKey(username);
try (Timer.Context ignored = redisSetTimer.time()) {
cacheCluster.useCluster(connection -> {
final RedisAdvancedClusterCommands<String, String> commands = connection.sync();
final Optional<String> maybeOldUsername = Optional.ofNullable(commands.get(uuidMapKey));
maybeOldUsername.ifPresent(oldUsername -> commands.del(getUsernameMapKey(oldUsername)));
commands.set(uuidMapKey, username);
commands.set(usernameMapKey, uuid.toString());
});
} catch (RedisException e) {
if (required) throw e;
else logger.warn("Ignoring Redis failure", e);
}
}
private Optional<UUID> redisGet(String username) {
try (Timer.Context ignored = redisUsernameGetTimer.time()) {
final String result = cacheCluster.withCluster(connection -> connection.sync().get(getUsernameMapKey(username)));
if (result == null) return Optional.empty();
else return Optional.of(UUID.fromString(result));
} catch (RedisException e) {
logger.warn("Redis get failure", e);
return Optional.empty();
}
}
private Optional<String> redisGet(UUID uuid) {
try (Timer.Context ignored = redisUuidGetTimer.time()) {
final String result = cacheCluster.withCluster(connection -> connection.sync().get(getUuidMapKey(uuid)));
return Optional.ofNullable(result);
} catch (RedisException e) {
logger.warn("Redis get failure", e);
return Optional.empty();
}
}
private void redisDelete(UUID uuid) {
try (Timer.Context ignored = redisUuidGetTimer.time()) {
cacheCluster.useCluster(connection -> {
final RedisAdvancedClusterCommands<String, String> commands = connection.sync();
commands.del(getUuidMapKey(uuid));
redisGet(uuid).ifPresent(username -> {
commands.del(getUsernameMapKey(username));
});
});
}
}
private String getUuidMapKey(UUID uuid) {
return "UsernameByUuid::" + uuid.toString();
}
private String getUsernameMapKey(String username) {
return "UsernameByUsername::" + username;
}
}

View File

@ -56,8 +56,6 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.Usernames;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
@ -175,10 +173,10 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
configuration.getAccountsDynamoDbConfiguration().getTableName(),
configuration.getAccountsDynamoDbConfiguration().getPhoneNumberTableName(),
configuration.getAccountsDynamoDbConfiguration().getPhoneNumberIdentifierTableName(),
configuration.getAccountsDynamoDbConfiguration().getUsernamesTableName(),
configuration.getAccountsDynamoDbConfiguration().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(phoneNumberIdentifiersDynamoDbClient,
configuration.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
Profiles profiles = new Profiles(accountDatabase);
ProfilesDynamoDb profilesDynamoDb = new ProfilesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
@ -210,7 +208,6 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
DirectoryQueue directoryQueue = new DirectoryQueue(
configuration.getDirectoryConfiguration().getSqsConfiguration());
UsernamesManager usernamesManager = new UsernamesManager(usernames, reservedUsernames, cacheCluster);
ProfilesManager profilesManager = new ProfilesManager(profiles, profilesDynamoDb, cacheCluster,
dynamicConfigurationManager);
ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(reportMessagesDynamoDb,
@ -225,7 +222,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
configuration.getDeletedAccountsLockDynamoDbConfiguration().getTableName());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, usernamesManager, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
for (String user : users) {

View File

@ -54,8 +54,6 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.Usernames;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
@ -179,10 +177,10 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
configuration.getAccountsDynamoDbConfiguration().getTableName(),
configuration.getAccountsDynamoDbConfiguration().getPhoneNumberTableName(),
configuration.getAccountsDynamoDbConfiguration().getPhoneNumberIdentifierTableName(),
configuration.getAccountsDynamoDbConfiguration().getUsernamesTableName(),
configuration.getAccountsDynamoDbConfiguration().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(phoneNumberIdentifiersDynamoDbClient,
configuration.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
Profiles profiles = new Profiles(accountDatabase);
ProfilesDynamoDb profilesDynamoDb = new ProfilesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
@ -212,7 +210,6 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
DirectoryQueue directoryQueue = new DirectoryQueue(
configuration.getDirectoryConfiguration().getSqsConfiguration());
UsernamesManager usernamesManager = new UsernamesManager(usernames, reservedUsernames, cacheCluster);
ProfilesManager profilesManager = new ProfilesManager(profiles, profilesDynamoDb, cacheCluster,
dynamicConfigurationManager);
ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(reportMessagesDynamoDb,
@ -227,7 +224,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
configuration.getDeletedAccountsLockDynamoDbConfiguration().getTableName());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, usernamesManager, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
Optional<Account> maybeAccount;

View File

@ -51,6 +51,7 @@ class AccountsManagerChangeNumberIntegrationTest {
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 String NEEDS_RECONCILIATION_INDEX_NAME = "needs_reconciliation_test";
private static final String DELETED_ACCOUNTS_LOCK_TABLE_NAME = "deleted_accounts_lock_test";
@ -155,6 +156,7 @@ class AccountsManagerChangeNumberIntegrationTest {
ACCOUNTS_DYNAMO_EXTENSION.getTableName(),
NUMBERS_TABLE_NAME,
PNI_ASSIGNMENT_TABLE_NAME,
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE);
{
@ -191,7 +193,7 @@ class AccountsManagerChangeNumberIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(UsernamesManager.class),
mock(ReservedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
secureStorageClient,

View File

@ -59,6 +59,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
private static final String ACCOUNTS_TABLE_NAME = "accounts_test";
private static final String NUMBERS_TABLE_NAME = "numbers_test";
private static final String PNI_TABLE_NAME = "pni_test";
private static final String USERNAMES_TABLE_NAME = "usernames_test";
private static final int SCAN_PAGE_SIZE = 1;
@ -122,6 +123,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
dynamoDbExtension.getTableName(),
NUMBERS_TABLE_NAME,
PNI_TABLE_NAME,
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE);
{
@ -148,7 +150,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(UsernamesManager.class),
mock(ReservedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),

View File

@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -44,25 +45,14 @@ import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.stubbing.Answer;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ContestedOptimisticLockException;
import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
import org.whispersystems.textsecuregcm.storage.Keys;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
class AccountsManagerTest {
@ -73,10 +63,13 @@ class AccountsManagerTest {
private Keys keys;
private MessagesManager messagesManager;
private ProfilesManager profilesManager;
private ReservedUsernames reservedUsernames;
private RedisAdvancedClusterCommands<String, String> commands;
private AccountsManager accountsManager;
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
private static final Answer<?> ACCOUNT_UPDATE_ANSWER = (answer) -> {
// it is implicit in the update() contract is that a successful call will
// result in an incremented version
@ -93,6 +86,7 @@ class AccountsManagerTest {
keys = mock(Keys.class);
messagesManager = mock(MessagesManager.class);
profilesManager = mock(ProfilesManager.class);
reservedUsernames = mock(ReservedUsernames.class);
//noinspection unchecked
commands = mock(RedisAdvancedClusterCommands.class);
@ -127,6 +121,13 @@ class AccountsManagerTest {
return phoneNumberIdentifiersByE164.computeIfAbsent(number, n -> UUID.randomUUID());
});
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
@ -135,7 +136,7 @@ class AccountsManagerTest {
directoryQueue,
keys,
messagesManager,
mock(UsernamesManager.class),
reservedUsernames,
profilesManager,
mock(StoredVerificationCodeManager.class),
storageClient,
@ -207,6 +208,29 @@ class AccountsManagerTest {
verifyNoInteractions(accounts);
}
@Test
void testGetByUsernameInCache() {
UUID uuid = UUID.randomUUID();
String username = "test";
when(commands.get(eq("AccountMap::" + username))).thenReturn(uuid.toString());
when(commands.get(eq("Account3::" + uuid))).thenReturn("{\"number\": \"+14152222222\", \"name\": \"test\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\", \"username\": \"test\"}");
Optional<Account> account = accountsManager.getByUsername(username);
assertTrue(account.isPresent());
assertEquals(account.get().getNumber(), "+14152222222");
assertEquals(account.get().getProfileName(), "test");
assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier());
assertEquals(Optional.of(username), account.get().getUsername());
verify(commands).get(eq("AccountMap::" + username));
verify(commands).get(eq("Account3::" + uuid));
verifyNoMoreInteractions(commands);
verifyNoInteractions(accounts);
}
@Test
void testGetAccountByNumberNotInCache() {
UUID uuid = UUID.randomUUID();
@ -280,6 +304,33 @@ class AccountsManagerTest {
verifyNoMoreInteractions(accounts);
}
@Test
void testGetAccountByUsernameNotInCache() {
UUID uuid = UUID.randomUUID();
String username = "test";
Account account = new Account("+14152222222", uuid, UUID.randomUUID(), new HashSet<>(), new byte[16]);
account.setUsername(username);
when(commands.get(eq("AccountMap::" + username))).thenReturn(null);
when(accounts.getByUsername(username)).thenReturn(Optional.of(account));
Optional<Account> retrieved = accountsManager.getByUsername(username);
assertTrue(retrieved.isPresent());
assertSame(retrieved.get(), account);
verify(commands).get(eq("AccountMap::" + username));
verify(commands).setex(eq("AccountMap::" + username), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString());
verifyNoMoreInteractions(commands);
verify(accounts).getByUsername(username);
verifyNoMoreInteractions(accounts);
}
@Test
void testGetAccountByNumberBrokenCache() {
UUID uuid = UUID.randomUUID();
@ -353,6 +404,33 @@ class AccountsManagerTest {
verifyNoMoreInteractions(accounts);
}
@Test
void testGetAccountByUsernameBrokenCache() {
UUID uuid = UUID.randomUUID();
String username = "test";
Account account = new Account("+14152222222", uuid, UUID.randomUUID(), new HashSet<>(), new byte[16]);
account.setUsername(username);
when(commands.get(eq("AccountMap::" + username))).thenThrow(new RedisException("OH NO"));
when(accounts.getByUsername(username)).thenReturn(Optional.of(account));
Optional<Account> retrieved = accountsManager.getByUsername(username);
assertTrue(retrieved.isPresent());
assertSame(retrieved.get(), account);
verify(commands).get(eq("AccountMap::" + username));
verify(commands).setex(eq("AccountMap::" + username), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString()));
verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString());
verifyNoMoreInteractions(commands);
verify(accounts).getByUsername(username);
verifyNoMoreInteractions(accounts);
}
@Test
void testUpdate_optimisticLockingFailure() {
UUID uuid = UUID.randomUUID();
@ -627,4 +705,54 @@ class AccountsManagerTest {
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setNumber(targetNumber, UUID.randomUUID())));
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
final Account account = new Account("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new HashSet<>(), new byte[16]);
final String username = "test";
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
verify(accounts).setUsername(account, username);
}
@Test
void testSetUsernameSameUsername() throws UsernameNotAvailableException {
final Account account = new Account("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new HashSet<>(), new byte[16]);
final String username = "test";
account.setUsername(username);
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
verify(accounts, never()).setUsername(eq(account), any());
}
@Test
void testSetUsernameNotAvailable() throws UsernameNotAvailableException {
final Account account = new Account("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new HashSet<>(), new byte[16]);
final String username = "test";
doThrow(new UsernameNotAvailableException()).when(accounts).setUsername(account, username);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
verify(accounts).setUsername(account, username);
assertTrue(account.getUsername().isEmpty());
}
@Test
void testSetUsernameReserved() {
final String username = "reserved";
when(reservedUsernames.isReserved(eq(username), any())).thenReturn(true);
final Account account = new Account("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new HashSet<>(), new byte[16]);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
assertTrue(account.getUsername().isEmpty());
}
@Test
void testSetUsernameViaUpdate() {
final Account account = new Account("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new HashSet<>(), new byte[16]);
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsername("test")));
}
}

View File

@ -6,6 +6,8 @@
package org.whispersystems.textsecuregcm.storage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
@ -19,7 +21,6 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@ -38,8 +39,6 @@ import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.CancellationReason;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
@ -61,6 +60,7 @@ class AccountsTest {
private static final String ACCOUNTS_TABLE_NAME = "accounts_test";
private static final String NUMBER_CONSTRAINT_TABLE_NAME = "numbers_test";
private static final String PNI_CONSTRAINT_TABLE_NAME = "pni_test";
private static final String USERNAME_CONSTRAINT_TABLE_NAME = "username_test";
private static final int SCAN_PAGE_SIZE = 1;
@ -108,11 +108,27 @@ class AccountsTest {
dynamoDbExtension.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder()
.tableName(USERNAME_CONSTRAINT_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();
dynamoDbExtension.getDynamoDbClient().createTable(createUsernamesTableRequest);
this.accounts = new Accounts(
dynamoDbExtension.getDynamoDbClient(),
dynamoDbExtension.getTableName(),
NUMBER_CONSTRAINT_TABLE_NAME,
PNI_CONSTRAINT_TABLE_NAME,
USERNAME_CONSTRAINT_TABLE_NAME,
SCAN_PAGE_SIZE);
}
@ -357,7 +373,7 @@ class AccountsTest {
final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);
accounts = new Accounts(dynamoDbClient,
dynamoDbExtension.getTableName(), NUMBER_CONSTRAINT_TABLE_NAME, PNI_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
dynamoDbExtension.getTableName(), NUMBER_CONSTRAINT_TABLE_NAME, PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
when(dynamoDbClient.updateItem(any(UpdateItemRequest.class)))
.thenThrow(TransactionConflictException.class);
@ -495,7 +511,7 @@ class AccountsTest {
.thenThrow(RuntimeException.class);
Accounts accounts = new Accounts(client, ACCOUNTS_TABLE_NAME, NUMBER_CONSTRAINT_TABLE_NAME,
PNI_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
try {
@ -646,6 +662,118 @@ class AccountsTest {
assertThrows(TransactionCanceledException.class, () -> accounts.changeNumber(account, targetNumber, existingPhoneNumberIdentifier));
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
final String username = "test";
assertThat(accounts.getByUsername(username)).isEmpty();
accounts.setUsername(account, username);
{
final Optional<Account> maybeAccount = accounts.getByUsername(username);
assertThat(maybeAccount).hasValueSatisfying(retrievedAccount ->
assertThat(retrievedAccount.getUsername()).hasValueSatisfying(retrievedUsername ->
assertThat(retrievedUsername).isEqualTo(username)));
verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), maybeAccount.orElseThrow(), account);
}
final String secondUsername = username + "2";
accounts.setUsername(account, secondUsername);
assertThat(accounts.getByUsername(username)).isEmpty();
{
final Optional<Account> maybeAccount = accounts.getByUsername(secondUsername);
assertThat(maybeAccount).isPresent();
verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(),
maybeAccount.get(), account);
}
}
@Test
void testSetUsernameConflict() {
final Account firstAccount = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
final Account secondAccount = generateAccount("+18005559876", UUID.randomUUID(), UUID.randomUUID());
accounts.create(firstAccount);
accounts.create(secondAccount);
final String username = "test";
assertThatNoException().isThrownBy(() -> accounts.setUsername(firstAccount, username));
final Optional<Account> maybeAccount = accounts.getByUsername(username);
assertThat(maybeAccount).isPresent();
verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), maybeAccount.get(), firstAccount);
assertThatExceptionOfType(UsernameNotAvailableException.class)
.isThrownBy(() -> accounts.setUsername(secondAccount, username));
assertThat(secondAccount.getUsername()).isEmpty();
}
@Test
void testSetUsernameVersionMismatch() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
account.setVersion(account.getVersion() + 77);
assertThatExceptionOfType(ContestedOptimisticLockException.class)
.isThrownBy(() -> accounts.setUsername(account, "test"));
assertThat(account.getUsername()).isEmpty();
}
@Test
void testClearUsername() throws UsernameNotAvailableException {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
final String username = "test";
accounts.setUsername(account, username);
assertThat(accounts.getByUsername(username)).isPresent();
accounts.clearUsername(account);
assertThat(accounts.getByUsername(username)).isEmpty();
assertThat(accounts.getByAccountIdentifier(account.getUuid()))
.hasValueSatisfying(clearedAccount -> assertThat(clearedAccount.getUsername()).isEmpty());
}
@Test
void testClearUsernameNoUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
assertThatNoException().isThrownBy(() -> accounts.clearUsername(account));
}
@Test
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
final String username = "test";
accounts.setUsername(account, username);
account.setVersion(account.getVersion() + 12);
assertThatExceptionOfType(ContestedOptimisticLockException.class).isThrownBy(() -> accounts.clearUsername(account));
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
}
private Device generateDevice(long id) {
Random random = new Random(System.currentTimeMillis());
SignedPreKey signedPreKey = new SignedPreKey(random.nextInt(), "testPublicKey-" + random.nextInt(), "testSignature-" + random.nextInt());

View File

@ -90,7 +90,7 @@ import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Hex;
@ -141,7 +141,6 @@ class AccountControllerTest {
private static RecaptchaClient recaptchaClient = mock(RecaptchaClient.class);
private static GCMSender gcmSender = mock(GCMSender.class);
private static APNSender apnSender = mock(APNSender.class);
private static UsernamesManager usernamesManager = mock(UsernamesManager.class);
private static DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
@ -164,7 +163,6 @@ class AccountControllerTest {
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new AccountController(pendingAccountsManager,
accountsManager,
usernamesManager,
abusiveHostRules,
rateLimiters,
smsSender,
@ -242,8 +240,8 @@ class AccountControllerTest {
return account;
});
when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("n00bkiller"))).thenReturn(true);
when(usernamesManager.put(eq(AuthHelper.VALID_UUID), eq("takenusername"))).thenReturn(false);
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername"))
.thenThrow(new UsernameNotAvailableException());
{
DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
@ -293,7 +291,6 @@ class AccountControllerTest {
recaptchaClient,
gcmSender,
apnSender,
usernamesManager,
verifyExperimentEnrollmentManager);
clearInvocations(AuthHelper.DISABLED_DEVICE);
@ -1622,7 +1619,7 @@ class AccountControllerTest {
.delete();
assertThat(response.getStatus()).isEqualTo(204);
verify(usernamesManager, times(1)).delete(eq(AuthHelper.VALID_UUID));
verify(accountsManager).clearUsername(AuthHelper.VALID_ACCOUNT);
}
@Test

View File

@ -66,7 +66,6 @@ import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
@ -80,7 +79,6 @@ class ProfileControllerTest {
private static final Clock clock = mock(Clock.class);
private static final AccountsManager accountsManager = mock(AccountsManager.class);
private static final ProfilesManager profilesManager = mock(ProfilesManager.class);
private static final UsernamesManager usernamesManager = mock(UsernamesManager.class);
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final RateLimiter rateLimiter = mock(RateLimiter.class);
private static final RateLimiter usernameRateLimiter = mock(RateLimiter.class);
@ -107,7 +105,6 @@ class ProfileControllerTest {
rateLimiters,
accountsManager,
profilesManager,
usernamesManager,
dynamicConfigurationManager,
(acceptableLanguages, accountBadges, isSelf) -> List.of(new Badge("TEST", "other", "Test Badge",
"This badge is in unit tests.", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))
@ -156,6 +153,7 @@ class ProfileControllerTest {
when(profileAccount.isAnnouncementGroupSupported()).thenReturn(false);
when(profileAccount.isChangeNumberSupported()).thenReturn(false);
when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());
when(profileAccount.getUsername()).thenReturn(Optional.of("n00bkiller"));
Account capabilitiesAccount = mock(Account.class);
@ -171,8 +169,7 @@ class ProfileControllerTest {
when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(profileAccount));
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(profileAccount));
when(usernamesManager.get(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of("n00bkiller"));
when(usernamesManager.get("n00bkiller")).thenReturn(Optional.of(AuthHelper.VALID_UUID_TWO));
when(accountsManager.getByUsername("n00bkiller")).thenReturn(Optional.of(profileAccount));
when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(capabilitiesAccount));
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(capabilitiesAccount));
@ -183,7 +180,6 @@ class ProfileControllerTest {
clearInvocations(rateLimiter);
clearInvocations(accountsManager);
clearInvocations(usernamesManager);
clearInvocations(usernameRateLimiter);
clearInvocations(profilesManager);
}
@ -209,7 +205,6 @@ class ProfileControllerTest {
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
verify(accountsManager).getByAccountIdentifier(AuthHelper.VALID_UUID_TWO);
verify(usernamesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO));
verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);
}
@ -229,8 +224,7 @@ class ProfileControllerTest {
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
verify(accountsManager, times(1)).getByAccountIdentifier(eq(AuthHelper.VALID_UUID_TWO));
verify(usernamesManager, times(1)).get(eq("n00bkiller"));
verify(accountsManager).getByUsername("n00bkiller");
verify(usernameRateLimiter, times(1)).validate(eq(AuthHelper.VALID_UUID));
}
@ -265,8 +259,8 @@ class ProfileControllerTest {
assertThat(response.getStatus()).isEqualTo(404);
verify(usernamesManager, times(1)).get(eq("n00bkillerzzzzz"));
verify(usernameRateLimiter, times(1)).validate(eq(AuthHelper.VALID_UUID));
verify(accountsManager).getByUsername("n00bkillerzzzzz");
verify(usernameRateLimiter).validate(eq(AuthHelper.VALID_UUID));
}
@ -594,7 +588,6 @@ class ProfileControllerTest {
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
verify(accountsManager, times(1)).getByAccountIdentifier(eq(AuthHelper.VALID_UUID_TWO));
verify(usernamesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO));
verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"));
verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);

View File

@ -1,185 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import org.junit.Test;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.Usernames;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
import java.util.Optional;
import java.util.UUID;
import static junit.framework.TestCase.assertSame;
import static junit.framework.TestCase.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
public class UsernamesManagerTest {
@Test
public void testGetByUsernameInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUsername::n00bkiller"))).thenReturn(uuid.toString());
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<UUID> retrieved = usernamesManager.get("n00bkiller");
assertTrue(retrieved.isPresent());
assertEquals(retrieved.get(), uuid);
verify(commands, times(1)).get(eq("UsernameByUsername::n00bkiller"));
verifyNoMoreInteractions(commands);
verifyNoMoreInteractions(usernames);
}
@Test
public void testGetByUuidInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUuid::" + uuid.toString()))).thenReturn("n00bkiller");
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<String> retrieved = usernamesManager.get(uuid);
assertTrue(retrieved.isPresent());
assertEquals(retrieved.get(), "n00bkiller");
verify(commands, times(1)).get(eq("UsernameByUuid::" + uuid.toString()));
verifyNoMoreInteractions(commands);
verifyNoMoreInteractions(usernames);
}
@Test
public void testGetByUsernameNotInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUsername::n00bkiller"))).thenReturn(null);
when(usernames.get(eq("n00bkiller"))).thenReturn(Optional.of(uuid));
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<UUID> retrieved = usernamesManager.get("n00bkiller");
assertTrue(retrieved.isPresent());
assertSame(retrieved.get(), uuid);
verify(commands, times(1)).get(eq("UsernameByUsername::n00bkiller"));
verify(commands, times(1)).set(eq("UsernameByUsername::n00bkiller"), eq(uuid.toString()));
verify(commands, times(1)).set(eq("UsernameByUuid::" + uuid.toString()), eq("n00bkiller"));
verify(commands, times(1)).get(eq("UsernameByUuid::" + uuid.toString()));
verifyNoMoreInteractions(commands);
verify(usernames, times(1)).get(eq("n00bkiller"));
verifyNoMoreInteractions(usernames);
}
@Test
public void testGetByUuidNotInCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUuid::" + uuid.toString()))).thenReturn(null);
when(usernames.get(eq(uuid))).thenReturn(Optional.of("n00bkiller"));
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<String> retrieved = usernamesManager.get(uuid);
assertTrue(retrieved.isPresent());
assertEquals(retrieved.get(), "n00bkiller");
verify(commands, times(2)).get(eq("UsernameByUuid::" + uuid));
verify(commands, times(1)).set(eq("UsernameByUuid::" + uuid), eq("n00bkiller"));
verify(commands, times(1)).set(eq("UsernameByUsername::n00bkiller"), eq(uuid.toString()));
verifyNoMoreInteractions(commands);
verify(usernames, times(1)).get(eq(uuid));
verifyNoMoreInteractions(usernames);
}
@Test
public void testGetByUsernameBrokenCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUsername::n00bkiller"))).thenThrow(new RedisException("Connection lost!"));
when(usernames.get(eq("n00bkiller"))).thenReturn(Optional.of(uuid));
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<UUID> retrieved = usernamesManager.get("n00bkiller");
assertTrue(retrieved.isPresent());
assertEquals(retrieved.get(), uuid);
verify(commands, times(1)).get(eq("UsernameByUsername::n00bkiller"));
verify(commands, times(1)).set(eq("UsernameByUsername::n00bkiller"), eq(uuid.toString()));
verify(commands, times(1)).set(eq("UsernameByUuid::" + uuid.toString()), eq("n00bkiller"));
verify(commands, times(1)).get(eq("UsernameByUuid::" + uuid.toString()));
verifyNoMoreInteractions(commands);
verify(usernames, times(1)).get(eq("n00bkiller"));
verifyNoMoreInteractions(usernames);
}
@Test
public void testGetAccountByUuidBrokenCache() {
RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);
FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.buildMockRedisCluster(commands);
Usernames usernames = mock(Usernames.class);
ReservedUsernames reserved = mock(ReservedUsernames.class);
UUID uuid = UUID.randomUUID();
when(commands.get(eq("UsernameByUuid::" + uuid))).thenThrow(new RedisException("Connection lost!"));
when(usernames.get(eq(uuid))).thenReturn(Optional.of("n00bkiller"));
UsernamesManager usernamesManager = new UsernamesManager(usernames, reserved, cacheCluster);
Optional<String> retrieved = usernamesManager.get(uuid);
assertTrue(retrieved.isPresent());
assertEquals(retrieved.get(), "n00bkiller");
verify(commands, times(2)).get(eq("UsernameByUuid::" + uuid));
verifyNoMoreInteractions(commands);
verify(usernames, times(1)).get(eq(uuid));
verifyNoMoreInteractions(usernames);
}
}

View File

@ -1,169 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
import com.opentable.db.postgres.embedded.LiquibasePreparer;
import com.opentable.db.postgres.junit.EmbeddedPostgresRules;
import com.opentable.db.postgres.junit.PreparedDbRule;
import org.jdbi.v3.core.Jdbi;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase;
import org.whispersystems.textsecuregcm.storage.Usernames;
import java.io.IOException;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import static junit.framework.TestCase.assertTrue;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertFalse;
public class UsernamesTest {
@Rule
public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml"));
private Usernames usernames;
@Before
public void setupAccountsDao() {
FaultTolerantDatabase faultTolerantDatabase = new FaultTolerantDatabase("usernamesTest",
Jdbi.create(db.getTestDatabase()),
new CircuitBreakerConfiguration());
this.usernames = new Usernames(faultTolerantDatabase);
}
@Test
public void testPut() throws SQLException, IOException {
UUID uuid = UUID.randomUUID();
String username = "myusername";
assertTrue(usernames.put(uuid, username));
PreparedStatement statement = db.getTestDatabase().getConnection().prepareStatement("SELECT * FROM usernames WHERE uuid = ?");
verifyStoredState(statement, uuid, username);
}
@Test
public void testPutChange() throws SQLException, IOException {
UUID uuid = UUID.randomUUID();
String firstUsername = "myfirstusername";
String secondUsername = "mysecondusername";
assertTrue(usernames.put(uuid, firstUsername));
PreparedStatement statement = db.getTestDatabase().getConnection().prepareStatement("SELECT * FROM usernames WHERE uuid = ?");
verifyStoredState(statement, uuid, firstUsername);
assertTrue(usernames.put(uuid, secondUsername));
verifyStoredState(statement, uuid, secondUsername);
}
@Test
public void testPutConflict() throws SQLException {
UUID firstUuid = UUID.randomUUID();
UUID secondUuid = UUID.randomUUID();
String username = "myfirstusername";
assertTrue(usernames.put(firstUuid, username));
assertFalse(usernames.put(secondUuid, username));
PreparedStatement statement = db.getTestDatabase().getConnection().prepareStatement("SELECT * FROM usernames WHERE username = ?");
statement.setString(1, username);
ResultSet resultSet = statement.executeQuery();
assertTrue(resultSet.next());
assertThat(resultSet.getString("uuid")).isEqualTo(firstUuid.toString());
assertThat(resultSet.next()).isFalse();
}
@Test
public void testGetByUuid() {
UUID uuid = UUID.randomUUID();
String username = "myusername";
assertTrue(usernames.put(uuid, username));
Optional<String> retrieved = usernames.get(uuid);
assertTrue(retrieved.isPresent());
assertThat(retrieved.get()).isEqualTo(username);
}
@Test
public void testGetByUuidMissing() {
Optional<String> retrieved = usernames.get(UUID.randomUUID());
assertFalse(retrieved.isPresent());
}
@Test
public void testGetByUsername() {
UUID uuid = UUID.randomUUID();
String username = "myusername";
assertTrue(usernames.put(uuid, username));
Optional<UUID> retrieved = usernames.get(username);
assertTrue(retrieved.isPresent());
assertThat(retrieved.get()).isEqualTo(uuid);
}
@Test
public void testGetByUsernameMissing() {
Optional<UUID> retrieved = usernames.get("myusername");
assertFalse(retrieved.isPresent());
}
@Test
public void testDelete() {
UUID uuid = UUID.randomUUID();
String username = "myusername";
assertTrue(usernames.put(uuid, username));
Optional<UUID> retrieved = usernames.get(username);
assertTrue(retrieved.isPresent());
assertThat(retrieved.get()).isEqualTo(uuid);
usernames.delete(uuid);
assertThat(usernames.get(uuid).isPresent()).isFalse();
}
private void verifyStoredState(PreparedStatement statement, UUID uuid, String expectedUsername)
throws SQLException, IOException
{
statement.setObject(1, uuid);
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
String data = resultSet.getString("username");
assertThat(data).isNotEmpty();
assertThat(data).isEqualTo(expectedUsername);
} else {
throw new AssertionError("No data");
}
assertThat(resultSet.next()).isFalse();
}
}

View File

@ -103,6 +103,10 @@ public class AccountsHelper {
when(updatedAccount.getNumber()).thenAnswer(stubbing);
break;
}
case "getUsername": {
when(updatedAccount.getUsername()).thenAnswer(stubbing);
break;
}
case "getDevices": {
when(updatedAccount.getDevices())
.thenAnswer(stubbing);