Add support for generating discriminators
- adds `PUT accounts/username` endpoint - adds `GET accounts/username/{username}` to lookup aci by username - deletes `PUT accounts/username/{username}`, `GET profile/username/{username}` - adds randomized discriminator generation
This commit is contained in:
parent
24d01f1ab2
commit
a84a7dbc3d
|
@ -45,6 +45,7 @@ import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
|
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
|
||||||
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||||
|
@ -247,6 +248,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();
|
private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private UsernameConfiguration username = new UsernameConfiguration();
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
||||||
|
@ -424,4 +430,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||||
public AbusiveMessageFilterConfiguration getAbusiveMessageFilterConfiguration() {
|
public AbusiveMessageFilterConfiguration getAbusiveMessageFilterConfiguration() {
|
||||||
return abusiveMessageFilter;
|
return abusiveMessageFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UsernameConfiguration getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -205,6 +205,7 @@ import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;
|
import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
|
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
|
||||||
import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
|
import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
|
||||||
|
@ -444,11 +445,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
||||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
||||||
|
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||||
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, reservedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
|
experimentEnrollmentManager, clock);
|
||||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||||
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
|
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
|
||||||
PubSubManager pubSubManager = new PubSubManager(pubsubClient, dispatchManager);
|
PubSubManager pubSubManager = new PubSubManager(pubsubClient, dispatchManager);
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2013-2020 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
|
||||||
|
public class UsernameConfiguration {
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Min(1)
|
||||||
|
private int discriminatorInitialWidth = 4;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Min(1)
|
||||||
|
private int discriminatorMaxWidth = 9;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Min(1)
|
||||||
|
private int attemptsPerWidth = 10;
|
||||||
|
|
||||||
|
public int getDiscriminatorInitialWidth() {
|
||||||
|
return discriminatorInitialWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDiscriminatorMaxWidth() {
|
||||||
|
return discriminatorMaxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAttemptsPerWidth() {
|
||||||
|
return attemptsPerWidth;
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,6 +64,7 @@ import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
|
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;
|
||||||
|
@ -73,6 +74,9 @@ 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.StaleDevices;
|
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||||
|
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
import org.whispersystems.textsecuregcm.push.PushNotification;
|
import org.whispersystems.textsecuregcm.push.PushNotification;
|
||||||
|
@ -93,7 +97,7 @@ import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Hex;
|
import org.whispersystems.textsecuregcm.util.Hex;
|
||||||
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||||
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||||
import org.whispersystems.textsecuregcm.util.Username;
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
||||||
|
|
||||||
|
@ -119,6 +123,7 @@ public class AccountController {
|
||||||
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
|
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
|
||||||
|
|
||||||
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
||||||
|
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||||
|
|
||||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||||
|
@ -127,6 +132,7 @@ public class AccountController {
|
||||||
|
|
||||||
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
||||||
|
|
||||||
|
|
||||||
private final StoredVerificationCodeManager pendingAccounts;
|
private final StoredVerificationCodeManager pendingAccounts;
|
||||||
private final AccountsManager accounts;
|
private final AccountsManager accounts;
|
||||||
private final AbusiveHostRules abusiveHostRules;
|
private final AbusiveHostRules abusiveHostRules;
|
||||||
|
@ -628,6 +634,7 @@ public class AccountController {
|
||||||
auth.getAccount().isStorageSupported());
|
auth.getAccount().isStorageSupported());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/username")
|
@Path("/username")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@ -635,20 +642,66 @@ public class AccountController {
|
||||||
accounts.clearUsername(auth.getAccount());
|
accounts.clearUsername(auth.getAccount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/username/{username}")
|
@Path("/username")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public Response setUsername(@Auth AuthenticatedAccount auth, @PathParam("username") @Username String username)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
throws RateLimitExceededException {
|
public UsernameResponse setUsername(
|
||||||
|
@Auth AuthenticatedAccount auth,
|
||||||
|
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||||
|
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||||
|
|
||||||
try {
|
if (StringUtils.isNotBlank(usernameRequest.existingUsername()) &&
|
||||||
accounts.setUsername(auth.getAccount(), username);
|
!UsernameGenerator.isStandardFormat(usernameRequest.existingUsername())) {
|
||||||
} catch (final UsernameNotAvailableException e) {
|
// Technically, a username may not be in the nickname#discriminator format
|
||||||
return Response.status(Response.Status.CONFLICT).build();
|
// if created through some out-of-band mechanism, but it is atypical.
|
||||||
|
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||||
|
.increment();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.ok().build();
|
try {
|
||||||
|
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
||||||
|
usernameRequest.existingUsername());
|
||||||
|
return account
|
||||||
|
.getUsername()
|
||||||
|
.map(UsernameResponse::new)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||||
|
} catch (final UsernameNotAvailableException e) {
|
||||||
|
throw new WebApplicationException(Status.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@GET
|
||||||
|
@Path("/username/{username}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public AccountIdentifierResponse lookupUsername(
|
||||||
|
@HeaderParam("X-Signal-Agent") final String userAgent,
|
||||||
|
@HeaderParam("X-Forwarded-For") final String forwardedFor,
|
||||||
|
@PathParam("username") final String username,
|
||||||
|
@Context final HttpServletRequest request) throws RateLimitExceededException {
|
||||||
|
|
||||||
|
// Disallow clients from making authenticated requests to this endpoint
|
||||||
|
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
.getByUsername(username)
|
||||||
|
.map(Account::getUuid)
|
||||||
|
.map(AccountIdentifierResponse::new)
|
||||||
|
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
@HEAD
|
@HEAD
|
||||||
|
@ -662,17 +715,7 @@ public class AccountController {
|
||||||
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
|
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
rateLimitByClientIp(rateLimiters.getCheckAccountExistenceLimiter(), forwardedFor);
|
||||||
final String mostRecentProxy = ForwardedIpUtil.getMostRecentProxy(forwardedFor)
|
|
||||||
.orElseThrow(() -> {
|
|
||||||
// Missing/malformed Forwarded-For, so we cannot check for a rate-limit.
|
|
||||||
// This shouldn't happen, so conservatively assume we're over the rate-limit
|
|
||||||
// and indicate that the client should retry
|
|
||||||
logger.error("Missing/bad Forwarded-For, cannot check account {}", uuid.toString());
|
|
||||||
return new RateLimitExceededException(Duration.ofHours(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
rateLimiters.getCheckAccountExistenceLimiter().validate(mostRecentProxy);
|
|
||||||
|
|
||||||
final Status status = accounts.getByAccountIdentifier(uuid)
|
final Status status = accounts.getByAccountIdentifier(uuid)
|
||||||
.or(() -> accounts.getByPhoneNumberIdentifier(uuid))
|
.or(() -> accounts.getByPhoneNumberIdentifier(uuid))
|
||||||
|
@ -681,6 +724,19 @@ public class AccountController {
|
||||||
return Response.status(status).build();
|
return Response.status(status).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void rateLimitByClientIp(final RateLimiter rateLimiter, final String forwardedFor) throws RateLimitExceededException {
|
||||||
|
final String mostRecentProxy = ForwardedIpUtil.getMostRecentProxy(forwardedFor)
|
||||||
|
.orElseThrow(() -> {
|
||||||
|
// Missing/malformed Forwarded-For, so we cannot check for a rate-limit.
|
||||||
|
// This shouldn't happen, so conservatively assume we're over the rate-limit
|
||||||
|
// and indicate that the client should retry
|
||||||
|
logger.error("Missing/bad Forwarded-For: {}", forwardedFor);
|
||||||
|
return new RateLimitExceededException(Duration.ofHours(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
rateLimiter.validate(mostRecentProxy);
|
||||||
|
}
|
||||||
|
|
||||||
private void verifyRegistrationLock(final Account existingAccount, @Nullable final String clientRegistrationLock)
|
private void verifyRegistrationLock(final Account existingAccount, @Nullable final String clientRegistrationLock)
|
||||||
throws RateLimitExceededException, WebApplicationException {
|
throws RateLimitExceededException, WebApplicationException {
|
||||||
|
|
||||||
|
|
|
@ -503,24 +503,6 @@ public class ProfileController {
|
||||||
account.getPhoneNumberIdentifier());
|
account.getPhoneNumberIdentifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
|
||||||
@GET
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Path("/username/{username}")
|
|
||||||
public BaseProfileResponse getProfileByUsername(
|
|
||||||
@Auth AuthenticatedAccount auth,
|
|
||||||
@Context ContainerRequestContext containerRequestContext,
|
|
||||||
@PathParam("username") String username)
|
|
||||||
throws RateLimitExceededException {
|
|
||||||
|
|
||||||
rateLimiters.getUsernameLookupLimiter().validate(auth.getAccount().getUuid());
|
|
||||||
|
|
||||||
final Account targetAccount = accountsManager.getByUsername(username).orElseThrow(NotFoundException::new);
|
|
||||||
final boolean isSelf = auth.getAccount().getUuid().equals(targetAccount.getUuid());
|
|
||||||
|
|
||||||
return buildBaseProfileResponseForAccountIdentity(targetAccount, isSelf, containerRequestContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest,
|
private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest,
|
||||||
final VersionedProfile profile,
|
final VersionedProfile profile,
|
||||||
final UUID uuid) {
|
final UUID uuid) {
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record AccountIdentifierResponse(@NotNull UUID uuid) {}
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
|
import org.whispersystems.textsecuregcm.util.Nickname;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
|
||||||
|
public record UsernameRequest(@Valid @Nickname String nickname, @Nullable String existingUsername) {}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
|
public record UsernameResponse(String username) {}
|
|
@ -345,8 +345,15 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
public void setUsername(final Account account, final String username)
|
||||||
throws ContestedOptimisticLockException, UsernameNotAvailableException {
|
throws ContestedOptimisticLockException {
|
||||||
final long startNanos = System.nanoTime();
|
final long startNanos = System.nanoTime();
|
||||||
|
|
||||||
final Optional<String> maybeOriginalUsername = account.getUsername();
|
final Optional<String> maybeOriginalUsername = account.getUsername();
|
||||||
|
@ -405,18 +412,14 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
} catch (final JsonProcessingException e) {
|
} catch (final JsonProcessingException e) {
|
||||||
throw new IllegalArgumentException(e);
|
throw new IllegalArgumentException(e);
|
||||||
} catch (final TransactionCanceledException e) {
|
} catch (final TransactionCanceledException e) {
|
||||||
if ("ConditionalCheckFailed".equals(e.cancellationReasons().get(0).code())) {
|
if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch("ConditionalCheckFailed"::equals)) {
|
||||||
throw new UsernameNotAvailableException();
|
|
||||||
} else if ("ConditionalCheckFailed".equals(e.cancellationReasons().get(1).code())) {
|
|
||||||
throw new ContestedOptimisticLockException();
|
throw new ContestedOptimisticLockException();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
account.setUsername(maybeOriginalUsername.orElse(null));
|
account.setUsername(maybeOriginalUsername.orElse(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -563,6 +566,14 @@ public class Accounts extends AbstractDynamoDbStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean usernameAvailable(final String username) {
|
||||||
|
final GetItemResponse response = client.getItem(GetItemRequest.builder()
|
||||||
|
.tableName(usernamesConstraintTableName)
|
||||||
|
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
|
.build());
|
||||||
|
return !response.hasItem();
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<Account> getByE164(String number) {
|
public Optional<Account> getByE164(String number) {
|
||||||
return GET_BY_NUMBER_TIMER.record(() -> {
|
return GET_BY_NUMBER_TIMER.record(() -> {
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.codahale.metrics.Timer;
|
import com.codahale.metrics.Timer;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.lettuce.core.RedisException;
|
import io.lettuce.core.RedisException;
|
||||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
import io.micrometer.core.instrument.Metrics;
|
||||||
|
@ -37,6 +38,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.redis.RedisOperation;
|
import org.whispersystems.textsecuregcm.redis.RedisOperation;
|
||||||
|
@ -46,7 +48,7 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator;
|
import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
import org.whispersystems.textsecuregcm.util.UsernameValidator;
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
|
|
||||||
public class AccountsManager {
|
public class AccountsManager {
|
||||||
|
@ -71,6 +73,9 @@ public class AccountsManager {
|
||||||
private static final String COUNTRY_CODE_TAG_NAME = "country";
|
private static final String COUNTRY_CODE_TAG_NAME = "country";
|
||||||
private static final String DELETION_REASON_TAG_NAME = "reason";
|
private static final String DELETION_REASON_TAG_NAME = "reason";
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public static final String USERNAME_EXPERIMENT_NAME = "usernames";
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(AccountsManager.class);
|
private final Logger logger = LoggerFactory.getLogger(AccountsManager.class);
|
||||||
|
|
||||||
private final Accounts accounts;
|
private final Accounts accounts;
|
||||||
|
@ -86,7 +91,9 @@ public class AccountsManager {
|
||||||
private final SecureStorageClient secureStorageClient;
|
private final SecureStorageClient secureStorageClient;
|
||||||
private final SecureBackupClient secureBackupClient;
|
private final SecureBackupClient secureBackupClient;
|
||||||
private final ClientPresenceManager clientPresenceManager;
|
private final ClientPresenceManager clientPresenceManager;
|
||||||
|
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final UsernameGenerator usernameGenerator;
|
||||||
|
|
||||||
private static final ObjectMapper mapper = SystemMapper.getMapper();
|
private static final ObjectMapper mapper = SystemMapper.getMapper();
|
||||||
|
|
||||||
|
@ -126,6 +133,8 @@ public class AccountsManager {
|
||||||
final SecureStorageClient secureStorageClient,
|
final SecureStorageClient secureStorageClient,
|
||||||
final SecureBackupClient secureBackupClient,
|
final SecureBackupClient secureBackupClient,
|
||||||
final ClientPresenceManager clientPresenceManager,
|
final ClientPresenceManager clientPresenceManager,
|
||||||
|
final UsernameGenerator usernameGenerator,
|
||||||
|
final ExperimentEnrollmentManager experimentEnrollmentManager,
|
||||||
final Clock clock) {
|
final Clock clock) {
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
|
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
|
||||||
|
@ -140,6 +149,8 @@ public class AccountsManager {
|
||||||
this.secureBackupClient = secureBackupClient;
|
this.secureBackupClient = secureBackupClient;
|
||||||
this.clientPresenceManager = clientPresenceManager;
|
this.clientPresenceManager = clientPresenceManager;
|
||||||
this.reservedUsernames = reservedUsernames;
|
this.reservedUsernames = reservedUsernames;
|
||||||
|
this.usernameGenerator = usernameGenerator;
|
||||||
|
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||||
this.clock = Objects.requireNonNull(clock);
|
this.clock = Objects.requireNonNull(clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -276,32 +287,27 @@ public class AccountsManager {
|
||||||
|
|
||||||
final Account numberChangedAccount;
|
final Account numberChangedAccount;
|
||||||
|
|
||||||
try {
|
numberChangedAccount = updateWithRetries(
|
||||||
numberChangedAccount = updateWithRetries(
|
account,
|
||||||
account,
|
a -> {
|
||||||
a -> {
|
//noinspection ConstantConditions
|
||||||
//noinspection ConstantConditions
|
if (pniSignedPreKeys != null && pniRegistrationIds != null) {
|
||||||
if (pniSignedPreKeys != null && pniRegistrationIds != null) {
|
pniSignedPreKeys.forEach((deviceId, signedPreKey) ->
|
||||||
pniSignedPreKeys.forEach((deviceId, signedPreKey) ->
|
a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentitySignedPreKey(signedPreKey)));
|
||||||
a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentitySignedPreKey(signedPreKey)));
|
|
||||||
|
|
||||||
pniRegistrationIds.forEach((deviceId, registrationId) ->
|
pniRegistrationIds.forEach((deviceId, registrationId) ->
|
||||||
a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentityRegistrationId(registrationId)));
|
a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentityRegistrationId(registrationId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pniIdentityKey != null) {
|
if (pniIdentityKey != null) {
|
||||||
a.setPhoneNumberIdentityKey(pniIdentityKey);
|
a.setPhoneNumberIdentityKey(pniIdentityKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
a -> accounts.changeNumber(a, number, phoneNumberIdentifier),
|
a -> accounts.changeNumber(a, number, phoneNumberIdentifier),
|
||||||
() -> accounts.getByAccountIdentifier(uuid).orElseThrow(),
|
() -> accounts.getByAccountIdentifier(uuid).orElseThrow(),
|
||||||
AccountChangeValidator.NUMBER_CHANGE_VALIDATOR);
|
AccountChangeValidator.NUMBER_CHANGE_VALIDATOR);
|
||||||
} catch (UsernameNotAvailableException e) {
|
|
||||||
// This should never happen when changing numbers
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedAccount.set(numberChangedAccount);
|
updatedAccount.set(numberChangedAccount);
|
||||||
directoryQueue.changePhoneNumber(numberChangedAccount, originalNumber, number);
|
directoryQueue.changePhoneNumber(numberChangedAccount, originalNumber, number);
|
||||||
|
@ -315,23 +321,31 @@ public class AccountsManager {
|
||||||
return updatedAccount.get();
|
return updatedAccount.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Account setUsername(final Account account, final String username) throws UsernameNotAvailableException {
|
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
|
||||||
final String canonicalUsername = UsernameValidator.getCanonicalUsername(username);
|
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
if (account.getUsername().map(canonicalUsername::equals).orElse(false)) {
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedUsernames.isReserved(canonicalUsername, account.getUuid())) {
|
if (reservedUsernames.isReserved(requestedNickname, account.getUuid())) {
|
||||||
throw new UsernameNotAvailableException();
|
throw new UsernameNotAvailableException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Optional<String> currentUsername = account.getUsername();
|
||||||
|
final Optional<String> currentNickname = currentUsername.map(UsernameGenerator::extractNickname);
|
||||||
|
if (currentNickname.map(requestedNickname::equals).orElse(false) && !Objects.equals(expectedOldUsername, currentUsername.orElse(null))) {
|
||||||
|
// The requested nickname matches what the server already has, and the
|
||||||
|
// client provided the wrong existing username. Treat this as a replayed
|
||||||
|
// request, assuming that the client has previously succeeded
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
redisDelete(account);
|
redisDelete(account);
|
||||||
|
|
||||||
return updateWithRetries(
|
return failableUpdateWithRetries(
|
||||||
account,
|
account,
|
||||||
a -> true,
|
a -> true,
|
||||||
a -> accounts.setUsername(a, canonicalUsername),
|
// In the future, this may also check for any forbidden discriminators
|
||||||
|
a -> accounts.setUsername(a, usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
|
||||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||||
}
|
}
|
||||||
|
@ -339,31 +353,20 @@ public class AccountsManager {
|
||||||
public Account clearUsername(final Account account) {
|
public Account clearUsername(final Account account) {
|
||||||
redisDelete(account);
|
redisDelete(account);
|
||||||
|
|
||||||
try {
|
return updateWithRetries(
|
||||||
return updateWithRetries(
|
account,
|
||||||
account,
|
a -> true,
|
||||||
a -> true,
|
accounts::clearUsername,
|
||||||
accounts::clearUsername,
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
||||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
|
||||||
} catch (UsernameNotAvailableException e) {
|
|
||||||
// This should never happen
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Account update(Account account, Consumer<Account> updater) {
|
public Account update(Account account, Consumer<Account> updater) {
|
||||||
|
return update(account, a -> {
|
||||||
try {
|
updater.accept(a);
|
||||||
return update(account, a -> {
|
// assume that all updaters passed to the public method actually modify the account
|
||||||
updater.accept(a);
|
return true;
|
||||||
// assume that all updaters passed to the public method actually modify the account
|
});
|
||||||
return true;
|
|
||||||
});
|
|
||||||
} catch (UsernameNotAvailableException e) {
|
|
||||||
// This should never happen for general-purpose, public account updates
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -371,34 +374,28 @@ public class AccountsManager {
|
||||||
* redundant updates of {@code device.lastSeen}
|
* redundant updates of {@code device.lastSeen}
|
||||||
*/
|
*/
|
||||||
public Account updateDeviceLastSeen(Account account, Device device, final long lastSeen) {
|
public Account updateDeviceLastSeen(Account account, Device device, final long lastSeen) {
|
||||||
|
return update(account, a -> {
|
||||||
|
|
||||||
try {
|
final Optional<Device> maybeDevice = a.getDevice(device.getId());
|
||||||
return update(account, a -> {
|
|
||||||
|
|
||||||
final Optional<Device> maybeDevice = a.getDevice(device.getId());
|
return maybeDevice.map(d -> {
|
||||||
|
if (d.getLastSeen() >= lastSeen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return maybeDevice.map(d -> {
|
d.setLastSeen(lastSeen);
|
||||||
if (d.getLastSeen() >= lastSeen) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
d.setLastSeen(lastSeen);
|
return true;
|
||||||
|
|
||||||
return true;
|
}).orElse(false);
|
||||||
|
});
|
||||||
}).orElse(false);
|
|
||||||
});
|
|
||||||
} catch (UsernameNotAvailableException e) {
|
|
||||||
// This should never happen when updating last-seen timestamps
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param account account to update
|
* @param account account to update
|
||||||
* @param updater must return {@code true} if the account was actually updated
|
* @param updater must return {@code true} if the account was actually updated
|
||||||
*/
|
*/
|
||||||
private Account update(Account account, Function<Account, Boolean> updater) throws UsernameNotAvailableException {
|
private Account update(Account account, Function<Account, Boolean> updater) {
|
||||||
|
|
||||||
final boolean wasVisibleBeforeUpdate = account.shouldBeVisibleInDirectory();
|
final boolean wasVisibleBeforeUpdate = account.shouldBeVisibleInDirectory();
|
||||||
|
|
||||||
|
@ -429,6 +426,19 @@ public class AccountsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Account updateWithRetries(Account account,
|
private Account updateWithRetries(Account account,
|
||||||
|
final Function<Account, Boolean> updater,
|
||||||
|
final Consumer<Account> persister,
|
||||||
|
final Supplier<Account> retriever,
|
||||||
|
final AccountChangeValidator changeValidator) {
|
||||||
|
try {
|
||||||
|
return failableUpdateWithRetries(account, updater, persister::accept, retriever, changeValidator);
|
||||||
|
} catch (UsernameNotAvailableException e) {
|
||||||
|
// not possible
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account failableUpdateWithRetries(Account account,
|
||||||
final Function<Account, Boolean> updater,
|
final Function<Account, Boolean> updater,
|
||||||
final AccountPersister persister,
|
final AccountPersister persister,
|
||||||
final Supplier<Account> retriever,
|
final Supplier<Account> retriever,
|
||||||
|
@ -482,16 +492,11 @@ public class AccountsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Account updateDevice(Account account, long deviceId, Consumer<Device> deviceUpdater) {
|
public Account updateDevice(Account account, long deviceId, Consumer<Device> deviceUpdater) {
|
||||||
try {
|
return update(account, a -> {
|
||||||
return update(account, a -> {
|
a.getDevice(deviceId).ifPresent(deviceUpdater);
|
||||||
a.getDevice(deviceId).ifPresent(deviceUpdater);
|
// assume that all updaters passed to the public method actually modify the device
|
||||||
// assume that all updaters passed to the public method actually modify the device
|
return true;
|
||||||
return true;
|
});
|
||||||
});
|
|
||||||
} catch (UsernameNotAvailableException e) {
|
|
||||||
// This should never happen when updating devices
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Account> getByE164(String number) {
|
public Optional<Account> getByE164(String number) {
|
||||||
|
@ -522,12 +527,10 @@ 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()) {
|
||||||
final String canonicalUsername = UsernameValidator.getCanonicalUsername(username);
|
Optional<Account> account = redisGetByUsername(username);
|
||||||
|
|
||||||
Optional<Account> account = redisGetByUsername(canonicalUsername);
|
|
||||||
|
|
||||||
if (account.isEmpty()) {
|
if (account.isEmpty()) {
|
||||||
account = accounts.getByUsername(canonicalUsername);
|
account = accounts.getByUsername(username);
|
||||||
account.ifPresent(this::redisSet);
|
account.ifPresent(this::redisSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ public class ReservedUsernames {
|
||||||
this.tableName = tableName;
|
this.tableName = tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isReserved(final String username, final UUID accountIdentifier) {
|
public boolean isReserved(final String nickname, final UUID accountIdentifier) {
|
||||||
return IS_RESERVED_TIMER.record(() -> {
|
return IS_RESERVED_TIMER.record(() -> {
|
||||||
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
|
@ -66,7 +66,7 @@ public class ReservedUsernames {
|
||||||
final Pattern pattern = patternCache.get(item.get(KEY_PATTERN).s());
|
final Pattern pattern = patternCache.get(item.get(KEY_PATTERN).s());
|
||||||
final UUID reservedFor = AttributeValues.getUUID(item, ATTR_RESERVED_FOR_UUID, null);
|
final UUID reservedFor = AttributeValues.getUUID(item, ATTR_RESERVED_FOR_UUID, null);
|
||||||
|
|
||||||
if (pattern.matcher(username).matches() && !accountIdentifier.equals(reservedFor)) {
|
if (pattern.matcher(nickname).matches() && !accountIdentifier.equals(reservedFor)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
|
|
|
@ -5,21 +5,21 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.util;
|
package org.whispersystems.textsecuregcm.util;
|
||||||
|
|
||||||
|
import javax.validation.Constraint;
|
||||||
|
import javax.validation.Payload;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
import static java.lang.annotation.ElementType.FIELD;
|
import static java.lang.annotation.ElementType.FIELD;
|
||||||
import static java.lang.annotation.ElementType.PARAMETER;
|
import static java.lang.annotation.ElementType.PARAMETER;
|
||||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||||
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
import javax.validation.Constraint;
|
|
||||||
import javax.validation.Payload;
|
|
||||||
|
|
||||||
@Target({ FIELD, PARAMETER })
|
@Target({ FIELD, PARAMETER })
|
||||||
@Retention(RUNTIME)
|
@Retention(RUNTIME)
|
||||||
@Constraint(validatedBy = UsernameValidator.class)
|
@Constraint(validatedBy = NicknameValidator.class)
|
||||||
public @interface Username {
|
public @interface Nickname {
|
||||||
|
|
||||||
String message() default "{org.whispersystems.textsecuregcm.util.Username.message}";
|
String message() default "{org.whispersystems.textsecuregcm.util.Nickname.message}";
|
||||||
|
|
||||||
Class<?>[] groups() default { };
|
Class<?>[] groups() default { };
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2013-2021 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.util;
|
||||||
|
|
||||||
|
import javax.validation.ConstraintValidator;
|
||||||
|
import javax.validation.ConstraintValidatorContext;
|
||||||
|
|
||||||
|
|
||||||
|
public class NicknameValidator implements ConstraintValidator<Nickname, String> {
|
||||||
|
@Override
|
||||||
|
public boolean isValid(final String nickname, final ConstraintValidatorContext context) {
|
||||||
|
return UsernameGenerator.isValidNickname(nickname);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2022 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.util;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.math.IntMath;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.DistributionSummary;
|
||||||
|
import io.micrometer.core.instrument.Metrics;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||||
|
|
||||||
|
public class UsernameGenerator {
|
||||||
|
/**
|
||||||
|
* Nicknames are
|
||||||
|
* <list>
|
||||||
|
* <li> lowercase </li>
|
||||||
|
* <li> do not start with a number </li>
|
||||||
|
* <li> alphanumeric or underscores only </li>
|
||||||
|
* <li> minimum length 3 </li>
|
||||||
|
* <li> maximum length 32 </li>
|
||||||
|
* </list>
|
||||||
|
*
|
||||||
|
* Usernames typically consist of a nickname and an integer discriminator
|
||||||
|
*/
|
||||||
|
public static final Pattern NICKNAME_PATTERN = Pattern.compile("^[_a-z][_a-z0-9]{2,31}$");
|
||||||
|
public static final String SEPARATOR = "#";
|
||||||
|
|
||||||
|
private static final Counter USERNAME_NOT_AVAILABLE_COUNTER = Metrics.counter(name(UsernameGenerator.class, "usernameNotAvailable"));
|
||||||
|
private static final DistributionSummary DISCRIMINATOR_ATTEMPT_COUNTER = Metrics.summary(name(UsernameGenerator.class, "discriminatorAttempts"));
|
||||||
|
|
||||||
|
private final int initialWidth;
|
||||||
|
private final int discriminatorMaxWidth;
|
||||||
|
private final int attemptsPerWidth;
|
||||||
|
|
||||||
|
public UsernameGenerator(UsernameConfiguration configuration) {
|
||||||
|
this(configuration.getDiscriminatorInitialWidth(), configuration.getDiscriminatorMaxWidth(), configuration.getAttemptsPerWidth());
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth) {
|
||||||
|
this.initialWidth = initialWidth;
|
||||||
|
this.discriminatorMaxWidth = discriminatorMaxWidth;
|
||||||
|
this.attemptsPerWidth = attemptsPerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a username with a random discriminator
|
||||||
|
*
|
||||||
|
* @param nickname The string nickname
|
||||||
|
* @param usernameAvailableFun A {@link Predicate} that returns true if the provided username is available
|
||||||
|
* @return The nickname appended with a random discriminator
|
||||||
|
* @throws UsernameNotAvailableException if we failed to find a nickname+discriminator pair that was available
|
||||||
|
*/
|
||||||
|
public String generateAvailableUsername(final String nickname, final Predicate<String> usernameAvailableFun) throws UsernameNotAvailableException {
|
||||||
|
int rangeMin = 1;
|
||||||
|
int rangeMax = IntMath.pow(10, initialWidth);
|
||||||
|
int totalMax = IntMath.pow(10, discriminatorMaxWidth);
|
||||||
|
int attempts = 0;
|
||||||
|
while (rangeMax <= totalMax) {
|
||||||
|
// check discriminators of the current width up to attemptsPerWidth times
|
||||||
|
for (int i = 0; i < attemptsPerWidth; i++) {
|
||||||
|
int discriminator = ThreadLocalRandom.current().nextInt(rangeMin, rangeMax);
|
||||||
|
String username = UsernameGenerator.fromParts(nickname, discriminator);
|
||||||
|
attempts++;
|
||||||
|
if (usernameAvailableFun.test(username)) {
|
||||||
|
DISCRIMINATOR_ATTEMPT_COUNTER.record(attempts);
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the search range to look for numbers of one more digit
|
||||||
|
// than the previous iteration
|
||||||
|
rangeMin = rangeMax;
|
||||||
|
rangeMax *= 10;
|
||||||
|
}
|
||||||
|
USERNAME_NOT_AVAILABLE_COUNTER.increment();
|
||||||
|
throw new UsernameNotAvailableException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips the discriminator from a username, if it is present
|
||||||
|
*
|
||||||
|
* @param username the string username
|
||||||
|
* @return the nickname prefix of the username
|
||||||
|
*/
|
||||||
|
public static String extractNickname(final String username) {
|
||||||
|
int sep = username.indexOf(SEPARATOR);
|
||||||
|
return sep == -1 ? username : username.substring(0, sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a username from a nickname and discriminator
|
||||||
|
*/
|
||||||
|
public static String fromParts(final String nickname, final int discriminator) throws IllegalArgumentException {
|
||||||
|
if (!isValidNickname(nickname)) {
|
||||||
|
throw new IllegalArgumentException("Invalid nickname " + nickname);
|
||||||
|
}
|
||||||
|
return nickname + SEPARATOR + discriminator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValidNickname(final String nickname) {
|
||||||
|
return StringUtils.isNotBlank(nickname) && NICKNAME_PATTERN.matcher(nickname).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the username consists of a valid nickname followed by an integer discriminator
|
||||||
|
*
|
||||||
|
* @param username string username to check
|
||||||
|
* @return true if the username is in standard form
|
||||||
|
*/
|
||||||
|
public static boolean isStandardFormat(final String username) {
|
||||||
|
if (username == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int sep = username.indexOf(SEPARATOR);
|
||||||
|
if (sep == -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String nickname = username.substring(0, sep);
|
||||||
|
if (!isValidNickname(nickname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int discriminator = Integer.parseInt(username.substring(sep + 1));
|
||||||
|
return discriminator > 0;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,26 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2013-2021 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.util;
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import javax.validation.ConstraintValidator;
|
|
||||||
import javax.validation.ConstraintValidatorContext;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
public class UsernameValidator implements ConstraintValidator<Username, String> {
|
|
||||||
|
|
||||||
private static final Pattern USERNAME_PATTERN =
|
|
||||||
Pattern.compile("^[a-z_][a-z0-9_]{3,25}$", Pattern.CASE_INSENSITIVE);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isValid(final String username, final ConstraintValidatorContext context) {
|
|
||||||
return StringUtils.isNotBlank(username) && USERNAME_PATTERN.matcher(getCanonicalUsername(username)).matches();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String getCanonicalUsername(final String username) {
|
|
||||||
return username != null ? username.toLowerCase() : null;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -26,11 +26,13 @@ import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.Accounts;
|
import org.whispersystems.textsecuregcm.storage.Accounts;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeletedAccounts;
|
import org.whispersystems.textsecuregcm.storage.DeletedAccounts;
|
||||||
|
@ -50,6 +52,7 @@ 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;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
|
|
||||||
|
@ -68,11 +71,11 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
public void configure(Subparser subparser) {
|
public void configure(Subparser subparser) {
|
||||||
super.configure(subparser);
|
super.configure(subparser);
|
||||||
|
|
||||||
subparser.addArgument("-n", "--username")
|
subparser.addArgument("-n", "--nickname")
|
||||||
.dest("username")
|
.dest("nickname")
|
||||||
.type(String.class)
|
.type(String.class)
|
||||||
.required(true)
|
.required(true)
|
||||||
.help("The username to assign");
|
.help("The nickname (without discriminator) to assign");
|
||||||
|
|
||||||
subparser.addArgument("-a", "--aci")
|
subparser.addArgument("-a", "--aci")
|
||||||
.dest("aci")
|
.dest("aci")
|
||||||
|
@ -109,6 +112,9 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
||||||
dynamicConfigurationManager.start();
|
dynamicConfigurationManager.start();
|
||||||
|
|
||||||
|
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
|
||||||
|
dynamicConfigurationManager);
|
||||||
|
|
||||||
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
||||||
configuration.getDynamoDbClientConfiguration(),
|
configuration.getDynamoDbClientConfiguration(),
|
||||||
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
||||||
|
@ -185,17 +191,20 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
|
||||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||||
deletedAccountsLockDynamoDbClient,
|
deletedAccountsLockDynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||||
|
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, reservedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, Clock.systemUTC());
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
|
experimentEnrollmentManager, Clock.systemUTC());
|
||||||
|
|
||||||
final String username = namespace.getString("username");
|
final String nickname = namespace.getString("nickname");
|
||||||
final UUID accountIdentifier = UUID.fromString(namespace.getString("aci"));
|
final UUID accountIdentifier = UUID.fromString(namespace.getString("aci"));
|
||||||
|
|
||||||
accountsManager.getByAccountIdentifier(accountIdentifier).ifPresentOrElse(account -> {
|
accountsManager.getByAccountIdentifier(accountIdentifier).ifPresentOrElse(account -> {
|
||||||
try {
|
try {
|
||||||
accountsManager.setUsername(account, username);
|
final Account result = accountsManager.setUsername(account, nickname, null);
|
||||||
|
System.out.println("New username: " + result.getUsername());
|
||||||
} catch (final UsernameNotAvailableException e) {
|
} catch (final UsernameNotAvailableException e) {
|
||||||
throw new IllegalArgumentException("Username already taken");
|
throw new IllegalArgumentException("Username already taken");
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
|
@ -53,6 +54,7 @@ import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
||||||
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;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
|
|
||||||
|
@ -112,6 +114,9 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
||||||
dynamicConfigurationManager.start();
|
dynamicConfigurationManager.start();
|
||||||
|
|
||||||
|
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
|
||||||
|
dynamicConfigurationManager);
|
||||||
|
|
||||||
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
||||||
configuration.getDynamoDbClientConfiguration(),
|
configuration.getDynamoDbClientConfiguration(),
|
||||||
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
||||||
|
@ -189,9 +194,11 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
|
||||||
deletedAccountsLockDynamoDbClient,
|
deletedAccountsLockDynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
|
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, reservedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
|
experimentEnrollmentManager, clock);
|
||||||
|
|
||||||
for (String user : users) {
|
for (String user : users) {
|
||||||
Optional<Account> account = accountsManager.getByE164(user);
|
Optional<Account> account = accountsManager.getByE164(user);
|
||||||
|
|
|
@ -27,6 +27,7 @@ import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
|
@ -51,6 +52,7 @@ import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
||||||
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;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
|
|
||||||
|
@ -115,6 +117,9 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class);
|
||||||
dynamicConfigurationManager.start();
|
dynamicConfigurationManager.start();
|
||||||
|
|
||||||
|
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
|
||||||
|
dynamicConfigurationManager);
|
||||||
|
|
||||||
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
||||||
configuration.getDynamoDbClientConfiguration(),
|
configuration.getDynamoDbClientConfiguration(),
|
||||||
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
||||||
|
@ -190,9 +195,11 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
|
||||||
deletedAccountsLockDynamoDbClient,
|
deletedAccountsLockDynamoDbClient,
|
||||||
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
|
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, reservedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, clock);
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
|
experimentEnrollmentManager, clock);
|
||||||
|
|
||||||
Optional<Account> maybeAccount;
|
Optional<Account> maybeAccount;
|
||||||
|
|
||||||
|
|
|
@ -80,8 +80,6 @@ import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.controllers.ProfileController;
|
|
||||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
|
||||||
import org.whispersystems.textsecuregcm.entities.Badge;
|
import org.whispersystems.textsecuregcm.entities.Badge;
|
||||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
||||||
import org.whispersystems.textsecuregcm.entities.BaseProfileResponse;
|
import org.whispersystems.textsecuregcm.entities.BaseProfileResponse;
|
||||||
|
@ -362,23 +360,6 @@ class ProfileControllerTest {
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testProfileGetByUsername() throws RateLimitExceededException {
|
|
||||||
BaseProfileResponse profile = resources.getJerseyTest()
|
|
||||||
.target("/v1/profile/username/n00bkiller")
|
|
||||||
.request()
|
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
|
||||||
.get(BaseProfileResponse.class);
|
|
||||||
|
|
||||||
assertThat(profile.getIdentityKey()).isEqualTo("bar");
|
|
||||||
assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_UUID_TWO);
|
|
||||||
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
|
|
||||||
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
|
|
||||||
|
|
||||||
verify(accountsManager).getByUsername("n00bkiller");
|
|
||||||
verify(usernameRateLimiter, times(1)).validate(eq(AuthHelper.VALID_UUID));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testProfileGetUnauthorized() {
|
void testProfileGetUnauthorized() {
|
||||||
Response response = resources.getJerseyTest()
|
Response response = resources.getJerseyTest()
|
||||||
|
@ -389,31 +370,6 @@ class ProfileControllerTest {
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testProfileGetByUsernameUnauthorized() {
|
|
||||||
Response response = resources.getJerseyTest()
|
|
||||||
.target("/v1/profile/username/n00bkiller")
|
|
||||||
.request()
|
|
||||||
.get();
|
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testProfileGetByUsernameNotFound() throws RateLimitExceededException {
|
|
||||||
Response response = resources.getJerseyTest()
|
|
||||||
.target("/v1/profile/username/n00bkillerzzzzz")
|
|
||||||
.request()
|
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(404);
|
|
||||||
|
|
||||||
verify(accountsManager).getByUsername("n00bkillerzzzzz");
|
|
||||||
verify(usernameRateLimiter).validate(eq(AuthHelper.VALID_UUID));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testProfileGetDisabled() {
|
void testProfileGetDisabled() {
|
||||||
|
|
|
@ -27,11 +27,13 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
|
||||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
|
||||||
|
@ -197,6 +199,8 @@ class AccountsManagerChangeNumberIntegrationTest {
|
||||||
secureStorageClient,
|
secureStorageClient,
|
||||||
secureBackupClient,
|
secureBackupClient,
|
||||||
clientPresenceManager,
|
clientPresenceManager,
|
||||||
|
mock(UsernameGenerator.class),
|
||||||
|
mock(ExperimentEnrollmentManager.class),
|
||||||
mock(Clock.class));
|
mock(Clock.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||||
|
@ -50,6 +51,7 @@ import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.JsonHelpers;
|
import org.whispersystems.textsecuregcm.tests.util.JsonHelpers;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||||
|
@ -164,6 +166,8 @@ class AccountsManagerConcurrentModificationIntegrationTest {
|
||||||
mock(SecureStorageClient.class),
|
mock(SecureStorageClient.class),
|
||||||
mock(SecureBackupClient.class),
|
mock(SecureBackupClient.class),
|
||||||
mock(ClientPresenceManager.class),
|
mock(ClientPresenceManager.class),
|
||||||
|
mock(UsernameGenerator.class),
|
||||||
|
mock(ExperimentEnrollmentManager.class),
|
||||||
mock(Clock.class)
|
mock(Clock.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.AdditionalMatchers.and;
|
||||||
|
import static org.mockito.AdditionalMatchers.not;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.startsWith;
|
||||||
import static org.mockito.Mockito.anyString;
|
import static org.mockito.Mockito.anyString;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
@ -43,11 +46,14 @@ import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.Arguments;
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
import org.junit.jupiter.params.provider.MethodSource;
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.mockito.ArgumentMatcher;
|
||||||
import org.mockito.stubbing.Answer;
|
import org.mockito.stubbing.Answer;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||||
|
@ -55,6 +61,7 @@ import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||||
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
|
import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
|
|
||||||
class AccountsManagerTest {
|
class AccountsManagerTest {
|
||||||
|
|
||||||
|
@ -65,6 +72,7 @@ class AccountsManagerTest {
|
||||||
private MessagesManager messagesManager;
|
private MessagesManager messagesManager;
|
||||||
private ProfilesManager profilesManager;
|
private ProfilesManager profilesManager;
|
||||||
private ReservedUsernames reservedUsernames;
|
private ReservedUsernames reservedUsernames;
|
||||||
|
private ExperimentEnrollmentManager enrollmentManager;
|
||||||
|
|
||||||
private Map<String, UUID> phoneNumberIdentifiersByE164;
|
private Map<String, UUID> phoneNumberIdentifiersByE164;
|
||||||
|
|
||||||
|
@ -129,6 +137,10 @@ class AccountsManagerTest {
|
||||||
|
|
||||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||||
|
|
||||||
|
enrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||||
|
when(enrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME))).thenReturn(true);
|
||||||
|
when(accounts.usernameAvailable(any())).thenReturn(true);
|
||||||
|
|
||||||
accountsManager = new AccountsManager(
|
accountsManager = new AccountsManager(
|
||||||
accounts,
|
accounts,
|
||||||
phoneNumberIdentifiers,
|
phoneNumberIdentifiers,
|
||||||
|
@ -143,6 +155,8 @@ class AccountsManagerTest {
|
||||||
storageClient,
|
storageClient,
|
||||||
backupClient,
|
backupClient,
|
||||||
mock(ClientPresenceManager.class),
|
mock(ClientPresenceManager.class),
|
||||||
|
new UsernameGenerator(new UsernameConfiguration()),
|
||||||
|
enrollmentManager,
|
||||||
mock(Clock.class));
|
mock(Clock.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -716,45 +730,82 @@ class AccountsManagerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsername() throws UsernameNotAvailableException {
|
void testSetUsername() {
|
||||||
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 username = "test";
|
final String nickname = "test";
|
||||||
|
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
|
||||||
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
|
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||||
verify(accounts).setUsername(account, username);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameSameUsername() throws UsernameNotAvailableException {
|
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]);
|
||||||
final String username = "test";
|
final String nickname = "test";
|
||||||
account.setUsername(username);
|
account.setUsername(nickname + "#123");
|
||||||
|
|
||||||
assertDoesNotThrow(() -> accountsManager.setUsername(account, username));
|
// should be treated as a replayed request
|
||||||
|
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
|
||||||
verify(accounts, never()).setUsername(eq(account), any());
|
verify(accounts, never()).setUsername(eq(account), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameNotAvailable() throws UsernameNotAvailableException {
|
void testSetUsernameReroll() 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 username = "test";
|
final String nickname = "test";
|
||||||
|
final String username = nickname + "#ZZZ";
|
||||||
|
account.setUsername(username);
|
||||||
|
|
||||||
doThrow(new UsernameNotAvailableException()).when(accounts).setUsername(account, username);
|
// given the correct old username, should reroll discriminator even if the nick matches
|
||||||
|
accountsManager.setUsername(account, nickname, username);
|
||||||
|
verify(accounts).setUsername(eq(account), and(startsWith(nickname), not(eq(username))));
|
||||||
|
}
|
||||||
|
|
||||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
|
@Test
|
||||||
verify(accounts).setUsername(account, username);
|
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String nickname = "test";
|
||||||
|
|
||||||
|
ArgumentMatcher<String> isWide = (String username) -> {
|
||||||
|
String[] spl = username.split(UsernameGenerator.SEPARATOR);
|
||||||
|
assertEquals(spl.length, 2);
|
||||||
|
int discriminator = Integer.parseInt(spl[1]);
|
||||||
|
// require a 7 digit discriminator
|
||||||
|
return discriminator > 1_000_000;
|
||||||
|
};
|
||||||
|
when(accounts.usernameAvailable(any())).thenReturn(false);
|
||||||
|
when(accounts.usernameAvailable(argThat(isWide))).thenReturn(true);
|
||||||
|
|
||||||
|
accountsManager.setUsername(account, nickname, null);
|
||||||
|
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testChangeUsername() throws UsernameNotAvailableException {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String nickname = "test";
|
||||||
|
account.setUsername("old#123");
|
||||||
|
accountsManager.setUsername(account, nickname, "old#123");
|
||||||
|
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetUsernameNotAvailable() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
final String nickname = "unavailable";
|
||||||
|
when(accounts.usernameAvailable(startsWith(nickname))).thenReturn(false);
|
||||||
|
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
|
||||||
|
verify(accounts, never()).setUsername(any(), any());
|
||||||
assertTrue(account.getUsername().isEmpty());
|
assertTrue(account.getUsername().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameReserved() {
|
void testSetUsernameReserved() {
|
||||||
final String username = "reserved";
|
final String nickname = "reserved";
|
||||||
when(reservedUsernames.isReserved(eq(username), any())).thenReturn(true);
|
when(reservedUsernames.isReserved(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]);
|
||||||
|
|
||||||
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, username));
|
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, nickname, null));
|
||||||
assertTrue(account.getUsername().isEmpty());
|
assertTrue(account.getUsername().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -765,6 +816,13 @@ class AccountsManagerTest {
|
||||||
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsername("test")));
|
assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsername("test")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetUsernameDisabled() {
|
||||||
|
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||||
|
when(enrollmentManager.isEnrolled(account.getUuid(), AccountsManager.USERNAME_EXPERIMENT_NAME)).thenReturn(false);
|
||||||
|
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
|
||||||
|
}
|
||||||
|
|
||||||
private 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);
|
||||||
|
|
|
@ -0,0 +1,238 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2013-2021 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.mockito.invocation.InvocationOnMock;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
|
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||||
|
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
|
||||||
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
|
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||||
|
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||||
|
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
|
import software.amazon.awssdk.services.dynamodb.model.*;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class AccountsManagerUsernameIntegrationTest {
|
||||||
|
|
||||||
|
private static final String ACCOUNTS_TABLE_NAME = "accounts_test";
|
||||||
|
private static final String NUMBERS_TABLE_NAME = "numbers_test";
|
||||||
|
private static final String PNI_ASSIGNMENT_TABLE_NAME = "pni_assignment_test";
|
||||||
|
private static final String USERNAMES_TABLE_NAME = "usernames_test";
|
||||||
|
private static final String PNI_TABLE_NAME = "pni_test";
|
||||||
|
private static final int SCAN_PAGE_SIZE = 1;
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static DynamoDbExtension ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder()
|
||||||
|
.tableName(ACCOUNTS_TABLE_NAME)
|
||||||
|
.hashKey(Accounts.KEY_ACCOUNT_UUID)
|
||||||
|
.attributeDefinition(AttributeDefinition.builder()
|
||||||
|
.attributeName(Accounts.KEY_ACCOUNT_UUID)
|
||||||
|
.attributeType(ScalarAttributeType.B)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static DynamoDbExtension PNI_DYNAMO_EXTENSION = DynamoDbExtension.builder()
|
||||||
|
.tableName(PNI_TABLE_NAME)
|
||||||
|
.hashKey(PhoneNumberIdentifiers.KEY_E164)
|
||||||
|
.attributeDefinition(AttributeDefinition.builder()
|
||||||
|
.attributeName(PhoneNumberIdentifiers.KEY_E164)
|
||||||
|
.attributeType(ScalarAttributeType.S)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
|
||||||
|
|
||||||
|
private AccountsManager accountsManager;
|
||||||
|
private Accounts accounts;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() throws InterruptedException {
|
||||||
|
CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder()
|
||||||
|
.tableName(NUMBERS_TABLE_NAME)
|
||||||
|
.keySchema(KeySchemaElement.builder()
|
||||||
|
.attributeName(Accounts.ATTR_ACCOUNT_E164)
|
||||||
|
.keyType(KeyType.HASH)
|
||||||
|
.build())
|
||||||
|
.attributeDefinitions(AttributeDefinition.builder()
|
||||||
|
.attributeName(Accounts.ATTR_ACCOUNT_E164)
|
||||||
|
.attributeType(ScalarAttributeType.S)
|
||||||
|
.build())
|
||||||
|
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createNumbersTableRequest);
|
||||||
|
CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder()
|
||||||
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
|
.keySchema(KeySchemaElement.builder()
|
||||||
|
.attributeName(Accounts.ATTR_USERNAME)
|
||||||
|
.keyType(KeyType.HASH)
|
||||||
|
.build())
|
||||||
|
.attributeDefinitions(AttributeDefinition.builder()
|
||||||
|
.attributeName(Accounts.ATTR_USERNAME)
|
||||||
|
.attributeType(ScalarAttributeType.S)
|
||||||
|
.build())
|
||||||
|
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createUsernamesTableRequest);
|
||||||
|
CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder()
|
||||||
|
.tableName(PNI_ASSIGNMENT_TABLE_NAME)
|
||||||
|
.keySchema(KeySchemaElement.builder()
|
||||||
|
.attributeName(Accounts.ATTR_PNI_UUID)
|
||||||
|
.keyType(KeyType.HASH)
|
||||||
|
.build())
|
||||||
|
.attributeDefinitions(AttributeDefinition.builder()
|
||||||
|
.attributeName(Accounts.ATTR_PNI_UUID)
|
||||||
|
.attributeType(ScalarAttributeType.B)
|
||||||
|
.build())
|
||||||
|
.provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
|
||||||
|
mock(DynamicConfigurationManager.class);
|
||||||
|
|
||||||
|
DynamicConfiguration dynamicConfiguration = new DynamicConfiguration();
|
||||||
|
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||||
|
|
||||||
|
accounts = Mockito.spy(new Accounts(
|
||||||
|
dynamicConfigurationManager,
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(),
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbAsyncClient(),
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getTableName(),
|
||||||
|
NUMBERS_TABLE_NAME,
|
||||||
|
PNI_ASSIGNMENT_TABLE_NAME,
|
||||||
|
USERNAMES_TABLE_NAME,
|
||||||
|
SCAN_PAGE_SIZE));
|
||||||
|
|
||||||
|
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
|
||||||
|
doAnswer((final InvocationOnMock invocationOnMock) -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Consumer<Optional<UUID>> consumer = invocationOnMock.getArgument(1, Consumer.class);
|
||||||
|
consumer.accept(Optional.empty());
|
||||||
|
return null;
|
||||||
|
}).when(deletedAccountsManager).lockAndTake(any(), any());
|
||||||
|
|
||||||
|
final PhoneNumberIdentifiers phoneNumberIdentifiers =
|
||||||
|
new PhoneNumberIdentifiers(PNI_DYNAMO_EXTENSION.getDynamoDbClient(), PNI_TABLE_NAME);
|
||||||
|
|
||||||
|
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||||
|
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
accountsManager = new AccountsManager(
|
||||||
|
accounts,
|
||||||
|
phoneNumberIdentifiers,
|
||||||
|
CACHE_CLUSTER_EXTENSION.getRedisCluster(),
|
||||||
|
deletedAccountsManager,
|
||||||
|
mock(DirectoryQueue.class),
|
||||||
|
mock(Keys.class),
|
||||||
|
mock(MessagesManager.class),
|
||||||
|
mock(ReservedUsernames.class),
|
||||||
|
mock(ProfilesManager.class),
|
||||||
|
mock(StoredVerificationCodeManager.class),
|
||||||
|
mock(SecureStorageClient.class),
|
||||||
|
mock(SecureBackupClient.class),
|
||||||
|
mock(ClientPresenceManager.class),
|
||||||
|
new UsernameGenerator(1, 2, 10),
|
||||||
|
experimentEnrollmentManager,
|
||||||
|
mock(Clock.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int discriminator(String username) {
|
||||||
|
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSetClearUsername() throws UsernameNotAvailableException, InterruptedException {
|
||||||
|
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
account = accountsManager.setUsername(account, "n00bkiller", null);
|
||||||
|
assertThat(account.getUsername()).isPresent();
|
||||||
|
assertThat(account.getUsername().get()).startsWith("n00bkiller");
|
||||||
|
int discriminator = discriminator(account.getUsername().get());
|
||||||
|
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
|
||||||
|
|
||||||
|
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
|
||||||
|
account.getUuid());
|
||||||
|
|
||||||
|
// reroll
|
||||||
|
account = accountsManager.setUsername(account, "n00bkiller", account.getUsername().get());
|
||||||
|
final String newUsername = account.getUsername().orElseThrow();
|
||||||
|
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
|
||||||
|
|
||||||
|
// clear
|
||||||
|
account = accountsManager.clearUsername(account);
|
||||||
|
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
|
||||||
|
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testNoUsernames() throws InterruptedException {
|
||||||
|
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
for (int i = 1; i <= 99; i++) {
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||||
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
|
.item(Map.of(
|
||||||
|
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||||
|
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
|
||||||
|
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUsernameSnatched() throws InterruptedException, UsernameNotAvailableException {
|
||||||
|
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
|
||||||
|
new ArrayList<>());
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
|
||||||
|
.tableName(USERNAMES_TABLE_NAME)
|
||||||
|
.item(Map.of(
|
||||||
|
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
|
||||||
|
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// first time this is called lie and say the username is available
|
||||||
|
// this simulates seeing an available username and then it being taken
|
||||||
|
// by someone before the write
|
||||||
|
doReturn(true).doCallRealMethod().when(accounts).usernameAvailable(any());
|
||||||
|
final String username = accountsManager
|
||||||
|
.setUsername(account, "n00bkiller", null)
|
||||||
|
.getUsername().orElseThrow();
|
||||||
|
assertThat(username).startsWith("n00bkiller");
|
||||||
|
assertThat(discriminator(username)).isGreaterThanOrEqualTo(10).isLessThan(100);
|
||||||
|
|
||||||
|
// 1 attempt on first try (returns true),
|
||||||
|
// 10 (attempts per width) on width=2 discriminators (all taken)
|
||||||
|
verify(accounts, times(11)).usernameAvailable(argThat(un -> discriminator(un) < 10));
|
||||||
|
|
||||||
|
// 1 final attempt on width=3 discriminators
|
||||||
|
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -722,7 +722,7 @@ class AccountsTest {
|
||||||
assertThat(maybeAccount).isPresent();
|
assertThat(maybeAccount).isPresent();
|
||||||
verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), maybeAccount.get(), firstAccount);
|
verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), maybeAccount.get(), firstAccount);
|
||||||
|
|
||||||
assertThatExceptionOfType(UsernameNotAvailableException.class)
|
assertThatExceptionOfType(ContestedOptimisticLockException.class)
|
||||||
.isThrownBy(() -> accounts.setUsername(secondAccount, username));
|
.isThrownBy(() -> accounts.setUsername(secondAccount, username));
|
||||||
|
|
||||||
assertThat(secondAccount.getUsername()).isEmpty();
|
assertThat(secondAccount.getUsername()).isEmpty();
|
||||||
|
|
|
@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.anyLong;
|
import static org.mockito.Mockito.anyLong;
|
||||||
import static org.mockito.Mockito.clearInvocations;
|
import static org.mockito.Mockito.clearInvocations;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
@ -23,6 +24,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
|
@ -66,6 +68,7 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
|
||||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||||
|
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;
|
||||||
|
@ -74,6 +77,8 @@ 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.SignedPreKey;
|
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||||
|
@ -138,6 +143,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 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);
|
||||||
private static Account senderPinAccount = mock(Account.class);
|
private static Account senderPinAccount = mock(Account.class);
|
||||||
|
@ -201,6 +207,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.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
|
||||||
|
|
||||||
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
|
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
|
||||||
when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
|
when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
|
||||||
|
@ -246,7 +253,7 @@ class AccountControllerTest {
|
||||||
return account;
|
return account;
|
||||||
});
|
});
|
||||||
|
|
||||||
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername"))
|
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername", null))
|
||||||
.thenThrow(new UsernameNotAvailableException());
|
.thenThrow(new UsernameNotAvailableException());
|
||||||
|
|
||||||
when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>) invocation -> {
|
when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>) invocation -> {
|
||||||
|
@ -311,6 +318,7 @@ class AccountControllerTest {
|
||||||
smsVoicePrefixLimiter,
|
smsVoicePrefixLimiter,
|
||||||
autoBlockLimiter,
|
autoBlockLimiter,
|
||||||
usernameSetLimiter,
|
usernameSetLimiter,
|
||||||
|
usernameLookupLimiter,
|
||||||
smsSender,
|
smsSender,
|
||||||
turnTokenGenerator,
|
turnTokenGenerator,
|
||||||
senderPinAccount,
|
senderPinAccount,
|
||||||
|
@ -1660,25 +1668,29 @@ class AccountControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsername() {
|
void testSetUsername() throws UsernameNotAvailableException {
|
||||||
|
Account account = mock(Account.class);
|
||||||
|
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
|
||||||
|
when(accountsManager.setUsername(any(), eq("n00bkiller"), isNull()))
|
||||||
|
.thenReturn(account);
|
||||||
Response response =
|
Response response =
|
||||||
resources.getJerseyTest()
|
resources.getJerseyTest()
|
||||||
.target("/v1/accounts/username/n00bkiller")
|
.target("/v1/accounts/username")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
.put(Entity.text(""));
|
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetTakenUsername() {
|
void testSetTakenUsername() {
|
||||||
Response response =
|
Response response =
|
||||||
resources.getJerseyTest()
|
resources.getJerseyTest()
|
||||||
.target("/v1/accounts/username/takenusername")
|
.target("/v1/accounts/username/")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
.put(Entity.text(""));
|
.put(Entity.json(new UsernameRequest("takenusername", null)));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(409);
|
assertThat(response.getStatus()).isEqualTo(409);
|
||||||
}
|
}
|
||||||
|
@ -1687,35 +1699,34 @@ class AccountControllerTest {
|
||||||
void testSetInvalidUsername() {
|
void testSetInvalidUsername() {
|
||||||
Response response =
|
Response response =
|
||||||
resources.getJerseyTest()
|
resources.getJerseyTest()
|
||||||
.target("/v1/accounts/username/pаypal")
|
.target("/v1/accounts/username")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
.put(Entity.text(""));
|
// contains non-ascii character
|
||||||
|
.put(Entity.json(new UsernameRequest("pаypal", null)));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(400);
|
assertThat(response.getStatus()).isEqualTo(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetInvalidPrefixUsername() {
|
void testSetInvalidPrefixUsername() throws JsonProcessingException {
|
||||||
Response response =
|
Response response =
|
||||||
resources.getJerseyTest()
|
resources.getJerseyTest()
|
||||||
.target("/v1/accounts/username/0n00bkiller")
|
.target("/v1/accounts/username")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
.put(Entity.text(""));
|
.put(Entity.json(new UsernameRequest("0n00bkiller", null)));
|
||||||
|
assertThat(response.getStatus()).isEqualTo(422);
|
||||||
assertThat(response.getStatus()).isEqualTo(400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSetUsernameBadAuth() {
|
void testSetUsernameBadAuth() {
|
||||||
Response response =
|
Response response =
|
||||||
resources.getJerseyTest()
|
resources.getJerseyTest()
|
||||||
.target("/v1/accounts/username/n00bkiller")
|
.target("/v1/accounts/username")
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))
|
||||||
.put(Entity.text(""));
|
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
|
||||||
|
|
||||||
assertThat(response.getStatus()).isEqualTo(401);
|
assertThat(response.getStatus()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1935,4 +1946,43 @@ class AccountControllerTest {
|
||||||
.head()
|
.head()
|
||||||
.getStatus()).isEqualTo(400);
|
.getStatus()).isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLookupUsername() {
|
||||||
|
final Account account = mock(Account.class);
|
||||||
|
final UUID uuid = UUID.randomUUID();
|
||||||
|
when(account.getUuid()).thenReturn(uuid);
|
||||||
|
|
||||||
|
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.of(account));
|
||||||
|
Response response = resources.getJerseyTest()
|
||||||
|
.target("v1/accounts/username/n00bkiller#1234")
|
||||||
|
.request()
|
||||||
|
.header("X-Forwarded-For", "127.0.0.1")
|
||||||
|
.get();
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.readEntity(AccountIdentifierResponse.class).uuid()).isEqualTo(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLookupUsernameDoesNotExist() {
|
||||||
|
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.empty());
|
||||||
|
assertThat(resources.getJerseyTest()
|
||||||
|
.target("v1/accounts/username/n00bkiller#1234")
|
||||||
|
.request()
|
||||||
|
.header("X-Forwarded-For", "127.0.0.1")
|
||||||
|
.get().getStatus()).isEqualTo(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLookupUsernameRateLimited() throws RateLimitExceededException {
|
||||||
|
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(usernameLookupLimiter).validate("127.0.0.1");
|
||||||
|
final Response response = resources.getJerseyTest()
|
||||||
|
.target("/v1/accounts/username/test#123")
|
||||||
|
.request()
|
||||||
|
.header("X-Forwarded-For", "127.0.0.1")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assertThat(response.getStatus()).isEqualTo(413);
|
||||||
|
assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
package org.whispersystems.textsecuregcm.tests.util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
public class UsernameGeneratorTest {
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "[{index}]:{0} ({2})")
|
||||||
|
@MethodSource
|
||||||
|
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
|
||||||
|
assertThat(UsernameGenerator.isValidNickname(nickname)).isEqualTo(valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> nicknameValidation() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("Test", false, "upper case"),
|
||||||
|
Arguments.of("tesT", false, "upper case"),
|
||||||
|
Arguments.of("te-st", false, "illegal character"),
|
||||||
|
Arguments.of("ab\uD83D\uDC1B", false, "illegal character"),
|
||||||
|
Arguments.of("1test", false, "illegal start"),
|
||||||
|
Arguments.of("test#123", false, "illegal character"),
|
||||||
|
Arguments.of("ab", false, "too short"),
|
||||||
|
Arguments.of("", false, ""),
|
||||||
|
Arguments.of("_123456789_123456789_123456789123", false, "33 characters"),
|
||||||
|
|
||||||
|
Arguments.of("_test", true, ""),
|
||||||
|
Arguments.of("test", true, ""),
|
||||||
|
Arguments.of("test123", true, ""),
|
||||||
|
Arguments.of("abc", true, ""),
|
||||||
|
Arguments.of("_123456789_123456789_12345678912", true, "32 characters")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name="[{index}]: {0}")
|
||||||
|
@MethodSource
|
||||||
|
public void nonStandardUsernames(final String username, final boolean isStandard) {
|
||||||
|
assertThat(UsernameGenerator.isStandardFormat(username)).isEqualTo(isStandard);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> nonStandardUsernames() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("Test#123", false),
|
||||||
|
Arguments.of("test#-123", false),
|
||||||
|
Arguments.of("test#0", false),
|
||||||
|
Arguments.of("test#", false),
|
||||||
|
Arguments.of("test#1_00", false),
|
||||||
|
|
||||||
|
Arguments.of("test#1", true),
|
||||||
|
Arguments.of("abc#1234", true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void expectedWidth() throws UsernameNotAvailableException {
|
||||||
|
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
|
||||||
|
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
|
||||||
|
|
||||||
|
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
|
||||||
|
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void expandDiscriminator() throws UsernameNotAvailableException {
|
||||||
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
||||||
|
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
|
||||||
|
int discriminator = extractDiscriminator(username);
|
||||||
|
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
|
||||||
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
||||||
|
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
|
||||||
|
int discriminator = extractDiscriminator(username);
|
||||||
|
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void exhaustDiscriminator() {
|
||||||
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
||||||
|
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
|
||||||
|
// allow greater than our max width
|
||||||
|
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
|
||||||
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
||||||
|
final Set<Integer> seen = new HashSet<>();
|
||||||
|
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
|
||||||
|
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
|
||||||
|
}
|
||||||
|
// after 1K iterations, probability of a missed value is (9/10)^999
|
||||||
|
assertThat(seen.size()).isEqualTo(9);
|
||||||
|
assertThat(seen).allMatch(i -> i > 0 && i < 10);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
|
||||||
|
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
|
||||||
|
final Set<Integer> seen = new HashSet<>();
|
||||||
|
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
|
||||||
|
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));
|
||||||
|
}
|
||||||
|
// after 100K iterations, probability of a missed value is (99/100)^99999
|
||||||
|
assertThat(seen.size()).isEqualTo(90);
|
||||||
|
assertThat(seen).allMatch(i -> i >= 10 && i < 100);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Predicate<String> allowDiscriminator(Predicate<Integer> p) {
|
||||||
|
return username -> p.test(extractDiscriminator(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int extractDiscriminator(final String username) {
|
||||||
|
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,14 +12,14 @@ import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.Arguments;
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
import org.junit.jupiter.params.provider.MethodSource;
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
class UsernameValidatorTest {
|
class NicknameValidatorTest {
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void isValid(final String username, final boolean expectValid) {
|
void isValid(final String username, final boolean expectValid) {
|
||||||
final UsernameValidator usernameValidator = new UsernameValidator();
|
final NicknameValidator nicknameValidator = new NicknameValidator();
|
||||||
|
|
||||||
assertEquals(expectValid, usernameValidator.isValid(username, null));
|
assertEquals(expectValid, nicknameValidator.isValid(username, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> isValid() {
|
private static Stream<Arguments> isValid() {
|
||||||
|
@ -28,8 +28,8 @@ class UsernameValidatorTest {
|
||||||
Arguments.of("_test", true),
|
Arguments.of("_test", true),
|
||||||
Arguments.of("test123", true),
|
Arguments.of("test123", true),
|
||||||
Arguments.of("a", false), // Too short
|
Arguments.of("a", false), // Too short
|
||||||
Arguments.of("thisIsAReallyReallyReallyLongUsernameThatWeWouldNotAllow", false),
|
Arguments.of("thisisareallyreallyreallylongusernamethatwewouldnotalllow", false),
|
||||||
Arguments.of("Illegal character", false),
|
Arguments.of("illegal character", false),
|
||||||
Arguments.of("0test", false), // Illegal first character
|
Arguments.of("0test", false), // Illegal first character
|
||||||
Arguments.of("pаypal", false), // Unicode confusable characters
|
Arguments.of("pаypal", false), // Unicode confusable characters
|
||||||
Arguments.of("test\uD83D\uDC4E", false), // Emoji
|
Arguments.of("test\uD83D\uDC4E", false), // Emoji
|
||||||
|
@ -38,19 +38,4 @@ class UsernameValidatorTest {
|
||||||
Arguments.of(null, false)
|
Arguments.of(null, false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@MethodSource
|
|
||||||
void getCanonicalUsername(final String username, final String expectedCanonicalUsername) {
|
|
||||||
assertEquals(expectedCanonicalUsername, UsernameValidator.getCanonicalUsername(username));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Stream<Arguments> getCanonicalUsername() {
|
|
||||||
return Stream.of(
|
|
||||||
Arguments.of("test", "test"),
|
|
||||||
Arguments.of("TEst", "test"),
|
|
||||||
Arguments.of("t_e_S_T", "t_e_s_t"),
|
|
||||||
Arguments.of(null, null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
Loading…
Reference in New Issue