mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-27 00:33:21 +01:00
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:
@@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.anyLong;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
@@ -23,6 +24,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||
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.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||
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.RegistrationLockFailure;
|
||||
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.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
@@ -138,6 +143,7 @@ class AccountControllerTest {
|
||||
private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class);
|
||||
private static RateLimiter autoBlockLimiter = 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 TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
|
||||
private static Account senderPinAccount = mock(Account.class);
|
||||
@@ -201,6 +207,7 @@ class AccountControllerTest {
|
||||
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
|
||||
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
|
||||
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
|
||||
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
|
||||
|
||||
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
|
||||
when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
|
||||
@@ -246,7 +253,7 @@ class AccountControllerTest {
|
||||
return account;
|
||||
});
|
||||
|
||||
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername"))
|
||||
when(accountsManager.setUsername(AuthHelper.VALID_ACCOUNT, "takenusername", null))
|
||||
.thenThrow(new UsernameNotAvailableException());
|
||||
|
||||
when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>) invocation -> {
|
||||
@@ -311,6 +318,7 @@ class AccountControllerTest {
|
||||
smsVoicePrefixLimiter,
|
||||
autoBlockLimiter,
|
||||
usernameSetLimiter,
|
||||
usernameLookupLimiter,
|
||||
smsSender,
|
||||
turnTokenGenerator,
|
||||
senderPinAccount,
|
||||
@@ -1660,25 +1668,29 @@ class AccountControllerTest {
|
||||
}
|
||||
|
||||
@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 =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/n00bkiller")
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.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.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetTakenUsername() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/takenusername")
|
||||
.target("/v1/accounts/username/")
|
||||
.request()
|
||||
.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);
|
||||
}
|
||||
@@ -1687,35 +1699,34 @@ class AccountControllerTest {
|
||||
void testSetInvalidUsername() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/pаypal")
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.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
|
||||
void testSetInvalidPrefixUsername() {
|
||||
void testSetInvalidPrefixUsername() throws JsonProcessingException {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/0n00bkiller")
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.text(""));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
.put(Entity.json(new UsernameRequest("0n00bkiller", null)));
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsernameBadAuth() {
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/n00bkiller")
|
||||
.target("/v1/accounts/username")
|
||||
.request()
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1935,4 +1946,43 @@ class AccountControllerTest {
|
||||
.head()
|
||||
.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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user