Revert "Stored hashed username"
This commit is contained in:
parent
d93d50d038
commit
c98b54ff15
|
@ -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;
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.anyLong;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
|
@ -24,6 +24,7 @@ import static org.mockito.Mockito.verify;
|
|||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
|
@ -36,7 +37,6 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
@ -79,20 +79,20 @@ 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.GcmRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
|
||||
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.SignedPreKey;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberResponse;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
|
@ -108,7 +108,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.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
|
@ -119,6 +119,7 @@ import org.whispersystems.textsecuregcm.util.TestClock;
|
|||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class AccountControllerTest {
|
||||
|
||||
private static final String SENDER = "+14152222222";
|
||||
private static final String SENDER_OLD = "+14151111111";
|
||||
private static final String SENDER_PIN = "+14153333333";
|
||||
|
@ -130,18 +131,10 @@ class AccountControllerTest {
|
|||
private static final String SENDER_TRANSFER = "+14151111112";
|
||||
private static final String RESTRICTED_COUNTRY = "800";
|
||||
private static final String RESTRICTED_NUMBER = "+" + RESTRICTED_COUNTRY + "11111111";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc";
|
||||
|
||||
private static final String INVALID_BASE_64_URL_USERNAME_HASH = "fA+VkNbvB6dVfx/6NpaRSK6mvhhAUBgDNWFaD7+7gvs=";
|
||||
private static final String TOO_SHORT_BASE_64_URL_USERNAME_HASH = "P2oMuxx0xgGxSpTO0ACq3IztEOBDaV9t9YFu4bAGpQ";
|
||||
private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);
|
||||
private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);
|
||||
private static final byte[] INVALID_USERNAME_HASH = Base64.getDecoder().decode(INVALID_BASE_64_URL_USERNAME_HASH);
|
||||
private static final byte[] TOO_SHORT_USERNAME_HASH = Base64.getUrlDecoder().decode(TOO_SHORT_BASE_64_URL_USERNAME_HASH);
|
||||
|
||||
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
|
||||
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
|
||||
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
|
||||
|
||||
private static final String NICE_HOST = "127.0.0.1";
|
||||
private static final String RATE_LIMITED_IP_HOST = "10.0.0.1";
|
||||
|
@ -193,7 +186,6 @@ class AccountControllerTest {
|
|||
new PolymorphicAuthValueFactoryProvider.Binder<>(
|
||||
ImmutableSet.of(AuthenticatedAccount.class,
|
||||
DisabledPermittedAuthenticatedAccount.class)))
|
||||
.addProvider(new JsonMappingExceptionMapper())
|
||||
.addProvider(new RateLimitExceededExceptionMapper())
|
||||
.addProvider(new ImpossiblePhoneNumberExceptionMapper())
|
||||
.addProvider(new NonNormalizedPhoneNumberExceptionMapper())
|
||||
|
@ -280,6 +272,9 @@ class AccountControllerTest {
|
|||
return account;
|
||||
});
|
||||
|
||||
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername", null))
|
||||
.thenThrow(new UsernameNotAvailableException());
|
||||
|
||||
when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>) invocation -> {
|
||||
final Account account = invocation.getArgument(0, Account.class);
|
||||
final String number = invocation.getArgument(1, String.class);
|
||||
|
@ -1653,140 +1648,143 @@ class AccountControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHash() throws UsernameHashNotAvailableException {
|
||||
when(accountsManager.reserveUsernameHash(any(), any()))
|
||||
.thenReturn(new AccountsManager.UsernameReservation(null, USERNAME_HASH_1));
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/reserve")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2))));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(ReserveUsernameHashResponse.class))
|
||||
.satisfies(r -> assertThat(r.usernameHash()).hasSize(32));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHashUnavailable() throws UsernameHashNotAvailableException {
|
||||
when(accountsManager.reserveUsernameHash(any(), anyList()))
|
||||
.thenThrow(new UsernameHashNotAvailableException());
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/reserve")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2))));
|
||||
assertThat(response.getStatus()).isEqualTo(409);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testReserveUsernameHashListSizeInvalid(List<byte[]> usernameHashes) {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/reserve")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameHashRequest(usernameHashes)));
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
static Stream<Arguments> testReserveUsernameHashListSizeInvalid() {
|
||||
return Stream.of(
|
||||
Arguments.of(Collections.nCopies(21, USERNAME_HASH_1)),
|
||||
Arguments.of(Collections.emptyList())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHashInvalidHashSize() {
|
||||
List<byte[]> usernameHashes = List.of(new byte[31]);
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/reserve")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameHashRequest(usernameHashes)));
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHashInvalidBase64UrlEncoding() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/reserve")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(
|
||||
// Has '+' and '='characters which are invalid in base64url
|
||||
"""
|
||||
{
|
||||
"usernameHashes": ["jh1jJ50oGn9wUXAFNtDus6AJgWOQ6XbZzF+wCv7OOQs="]
|
||||
}
|
||||
"""));
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUsername() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
void testSetUsername() throws UsernameNotAvailableException {
|
||||
Account account = mock(Account.class);
|
||||
when(account.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH_1));
|
||||
when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1))).thenReturn(account);
|
||||
when(account.getUsername()).thenReturn(Optional.of("N00bkilleR.1234"));
|
||||
when(accountsManager.setUsername(any(), eq("N00bkilleR"), isNull()))
|
||||
.thenReturn(account);
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1)));
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new UsernameRequest("N00bkilleR", null)));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertArrayEquals(response.readEntity(UsernameHashResponse.class).usernameHash(), USERNAME_HASH_1);
|
||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("N00bkilleR.1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUnreservedUsername() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1)))
|
||||
void testReserveUsername() throws UsernameNotAvailableException {
|
||||
when(accountsManager.reserveUsername(any(), eq("N00bkilleR")))
|
||||
.thenReturn(new AccountsManager.UsernameReservation(null, "N00bkilleR.1234", RESERVATION_TOKEN));
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/reserved")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameRequest("N00bkilleR")));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(ReserveUsernameResponse.class))
|
||||
.satisfies(r -> r.username().equals("N00bkilleR.1234"))
|
||||
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
Account account = mock(Account.class);
|
||||
when(account.getUsername()).thenReturn(Optional.of("n00bkiller.1234"));
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller.1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
|
||||
.thenThrow(new UsernameReservationNotFoundException());
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/confirm")
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1)));
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(409);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitLapsedUsername() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1)))
|
||||
.thenThrow(new UsernameHashNotAvailableException());
|
||||
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
|
||||
.thenThrow(new UsernameNotAvailableException());
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/confirm")
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1)));
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(410);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetTakenUsername() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new UsernameRequest("takenusername", null)));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(409);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetInvalidUsername() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
// contains non-ascii character
|
||||
.put(Entity.json(new UsernameRequest("pаypal", null)));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetInvalidPrefixUsername() throws JsonProcessingException {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new UsernameRequest("0n00bkiller", null)));
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsernameBadAuth() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
||||
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteUsername() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/")
|
||||
.target("/v1/accounts/username/")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.delete();
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
verify(accountsManager).clearUsernameHash(AuthHelper.VALID_ACCOUNT);
|
||||
verify(accountsManager).clearUsername(AuthHelper.VALID_ACCOUNT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteUsernameBadAuth() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username_hash/")
|
||||
.target("/v1/accounts/username/")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
||||
.delete();
|
||||
|
@ -2000,9 +1998,9 @@ class AccountControllerTest {
|
|||
final UUID uuid = UUID.randomUUID();
|
||||
when(account.getUuid()).thenReturn(uuid);
|
||||
|
||||
when(accountsManager.getByUsernameHash(any())).thenReturn(Optional.of(account));
|
||||
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.of(account));
|
||||
Response response = resources.getJerseyTest()
|
||||
.target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1))
|
||||
.target("v1/accounts/username/n00bkiller.1234")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get();
|
||||
|
@ -2012,9 +2010,9 @@ class AccountControllerTest {
|
|||
|
||||
@Test
|
||||
void testLookupUsernameDoesNotExist() {
|
||||
when(accountsManager.getByUsernameHash(any())).thenReturn(Optional.empty());
|
||||
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.empty());
|
||||
assertThat(resources.getJerseyTest()
|
||||
.target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1))
|
||||
.target("v1/accounts/username/n00bkiller.1234")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get().getStatus()).isEqualTo(404);
|
||||
|
@ -2026,7 +2024,7 @@ class AccountControllerTest {
|
|||
MockUtils.updateRateLimiterResponseToFail(
|
||||
rateLimiters, RateLimiters.Handle.USERNAME_LOOKUP, "127.0.0.1", expectedRetryAfter);
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1))
|
||||
.target("/v1/accounts/username/test.123")
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get();
|
||||
|
@ -2035,34 +2033,6 @@ class AccountControllerTest {
|
|||
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookupUsernameAuthenticated() {
|
||||
assertThat(resources.getJerseyTest()
|
||||
.target(String.format("/v1/accounts/username_hash/%s", USERNAME_HASH_1))
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get()
|
||||
.getStatus()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLookupUsernameInvalidFormat() {
|
||||
assertThat(resources.getJerseyTest()
|
||||
.target(String.format("/v1/accounts/username_hash/%s", INVALID_USERNAME_HASH))
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get()
|
||||
.getStatus()).isEqualTo(422);
|
||||
|
||||
assertThat(resources.getJerseyTest()
|
||||
.target(String.format("/v1/accounts/username_hash/%s", TOO_SHORT_USERNAME_HASH))
|
||||
.request()
|
||||
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1")
|
||||
.get()
|
||||
.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void pushTokensMatch(@Nullable final String pushChallenge, @Nullable final StoredVerificationCode storedVerificationCode, final boolean expectMatch) {
|
||||
|
|
|
@ -31,6 +31,7 @@ import java.security.SecureRandom;
|
|||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
@ -131,8 +132,6 @@ class ProfileControllerTest {
|
|||
private static final String ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = "bazz";
|
||||
private static final String ACCOUNT_TWO_IDENTITY_KEY = "bar";
|
||||
private static final String ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY = "baz";
|
||||
private static final String BASE_64_URL_USERNAME_HASH = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final byte[] USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH);
|
||||
@SuppressWarnings("unchecked")
|
||||
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(
|
||||
DynamicConfigurationManager.class);
|
||||
|
@ -198,7 +197,7 @@ class ProfileControllerTest {
|
|||
when(profileAccount.isAnnouncementGroupSupported()).thenReturn(false);
|
||||
when(profileAccount.isChangeNumberSupported()).thenReturn(false);
|
||||
when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());
|
||||
when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH));
|
||||
when(profileAccount.getUsername()).thenReturn(Optional.of("n00bkiller"));
|
||||
when(profileAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of("1337".getBytes()));
|
||||
|
||||
Account capabilitiesAccount = mock(Account.class);
|
||||
|
@ -213,7 +212,7 @@ class ProfileControllerTest {
|
|||
when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(profileAccount));
|
||||
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(profileAccount));
|
||||
when(accountsManager.getByPhoneNumberIdentifier(AuthHelper.VALID_PNI_TWO)).thenReturn(Optional.of(profileAccount));
|
||||
when(accountsManager.getByUsernameHash(USERNAME_HASH)).thenReturn(Optional.of(profileAccount));
|
||||
when(accountsManager.getByUsername("n00bkiller")).thenReturn(Optional.of(profileAccount));
|
||||
|
||||
when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(capabilitiesAccount));
|
||||
when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(capabilitiesAccount));
|
||||
|
|
|
@ -10,7 +10,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -27,10 +26,8 @@ class AccountChangeValidatorTest {
|
|||
private static final UUID ORIGINAL_PNI = UUID.randomUUID();
|
||||
private static final UUID CHANGED_PNI = UUID.randomUUID();
|
||||
|
||||
private static final String BASE_64_URL_ORIGINAL_USERNAME = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final String BASE_64_URL_CHANGED_USERNAME = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc";
|
||||
private static final byte[] ORIGINAL_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_ORIGINAL_USERNAME);
|
||||
private static final byte[] CHANGED_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_CHANGED_USERNAME);
|
||||
private static final String ORIGINAL_USERNAME = "bruce_wayne";
|
||||
private static final String CHANGED_USERNAME = "batman";
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
|
@ -52,22 +49,22 @@ class AccountChangeValidatorTest {
|
|||
final Account originalAccount = mock(Account.class);
|
||||
when(originalAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);
|
||||
when(originalAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);
|
||||
when(originalAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));
|
||||
when(originalAccount.getUsername()).thenReturn(Optional.of(ORIGINAL_USERNAME));
|
||||
|
||||
final Account unchangedAccount = mock(Account.class);
|
||||
when(unchangedAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);
|
||||
when(unchangedAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);
|
||||
when(unchangedAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));
|
||||
when(unchangedAccount.getUsername()).thenReturn(Optional.of(ORIGINAL_USERNAME));
|
||||
|
||||
final Account changedNumberAccount = mock(Account.class);
|
||||
when(changedNumberAccount.getNumber()).thenReturn(CHANGED_NUMBER);
|
||||
when(changedNumberAccount.getPhoneNumberIdentifier()).thenReturn(CHANGED_PNI);
|
||||
when(changedNumberAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));
|
||||
when(changedNumberAccount.getUsername()).thenReturn(Optional.of(ORIGINAL_USERNAME));
|
||||
|
||||
final Account changedUsernameAccount = mock(Account.class);
|
||||
when(changedUsernameAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);
|
||||
when(changedUsernameAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);
|
||||
when(changedUsernameAccount.getUsernameHash()).thenReturn(Optional.of(CHANGED_USERNAME_HASH));
|
||||
when(changedUsernameAccount.getUsername()).thenReturn(Optional.of(CHANGED_USERNAME));
|
||||
|
||||
return Stream.of(
|
||||
Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, true),
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
|||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
||||
|
@ -191,11 +192,13 @@ class AccountsManagerChangeNumberIntegrationTest {
|
|||
mock(DirectoryQueue.class),
|
||||
mock(Keys.class),
|
||||
mock(MessagesManager.class),
|
||||
mock(ProhibitedUsernames.class),
|
||||
mock(ProfilesManager.class),
|
||||
mock(StoredVerificationCodeManager.class),
|
||||
secureStorageClient,
|
||||
secureBackupClient,
|
||||
clientPresenceManager,
|
||||
mock(UsernameGenerator.class),
|
||||
mock(ExperimentEnrollmentManager.class),
|
||||
mock(Clock.class));
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
|
|||
import org.whispersystems.textsecuregcm.tests.util.JsonHelpers;
|
||||
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||
|
@ -158,11 +159,13 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
|||
mock(DirectoryQueue.class),
|
||||
mock(Keys.class),
|
||||
mock(MessagesManager.class),
|
||||
mock(ProhibitedUsernames.class),
|
||||
mock(ProfilesManager.class),
|
||||
mock(StoredVerificationCodeManager.class),
|
||||
mock(SecureStorageClient.class),
|
||||
mock(SecureBackupClient.class),
|
||||
mock(ClientPresenceManager.class),
|
||||
mock(UsernameGenerator.class),
|
||||
mock(ExperimentEnrollmentManager.class),
|
||||
mock(Clock.class)
|
||||
);
|
||||
|
|
|
@ -5,15 +5,18 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.AdditionalMatchers.and;
|
||||
import static org.mockito.AdditionalMatchers.not;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.startsWith;
|
||||
import static org.mockito.Mockito.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
|
@ -28,17 +31,15 @@ import static org.mockito.Mockito.when;
|
|||
import io.lettuce.core.RedisException;
|
||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -46,7 +47,9 @@ import org.junit.jupiter.params.ParameterizedTest;
|
|||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentMatcher;
|
||||
import org.mockito.stubbing.Answer;
|
||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
|
@ -59,12 +62,10 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
|||
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
|
||||
|
||||
class AccountsManagerTest {
|
||||
private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc";
|
||||
private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);
|
||||
private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);
|
||||
|
||||
private Accounts accounts;
|
||||
private DeletedAccountsManager deletedAccountsManager;
|
||||
|
@ -72,6 +73,7 @@ class AccountsManagerTest {
|
|||
private Keys keys;
|
||||
private MessagesManager messagesManager;
|
||||
private ProfilesManager profilesManager;
|
||||
private ProhibitedUsernames prohibitedUsernames;
|
||||
private ExperimentEnrollmentManager enrollmentManager;
|
||||
|
||||
private Map<String, UUID> phoneNumberIdentifiersByE164;
|
||||
|
@ -87,6 +89,8 @@ class AccountsManagerTest {
|
|||
return null;
|
||||
};
|
||||
|
||||
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws InterruptedException {
|
||||
accounts = mock(Accounts.class);
|
||||
|
@ -95,6 +99,7 @@ class AccountsManagerTest {
|
|||
keys = mock(Keys.class);
|
||||
messagesManager = mock(MessagesManager.class);
|
||||
profilesManager = mock(ProfilesManager.class);
|
||||
prohibitedUsernames = mock(ProhibitedUsernames.class);
|
||||
|
||||
//noinspection unchecked
|
||||
commands = mock(RedisAdvancedClusterCommands.class);
|
||||
|
@ -138,7 +143,7 @@ class AccountsManagerTest {
|
|||
|
||||
enrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||
when(enrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME))).thenReturn(true);
|
||||
when(accounts.usernameHashAvailable(any())).thenReturn(true);
|
||||
when(accounts.usernameAvailable(any())).thenReturn(true);
|
||||
|
||||
accountsManager = new AccountsManager(
|
||||
accounts,
|
||||
|
@ -148,11 +153,13 @@ class AccountsManagerTest {
|
|||
directoryQueue,
|
||||
keys,
|
||||
messagesManager,
|
||||
prohibitedUsernames,
|
||||
profilesManager,
|
||||
mock(StoredVerificationCodeManager.class),
|
||||
storageClient,
|
||||
backupClient,
|
||||
mock(ClientPresenceManager.class),
|
||||
new UsernameGenerator(new UsernameConfiguration()),
|
||||
enrollmentManager,
|
||||
mock(Clock.class));
|
||||
}
|
||||
|
@ -162,8 +169,7 @@ class AccountsManagerTest {
|
|||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
when(commands.get(eq("AccountMap::+14152222222"))).thenReturn(uuid.toString());
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn(
|
||||
"{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
|
||||
Optional<Account> account = accountsManager.getByE164("+14152222222");
|
||||
|
||||
|
@ -182,8 +188,7 @@ class AccountsManagerTest {
|
|||
void testGetAccountByUuidInCache() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn(
|
||||
"{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
|
||||
Optional<Account> account = accountsManager.getByAccountIdentifier(uuid);
|
||||
|
||||
|
@ -204,8 +209,7 @@ class AccountsManagerTest {
|
|||
UUID pni = UUID.randomUUID();
|
||||
|
||||
when(commands.get(eq("AccountMap::" + pni))).thenReturn(uuid.toString());
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn(
|
||||
"{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}");
|
||||
|
||||
Optional<Account> account = accountsManager.getByPhoneNumberIdentifier(pni);
|
||||
|
||||
|
@ -221,21 +225,21 @@ class AccountsManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testGetByUsernameHashInCache() {
|
||||
void testGetByUsernameInCache() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenReturn(uuid.toString());
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn(
|
||||
String.format("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\", \"usernameHash\": \"%s\"}",
|
||||
BASE_64_URL_USERNAME_HASH_1));
|
||||
String username = "test";
|
||||
|
||||
Optional<Account> account = accountsManager.getByUsernameHash(USERNAME_HASH_1);
|
||||
when(commands.get(eq("UAccountMap::" + username))).thenReturn(uuid.toString());
|
||||
when(commands.get(eq("Account3::" + uuid))).thenReturn("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\", \"username\": \"test\"}");
|
||||
|
||||
Optional<Account> account = accountsManager.getByUsername(username);
|
||||
|
||||
assertTrue(account.isPresent());
|
||||
assertEquals(account.get().getNumber(), "+14152222222");
|
||||
assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier());
|
||||
assertArrayEquals(USERNAME_HASH_1, account.get().getUsernameHash().get());
|
||||
assertEquals(Optional.of(username), account.get().getUsername());
|
||||
|
||||
verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1));
|
||||
verify(commands).get(eq("UAccountMap::" + username));
|
||||
verify(commands).get(eq("Account3::" + uuid));
|
||||
verifyNoMoreInteractions(commands);
|
||||
|
||||
|
@ -316,28 +320,29 @@ class AccountsManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testGetAccountByUsernameHashNotInCache() {
|
||||
void testGetAccountByUsernameNotInCache() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
String username = "test";
|
||||
|
||||
Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
account.setUsernameHash(USERNAME_HASH_1);
|
||||
account.setUsername(username);
|
||||
|
||||
when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenReturn(null);
|
||||
when(accounts.getByUsernameHash(USERNAME_HASH_1)).thenReturn(Optional.of(account));
|
||||
when(commands.get(eq("UAccountMap::" + username))).thenReturn(null);
|
||||
when(accounts.getByUsername(username)).thenReturn(Optional.of(account));
|
||||
|
||||
Optional<Account> retrieved = accountsManager.getByUsernameHash(USERNAME_HASH_1);
|
||||
Optional<Account> retrieved = accountsManager.getByUsername(username);
|
||||
|
||||
assertTrue(retrieved.isPresent());
|
||||
assertSame(retrieved.get(), account);
|
||||
|
||||
verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1));
|
||||
verify(commands).setex(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).get(eq("UAccountMap::" + username));
|
||||
verify(commands).setex(eq("UAccountMap::" + username), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString());
|
||||
verifyNoMoreInteractions(commands);
|
||||
|
||||
verify(accounts).getByUsernameHash(USERNAME_HASH_1);
|
||||
verify(accounts).getByUsername(username);
|
||||
verifyNoMoreInteractions(accounts);
|
||||
}
|
||||
|
||||
|
@ -417,26 +422,27 @@ class AccountsManagerTest {
|
|||
@Test
|
||||
void testGetAccountByUsernameBrokenCache() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
String username = "test";
|
||||
|
||||
Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
account.setUsernameHash(USERNAME_HASH_1);
|
||||
account.setUsername(username);
|
||||
|
||||
when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenThrow(new RedisException("OH NO"));
|
||||
when(accounts.getByUsernameHash(USERNAME_HASH_1)).thenReturn(Optional.of(account));
|
||||
when(commands.get(eq("UAccountMap::" + username))).thenThrow(new RedisException("OH NO"));
|
||||
when(accounts.getByUsername(username)).thenReturn(Optional.of(account));
|
||||
|
||||
Optional<Account> retrieved = accountsManager.getByUsernameHash(USERNAME_HASH_1);
|
||||
Optional<Account> retrieved = accountsManager.getByUsername(username);
|
||||
|
||||
assertTrue(retrieved.isPresent());
|
||||
assertSame(retrieved.get(), account);
|
||||
|
||||
verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1));
|
||||
verify(commands).setex(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).get(eq("UAccountMap::" + username));
|
||||
verify(commands).setex(eq("UAccountMap::" + username), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString()));
|
||||
verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString());
|
||||
verifyNoMoreInteractions(commands);
|
||||
|
||||
verify(accounts).getByUsernameHash(USERNAME_HASH_1);
|
||||
verify(accounts).getByUsername(username);
|
||||
verifyNoMoreInteractions(accounts);
|
||||
}
|
||||
|
||||
|
@ -728,96 +734,188 @@ class AccountsManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHash() throws UsernameHashNotAvailableException {
|
||||
void testSetUsername() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final List<byte[]> usernameHashes = List.of(new byte[32], new byte[32]);
|
||||
when(accounts.usernameHashAvailable(any())).thenReturn(true);
|
||||
accountsManager.reserveUsernameHash(account, usernameHashes);
|
||||
verify(accounts).reserveUsernameHash(eq(account), eq(new byte[32]), eq(Duration.ofMinutes(5)));
|
||||
final String nickname = "test";
|
||||
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
|
||||
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameHashNotAvailable() {
|
||||
void testReserveUsername() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
when(accounts.usernameHashAvailable(any())).thenReturn(false);
|
||||
|
||||
assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_1, USERNAME_HASH_2)));
|
||||
final String nickname = "beethoven";
|
||||
accountsManager.reserveUsername(account, nickname);
|
||||
verify(accounts).reserveUsername(eq(account), startsWith(nickname), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameDisabled() {
|
||||
void testSetReservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
when(enrollmentManager.isEnrolled(account.getUuid(), AccountsManager.USERNAME_EXPERIMENT_NAME)).thenReturn(false);
|
||||
assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_1)));
|
||||
final String reserved = "sCoObY.1234";
|
||||
setReservationHash(account, reserved);
|
||||
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
|
||||
accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN);
|
||||
verify(accounts).confirmUsername(eq(account), eq(reserved), eq(RESERVATION_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedUsernameHash() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
void testSetReservedHashNameMismatch() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
setReservationHash(account, USERNAME_HASH_1);
|
||||
when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(true);
|
||||
accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1);
|
||||
verify(accounts).confirmUsernameHash(eq(account), eq(USERNAME_HASH_1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedHashNameMismatch() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
setReservationHash(account, USERNAME_HASH_1);
|
||||
when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(true);
|
||||
setReservationHash(account, "pluto.1234");
|
||||
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq("pluto.1234"))).thenReturn(true);
|
||||
assertThrows(UsernameReservationNotFoundException.class,
|
||||
() -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_2));
|
||||
() -> accountsManager.confirmReservedUsername(account, "goofy.1234", RESERVATION_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedLapsed() {
|
||||
void testSetReservedHashAciMismatch() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
// hash was reserved, but the reservation lapsed and another account took it
|
||||
setReservationHash(account, USERNAME_HASH_1);
|
||||
when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(false);
|
||||
assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.confirmReservedUsernameHash(account,
|
||||
USERNAME_HASH_1));
|
||||
verify(accounts, never()).confirmUsernameHash(any(), any());
|
||||
final String reserved = "toto.1234";
|
||||
account.setReservedUsernameHash(Accounts.reservedUsernameHash(UUID.randomUUID(), reserved));
|
||||
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
|
||||
assertThrows(UsernameReservationNotFoundException.class,
|
||||
() -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedRetry() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
void testSetReservedLapsed() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
account.setUsernameHash(USERNAME_HASH_1);
|
||||
final String reserved = "porkchop.1234";
|
||||
// name was reserved, but the reservation lapsed and another account took it
|
||||
setReservationHash(account, reserved);
|
||||
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(false);
|
||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
|
||||
verify(accounts, never()).confirmUsername(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetReservedRetry() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String username = "santaslittlehelper.1234";
|
||||
account.setUsername(username);
|
||||
|
||||
// reserved username already set, should be treated as a replay
|
||||
accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1);
|
||||
accountsManager.confirmReservedUsername(account, username, RESERVATION_TOKEN);
|
||||
verifyNoInteractions(accounts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedUsernameHashWithNoReservation() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
|
||||
new ArrayList<>(), new byte[16]);
|
||||
assertThrows(UsernameReservationNotFoundException.class,
|
||||
() -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1));
|
||||
verify(accounts, never()).confirmUsernameHash(any(), any());
|
||||
void testSetUsernameSameUsername() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
account.setUsername(nickname + ".123");
|
||||
|
||||
// should be treated as a replayed request
|
||||
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
|
||||
verify(accounts, never()).setUsername(eq(account), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearUsernameHash() {
|
||||
Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
account.setUsernameHash(USERNAME_HASH_1);
|
||||
accountsManager.clearUsernameHash(account);
|
||||
verify(accounts).clearUsernameHash(eq(account));
|
||||
void testSetUsernameReroll() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
final String username = nickname + ".ZZZ";
|
||||
account.setUsername(username);
|
||||
|
||||
// given the correct old username, should reroll discriminator even if the nick matches
|
||||
accountsManager.setUsername(account, nickname, username);
|
||||
verify(accounts).setUsername(eq(account), and(startsWith(nickname), not(eq(username))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameReroll() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "clifford";
|
||||
final String username = nickname + ".ZZZ";
|
||||
account.setUsername(username);
|
||||
|
||||
// given the correct old username, should reroll discriminator even if the nick matches
|
||||
accountsManager.reserveUsername(account, nickname);
|
||||
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), not(eq(username))), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetReservedUsernameWithNoReservation() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
|
||||
new ArrayList<>(), new byte[16]);
|
||||
assertThrows(UsernameReservationNotFoundException.class,
|
||||
() -> accountsManager.confirmReservedUsername(account, "laika.1234", RESERVATION_TOKEN));
|
||||
verify(accounts, never()).confirmUsername(any(), any(), any());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void testUsernameExpandDiscriminator(boolean reserve) throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
|
||||
ArgumentMatcher<String> isWide = (String username) -> {
|
||||
String[] spl = username.split(Pattern.quote(UsernameGenerator.SEPARATOR));
|
||||
assertEquals(spl.length, 2);
|
||||
int discriminator = Integer.parseInt(spl[1]);
|
||||
// require a 7 digit discriminator
|
||||
return discriminator > 1_000_000;
|
||||
};
|
||||
when(accounts.usernameAvailable(any())).thenReturn(false);
|
||||
when(accounts.usernameAvailable(argThat(isWide))).thenReturn(true);
|
||||
|
||||
if (reserve) {
|
||||
accountsManager.reserveUsername(account, nickname);
|
||||
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), argThat(isWide)), any());
|
||||
|
||||
} else {
|
||||
accountsManager.setUsername(account, nickname, null);
|
||||
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testChangeUsername() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
account.setUsername("old.123");
|
||||
accountsManager.setUsername(account, nickname, "old.123");
|
||||
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsernameNotAvailable() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "unavailable";
|
||||
when(accounts.usernameAvailable(startsWith(nickname))).thenReturn(false);
|
||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
|
||||
verify(accounts, never()).setUsername(any(), any());
|
||||
assertTrue(account.getUsername().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsernameReserved() {
|
||||
final String nickname = "reserved";
|
||||
when(prohibitedUsernames.isProhibited(eq(nickname), any())).thenReturn(true);
|
||||
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
|
||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
|
||||
assertTrue(account.getUsername().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsernameViaUpdate() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
|
||||
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsernameHash(USERNAME_HASH_1)));
|
||||
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsername("test")));
|
||||
}
|
||||
|
||||
private void setReservationHash(final Account account, final byte[] reservedUsernameHash) {
|
||||
account.setReservedUsernameHash(reservedUsernameHash);
|
||||
@Test
|
||||
void testSetUsernameDisabled() {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
when(enrollmentManager.isEnrolled(account.getUuid(), AccountsManager.USERNAME_EXPERIMENT_NAME)).thenReturn(false);
|
||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
|
||||
}
|
||||
|
||||
private void setReservationHash(final Account account, final String reservedUsername) {
|
||||
account.setReservedUsernameHash(Accounts.reservedUsernameHash(account.getUuid(), reservedUsername));
|
||||
}
|
||||
|
||||
private static Device generateTestDevice(final long lastSeen) {
|
||||
|
|
|
@ -6,10 +6,9 @@
|
|||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.argThat;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.eq;
|
||||
|
@ -18,15 +17,11 @@ import static org.mockito.Mockito.times;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
@ -34,6 +29,8 @@ import java.util.function.Consumer;
|
|||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
|
@ -45,6 +42,7 @@ import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
|||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||
|
@ -61,11 +59,7 @@ class AccountsManagerUsernameIntegrationTest {
|
|||
private static final String PNI_ASSIGNMENT_TABLE_NAME = "pni_assignment_test";
|
||||
private static final String USERNAMES_TABLE_NAME = "usernames_test";
|
||||
private static final String PNI_TABLE_NAME = "pni_test";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc";
|
||||
private static final int SCAN_PAGE_SIZE = 1;
|
||||
private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);
|
||||
private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);
|
||||
|
||||
@RegisterExtension
|
||||
static DynamoDbExtension ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder()
|
||||
|
@ -92,6 +86,7 @@ class AccountsManagerUsernameIntegrationTest {
|
|||
|
||||
private AccountsManager accountsManager;
|
||||
private Accounts accounts;
|
||||
private UsernameGenerator usernameGenerator;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws InterruptedException {
|
||||
|
@ -112,12 +107,12 @@ class AccountsManagerUsernameIntegrationTest {
|
|||
CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder()
|
||||
.tableName(USERNAMES_TABLE_NAME)
|
||||
.keySchema(KeySchemaElement.builder()
|
||||
.attributeName(Accounts.ATTR_USERNAME_HASH)
|
||||
.attributeName(Accounts.ATTR_USERNAME)
|
||||
.keyType(KeyType.HASH)
|
||||
.build())
|
||||
.attributeDefinitions(AttributeDefinition.builder()
|
||||
.attributeName(Accounts.ATTR_USERNAME_HASH)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.attributeName(Accounts.ATTR_USERNAME)
|
||||
.attributeType(ScalarAttributeType.S)
|
||||
.build())
|
||||
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
|
||||
.build();
|
||||
|
@ -157,6 +152,8 @@ class AccountsManagerUsernameIntegrationTest {
|
|||
USERNAMES_TABLE_NAME,
|
||||
SCAN_PAGE_SIZE));
|
||||
|
||||
usernameGenerator = new UsernameGenerator(initialWidth, discriminatorMaxWidth, attemptsPerWidth,
|
||||
Duration.ofDays(1));
|
||||
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
|
||||
doAnswer((final InvocationOnMock invocationOnMock) -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
|
@ -179,159 +176,211 @@ class AccountsManagerUsernameIntegrationTest {
|
|||
mock(DirectoryQueue.class),
|
||||
mock(Keys.class),
|
||||
mock(MessagesManager.class),
|
||||
mock(ProhibitedUsernames.class),
|
||||
mock(ProfilesManager.class),
|
||||
mock(StoredVerificationCodeManager.class),
|
||||
mock(SecureStorageClient.class),
|
||||
mock(SecureBackupClient.class),
|
||||
mock(ClientPresenceManager.class),
|
||||
usernameGenerator,
|
||||
experimentEnrollmentManager,
|
||||
mock(Clock.class));
|
||||
}
|
||||
|
||||
private static int discriminator(String username) {
|
||||
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNoUsernames() throws InterruptedException {
|
||||
void testSetClearUsername() throws UsernameNotAvailableException, InterruptedException {
|
||||
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
List<byte[]> usernameHashes = List.of(USERNAME_HASH_1, USERNAME_HASH_2);
|
||||
int i = 0;
|
||||
for (byte[] hash : usernameHashes) {
|
||||
account = accountsManager.setUsername(account, "n00bkiller", null);
|
||||
assertThat(account.getUsername()).isPresent();
|
||||
assertThat(account.getUsername().get()).startsWith("n00bkiller");
|
||||
int discriminator = discriminator(account.getUsername().get());
|
||||
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
|
||||
|
||||
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
|
||||
account.getUuid());
|
||||
|
||||
// reroll
|
||||
account = accountsManager.setUsername(account, "n00bkiller", account.getUsername().get());
|
||||
final String newUsername = account.getUsername().orElseThrow();
|
||||
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
|
||||
|
||||
// clear
|
||||
account = accountsManager.clearUsername(account);
|
||||
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
|
||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void testNoUsernames(boolean reserve) throws InterruptedException {
|
||||
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
for (int i = 1; i <= 99; i++) {
|
||||
final Map<String, AttributeValue> item = new HashMap<>(Map.of(
|
||||
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||
Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(hash)));
|
||||
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))));
|
||||
// half of these are taken usernames, half are only reservations (have a TTL)
|
||||
if (i % 2 == 0) {
|
||||
item.put(Accounts.ATTR_TTL,
|
||||
AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));
|
||||
}
|
||||
i++;
|
||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||
.tableName(USERNAMES_TABLE_NAME)
|
||||
.item(item)
|
||||
.build());
|
||||
}
|
||||
assertThrows(UsernameHashNotAvailableException.class, () -> {accountsManager.reserveUsernameHash(account, usernameHashes);});
|
||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty();
|
||||
assertThrows(UsernameNotAvailableException.class, () -> {
|
||||
if (reserve) {
|
||||
accountsManager.reserveUsername(account, "n00bkiller");
|
||||
} else {
|
||||
accountsManager.setUsername(account, "n00bkiller", null);
|
||||
}
|
||||
});
|
||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsernameSnatched() throws InterruptedException, UsernameHashNotAvailableException {
|
||||
void testUsernameSnatched() throws InterruptedException, UsernameNotAvailableException {
|
||||
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
ArrayList<byte[]> usernameHashes = new ArrayList<>(Arrays.asList(USERNAME_HASH_1, USERNAME_HASH_2));
|
||||
for (byte[] hash : usernameHashes) {
|
||||
for (int i = 1; i <= 9; i++) {
|
||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||
.tableName(USERNAMES_TABLE_NAME)
|
||||
.item(Map.of(
|
||||
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||
Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(hash)))
|
||||
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
|
||||
.build());
|
||||
}
|
||||
|
||||
|
||||
byte[] availableHash = new byte[32];
|
||||
new SecureRandom().nextBytes(availableHash);
|
||||
usernameHashes.add(availableHash);
|
||||
|
||||
// first time this is called lie and say the username is available
|
||||
// this simulates seeing an available username and then it being taken
|
||||
// by someone before the write
|
||||
doReturn(true).doCallRealMethod().when(accounts).usernameHashAvailable(any());
|
||||
final byte[] username = accountsManager
|
||||
.reserveUsernameHash(account, usernameHashes)
|
||||
.reservedUsernameHash();
|
||||
|
||||
assertArrayEquals(username, availableHash);
|
||||
doReturn(true).doCallRealMethod().when(accounts).usernameAvailable(any());
|
||||
final String username = accountsManager
|
||||
.setUsername(account, "n00bkiller", null)
|
||||
.getUsername().orElseThrow();
|
||||
assertThat(username).startsWith("n00bkiller");
|
||||
assertThat(discriminator(username)).isGreaterThanOrEqualTo(10).isLessThan(100);
|
||||
|
||||
// 1 attempt on first try (returns true),
|
||||
// 5 more attempts until "availableHash" returns true
|
||||
verify(accounts, times(4)).usernameHashAvailable(any());
|
||||
// 10 (attempts per width) on width=2 discriminators (all taken)
|
||||
verify(accounts, times(11)).usernameAvailable(argThat(un -> discriminator(un) < 10));
|
||||
|
||||
// 1 final attempt on width=3 discriminators
|
||||
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReserveConfirmClear()
|
||||
throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
public void testReserveSetClear()
|
||||
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "n00bkiller");
|
||||
account = reservation.account();
|
||||
assertThat(account.getReservedUsernameHash()).isPresent();
|
||||
assertThat(reservation.reservedUsername()).startsWith("n00bkiller");
|
||||
int discriminator = discriminator(reservation.reservedUsername());
|
||||
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
|
||||
assertThat(accountsManager.getByUsername(reservation.reservedUsername())).isEmpty();
|
||||
|
||||
// reserve
|
||||
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_1));
|
||||
assertArrayEquals(reservation.account().getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(accountsManager.getByUsernameHash(reservation.reservedUsernameHash())).isEmpty();
|
||||
account = accountsManager.confirmReservedUsername(
|
||||
account,
|
||||
reservation.reservedUsername(),
|
||||
reservation.reservationToken());
|
||||
|
||||
// confirm
|
||||
account = accountsManager.confirmReservedUsernameHash(
|
||||
reservation.account(),
|
||||
reservation.reservedUsernameHash());
|
||||
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).orElseThrow().getUuid()).isEqualTo(
|
||||
assertThat(account.getUsername().get()).startsWith("n00bkiller");
|
||||
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
|
||||
account.getUuid());
|
||||
|
||||
// reroll
|
||||
reservation = accountsManager.reserveUsername(account, "n00bkiller");
|
||||
account = reservation.account();
|
||||
account = accountsManager.confirmReservedUsername(
|
||||
account,
|
||||
reservation.reservedUsername(),
|
||||
reservation.reservationToken());
|
||||
|
||||
final String newUsername = account.getUsername().orElseThrow();
|
||||
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
|
||||
|
||||
// clear
|
||||
account = accountsManager.clearUsernameHash(account);
|
||||
assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1)).isEmpty();
|
||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty();
|
||||
account = accountsManager.clearUsername(account);
|
||||
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
|
||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReservationLapsed()
|
||||
throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
// use a username generator that can retry a lot
|
||||
buildAccountsManager(1, 1, 1000000);
|
||||
|
||||
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_1));
|
||||
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsername(account, "n00bkiller");
|
||||
final String reservedUsername = reservation1.reservedUsername();
|
||||
|
||||
long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();
|
||||
// force expiration
|
||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
|
||||
.tableName(USERNAMES_TABLE_NAME)
|
||||
.key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1)))
|
||||
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString(reservedUsername)))
|
||||
.updateExpression("SET #ttl = :ttl")
|
||||
.expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL))
|
||||
.expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past)))
|
||||
.build());
|
||||
|
||||
int discriminator = discriminator(reservedUsername);
|
||||
|
||||
// use up all names except the reserved one
|
||||
for (int i = 1; i <= 9; i++) {
|
||||
if (i == discriminator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||
.tableName(USERNAMES_TABLE_NAME)
|
||||
.item(Map.of(
|
||||
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
|
||||
.build());
|
||||
}
|
||||
|
||||
// a different account should be able to reserve it
|
||||
Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsernameHash(account2, List.of(
|
||||
USERNAME_HASH_1));
|
||||
assertArrayEquals(reservation2.reservedUsernameHash(), USERNAME_HASH_1);
|
||||
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsername(account2, "n00bkiller"
|
||||
);
|
||||
assertThat(reservation2.reservedUsername()).isEqualTo(reservedUsername);
|
||||
|
||||
assertThrows(UsernameHashNotAvailableException.class,
|
||||
() -> accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1));
|
||||
account2 = accountsManager.confirmReservedUsernameHash(reservation2.account(), USERNAME_HASH_1);
|
||||
assertEquals(accountsManager.getByUsernameHash(USERNAME_HASH_1).orElseThrow().getUuid(), account2.getUuid());
|
||||
assertArrayEquals(account2.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThrows(UsernameNotAvailableException.class,
|
||||
() -> accountsManager.confirmReservedUsername(reservation1.account(), reservedUsername, reservation1.reservationToken()));
|
||||
accountsManager.confirmReservedUsername(reservation2.account(), reservedUsername, reservation2.reservationToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUsernameSetReserveAnotherClearSetReserved()
|
||||
throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {
|
||||
void testUsernameReserveClearSetReserved()
|
||||
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||
new ArrayList<>());
|
||||
account = accountsManager.setUsername(account, "n00bkiller", null);
|
||||
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "other");
|
||||
account = reservation.account();
|
||||
|
||||
// Set username hash
|
||||
final AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_1));
|
||||
account = accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1);
|
||||
assertThat(reservation.reservedUsername()).startsWith("other");
|
||||
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("n00bkiller"));
|
||||
|
||||
// Reserve another hash on the same account
|
||||
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsernameHash(account, List.of(
|
||||
USERNAME_HASH_2));
|
||||
account = reservation2.account();
|
||||
|
||||
assertArrayEquals(account.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_2);
|
||||
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
|
||||
// Clear the set username hash but not the reserved one
|
||||
account = accountsManager.clearUsernameHash(account);
|
||||
account = accountsManager.clearUsername(account);
|
||||
assertThat(account.getReservedUsernameHash()).isPresent();
|
||||
assertThat(account.getUsernameHash()).isEmpty();
|
||||
assertThat(account.getUsername()).isEmpty();
|
||||
|
||||
// Confirm second reservation
|
||||
account = accountsManager.confirmReservedUsernameHash(account, reservation2.reservedUsernameHash());
|
||||
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_2);
|
||||
account = accountsManager.confirmReservedUsername(account, reservation.reservedUsername(), reservation.reservationToken());
|
||||
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("other"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,6 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
@ -23,7 +21,6 @@ import java.time.Duration;
|
|||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
@ -31,6 +28,7 @@ import java.util.Random;
|
|||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Supplier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
@ -68,15 +66,9 @@ class AccountsTest {
|
|||
private static final String NUMBER_CONSTRAINT_TABLE_NAME = "numbers_test";
|
||||
private static final String PNI_CONSTRAINT_TABLE_NAME = "pni_test";
|
||||
private static final String USERNAME_CONSTRAINT_TABLE_NAME = "username_test";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE";
|
||||
private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc";
|
||||
private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);
|
||||
private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);
|
||||
|
||||
private static final int SCAN_PAGE_SIZE = 1;
|
||||
|
||||
|
||||
|
||||
@RegisterExtension
|
||||
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||
.tableName(ACCOUNTS_TABLE_NAME)
|
||||
|
@ -126,12 +118,12 @@ class AccountsTest {
|
|||
CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder()
|
||||
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
|
||||
.keySchema(KeySchemaElement.builder()
|
||||
.attributeName(Accounts.ATTR_USERNAME_HASH)
|
||||
.attributeName(Accounts.ATTR_USERNAME)
|
||||
.keyType(KeyType.HASH)
|
||||
.build())
|
||||
.attributeDefinitions(AttributeDefinition.builder()
|
||||
.attributeName(Accounts.ATTR_USERNAME_HASH)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.attributeName(Accounts.ATTR_USERNAME)
|
||||
.attributeType(ScalarAttributeType.S)
|
||||
.build())
|
||||
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
|
||||
.build();
|
||||
|
@ -622,38 +614,40 @@ class AccountsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testSwitchUsernameHashes() {
|
||||
void testSetUsername() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty();
|
||||
final String username = "TeST";
|
||||
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
accounts.confirmUsernameHash(account, USERNAME_HASH_1);
|
||||
assertThat(accounts.getByUsername(username)).isEmpty();
|
||||
|
||||
accounts.setUsername(account, username);
|
||||
|
||||
{
|
||||
final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1);
|
||||
final Optional<Account> maybeAccount = accounts.getByUsername(username);
|
||||
|
||||
assertThat(maybeAccount).hasValueSatisfying(retrievedAccount ->
|
||||
assertThat(retrievedAccount.getUsernameHash()).hasValueSatisfying(retrievedUsernameHash ->
|
||||
assertArrayEquals(retrievedUsernameHash, USERNAME_HASH_1)));
|
||||
assertThat(retrievedAccount.getUsername()).hasValueSatisfying(retrievedUsername ->
|
||||
assertThat(retrievedUsername).isEqualTo(username)));
|
||||
|
||||
verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), maybeAccount.orElseThrow(), account);
|
||||
}
|
||||
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofDays(1));
|
||||
accounts.confirmUsernameHash(account, USERNAME_HASH_2);
|
||||
final String secondUsername = username + "2";
|
||||
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty();
|
||||
accounts.setUsername(account, secondUsername);
|
||||
|
||||
assertThat(accounts.getByUsername(username)).isEmpty();
|
||||
assertThat(dynamoDbExtension.getDynamoDbClient()
|
||||
.getItem(GetItemRequest.builder()
|
||||
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
|
||||
.key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1)))
|
||||
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("test")))
|
||||
.build())
|
||||
.item()).isEmpty();
|
||||
|
||||
{
|
||||
final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_2);
|
||||
final Optional<Account> maybeAccount = accounts.getByUsername(secondUsername);
|
||||
|
||||
assertThat(maybeAccount).isPresent();
|
||||
verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(),
|
||||
|
@ -662,51 +656,38 @@ class AccountsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void testUsernameHashConflict() {
|
||||
void testSetUsernameConflict() {
|
||||
final Account firstAccount = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
final Account secondAccount = generateAccount("+18005559876", UUID.randomUUID(), UUID.randomUUID());
|
||||
|
||||
accounts.create(firstAccount);
|
||||
accounts.create(secondAccount);
|
||||
|
||||
// first account reserves and confirms username hash
|
||||
assertThatNoException().isThrownBy(() -> {
|
||||
accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1);
|
||||
});
|
||||
final String username = "test";
|
||||
|
||||
final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1);
|
||||
assertThatNoException().isThrownBy(() -> accounts.setUsername(firstAccount, username));
|
||||
|
||||
final Optional<Account> maybeAccount = accounts.getByUsername(username);
|
||||
|
||||
assertThat(maybeAccount).isPresent();
|
||||
verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), maybeAccount.get(), firstAccount);
|
||||
|
||||
// throw an error if second account tries to reserve or confirm the same username hash
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||
.isThrownBy(() -> accounts.reserveUsernameHash(secondAccount, USERNAME_HASH_1, Duration.ofDays(1)));
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||
.isThrownBy(() -> accounts.confirmUsernameHash(secondAccount, USERNAME_HASH_1));
|
||||
.isThrownBy(() -> accounts.setUsername(secondAccount, username));
|
||||
|
||||
// throw an error if first account tries to reserve or confirm the username hash that it has already confirmed
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||
.isThrownBy(() -> accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1)));
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||
.isThrownBy(() -> accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1));
|
||||
|
||||
assertThat(secondAccount.getReservedUsernameHash()).isEmpty();
|
||||
assertThat(secondAccount.getUsernameHash()).isEmpty();
|
||||
assertThat(secondAccount.getUsername()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmUsernameHashVersionMismatch() {
|
||||
void testSetUsernameVersionMismatch() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
account.setVersion(account.getVersion() + 77);
|
||||
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||
.isThrownBy(() -> accounts.confirmUsernameHash(account, USERNAME_HASH_1));
|
||||
.isThrownBy(() -> accounts.setUsername(account, "test"));
|
||||
|
||||
assertThat(account.getUsernameHash()).isEmpty();
|
||||
assertThat(account.getUsername()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -714,15 +695,16 @@ class AccountsTest {
|
|||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
accounts.confirmUsernameHash(account, USERNAME_HASH_1);
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isPresent();
|
||||
final String username = "TeST";
|
||||
|
||||
accounts.clearUsernameHash(account);
|
||||
accounts.setUsername(account, username);
|
||||
assertThat(accounts.getByUsername(username)).isPresent();
|
||||
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty();
|
||||
accounts.clearUsername(account);
|
||||
|
||||
assertThat(accounts.getByUsername(username)).isEmpty();
|
||||
assertThat(accounts.getByAccountIdentifier(account.getUuid()))
|
||||
.hasValueSatisfying(clearedAccount -> assertThat(clearedAccount.getUsernameHash()).isEmpty());
|
||||
.hasValueSatisfying(clearedAccount -> assertThat(clearedAccount.getUsername()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -730,7 +712,7 @@ class AccountsTest {
|
|||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
assertThatNoException().isThrownBy(() -> accounts.clearUsernameHash(account));
|
||||
assertThatNoException().isThrownBy(() -> accounts.clearUsername(account));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -738,136 +720,167 @@ class AccountsTest {
|
|||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
accounts.confirmUsernameHash(account, USERNAME_HASH_1);
|
||||
final String username = "test";
|
||||
|
||||
accounts.setUsername(account, username);
|
||||
|
||||
account.setVersion(account.getVersion() + 12);
|
||||
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class).isThrownBy(() -> accounts.clearUsernameHash(account));
|
||||
assertThatExceptionOfType(ContestedOptimisticLockException.class).isThrownBy(() -> accounts.clearUsername(account));
|
||||
|
||||
assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReservedUsernameHash() {
|
||||
void testReservedUsername() {
|
||||
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account1);
|
||||
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account2);
|
||||
|
||||
accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
assertArrayEquals(account1.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(account1.getUsernameHash()).isEmpty();
|
||||
final UUID token = accounts.reserveUsername(account1, "GarfielD", Duration.ofDays(1));
|
||||
assertThat(account1.getReservedUsernameHash()).get().isEqualTo(Accounts.reservedUsernameHash(account1.getUuid(), "GarfielD"));
|
||||
assertThat(account1.getUsername()).isEmpty();
|
||||
|
||||
// account 2 shouldn't be able to reserve or confirm the same username hash
|
||||
// account 2 shouldn't be able to reserve the username if it's the same when normalized
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1)));
|
||||
() -> accounts.reserveUsername(account2, "gARFIELd", Duration.ofDays(1)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1));
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty();
|
||||
() -> accounts.confirmUsername(account2, "gARFIELd", UUID.randomUUID()));
|
||||
assertThat(accounts.getByUsername("gARFIELd")).isEmpty();
|
||||
|
||||
accounts.confirmUsernameHash(account1, USERNAME_HASH_1);
|
||||
accounts.confirmUsername(account1, "GarfielD", token);
|
||||
assertThat(account1.getReservedUsernameHash()).isEmpty();
|
||||
assertArrayEquals(account1.getUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).get().getUuid()).isEqualTo(account1.getUuid());
|
||||
assertThat(account1.getUsername()).get().isEqualTo("GarfielD");
|
||||
assertThat(accounts.getByUsername("GarfielD").get().getUuid()).isEqualTo(account1.getUuid());
|
||||
|
||||
final Map<String, AttributeValue> usernameConstraintRecord = dynamoDbExtension.getDynamoDbClient()
|
||||
.getItem(GetItemRequest.builder()
|
||||
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
|
||||
.key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1)))
|
||||
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("garfield")))
|
||||
.build())
|
||||
.item();
|
||||
|
||||
assertThat(usernameConstraintRecord).containsKey(Accounts.ATTR_USERNAME_HASH);
|
||||
assertThat(usernameConstraintRecord).containsKey(Accounts.ATTR_USERNAME);
|
||||
assertThat(usernameConstraintRecord).doesNotContainKey(Accounts.ATTR_TTL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUsernameHashAvailable() {
|
||||
void testUsernameAvailable() {
|
||||
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account1);
|
||||
|
||||
accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
assertThat(accounts.usernameHashAvailable(USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.empty(), USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.of(UUID.randomUUID()), USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.of(account1.getUuid()), USERNAME_HASH_1)).isTrue();
|
||||
final String username = "UnSinkaBlesam";
|
||||
|
||||
accounts.confirmUsernameHash(account1, USERNAME_HASH_1);
|
||||
assertThat(accounts.usernameHashAvailable(USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.empty(), USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.of(UUID.randomUUID()), USERNAME_HASH_1)).isFalse();
|
||||
assertThat(accounts.usernameHashAvailable(Optional.of(account1.getUuid()), USERNAME_HASH_1)).isFalse();
|
||||
final UUID token = accounts.reserveUsername(account1, username, Duration.ofDays(1));
|
||||
assertThat(accounts.usernameAvailable(username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isTrue();
|
||||
|
||||
accounts.confirmUsername(account1, username, token);
|
||||
assertThat(accounts.usernameAvailable(username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
|
||||
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmReservedUsernameHashWrongAccountUuid() {
|
||||
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account1);
|
||||
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account2);
|
||||
|
||||
accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
assertArrayEquals(account1.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
assertThat(account1.getUsernameHash()).isEmpty();
|
||||
@Test
|
||||
void testReservedUsernameWrongToken() {
|
||||
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
accounts.reserveUsername(account, "grumpy", Duration.ofDays(1));
|
||||
assertThat(account.getReservedUsernameHash())
|
||||
.get()
|
||||
.isEqualTo(Accounts.reservedUsernameHash(account.getUuid(), "grumpy"));
|
||||
assertThat(account.getUsername()).isEmpty();
|
||||
|
||||
// only account1 should be able to confirm the reserved hash
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1));
|
||||
() -> accounts.confirmUsername(account, "grumpy", UUID.randomUUID()));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.setUsername(account, "grumpy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfirmExpiredReservedUsernameHash() {
|
||||
void testReserveExpiredReservedUsername() {
|
||||
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account1);
|
||||
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account2);
|
||||
final String username = "snowball.02";
|
||||
|
||||
accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2));
|
||||
accounts.reserveUsername(account1, username, Duration.ofDays(2));
|
||||
|
||||
Runnable runnable = () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1));
|
||||
Supplier<UUID> take = () -> accounts.reserveUsername(account2, username, Duration.ofDays(2));
|
||||
|
||||
for (int i = 0; i <= 2; i++) {
|
||||
clock.pin(Instant.EPOCH.plus(Duration.ofDays(i)));
|
||||
assertThrows(ContestedOptimisticLockException.class, runnable::run);
|
||||
assertThrows(ContestedOptimisticLockException.class, take::get);
|
||||
}
|
||||
|
||||
// after 2 days, can reserve and confirm the hash
|
||||
// after 2 days, can take the name
|
||||
clock.pin(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
|
||||
runnable.run();
|
||||
assertEquals(account2.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1);
|
||||
|
||||
accounts.confirmUsernameHash(account2, USERNAME_HASH_1);
|
||||
final UUID token = take.get();
|
||||
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2)));
|
||||
() -> accounts.reserveUsername(account1, username, Duration.ofDays(2)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.confirmUsernameHash(account1, USERNAME_HASH_1));
|
||||
assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).get().getUuid()).isEqualTo(account2.getUuid());
|
||||
() -> accounts.setUsername(account1, username));
|
||||
|
||||
accounts.confirmUsername(account2, username, token);
|
||||
assertThat(accounts.getByUsername(username).get().getUuid()).isEqualTo(account2.getUuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetryReserveUsernameHash() {
|
||||
void testTakeExpiredReservedUsername() {
|
||||
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account1);
|
||||
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account2);
|
||||
final String username = "simon.123";
|
||||
|
||||
accounts.reserveUsername(account1, username, Duration.ofDays(2));
|
||||
|
||||
Runnable take = () -> accounts.setUsername(account2, username);
|
||||
|
||||
for (int i = 0; i <= 2; i++) {
|
||||
clock.pin(Instant.EPOCH.plus(Duration.ofDays(i)));
|
||||
assertThrows(ContestedOptimisticLockException.class, take::run);
|
||||
}
|
||||
|
||||
// after 2 days, can take the name
|
||||
clock.pin(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
|
||||
take.run();
|
||||
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.reserveUsername(account1, username, Duration.ofDays(2)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.setUsername(account1, username));
|
||||
assertThat(accounts.getByUsername(username).get().getUuid()).isEqualTo(account2.getUuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetryReserveUsername() {
|
||||
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(2));
|
||||
accounts.reserveUsername(account, "jorts", Duration.ofDays(2));
|
||||
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(2)),
|
||||
"Shouldn't be able to re-reserve same username hash (would extend ttl)");
|
||||
() -> accounts.reserveUsername(account, "jorts", Duration.ofDays(2)),
|
||||
"Shouldn't be able to re-reserve same username (would extend ttl)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveConfirmUsernameHashVersionConflict() {
|
||||
void testReserveUsernameVersionConflict() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
account.setVersion(account.getVersion() + 12);
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)));
|
||||
() -> accounts.reserveUsername(account, "salem", Duration.ofDays(1)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.confirmUsernameHash(account, USERNAME_HASH_1));
|
||||
assertThat(account.getReservedUsernameHash()).isEmpty();
|
||||
assertThat(account.getUsernameHash()).isEmpty();
|
||||
() -> accounts.setUsername(account, "salem"));
|
||||
|
||||
}
|
||||
|
||||
private Device generateDevice(long id) {
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||
|
||||
class ProhibitedUsernamesTest {
|
||||
|
||||
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
|
||||
|
||||
@RegisterExtension
|
||||
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||
.tableName(RESERVED_USERNAMES_TABLE_NAME)
|
||||
.hashKey(ProhibitedUsernames.KEY_PATTERN)
|
||||
.attributeDefinition(AttributeDefinition.builder()
|
||||
.attributeName(ProhibitedUsernames.KEY_PATTERN)
|
||||
.attributeType(ScalarAttributeType.S)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
|
||||
|
||||
private ProhibitedUsernames prohibitedUsernames;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
prohibitedUsernames =
|
||||
new ProhibitedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
|
||||
prohibitedUsernames.prohibitUsername(".*myusername.*", RESERVED_FOR_UUID);
|
||||
prohibitedUsernames.prohibitUsername("^foobar$", RESERVED_FOR_UUID);
|
||||
|
||||
assertEquals(expectReserved, prohibitedUsernames.isProhibited(username, uuid));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> isReserved() {
|
||||
return Stream.of(
|
||||
Arguments.of("myusername", UUID.randomUUID(), true),
|
||||
Arguments.of("myusername", RESERVED_FOR_UUID, false),
|
||||
Arguments.of("thyusername", UUID.randomUUID(), false),
|
||||
Arguments.of("somemyusername", UUID.randomUUID(), true),
|
||||
Arguments.of("myusernamesome", UUID.randomUUID(), true),
|
||||
Arguments.of("somemyusernamesome", UUID.randomUUID(), true),
|
||||
Arguments.of("MYUSERNAME", UUID.randomUUID(), true),
|
||||
Arguments.of("foobar", UUID.randomUUID(), true),
|
||||
Arguments.of("foobar", RESERVED_FOR_UUID, false),
|
||||
Arguments.of("somefoobar", UUID.randomUUID(), false),
|
||||
Arguments.of("foobarsome", UUID.randomUUID(), false),
|
||||
Arguments.of("somefoobarsome", UUID.randomUUID(), false),
|
||||
Arguments.of("FOOBAR", UUID.randomUUID(), true));
|
||||
}
|
||||
}
|
|
@ -113,7 +113,7 @@ public class AccountsHelper {
|
|||
case "getUuid" -> when(updatedAccount.getUuid()).thenAnswer(stubbing);
|
||||
case "getPhoneNumberIdentifier" -> when(updatedAccount.getPhoneNumberIdentifier()).thenAnswer(stubbing);
|
||||
case "getNumber" -> when(updatedAccount.getNumber()).thenAnswer(stubbing);
|
||||
case "getUsername" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing);
|
||||
case "getUsername" -> when(updatedAccount.getUsername()).thenAnswer(stubbing);
|
||||
case "getDevices" -> when(updatedAccount.getDevices()).thenAnswer(stubbing);
|
||||
case "getDevice" -> when(updatedAccount.getDevice(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);
|
||||
case "getMasterDevice" -> when(updatedAccount.getMasterDevice()).thenAnswer(stubbing);
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
package org.whispersystems.textsecuregcm.tests.util;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class UsernameGeneratorTest {
|
||||
|
||||
private static final Duration TTL = Duration.ofMinutes(5);
|
||||
|
||||
@ParameterizedTest(name = "[{index}]:{0} ({2})")
|
||||
@MethodSource
|
||||
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
|
||||
assertThat(UsernameGenerator.isValidNickname(nickname)).isEqualTo(valid);
|
||||
}
|
||||
|
||||
static Stream<Arguments> nicknameValidation() {
|
||||
return Stream.of(
|
||||
Arguments.of("Test", true, "upper case"),
|
||||
Arguments.of("tesT", true, "upper case"),
|
||||
Arguments.of("te-st", false, "illegal character"),
|
||||
Arguments.of("ab\uD83D\uDC1B", false, "illegal character"),
|
||||
Arguments.of("1test", false, "illegal start"),
|
||||
Arguments.of("test#123", false, "illegal character"),
|
||||
Arguments.of("test.123", false, "illegal character"),
|
||||
Arguments.of("ab", false, "too short"),
|
||||
Arguments.of("", false, ""),
|
||||
Arguments.of("_123456789_123456789_123456789123", false, "33 characters"),
|
||||
|
||||
Arguments.of("_test", true, ""),
|
||||
Arguments.of("test", true, ""),
|
||||
Arguments.of("test123", true, ""),
|
||||
Arguments.of("abc", true, ""),
|
||||
Arguments.of("_123456789_123456789_12345678912", true, "32 characters")
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name="[{index}]: {0}")
|
||||
@MethodSource
|
||||
public void nonStandardUsernames(final String username, final boolean isStandard) {
|
||||
assertThat(UsernameGenerator.isStandardFormat(username)).isEqualTo(isStandard);
|
||||
}
|
||||
|
||||
static Stream<Arguments> nonStandardUsernames() {
|
||||
return Stream.of(
|
||||
Arguments.of("Test.123", true),
|
||||
Arguments.of("test.-123", false),
|
||||
Arguments.of("test.0", false),
|
||||
Arguments.of("test.", false),
|
||||
Arguments.of("test.1_00", false),
|
||||
|
||||
Arguments.of("test.1", true),
|
||||
Arguments.of("abc.1234", true)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroPadDiscriminators() {
|
||||
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1, TTL);
|
||||
assertThat(generator.fromParts("test", 1)).isEqualTo("test.0001");
|
||||
assertThat(generator.fromParts("test", 123)).isEqualTo("test.0123");
|
||||
assertThat(generator.fromParts("test", 9999)).isEqualTo("test.9999");
|
||||
assertThat(generator.fromParts("test", 99999)).isEqualTo("test.99999");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expectedWidth() throws UsernameNotAvailableException {
|
||||
String username = new UsernameGenerator(1, 6, 1, TTL).generateAvailableUsername("test", t -> true);
|
||||
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
|
||||
|
||||
username = new UsernameGenerator(2, 6, 1, TTL).generateAvailableUsername("test", t -> true);
|
||||
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expandDiscriminator() throws UsernameNotAvailableException {
|
||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
|
||||
int discriminator = extractDiscriminator(username);
|
||||
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
|
||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
|
||||
int discriminator = extractDiscriminator(username);
|
||||
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exhaustDiscriminator() {
|
||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
|
||||
// allow greater than our max width
|
||||
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
|
||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||
final Set<Integer> seen = new HashSet<>();
|
||||
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
|
||||
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
|
||||
}
|
||||
// after 1K iterations, probability of a missed value is (9/10)^999
|
||||
assertThat(seen.size()).isEqualTo(9);
|
||||
assertThat(seen).allMatch(i -> i > 0 && i < 10);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
|
||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||
final Set<Integer> seen = new HashSet<>();
|
||||
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
|
||||
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
|
||||
}
|
||||
// after 100K iterations, probability of a missed value is (99/100)^99999
|
||||
assertThat(seen.size()).isEqualTo(90);
|
||||
assertThat(seen).allMatch(i -> i >= 10 && i < 100);
|
||||
|
||||
}
|
||||
|
||||
private static Predicate<String> allowDiscriminator(Predicate<Integer> p) {
|
||||
return username -> p.test(extractDiscriminator(username));
|
||||
}
|
||||
|
||||
private static int extractDiscriminator(final String username) {
|
||||
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package org.whispersystems.textsecuregcm.tests.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameNormalizer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class UsernameNormalizerTest {
|
||||
|
||||
@Test
|
||||
public void usernameNormalization() {
|
||||
assertThat(UsernameNormalizer.normalize("TeST")).isEqualTo("test");
|
||||
assertThat(UsernameNormalizer.normalize("TeST_")).isEqualTo("test_");
|
||||
assertThat(UsernameNormalizer.normalize("TeST_.123")).isEqualTo("test_.123");
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
class NicknameValidatorTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void isValid(final String username, final boolean expectValid) {
|
||||
final NicknameValidator nicknameValidator = new NicknameValidator();
|
||||
|
||||
assertEquals(expectValid, nicknameValidator.isValid(username, null));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> isValid() {
|
||||
return Stream.of(
|
||||
Arguments.of("test", true),
|
||||
Arguments.of("_test", true),
|
||||
Arguments.of("test123", true),
|
||||
Arguments.of("a", false), // Too short
|
||||
Arguments.of("thisisareallyreallyreallylongusernamethatwewouldnotalllow", false),
|
||||
Arguments.of("illegal character", false),
|
||||
Arguments.of("0test", false), // Illegal first character
|
||||
Arguments.of("pаypal", false), // Unicode confusable characters
|
||||
Arguments.of("test\uD83D\uDC4E", false), // Emoji
|
||||
Arguments.of(" ", false),
|
||||
Arguments.of("", false),
|
||||
Arguments.of(null, false)
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue