Add reserve/confirm for usernames
This commit is contained in:
parent
98c8dc05f1
commit
4032ddd4fd
|
@ -195,7 +195,7 @@ import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
|
||||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
|
@ -337,7 +337,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
||||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||||
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
config.getDynamoDbTables().getProfiles().getTableName());
|
config.getDynamoDbTables().getProfiles().getTableName());
|
||||||
|
@ -447,7 +447,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||||
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||||
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
|
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
experimentEnrollmentManager, clock);
|
experimentEnrollmentManager, clock);
|
||||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||||
|
|
|
@ -62,6 +62,9 @@ public class RateLimitsConfiguration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
|
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
|
||||||
|
|
||||||
|
@ -137,6 +140,10 @@ public class RateLimitsConfiguration {
|
||||||
return usernameSet;
|
return usernameSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimitConfiguration getUsernameReserve() {
|
||||||
|
return usernameReserve;
|
||||||
|
}
|
||||||
|
|
||||||
public RateLimitConfiguration getCheckAccountExistence() {
|
public RateLimitConfiguration getCheckAccountExistence() {
|
||||||
return checkAccountExistence;
|
return checkAccountExistence;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import javax.validation.constraints.Min;
|
import javax.validation.constraints.Min;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
public class UsernameConfiguration {
|
public class UsernameConfiguration {
|
||||||
|
|
||||||
|
@ -22,6 +23,9 @@ public class UsernameConfiguration {
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int attemptsPerWidth = 10;
|
private int attemptsPerWidth = 10;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private Duration reservationTtl = Duration.ofMinutes(5);
|
||||||
|
|
||||||
public int getDiscriminatorInitialWidth() {
|
public int getDiscriminatorInitialWidth() {
|
||||||
return discriminatorInitialWidth;
|
return discriminatorInitialWidth;
|
||||||
}
|
}
|
||||||
|
@ -33,4 +37,8 @@ public class UsernameConfiguration {
|
||||||
public int getAttemptsPerWidth() {
|
public int getAttemptsPerWidth() {
|
||||||
return attemptsPerWidth;
|
return attemptsPerWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Duration getReservationTtl() {
|
||||||
|
return reservationTtl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,11 +68,14 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||||
|
@ -92,6 +95,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Hex;
|
import org.whispersystems.textsecuregcm.util.Hex;
|
||||||
|
@ -642,6 +646,52 @@ public class AccountController {
|
||||||
accounts.clearUsername(auth.getAccount());
|
accounts.clearUsername(auth.getAccount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@PUT
|
||||||
|
@Path("/username/reserved")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public ReserveUsernameResponse reserveUsername(@Auth AuthenticatedAccount auth,
|
||||||
|
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||||
|
@NotNull @Valid ReserveUsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||||
|
|
||||||
|
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
|
||||||
|
auth.getAccount(),
|
||||||
|
usernameRequest.nickname()
|
||||||
|
);
|
||||||
|
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
|
||||||
|
} catch (final UsernameNotAvailableException e) {
|
||||||
|
throw new WebApplicationException(Status.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@PUT
|
||||||
|
@Path("/username/confirm")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public UsernameResponse confirmUsername(@Auth AuthenticatedAccount auth,
|
||||||
|
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||||
|
@NotNull @Valid ConfirmUsernameRequest confirmRequest) throws RateLimitExceededException {
|
||||||
|
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
|
||||||
|
return account
|
||||||
|
.getUsername()
|
||||||
|
.map(UsernameResponse::new)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||||
|
} catch (final UsernameReservationNotFoundException e) {
|
||||||
|
throw new WebApplicationException(Status.CONFLICT);
|
||||||
|
} catch (final UsernameNotAvailableException e) {
|
||||||
|
throw new WebApplicationException(Status.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/username")
|
@Path("/username")
|
||||||
|
@ -652,14 +702,7 @@ public class AccountController {
|
||||||
@HeaderParam("X-Signal-Agent") String userAgent,
|
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||||
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||||
|
checkUsername(usernameRequest.existingUsername(), userAgent);
|
||||||
if (StringUtils.isNotBlank(usernameRequest.existingUsername()) &&
|
|
||||||
!UsernameGenerator.isStandardFormat(usernameRequest.existingUsername())) {
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
||||||
|
@ -688,15 +731,10 @@ public class AccountController {
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!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();
|
|
||||||
}
|
|
||||||
|
|
||||||
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
|
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
|
||||||
|
|
||||||
|
checkUsername(username, userAgent);
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
.getByUsername(username)
|
.getByUsername(username)
|
||||||
.map(Account::getUuid)
|
.map(Account::getUuid)
|
||||||
|
@ -866,6 +904,15 @@ public class AccountController {
|
||||||
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
|
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 boolean shouldAutoBlock(String sourceHost) {
|
private boolean shouldAutoBlock(String sourceHost) {
|
||||||
try {
|
try {
|
||||||
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
|
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
|
||||||
|
|
|
@ -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) {}
|
|
@ -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) {}
|
|
@ -33,6 +33,8 @@ public class RateLimiters {
|
||||||
private final RateLimiter usernameLookupLimiter;
|
private final RateLimiter usernameLookupLimiter;
|
||||||
private final RateLimiter usernameSetLimiter;
|
private final RateLimiter usernameSetLimiter;
|
||||||
|
|
||||||
|
private final RateLimiter usernameReserveLimiter;
|
||||||
|
|
||||||
private final RateLimiter checkAccountExistenceLimiter;
|
private final RateLimiter checkAccountExistenceLimiter;
|
||||||
|
|
||||||
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
|
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
|
||||||
|
@ -108,6 +110,11 @@ public class RateLimiters {
|
||||||
config.getUsernameSet().getBucketSize(),
|
config.getUsernameSet().getBucketSize(),
|
||||||
config.getUsernameSet().getLeakRatePerMinute());
|
config.getUsernameSet().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
this.usernameReserveLimiter = new RateLimiter(cacheCluster, "usernameReserve",
|
||||||
|
config.getUsernameReserve().getBucketSize(),
|
||||||
|
config.getUsernameReserve().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
|
||||||
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
|
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
|
||||||
config.getCheckAccountExistence().getBucketSize(),
|
config.getCheckAccountExistence().getBucketSize(),
|
||||||
config.getCheckAccountExistence().getLeakRatePerMinute());
|
config.getCheckAccountExistence().getLeakRatePerMinute());
|
||||||
|
@ -185,6 +192,10 @@ public class RateLimiters {
|
||||||
return usernameSetLimiter;
|
return usernameSetLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimiter getUsernameReserveLimiter() {
|
||||||
|
return usernameReserveLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
public RateLimiter getCheckAccountExistenceLimiter() {
|
public RateLimiter getCheckAccountExistenceLimiter() {
|
||||||
return checkAccountExistenceLimiter;
|
return checkAccountExistenceLimiter;
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,10 @@ public class Account {
|
||||||
@Nullable
|
@Nullable
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Nullable
|
||||||
|
private byte[] reservedUsernameHash;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private List<Device> devices = new ArrayList<>();
|
private List<Device> devices = new ArrayList<>();
|
||||||
|
|
||||||
|
@ -133,6 +137,18 @@ public class Account {
|
||||||
this.username = username;
|
this.username = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<byte[]> getReservedUsernameHash() {
|
||||||
|
requireNotStale();
|
||||||
|
|
||||||
|
return Optional.ofNullable(reservedUsernameHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReservedUsernameHash(final byte[] reservedUsernameHash) {
|
||||||
|
requireNotStale();
|
||||||
|
|
||||||
|
this.reservedUsernameHash = reservedUsernameHash;
|
||||||
|
}
|
||||||
|
|
||||||
public void addDevice(Device device) {
|
public void addDevice(Device device) {
|
||||||
requireNotStale();
|
requireNotStale();
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,10 @@ import io.micrometer.core.instrument.Metrics;
|
||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -70,7 +74,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
static final String ATTR_USERNAME = "N";
|
static final String ATTR_USERNAME = "N";
|
||||||
// unidentified access key; byte[] or null
|
// unidentified access key; byte[] or null
|
||||||
static final String ATTR_UAK = "UAK";
|
static final String ATTR_UAK = "UAK";
|
||||||
|
// time to live; number
|
||||||
|
static final String ATTR_TTL = "TTL";
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
private final DynamoDbClient client;
|
private final DynamoDbClient client;
|
||||||
private final DynamoDbAsyncClient asyncClient;
|
private final DynamoDbAsyncClient asyncClient;
|
||||||
|
|
||||||
|
@ -81,9 +88,12 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
|
|
||||||
private final int scanPageSize;
|
private final int scanPageSize;
|
||||||
|
|
||||||
|
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 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 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 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_TIMER = Metrics.timer(name(Accounts.class, "clearUsername"));
|
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 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_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
|
||||||
|
@ -96,13 +106,16 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
|
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
|
||||||
|
|
||||||
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
@VisibleForTesting
|
||||||
|
public Accounts(
|
||||||
|
final Clock clock,
|
||||||
|
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||||
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
|
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
|
||||||
String accountsTableName, String phoneNumberConstraintTableName,
|
String accountsTableName, String phoneNumberConstraintTableName,
|
||||||
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
|
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
|
||||||
final int scanPageSize) {
|
final int scanPageSize) {
|
||||||
|
|
||||||
super(client);
|
super(client);
|
||||||
|
this.clock = clock;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.asyncClient = asyncClient;
|
this.asyncClient = asyncClient;
|
||||||
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
|
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
|
||||||
|
@ -112,6 +125,16 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
this.scanPageSize = scanPageSize;
|
this.scanPageSize = scanPageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||||
|
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
|
||||||
|
String accountsTableName, String phoneNumberConstraintTableName,
|
||||||
|
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
|
||||||
|
final int scanPageSize) {
|
||||||
|
this(Clock.systemUTC(), dynamicConfigurationManager, client, asyncClient, accountsTableName,
|
||||||
|
phoneNumberConstraintTableName, phoneNumberIdentifierConstraintTableName, usernamesConstraintTableName,
|
||||||
|
scanPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean create(Account account) {
|
public boolean create(Account account) {
|
||||||
return CREATE_TIMER.record(() -> {
|
return CREATE_TIMER.record(() -> {
|
||||||
|
|
||||||
|
@ -331,33 +354,150 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static byte[] reservedUsernameHash(final UUID accountId, final String reservedUsername) {
|
||||||
|
final MessageDigest sha256;
|
||||||
|
try {
|
||||||
|
sha256 = MessageDigest.getInstance("SHA-256");
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new AssertionError(e);
|
||||||
|
}
|
||||||
|
final ByteBuffer byteBuffer = ByteBuffer.allocate(32 + 1);
|
||||||
|
sha256.update(reservedUsername.getBytes(StandardCharsets.UTF_8));
|
||||||
|
sha256.update(UUIDUtil.toBytes(accountId));
|
||||||
|
byteBuffer.put(RESERVED_USERNAME_HASH_VERSION);
|
||||||
|
byteBuffer.put(sha256.digest());
|
||||||
|
return byteBuffer.array();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the account username
|
* Reserve a username under a token
|
||||||
*
|
*
|
||||||
* @param account to update
|
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
|
||||||
* @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)
|
public UUID reserveUsername(
|
||||||
throws ContestedOptimisticLockException {
|
final Account account,
|
||||||
|
final String reservedUsername,
|
||||||
|
final Duration ttl) {
|
||||||
final long startNanos = System.nanoTime();
|
final long startNanos = System.nanoTime();
|
||||||
|
|
||||||
final Optional<String> maybeOriginalUsername = account.getUsername();
|
// if there is an existing old reservation it will be cleaned up via ttl
|
||||||
account.setUsername(username);
|
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
|
||||||
|
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
|
||||||
|
|
||||||
boolean succeeded = false;
|
boolean succeeded = false;
|
||||||
|
|
||||||
|
long expirationTime = clock.instant().plus(ttl).getEpochSecond();
|
||||||
|
|
||||||
|
final UUID reservationToken = UUID.randomUUID();
|
||||||
try {
|
try {
|
||||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||||
|
|
||||||
|
writeItems.add(TransactWriteItem.builder()
|
||||||
|
.put(Put.builder()
|
||||||
|
.tableName(usernamesConstraintTableName)
|
||||||
|
.item(Map.of(
|
||||||
|
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(reservationToken),
|
||||||
|
ATTR_USERNAME, AttributeValues.fromString(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())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
writeItems.add(
|
||||||
|
TransactWriteItem.builder()
|
||||||
|
.update(Update.builder()
|
||||||
|
.tableName(accountsTableName)
|
||||||
|
.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))
|
||||||
|
.expressionAttributeValues(Map.of(
|
||||||
|
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
||||||
|
":version", AttributeValues.fromInt(account.getVersion()),
|
||||||
|
":version_increment", AttributeValues.fromInt(1)))
|
||||||
|
.build())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
|
||||||
|
.transactItems(writeItems)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
client.transactWriteItems(request);
|
||||||
|
|
||||||
|
account.setVersion(account.getVersion() + 1);
|
||||||
|
succeeded = true;
|
||||||
|
} catch (final JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
} catch (final TransactionCanceledException e) {
|
||||||
|
if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch("ConditionalCheckFailed"::equals)) {
|
||||||
|
throw new ContestedOptimisticLockException();
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!succeeded) {
|
||||||
|
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
|
||||||
|
}
|
||||||
|
RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
return reservationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm (set) a previously reserved username
|
||||||
|
*
|
||||||
|
* @param account to update
|
||||||
|
* @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 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<String> maybeOriginalUsername = account.getUsername();
|
||||||
|
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
|
||||||
|
|
||||||
|
account.setUsername(username);
|
||||||
|
account.setReservedUsernameHash(null);
|
||||||
|
|
||||||
|
boolean succeeded = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
||||||
|
|
||||||
|
// add the username to the constraint table, wiping out the ttl if we had already reserved the name
|
||||||
writeItems.add(TransactWriteItem.builder()
|
writeItems.add(TransactWriteItem.builder()
|
||||||
.put(Put.builder()
|
.put(Put.builder()
|
||||||
.tableName(usernamesConstraintTableName)
|
.tableName(usernamesConstraintTableName)
|
||||||
.item(Map.of(
|
.item(Map.of(
|
||||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
|
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
|
||||||
ATTR_USERNAME, AttributeValues.fromString(username)))
|
ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.conditionExpression("attribute_not_exists(#username)")
|
// it's not in the constraint table OR it's expired OR it was reserved by us
|
||||||
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME))
|
.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()),
|
||||||
|
":reservation", AttributeValues.fromUUID(reservationToken.orElseGet(UUID::randomUUID))))
|
||||||
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
||||||
.build())
|
.build())
|
||||||
.build());
|
.build());
|
||||||
|
@ -405,6 +545,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
} finally {
|
} finally {
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
account.setUsername(maybeOriginalUsername.orElse(null));
|
account.setUsername(maybeOriginalUsername.orElse(null));
|
||||||
|
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
|
||||||
}
|
}
|
||||||
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||||
}
|
}
|
||||||
|
@ -553,11 +694,29 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean usernameAvailable(final String username) {
|
public boolean usernameAvailable(final String username) {
|
||||||
|
return usernameAvailable(Optional.empty(), username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean usernameAvailable(final Optional<UUID> reservationToken, final String username) {
|
||||||
final GetItemResponse response = client.getItem(GetItemRequest.builder()
|
final GetItemResponse response = client.getItem(GetItemRequest.builder()
|
||||||
.tableName(usernamesConstraintTableName)
|
.tableName(usernamesConstraintTableName)
|
||||||
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.build());
|
.build());
|
||||||
return !response.hasItem();
|
if (!response.hasItem()) {
|
||||||
|
// username is free
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final Map<String, AttributeValue> item = response.item();
|
||||||
|
|
||||||
|
if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) {
|
||||||
|
// username was reserved, but has expired
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// username is reserved by us
|
||||||
|
return reservationToken
|
||||||
|
.map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals)
|
||||||
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Account> getByE164(String number) {
|
public Optional<Account> getByE164(String number) {
|
||||||
|
@ -583,7 +742,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
|
||||||
return Optional.ofNullable(response.item())
|
return Optional.ofNullable(response.item())
|
||||||
|
// ignore items with a ttl (reservations)
|
||||||
|
.filter(item -> !item.containsKey(ATTR_TTL))
|
||||||
.map(item -> item.get(KEY_ACCOUNT_UUID))
|
.map(item -> item.get(KEY_ACCOUNT_UUID))
|
||||||
.map(this::accountByUuid)
|
.map(this::accountByUuid)
|
||||||
.map(Accounts::fromItem);
|
.map(Accounts::fromItem);
|
||||||
|
|
|
@ -21,6 +21,7 @@ import io.micrometer.core.instrument.Tags;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -86,7 +87,7 @@ public class AccountsManager {
|
||||||
private final DirectoryQueue directoryQueue;
|
private final DirectoryQueue directoryQueue;
|
||||||
private final Keys keys;
|
private final Keys keys;
|
||||||
private final MessagesManager messagesManager;
|
private final MessagesManager messagesManager;
|
||||||
private final ReservedUsernames reservedUsernames;
|
private final ProhibitedUsernames prohibitedUsernames;
|
||||||
private final ProfilesManager profilesManager;
|
private final ProfilesManager profilesManager;
|
||||||
private final StoredVerificationCodeManager pendingAccounts;
|
private final StoredVerificationCodeManager pendingAccounts;
|
||||||
private final SecureStorageClient secureStorageClient;
|
private final SecureStorageClient secureStorageClient;
|
||||||
|
@ -128,7 +129,7 @@ public class AccountsManager {
|
||||||
final DirectoryQueue directoryQueue,
|
final DirectoryQueue directoryQueue,
|
||||||
final Keys keys,
|
final Keys keys,
|
||||||
final MessagesManager messagesManager,
|
final MessagesManager messagesManager,
|
||||||
final ReservedUsernames reservedUsernames,
|
final ProhibitedUsernames prohibitedUsernames,
|
||||||
final ProfilesManager profilesManager,
|
final ProfilesManager profilesManager,
|
||||||
final StoredVerificationCodeManager pendingAccounts,
|
final StoredVerificationCodeManager pendingAccounts,
|
||||||
final SecureStorageClient secureStorageClient,
|
final SecureStorageClient secureStorageClient,
|
||||||
|
@ -149,7 +150,7 @@ public class AccountsManager {
|
||||||
this.secureStorageClient = secureStorageClient;
|
this.secureStorageClient = secureStorageClient;
|
||||||
this.secureBackupClient = secureBackupClient;
|
this.secureBackupClient = secureBackupClient;
|
||||||
this.clientPresenceManager = clientPresenceManager;
|
this.clientPresenceManager = clientPresenceManager;
|
||||||
this.reservedUsernames = reservedUsernames;
|
this.prohibitedUsernames = prohibitedUsernames;
|
||||||
this.usernameGenerator = usernameGenerator;
|
this.usernameGenerator = usernameGenerator;
|
||||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||||
this.clock = Objects.requireNonNull(clock);
|
this.clock = Objects.requireNonNull(clock);
|
||||||
|
@ -322,12 +323,112 @@ public class AccountsManager {
|
||||||
return updatedAccount.get();
|
return updatedAccount.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record UsernameReservation(Account account, String reservedUsername, UUID reservationToken){}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a username from a nickname, and reserve it so no other accounts may take it.
|
||||||
|
*
|
||||||
|
* 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 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 reserveUsername(final Account account, final String requestedNickname) throws UsernameNotAvailableException {
|
||||||
|
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
|
}
|
||||||
|
redisDelete(account);
|
||||||
|
|
||||||
|
class Reserver implements AccountPersister {
|
||||||
|
UUID reservationToken;
|
||||||
|
String reservedUsername;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
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();
|
||||||
|
final Account updatedAccount = failableUpdateWithRetries(
|
||||||
|
account,
|
||||||
|
a -> true,
|
||||||
|
reserver,
|
||||||
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
|
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||||
|
return new UsernameReservation(updatedAccount, reserver.reservedUsername, reserver.reservationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a username previously reserved with {@link #reserveUsername(Account, String)}
|
||||||
|
*
|
||||||
|
* @param account the account to update
|
||||||
|
* @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 confirmReservedUsername(final Account account, final String reservedUsername, final UUID reservationToken) throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.getUsername().map(reservedUsername::equals).orElse(false)) {
|
||||||
|
// the client likely already succeeded and is retrying
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] newHash = Accounts.reservedUsernameHash(account.getUuid(), 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
redisDelete(account);
|
||||||
|
|
||||||
|
return failableUpdateWithRetries(
|
||||||
|
account,
|
||||||
|
a -> true,
|
||||||
|
a -> {
|
||||||
|
// though we know this username was reserved, the reservation could have lapsed
|
||||||
|
if (!accounts.usernameAvailable(Optional.of(reservationToken), reservedUsername)) {
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
|
}
|
||||||
|
accounts.confirmUsername(a, reservedUsername, reservationToken);
|
||||||
|
},
|
||||||
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
|
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
|
||||||
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||||
throw new UsernameNotAvailableException();
|
throw new UsernameNotAvailableException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedUsernames.isReserved(requestedNickname, account.getUuid())) {
|
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
|
||||||
throw new UsernameNotAvailableException();
|
throw new UsernameNotAvailableException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -346,7 +447,9 @@ public class AccountsManager {
|
||||||
account,
|
account,
|
||||||
a -> true,
|
a -> true,
|
||||||
// In the future, this may also check for any forbidden discriminators
|
// In the future, this may also check for any forbidden discriminators
|
||||||
a -> accounts.setUsername(a, usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
|
a -> accounts.setUsername(
|
||||||
|
a,
|
||||||
|
usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
|
||||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||||
}
|
}
|
||||||
|
@ -539,7 +642,6 @@ public class AccountsManager {
|
||||||
public Optional<Account> getByUsername(final String username) {
|
public Optional<Account> getByUsername(final String username) {
|
||||||
try (final Timer.Context ignored = getByUsernameTimer.time()) {
|
try (final Timer.Context ignored = getByUsernameTimer.time()) {
|
||||||
Optional<Account> account = redisGetByUsername(username);
|
Optional<Account> account = redisGetByUsername(username);
|
||||||
|
|
||||||
if (account.isEmpty()) {
|
if (account.isEmpty()) {
|
||||||
account = accounts.getByUsername(username);
|
account = accounts.getByUsername(username);
|
||||||
account.ifPresent(this::redisSet);
|
account.ifPresent(this::redisSet);
|
||||||
|
|
|
@ -26,7 +26,7 @@ import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
|
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
|
||||||
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
|
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
|
||||||
|
|
||||||
public class ReservedUsernames {
|
public class ProhibitedUsernames {
|
||||||
|
|
||||||
private final DynamoDbClient dynamoDbClient;
|
private final DynamoDbClient dynamoDbClient;
|
||||||
private final String tableName;
|
private final String tableName;
|
||||||
|
@ -44,17 +44,17 @@ public class ReservedUsernames {
|
||||||
static final String KEY_PATTERN = "P";
|
static final String KEY_PATTERN = "P";
|
||||||
private static final String ATTR_RESERVED_FOR_UUID = "U";
|
private static final String ATTR_RESERVED_FOR_UUID = "U";
|
||||||
|
|
||||||
private static final Timer IS_RESERVED_TIMER = Metrics.timer(name(ReservedUsernames.class, "isReserved"));
|
private static final Timer IS_PROHIBITED_TIMER = Metrics.timer(name(ProhibitedUsernames.class, "isProhibited"));
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ReservedUsernames.class);
|
private static final Logger log = LoggerFactory.getLogger(ProhibitedUsernames.class);
|
||||||
|
|
||||||
public ReservedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
|
public ProhibitedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
|
||||||
this.dynamoDbClient = dynamoDbClient;
|
this.dynamoDbClient = dynamoDbClient;
|
||||||
this.tableName = tableName;
|
this.tableName = tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isReserved(final String nickname, final UUID accountIdentifier) {
|
public boolean isProhibited(final String nickname, final UUID accountIdentifier) {
|
||||||
return IS_RESERVED_TIMER.record(() -> {
|
return IS_PROHIBITED_TIMER.record(() -> {
|
||||||
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.build());
|
.build());
|
||||||
|
@ -80,7 +80,13 @@ public class ReservedUsernames {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void reserveUsername(final String pattern, final UUID reservedFor) {
|
/**
|
||||||
|
* Prohibits username except for all accounts except `reservedFor`
|
||||||
|
*
|
||||||
|
* @param pattern pattern to prohibit
|
||||||
|
* @param reservedFor an account that is allowed to use names in the pattern
|
||||||
|
*/
|
||||||
|
public void prohibitUsername(final String pattern, final UUID reservedFor) {
|
||||||
dynamoDbClient.putItem(PutItemRequest.builder()
|
dynamoDbClient.putItem(PutItemRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.item(Map.of(
|
.item(Map.of(
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
|
public class UsernameReservationNotFoundException extends Exception {
|
||||||
|
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import io.micrometer.core.instrument.Metrics;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
@ -41,16 +42,21 @@ public class UsernameGenerator {
|
||||||
private final int initialWidth;
|
private final int initialWidth;
|
||||||
private final int discriminatorMaxWidth;
|
private final int discriminatorMaxWidth;
|
||||||
private final int attemptsPerWidth;
|
private final int attemptsPerWidth;
|
||||||
|
private final Duration reservationTtl;
|
||||||
|
|
||||||
public UsernameGenerator(UsernameConfiguration configuration) {
|
public UsernameGenerator(UsernameConfiguration configuration) {
|
||||||
this(configuration.getDiscriminatorInitialWidth(), configuration.getDiscriminatorMaxWidth(), configuration.getAttemptsPerWidth());
|
this(configuration.getDiscriminatorInitialWidth(),
|
||||||
|
configuration.getDiscriminatorMaxWidth(),
|
||||||
|
configuration.getAttemptsPerWidth(),
|
||||||
|
configuration.getReservationTtl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth) {
|
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth, final Duration reservationTtl) {
|
||||||
this.initialWidth = initialWidth;
|
this.initialWidth = initialWidth;
|
||||||
this.discriminatorMaxWidth = discriminatorMaxWidth;
|
this.discriminatorMaxWidth = discriminatorMaxWidth;
|
||||||
this.attemptsPerWidth = attemptsPerWidth;
|
this.attemptsPerWidth = attemptsPerWidth;
|
||||||
|
this.reservationTtl = reservationTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -109,6 +115,10 @@ public class UsernameGenerator {
|
||||||
return String.format("%s#%0" + initialWidth + "d", nickname, discriminator);
|
return String.format("%s#%0" + initialWidth + "d", nickname, discriminator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Duration getReservationTtl() {
|
||||||
|
return reservationTtl;
|
||||||
|
}
|
||||||
|
|
||||||
public static boolean isValidNickname(final String nickname) {
|
public static boolean isValidNickname(final String nickname) {
|
||||||
return StringUtils.isNotBlank(nickname) && NICKNAME_PATTERN.matcher(nickname).matches();
|
return StringUtils.isNotBlank(nickname) && NICKNAME_PATTERN.matcher(nickname).matches();
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,7 +47,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
|
@ -152,7 +152,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getProfiles().getTableName());
|
configuration.getDynamoDbTables().getProfiles().getTableName());
|
||||||
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
|
@ -194,7 +194,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||||
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
|
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
experimentEnrollmentManager, Clock.systemUTC());
|
experimentEnrollmentManager, Clock.systemUTC());
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
|
@ -154,7 +154,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getProfiles().getTableName());
|
configuration.getDynamoDbTables().getProfiles().getTableName());
|
||||||
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
|
@ -196,7 +196,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||||
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
|
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
experimentEnrollmentManager, clock);
|
experimentEnrollmentManager, clock);
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import io.dropwizard.setup.Bootstrap;
|
||||||
import net.sourceforge.argparse4j.inf.Namespace;
|
import net.sourceforge.argparse4j.inf.Namespace;
|
||||||
import net.sourceforge.argparse4j.inf.Subparser;
|
import net.sourceforge.argparse4j.inf.Subparser;
|
||||||
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
@ -44,7 +44,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
|
||||||
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(config.getDynamoDbClientConfiguration(),
|
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(config.getDynamoDbClientConfiguration(),
|
||||||
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
||||||
|
|
||||||
final ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
final ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
|
|
||||||
final String pattern = namespace.getString("pattern").trim();
|
final String pattern = namespace.getString("pattern").trim();
|
||||||
|
@ -57,7 +57,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
|
||||||
|
|
||||||
final UUID aci = UUID.fromString(namespace.getString("uuid").trim());
|
final UUID aci = UUID.fromString(namespace.getString("uuid").trim());
|
||||||
|
|
||||||
reservedUsernames.reserveUsername(pattern, aci);
|
prohibitedUsernames.prohibitUsername(pattern, aci);
|
||||||
|
|
||||||
System.out.format("Reserved %s for account %s\n", pattern, aci);
|
System.out.format("Reserved %s for account %s\n", pattern, aci);
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
|
@ -157,7 +157,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
configuration.getDynamoDbTables().getProfiles().getTableName());
|
configuration.getDynamoDbTables().getProfiles().getTableName());
|
||||||
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient,
|
Keys keys = new Keys(dynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getKeys().getTableName());
|
configuration.getDynamoDbTables().getKeys().getTableName());
|
||||||
|
@ -197,7 +197,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
|
||||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||||
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
|
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
experimentEnrollmentManager, clock);
|
experimentEnrollmentManager, clock);
|
||||||
|
|
||||||
|
|
|
@ -193,7 +193,7 @@ class AccountsManagerChangeNumberIntegrationTest {
|
||||||
mock(DirectoryQueue.class),
|
mock(DirectoryQueue.class),
|
||||||
mock(Keys.class),
|
mock(Keys.class),
|
||||||
mock(MessagesManager.class),
|
mock(MessagesManager.class),
|
||||||
mock(ReservedUsernames.class),
|
mock(ProhibitedUsernames.class),
|
||||||
mock(ProfilesManager.class),
|
mock(ProfilesManager.class),
|
||||||
mock(StoredVerificationCodeManager.class),
|
mock(StoredVerificationCodeManager.class),
|
||||||
secureStorageClient,
|
secureStorageClient,
|
||||||
|
|
|
@ -160,7 +160,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
mock(DirectoryQueue.class),
|
mock(DirectoryQueue.class),
|
||||||
mock(Keys.class),
|
mock(Keys.class),
|
||||||
mock(MessagesManager.class),
|
mock(MessagesManager.class),
|
||||||
mock(ReservedUsernames.class),
|
mock(ProhibitedUsernames.class),
|
||||||
mock(ProfilesManager.class),
|
mock(ProfilesManager.class),
|
||||||
mock(StoredVerificationCodeManager.class),
|
mock(StoredVerificationCodeManager.class),
|
||||||
mock(SecureStorageClient.class),
|
mock(SecureStorageClient.class),
|
||||||
|
|
|
@ -71,7 +71,7 @@ class AccountsManagerTest {
|
||||||
private Keys keys;
|
private Keys keys;
|
||||||
private MessagesManager messagesManager;
|
private MessagesManager messagesManager;
|
||||||
private ProfilesManager profilesManager;
|
private ProfilesManager profilesManager;
|
||||||
private ReservedUsernames reservedUsernames;
|
private ProhibitedUsernames prohibitedUsernames;
|
||||||
private ExperimentEnrollmentManager enrollmentManager;
|
private ExperimentEnrollmentManager enrollmentManager;
|
||||||
|
|
||||||
private Map<String, UUID> phoneNumberIdentifiersByE164;
|
private Map<String, UUID> phoneNumberIdentifiersByE164;
|
||||||
|
@ -87,6 +87,8 @@ class AccountsManagerTest {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() throws InterruptedException {
|
void setup() throws InterruptedException {
|
||||||
accounts = mock(Accounts.class);
|
accounts = mock(Accounts.class);
|
||||||
|
@ -95,7 +97,7 @@ class AccountsManagerTest {
|
||||||
keys = mock(Keys.class);
|
keys = mock(Keys.class);
|
||||||
messagesManager = mock(MessagesManager.class);
|
messagesManager = mock(MessagesManager.class);
|
||||||
profilesManager = mock(ProfilesManager.class);
|
profilesManager = mock(ProfilesManager.class);
|
||||||
reservedUsernames = mock(ReservedUsernames.class);
|
prohibitedUsernames = mock(ProhibitedUsernames.class);
|
||||||
|
|
||||||
//noinspection unchecked
|
//noinspection unchecked
|
||||||
commands = mock(RedisAdvancedClusterCommands.class);
|
commands = mock(RedisAdvancedClusterCommands.class);
|
||||||
|
@ -149,7 +151,7 @@ class AccountsManagerTest {
|
||||||
directoryQueue,
|
directoryQueue,
|
||||||
keys,
|
keys,
|
||||||
messagesManager,
|
messagesManager,
|
||||||
reservedUsernames,
|
prohibitedUsernames,
|
||||||
profilesManager,
|
profilesManager,
|
||||||
mock(StoredVerificationCodeManager.class),
|
mock(StoredVerificationCodeManager.class),
|
||||||
storageClient,
|
storageClient,
|
||||||
|
@ -737,6 +739,65 @@ class AccountsManagerTest {
|
||||||
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReserveUsername() throws UsernameNotAvailableException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String nickname = "beethoven";
|
||||||
|
accountsManager.reserveUsername(account, nickname);
|
||||||
|
verify(accounts).reserveUsername(eq(account), startsWith(nickname), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String reserved = "scooby#1234";
|
||||||
|
setReservationHash(account, reserved);
|
||||||
|
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
|
||||||
|
accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN);
|
||||||
|
verify(accounts).confirmUsername(eq(account), eq(reserved), eq(RESERVATION_TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedHashNameMismatch() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
setReservationHash(account, "pluto#1234");
|
||||||
|
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq("pluto#1234"))).thenReturn(true);
|
||||||
|
assertThrows(UsernameReservationNotFoundException.class,
|
||||||
|
() -> accountsManager.confirmReservedUsername(account, "goofy#1234", RESERVATION_TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedHashAciMismatch() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String reserved = "toto#1234";
|
||||||
|
account.setReservedUsernameHash(Accounts.reservedUsernameHash(UUID.randomUUID(), reserved));
|
||||||
|
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
|
||||||
|
assertThrows(UsernameReservationNotFoundException.class,
|
||||||
|
() -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedLapsed() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String reserved = "porkchop#1234";
|
||||||
|
// name was reserved, but the reservation lapsed and another account took it
|
||||||
|
setReservationHash(account, reserved);
|
||||||
|
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(false);
|
||||||
|
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
|
||||||
|
verify(accounts, never()).confirmUsername(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedRetry() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String username = "santaslittlehelper#1234";
|
||||||
|
account.setUsername(username);
|
||||||
|
|
||||||
|
// reserved username already set, should be treated as a replay
|
||||||
|
accountsManager.confirmReservedUsername(account, username, RESERVATION_TOKEN);
|
||||||
|
verifyNoInteractions(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameSameUsername() {
|
void testSetUsernameSameUsername() {
|
||||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
@ -761,7 +822,29 @@ class AccountsManagerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
|
void testReserveUsernameReroll() throws UsernameNotAvailableException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String nickname = "clifford";
|
||||||
|
final String username = nickname + "#ZZZ";
|
||||||
|
account.setUsername(username);
|
||||||
|
|
||||||
|
// given the correct old username, should reroll discriminator even if the nick matches
|
||||||
|
accountsManager.reserveUsername(account, nickname);
|
||||||
|
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), not(eq(username))), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetReservedUsernameWithNoReservation() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
|
||||||
|
new ArrayList<>(), new byte[16]);
|
||||||
|
assertThrows(UsernameReservationNotFoundException.class,
|
||||||
|
() -> accountsManager.confirmReservedUsername(account, "laika#1234", RESERVATION_TOKEN));
|
||||||
|
verify(accounts, never()).confirmUsername(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {false, true})
|
||||||
|
void testUsernameExpandDiscriminator(boolean reserve) throws UsernameNotAvailableException {
|
||||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
final String nickname = "test";
|
final String nickname = "test";
|
||||||
|
|
||||||
|
@ -775,9 +858,15 @@ class AccountsManagerTest {
|
||||||
when(accounts.usernameAvailable(any())).thenReturn(false);
|
when(accounts.usernameAvailable(any())).thenReturn(false);
|
||||||
when(accounts.usernameAvailable(argThat(isWide))).thenReturn(true);
|
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);
|
accountsManager.setUsername(account, nickname, null);
|
||||||
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
|
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testChangeUsername() throws UsernameNotAvailableException {
|
void testChangeUsername() throws UsernameNotAvailableException {
|
||||||
|
@ -801,7 +890,7 @@ class AccountsManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameReserved() {
|
void testSetUsernameReserved() {
|
||||||
final String nickname = "reserved";
|
final String nickname = "reserved";
|
||||||
when(reservedUsernames.isReserved(eq(nickname), any())).thenReturn(true);
|
when(prohibitedUsernames.isProhibited(eq(nickname), any())).thenReturn(true);
|
||||||
|
|
||||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
|
||||||
|
@ -823,6 +912,10 @@ class AccountsManagerTest {
|
||||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
|
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) {
|
private static Device generateTestDevice(final long lastSeen) {
|
||||||
final Device device = new Device();
|
final Device device = new Device();
|
||||||
device.setId(Device.MASTER_ID);
|
device.setId(Device.MASTER_ID);
|
||||||
|
|
|
@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.storage;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
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.Mockito;
|
||||||
import org.mockito.invocation.InvocationOnMock;
|
import org.mockito.invocation.InvocationOnMock;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
|
@ -22,6 +24,8 @@ import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.*;
|
import software.amazon.awssdk.services.dynamodb.model.*;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@ -110,7 +114,11 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
|
||||||
|
buildAccountsManager(1, 2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth)
|
||||||
|
throws InterruptedException {
|
||||||
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
|
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
|
||||||
mock(DynamicConfigurationManager.class);
|
mock(DynamicConfigurationManager.class);
|
||||||
|
|
||||||
|
@ -127,6 +135,8 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
USERNAMES_TABLE_NAME,
|
USERNAMES_TABLE_NAME,
|
||||||
SCAN_PAGE_SIZE));
|
SCAN_PAGE_SIZE));
|
||||||
|
|
||||||
|
usernameGenerator = new UsernameGenerator(initialWidth, discriminatorMaxWidth, attemptsPerWidth,
|
||||||
|
Duration.ofDays(1));
|
||||||
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
|
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
|
||||||
doAnswer((final InvocationOnMock invocationOnMock) -> {
|
doAnswer((final InvocationOnMock invocationOnMock) -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -141,8 +151,6 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||||
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
|
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
|
||||||
.thenReturn(true);
|
.thenReturn(true);
|
||||||
|
|
||||||
usernameGenerator = new UsernameGenerator(1, 2, 10);
|
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
phoneNumberIdentifiers,
|
phoneNumberIdentifiers,
|
||||||
|
@ -151,7 +159,7 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
mock(DirectoryQueue.class),
|
mock(DirectoryQueue.class),
|
||||||
mock(Keys.class),
|
mock(Keys.class),
|
||||||
mock(MessagesManager.class),
|
mock(MessagesManager.class),
|
||||||
mock(ReservedUsernames.class),
|
mock(ProhibitedUsernames.class),
|
||||||
mock(ProfilesManager.class),
|
mock(ProfilesManager.class),
|
||||||
mock(StoredVerificationCodeManager.class),
|
mock(StoredVerificationCodeManager.class),
|
||||||
mock(SecureStorageClient.class),
|
mock(SecureStorageClient.class),
|
||||||
|
@ -190,19 +198,32 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testNoUsernames() throws InterruptedException {
|
@ValueSource(booleans = {false, true})
|
||||||
|
void testNoUsernames(boolean reserve) throws InterruptedException {
|
||||||
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
new ArrayList<>());
|
new ArrayList<>());
|
||||||
for (int i = 1; i <= 99; i++) {
|
for (int i = 1; i <= 99; i++) {
|
||||||
|
final Map<String, AttributeValue> item = new HashMap<>(Map.of(
|
||||||
|
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||||
|
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))));
|
||||||
|
// half of these are taken usernames, half are only reservations (have a TTL)
|
||||||
|
if (i % 2 == 0) {
|
||||||
|
item.put(Accounts.ATTR_TTL,
|
||||||
|
AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));
|
||||||
|
}
|
||||||
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||||
.tableName(USERNAMES_TABLE_NAME)
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
.item(Map.of(
|
.item(item)
|
||||||
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
|
||||||
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
|
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
|
assertThrows(UsernameNotAvailableException.class, () -> {
|
||||||
|
if (reserve) {
|
||||||
|
accountsManager.reserveUsername(account, "n00bkiller");
|
||||||
|
} else {
|
||||||
|
accountsManager.setUsername(account, "n00bkiller", null);
|
||||||
|
}
|
||||||
|
});
|
||||||
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,4 +258,112 @@ class AccountsManagerUsernameIntegrationTest {
|
||||||
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
|
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReserveSetClear()
|
||||||
|
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "n00bkiller");
|
||||||
|
account = reservation.account();
|
||||||
|
assertThat(account.getReservedUsernameHash()).isPresent();
|
||||||
|
assertThat(reservation.reservedUsername()).startsWith("n00bkiller");
|
||||||
|
int discriminator = discriminator(reservation.reservedUsername());
|
||||||
|
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
|
||||||
|
assertThat(accountsManager.getByUsername(reservation.reservedUsername())).isEmpty();
|
||||||
|
|
||||||
|
account = accountsManager.confirmReservedUsername(
|
||||||
|
account,
|
||||||
|
reservation.reservedUsername(),
|
||||||
|
reservation.reservationToken());
|
||||||
|
|
||||||
|
assertThat(account.getUsername().get()).startsWith("n00bkiller");
|
||||||
|
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
|
||||||
|
account.getUuid());
|
||||||
|
|
||||||
|
// reroll
|
||||||
|
reservation = accountsManager.reserveUsername(account, "n00bkiller");
|
||||||
|
account = reservation.account();
|
||||||
|
account = accountsManager.confirmReservedUsername(
|
||||||
|
account,
|
||||||
|
reservation.reservedUsername(),
|
||||||
|
reservation.reservationToken());
|
||||||
|
|
||||||
|
final String newUsername = account.getUsername().orElseThrow();
|
||||||
|
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
|
||||||
|
|
||||||
|
// clear
|
||||||
|
account = accountsManager.clearUsername(account);
|
||||||
|
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
|
||||||
|
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReservationLapsed()
|
||||||
|
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
// use a username generator that can retry a lot
|
||||||
|
buildAccountsManager(1, 1, 1000000);
|
||||||
|
|
||||||
|
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsername(account, "n00bkiller");
|
||||||
|
final String reservedUsername = reservation1.reservedUsername();
|
||||||
|
|
||||||
|
long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();
|
||||||
|
// force expiration
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
|
||||||
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
|
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString(reservedUsername)))
|
||||||
|
.updateExpression("SET #ttl = :ttl")
|
||||||
|
.expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL))
|
||||||
|
.expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past)))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
int discriminator = discriminator(reservedUsername);
|
||||||
|
|
||||||
|
// use up all names except the reserved one
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
if (i == discriminator) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||||
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
|
.item(Map.of(
|
||||||
|
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||||
|
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// a different account should be able to reserve it
|
||||||
|
Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsername(account2, "n00bkiller"
|
||||||
|
);
|
||||||
|
assertThat(reservation2.reservedUsername()).isEqualTo(reservedUsername);
|
||||||
|
|
||||||
|
assertThrows(UsernameNotAvailableException.class,
|
||||||
|
() -> accountsManager.confirmReservedUsername(reservation1.account(), reservedUsername, reservation1.reservationToken()));
|
||||||
|
accountsManager.confirmReservedUsername(reservation2.account(), reservedUsername, reservation2.reservationToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUsernameReserveClearSetReserved()
|
||||||
|
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
account = accountsManager.setUsername(account, "n00bkiller", null);
|
||||||
|
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "other");
|
||||||
|
account = reservation.account();
|
||||||
|
|
||||||
|
assertThat(reservation.reservedUsername()).startsWith("other");
|
||||||
|
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("n00bkiller"));
|
||||||
|
|
||||||
|
account = accountsManager.clearUsername(account);
|
||||||
|
assertThat(account.getReservedUsernameHash()).isPresent();
|
||||||
|
assertThat(account.getUsername()).isEmpty();
|
||||||
|
|
||||||
|
account = accountsManager.confirmReservedUsername(account, reservation.reservedUsername(), reservation.reservationToken());
|
||||||
|
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("other"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,9 @@ import static org.mockito.Mockito.when;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.uuid.UUIDComparator;
|
import com.fasterxml.uuid.UUIDComparator;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -26,6 +29,7 @@ import java.util.Random;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
@ -74,6 +78,7 @@ class AccountsTest {
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
private Clock mockClock;
|
||||||
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
|
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
|
||||||
private Accounts accounts;
|
private Accounts accounts;
|
||||||
|
|
||||||
|
@ -130,7 +135,11 @@ class AccountsTest {
|
||||||
when(mockDynamicConfigManager.getConfiguration())
|
when(mockDynamicConfigManager.getConfiguration())
|
||||||
.thenReturn(new DynamicConfiguration());
|
.thenReturn(new DynamicConfiguration());
|
||||||
|
|
||||||
|
mockClock = mock(Clock.class);
|
||||||
|
when(mockClock.instant()).thenReturn(Instant.EPOCH);
|
||||||
|
|
||||||
this.accounts = new Accounts(
|
this.accounts = new Accounts(
|
||||||
|
mockClock,
|
||||||
mockDynamicConfigManager,
|
mockDynamicConfigManager,
|
||||||
dynamoDbExtension.getDynamoDbClient(),
|
dynamoDbExtension.getDynamoDbClient(),
|
||||||
dynamoDbExtension.getDynamoDbAsyncClient(),
|
dynamoDbExtension.getDynamoDbAsyncClient(),
|
||||||
|
@ -608,7 +617,7 @@ class AccountsTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsername() throws UsernameNotAvailableException {
|
void testSetUsername() {
|
||||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||||
accounts.create(account);
|
accounts.create(account);
|
||||||
|
|
||||||
|
@ -679,7 +688,7 @@ class AccountsTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testClearUsername() throws UsernameNotAvailableException {
|
void testClearUsername() {
|
||||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||||
accounts.create(account);
|
accounts.create(account);
|
||||||
|
|
||||||
|
@ -704,7 +713,7 @@ class AccountsTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
|
void testClearUsernameVersionMismatch() {
|
||||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||||
accounts.create(account);
|
accounts.create(account);
|
||||||
|
|
||||||
|
@ -719,6 +728,154 @@ class AccountsTest {
|
||||||
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
|
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReservedUsername() {
|
||||||
|
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account1);
|
||||||
|
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account2);
|
||||||
|
|
||||||
|
final UUID token = accounts.reserveUsername(account1, "garfield", Duration.ofDays(1));
|
||||||
|
assertThat(account1.getReservedUsernameHash()).get().isEqualTo(Accounts.reservedUsernameHash(account1.getUuid(), "garfield"));
|
||||||
|
assertThat(account1.getUsername()).isEmpty();
|
||||||
|
|
||||||
|
// account 2 shouldn't be able to reserve the username
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.reserveUsername(account2, "garfield", Duration.ofDays(1)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.confirmUsername(account2, "garfield", UUID.randomUUID()));
|
||||||
|
assertThat(accounts.getByUsername("garfield")).isEmpty();
|
||||||
|
|
||||||
|
accounts.confirmUsername(account1, "garfield", token);
|
||||||
|
assertThat(account1.getReservedUsernameHash()).isEmpty();
|
||||||
|
assertThat(account1.getUsername()).get().isEqualTo("garfield");
|
||||||
|
assertThat(accounts.getByUsername("garfield").get().getUuid()).isEqualTo(account1.getUuid());
|
||||||
|
|
||||||
|
assertThat(dynamoDbExtension.getDynamoDbClient()
|
||||||
|
.getItem(GetItemRequest.builder()
|
||||||
|
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
|
||||||
|
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("garfield")))
|
||||||
|
.build())
|
||||||
|
.item())
|
||||||
|
.doesNotContainKey(Accounts.ATTR_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUsernameAvailable() {
|
||||||
|
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account1);
|
||||||
|
|
||||||
|
final String username = "unsinkablesam";
|
||||||
|
|
||||||
|
final UUID token = accounts.reserveUsername(account1, username, Duration.ofDays(1));
|
||||||
|
assertThat(accounts.usernameAvailable(username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isTrue();
|
||||||
|
|
||||||
|
accounts.confirmUsername(account1, username, token);
|
||||||
|
assertThat(accounts.usernameAvailable(username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
|
||||||
|
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReservedUsernameWrongToken() {
|
||||||
|
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account);
|
||||||
|
accounts.reserveUsername(account, "grumpy", Duration.ofDays(1));
|
||||||
|
assertThat(account.getReservedUsernameHash())
|
||||||
|
.get()
|
||||||
|
.isEqualTo(Accounts.reservedUsernameHash(account.getUuid(), "grumpy"));
|
||||||
|
assertThat(account.getUsername()).isEmpty();
|
||||||
|
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.confirmUsername(account, "grumpy", UUID.randomUUID()));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.setUsername(account, "grumpy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReserveExpiredReservedUsername() {
|
||||||
|
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account1);
|
||||||
|
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account2);
|
||||||
|
|
||||||
|
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
|
||||||
|
|
||||||
|
Supplier<UUID> take = () -> accounts.reserveUsername(account2, "snowball#0002", Duration.ofDays(2));
|
||||||
|
|
||||||
|
for (int i = 0; i <= 2; i++) {
|
||||||
|
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class, take::get);
|
||||||
|
}
|
||||||
|
|
||||||
|
// after 2 days, can take the name
|
||||||
|
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
|
||||||
|
final UUID token = take.get();
|
||||||
|
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.setUsername(account1, "snowball#0002"));
|
||||||
|
|
||||||
|
accounts.confirmUsername(account2, "snowball#0002", token);
|
||||||
|
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTakeExpiredReservedUsername() {
|
||||||
|
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account1);
|
||||||
|
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account2);
|
||||||
|
|
||||||
|
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
|
||||||
|
|
||||||
|
Runnable take = () -> accounts.setUsername(account2, "snowball#0002");
|
||||||
|
|
||||||
|
for (int i = 0; i <= 2; i++) {
|
||||||
|
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class, take::run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// after 2 days, can take the name
|
||||||
|
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
|
||||||
|
take.run();
|
||||||
|
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.setUsername(account1, "snowball#0002"));
|
||||||
|
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRetryReserveUsername() {
|
||||||
|
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account);
|
||||||
|
accounts.reserveUsername(account, "jorts", Duration.ofDays(2));
|
||||||
|
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.reserveUsername(account, "jorts", Duration.ofDays(2)),
|
||||||
|
"Shouldn't be able to re-reserve same username (would extend ttl)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReserveUsernameVersionConflict() {
|
||||||
|
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||||
|
accounts.create(account);
|
||||||
|
account.setVersion(account.getVersion() + 12);
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.reserveUsername(account, "salem", Duration.ofDays(1)));
|
||||||
|
assertThrows(ContestedOptimisticLockException.class,
|
||||||
|
() -> accounts.setUsername(account, "salem"));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private Device generateDevice(long id) {
|
private Device generateDevice(long id) {
|
||||||
return DevicesHelper.createDevice(id);
|
return DevicesHelper.createDevice(id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,37 +17,37 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||||
|
|
||||||
class ReservedUsernamesTest {
|
class ProhibitedUsernamesTest {
|
||||||
|
|
||||||
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
|
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
|
||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||||
.tableName(RESERVED_USERNAMES_TABLE_NAME)
|
.tableName(RESERVED_USERNAMES_TABLE_NAME)
|
||||||
.hashKey(ReservedUsernames.KEY_PATTERN)
|
.hashKey(ProhibitedUsernames.KEY_PATTERN)
|
||||||
.attributeDefinition(AttributeDefinition.builder()
|
.attributeDefinition(AttributeDefinition.builder()
|
||||||
.attributeName(ReservedUsernames.KEY_PATTERN)
|
.attributeName(ProhibitedUsernames.KEY_PATTERN)
|
||||||
.attributeType(ScalarAttributeType.S)
|
.attributeType(ScalarAttributeType.S)
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
|
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
|
||||||
|
|
||||||
private ReservedUsernames reservedUsernames;
|
private ProhibitedUsernames prohibitedUsernames;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
reservedUsernames =
|
prohibitedUsernames =
|
||||||
new ReservedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
|
new ProhibitedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
|
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
|
||||||
reservedUsernames.reserveUsername(".*myusername.*", RESERVED_FOR_UUID);
|
prohibitedUsernames.prohibitUsername(".*myusername.*", RESERVED_FOR_UUID);
|
||||||
reservedUsernames.reserveUsername("^foobar$", RESERVED_FOR_UUID);
|
prohibitedUsernames.prohibitUsername("^foobar$", RESERVED_FOR_UUID);
|
||||||
|
|
||||||
assertEquals(expectReserved, reservedUsernames.isReserved(username, uuid));
|
assertEquals(expectReserved, prohibitedUsernames.isProhibited(username, uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> isReserved() {
|
private static Stream<Arguments> isReserved() {
|
|
@ -73,10 +73,13 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
|
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||||
|
@ -99,6 +102,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
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.AccountsHelper;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
import org.whispersystems.textsecuregcm.util.Hex;
|
import org.whispersystems.textsecuregcm.util.Hex;
|
||||||
|
@ -121,6 +125,7 @@ class AccountControllerTest {
|
||||||
|
|
||||||
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
|
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
|
||||||
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
|
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
|
||||||
|
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
|
||||||
|
|
||||||
private static final String ABUSIVE_HOST = "192.168.1.1";
|
private static final String ABUSIVE_HOST = "192.168.1.1";
|
||||||
private static final String NICE_HOST = "127.0.0.1";
|
private static final String NICE_HOST = "127.0.0.1";
|
||||||
|
@ -144,6 +149,7 @@ class AccountControllerTest {
|
||||||
private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class);
|
private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class);
|
||||||
private static RateLimiter autoBlockLimiter = mock(RateLimiter.class);
|
private static RateLimiter autoBlockLimiter = mock(RateLimiter.class);
|
||||||
private static RateLimiter usernameSetLimiter = mock(RateLimiter.class);
|
private static RateLimiter usernameSetLimiter = mock(RateLimiter.class);
|
||||||
|
private static RateLimiter usernameReserveLimiter = mock(RateLimiter.class);
|
||||||
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
|
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
|
||||||
private static SmsSender smsSender = mock(SmsSender.class);
|
private static SmsSender smsSender = mock(SmsSender.class);
|
||||||
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
|
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
|
||||||
|
@ -208,6 +214,7 @@ class AccountControllerTest {
|
||||||
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
|
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
|
||||||
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
|
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
|
||||||
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
|
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
|
||||||
|
when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter);
|
||||||
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
|
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
|
||||||
|
|
||||||
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
|
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
|
||||||
|
@ -319,6 +326,7 @@ class AccountControllerTest {
|
||||||
smsVoicePrefixLimiter,
|
smsVoicePrefixLimiter,
|
||||||
autoBlockLimiter,
|
autoBlockLimiter,
|
||||||
usernameSetLimiter,
|
usernameSetLimiter,
|
||||||
|
usernameReserveLimiter,
|
||||||
usernameLookupLimiter,
|
usernameLookupLimiter,
|
||||||
smsSender,
|
smsSender,
|
||||||
turnTokenGenerator,
|
turnTokenGenerator,
|
||||||
|
@ -1733,6 +1741,63 @@ class AccountControllerTest {
|
||||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testReserveUsername() throws UsernameNotAvailableException {
|
||||||
|
when(accountsManager.reserveUsername(any(), eq("n00bkiller")))
|
||||||
|
.thenReturn(new AccountsManager.UsernameReservation(null, "n00bkiller#1234", RESERVATION_TOKEN));
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/accounts/username/reserved")
|
||||||
|
.request()
|
||||||
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
.put(Entity.json(new ReserveUsernameRequest("n00bkiller")));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(ReserveUsernameResponse.class))
|
||||||
|
.satisfies(r -> r.username().equals("n00bkiller#1234"))
|
||||||
|
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
Account account = mock(Account.class);
|
||||||
|
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
|
||||||
|
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/accounts/username/confirm")
|
||||||
|
.request()
|
||||||
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
|
||||||
|
.thenThrow(new UsernameReservationNotFoundException());
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/accounts/username/confirm")
|
||||||
|
.request()
|
||||||
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(409);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||||
|
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
|
||||||
|
.thenThrow(new UsernameNotAvailableException());
|
||||||
|
Response response =
|
||||||
|
resources.getJerseyTest()
|
||||||
|
.target("/v1/accounts/username/confirm")
|
||||||
|
.request()
|
||||||
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(410);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetTakenUsername() {
|
void testSetTakenUsername() {
|
||||||
Response response =
|
Response response =
|
||||||
|
|
|
@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
@ -17,6 +18,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
public class UsernameGeneratorTest {
|
public class UsernameGeneratorTest {
|
||||||
|
|
||||||
|
private static final Duration TTL = Duration.ofMinutes(5);
|
||||||
|
|
||||||
@ParameterizedTest(name = "[{index}]:{0} ({2})")
|
@ParameterizedTest(name = "[{index}]:{0} ({2})")
|
||||||
@MethodSource
|
@MethodSource
|
||||||
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
|
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
|
||||||
|
@ -64,7 +67,7 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void zeroPadDiscriminators() {
|
public void zeroPadDiscriminators() {
|
||||||
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1);
|
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1, TTL);
|
||||||
assertThat(generator.fromParts("test", 1)).isEqualTo("test#0001");
|
assertThat(generator.fromParts("test", 1)).isEqualTo("test#0001");
|
||||||
assertThat(generator.fromParts("test", 123)).isEqualTo("test#0123");
|
assertThat(generator.fromParts("test", 123)).isEqualTo("test#0123");
|
||||||
assertThat(generator.fromParts("test", 9999)).isEqualTo("test#9999");
|
assertThat(generator.fromParts("test", 9999)).isEqualTo("test#9999");
|
||||||
|
@ -73,16 +76,16 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void expectedWidth() throws UsernameNotAvailableException {
|
public void expectedWidth() throws UsernameNotAvailableException {
|
||||||
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
|
String username = new UsernameGenerator(1, 6, 1, TTL).generateAvailableUsername("test", t -> true);
|
||||||
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
|
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
|
||||||
|
|
||||||
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
|
username = new UsernameGenerator(2, 6, 1, TTL).generateAvailableUsername("test", t -> true);
|
||||||
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
|
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void expandDiscriminator() throws UsernameNotAvailableException {
|
public void expandDiscriminator() throws UsernameNotAvailableException {
|
||||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||||
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
|
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
|
||||||
int discriminator = extractDiscriminator(username);
|
int discriminator = extractDiscriminator(username);
|
||||||
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
|
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
|
||||||
|
@ -90,7 +93,7 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
|
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
|
||||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||||
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
|
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
|
||||||
int discriminator = extractDiscriminator(username);
|
int discriminator = extractDiscriminator(username);
|
||||||
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
|
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
|
||||||
|
@ -98,7 +101,7 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void exhaustDiscriminator() {
|
public void exhaustDiscriminator() {
|
||||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||||
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
|
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
|
||||||
// allow greater than our max width
|
// allow greater than our max width
|
||||||
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
|
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
|
||||||
|
@ -107,7 +110,7 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
|
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
|
||||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||||
final Set<Integer> seen = new HashSet<>();
|
final Set<Integer> seen = new HashSet<>();
|
||||||
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
|
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
|
||||||
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
|
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
|
||||||
|
@ -120,7 +123,7 @@ public class UsernameGeneratorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
|
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
|
||||||
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
|
||||||
final Set<Integer> seen = new HashSet<>();
|
final Set<Integer> seen = new HashSet<>();
|
||||||
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
|
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
|
||||||
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
|
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
|
||||||
|
|
Loading…
Reference in New Issue