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:
ravi-signal
2022-08-15 10:44:36 -05:00
committed by GitHub
parent 24d01f1ab2
commit a84a7dbc3d
27 changed files with 989 additions and 274 deletions

View File

@@ -5,21 +5,21 @@
package org.whispersystems.textsecuregcm.util;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Target({ FIELD, PARAMETER })
@Retention(RUNTIME)
@Constraint(validatedBy = UsernameValidator.class)
public @interface Username {
@Constraint(validatedBy = NicknameValidator.class)
public @interface Nickname {
String message() default "{org.whispersystems.textsecuregcm.util.Username.message}";
String message() default "{org.whispersystems.textsecuregcm.util.Nickname.message}";
Class<?>[] groups() default { };

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}