mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 16:38:39 +01:00
Refactor/clarify account creation/reclamation process
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
class AccountAlreadyExistsException extends Exception {
|
||||
private final Account existingAccount;
|
||||
|
||||
public AccountAlreadyExistsException(final Account existingAccount) {
|
||||
this.existingAccount = existingAccount;
|
||||
}
|
||||
|
||||
public Account getExistingAccount() {
|
||||
return existingAccount;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import com.google.common.base.Throwables;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -29,15 +28,12 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.util.AsyncTimerUtil;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
@@ -186,84 +182,86 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
deletedAccountsTableName);
|
||||
}
|
||||
|
||||
public boolean create(final Account account,
|
||||
final Function<Account, Collection<TransactWriteItem>> additionalWriteItemsFunction,
|
||||
final BiFunction<UUID, UUID, CompletableFuture<Void>> existingAccountCleanupOperation) {
|
||||
boolean create(final Account account, final List<TransactWriteItem> additionalWriteItems)
|
||||
throws AccountAlreadyExistsException {
|
||||
|
||||
final Timer.Sample sample = Timer.start();
|
||||
|
||||
try {
|
||||
final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid());
|
||||
final AttributeValue numberAttr = AttributeValues.fromString(account.getNumber());
|
||||
final AttributeValue pniUuidAttr = AttributeValues.fromUUID(account.getPhoneNumberIdentifier());
|
||||
|
||||
final TransactWriteItem phoneNumberConstraintPut = buildConstraintTablePutIfAbsent(
|
||||
phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr);
|
||||
|
||||
final TransactWriteItem phoneNumberIdentifierConstraintPut = buildConstraintTablePutIfAbsent(
|
||||
phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniUuidAttr);
|
||||
|
||||
final TransactWriteItem accountPut = buildAccountPut(account, uuidAttr, numberAttr, pniUuidAttr);
|
||||
|
||||
// Clear any "recently deleted account" record for this number since, if it existed, we've used its old ACI for
|
||||
// the newly-created account.
|
||||
final TransactWriteItem deletedAccountDelete = buildRemoveDeletedAccount(account.getNumber());
|
||||
|
||||
final Collection<TransactWriteItem> writeItems = new ArrayList<>(
|
||||
List.of(phoneNumberConstraintPut, phoneNumberIdentifierConstraintPut, accountPut, deletedAccountDelete));
|
||||
|
||||
writeItems.addAll(additionalWriteItems);
|
||||
|
||||
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||
.transactItems(writeItems)
|
||||
.build();
|
||||
|
||||
return CREATE_TIMER.record(() -> {
|
||||
try {
|
||||
final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid());
|
||||
final AttributeValue numberAttr = AttributeValues.fromString(account.getNumber());
|
||||
final AttributeValue pniUuidAttr = AttributeValues.fromUUID(account.getPhoneNumberIdentifier());
|
||||
db().transactWriteItems(request);
|
||||
} catch (final TransactionCanceledException e) {
|
||||
|
||||
final TransactWriteItem phoneNumberConstraintPut = buildConstraintTablePutIfAbsent(
|
||||
phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr);
|
||||
final CancellationReason accountCancellationReason = e.cancellationReasons().get(2);
|
||||
|
||||
final TransactWriteItem phoneNumberIdentifierConstraintPut = buildConstraintTablePutIfAbsent(
|
||||
phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniUuidAttr);
|
||||
|
||||
final TransactWriteItem accountPut = buildAccountPut(account, uuidAttr, numberAttr, pniUuidAttr);
|
||||
|
||||
// Clear any "recently deleted account" record for this number since, if it existed, we've used its old ACI for
|
||||
// the newly-created account.
|
||||
final TransactWriteItem deletedAccountDelete = buildRemoveDeletedAccount(account.getNumber());
|
||||
|
||||
final Collection<TransactWriteItem> writeItems = new ArrayList<>(
|
||||
List.of(phoneNumberConstraintPut, phoneNumberIdentifierConstraintPut, accountPut, deletedAccountDelete));
|
||||
|
||||
writeItems.addAll(additionalWriteItemsFunction.apply(account));
|
||||
|
||||
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||
.transactItems(writeItems)
|
||||
.build();
|
||||
|
||||
try {
|
||||
db().transactWriteItems(request);
|
||||
} catch (final TransactionCanceledException e) {
|
||||
|
||||
final CancellationReason accountCancellationReason = e.cancellationReasons().get(2);
|
||||
|
||||
if (conditionalCheckFailed(accountCancellationReason)) {
|
||||
throw new IllegalArgumentException("account identifier present with different phone number");
|
||||
}
|
||||
|
||||
final CancellationReason phoneNumberConstraintCancellationReason = e.cancellationReasons().get(0);
|
||||
final CancellationReason phoneNumberIdentifierConstraintCancellationReason = e.cancellationReasons().get(1);
|
||||
|
||||
if (conditionalCheckFailed(phoneNumberConstraintCancellationReason)
|
||||
|| conditionalCheckFailed(phoneNumberIdentifierConstraintCancellationReason)) {
|
||||
|
||||
// In theory, both reasons should trip in tandem and either should give us the information we need. Even so,
|
||||
// we'll be cautious here and make sure we're choosing a condition check that really failed.
|
||||
final CancellationReason reason = conditionalCheckFailed(phoneNumberConstraintCancellationReason)
|
||||
? phoneNumberConstraintCancellationReason
|
||||
: phoneNumberIdentifierConstraintCancellationReason;
|
||||
|
||||
final ByteBuffer actualAccountUuid = reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer();
|
||||
account.setUuid(UUIDUtil.fromByteBuffer(actualAccountUuid));
|
||||
final Account existingAccount = getByAccountIdentifier(account.getUuid()).orElseThrow();
|
||||
account.setNumber(existingAccount.getNumber(), existingAccount.getPhoneNumberIdentifier());
|
||||
|
||||
existingAccountCleanupOperation.apply(existingAccount.getIdentifier(IdentityType.ACI), existingAccount.getIdentifier(IdentityType.PNI))
|
||||
.thenCompose(ignored -> reclaimAccount(existingAccount, account, additionalWriteItemsFunction.apply(account)))
|
||||
.join();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TRANSACTION_CONFLICT.equals(accountCancellationReason.code())) {
|
||||
// this should only happen if two clients manage to make concurrent create() calls
|
||||
throw new ContestedOptimisticLockException();
|
||||
}
|
||||
|
||||
// this shouldn't happen
|
||||
throw new RuntimeException("could not create account: " + extractCancellationReasonCodes(e));
|
||||
if (conditionalCheckFailed(accountCancellationReason)) {
|
||||
throw new IllegalArgumentException("account identifier present with different phone number");
|
||||
}
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
|
||||
final CancellationReason phoneNumberConstraintCancellationReason = e.cancellationReasons().get(0);
|
||||
final CancellationReason phoneNumberIdentifierConstraintCancellationReason = e.cancellationReasons().get(1);
|
||||
|
||||
if (conditionalCheckFailed(phoneNumberConstraintCancellationReason)
|
||||
|| conditionalCheckFailed(phoneNumberIdentifierConstraintCancellationReason)) {
|
||||
|
||||
// In theory, both reasons should trip in tandem and either should give us the information we need. Even so,
|
||||
// we'll be cautious here and make sure we're choosing a condition check that really failed.
|
||||
final CancellationReason reason = conditionalCheckFailed(phoneNumberConstraintCancellationReason)
|
||||
? phoneNumberConstraintCancellationReason
|
||||
: phoneNumberIdentifierConstraintCancellationReason;
|
||||
|
||||
final UUID existingAccountUuid =
|
||||
UUIDUtil.fromByteBuffer(reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer());
|
||||
|
||||
// This is unlikely, but it could be that the existing account was deleted in between the time the transaction
|
||||
// happened and when we tried to read the full existing account. If that happens, we can just consider this a
|
||||
// contested lock, and retrying is likely to succeed.
|
||||
final Account existingAccount = getByAccountIdentifier(existingAccountUuid)
|
||||
.orElseThrow(ContestedOptimisticLockException::new);
|
||||
|
||||
throw new AccountAlreadyExistsException(existingAccount);
|
||||
}
|
||||
|
||||
if (TRANSACTION_CONFLICT.equals(accountCancellationReason.code())) {
|
||||
// this should only happen if two clients manage to make concurrent create() calls
|
||||
throw new ContestedOptimisticLockException();
|
||||
}
|
||||
|
||||
// this shouldn't happen
|
||||
throw new RuntimeException("could not create account: " + extractCancellationReasonCodes(e));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
} finally {
|
||||
sample.stop(CREATE_TIMER);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,9 +270,13 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
* @param existingAccount the existing account in the accounts table
|
||||
* @param accountToCreate a new account, with the same number and identifier as existingAccount
|
||||
*/
|
||||
private CompletionStage<Void> reclaimAccount(final Account existingAccount, final Account accountToCreate, final Collection<TransactWriteItem> additionalWriteItems) {
|
||||
CompletionStage<Void> reclaimAccount(final Account existingAccount,
|
||||
final Account accountToCreate,
|
||||
final Collection<TransactWriteItem> additionalWriteItems) {
|
||||
|
||||
if (!existingAccount.getUuid().equals(accountToCreate.getUuid()) ||
|
||||
!existingAccount.getNumber().equals(accountToCreate.getNumber())) {
|
||||
|
||||
throw new IllegalArgumentException("reclaimed accounts must match");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.google.common.base.Preconditions;
|
||||
import io.lettuce.core.RedisException;
|
||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.time.Clock;
|
||||
@@ -182,80 +181,79 @@ public class AccountsManager {
|
||||
final IdentityKey pniIdentityKey,
|
||||
final DeviceSpec primaryDeviceSpec) throws InterruptedException {
|
||||
|
||||
try (Timer.Context ignored = createTimer.time()) {
|
||||
final Account account = new Account();
|
||||
final Account account = new Account();
|
||||
|
||||
try (Timer.Context ignoredTimerContext = createTimer.time()) {
|
||||
accountLockManager.withLock(List.of(number), () -> {
|
||||
final Device device = primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock);
|
||||
|
||||
account.setNumber(number, phoneNumberIdentifiers.getPhoneNumberIdentifier(number));
|
||||
|
||||
final Optional<UUID> maybeRecentlyDeletedAccountIdentifier =
|
||||
accounts.findRecentlyDeletedAccountIdentifier(number);
|
||||
|
||||
// Reuse the ACI from any recently-deleted account with this number to cover cases where somebody is
|
||||
// re-registering.
|
||||
account.setNumber(number, phoneNumberIdentifiers.getPhoneNumberIdentifier(number));
|
||||
account.setUuid(maybeRecentlyDeletedAccountIdentifier.orElseGet(UUID::randomUUID));
|
||||
account.setIdentityKey(aciIdentityKey);
|
||||
account.setPhoneNumberIdentityKey(pniIdentityKey);
|
||||
account.addDevice(device);
|
||||
account.addDevice(primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock));
|
||||
account.setRegistrationLockFromAttributes(accountAttributes);
|
||||
account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());
|
||||
account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());
|
||||
account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber());
|
||||
account.setBadges(clock, accountBadges);
|
||||
|
||||
final UUID originalUuid = account.getUuid();
|
||||
String accountCreationType = maybeRecentlyDeletedAccountIdentifier.isPresent() ? "recently-deleted" : "new";
|
||||
|
||||
final boolean freshUser = accounts.create(account,
|
||||
a -> keysManager.buildWriteItemsForRepeatedUseKeys(a.getIdentifier(IdentityType.ACI),
|
||||
a.getIdentifier(IdentityType.PNI),
|
||||
Device.PRIMARY_ID,
|
||||
primaryDeviceSpec.aciSignedPreKey(),
|
||||
primaryDeviceSpec.pniSignedPreKey(),
|
||||
primaryDeviceSpec.aciPqLastResortPreKey(),
|
||||
primaryDeviceSpec.pniPqLastResortPreKey()),
|
||||
(aci, pni) -> CompletableFuture.allOf(
|
||||
keysManager.delete(aci),
|
||||
keysManager.delete(pni),
|
||||
messagesManager.clear(aci),
|
||||
profilesManager.deleteAll(aci)
|
||||
).thenRunAsync(() -> clientPresenceManager.disconnectAllPresencesForUuid(aci), clientPresenceExecutor));
|
||||
try {
|
||||
accounts.create(account, keysManager.buildWriteItemsForRepeatedUseKeys(account.getIdentifier(IdentityType.ACI),
|
||||
account.getIdentifier(IdentityType.PNI),
|
||||
Device.PRIMARY_ID,
|
||||
primaryDeviceSpec.aciSignedPreKey(),
|
||||
primaryDeviceSpec.pniSignedPreKey(),
|
||||
primaryDeviceSpec.aciPqLastResortPreKey(),
|
||||
primaryDeviceSpec.pniPqLastResortPreKey()));
|
||||
} catch (final AccountAlreadyExistsException e) {
|
||||
accountCreationType = "re-registration";
|
||||
|
||||
if (!account.getUuid().equals(originalUuid)) {
|
||||
// If the UUID changed, then we overwrote an existing account. We should have cleared all messages before
|
||||
// overwriting the old account, but more may have arrived while we were working. Similarly, the old account
|
||||
// holder could have added keys or profiles. We'll largely repeat the cleanup process after creating the
|
||||
// account to make sure we really REALLY got everything.
|
||||
//
|
||||
// We exclude the primary device's repeated-use keys from deletion because new keys were provided as
|
||||
// part of the account creation process, and we don't want to delete the keys that just got added.
|
||||
CompletableFuture.allOf(keysManager.delete(account.getIdentifier(IdentityType.ACI), true),
|
||||
keysManager.delete(account.getIdentifier(IdentityType.PNI), true),
|
||||
messagesManager.clear(account.getIdentifier(IdentityType.ACI)),
|
||||
profilesManager.deleteAll(account.getIdentifier(IdentityType.ACI)))
|
||||
final UUID aci = e.getExistingAccount().getIdentifier(IdentityType.ACI);
|
||||
final UUID pni = e.getExistingAccount().getIdentifier(IdentityType.PNI);
|
||||
|
||||
account.setUuid(aci);
|
||||
account.setNumber(e.getExistingAccount().getNumber(), pni);
|
||||
|
||||
CompletableFuture.allOf(
|
||||
keysManager.delete(aci),
|
||||
keysManager.delete(pni),
|
||||
messagesManager.clear(aci),
|
||||
profilesManager.deleteAll(aci))
|
||||
.thenRunAsync(() -> clientPresenceManager.disconnectAllPresencesForUuid(aci), clientPresenceExecutor)
|
||||
.thenCompose(ignored -> accounts.reclaimAccount(e.getExistingAccount(),
|
||||
account,
|
||||
keysManager.buildWriteItemsForRepeatedUseKeys(account.getIdentifier(IdentityType.ACI),
|
||||
account.getIdentifier(IdentityType.PNI),
|
||||
Device.PRIMARY_ID,
|
||||
primaryDeviceSpec.aciSignedPreKey(),
|
||||
primaryDeviceSpec.pniSignedPreKey(),
|
||||
primaryDeviceSpec.aciPqLastResortPreKey(),
|
||||
primaryDeviceSpec.pniPqLastResortPreKey())))
|
||||
.thenCompose(ignored -> {
|
||||
// We should have cleared all messages before overwriting the old account, but more may have arrived
|
||||
// while we were working. Similarly, the old account holder could have added keys or profiles. We'll
|
||||
// largely repeat the cleanup process after creating the account to make sure we really REALLY got
|
||||
// everything.
|
||||
//
|
||||
// We exclude the primary device's repeated-use keys from deletion because new keys were provided as
|
||||
// part of the account creation process, and we don't want to delete the keys that just got added.
|
||||
return CompletableFuture.allOf(keysManager.delete(aci, true),
|
||||
keysManager.delete(pni, true),
|
||||
messagesManager.clear(aci),
|
||||
profilesManager.deleteAll(aci));
|
||||
})
|
||||
.join();
|
||||
}
|
||||
|
||||
redisSet(account);
|
||||
|
||||
final Tags tags;
|
||||
|
||||
// In terms of previously-existing accounts, there are three possible cases:
|
||||
//
|
||||
// 1. This is a completely new account; there was no pre-existing account and no recently-deleted account
|
||||
// 2. This is a re-registration of an existing account. The storage layer will update the existing account in
|
||||
// place to match the account record created above, and will update the UUID of the newly-created account
|
||||
// instance to match the stored account record (i.e. originalUuid != actualUuid).
|
||||
// 3. This is a re-registration of a recently-deleted account, in which case maybeRecentlyDeletedUuid is
|
||||
// present.
|
||||
if (freshUser) {
|
||||
tags = Tags.of("type", maybeRecentlyDeletedAccountIdentifier.isPresent() ? "recently-deleted" : "new");
|
||||
} else {
|
||||
tags = Tags.of("type", "re-registration");
|
||||
}
|
||||
|
||||
Metrics.counter(CREATE_COUNTER_NAME, tags).increment();
|
||||
Metrics.counter(CREATE_COUNTER_NAME, "type", accountCreationType).increment();
|
||||
|
||||
accountAttributes.recoveryPassword().ifPresent(registrationRecoveryPassword ->
|
||||
registrationRecoveryPasswordsManager.storeForCurrentNumber(account.getNumber(), registrationRecoveryPassword));
|
||||
|
||||
Reference in New Issue
Block a user