mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 22:28:03 +01:00
Revert "Stored hashed username"
This commit is contained in:
@@ -48,6 +48,7 @@ import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
|
||||
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||
|
||||
@@ -254,6 +255,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private UsernameConfiguration username = new UsernameConfiguration();
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SpamFilterConfiguration spamFilterConfiguration;
|
||||
@@ -441,6 +447,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return spamFilterConfiguration;
|
||||
}
|
||||
|
||||
public UsernameConfiguration getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
|
||||
return registrationService;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,6 @@ import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapp
|
||||
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
|
||||
@@ -193,6 +192,7 @@ import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListe
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
|
||||
@@ -209,6 +209,7 @@ import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
|
||||
import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
|
||||
@@ -350,6 +351,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getProfiles().getTableName());
|
||||
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
|
||||
@@ -480,11 +483,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getReportMessageConfiguration().getCounterTtl());
|
||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager,
|
||||
messageDeletionAsyncExecutor);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||
experimentEnrollmentManager, clock);
|
||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
|
||||
@@ -816,8 +820,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new DeviceLimitExceededExceptionMapper(),
|
||||
new ServerRejectedExceptionMapper(),
|
||||
new ImpossiblePhoneNumberExceptionMapper(),
|
||||
new NonNormalizedPhoneNumberExceptionMapper(),
|
||||
new JsonMappingExceptionMapper()
|
||||
new NonNormalizedPhoneNumberExceptionMapper()
|
||||
).forEach(exceptionMapper -> {
|
||||
environment.jersey().register(exceptionMapper);
|
||||
webSocketEnvironment.jersey().register(exceptionMapper);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.Min;
|
||||
import java.time.Duration;
|
||||
|
||||
public class UsernameConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int discriminatorInitialWidth = 2;
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int discriminatorMaxWidth = 9;
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int attemptsPerWidth = 10;
|
||||
|
||||
@JsonProperty
|
||||
private Duration reservationTtl = Duration.ofMinutes(5);
|
||||
|
||||
public int getDiscriminatorInitialWidth() {
|
||||
return discriminatorInitialWidth;
|
||||
}
|
||||
|
||||
public int getDiscriminatorMaxWidth() {
|
||||
return discriminatorMaxWidth;
|
||||
}
|
||||
|
||||
public int getAttemptsPerWidth() {
|
||||
return attemptsPerWidth;
|
||||
}
|
||||
|
||||
public Duration getReservationTtl() {
|
||||
return reservationTtl;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -76,17 +75,18 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
@@ -103,7 +103,7 @@ import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
@@ -111,13 +111,13 @@ import org.whispersystems.textsecuregcm.util.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.Optionals;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/accounts")
|
||||
public class AccountController {
|
||||
public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;
|
||||
public static final int USERNAME_HASH_LENGTH = 32;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
|
||||
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||
private final Meter countryFilteredHostMeter = metricRegistry.meter(name(AccountController.class, "country_limited_host" ));
|
||||
@@ -136,7 +136,11 @@ public class AccountController {
|
||||
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(Metrics.globalRegistry);
|
||||
|
||||
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||
|
||||
private static final String LOCKED_ACCOUNT_COUNTER_NAME = name(AccountController.class, "lockedAccount");
|
||||
|
||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||
@@ -443,7 +447,7 @@ public class AccountController {
|
||||
return new AccountIdentityResponse(account.getUuid(),
|
||||
account.getNumber(),
|
||||
account.getPhoneNumberIdentifier(),
|
||||
account.getUsernameHash().orElse(null),
|
||||
account.getUsername().orElse(null),
|
||||
existingAccount.map(Account::isStorageSupported).orElse(false));
|
||||
}
|
||||
|
||||
@@ -504,7 +508,7 @@ public class AccountController {
|
||||
updatedAccount.getUuid(),
|
||||
updatedAccount.getNumber(),
|
||||
updatedAccount.getPhoneNumberIdentifier(),
|
||||
updatedAccount.getUsernameHash().orElse(null),
|
||||
updatedAccount.getUsername().orElse(null),
|
||||
updatedAccount.isStorageSupported());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(409)
|
||||
@@ -683,78 +687,96 @@ public class AccountController {
|
||||
return new AccountIdentityResponse(auth.getAccount().getUuid(),
|
||||
auth.getAccount().getNumber(),
|
||||
auth.getAccount().getPhoneNumberIdentifier(),
|
||||
auth.getAccount().getUsernameHash().orElse(null),
|
||||
auth.getAccount().getUsername().orElse(null),
|
||||
auth.getAccount().isStorageSupported());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/username_hash")
|
||||
@Path("/username")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void deleteUsernameHash(@Auth AuthenticatedAccount auth) {
|
||||
accounts.clearUsernameHash(auth.getAccount());
|
||||
public void deleteUsername(@Auth AuthenticatedAccount auth) {
|
||||
accounts.clearUsername(auth.getAccount());
|
||||
}
|
||||
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username_hash/reserve")
|
||||
@Path("/username/reserved")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public ReserveUsernameHashResponse reserveUsernameHash(@Auth AuthenticatedAccount auth,
|
||||
public ReserveUsernameResponse reserveUsername(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
|
||||
@NotNull @Valid ReserveUsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
|
||||
|
||||
for (byte[] hash : usernameRequest.usernameHashes()) {
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final AccountsManager.UsernameReservation reservation = accounts.reserveUsernameHash(
|
||||
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
|
||||
auth.getAccount(),
|
||||
usernameRequest.usernameHashes()
|
||||
usernameRequest.nickname()
|
||||
);
|
||||
return new ReserveUsernameHashResponse(reservation.reservedUsernameHash());
|
||||
} catch (final UsernameHashNotAvailableException e) {
|
||||
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username_hash/confirm")
|
||||
@Path("/username/confirm")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public UsernameHashResponse confirmUsernameHash(@Auth AuthenticatedAccount auth,
|
||||
public UsernameResponse confirmUsername(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException {
|
||||
@NotNull @Valid ConfirmUsernameRequest confirmRequest) throws RateLimitExceededException {
|
||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||
|
||||
try {
|
||||
final Account account = accounts.confirmReservedUsernameHash(auth.getAccount(), confirmRequest.usernameHash());
|
||||
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
|
||||
return account
|
||||
.getUsernameHash()
|
||||
.map(UsernameHashResponse::new)
|
||||
.getUsername()
|
||||
.map(UsernameResponse::new)
|
||||
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||
} catch (final UsernameReservationNotFoundException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
} catch (final UsernameHashNotAvailableException e) {
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public UsernameResponse setUsername(
|
||||
@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||
checkUsername(usernameRequest.existingUsername(), userAgent);
|
||||
|
||||
try {
|
||||
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
||||
usernameRequest.existingUsername());
|
||||
return account
|
||||
.getUsername()
|
||||
.map(UsernameResponse::new)
|
||||
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/username_hash/{usernameHash}")
|
||||
@Path("/username/{username}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RateLimitedByIp(RateLimiters.Handle.USERNAME_LOOKUP)
|
||||
public AccountIdentifierResponse lookupUsernameHash(
|
||||
public AccountIdentifierResponse lookupUsername(
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
|
||||
@PathParam("usernameHash") final String usernameHash,
|
||||
@PathParam("username") final String username,
|
||||
@Context final HttpServletRequest request) throws RateLimitExceededException {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -762,21 +784,10 @@ public class AccountController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
|
||||
|
||||
final byte[] hash;
|
||||
try {
|
||||
hash = Base64.getUrlDecoder().decode(usernameHash);
|
||||
} catch (IllegalArgumentException | AssertionError e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
checkUsername(username, userAgent);
|
||||
|
||||
return accounts
|
||||
.getByUsernameHash(hash)
|
||||
.getByUsername(username)
|
||||
.map(Account::getUuid)
|
||||
.map(AccountIdentifierResponse::new)
|
||||
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND));
|
||||
@@ -933,6 +944,15 @@ public class AccountController {
|
||||
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
|
||||
}
|
||||
|
||||
private void checkUsername(final String username, final String userAgent) {
|
||||
if (StringUtils.isNotBlank(username) && !UsernameGenerator.isStandardFormat(username)) {
|
||||
// Technically, a username may not be in the nickname#discriminator format
|
||||
// if created through some out-of-band mechanism, but it is atypical.
|
||||
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
|
||||
private String generatePushChallenge() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] challenge = new byte[16];
|
||||
|
||||
@@ -11,6 +11,6 @@ import javax.annotation.Nullable;
|
||||
public record AccountIdentityResponse(UUID uuid,
|
||||
String number,
|
||||
UUID pni,
|
||||
@Nullable byte[] usernameHash,
|
||||
@Nullable String username,
|
||||
boolean storageCapable) {
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public record ConfirmUsernameHashRequest(
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
|
||||
byte[] usernameHash
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ConfirmUsernameRequest(@NotBlank String usernameToConfirm, @NotNull UUID reservationToken) {}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
public record ReserveUsernameHashRequest(
|
||||
@Valid
|
||||
@Size(min=1, max=AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH)
|
||||
@JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
List<byte[]> usernameHashes
|
||||
) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ReserveUsernameHashResponse(
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
|
||||
byte[] usernameHash
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import org.whispersystems.textsecuregcm.util.Nickname;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
public record ReserveUsernameRequest(@Valid @Nickname String nickname) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record ReserveUsernameResponse(String username, UUID reservationToken) {}
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import javax.validation.Valid;
|
||||
|
||||
public record UsernameHashResponse(
|
||||
@Valid
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@ExactlySize(AccountController.USERNAME_HASH_LENGTH)
|
||||
byte[] usernameHash
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import org.whispersystems.textsecuregcm.util.Nickname;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.Valid;
|
||||
|
||||
public record UsernameRequest(@Valid @Nickname String nickname, @Nullable String existingUsername) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
public record UsernameResponse(String username) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.mappers;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
|
||||
public class JsonMappingExceptionMapper implements ExceptionMapper<JsonMappingException> {
|
||||
@Override
|
||||
public Response toResponse(final JsonMappingException exception) {
|
||||
return Response.status(422).build();
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,12 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import javax.annotation.Nullable;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class Account {
|
||||
@@ -42,14 +39,10 @@ public class Account {
|
||||
private String number;
|
||||
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@Nullable
|
||||
private byte[] usernameHash;
|
||||
private String username;
|
||||
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)
|
||||
@Nullable
|
||||
private byte[] reservedUsernameHash;
|
||||
|
||||
@@ -133,16 +126,16 @@ public class Account {
|
||||
this.phoneNumberIdentifier = phoneNumberIdentifier;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getUsernameHash() {
|
||||
public Optional<String> getUsername() {
|
||||
requireNotStale();
|
||||
|
||||
return Optional.ofNullable(usernameHash);
|
||||
return Optional.ofNullable(username);
|
||||
}
|
||||
|
||||
public void setUsernameHash(final byte[] usernameHash) {
|
||||
public void setUsername(final String username) {
|
||||
requireNotStale();
|
||||
|
||||
this.usernameHash = usernameHash;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getReservedUsernameHash() {
|
||||
|
||||
@@ -7,16 +7,11 @@ package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.Optionals;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
|
||||
class AccountChangeValidator {
|
||||
|
||||
private final boolean allowNumberChange;
|
||||
private final boolean allowUsernameHashChange;
|
||||
private final boolean allowUsernameChange;
|
||||
|
||||
static final AccountChangeValidator GENERAL_CHANGE_VALIDATOR = new AccountChangeValidator(false, false);
|
||||
static final AccountChangeValidator NUMBER_CHANGE_VALIDATOR = new AccountChangeValidator(true, false);
|
||||
@@ -25,10 +20,10 @@ class AccountChangeValidator {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AccountChangeValidator.class);
|
||||
|
||||
AccountChangeValidator(final boolean allowNumberChange,
|
||||
final boolean allowUsernameHashChange) {
|
||||
final boolean allowUsernameChange) {
|
||||
|
||||
this.allowNumberChange = allowNumberChange;
|
||||
this.allowUsernameHashChange = allowUsernameHashChange;
|
||||
this.allowUsernameChange = allowUsernameChange;
|
||||
}
|
||||
|
||||
public void validateChange(final Account originalAccount, final Account updatedAccount) {
|
||||
@@ -49,21 +44,13 @@ class AccountChangeValidator {
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowUsernameHashChange) {
|
||||
// We can potentially replace this with the actual hash of some invalid username (e.g. 1nickname.123)
|
||||
final byte[] dummyHash = new byte[32];
|
||||
new SecureRandom().nextBytes(dummyHash);
|
||||
if (!allowUsernameChange) {
|
||||
assert updatedAccount.getUsername().equals(originalAccount.getUsername());
|
||||
|
||||
final byte[] updatedAccountUsernameHash = updatedAccount.getUsernameHash().orElse(dummyHash);
|
||||
final byte[] originalAccountUsernameHash = originalAccount.getUsernameHash().orElse(dummyHash);
|
||||
|
||||
boolean usernameUnchanged = MessageDigest.isEqual(updatedAccountUsernameHash, originalAccountUsernameHash);
|
||||
|
||||
if (!usernameUnchanged) {
|
||||
logger.error("Username hash changed via \"normal\" update; username hashes must be changed via reserveUsernameHash and confirmUsernameHash methods",
|
||||
if (!updatedAccount.getUsername().equals(originalAccount.getUsername())) {
|
||||
logger.error("Username changed via \"normal\" update; usernames must be changed via setUsername method",
|
||||
new RuntimeException());
|
||||
}
|
||||
assert usernameUnchanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
@@ -63,14 +64,16 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
|
||||
|
||||
private static final byte RESERVED_USERNAME_HASH_VERSION = 1;
|
||||
|
||||
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 RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername"));
|
||||
private static final Timer CLEAR_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "clearUsernameHash"));
|
||||
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_HASH_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameHash"));
|
||||
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"));
|
||||
@@ -93,10 +96,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
static final String ATTR_VERSION = "V";
|
||||
// canonically discoverable
|
||||
static final String ATTR_CANONICALLY_DISCOVERABLE = "C";
|
||||
// username hash; byte[] or null
|
||||
static final String ATTR_USERNAME_HASH = "N";
|
||||
// confirmed; bool
|
||||
static final String ATTR_CONFIRMED = "F";
|
||||
// username; string
|
||||
static final String ATTR_USERNAME = "N";
|
||||
// unidentified access key; byte[] or null
|
||||
static final String ATTR_UAK = "UAK";
|
||||
// time to live; number
|
||||
@@ -295,23 +296,24 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a username hash under the account UUID
|
||||
* Reserve a username under a token
|
||||
*
|
||||
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
|
||||
*/
|
||||
public void reserveUsernameHash(
|
||||
public UUID reserveUsername(
|
||||
final Account account,
|
||||
final byte[] reservedUsernameHash,
|
||||
final String reservedUsername,
|
||||
final Duration ttl) {
|
||||
final long startNanos = System.nanoTime();
|
||||
// if there is an existing old reservation it will be cleaned up via ttl
|
||||
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
|
||||
account.setReservedUsernameHash(reservedUsernameHash);
|
||||
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
|
||||
|
||||
boolean succeeded = false;
|
||||
|
||||
final long expirationTime = clock.instant().plus(ttl).getEpochSecond();
|
||||
|
||||
// Use account UUID as a "reservation token" - by providing this, the client proves ownership of the hash
|
||||
UUID uuid = account.getUuid();
|
||||
final UUID reservationToken = UUID.randomUUID();
|
||||
try {
|
||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||
|
||||
@@ -319,12 +321,11 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
.put(Put.builder()
|
||||
.tableName(usernamesConstraintTableName)
|
||||
.item(Map.of(
|
||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),
|
||||
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash),
|
||||
ATTR_TTL, AttributeValues.fromLong(expirationTime),
|
||||
ATTR_CONFIRMED, AttributeValues.fromBool(false)))
|
||||
.conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now)")
|
||||
.expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL))
|
||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(reservationToken),
|
||||
ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(reservedUsername)),
|
||||
ATTR_TTL, AttributeValues.fromLong(expirationTime)))
|
||||
.conditionExpression("attribute_not_exists(#username) OR (#ttl < :now)")
|
||||
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL))
|
||||
.expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond())))
|
||||
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
||||
.build())
|
||||
@@ -334,7 +335,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
TransactWriteItem.builder()
|
||||
.update(Update.builder()
|
||||
.tableName(accountsTableName)
|
||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)))
|
||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
||||
.updateExpression("SET #data = :data ADD #version :version_increment")
|
||||
.conditionExpression("#version = :version")
|
||||
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, "#version", ATTR_VERSION))
|
||||
@@ -366,23 +367,42 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
}
|
||||
RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
return reservationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm (set) a previously reserved username hash
|
||||
* Confirm (set) a previously reserved username
|
||||
*
|
||||
* @param account to update
|
||||
* @param usernameHash believed to be available
|
||||
* @throws ContestedOptimisticLockException if the account has been updated or the username has taken by someone else
|
||||
* @param username believed to be available
|
||||
* @param reservationToken a token returned by the call to {@link #reserveUsername(Account, String, Duration)},
|
||||
* only required if setting a reserved username
|
||||
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
|
||||
*/
|
||||
public void confirmUsernameHash(final Account account, final byte[] usernameHash)
|
||||
public void confirmUsername(final Account account, final String username, final UUID reservationToken)
|
||||
throws ContestedOptimisticLockException {
|
||||
setUsername(account, username, Optional.of(reservationToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the account username
|
||||
*
|
||||
* @param account to update
|
||||
* @param username believed to be available
|
||||
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
|
||||
*/
|
||||
public void setUsername(final Account account, final String username) throws ContestedOptimisticLockException {
|
||||
setUsername(account, username, Optional.empty());
|
||||
}
|
||||
|
||||
private void setUsername(final Account account, final String username, final Optional<UUID> reservationToken)
|
||||
throws ContestedOptimisticLockException {
|
||||
final long startNanos = System.nanoTime();
|
||||
|
||||
final Optional<byte[]> maybeOriginalUsernameHash = account.getUsernameHash();
|
||||
final Optional<byte[]> maybeOriginalReservationHash = account.getReservedUsernameHash();
|
||||
final Optional<String> maybeOriginalUsername = account.getUsername();
|
||||
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
|
||||
|
||||
account.setUsernameHash(usernameHash);
|
||||
account.setUsername(username);
|
||||
account.setReservedUsernameHash(null);
|
||||
|
||||
boolean succeeded = false;
|
||||
@@ -390,21 +410,20 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
try {
|
||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||
|
||||
// add the username hash to the constraint table, wiping out the ttl if we had already reserved the hash
|
||||
// add the username to the constraint table, wiping out the ttl if we had already reserved the name
|
||||
// Persist the normalized username in the usernamesConstraint table and the original username in the accounts table
|
||||
writeItems.add(TransactWriteItem.builder()
|
||||
.put(Put.builder()
|
||||
.tableName(usernamesConstraintTableName)
|
||||
.item(Map.of(
|
||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
|
||||
ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),
|
||||
ATTR_CONFIRMED, AttributeValues.fromBool(true)))
|
||||
ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(username))))
|
||||
// it's not in the constraint table OR it's expired OR it was reserved by us
|
||||
.conditionExpression("attribute_not_exists(#username_hash) OR #ttl < :now OR (#aci = :aci AND #confirmed = :confirmed)")
|
||||
.expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID, "#confirmed", ATTR_CONFIRMED))
|
||||
.conditionExpression("attribute_not_exists(#username) OR #ttl < :now OR #aci = :reservation ")
|
||||
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":now", AttributeValues.fromLong(clock.instant().getEpochSecond()),
|
||||
":aci", AttributeValues.fromUUID(account.getUuid()),
|
||||
":confirmed", AttributeValues.fromBool(false)))
|
||||
":reservation", AttributeValues.fromUUID(reservationToken.orElseGet(UUID::randomUUID))))
|
||||
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
||||
.build())
|
||||
.build());
|
||||
@@ -414,21 +433,21 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
.update(Update.builder()
|
||||
.tableName(accountsTableName)
|
||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
||||
.updateExpression("SET #data = :data, #username_hash = :username_hash ADD #version :version_increment")
|
||||
.updateExpression("SET #data = :data, #username = :username ADD #version :version_increment")
|
||||
.conditionExpression("#version = :version")
|
||||
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
|
||||
"#username_hash", ATTR_USERNAME_HASH,
|
||||
"#username", ATTR_USERNAME,
|
||||
"#version", ATTR_VERSION))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
||||
":username_hash", AttributeValues.fromByteArray(usernameHash),
|
||||
":username", AttributeValues.fromString(username),
|
||||
":version", AttributeValues.fromInt(account.getVersion()),
|
||||
":version_increment", AttributeValues.fromInt(1)))
|
||||
.build())
|
||||
.build());
|
||||
|
||||
maybeOriginalUsernameHash.ifPresent(originalUsernameHash -> writeItems.add(
|
||||
buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, originalUsernameHash)));
|
||||
maybeOriginalUsername.ifPresent(originalUsername -> writeItems.add(
|
||||
buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(originalUsername))));
|
||||
|
||||
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||
.transactItems(writeItems)
|
||||
@@ -447,17 +466,17 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
throw e;
|
||||
} finally {
|
||||
if (!succeeded) {
|
||||
account.setUsernameHash(maybeOriginalUsernameHash.orElse(null));
|
||||
account.setReservedUsernameHash(maybeOriginalReservationHash.orElse(null));
|
||||
account.setUsername(maybeOriginalUsername.orElse(null));
|
||||
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
|
||||
}
|
||||
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearUsernameHash(final Account account) {
|
||||
account.getUsernameHash().ifPresent(usernameHash -> {
|
||||
CLEAR_USERNAME_HASH_TIMER.record(() -> {
|
||||
account.setUsernameHash(null);
|
||||
public void clearUsername(final Account account) {
|
||||
account.getUsername().ifPresent(username -> {
|
||||
CLEAR_USERNAME_TIMER.record(() -> {
|
||||
account.setUsername(null);
|
||||
|
||||
boolean succeeded = false;
|
||||
|
||||
@@ -469,10 +488,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
.update(Update.builder()
|
||||
.tableName(accountsTableName)
|
||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
||||
.updateExpression("SET #data = :data REMOVE #username_hash ADD #version :version_increment")
|
||||
.updateExpression("SET #data = :data REMOVE #username ADD #version :version_increment")
|
||||
.conditionExpression("#version = :version")
|
||||
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA,
|
||||
"#username_hash", ATTR_USERNAME_HASH,
|
||||
"#username", ATTR_USERNAME,
|
||||
"#version", ATTR_VERSION))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
||||
@@ -481,7 +500,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
.build())
|
||||
.build());
|
||||
|
||||
writeItems.add(buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash));
|
||||
writeItems.add(buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(username)));
|
||||
|
||||
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||
.transactItems(writeItems)
|
||||
@@ -501,7 +520,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
throw e;
|
||||
} finally {
|
||||
if (!succeeded) {
|
||||
account.setUsernameHash(usernameHash);
|
||||
account.setUsername(username);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -582,27 +601,27 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean usernameHashAvailable(final byte[] username) {
|
||||
return usernameHashAvailable(Optional.empty(), username);
|
||||
public boolean usernameAvailable(final String username) {
|
||||
return usernameAvailable(Optional.empty(), username);
|
||||
}
|
||||
|
||||
public boolean usernameHashAvailable(final Optional<UUID> accountUuid, final byte[] usernameHash) {
|
||||
final Optional<Map<String, AttributeValue>> usernameHashItem = itemByKey(
|
||||
usernamesConstraintTableName, ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash));
|
||||
public boolean usernameAvailable(final Optional<UUID> reservationToken, final String username) {
|
||||
final Optional<Map<String, AttributeValue>> usernameItem = itemByKey(
|
||||
usernamesConstraintTableName, ATTR_USERNAME, AttributeValues.fromString(UsernameNormalizer.normalize(username)));
|
||||
|
||||
if (usernameHashItem.isEmpty()) {
|
||||
// username hash is free
|
||||
if (usernameItem.isEmpty()) {
|
||||
// username is free
|
||||
return true;
|
||||
}
|
||||
final Map<String, AttributeValue> item = usernameHashItem.get();
|
||||
final Map<String, AttributeValue> item = usernameItem.get();
|
||||
|
||||
if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) {
|
||||
// username hash was reserved, but has expired
|
||||
// username was reserved, but has expired
|
||||
return true;
|
||||
}
|
||||
|
||||
// username hash is reserved by us
|
||||
return !AttributeValues.getBool(item, ATTR_CONFIRMED, true) && accountUuid
|
||||
// username is reserved by us
|
||||
return reservationToken
|
||||
.map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals)
|
||||
.orElse(false);
|
||||
}
|
||||
@@ -620,13 +639,13 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<Account> getByUsernameHash(final byte[] usernameHash) {
|
||||
public Optional<Account> getByUsername(final String username) {
|
||||
return getByIndirectLookup(
|
||||
GET_BY_USERNAME_HASH_TIMER,
|
||||
GET_BY_USERNAME_TIMER,
|
||||
usernamesConstraintTableName,
|
||||
ATTR_USERNAME_HASH,
|
||||
AttributeValues.fromByteArray(usernameHash),
|
||||
item -> AttributeValues.getBool(item, ATTR_CONFIRMED, false) // ignore items that are reservations (not confirmed)
|
||||
ATTR_USERNAME,
|
||||
AttributeValues.fromString(UsernameNormalizer.normalize(username)),
|
||||
item -> !item.containsKey(ATTR_TTL) // ignore items with a ttl (reservations)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,8 +665,8 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, account.getPhoneNumberIdentifier())
|
||||
));
|
||||
|
||||
account.getUsernameHash().ifPresent(usernameHash -> transactWriteItems.add(
|
||||
buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash)));
|
||||
account.getUsername().ifPresent(username -> transactWriteItems.add(
|
||||
buildDelete(usernamesConstraintTableName, ATTR_USERNAME, UsernameNormalizer.normalize(username))));
|
||||
|
||||
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||
.transactItems(transactWriteItems).build();
|
||||
@@ -788,11 +807,6 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
return buildDelete(tableName, keyName, AttributeValues.fromString(keyValue));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static TransactWriteItem buildDelete(final String tableName, final String keyName, final byte[] keyValue) {
|
||||
return buildDelete(tableName, keyName, AttributeValues.fromByteArray(keyValue));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static TransactWriteItem buildDelete(final String tableName, final String keyName, final UUID keyValue) {
|
||||
return buildDelete(tableName, keyName, AttributeValues.fromUUID(keyValue));
|
||||
@@ -829,6 +843,22 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static byte[] reservedUsernameHash(final UUID accountId, final String reservedUsername) {
|
||||
final MessageDigest sha256;
|
||||
try {
|
||||
sha256 = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
final ByteBuffer byteBuffer = ByteBuffer.allocate(32 + 1);
|
||||
sha256.update(UsernameNormalizer.normalize(reservedUsername).getBytes(StandardCharsets.UTF_8));
|
||||
sha256.update(UUIDUtil.toBytes(accountId));
|
||||
byteBuffer.put(RESERVED_USERNAME_HASH_VERSION);
|
||||
byteBuffer.put(sha256.digest());
|
||||
return byteBuffer.array();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Nonnull
|
||||
static Account fromItem(final Map<String, AttributeValue> item) {
|
||||
@@ -853,7 +883,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||
|
||||
account.setNumber(item.get(ATTR_ACCOUNT_E164).s(), phoneNumberIdentifierFromAttribute);
|
||||
account.setUuid(accountIdentifier);
|
||||
account.setUsernameHash(AttributeValues.getByteArray(item, ATTR_USERNAME_HASH, null));
|
||||
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(AttributeValue::bool)
|
||||
|
||||
@@ -22,7 +22,6 @@ import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -51,6 +50,8 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class AccountsManager {
|
||||
@@ -59,13 +60,13 @@ public class AccountsManager {
|
||||
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 getByUsernameHashTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsernameHash"));
|
||||
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 redisNumberGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisNumberGet"));
|
||||
private static final Timer redisUsernameHashGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameHashGet"));
|
||||
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"));
|
||||
@@ -87,6 +88,7 @@ public class AccountsManager {
|
||||
private final DirectoryQueue directoryQueue;
|
||||
private final Keys keys;
|
||||
private final MessagesManager messagesManager;
|
||||
private final ProhibitedUsernames prohibitedUsernames;
|
||||
private final ProfilesManager profilesManager;
|
||||
private final StoredVerificationCodeManager pendingAccounts;
|
||||
private final SecureStorageClient secureStorageClient;
|
||||
@@ -94,6 +96,7 @@ public class AccountsManager {
|
||||
private final ClientPresenceManager clientPresenceManager;
|
||||
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||
private final Clock clock;
|
||||
private final UsernameGenerator usernameGenerator;
|
||||
|
||||
private static final ObjectMapper mapper = SystemMapper.getMapper();
|
||||
|
||||
@@ -103,11 +106,9 @@ public class AccountsManager {
|
||||
// the owner.
|
||||
private static final long CACHE_TTL_SECONDS = Duration.ofDays(2).toSeconds();
|
||||
|
||||
private static final Duration USERNAME_HASH_RESERVATION_TTL_MINUTES = Duration.ofMinutes(5);
|
||||
|
||||
@FunctionalInterface
|
||||
private interface AccountPersister {
|
||||
void persistAccount(Account account) throws UsernameHashNotAvailableException;
|
||||
void persistAccount(Account account) throws UsernameNotAvailableException;
|
||||
}
|
||||
|
||||
public enum DeletionReason {
|
||||
@@ -129,11 +130,13 @@ public class AccountsManager {
|
||||
final DirectoryQueue directoryQueue,
|
||||
final Keys keys,
|
||||
final MessagesManager messagesManager,
|
||||
final ProhibitedUsernames prohibitedUsernames,
|
||||
final ProfilesManager profilesManager,
|
||||
final StoredVerificationCodeManager pendingAccounts,
|
||||
final SecureStorageClient secureStorageClient,
|
||||
final SecureBackupClient secureBackupClient,
|
||||
final ClientPresenceManager clientPresenceManager,
|
||||
final UsernameGenerator usernameGenerator,
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager,
|
||||
final Clock clock) {
|
||||
this.accounts = accounts;
|
||||
@@ -148,6 +151,8 @@ public class AccountsManager {
|
||||
this.secureStorageClient = secureStorageClient;
|
||||
this.secureBackupClient = secureBackupClient;
|
||||
this.clientPresenceManager = clientPresenceManager;
|
||||
this.prohibitedUsernames = prohibitedUsernames;
|
||||
this.usernameGenerator = usernameGenerator;
|
||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
@@ -319,43 +324,42 @@ public class AccountsManager {
|
||||
return updatedAccount.get();
|
||||
}
|
||||
|
||||
public record UsernameReservation(Account account, byte[] reservedUsernameHash){}
|
||||
public record UsernameReservation(Account account, String reservedUsername, UUID reservationToken){}
|
||||
|
||||
/**
|
||||
* Reserve a username hash so that no other accounts may take it.
|
||||
* Generate a username from a nickname, and reserve it so no other accounts may take it.
|
||||
*
|
||||
* The reserved hash can later be set with {@link #confirmReservedUsernameHash(Account, byte[])}. The reservation
|
||||
* will eventually expire, after which point confirmReservedUsernameHash may fail if another account has taken the
|
||||
* username hash.
|
||||
* The reserved username can later be set with {@link #confirmReservedUsername(Account, String, UUID)}. The reservation
|
||||
* will eventually expire, after which point confirmReservedUsername may fail if another account has taken the
|
||||
* username.
|
||||
*
|
||||
* @param account the account to update
|
||||
* @param requestedUsernameHashes the list of username hashes to attempt to reserve
|
||||
* @return the reserved username hash and an updated Account object
|
||||
* @throws UsernameHashNotAvailableException no username hash is available
|
||||
* @param requestedNickname the nickname to reserve a username for
|
||||
* @return the reserved username and an updated Account object
|
||||
* @throws UsernameNotAvailableException no username is available for the requested nickname
|
||||
*/
|
||||
public UsernameReservation reserveUsernameHash(final Account account, final List<byte[]> requestedUsernameHashes) throws UsernameHashNotAvailableException {
|
||||
public UsernameReservation reserveUsername(final Account account, final String requestedNickname) throws UsernameNotAvailableException {
|
||||
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||
throw new UsernameHashNotAvailableException();
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
|
||||
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
redisDelete(account);
|
||||
|
||||
class Reserver implements AccountPersister {
|
||||
byte[] reservedUsernameHash;
|
||||
UUID reservationToken;
|
||||
String reservedUsername;
|
||||
|
||||
@Override
|
||||
public void persistAccount(final Account account) throws UsernameHashNotAvailableException {
|
||||
for (byte[] usernameHash : requestedUsernameHashes) {
|
||||
if (accounts.usernameHashAvailable(usernameHash)) {
|
||||
reservedUsernameHash = usernameHash;
|
||||
accounts.reserveUsernameHash(
|
||||
account,
|
||||
usernameHash,
|
||||
USERNAME_HASH_RESERVATION_TTL_MINUTES);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new UsernameHashNotAvailableException();
|
||||
public void persistAccount(final Account account) throws UsernameNotAvailableException {
|
||||
// In the future, this may also check for any forbidden discriminators
|
||||
reservedUsername = usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable);
|
||||
reservationToken = accounts.reserveUsername(
|
||||
account,
|
||||
reservedUsername,
|
||||
usernameGenerator.getReservationTtl());
|
||||
}
|
||||
}
|
||||
final Reserver reserver = new Reserver();
|
||||
@@ -365,28 +369,31 @@ public class AccountsManager {
|
||||
reserver,
|
||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||
return new UsernameReservation(updatedAccount, reserver.reservedUsernameHash);
|
||||
return new UsernameReservation(updatedAccount, reserver.reservedUsername, reserver.reservationToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a username hash previously reserved with {@link #reserveUsernameHash(Account, List<String>)}
|
||||
* Set a username previously reserved with {@link #reserveUsername(Account, String)}
|
||||
*
|
||||
* @param account the account to update
|
||||
* @param reservedUsernameHash the previously reserved username hash
|
||||
* @return the updated account with the username hash field set
|
||||
* @throws UsernameHashNotAvailableException if the reserved username hash has been taken (because the reservation expired)
|
||||
* @throws UsernameReservationNotFoundException if `reservedUsernameHash` was not reserved in the account
|
||||
* @param reservedUsername the previously reserved username
|
||||
* @param reservationToken the UUID returned from the reservation
|
||||
* @return the updated account with the username field set
|
||||
* @throws UsernameNotAvailableException if the reserved username has been taken (because the reservation expired)
|
||||
* @throws UsernameReservationNotFoundException if `reservedUsername` was not reserved in the account
|
||||
*/
|
||||
public Account confirmReservedUsernameHash(final Account account, final byte[] reservedUsernameHash) throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
public Account confirmReservedUsername(final Account account, final String reservedUsername, final UUID reservationToken) throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||
throw new UsernameHashNotAvailableException();
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
if (account.getUsernameHash().map(currentUsernameHash -> Arrays.equals(currentUsernameHash, reservedUsernameHash)).orElse(false)) {
|
||||
|
||||
if (account.getUsername().map(reservedUsername::equals).orElse(false)) {
|
||||
// the client likely already succeeded and is retrying
|
||||
return account;
|
||||
}
|
||||
|
||||
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, reservedUsernameHash)).orElse(false)) {
|
||||
final byte[] newHash = Accounts.reservedUsernameHash(account.getUuid(), UsernameNormalizer.normalize(reservedUsername));
|
||||
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, newHash)).orElse(false)) {
|
||||
// no such reservation existed, either there was no previous call to reserveUsername
|
||||
// or the reservation changed
|
||||
throw new UsernameReservationNotFoundException();
|
||||
@@ -398,23 +405,63 @@ public class AccountsManager {
|
||||
account,
|
||||
a -> true,
|
||||
a -> {
|
||||
// though we know this username hash was reserved, the reservation could have lapsed
|
||||
if (!accounts.usernameHashAvailable(Optional.of(account.getUuid()), reservedUsernameHash)) {
|
||||
throw new UsernameHashNotAvailableException();
|
||||
// though we know this username was reserved, the reservation could have lapsed
|
||||
if (!accounts.usernameAvailable(Optional.of(reservationToken), reservedUsername)) {
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
accounts.confirmUsernameHash(a, reservedUsernameHash);
|
||||
accounts.confirmUsername(a, reservedUsername, reservationToken);
|
||||
},
|
||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||
}
|
||||
|
||||
public Account clearUsernameHash(final Account account) {
|
||||
/**
|
||||
* Sets a username generated from `requestedNickname` with no prior reservation
|
||||
*
|
||||
* @param account the account to update
|
||||
* @param requestedNickname the nickname to generate a username from
|
||||
* @param expectedOldUsername the expected existing username of the account (for replay detection)
|
||||
* @return the updated account with the username field set
|
||||
* @throws UsernameNotAvailableException if no free username could be set for `requestedNickname`
|
||||
*/
|
||||
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
|
||||
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
|
||||
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
|
||||
final Optional<String> currentUsername = account.getUsername();
|
||||
final Optional<String> currentNickname = currentUsername.map(UsernameGenerator::extractNickname);
|
||||
if (currentNickname.map(requestedNickname::equals).orElse(false) && !Objects.equals(expectedOldUsername, currentUsername.orElse(null))) {
|
||||
// The requested nickname matches what the server already has, and the
|
||||
// client provided the wrong existing username. Treat this as a replayed
|
||||
// request, assuming that the client has previously succeeded
|
||||
return account;
|
||||
}
|
||||
|
||||
redisDelete(account);
|
||||
|
||||
return failableUpdateWithRetries(
|
||||
account,
|
||||
a -> true,
|
||||
// In the future, this may also check for any forbidden discriminators
|
||||
a -> accounts.setUsername(
|
||||
a,
|
||||
usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
|
||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||
}
|
||||
|
||||
public Account clearUsername(final Account account) {
|
||||
redisDelete(account);
|
||||
|
||||
return updateWithRetries(
|
||||
account,
|
||||
a -> true,
|
||||
accounts::clearUsernameHash,
|
||||
accounts::clearUsername,
|
||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||
}
|
||||
@@ -500,7 +547,7 @@ public class AccountsManager {
|
||||
final AccountChangeValidator changeValidator) {
|
||||
try {
|
||||
return failableUpdateWithRetries(account, updater, persister::accept, retriever, changeValidator);
|
||||
} catch (UsernameHashNotAvailableException e) {
|
||||
} catch (UsernameNotAvailableException e) {
|
||||
// not possible
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
@@ -510,7 +557,7 @@ public class AccountsManager {
|
||||
final Function<Account, Boolean> updater,
|
||||
final AccountPersister persister,
|
||||
final Supplier<Account> retriever,
|
||||
final AccountChangeValidator changeValidator) throws UsernameHashNotAvailableException {
|
||||
final AccountChangeValidator changeValidator) throws UsernameNotAvailableException {
|
||||
|
||||
Account originalAccount = cloneAccount(account);
|
||||
|
||||
@@ -593,11 +640,11 @@ public class AccountsManager {
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Account> getByUsernameHash(final byte[] usernameHash) {
|
||||
try (final Timer.Context ignored = getByUsernameHashTimer.time()) {
|
||||
Optional<Account> account = redisGetByUsernameHash(usernameHash);
|
||||
public Optional<Account> getByUsername(final String username) {
|
||||
try (final Timer.Context ignored = getByUsernameTimer.time()) {
|
||||
Optional<Account> account = redisGetByUsername(username);
|
||||
if (account.isEmpty()) {
|
||||
account = accounts.getByUsernameHash(usernameHash);
|
||||
account = accounts.getByUsername(username);
|
||||
account.ifPresent(this::redisSet);
|
||||
}
|
||||
|
||||
@@ -674,8 +721,8 @@ public class AccountsManager {
|
||||
clientPresenceManager.disconnectPresence(account.getUuid(), device.getId())));
|
||||
}
|
||||
|
||||
private String getUsernameHashAccountMapKey(byte[] usernameHash) {
|
||||
return "UAccountMap::" + Base64.getUrlEncoder().withoutPadding().encodeToString(usernameHash);
|
||||
private String getUsernameAccountMapKey(String username) {
|
||||
return "UAccountMap::" + UsernameNormalizer.normalize(username);
|
||||
}
|
||||
|
||||
private String getAccountMapKey(String key) {
|
||||
@@ -697,8 +744,8 @@ public class AccountsManager {
|
||||
commands.setex(getAccountMapKey(account.getNumber()), CACHE_TTL_SECONDS, account.getUuid().toString());
|
||||
commands.setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson);
|
||||
|
||||
account.getUsernameHash().ifPresent(usernameHash ->
|
||||
commands.setex(getUsernameHashAccountMapKey(usernameHash), CACHE_TTL_SECONDS, account.getUuid().toString()));
|
||||
account.getUsername().ifPresent(username ->
|
||||
commands.setex(getUsernameAccountMapKey(username), CACHE_TTL_SECONDS, account.getUuid().toString()));
|
||||
});
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(e);
|
||||
@@ -713,8 +760,8 @@ public class AccountsManager {
|
||||
return redisGetBySecondaryKey(getAccountMapKey(e164), redisNumberGetTimer);
|
||||
}
|
||||
|
||||
private Optional<Account> redisGetByUsernameHash(byte[] usernameHash) {
|
||||
return redisGetBySecondaryKey(getUsernameHashAccountMapKey(usernameHash), redisUsernameHashGetTimer);
|
||||
private Optional<Account> redisGetByUsername(String username) {
|
||||
return redisGetBySecondaryKey(getUsernameAccountMapKey(username), redisUsernameGetTimer);
|
||||
}
|
||||
|
||||
private Optional<Account> redisGetBySecondaryKey(String secondaryKey, Timer timer) {
|
||||
@@ -765,7 +812,7 @@ public class AccountsManager {
|
||||
getAccountMapKey(account.getPhoneNumberIdentifier().toString()),
|
||||
getAccountEntityKey(account.getUuid()));
|
||||
|
||||
account.getUsernameHash().ifPresent(usernameHash -> connection.sync().del(getUsernameHashAccountMapKey(usernameHash)));
|
||||
account.getUsername().ifPresent(username -> connection.sync().del(getUsernameAccountMapKey(username)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
public class UsernameHashNotAvailableException extends Exception {
|
||||
public class UsernameNotAvailableException extends Exception {
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
|
||||
public class ByteArrayBase64UrlAdapter {
|
||||
public static class Serializing extends JsonSerializer<byte[]> {
|
||||
@Override
|
||||
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeString(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
public static class Deserializing extends JsonDeserializer<byte[]> {
|
||||
@Override
|
||||
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
return Base64.getUrlDecoder().decode(jsonParser.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static java.lang.annotation.ElementType.FIELD;
|
||||
import static java.lang.annotation.ElementType.PARAMETER;
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
@Target({ FIELD, PARAMETER })
|
||||
@Retention(RUNTIME)
|
||||
@Constraint(validatedBy = NicknameValidator.class)
|
||||
public @interface Nickname {
|
||||
|
||||
String message() default "{org.whispersystems.textsecuregcm.util.Nickname.message}";
|
||||
|
||||
Class<?>[] groups() default { };
|
||||
|
||||
Class<? extends Payload>[] payload() default { };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
|
||||
|
||||
public class NicknameValidator implements ConstraintValidator<Nickname, String> {
|
||||
@Override
|
||||
public boolean isValid(final String nickname, final ConstraintValidatorContext context) {
|
||||
return UsernameGenerator.isValidNickname(nickname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.math.IntMath;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
public class UsernameGenerator {
|
||||
/**
|
||||
* Nicknames
|
||||
* <list>
|
||||
* <li> do not start with a number </li>
|
||||
* <li> are alphanumeric or underscores only </li>
|
||||
* <li> have minimum length 3 </li>
|
||||
* <li> have maximum length 32 </li>
|
||||
* </list>
|
||||
*
|
||||
* Usernames typically consist of a nickname and an integer discriminator
|
||||
*/
|
||||
public static final Pattern NICKNAME_PATTERN = Pattern.compile("^[_a-zA-Z][_a-zA-Z0-9]{2,31}$");
|
||||
public static final String SEPARATOR = ".";
|
||||
|
||||
private static final Counter USERNAME_NOT_AVAILABLE_COUNTER = Metrics.counter(name(UsernameGenerator.class, "usernameNotAvailable"));
|
||||
private static final DistributionSummary DISCRIMINATOR_ATTEMPT_COUNTER = Metrics.summary(name(UsernameGenerator.class, "discriminatorAttempts"));
|
||||
|
||||
private final int initialWidth;
|
||||
private final int discriminatorMaxWidth;
|
||||
private final int attemptsPerWidth;
|
||||
private final Duration reservationTtl;
|
||||
|
||||
public UsernameGenerator(UsernameConfiguration configuration) {
|
||||
this(configuration.getDiscriminatorInitialWidth(),
|
||||
configuration.getDiscriminatorMaxWidth(),
|
||||
configuration.getAttemptsPerWidth(),
|
||||
configuration.getReservationTtl());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth, final Duration reservationTtl) {
|
||||
this.initialWidth = initialWidth;
|
||||
this.discriminatorMaxWidth = discriminatorMaxWidth;
|
||||
this.attemptsPerWidth = attemptsPerWidth;
|
||||
this.reservationTtl = reservationTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a username with a random discriminator
|
||||
*
|
||||
* @param nickname The string nickname
|
||||
* @param usernameAvailableFun A {@link Predicate} that returns true if the provided username is available
|
||||
* @return The nickname appended with a random discriminator
|
||||
* @throws UsernameNotAvailableException if we failed to find a nickname+discriminator pair that was available
|
||||
*/
|
||||
public String generateAvailableUsername(final String nickname, final Predicate<String> usernameAvailableFun) throws UsernameNotAvailableException {
|
||||
int rangeMin = 1;
|
||||
int rangeMax = IntMath.pow(10, initialWidth);
|
||||
int totalMax = IntMath.pow(10, discriminatorMaxWidth);
|
||||
int attempts = 0;
|
||||
while (rangeMax <= totalMax) {
|
||||
// check discriminators of the current width up to attemptsPerWidth times
|
||||
for (int i = 0; i < attemptsPerWidth; i++) {
|
||||
int discriminator = ThreadLocalRandom.current().nextInt(rangeMin, rangeMax);
|
||||
String username = fromParts(nickname, discriminator);
|
||||
attempts++;
|
||||
if (usernameAvailableFun.test(username)) {
|
||||
DISCRIMINATOR_ATTEMPT_COUNTER.record(attempts);
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
// update the search range to look for numbers of one more digit
|
||||
// than the previous iteration
|
||||
rangeMin = rangeMax;
|
||||
rangeMax *= 10;
|
||||
}
|
||||
USERNAME_NOT_AVAILABLE_COUNTER.increment();
|
||||
throw new UsernameNotAvailableException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the discriminator from a username, if it is present
|
||||
*
|
||||
* @param username the string username
|
||||
* @return the nickname prefix of the username
|
||||
*/
|
||||
public static String extractNickname(final String username) {
|
||||
int sep = username.indexOf(SEPARATOR);
|
||||
return sep == -1 ? username : username.substring(0, sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a username from a nickname and discriminator
|
||||
*/
|
||||
public String fromParts(final String nickname, final int discriminator) throws IllegalArgumentException {
|
||||
if (!isValidNickname(nickname)) {
|
||||
throw new IllegalArgumentException("Invalid nickname " + nickname);
|
||||
}
|
||||
// zero pad discriminators less than the discriminator initial width
|
||||
return String.format("%s%s%0" + initialWidth + "d", nickname, SEPARATOR, discriminator);
|
||||
}
|
||||
|
||||
public Duration getReservationTtl() {
|
||||
return reservationTtl;
|
||||
}
|
||||
|
||||
public static boolean isValidNickname(final String nickname) {
|
||||
return StringUtils.isNotBlank(nickname) && NICKNAME_PATTERN.matcher(nickname).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the username consists of a valid nickname followed by an integer discriminator
|
||||
*
|
||||
* @param username string username to check
|
||||
* @return true if the username is in standard form
|
||||
*/
|
||||
public static boolean isStandardFormat(final String username) {
|
||||
if (username == null) {
|
||||
return false;
|
||||
}
|
||||
int sep = username.indexOf(SEPARATOR);
|
||||
if (sep == -1) {
|
||||
return false;
|
||||
}
|
||||
final String nickname = username.substring(0, sep);
|
||||
if (!isValidNickname(nickname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
int discriminator = Integer.parseInt(username.substring(sep + 1));
|
||||
return discriminator > 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public final class UsernameNormalizer {
|
||||
private UsernameNormalizer() {}
|
||||
public static String normalize(final String username) {
|
||||
return username.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import io.dropwizard.cli.EnvironmentCommand;
|
||||
import io.dropwizard.setup.Environment;
|
||||
import io.lettuce.core.resource.ClientResources;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -52,10 +50,10 @@ import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
@@ -67,18 +65,18 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||
public void run(WhisperServerConfiguration configuration, Environment environment) {
|
||||
|
||||
}
|
||||
}, "assign-username-hash", "assign a username hash to an account");
|
||||
}, "assign-username", "assign a username to an account");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(Subparser subparser) {
|
||||
super.configure(subparser);
|
||||
|
||||
subparser.addArgument("-u", "--usernameHash")
|
||||
.dest("usernameHash")
|
||||
subparser.addArgument("-n", "--nickname")
|
||||
.dest("nickname")
|
||||
.type(String.class)
|
||||
.required(true)
|
||||
.help("The username hash to assign");
|
||||
.help("The nickname (without discriminator) to assign");
|
||||
|
||||
subparser.addArgument("-a", "--aci")
|
||||
.dest("aci")
|
||||
@@ -194,25 +192,22 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||
deletedAccountsLockDynamoDbClient,
|
||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||
experimentEnrollmentManager, Clock.systemUTC());
|
||||
|
||||
final String usernameHash = namespace.getString("usernameHash");
|
||||
final String nickname = namespace.getString("nickname");
|
||||
final UUID accountIdentifier = UUID.fromString(namespace.getString("aci"));
|
||||
|
||||
accountsManager.getByAccountIdentifier(accountIdentifier).ifPresentOrElse(account -> {
|
||||
try {
|
||||
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsernameHash(account,
|
||||
List.of(Base64.getUrlDecoder().decode(usernameHash)));
|
||||
final Account result = accountsManager.confirmReservedUsernameHash(account, Base64.getUrlDecoder().decode(usernameHash));
|
||||
System.out.println("New username hash: " + usernameHash);
|
||||
} catch (final UsernameHashNotAvailableException e) {
|
||||
throw new IllegalArgumentException("Username hash already taken");
|
||||
} catch (final UsernameReservationNotFoundException e) {
|
||||
throw new IllegalArgumentException("Username hash reservation not found");
|
||||
final Account result = accountsManager.setUsername(account, nickname, null);
|
||||
System.out.println("New username: " + result.getUsername());
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
throw new IllegalArgumentException("Username already taken");
|
||||
}
|
||||
},
|
||||
() -> {
|
||||
|
||||
@@ -55,6 +55,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
@@ -194,9 +195,10 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||
deletedAccountsLockDynamoDbClient,
|
||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||
experimentEnrollmentManager, clock);
|
||||
|
||||
for (String user : users) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
@@ -195,9 +196,10 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||
deletedAccountsLockDynamoDbClient,
|
||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||
experimentEnrollmentManager, clock);
|
||||
|
||||
Optional<Account> maybeAccount;
|
||||
|
||||
Reference in New Issue
Block a user