Compare commits

...

30 Commits

Author SHA1 Message Date
Ravi Khadiwala
4032ddd4fd Add reserve/confirm for usernames 2022-09-07 11:49:49 -05:00
Chris Eager
98c8dc05f1 Update to the latest version of the abusive message filter 2022-09-07 11:49:01 -05:00
Chris Eager
4c677ec2da Remove deprecated /v1/attachments 2022-09-07 11:48:16 -05:00
Chris Eager
c05692e417 Update deprecated CircuitBreakerConfig usage 2022-09-07 11:47:15 -05:00
Chris Eager
1e7aa89664 Update resilience4j to 1.7.0 2022-09-07 11:47:15 -05:00
gram-signal
ae1edf3c5c Remove experiment associated with auth1->auth2 rollout. 2022-08-31 12:10:46 -06:00
gram-signal
b17f41c3e8 Check if dashes work in dynamic configuration keys. 2022-08-29 15:51:37 -06:00
gram-signal
08db4ba54b Update authentication to use HKDF_SHA256. 2022-08-29 14:20:47 -06:00
gram-signal
cb6cc39679 Ignore null identity key. 2022-08-29 13:26:49 -06:00
Jon Chambers
b6bf6c994c Remove a spurious @Nullable annotation 2022-08-26 15:22:23 -04:00
Jon Chambers
3bb4709563 Add CLDR region as a dimension 2022-08-26 12:41:51 -04:00
Jon Chambers
b280c768a4 Allow signup captchas to target CLDR two-letter region codes 2022-08-26 12:41:51 -04:00
Chris Eager
d23e89fb9c Update micrometer to 1.9.3 2022-08-25 13:46:36 -07:00
Chris Eager
3a27bd0318 Update test dependencies 2022-08-25 13:40:46 -07:00
Chris Eager
616513edaf Remove unused jdbi dependency 2022-08-25 13:40:46 -07:00
Chris Eager
09a51020e9 Update stripe-java to 21.2.0 2022-08-25 13:40:46 -07:00
Chris Eager
cb8cb94d1a Update aws java v1 SDK to 1.12.287 2022-08-25 13:40:46 -07:00
Chris Eager
2440dc0089 Update netty to 4.1.79.Final 2022-08-25 13:40:46 -07:00
Chris Eager
2336eef333 Update aws java v2 SDK to 2.17.258 2022-08-25 13:40:46 -07:00
Chris Eager
a0e948627c Update jackson to 2.13.3 2022-08-25 13:40:46 -07:00
Chris Eager
88159af588 Update dropwizard to 2.0.32 2022-08-25 13:40:46 -07:00
Chris Eager
38b77bb550 Update libphonenumber to 8.12.54 2022-08-25 13:40:32 -07:00
Jon Chambers
e72d1d0b6f Stop reading attribute-based messages from the messages table 2022-08-22 13:37:39 -07:00
Ravi Khadiwala
1891622e69 Zero-pad discriminators less than initial width 2022-08-22 13:36:38 -07:00
Chris Eager
628a112b38 Include country code for verify failure 2022-08-19 12:21:05 -07:00
Jon Chambers
50f5d760c9 Use existing tagging tools for keepalive counters 2022-08-16 13:16:19 -07:00
Jon Chambers
7292a88ea3 Record table performance metrics around reported messages 2022-08-16 13:15:30 -07:00
Jon Chambers
07cb3ab576 Add a "sealed sender" dimension to the sent message counter 2022-08-16 13:11:12 -07:00
Chris Eager
27b749abbd Filter expired items from Dynamo 2022-08-16 13:09:47 -07:00
Chris Eager
27f67a077c Add metrics for report-verification-succeeded response 2022-08-16 13:08:16 -07:00
68 changed files with 1528 additions and 794 deletions

24
pom.xml
View File

@@ -41,31 +41,31 @@
</modules>
<properties>
<aws.sdk.version>1.12.154</aws.sdk.version>
<aws.sdk2.version>2.17.125</aws.sdk2.version>
<aws.sdk.version>1.12.287</aws.sdk.version>
<aws.sdk2.version>2.17.258</aws.sdk2.version>
<commons-codec.version>1.15</commons-codec.version>
<commons-csv.version>1.8</commons-csv.version>
<commons-io.version>2.9.0</commons-io.version>
<dropwizard.version>2.0.28</dropwizard.version>
<dropwizard.version>2.0.32</dropwizard.version>
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.version>
<gson.version>2.9.0</gson.version>
<guava.version>30.1.1-jre</guava.version>
<jackson.version>2.13.2.20220328</jackson.version>
<jackson.version>2.13.3</jackson.version>
<jaxb.version>2.3.1</jaxb.version>
<jedis.version>2.9.0</jedis.version>
<lettuce.version>6.1.9.RELEASE</lettuce.version>
<libphonenumber.version>8.12.50</libphonenumber.version>
<libphonenumber.version>8.12.54</libphonenumber.version>
<logstash.logback.version>7.0.1</logstash.logback.version>
<micrometer.version>1.5.3</micrometer.version>
<mockito.version>4.3.1</mockito.version>
<netty.version>4.1.65.Final</netty.version>
<micrometer.version>1.9.3</micrometer.version>
<mockito.version>4.7.0</mockito.version>
<netty.version>4.1.79.Final</netty.version>
<opentest4j.version>1.2.0</opentest4j.version>
<protobuf.version>3.19.4</protobuf.version>
<pushy.version>0.15.1</pushy.version>
<resilience4j.version>1.5.0</resilience4j.version>
<resilience4j.version>1.7.0</resilience4j.version>
<semver4j.version>3.1.0</semver4j.version>
<slf4j.version>1.7.30</slf4j.version>
<stripe.version>20.79.0</stripe.version>
<stripe.version>21.2.0</stripe.version>
<vavr.version>0.10.4</vavr.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -80,7 +80,7 @@
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.13.2.20220328</version>
<version>2.13.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -296,7 +296,7 @@
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.32.0</version>
<version>2.33.2</version>
<scope>test</scope>
<exclusions>
<exclusion>

View File

@@ -43,10 +43,6 @@
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-core</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-jdbi3</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-auth</artifactId>
@@ -118,19 +114,10 @@
<artifactId>logstash-logback-encoder</artifactId>
</dependency>
<dependency>
<groupId>org.jdbi</groupId>
<artifactId>jdbi3-core</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-core</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-jdbi3</artifactId>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-healthchecks</artifactId>
@@ -302,10 +289,6 @@
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>dynamodb-lock-client</artifactId>
@@ -428,14 +411,8 @@
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>DynamoDBLocal</artifactId>
<version>1.16.0</version>
<version>1.17.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>

View File

@@ -80,7 +80,6 @@ import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
@@ -136,7 +135,6 @@ import org.whispersystems.textsecuregcm.metrics.MicrometerRegistryManager;
import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge;
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
import org.whispersystems.textsecuregcm.metrics.OperatingSystemMemoryGauge;
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
import org.whispersystems.textsecuregcm.metrics.TrafficSource;
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
@@ -148,6 +146,7 @@ import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.push.FcmSender;
import org.whispersystems.textsecuregcm.push.MessageSender;
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
@@ -196,7 +195,7 @@ import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
@@ -338,7 +337,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDynamoDbTables().getAccounts().getScanPageSize());
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
config.getDynamoDbTables().getReservedUsernames().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getProfiles().getTableName());
@@ -448,7 +447,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
@@ -632,7 +631,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
final List<Object> commonControllers = Lists.newArrayList(
new AttachmentControllerV1(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getBucket()),
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()),
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock),

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.auth;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials;
import java.util.Optional;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
public class AccountAuthenticator extends BaseAccountAuthenticator implements

View File

@@ -4,7 +4,9 @@
*/
package org.whispersystems.textsecuregcm.auth;
import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.codec.binary.Hex;
import org.signal.libsignal.protocol.kdf.HKDF;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -12,10 +14,18 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
public class AuthenticationCredentials {
private static final String V2_PREFIX = "2.";
private final String hashedAuthenticationToken;
private final String salt;
public enum Version {
V1,
V2,
}
public static final Version CURRENT_VERSION = Version.V2;
public AuthenticationCredentials(String hashedAuthenticationToken, String salt) {
this.hashedAuthenticationToken = hashedAuthenticationToken;
this.salt = salt;
@@ -23,7 +33,20 @@ public class AuthenticationCredentials {
public AuthenticationCredentials(String authenticationToken) {
this.salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
this.hashedAuthenticationToken = getHashedValue(salt, authenticationToken);
this.hashedAuthenticationToken = getV2HashedValue(salt, authenticationToken);
}
@VisibleForTesting
public AuthenticationCredentials v1ForTesting(String authenticationToken) {
String salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
return new AuthenticationCredentials(getV1HashedValue(salt, authenticationToken), salt);
}
public Version getVersion() {
if (this.hashedAuthenticationToken.startsWith(V2_PREFIX)) {
return Version.V2;
}
return Version.V1;
}
public String getHashedAuthenticationToken() {
@@ -35,11 +58,14 @@ public class AuthenticationCredentials {
}
public boolean verify(String authenticationToken) {
String theirValue = getHashedValue(salt, authenticationToken);
final String theirValue = switch (getVersion()) {
case V1 -> getV1HashedValue(salt, authenticationToken);
case V2 -> getV2HashedValue(salt, authenticationToken);
};
return MessageDigest.isEqual(theirValue.getBytes(StandardCharsets.UTF_8), this.hashedAuthenticationToken.getBytes(StandardCharsets.UTF_8));
}
private static String getHashedValue(String salt, String token) {
private static String getV1HashedValue(String salt, String token) {
try {
return new String(Hex.encodeHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))));
} catch (NoSuchAlgorithmException e) {
@@ -47,4 +73,13 @@ public class AuthenticationCredentials {
}
}
private static final byte[] AUTH_TOKEN_HKDF_INFO = "authtoken".getBytes(StandardCharsets.UTF_8);
private static String getV2HashedValue(String salt, String token) {
byte[] secret = HKDF.deriveSecrets(
token.getBytes(StandardCharsets.UTF_8), // key
salt.getBytes(StandardCharsets.UTF_8), // salt
AUTH_TOKEN_HKDF_INFO,
32);
return V2_PREFIX + Hex.encodeHexString(secret);
}
}

View File

@@ -43,8 +43,8 @@ public class BaseAccountAuthenticator {
@VisibleForTesting
public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) {
this.accountsManager = accountsManager;
this.clock = clock;
this.accountsManager = accountsManager;
this.clock = clock;
}
static Pair<String, Long> getIdentifierAndDeviceId(final String basicUsername) {
@@ -104,9 +104,16 @@ public class BaseAccountAuthenticator {
}
}
if (device.get().getAuthenticationCredentials().verify(basicCredentials.getPassword())) {
AuthenticationCredentials deviceAuthenticationCredentials = device.get().getAuthenticationCredentials();
if (deviceAuthenticationCredentials.verify(basicCredentials.getPassword())) {
succeeded = true;
final Account authenticatedAccount = updateLastSeen(account.get(), device.get());
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
if (deviceAuthenticationCredentials.getVersion() != AuthenticationCredentials.CURRENT_VERSION) {
authenticatedAccount = accountsManager.updateDeviceAuthentication(
authenticatedAccount,
device.get(),
new AuthenticationCredentials(basicCredentials.getPassword())); // new credentials have current version
}
return Optional.of(new AuthenticatedAccount(
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
}
@@ -142,5 +149,4 @@ public class BaseAccountAuthenticator {
return account;
}
}

View File

@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.auth;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials;
import java.util.Optional;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements

View File

@@ -27,12 +27,17 @@ public class CircuitBreakerConfiguration {
@JsonProperty
@NotNull
@Min(1)
private int ringBufferSizeInHalfOpenState = 10;
private int permittedNumberOfCallsInHalfOpenState = 10;
@JsonProperty
@NotNull
@Min(1)
private int ringBufferSizeInClosedState = 100;
private int slidingWindowSize = 100;
@JsonProperty
@NotNull
@Min(1)
private int slidingWindowMinimumNumberOfCalls = 100;
@JsonProperty
@NotNull
@@ -47,28 +52,32 @@ public class CircuitBreakerConfiguration {
return failureRateThreshold;
}
public int getRingBufferSizeInHalfOpenState() {
return ringBufferSizeInHalfOpenState;
public int getPermittedNumberOfCallsInHalfOpenState() {
return permittedNumberOfCallsInHalfOpenState;
}
public int getRingBufferSizeInClosedState() {
return ringBufferSizeInClosedState;
public int getSlidingWindowSize() {
return slidingWindowSize;
}
public int getSlidingWindowMinimumNumberOfCalls() {
return slidingWindowMinimumNumberOfCalls;
}
public long getWaitDurationInOpenStateInSeconds() {
return waitDurationInOpenStateInSeconds;
}
public List<Class> getIgnoredExceptions() {
return ignoredExceptions.stream()
.map(name -> {
try {
return Class.forName(name);
} catch (final ClassNotFoundException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
public List<Class<?>> getIgnoredExceptions() {
return ignoredExceptions.stream()
.map(name -> {
try {
return Class.forName(name);
} catch (final ClassNotFoundException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
}
@VisibleForTesting
@@ -77,13 +86,18 @@ public class CircuitBreakerConfiguration {
}
@VisibleForTesting
public void setRingBufferSizeInClosedState(int size) {
this.ringBufferSizeInClosedState = size;
public void setSlidingWindowSize(int size) {
this.slidingWindowSize = size;
}
@VisibleForTesting
public void setRingBufferSizeInHalfOpenState(int size) {
this.ringBufferSizeInHalfOpenState = size;
public void setSlidingWindowMinimumNumberOfCalls(int size) {
this.slidingWindowMinimumNumberOfCalls = size;
}
@VisibleForTesting
public void setPermittedNumberOfCallsInHalfOpenState(int size) {
this.permittedNumberOfCallsInHalfOpenState = size;
}
@VisibleForTesting
@@ -98,11 +112,12 @@ public class CircuitBreakerConfiguration {
public CircuitBreakerConfig toCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(getFailureRateThreshold())
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
.ringBufferSizeInHalfOpenState(getRingBufferSizeInHalfOpenState())
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
.ringBufferSizeInClosedState(getRingBufferSizeInClosedState())
.build();
.failureRateThreshold(getFailureRateThreshold())
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
.permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState())
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
.slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(),
CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.build();
}
}

View File

@@ -62,6 +62,9 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
@@ -137,6 +140,10 @@ public class RateLimitsConfiguration {
return usernameSet;
}
public RateLimitConfiguration getUsernameReserve() {
return usernameReserve;
}
public RateLimitConfiguration getCheckAccountExistence() {
return checkAccountExistence;
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.validation.constraints.Min;
import java.time.Duration;
public class UsernameConfiguration {
@@ -22,6 +23,9 @@ public class UsernameConfiguration {
@Min(1)
private int attemptsPerWidth = 10;
@JsonProperty
private Duration reservationTtl = Duration.ofMinutes(5);
public int getDiscriminatorInitialWidth() {
return discriminatorInitialWidth;
}
@@ -33,4 +37,8 @@ public class UsernameConfiguration {
public int getAttemptsPerWidth() {
return attemptsPerWidth;
}
public Duration getReservationTtl() {
return reservationTtl;
}
}

View File

@@ -21,6 +21,14 @@ public class DynamicCaptchaConfiguration {
@NotNull
private Set<String> signupCountryCodes = Collections.emptySet();
@JsonProperty
@NotNull
private Set<String> signupRegions = Collections.emptySet();
public BigDecimal getScoreFloor() {
return scoreFloor;
}
public Set<String> getSignupCountryCodes() {
return signupCountryCodes;
}
@@ -30,7 +38,12 @@ public class DynamicCaptchaConfiguration {
this.signupCountryCodes = numbers;
}
public BigDecimal getScoreFloor() {
return scoreFloor;
@VisibleForTesting
public void setSignupRegions(final Set<String> signupRegions) {
this.signupRegions = signupRegions;
}
public Set<String> getSignupRegions() {
return signupRegions;
}
}

View File

@@ -68,11 +68,14 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
@@ -92,6 +95,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
import org.whispersystems.textsecuregcm.util.Hex;
@@ -128,6 +132,7 @@ public class AccountController {
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
private static final String REGION_TAG_NAME = "region";
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
@@ -232,11 +237,11 @@ public class AccountController {
if (requirement.isCaptchaRequired()) {
captchaRequiredMeter.mark();
final Tags tags = Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)));
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, tags).increment();
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number))))
.increment();
if (requirement.isAutoBlock() && shouldAutoBlock(sourceHost)) {
logger.info("Auto-block: {}", sourceHost);
@@ -323,15 +328,13 @@ public class AccountController {
// TODO Remove this meter when external dependencies have been resolved
metricRegistry.meter(name(AccountController.class, "create", Util.getCountryCode(number))).mark();
{
final List<Tag> tags = new ArrayList<>();
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)));
tags.add(Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport));
tags.add(UserAgentTagUtil.getPlatformTag(userAgent));
tags.add(Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(enrolledInVerifyExperiment)));
Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, tags).increment();
}
Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport),
Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(enrolledInVerifyExperiment))))
.increment();
return Response.ok().build();
}
@@ -364,7 +367,8 @@ public class AccountController {
}
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
.ifPresent(smsSender::reportVerificationSucceeded);
.ifPresent(
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "registration"));
Optional<Account> existingAccount = accounts.getByE164(number);
@@ -381,16 +385,13 @@ public class AccountController {
Account account = accounts.create(number, password, signalAgent, accountAttributes,
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
{
metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark();
metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark();
final List<Tag> tags = new ArrayList<>();
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)));
tags.add(UserAgentTagUtil.getPlatformTag(userAgent));
tags.add(Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(storedVerificationCode.get().getTwilioVerificationSid().isPresent())));
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, tags).increment();
}
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(storedVerificationCode.get().getTwilioVerificationSid().isPresent()))))
.increment();
return new AccountIdentityResponse(account.getUuid(),
account.getNumber(),
@@ -403,7 +404,9 @@ public class AccountController {
@PUT
@Path("/number")
@Produces(MediaType.APPLICATION_JSON)
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount, @NotNull @Valid final ChangePhoneNumberRequest request)
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
@NotNull @Valid final ChangePhoneNumberRequest request,
@HeaderParam("User-Agent") String userAgent)
throws RateLimitExceededException, InterruptedException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {
if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) {
@@ -426,7 +429,8 @@ public class AccountController {
}
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
.ifPresent(smsSender::reportVerificationSucceeded);
.ifPresent(
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "changeNumber"));
final Optional<Account> existingAccount = accounts.getByE164(number);
@@ -642,6 +646,52 @@ public class AccountController {
accounts.clearUsername(auth.getAccount());
}
@Timed
@PUT
@Path("/username/reserved")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public ReserveUsernameResponse reserveUsername(@Auth AuthenticatedAccount auth,
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid ReserveUsernameRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
try {
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
auth.getAccount(),
usernameRequest.nickname()
);
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
} catch (final UsernameNotAvailableException e) {
throw new WebApplicationException(Status.CONFLICT);
}
}
@Timed
@PUT
@Path("/username/confirm")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public UsernameResponse confirmUsername(@Auth AuthenticatedAccount auth,
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid ConfirmUsernameRequest confirmRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
try {
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
return account
.getUsername()
.map(UsernameResponse::new)
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
} catch (final UsernameReservationNotFoundException e) {
throw new WebApplicationException(Status.CONFLICT);
} catch (final UsernameNotAvailableException e) {
throw new WebApplicationException(Status.GONE);
}
}
@Timed
@PUT
@Path("/username")
@@ -652,14 +702,7 @@ public class AccountController {
@HeaderParam("X-Signal-Agent") String userAgent,
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
if (StringUtils.isNotBlank(usernameRequest.existingUsername()) &&
!UsernameGenerator.isStandardFormat(usernameRequest.existingUsername())) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
checkUsername(usernameRequest.existingUsername(), userAgent);
try {
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
@@ -688,15 +731,10 @@ public class AccountController {
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);
checkUsername(username, userAgent);
return accounts
.getByUsername(username)
.map(Account::getUuid)
@@ -772,16 +810,17 @@ public class AccountController {
}
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
if (captchaToken.isPresent()) {
boolean validToken = recaptchaClient.verify(captchaToken.get(), sourceHost);
{
final List<Tag> tags = new ArrayList<>();
tags.add(Tag.of("success", String.valueOf(validToken)));
tags.add(UserAgentTagUtil.getPlatformTag(userAgent));
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode));
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, tags).increment();
}
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of("success", String.valueOf(validToken)),
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region)))
.increment();
if (validToken) {
return new CaptchaRequirement(false, false);
@@ -793,6 +832,7 @@ public class AccountController {
{
final List<Tag> tags = new ArrayList<>();
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode));
tags.add(Tag.of(REGION_TAG_NAME, region));
try {
if (pushChallenge.isPresent()) {
@@ -818,7 +858,9 @@ public class AccountController {
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
.getCaptchaConfiguration();
boolean countryFiltered = captchaConfig.getSignupCountryCodes().contains(countryCode);
boolean countryFiltered = captchaConfig.getSignupCountryCodes().contains(countryCode) ||
captchaConfig.getSignupRegions().contains(region);
if (abusiveHostRules.isBlocked(sourceHost)) {
blockedHostMeter.mark();
@@ -862,6 +904,15 @@ public class AccountController {
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
}
private void checkUsername(final String username, final String userAgent) {
if (StringUtils.isNotBlank(username) && !UsernameGenerator.isStandardFormat(username)) {
// Technically, a username may not be in the nickname#discriminator format
// if created through some out-of-band mechanism, but it is atypical.
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
}
}
private boolean shouldAutoBlock(String sourceHost) {
try {
rateLimiters.getAutoBlockLimiter().validate(sourceHost);

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.util.Conversions;
import java.security.SecureRandom;
public class AttachmentControllerBase {
protected long generateAttachmentId() {
byte[] attachmentBytes = new byte[8];
new SecureRandom().nextBytes(attachmentBytes);
attachmentBytes[0] = (byte)(attachmentBytes[0] & 0x7F);
return Conversions.byteArrayToLong(attachmentBytes);
}
}

View File

@@ -1,66 +0,0 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import com.amazonaws.HttpMethod;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import java.net.URL;
import java.util.stream.Stream;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1;
import org.whispersystems.textsecuregcm.entities.AttachmentUri;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
@Path("/v1/attachments")
public class AttachmentControllerV1 extends AttachmentControllerBase {
@SuppressWarnings("unused")
private final Logger logger = LoggerFactory.getLogger(AttachmentControllerV1.class);
private static final String[] UNACCELERATED_REGIONS = {"+20", "+971", "+968", "+974"};
private final RateLimiters rateLimiters;
private final UrlSigner urlSigner;
public AttachmentControllerV1(RateLimiters rateLimiters, String accessKey, String accessSecret, String bucket) {
this.rateLimiters = rateLimiters;
this.urlSigner = new UrlSigner(accessKey, accessSecret, bucket);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
public AttachmentDescriptorV1 allocateAttachment(@Auth AuthenticatedAccount auth) throws RateLimitExceededException {
rateLimiters.getAttachmentLimiter().validate(auth.getAccount().getUuid());
long attachmentId = generateAttachmentId();
URL url = urlSigner.getPreSignedUrl(attachmentId, HttpMethod.PUT,
Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> auth.getAccount().getNumber().startsWith(region)));
return new AttachmentDescriptorV1(attachmentId, url.toExternalForm());
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/{attachmentId}")
public AttachmentUri redirectToAttachment(@Auth AuthenticatedAccount auth,
@PathParam("attachmentId") long attachmentId) {
return new AttachmentUri(urlSigner.getPreSignedUrl(attachmentId, HttpMethod.GET,
Stream.of(UNACCELERATED_REGIONS).anyMatch(region -> auth.getAccount().getNumber().startsWith(region))));
}
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth;
import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import javax.ws.rs.GET;
@@ -19,19 +20,21 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.util.Conversions;
import org.whispersystems.textsecuregcm.util.Pair;
@Path("/v2/attachments")
public class AttachmentControllerV2 extends AttachmentControllerBase {
public class AttachmentControllerV2 {
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final RateLimiter rateLimiter;
private final PolicySigner policySigner;
private final RateLimiter rateLimiter;
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policySigner = new PolicySigner(accessSecret, region);
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region,
String bucket) {
this.rateLimiter = rateLimiters.getAttachmentLimiter();
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policySigner = new PolicySigner(accessSecret, region);
}
@Timed
@@ -54,5 +57,12 @@ public class AttachmentControllerV2 extends AttachmentControllerBase {
policy.second(), signature);
}
private long generateAttachmentId() {
byte[] attachmentBytes = new byte[8];
new SecureRandom().nextBytes(attachmentBytes);
attachmentBytes[0] = (byte) (attachmentBytes[0] & 0x7F);
return Conversions.byteArrayToLong(attachmentBytes);
}
}

View File

@@ -29,7 +29,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@Path("/v3/attachments")
public class AttachmentControllerV3 extends AttachmentControllerBase {
public class AttachmentControllerV3 {
@Nonnull
private final RateLimiter rateLimiter;

View File

@@ -13,9 +13,11 @@ import io.micrometer.core.instrument.Metrics;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import io.micrometer.core.instrument.Tags;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
@@ -30,8 +32,7 @@ public class KeepAliveController {
private final ClientPresenceManager clientPresenceManager;
private static final String NO_LOCAL_SUBSCRIPTION_COUNTER_NAME = name(KeepAliveController.class, "noLocalSubscription");
private static final String NO_LOCAL_SUBSCRIPTION_PLATFORM_TAG_NAME = "platform";
private static final String NO_LOCAL_SUBSCRIPTION_COUNTER_NAME = name(KeepAliveController.class, "noLocalSubscription");
public KeepAliveController(final ClientPresenceManager clientPresenceManager) {
this.clientPresenceManager = clientPresenceManager;
@@ -50,15 +51,9 @@ public class KeepAliveController {
context.getClient().close(1000, "OK");
String platform;
try {
platform = UserAgentUtil.parseUserAgentString(context.getClient().getUserAgent()).getPlatform().name().toLowerCase();
} catch (UnrecognizedUserAgentException e) {
platform = "unknown";
}
Metrics.counter(NO_LOCAL_SUBSCRIPTION_COUNTER_NAME, NO_LOCAL_SUBSCRIPTION_PLATFORM_TAG_NAME, platform).increment();
Metrics.counter(NO_LOCAL_SUBSCRIPTION_COUNTER_NAME,
Tags.of(UserAgentTagUtil.getPlatformTag(context.getClient().getUserAgent())))
.increment();
}
}

View File

@@ -388,6 +388,7 @@ public class ProfileController {
private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,
Collection<BatchIdentityCheckResponse.Element> responseElements, MessageDigest md) {
accountsManager.getByAccountIdentifier(element.aci()).ifPresent(account -> {
if (account.getIdentityKey() == null) return;
byte[] identityKeyBytes;
try {
identityKeyBytes = Base64.getDecoder().decode(account.getIdentityKey());

View File

@@ -1,39 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
public class AttachmentDescriptorV1 {
@JsonProperty
private long id;
@JsonProperty
private String idString;
@JsonProperty
private String location;
public AttachmentDescriptorV1(long id, String location) {
this.id = id;
this.idString = String.valueOf(id);
this.location = location;
}
public AttachmentDescriptorV1() {}
public long getId() {
return id;
}
public String getLocation() {
return location;
}
public String getIdString() {
return idString;
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.UUID;
public record ConfirmUsernameRequest(@NotBlank String usernameToConfirm, @NotNull UUID reservationToken) {}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import org.whispersystems.textsecuregcm.util.Nickname;
import javax.validation.Valid;
public record ReserveUsernameRequest(@Valid @Nickname String nickname) {}

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import java.util.UUID;
public record ReserveUsernameResponse(String username, UUID reservationToken) {}

View File

@@ -33,6 +33,8 @@ public class RateLimiters {
private final RateLimiter usernameLookupLimiter;
private final RateLimiter usernameSetLimiter;
private final RateLimiter usernameReserveLimiter;
private final RateLimiter checkAccountExistenceLimiter;
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
@@ -108,6 +110,11 @@ public class RateLimiters {
config.getUsernameSet().getBucketSize(),
config.getUsernameSet().getLeakRatePerMinute());
this.usernameReserveLimiter = new RateLimiter(cacheCluster, "usernameReserve",
config.getUsernameReserve().getBucketSize(),
config.getUsernameReserve().getLeakRatePerMinute());
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
config.getCheckAccountExistence().getBucketSize(),
config.getCheckAccountExistence().getLeakRatePerMinute());
@@ -185,6 +192,10 @@ public class RateLimiters {
return usernameSetLimiter;
}
public RateLimiter getUsernameReserveLimiter() {
return usernameReserveLimiter;
}
public RateLimiter getCheckAccountExistenceLimiter() {
return checkAccountExistenceLimiter;
}

View File

@@ -39,6 +39,7 @@ public class MessageSender {
private static final String EPHEMERAL_TAG_NAME = "ephemeral";
private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline";
private static final String URGENT_TAG_NAME = "urgent";
private static final String SEALED_SENDER_TAG_NAME = "sealedSender";
public MessageSender(ClientPresenceManager clientPresenceManager,
MessagesManager messagesManager,
@@ -99,7 +100,8 @@ public class MessageSender {
CHANNEL_TAG_NAME, channel,
EPHEMERAL_TAG_NAME, String.valueOf(online),
CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent),
URGENT_TAG_NAME, String.valueOf(message.getUrgent()))
URGENT_TAG_NAME, String.valueOf(message.getUrgent()),
SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceUuid()))
.increment();
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.s3;
import com.amazonaws.HttpMethod;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.S3ClientOptions;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import java.net.URL;
import java.util.Date;
public class UrlSigner {
private static final long DURATION = 60 * 60 * 1000;
private final AWSCredentials credentials;
private final String bucket;
public UrlSigner(String accessKey, String accessSecret, String bucket) {
this.credentials = new BasicAWSCredentials(accessKey, accessSecret);
this.bucket = bucket;
}
public URL getPreSignedUrl(long attachmentId, HttpMethod method, boolean unaccelerated) {
AmazonS3 client = new AmazonS3Client(credentials);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, String.valueOf(attachmentId), method);
request.setExpiration(new Date(System.currentTimeMillis() + DURATION));
request.setContentType("application/octet-stream");
if (unaccelerated) {
client.setS3ClientOptions(S3ClientOptions.builder().setPathStyleAccess(true).build());
} else {
client.setS3ClientOptions(S3ClientOptions.builder().setAccelerateModeEnabled(true).build());
}
return client.generatePresignedUrl(request);
}
}

View File

@@ -9,6 +9,7 @@ import java.util.List;
import java.util.Locale.LanguageRange;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nullable;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public class SmsSender {
@@ -32,7 +33,8 @@ public class SmsSender {
twilioSender.deliverVoxVerification(destination, verificationCode, languageRanges);
}
public CompletableFuture<Optional<String>> deliverSmsVerificationWithTwilioVerify(String destination, Optional<String> clientType,
public CompletableFuture<Optional<String>> deliverSmsVerificationWithTwilioVerify(String destination,
Optional<String> clientType,
String verificationCode, List<LanguageRange> languageRanges) {
// Fix up mexico numbers to 'mobile' format just for SMS delivery.
if (destination.startsWith("+52") && !destination.startsWith("+521")) {
@@ -42,13 +44,14 @@ public class SmsSender {
return twilioSender.deliverSmsVerificationWithVerify(destination, clientType, verificationCode, languageRanges);
}
public CompletableFuture<Optional<String>> deliverVoxVerificationWithTwilioVerify(String destination, String verificationCode,
public CompletableFuture<Optional<String>> deliverVoxVerificationWithTwilioVerify(String destination,
String verificationCode,
List<LanguageRange> languageRanges) {
return twilioSender.deliverVoxVerificationWithVerify(destination, verificationCode, languageRanges);
return twilioSender.deliverVoxVerificationWithVerify(destination, verificationCode, languageRanges);
}
public void reportVerificationSucceeded(String verificationSid) {
twilioSender.reportVerificationSucceeded(verificationSid);
public void reportVerificationSucceeded(String verificationSid, @Nullable String userAgent, String context) {
twilioSender.reportVerificationSucceeded(verificationSid, userAgent, context);
}
}

View File

@@ -133,7 +133,8 @@ public class TwilioSmsSender {
smsMeter.mark();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(this::parseResponse).handle(this::processResponse);
.thenApply(this::parseResponse)
.handle((response, throwable) -> processResponse(response, throwable, destination));
}
private String getBodyFormatString(@Nonnull String destination, @Nullable String clientType) {
@@ -198,26 +199,29 @@ public class TwilioSmsSender {
voxMeter.mark();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(this::parseResponse)
.handle(this::processResponse);
.thenApply(this::parseResponse)
.handle((response, throwable) -> processResponse(response, throwable, destination));
}
private String getRandom(Random random, List<String> elements) {
return elements.get(random.nextInt(elements.size()));
}
private boolean processResponse(TwilioResponse response, Throwable throwable) {
private boolean processResponse(TwilioResponse response, Throwable throwable, String destination) {
if (response != null && response.isSuccess()) {
priceMeter.mark((long) (response.successResponse.price * 1000));
return true;
} else if (response != null && response.isFailure()) {
logger.debug("Twilio request failed: " + response.failureResponse.status + "(code " + response.failureResponse.code + "), " + response.failureResponse.message);
Metrics.counter(FAILED_REQUEST_COUNTER_NAME,
SERVICE_NAME_TAG, "classic",
STATUS_CODE_TAG_NAME, String.valueOf(response.failureResponse.status),
ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code)).increment();
logger.info("Failed with code={}, country={}",
response.failureResponse.code,
Util.getCountryCode(destination));
return false;
} else if (throwable != null) {
logger.info("Twilio request failed", throwable);
@@ -246,23 +250,27 @@ public class TwilioSmsSender {
}
}
public CompletableFuture<Optional<String>> deliverSmsVerificationWithVerify(String destination, Optional<String> clientType, String verificationCode, List<LanguageRange> languageRanges) {
public CompletableFuture<Optional<String>> deliverSmsVerificationWithVerify(String destination,
Optional<String> clientType, String verificationCode, List<LanguageRange> languageRanges) {
smsMeter.mark();
return twilioVerifySender.deliverSmsVerificationWithVerify(destination, clientType, verificationCode, languageRanges);
return twilioVerifySender.deliverSmsVerificationWithVerify(destination, clientType, verificationCode,
languageRanges);
}
public CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination, String verificationCode, List<LanguageRange> languageRanges) {
public CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination,
String verificationCode, List<LanguageRange> languageRanges) {
voxMeter.mark();
return twilioVerifySender.deliverVoxVerificationWithVerify(destination, verificationCode, languageRanges);
}
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid) {
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid, @Nullable String userAgent,
String context) {
return twilioVerifySender.reportVerificationSucceeded(verificationSid);
return twilioVerifySender.reportVerificationSucceeded(verificationSid, userAgent, context);
}
public static class TwilioResponse {

View File

@@ -1,7 +1,12 @@
package org.whispersystems.textsecuregcm.sms;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
@@ -14,13 +19,14 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nullable;
import javax.validation.constraints.NotEmpty;
import io.micrometer.core.instrument.Metrics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.Util;
@@ -29,6 +35,13 @@ class TwilioVerifySender {
private static final Logger logger = LoggerFactory.getLogger(TwilioVerifySender.class);
private static final String VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME = name(TwilioVerifySender.class,
"verificationSucceeded");
private static final String CONTEXT_TAG_NAME = "context";
private static final String STATUS_CODE_TAG_NAME = "statusCode";
private static final String ERROR_CODE_TAG_NAME = "errorCode";
static final Set<String> TWILIO_VERIFY_LANGUAGES = Set.of(
"af",
"ar",
@@ -99,7 +112,7 @@ class TwilioVerifySender {
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(this::parseResponse)
.handle(this::extractVerifySid);
.handle((response, throwable) -> extractVerifySid(response, throwable, destination));
}
private Optional<String> findBestLocale(List<LanguageRange> priorityList) {
@@ -124,18 +137,19 @@ class TwilioVerifySender {
}
}
CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination, String verificationCode,
List<LanguageRange> languageRanges) {
CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination,
String verificationCode, List<LanguageRange> languageRanges) {
HttpRequest request = buildVerifyRequest("call", destination, verificationCode, findBestLocale(languageRanges),
Optional.empty());
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(this::parseResponse)
.handle(this::extractVerifySid);
.handle((response, throwable) -> extractVerifySid(response, throwable, destination));
}
private Optional<String> extractVerifySid(TwilioVerifyResponse twilioVerifyResponse, Throwable throwable) {
private Optional<String> extractVerifySid(TwilioVerifyResponse twilioVerifyResponse, Throwable throwable,
String destination) {
if (throwable != null) {
logger.warn("Failed to send Twilio request", throwable);
@@ -148,6 +162,10 @@ class TwilioVerifySender {
TwilioSmsSender.STATUS_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.status),
TwilioSmsSender.ERROR_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.code)).increment();
logger.info("Failed with code={}, country={}",
twilioVerifyResponse.failureResponse.code,
Util.getCountryCode(destination));
return Optional.empty();
}
@@ -170,11 +188,13 @@ class TwilioVerifySender {
.uri(verifyServiceUri)
.POST(FormDataBodyPublisher.of(requestParameters))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
.header("Authorization",
"Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
.build();
}
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid) {
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid, @Nullable String userAgent,
String context) {
final Map<String, String> requestParameters = new HashMap<>();
requestParameters.put("Status", "approved");
@@ -183,14 +203,47 @@ class TwilioVerifySender {
.uri(verifyApprovalBaseUri.resolve(verificationSid))
.POST(FormDataBodyPublisher.of(requestParameters))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
.header("Authorization",
"Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(this::parseResponse)
.handle((response, throwable) -> throwable == null
&& response.isSuccess()
&& "approved".equals(response.successResponse.getStatus()));
.handle((response, throwable) -> processVerificationSucceededResponse(response, throwable, userAgent, context));
}
private boolean processVerificationSucceededResponse(@Nullable final TwilioVerifyResponse response,
@Nullable final Throwable throwable,
final String userAgent,
final String context) {
if (throwable == null) {
assert response != null;
final Tags tags = Tags.of(Tag.of(CONTEXT_TAG_NAME, context), UserAgentTagUtil.getPlatformTag(userAgent));
if (response.isSuccess() && "approved".equals(response.successResponse.getStatus())) {
// the other possible values of `status` are `pending` or `canceled`, but these can never happen in a response
// to this POST, so we dont consider them
Metrics.counter(VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME, tags)
.increment();
return true;
}
// at this point, response.isFailure() == true
Metrics.counter(
VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME,
Tags.of(ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code),
STATUS_CODE_TAG_NAME, String.valueOf(response.failureResponse.status))
.and(tags))
.increment();
} else {
logger.warn("Failed to send verification succeeded", throwable);
}
return false;
}
public static class TwilioVerifyResponse {

View File

@@ -42,6 +42,10 @@ public class Account {
@Nullable
private String username;
@JsonProperty
@Nullable
private byte[] reservedUsernameHash;
@JsonProperty
private List<Device> devices = new ArrayList<>();
@@ -133,6 +137,18 @@ public class Account {
this.username = username;
}
public Optional<byte[]> getReservedUsernameHash() {
requireNotStale();
return Optional.ofNullable(reservedUsernameHash);
}
public void setReservedUsernameHash(final byte[] reservedUsernameHash) {
requireNotStale();
this.reservedUsernameHash = reservedUsernameHash;
}
public void addDevice(Device device) {
requireNotStale();

View File

@@ -13,6 +13,10 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -70,7 +74,10 @@ public class Accounts extends AbstractDynamoDbStore {
static final String ATTR_USERNAME = "N";
// unidentified access key; byte[] or null
static final String ATTR_UAK = "UAK";
// time to live; number
static final String ATTR_TTL = "TTL";
private final Clock clock;
private final DynamoDbClient client;
private final DynamoDbAsyncClient asyncClient;
@@ -81,9 +88,12 @@ public class Accounts extends AbstractDynamoDbStore {
private final int scanPageSize;
private static final byte RESERVED_USERNAME_HASH_VERSION = 1;
private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, "create"));
private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "changeNumber"));
private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "setUsername"));
private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername"));
private static final Timer CLEAR_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "clearUsername"));
private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update"));
private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
@@ -96,13 +106,16 @@ public class Accounts extends AbstractDynamoDbStore {
private static final Logger log = LoggerFactory.getLogger(Accounts.class);
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
@VisibleForTesting
public Accounts(
final Clock clock,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
String accountsTableName, String phoneNumberConstraintTableName,
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
final int scanPageSize) {
super(client);
this.clock = clock;
this.client = client;
this.asyncClient = asyncClient;
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
@@ -112,6 +125,16 @@ public class Accounts extends AbstractDynamoDbStore {
this.scanPageSize = scanPageSize;
}
public Accounts(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
String accountsTableName, String phoneNumberConstraintTableName,
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
final int scanPageSize) {
this(Clock.systemUTC(), dynamicConfigurationManager, client, asyncClient, accountsTableName,
phoneNumberConstraintTableName, phoneNumberIdentifierConstraintTableName, usernamesConstraintTableName,
scanPageSize);
}
public boolean create(Account account) {
return CREATE_TIMER.record(() -> {
@@ -331,33 +354,150 @@ public class Accounts extends AbstractDynamoDbStore {
});
}
public static byte[] reservedUsernameHash(final UUID accountId, final String reservedUsername) {
final MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
final ByteBuffer byteBuffer = ByteBuffer.allocate(32 + 1);
sha256.update(reservedUsername.getBytes(StandardCharsets.UTF_8));
sha256.update(UUIDUtil.toBytes(accountId));
byteBuffer.put(RESERVED_USERNAME_HASH_VERSION);
byteBuffer.put(sha256.digest());
return byteBuffer.array();
}
/**
* Set the account username
* Reserve a username under a token
*
* @param account to update
* @param username believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
*/
public void setUsername(final Account account, final String username)
throws ContestedOptimisticLockException {
public UUID reserveUsername(
final Account account,
final String reservedUsername,
final Duration ttl) {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
account.setUsername(username);
// if there is an existing old reservation it will be cleaned up via ttl
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
boolean succeeded = false;
long expirationTime = clock.instant().plus(ttl).getEpochSecond();
final UUID reservationToken = UUID.randomUUID();
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(reservationToken),
ATTR_USERNAME, AttributeValues.fromString(reservedUsername),
ATTR_TTL, AttributeValues.fromLong(expirationTime)))
.conditionExpression("attribute_not_exists(#username) OR (#ttl < :now)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL))
.expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond())))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
writeItems.add(
TransactWriteItem.builder()
.update(Update.builder()
.tableName(accountsTableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
.updateExpression("SET #data = :data ADD #version :version_increment")
.conditionExpression("#version = :version")
.expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, "#version", ATTR_VERSION))
.expressionAttributeValues(Map.of(
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
":version", AttributeValues.fromInt(account.getVersion()),
":version_increment", AttributeValues.fromInt(1)))
.build())
.build());
final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()
.transactItems(writeItems)
.build();
client.transactWriteItems(request);
account.setVersion(account.getVersion() + 1);
succeeded = true;
} catch (final JsonProcessingException e) {
throw new IllegalArgumentException(e);
} catch (final TransactionCanceledException e) {
if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch("ConditionalCheckFailed"::equals)) {
throw new ContestedOptimisticLockException();
}
throw e;
} finally {
if (!succeeded) {
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
}
RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
return reservationToken;
}
/**
* Confirm (set) a previously reserved username
*
* @param account to update
* @param username believed to be available
* @param reservationToken a token returned by the call to {@link #reserveUsername(Account, String, Duration)},
* only required if setting a reserved username
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
*/
public void confirmUsername(final Account account, final String username, final UUID reservationToken)
throws ContestedOptimisticLockException {
setUsername(account, username, Optional.of(reservationToken));
}
/**
* Set the account username
*
* @param account to update
* @param username believed to be available
* @throws ContestedOptimisticLockException if the account has been updated or the username taken by someone else
*/
public void setUsername(final Account account, final String username) throws ContestedOptimisticLockException {
setUsername(account, username, Optional.empty());
}
private void setUsername(final Account account, final String username, final Optional<UUID> reservationToken)
throws ContestedOptimisticLockException {
final long startNanos = System.nanoTime();
final Optional<String> maybeOriginalUsername = account.getUsername();
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
account.setUsername(username);
account.setReservedUsernameHash(null);
boolean succeeded = false;
try {
final List<TransactWriteItem> writeItems = new ArrayList<>();
// add the username to the constraint table, wiping out the ttl if we had already reserved the name
writeItems.add(TransactWriteItem.builder()
.put(Put.builder()
.tableName(usernamesConstraintTableName)
.item(Map.of(
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
ATTR_USERNAME, AttributeValues.fromString(username)))
.conditionExpression("attribute_not_exists(#username)")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME))
// it's not in the constraint table OR it's expired OR it was reserved by us
.conditionExpression("attribute_not_exists(#username) OR #ttl < :now OR #aci = :reservation ")
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID))
.expressionAttributeValues(Map.of(
":now", AttributeValues.fromLong(clock.instant().getEpochSecond()),
":reservation", AttributeValues.fromUUID(reservationToken.orElseGet(UUID::randomUUID))))
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
.build())
.build());
@@ -405,6 +545,7 @@ public class Accounts extends AbstractDynamoDbStore {
} finally {
if (!succeeded) {
account.setUsername(maybeOriginalUsername.orElse(null));
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
}
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
@@ -553,11 +694,29 @@ public class Accounts extends AbstractDynamoDbStore {
}
public boolean usernameAvailable(final String username) {
return usernameAvailable(Optional.empty(), username);
}
public boolean usernameAvailable(final Optional<UUID> reservationToken, final String username) {
final GetItemResponse response = client.getItem(GetItemRequest.builder()
.tableName(usernamesConstraintTableName)
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build());
return !response.hasItem();
if (!response.hasItem()) {
// username is free
return true;
}
final Map<String, AttributeValue> item = response.item();
if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) {
// username was reserved, but has expired
return true;
}
// username is reserved by us
return reservationToken
.map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals)
.orElse(false);
}
public Optional<Account> getByE164(String number) {
@@ -583,7 +742,10 @@ public class Accounts extends AbstractDynamoDbStore {
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
.build());
return Optional.ofNullable(response.item())
// ignore items with a ttl (reservations)
.filter(item -> !item.containsKey(ATTR_TTL))
.map(item -> item.get(KEY_ACCOUNT_UUID))
.map(this::accountByUuid)
.map(Accounts::fromItem);

View File

@@ -13,6 +13,7 @@ import com.codahale.metrics.Timer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import io.micrometer.core.instrument.Metrics;
@@ -20,6 +21,7 @@ import io.micrometer.core.instrument.Tags;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -85,7 +87,7 @@ public class AccountsManager {
private final DirectoryQueue directoryQueue;
private final Keys keys;
private final MessagesManager messagesManager;
private final ReservedUsernames reservedUsernames;
private final ProhibitedUsernames prohibitedUsernames;
private final ProfilesManager profilesManager;
private final StoredVerificationCodeManager pendingAccounts;
private final SecureStorageClient secureStorageClient;
@@ -127,7 +129,7 @@ public class AccountsManager {
final DirectoryQueue directoryQueue,
final Keys keys,
final MessagesManager messagesManager,
final ReservedUsernames reservedUsernames,
final ProhibitedUsernames prohibitedUsernames,
final ProfilesManager profilesManager,
final StoredVerificationCodeManager pendingAccounts,
final SecureStorageClient secureStorageClient,
@@ -148,7 +150,7 @@ public class AccountsManager {
this.secureStorageClient = secureStorageClient;
this.secureBackupClient = secureBackupClient;
this.clientPresenceManager = clientPresenceManager;
this.reservedUsernames = reservedUsernames;
this.prohibitedUsernames = prohibitedUsernames;
this.usernameGenerator = usernameGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.clock = Objects.requireNonNull(clock);
@@ -321,12 +323,112 @@ public class AccountsManager {
return updatedAccount.get();
}
public record UsernameReservation(Account account, String reservedUsername, UUID reservationToken){}
/**
* Generate a username from a nickname, and reserve it so no other accounts may take it.
*
* The reserved username can later be set with {@link #confirmReservedUsername(Account, String, UUID)}. The reservation
* will eventually expire, after which point confirmReservedUsername may fail if another account has taken the
* username.
*
* @param account the account to update
* @param requestedNickname the nickname to reserve a username for
* @return the reserved username and an updated Account object
* @throws UsernameNotAvailableException no username is available for the requested nickname
*/
public UsernameReservation reserveUsername(final Account account, final String requestedNickname) throws UsernameNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
redisDelete(account);
class Reserver implements AccountPersister {
UUID reservationToken;
String reservedUsername;
@Override
public void persistAccount(final Account account) throws UsernameNotAvailableException {
// In the future, this may also check for any forbidden discriminators
reservedUsername = usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable);
reservationToken = accounts.reserveUsername(
account,
reservedUsername,
usernameGenerator.getReservationTtl());
}
}
final Reserver reserver = new Reserver();
final Account updatedAccount = failableUpdateWithRetries(
account,
a -> true,
reserver,
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
return new UsernameReservation(updatedAccount, reserver.reservedUsername, reserver.reservationToken);
}
/**
* Set a username previously reserved with {@link #reserveUsername(Account, String)}
*
* @param account the account to update
* @param reservedUsername the previously reserved username
* @param reservationToken the UUID returned from the reservation
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if the reserved username has been taken (because the reservation expired)
* @throws UsernameReservationNotFoundException if `reservedUsername` was not reserved in the account
*/
public Account confirmReservedUsername(final Account account, final String reservedUsername, final UUID reservationToken) throws UsernameNotAvailableException, UsernameReservationNotFoundException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (account.getUsername().map(reservedUsername::equals).orElse(false)) {
// the client likely already succeeded and is retrying
return account;
}
final byte[] newHash = Accounts.reservedUsernameHash(account.getUuid(), reservedUsername);
if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, newHash)).orElse(false)) {
// no such reservation existed, either there was no previous call to reserveUsername
// or the reservation changed
throw new UsernameReservationNotFoundException();
}
redisDelete(account);
return failableUpdateWithRetries(
account,
a -> true,
a -> {
// though we know this username was reserved, the reservation could have lapsed
if (!accounts.usernameAvailable(Optional.of(reservationToken), reservedUsername)) {
throw new UsernameNotAvailableException();
}
accounts.confirmUsername(a, reservedUsername, reservationToken);
},
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
/**
* Sets a username generated from `requestedNickname` with no prior reservation
*
* @param account the account to update
* @param requestedNickname the nickname to generate a username from
* @param expectedOldUsername the expected existing username of the account (for replay detection)
* @return the updated account with the username field set
* @throws UsernameNotAvailableException if no free username could be set for `requestedNickname`
*/
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
throw new UsernameNotAvailableException();
}
if (reservedUsernames.isReserved(requestedNickname, account.getUuid())) {
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
throw new UsernameNotAvailableException();
}
@@ -345,7 +447,9 @@ public class AccountsManager {
account,
a -> true,
// In the future, this may also check for any forbidden discriminators
a -> accounts.setUsername(a, usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
a -> accounts.setUsername(
a,
usernameGenerator.generateAvailableUsername(requestedNickname, accounts::usernameAvailable)),
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
}
@@ -391,6 +495,16 @@ public class AccountsManager {
});
}
public Account updateDeviceAuthentication(final Account account, final Device device, final AuthenticationCredentials credentials) {
Preconditions.checkArgument(credentials.getVersion() == AuthenticationCredentials.CURRENT_VERSION);
return updateDevice(account, device.getId(), new Consumer<Device>() {
@Override
public void accept(final Device device) {
device.setAuthenticationCredentials(credentials);
}
});
}
/**
* @param account account to update
* @param updater must return {@code true} if the account was actually updated
@@ -528,7 +642,6 @@ public class AccountsManager {
public Optional<Account> getByUsername(final String username) {
try (final Timer.Context ignored = getByUsernameTimer.time()) {
Optional<Account> account = redisGetByUsername(username);
if (account.isEmpty()) {
account = accounts.getByUsername(username);
account.ifPresent(this::redisSet);

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.codahale.metrics.SharedMetricRegistries;
import org.jdbi.v3.core.Jdbi;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
import org.whispersystems.textsecuregcm.util.Constants;
import java.util.function.Consumer;
import java.util.function.Function;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
public class FaultTolerantDatabase {
private final Jdbi database;
private final CircuitBreaker circuitBreaker;
public FaultTolerantDatabase(String name, Jdbi database, CircuitBreakerConfiguration circuitBreakerConfiguration) {
this.database = database;
this.circuitBreaker = CircuitBreaker.of(name, circuitBreakerConfiguration.toCircuitBreakerConfig());
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME),
circuitBreaker,
FaultTolerantDatabase.class);
}
public void use(Consumer<Jdbi> consumer) {
this.circuitBreaker.executeRunnable(() -> consumer.accept(database));
}
public <T> T with(Function<Jdbi, T> consumer) {
return this.circuitBreaker.executeSupplier(() -> consumer.apply(database));
}
public Jdbi getDatabase() {
return database;
}
}

View File

@@ -11,8 +11,6 @@ import static io.micrometer.core.instrument.Metrics.timer;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import com.google.protobuf.InvalidProtocolBufferException;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.nio.ByteBuffer;
import java.time.Duration;
@@ -26,9 +24,7 @@ import javax.annotation.Nonnull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -51,26 +47,6 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
private static final String KEY_TTL = "E";
private static final String KEY_ENVELOPE_BYTES = "EB";
// TODO Stop reading messages by attribute value after DATE
@Deprecated
private static final String KEY_TYPE = "T";
@Deprecated
private static final String KEY_TIMESTAMP = "TS";
private static final String KEY_SOURCE_UUID = "SU";
@Deprecated
private static final String KEY_SOURCE_DEVICE = "SD";
@Deprecated
private static final String KEY_DESTINATION_UUID = "DU";
@Deprecated
private static final String KEY_UPDATED_PNI = "UP";
@Deprecated
private static final String KEY_CONTENT = "C";
private final Timer storeTimer = timer(name(getClass(), "store"));
private final Timer loadTimer = timer(name(getClass(), "load"));
private final Timer deleteByGuid = timer(name(getClass(), "delete", "guid"));
@@ -81,9 +57,6 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
private final String tableName;
private final Duration timeToLive;
private static final Counter GET_MESSAGE_WITH_ATTRIBUTES_COUNTER = Metrics.counter(name(MessagesDynamoDb.class, "loadMessage"), "format", "attributes");
private static final Counter GET_MESSAGE_WITH_ENVELOPE_COUNTER = Metrics.counter(name(MessagesDynamoDb.class, "loadMessage"), "format", "envelope");
private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);
public MessagesDynamoDb(DynamoDbClient dynamoDb, String tableName, Duration timeToLive) {
@@ -260,30 +233,8 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
@VisibleForTesting
static MessageProtos.Envelope convertItemToEnvelope(final Map<String, AttributeValue> item)
throws InvalidProtocolBufferException {
final MessageProtos.Envelope envelope;
if (item.containsKey(KEY_ENVELOPE_BYTES)) {
envelope = MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray());
GET_MESSAGE_WITH_ENVELOPE_COUNTER.increment();
} else {
final SortKey sortKey = convertSortKey(item.get(KEY_SORT).b().asByteArray());
final UUID messageUuid = convertLocalIndexMessageUuidSortKey(item.get(LOCAL_INDEX_MESSAGE_UUID_KEY_SORT).b().asByteArray());
final int type = AttributeValues.getInt(item, KEY_TYPE, 0);
final long timestamp = AttributeValues.getLong(item, KEY_TIMESTAMP, 0L);
final UUID sourceUuid = AttributeValues.getUUID(item, KEY_SOURCE_UUID, null);
final int sourceDevice = AttributeValues.getInt(item, KEY_SOURCE_DEVICE, 0);
final UUID destinationUuid = AttributeValues.getUUID(item, KEY_DESTINATION_UUID, null);
final byte[] content = AttributeValues.getByteArray(item, KEY_CONTENT, null);
final UUID updatedPni = AttributeValues.getUUID(item, KEY_UPDATED_PNI, null);
envelope = new OutgoingMessageEntity(messageUuid, type, timestamp, sourceUuid, sourceDevice, destinationUuid,
updatedPni, content, sortKey.getServerTimestamp(), true).toEnvelope();
GET_MESSAGE_WITH_ATTRIBUTES_COUNTER.increment();
}
return envelope;
return MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray());
}
private void deleteRowsMatchingQuery(AttributeValue partitionKey, QueryRequest querySpec) {
@@ -324,56 +275,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
return AttributeValues.fromByteBuffer(byteBuffer.flip());
}
private static SortKey convertSortKey(final byte[] bytes) {
if (bytes.length != 32) {
throw new IllegalArgumentException("unexpected sort key byte length");
}
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
final long destinationDeviceId = byteBuffer.getLong();
final long serverTimestamp = byteBuffer.getLong();
final long mostSigBits = byteBuffer.getLong();
final long leastSigBits = byteBuffer.getLong();
return new SortKey(destinationDeviceId, serverTimestamp, new UUID(mostSigBits, leastSigBits));
}
private static AttributeValue convertLocalIndexMessageUuidSortKey(final UUID messageUuid) {
return AttributeValues.fromUUID(messageUuid);
}
private static UUID convertLocalIndexMessageUuidSortKey(final byte[] bytes) {
return convertUuidFromBytes(bytes, "local index message uuid sort key");
}
private static UUID convertUuidFromBytes(final byte[] bytes, final String name) {
try {
return UUIDUtil.fromBytes(bytes);
} catch (final IllegalArgumentException e) {
throw new IllegalArgumentException("unexpected " + name + " byte length; was " + bytes.length + " but expected 16");
}
}
private static final class SortKey {
private final long destinationDeviceId;
private final long serverTimestamp;
private final UUID messageUuid;
public SortKey(long destinationDeviceId, long serverTimestamp, UUID messageUuid) {
this.destinationDeviceId = destinationDeviceId;
this.serverTimestamp = serverTimestamp;
this.messageUuid = messageUuid;
}
public long getDestinationDeviceId() {
return destinationDeviceId;
}
public long getServerTimestamp() {
return serverTimestamp;
}
public UUID getMessageUuid() {
return messageUuid;
}
}
}

View File

@@ -26,7 +26,7 @@ import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
public class ReservedUsernames {
public class ProhibitedUsernames {
private final DynamoDbClient dynamoDbClient;
private final String tableName;
@@ -44,17 +44,17 @@ public class ReservedUsernames {
static final String KEY_PATTERN = "P";
private static final String ATTR_RESERVED_FOR_UUID = "U";
private static final Timer IS_RESERVED_TIMER = Metrics.timer(name(ReservedUsernames.class, "isReserved"));
private static final Timer IS_PROHIBITED_TIMER = Metrics.timer(name(ProhibitedUsernames.class, "isProhibited"));
private static final Logger log = LoggerFactory.getLogger(ReservedUsernames.class);
private static final Logger log = LoggerFactory.getLogger(ProhibitedUsernames.class);
public ReservedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
public ProhibitedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
this.dynamoDbClient = dynamoDbClient;
this.tableName = tableName;
}
public boolean isReserved(final String nickname, final UUID accountIdentifier) {
return IS_RESERVED_TIMER.record(() -> {
public boolean isProhibited(final String nickname, final UUID accountIdentifier) {
return IS_PROHIBITED_TIMER.record(() -> {
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
.tableName(tableName)
.build());
@@ -80,7 +80,13 @@ public class ReservedUsernames {
});
}
public void reserveUsername(final String pattern, final UUID reservedFor) {
/**
* Prohibits username except for all accounts except `reservedFor`
*
* @param pattern pattern to prohibit
* @param reservedFor an account that is allowed to use names in the pattern
*/
public void prohibitUsername(final String pattern, final UUID reservedFor) {
dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(

View File

@@ -5,11 +5,11 @@
package org.whispersystems.textsecuregcm.storage;
import com.google.common.annotations.VisibleForTesting;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import com.google.common.annotations.VisibleForTesting;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
@@ -29,7 +29,8 @@ public class PushChallengeDynamoDb extends AbstractDynamoDbStore {
static final String ATTR_TTL = "T";
private static final Map<String, String> UUID_NAME_MAP = Map.of("#uuid", KEY_ACCOUNT_UUID);
private static final Map<String, String> CHALLENGE_TOKEN_NAME_MAP = Map.of("#challenge", ATTR_CHALLENGE_TOKEN);
private static final Map<String, String> CHALLENGE_TOKEN_NAME_MAP = Map.of("#challenge", ATTR_CHALLENGE_TOKEN, "#ttl",
ATTR_TTL);
public PushChallengeDynamoDb(final DynamoDbClient dynamoDB, final String tableName) {
this(dynamoDB, tableName, Clock.systemUTC());
@@ -87,9 +88,10 @@ public class PushChallengeDynamoDb extends AbstractDynamoDbStore {
db().deleteItem(DeleteItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountUuid)))
.conditionExpression("#challenge = :challenge")
.conditionExpression("#challenge = :challenge AND #ttl >= :currentTime")
.expressionAttributeNames(CHALLENGE_TOKEN_NAME_MAP)
.expressionAttributeValues(Map.of(":challenge", AttributeValues.fromByteArray(challengeToken)))
.expressionAttributeValues(Map.of(":challenge", AttributeValues.fromByteArray(challengeToken),
":currentTime", AttributeValues.fromLong(clock.instant().getEpochSecond())))
.build());
return true;
} catch (final ConditionalCheckFailedException e) {

View File

@@ -1,5 +1,7 @@
package org.whispersystems.textsecuregcm.storage;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
@@ -10,6 +12,8 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
public class ReportMessageDynamoDb {
static final String KEY_HASH = "H";
@@ -19,6 +23,9 @@ public class ReportMessageDynamoDb {
private final String tableName;
private final Duration ttl;
private static final String REMOVED_MESSAGE_COUNTER_NAME = name(ReportMessageDynamoDb.class, "removed");
private static final Timer REMOVED_MESSAGE_AGE_TIMER = Metrics.timer(name(ReportMessageDynamoDb.class, "removedMessageAge"));
public ReportMessageDynamoDb(final DynamoDbClient dynamoDB, final String tableName, final Duration ttl) {
this.db = dynamoDB;
this.tableName = tableName;
@@ -41,6 +48,22 @@ public class ReportMessageDynamoDb {
.key(Map.of(KEY_HASH, AttributeValues.fromByteArray(hash)))
.returnValues(ReturnValue.ALL_OLD)
.build());
return !deleteItemResponse.attributes().isEmpty();
final boolean found = !deleteItemResponse.attributes().isEmpty();
if (found) {
if (deleteItemResponse.attributes().containsKey(ATTR_TTL)) {
final Instant expiration =
Instant.ofEpochSecond(Long.parseLong(deleteItemResponse.attributes().get(ATTR_TTL).n()));
final Duration approximateAge = ttl.minus(Duration.between(Instant.now(), expiration));
REMOVED_MESSAGE_AGE_TIMER.record(approximateAge);
}
}
Metrics.counter(REMOVED_MESSAGE_COUNTER_NAME, "found", String.valueOf(found)).increment();
return found;
}
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
public class UsernameReservationNotFoundException extends Exception {
}

View File

@@ -5,10 +5,15 @@
package org.whispersystems.textsecuregcm.storage;
import static com.codahale.metrics.MetricRegistry.name;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
@@ -19,11 +24,6 @@ import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static com.codahale.metrics.MetricRegistry.name;
public class VerificationCodeStore {
@@ -83,7 +83,8 @@ public class VerificationCodeStore {
try {
return response.hasItem()
? Optional.of(SystemMapper.getMapper().readValue(response.item().get(ATTR_STORED_CODE).s(), StoredVerificationCode.class))
? filterMaybeExpiredCode(
SystemMapper.getMapper().readValue(response.item().get(ATTR_STORED_CODE).s(), StoredVerificationCode.class))
: Optional.empty();
} catch (final JsonProcessingException e) {
log.error("Failed to parse stored verification code", e);
@@ -92,6 +93,16 @@ public class VerificationCodeStore {
});
}
private Optional<StoredVerificationCode> filterMaybeExpiredCode(StoredVerificationCode storedVerificationCode) {
// It's possible for DynamoDB to return items after their expiration time (although it is very unlikely for small
// tables)
if (getExpirationTimestamp(storedVerificationCode) < Instant.now().getEpochSecond()) {
return Optional.empty();
}
return Optional.of(storedVerificationCode);
}
public void remove(final String number) {
removeTimer.record(() -> {
dynamoDbClient.deleteItem(DeleteItemRequest.builder()

View File

@@ -13,6 +13,7 @@ import io.micrometer.core.instrument.Metrics;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@@ -41,16 +42,21 @@ public class UsernameGenerator {
private final int initialWidth;
private final int discriminatorMaxWidth;
private final int attemptsPerWidth;
private final Duration reservationTtl;
public UsernameGenerator(UsernameConfiguration configuration) {
this(configuration.getDiscriminatorInitialWidth(), configuration.getDiscriminatorMaxWidth(), configuration.getAttemptsPerWidth());
this(configuration.getDiscriminatorInitialWidth(),
configuration.getDiscriminatorMaxWidth(),
configuration.getAttemptsPerWidth(),
configuration.getReservationTtl());
}
@VisibleForTesting
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth) {
public UsernameGenerator(int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth, final Duration reservationTtl) {
this.initialWidth = initialWidth;
this.discriminatorMaxWidth = discriminatorMaxWidth;
this.attemptsPerWidth = attemptsPerWidth;
this.reservationTtl = reservationTtl;
}
/**
@@ -70,7 +76,7 @@ public class UsernameGenerator {
// 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);
String username = fromParts(nickname, discriminator);
attempts++;
if (usernameAvailableFun.test(username)) {
DISCRIMINATOR_ATTEMPT_COUNTER.record(attempts);
@@ -101,11 +107,16 @@ public class UsernameGenerator {
/**
* Generate a username from a nickname and discriminator
*/
public static String fromParts(final String nickname, final int discriminator) throws IllegalArgumentException {
public String fromParts(final String nickname, final int discriminator) throws IllegalArgumentException {
if (!isValidNickname(nickname)) {
throw new IllegalArgumentException("Invalid nickname " + nickname);
}
return nickname + SEPARATOR + discriminator;
// zero pad discriminators less than the discriminator initial width
return String.format("%s#%0" + initialWidth + "d", nickname, discriminator);
}
public Duration getReservationTtl() {
return reservationTtl;
}
public static boolean isValidNickname(final String nickname) {

View File

@@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import org.apache.commons.lang3.StringUtils;
public class Util {
@@ -79,6 +80,15 @@ public class Util {
else return "0";
}
public static String getRegion(final String number) {
try {
final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);
return StringUtils.defaultIfBlank(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber), "ZZ");
} catch (final NumberParseException e) {
return "ZZ";
}
}
public static String getNumberPrefix(String number) {
String countryCode = getCountryCode(number);
int remaining = number.length() - (1 + countryCode.length());

View File

@@ -47,7 +47,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
@@ -152,7 +152,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -194,7 +194,7 @@ public class AssignUsernameCommand extends EnvironmentCommand<WhisperServerConfi
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, Clock.systemUTC());

View File

@@ -50,7 +50,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
@@ -154,7 +154,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -196,7 +196,7 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);

View File

@@ -10,7 +10,7 @@ import io.dropwizard.setup.Bootstrap;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import java.util.UUID;
@@ -44,7 +44,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(config.getDynamoDbClientConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
final ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
final ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
config.getDynamoDbTables().getReservedUsernames().getTableName());
final String pattern = namespace.getString("pattern").trim();
@@ -57,7 +57,7 @@ public class ReserveUsernameCommand extends ConfiguredCommand<WhisperServerConfi
final UUID aci = UUID.fromString(namespace.getString("uuid").trim());
reservedUsernames.reserveUsername(pattern, aci);
prohibitedUsernames.prohibitUsername(pattern, aci);
System.out.format("Reserved %s for account %s\n", pattern, aci);
}

View File

@@ -48,7 +48,7 @@ import org.whispersystems.textsecuregcm.storage.Profiles;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
@@ -157,7 +157,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
configuration.getDynamoDbTables().getProfiles().getTableName());
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
configuration.getDynamoDbTables().getReservedUsernames().getTableName());
Keys keys = new Keys(dynamoDbClient,
configuration.getDynamoDbTables().getKeys().getTableName());
@@ -197,7 +197,7 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
UsernameGenerator usernameGenerator = new UsernameGenerator(configuration.getUsername());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
experimentEnrollmentManager, clock);

View File

@@ -14,6 +14,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -30,6 +31,7 @@ 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.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
@@ -164,6 +166,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true);
@@ -171,6 +174,7 @@ class BaseAccountAuthenticatorTest {
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());;
}
@Test
@@ -192,6 +196,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + deviceId, password), true);
@@ -199,6 +204,7 @@ class BaseAccountAuthenticatorTest {
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());
}
@ParameterizedTest
@@ -221,6 +227,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(false);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), enabledRequired);
@@ -234,6 +241,37 @@ class BaseAccountAuthenticatorTest {
}
}
@Test
void testAuthenticateV1() {
final UUID uuid = UUID.randomUUID();
final long deviceId = 1;
final String password = "12345";
final Account account = mock(Account.class);
final Device device = mock(Device.class);
final AuthenticationCredentials credentials = mock(AuthenticationCredentials.class);
when(clock.instant()).thenReturn(Instant.now());
when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));
when(account.getUuid()).thenReturn(uuid);
when(account.getDevice(deviceId)).thenReturn(Optional.of(device));
when(account.isEnabled()).thenReturn(true);
when(device.getId()).thenReturn(deviceId);
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.Version.V1);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true);
assertThat(maybeAuthenticatedAccount).isPresent();
assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid);
assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device);
verify(accountsManager, times(1)).updateDeviceAuthentication(
any(), // this won't be 'account', because it'll already be updated by updateDeviceLastSeen
eq(device), any());
}
@Test
void testAuthenticateAccountNotFound() {
assertThat(baseAccountAuthenticator.authenticate(new BasicCredentials(UUID.randomUUID().toString(), "password"), true))
@@ -259,6 +297,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final Optional<AuthenticatedAccount> maybeAuthenticatedAccount =
baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + (deviceId + 1), password), true);
@@ -286,6 +325,7 @@ class BaseAccountAuthenticatorTest {
when(device.isEnabled()).thenReturn(true);
when(device.getAuthenticationCredentials()).thenReturn(credentials);
when(credentials.verify(password)).thenReturn(true);
when(credentials.getVersion()).thenReturn(AuthenticationCredentials.CURRENT_VERSION);
final String incorrectPassword = password + "incorrect";

View File

@@ -54,6 +54,9 @@ class DynamicConfigurationTest {
uuidsOnly:
enrolledUuids:
- 71618739-114c-4b1f-bb0d-6478a44eb600
uuids-with-dash:
enrolledUuids:
- 71618739-114c-4b1f-bb0d-6478ffffffff
""");
final DynamicConfiguration config =
@@ -77,6 +80,11 @@ class DynamicConfigurationTest {
assertEquals(0, config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrollmentPercentage());
assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478a44eb600")),
config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids());
assertTrue(config.getExperimentEnrollmentConfiguration("uuids-with-dash").isPresent());
assertEquals(0, config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrollmentPercentage());
assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478ffffffff")),
config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrolledUuids());
}
}

View File

@@ -19,8 +19,8 @@ import io.lettuce.core.RedisCommandTimeoutException;
import io.lettuce.core.RedisException;
import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;
import io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
@@ -32,25 +32,28 @@ class FaultTolerantPubSubConnectionTest {
@SuppressWarnings("unchecked")
@BeforeEach
public void setUp() {
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = mock(StatefulRedisClusterPubSubConnection.class);
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = mock(
StatefulRedisClusterPubSubConnection.class);
pubSubCommands = mock(RedisClusterPubSubCommands.class);
pubSubCommands = mock(RedisClusterPubSubCommands.class);
when(pubSubConnection.sync()).thenReturn(pubSubCommands);
when(pubSubConnection.sync()).thenReturn(pubSubCommands);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setRingBufferSizeInClosedState(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setSlidingWindowSize(1);
breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final CircuitBreaker circuitBreaker = CircuitBreaker.of("test", breakerConfiguration.toCircuitBreakerConfig());
final Retry retry = Retry.of("test", retryConfiguration.toRetryConfig());
final CircuitBreaker circuitBreaker = CircuitBreaker.of("test", breakerConfiguration.toCircuitBreakerConfig());
final Retry retry = Retry.of("test", retryConfiguration.toRetryConfig());
faultTolerantPubSubConnection = new FaultTolerantPubSubConnection<>("test", pubSubConnection, circuitBreaker, retry);
faultTolerantPubSubConnection = new FaultTolerantPubSubConnection<>("test", pubSubConnection, circuitBreaker,
retry);
}
@Test

View File

@@ -22,8 +22,8 @@ import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;
import io.lettuce.core.event.EventBus;
import io.lettuce.core.resource.ClientResources;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import reactor.core.publisher.Flux;
@@ -44,23 +44,25 @@ class FaultTolerantRedisClusterTest {
clusterCommands = mock(RedisAdvancedClusterCommands.class);
when(clusterClient.connect()).thenReturn(clusterConnection);
when(clusterClient.connectPubSub()).thenReturn(pubSubConnection);
when(clusterClient.getResources()).thenReturn(clientResources);
when(clusterConnection.sync()).thenReturn(clusterCommands);
when(clientResources.eventBus()).thenReturn(eventBus);
when(eventBus.get()).thenReturn(mock(Flux.class));
when(clusterClient.connect()).thenReturn(clusterConnection);
when(clusterClient.connectPubSub()).thenReturn(pubSubConnection);
when(clusterClient.getResources()).thenReturn(clientResources);
when(clusterConnection.sync()).thenReturn(clusterCommands);
when(clientResources.eventBus()).thenReturn(eventBus);
when(eventBus.get()).thenReturn(mock(Flux.class));
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setRingBufferSizeInClosedState(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration();
breakerConfiguration.setFailureRateThreshold(100);
breakerConfiguration.setSlidingWindowSize(1);
breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1);
breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
final RetryConfiguration retryConfiguration = new RetryConfiguration();
retryConfiguration.setMaxAttempts(3);
retryConfiguration.setWaitDuration(0);
faultTolerantCluster = new FaultTolerantRedisCluster("test", clusterClient, Duration.ofSeconds(2), breakerConfiguration, retryConfiguration);
faultTolerantCluster = new FaultTolerantRedisCluster("test", clusterClient, Duration.ofSeconds(2),
breakerConfiguration, retryConfiguration);
}
@Test

View File

@@ -216,7 +216,7 @@ class TwilioVerifySenderTest {
.withHeader("Content-Type", "application/json")
.withBody("{\"status\": \"approved\", \"sid\": \"" + VERIFICATION_SID + "\"}")));
final Boolean success = sender.reportVerificationSucceeded(VERIFICATION_SID).get();
final Boolean success = sender.reportVerificationSucceeded(VERIFICATION_SID, null, "test").get();
assertThat(success).isTrue();
@@ -225,4 +225,24 @@ class TwilioVerifySenderTest {
.withHeader("Content-Type", equalTo("application/x-www-form-urlencoded"))
.withRequestBody(equalTo("Status=approved")));
}
@Test
void reportVerificationFailed() throws Exception {
wireMock.stubFor(post(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications/" + VERIFICATION_SID))
.withBasicAuth(ACCOUNT_ID, ACCOUNT_TOKEN)
.willReturn(aResponse()
.withStatus(404)
.withHeader("Content-Type", "application/json")
.withBody("{\"status\": 404, \"code\": 20404}")));
final Boolean success = sender.reportVerificationSucceeded(VERIFICATION_SID, null, "test").get();
assertThat(success).isFalse();
wireMock.verify(1,
postRequestedFor(urlEqualTo("/v2/Services/" + VERIFY_SERVICE_SID + "/Verifications/" + VERIFICATION_SID))
.withHeader("Content-Type", equalTo("application/x-www-form-urlencoded"))
.withRequestBody(equalTo("Status=approved")));
}
}

View File

@@ -193,7 +193,7 @@ class AccountsManagerChangeNumberIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
secureStorageClient,

View File

@@ -160,7 +160,7 @@ class AccountsManagerConcurrentModificationIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),

View File

@@ -71,7 +71,7 @@ class AccountsManagerTest {
private Keys keys;
private MessagesManager messagesManager;
private ProfilesManager profilesManager;
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
private ExperimentEnrollmentManager enrollmentManager;
private Map<String, UUID> phoneNumberIdentifiersByE164;
@@ -87,6 +87,8 @@ class AccountsManagerTest {
return null;
};
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
@BeforeEach
void setup() throws InterruptedException {
accounts = mock(Accounts.class);
@@ -95,7 +97,7 @@ class AccountsManagerTest {
keys = mock(Keys.class);
messagesManager = mock(MessagesManager.class);
profilesManager = mock(ProfilesManager.class);
reservedUsernames = mock(ReservedUsernames.class);
prohibitedUsernames = mock(ProhibitedUsernames.class);
//noinspection unchecked
commands = mock(RedisAdvancedClusterCommands.class);
@@ -149,7 +151,7 @@ class AccountsManagerTest {
directoryQueue,
keys,
messagesManager,
reservedUsernames,
prohibitedUsernames,
profilesManager,
mock(StoredVerificationCodeManager.class),
storageClient,
@@ -737,6 +739,65 @@ class AccountsManagerTest {
verify(accounts).setUsername(eq(account), startsWith(nickname));
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "beethoven";
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), startsWith(nickname), any());
}
@Test
void testSetReservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "scooby#1234";
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN);
verify(accounts).confirmUsername(eq(account), eq(reserved), eq(RESERVATION_TOKEN));
}
@Test
void testSetReservedHashNameMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
setReservationHash(account, "pluto#1234");
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq("pluto#1234"))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "goofy#1234", RESERVATION_TOKEN));
}
@Test
void testSetReservedHashAciMismatch() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "toto#1234";
account.setReservedUsernameHash(Accounts.reservedUsernameHash(UUID.randomUUID(), reserved));
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(true);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
}
@Test
void testSetReservedLapsed() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String reserved = "porkchop#1234";
// name was reserved, but the reservation lapsed and another account took it
setReservationHash(account, reserved);
when(accounts.usernameAvailable(eq(Optional.of(RESERVATION_TOKEN)), eq(reserved))).thenReturn(false);
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.confirmReservedUsername(account, reserved, RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@Test
void testSetReservedRetry() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String username = "santaslittlehelper#1234";
account.setUsername(username);
// reserved username already set, should be treated as a replay
accountsManager.confirmReservedUsername(account, username, RESERVATION_TOKEN);
verifyNoInteractions(accounts);
}
@Test
void testSetUsernameSameUsername() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
@@ -761,7 +822,29 @@ class AccountsManagerTest {
}
@Test
void testSetUsernameExpandDiscriminator() throws UsernameNotAvailableException {
void testReserveUsernameReroll() throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "clifford";
final String username = nickname + "#ZZZ";
account.setUsername(username);
// given the correct old username, should reroll discriminator even if the nick matches
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), not(eq(username))), any());
}
@Test
void testSetReservedUsernameWithNoReservation() {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(),
new ArrayList<>(), new byte[16]);
assertThrows(UsernameReservationNotFoundException.class,
() -> accountsManager.confirmReservedUsername(account, "laika#1234", RESERVATION_TOKEN));
verify(accounts, never()).confirmUsername(any(), any(), any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testUsernameExpandDiscriminator(boolean reserve) throws UsernameNotAvailableException {
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
final String nickname = "test";
@@ -775,8 +858,14 @@ class AccountsManagerTest {
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)));
if (reserve) {
accountsManager.reserveUsername(account, nickname);
verify(accounts).reserveUsername(eq(account), and(startsWith(nickname), argThat(isWide)), any());
} else {
accountsManager.setUsername(account, nickname, null);
verify(accounts).setUsername(eq(account), and(startsWith(nickname), argThat(isWide)));
}
}
@Test
@@ -801,7 +890,7 @@ class AccountsManagerTest {
@Test
void testSetUsernameReserved() {
final String nickname = "reserved";
when(reservedUsernames.isReserved(eq(nickname), any())).thenReturn(true);
when(prohibitedUsernames.isProhibited(eq(nickname), any())).thenReturn(true);
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
@@ -823,6 +912,10 @@ class AccountsManagerTest {
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
}
private void setReservationHash(final Account account, final String reservedUsername) {
account.setReservedUsernameHash(Accounts.reservedUsernameHash(account.getUuid(), reservedUsername));
}
private static Device generateTestDevice(final long lastSeen) {
final Device device = new Device();
device.setId(Device.MASTER_ID);

View File

@@ -8,6 +8,8 @@ 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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -22,6 +24,8 @@ 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.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
@@ -64,6 +68,7 @@ class AccountsManagerUsernameIntegrationTest {
private AccountsManager accountsManager;
private Accounts accounts;
private UsernameGenerator usernameGenerator;
@BeforeEach
void setup() throws InterruptedException {
@@ -109,7 +114,11 @@ class AccountsManagerUsernameIntegrationTest {
.build();
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest);
buildAccountsManager(1, 2, 10);
}
private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth)
throws InterruptedException {
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
@@ -126,6 +135,8 @@ class AccountsManagerUsernameIntegrationTest {
USERNAMES_TABLE_NAME,
SCAN_PAGE_SIZE));
usernameGenerator = new UsernameGenerator(initialWidth, discriminatorMaxWidth, attemptsPerWidth,
Duration.ofDays(1));
final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class);
doAnswer((final InvocationOnMock invocationOnMock) -> {
@SuppressWarnings("unchecked")
@@ -140,7 +151,6 @@ class AccountsManagerUsernameIntegrationTest {
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
.thenReturn(true);
accountsManager = new AccountsManager(
accounts,
phoneNumberIdentifiers,
@@ -149,13 +159,13 @@ class AccountsManagerUsernameIntegrationTest {
mock(DirectoryQueue.class),
mock(Keys.class),
mock(MessagesManager.class),
mock(ReservedUsernames.class),
mock(ProhibitedUsernames.class),
mock(ProfilesManager.class),
mock(StoredVerificationCodeManager.class),
mock(SecureStorageClient.class),
mock(SecureBackupClient.class),
mock(ClientPresenceManager.class),
new UsernameGenerator(1, 2, 10),
usernameGenerator,
experimentEnrollmentManager,
mock(Clock.class));
}
@@ -188,19 +198,32 @@ class AccountsManagerUsernameIntegrationTest {
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
void testNoUsernames() throws InterruptedException {
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testNoUsernames(boolean reserve) throws InterruptedException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
for (int i = 1; i <= 99; i++) {
final Map<String, AttributeValue> item = new HashMap<>(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))));
// half of these are taken usernames, half are only reservations (have a TTL)
if (i % 2 == 0) {
item.put(Accounts.ATTR_TTL,
AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));
}
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
.item(item)
.build());
}
assertThrows(UsernameNotAvailableException.class, () -> accountsManager.setUsername(account, "n00bkiller", null));
assertThrows(UsernameNotAvailableException.class, () -> {
if (reserve) {
accountsManager.reserveUsername(account, "n00bkiller");
} else {
accountsManager.setUsername(account, "n00bkiller", null);
}
});
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@@ -213,7 +236,7 @@ class AccountsManagerUsernameIntegrationTest {
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(UsernameGenerator.fromParts("n00bkiller", i))))
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
.build());
}
@@ -235,4 +258,112 @@ class AccountsManagerUsernameIntegrationTest {
verify(accounts, times(1)).usernameAvailable(argThat(un -> discriminator(un) >= 10));
}
@Test
public void testReserveSetClear()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(reservation.reservedUsername()).startsWith("n00bkiller");
int discriminator = discriminator(reservation.reservedUsername());
assertThat(discriminator).isGreaterThan(0).isLessThan(10);
assertThat(accountsManager.getByUsername(reservation.reservedUsername())).isEmpty();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
assertThat(account.getUsername().get()).startsWith("n00bkiller");
assertThat(accountsManager.getByUsername(account.getUsername().get()).orElseThrow().getUuid()).isEqualTo(
account.getUuid());
// reroll
reservation = accountsManager.reserveUsername(account, "n00bkiller");
account = reservation.account();
account = accountsManager.confirmReservedUsername(
account,
reservation.reservedUsername(),
reservation.reservationToken());
final String newUsername = account.getUsername().orElseThrow();
assertThat(discriminator(account.getUsername().orElseThrow())).isNotEqualTo(discriminator);
// clear
account = accountsManager.clearUsername(account);
assertThat(accountsManager.getByUsername(newUsername)).isEmpty();
assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsername()).isEmpty();
}
@Test
public void testReservationLapsed()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
// use a username generator that can retry a lot
buildAccountsManager(1, 1, 1000000);
final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsername(account, "n00bkiller");
final String reservedUsername = reservation1.reservedUsername();
long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();
// force expiration
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString(reservedUsername)))
.updateExpression("SET #ttl = :ttl")
.expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL))
.expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past)))
.build());
int discriminator = discriminator(reservedUsername);
// use up all names except the reserved one
for (int i = 1; i <= 9; i++) {
if (i == discriminator) {
continue;
}
ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()
.tableName(USERNAMES_TABLE_NAME)
.item(Map.of(
Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),
Accounts.ATTR_USERNAME, AttributeValues.fromString(usernameGenerator.fromParts("n00bkiller", i))))
.build());
}
// a different account should be able to reserve it
Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(),
new ArrayList<>());
final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsername(account2, "n00bkiller"
);
assertThat(reservation2.reservedUsername()).isEqualTo(reservedUsername);
assertThrows(UsernameNotAvailableException.class,
() -> accountsManager.confirmReservedUsername(reservation1.account(), reservedUsername, reservation1.reservationToken()));
accountsManager.confirmReservedUsername(reservation2.account(), reservedUsername, reservation2.reservationToken());
}
@Test
void testUsernameReserveClearSetReserved()
throws InterruptedException, UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(),
new ArrayList<>());
account = accountsManager.setUsername(account, "n00bkiller", null);
final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsername(account, "other");
account = reservation.account();
assertThat(reservation.reservedUsername()).startsWith("other");
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("n00bkiller"));
account = accountsManager.clearUsername(account);
assertThat(account.getReservedUsernameHash()).isPresent();
assertThat(account.getUsername()).isEmpty();
account = accountsManager.confirmReservedUsername(account, reservation.reservedUsername(), reservation.reservationToken());
assertThat(account.getUsername()).hasValueSatisfying(s -> s.startsWith("other"));
}
}

View File

@@ -16,8 +16,10 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.uuid.UUIDComparator;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -27,14 +29,12 @@ import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.jdbi.v3.core.transaction.TransactionException;
import java.util.function.Supplier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
@@ -78,6 +78,7 @@ class AccountsTest {
.build())
.build();
private Clock mockClock;
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
private Accounts accounts;
@@ -134,7 +135,11 @@ class AccountsTest {
when(mockDynamicConfigManager.getConfiguration())
.thenReturn(new DynamicConfiguration());
mockClock = mock(Clock.class);
when(mockClock.instant()).thenReturn(Instant.EPOCH);
this.accounts = new Accounts(
mockClock,
mockDynamicConfigManager,
dynamoDbExtension.getDynamoDbClient(),
dynamoDbExtension.getDynamoDbAsyncClient(),
@@ -494,60 +499,6 @@ class AccountsTest {
assertThat(retrieved.isPresent()).isFalse();
}
@Test
@Disabled("Need fault tolerant dynamodb")
void testBreaker() throws InterruptedException {
CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration();
configuration.setWaitDurationInOpenStateInSeconds(1);
configuration.setRingBufferSizeInHalfOpenState(1);
configuration.setRingBufferSizeInClosedState(2);
configuration.setFailureRateThreshold(50);
final DynamoDbClient client = mock(DynamoDbClient.class);
final DynamoDbAsyncClient asyncClient = mock(DynamoDbAsyncClient.class);
when(client.transactWriteItems(any(TransactWriteItemsRequest.class)))
.thenThrow(RuntimeException.class);
when(asyncClient.updateItem(any(UpdateItemRequest.class)))
.thenReturn(CompletableFuture.failedFuture(new RuntimeException()));
Accounts accounts = new Accounts(mockDynamicConfigManager, client, asyncClient, ACCOUNTS_TABLE_NAME, NUMBER_CONSTRAINT_TABLE_NAME,
PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE);
Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID());
try {
accounts.update(account);
throw new AssertionError();
} catch (TransactionException e) {
// good
}
try {
accounts.update(account);
throw new AssertionError();
} catch (TransactionException e) {
// good
}
try {
accounts.update(account);
throw new AssertionError();
} catch (CallNotPermittedException e) {
// good
}
Thread.sleep(1100);
try {
accounts.update(account);
throw new AssertionError();
} catch (TransactionException e) {
// good
}
}
@Test
void testCanonicallyDiscoverableSet() {
Device device = generateDevice(1);
@@ -666,7 +617,7 @@ class AccountsTest {
}
@Test
void testSetUsername() throws UsernameNotAvailableException {
void testSetUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -737,7 +688,7 @@ class AccountsTest {
}
@Test
void testClearUsername() throws UsernameNotAvailableException {
void testClearUsername() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -762,7 +713,7 @@ class AccountsTest {
}
@Test
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
void testClearUsernameVersionMismatch() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
@@ -777,6 +728,154 @@ class AccountsTest {
assertThat(account.getUsername()).hasValueSatisfying(u -> assertThat(u).isEqualTo(username));
}
@Test
void testReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
final UUID token = accounts.reserveUsername(account1, "garfield", Duration.ofDays(1));
assertThat(account1.getReservedUsernameHash()).get().isEqualTo(Accounts.reservedUsernameHash(account1.getUuid(), "garfield"));
assertThat(account1.getUsername()).isEmpty();
// account 2 shouldn't be able to reserve the username
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account2, "garfield", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account2, "garfield", UUID.randomUUID()));
assertThat(accounts.getByUsername("garfield")).isEmpty();
accounts.confirmUsername(account1, "garfield", token);
assertThat(account1.getReservedUsernameHash()).isEmpty();
assertThat(account1.getUsername()).get().isEqualTo("garfield");
assertThat(accounts.getByUsername("garfield").get().getUuid()).isEqualTo(account1.getUuid());
assertThat(dynamoDbExtension.getDynamoDbClient()
.getItem(GetItemRequest.builder()
.tableName(USERNAME_CONSTRAINT_TABLE_NAME)
.key(Map.of(Accounts.ATTR_USERNAME, AttributeValues.fromString("garfield")))
.build())
.item())
.doesNotContainKey(Accounts.ATTR_TTL);
}
@Test
void testUsernameAvailable() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final String username = "unsinkablesam";
final UUID token = accounts.reserveUsername(account1, username, Duration.ofDays(1));
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isTrue();
accounts.confirmUsername(account1, username, token);
assertThat(accounts.usernameAvailable(username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.empty(), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(UUID.randomUUID()), username)).isFalse();
assertThat(accounts.usernameAvailable(Optional.of(token), username)).isFalse();
}
@Test
void testReservedUsernameWrongToken() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "grumpy", Duration.ofDays(1));
assertThat(account.getReservedUsernameHash())
.get()
.isEqualTo(Accounts.reservedUsernameHash(account.getUuid(), "grumpy"));
assertThat(account.getUsername()).isEmpty();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.confirmUsername(account, "grumpy", UUID.randomUUID()));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "grumpy"));
}
@Test
void testReserveExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
Supplier<UUID> take = () -> accounts.reserveUsername(account2, "snowball#0002", Duration.ofDays(2));
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::get);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
final UUID token = take.get();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, "snowball#0002"));
accounts.confirmUsername(account2, "snowball#0002", token);
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testTakeExpiredReservedUsername() {
final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account1);
final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account2);
accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2));
Runnable take = () -> accounts.setUsername(account2, "snowball#0002");
for (int i = 0; i <= 2; i++) {
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(i)));
assertThrows(ContestedOptimisticLockException.class, take::run);
}
// after 2 days, can take the name
when(mockClock.instant()).thenReturn(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));
take.run();
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account1, "snowball#0002", Duration.ofDays(2)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account1, "snowball#0002"));
assertThat(accounts.getByUsername("snowball#0002").get().getUuid()).isEqualTo(account2.getUuid());
}
@Test
void testRetryReserveUsername() {
final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
accounts.reserveUsername(account, "jorts", Duration.ofDays(2));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "jorts", Duration.ofDays(2)),
"Shouldn't be able to re-reserve same username (would extend ttl)");
}
@Test
void testReserveUsernameVersionConflict() {
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
accounts.create(account);
account.setVersion(account.getVersion() + 12);
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.reserveUsername(account, "salem", Duration.ofDays(1)));
assertThrows(ContestedOptimisticLockException.class,
() -> accounts.setUsername(account, "salem"));
}
private Device generateDevice(long id) {
return DevicesHelper.createDevice(id);
}

View File

@@ -17,37 +17,37 @@ import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class ReservedUsernamesTest {
class ProhibitedUsernamesTest {
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(RESERVED_USERNAMES_TABLE_NAME)
.hashKey(ReservedUsernames.KEY_PATTERN)
.hashKey(ProhibitedUsernames.KEY_PATTERN)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ReservedUsernames.KEY_PATTERN)
.attributeName(ProhibitedUsernames.KEY_PATTERN)
.attributeType(ScalarAttributeType.S)
.build())
.build();
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
private ReservedUsernames reservedUsernames;
private ProhibitedUsernames prohibitedUsernames;
@BeforeEach
void setUp() {
reservedUsernames =
new ReservedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
prohibitedUsernames =
new ProhibitedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
}
@ParameterizedTest
@MethodSource
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
reservedUsernames.reserveUsername(".*myusername.*", RESERVED_FOR_UUID);
reservedUsernames.reserveUsername("^foobar$", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername(".*myusername.*", RESERVED_FOR_UUID);
prohibitedUsernames.prohibitUsername("^foobar$", RESERVED_FOR_UUID);
assertEquals(expectReserved, reservedUsernames.isReserved(username, uuid));
assertEquals(expectReserved, prohibitedUsernames.isProhibited(username, uuid));
}
private static Stream<Arguments> isReserved() {

View File

@@ -62,6 +62,8 @@ class PushChallengeDynamoDbTest {
assertFalse(pushChallengeDynamoDb.remove(uuid, token));
assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(1)));
assertTrue(pushChallengeDynamoDb.remove(uuid, token));
assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(-1)));
assertFalse(pushChallengeDynamoDb.remove(uuid, token));
}
@Test

View File

@@ -5,27 +5,33 @@
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import java.util.Objects;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class VerificationCodeStoreTest {
private VerificationCodeStore verificationCodeStore;
private static final String TABLE_NAME = "verification_code_test";
private static final String PHONE_NUMBER = "+14151112222";
private static final long VALID_TIMESTAMP = Instant.now().toEpochMilli();
private static final long EXPIRED_TIMESTAMP = Instant.now().minus(StoredVerificationCode.EXPIRATION).minus(
Duration.ofHours(1)).toEpochMilli();
@RegisterExtension
static final DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder()
.tableName(TABLE_NAME)
@@ -45,8 +51,8 @@ class VerificationCodeStoreTest {
void testStoreAndFind() {
assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER));
final StoredVerificationCode originalCode = new StoredVerificationCode("1234", 1111, "abcd", "0987");
final StoredVerificationCode secondCode = new StoredVerificationCode("5678", 2222, "efgh", "7890");
final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987");
final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh", "7890");
verificationCodeStore.insert(PHONE_NUMBER, originalCode);
{
@@ -69,11 +75,14 @@ class VerificationCodeStoreTest {
void testRemove() {
assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER));
verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", 1111, "abcd", "0987"));
verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987"));
assertTrue(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
verificationCodeStore.remove(PHONE_NUMBER);
assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "0987"));
assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
}
private static boolean storedVerificationCodesAreEqual(final StoredVerificationCode first, final StoredVerificationCode second) {

View File

@@ -17,7 +17,7 @@ class AuthenticationCredentialsTest {
AuthenticationCredentials credentials = new AuthenticationCredentials("mypassword");
assertThat(credentials.getSalt()).isNotEmpty();
assertThat(credentials.getHashedAuthenticationToken()).isNotEmpty();
assertThat(credentials.getHashedAuthenticationToken().length()).isEqualTo(40);
assertThat(credentials.getHashedAuthenticationToken().length()).isEqualTo(66);
}
@Test

View File

@@ -52,6 +52,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
@@ -72,10 +73,13 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
@@ -98,6 +102,7 @@ import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Hex;
@@ -120,6 +125,7 @@ class AccountControllerTest {
private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID();
private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID();
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
private static final String ABUSIVE_HOST = "192.168.1.1";
private static final String NICE_HOST = "127.0.0.1";
@@ -143,6 +149,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 usernameReserveLimiter = mock(RateLimiter.class);
private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class);
private static SmsSender smsSender = mock(SmsSender.class);
private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class);
@@ -207,6 +214,7 @@ class AccountControllerTest {
when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter);
when(rateLimiters.getAutoBlockLimiter()).thenReturn(autoBlockLimiter);
when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);
when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter);
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
@@ -318,6 +326,7 @@ class AccountControllerTest {
smsVoicePrefixLimiter,
autoBlockLimiter,
usernameSetLimiter,
usernameReserveLimiter,
usernameLookupLimiter,
smsSender,
turnTokenGenerator,
@@ -984,7 +993,8 @@ class AccountControllerTest {
.thenReturn(enrolledInVerifyExperiment);
final String challenge = "challenge";
when(pendingAccountsManager.getCodeForNumber(RESTRICTED_NUMBER)).thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null)));
when(pendingAccountsManager.getCodeForNumber(RESTRICTED_NUMBER))
.thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null)));
Response response =
resources.getJerseyTest()
@@ -1000,6 +1010,54 @@ class AccountControllerTest {
verifyNoMoreInteractions(smsSender);
}
@ParameterizedTest
@CsvSource({
"+12025550123, true, true",
"+12025550123, false, true",
"+12505550199, true, false",
"+12505550199, false, false",
})
void testRestrictedRegion(final String number, final boolean enrolledInVerifyExperiment, final boolean expectSendCode) {
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
final DynamicCaptchaConfiguration signupCaptchaConfig = new DynamicCaptchaConfiguration();
signupCaptchaConfig.setSignupRegions(Set.of("CA"));
when(dynamicConfiguration.getCaptchaConfiguration()).thenReturn(signupCaptchaConfig);
when(verifyExperimentEnrollmentManager.isEnrolled(any(), anyString(), anyList(), anyString()))
.thenReturn(enrolledInVerifyExperiment);
final String challenge = "challenge";
when(pendingAccountsManager.getCodeForNumber(number))
.thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null)));
when(smsSender.deliverSmsVerificationWithTwilioVerify(any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.empty()));
Response response =
resources.getJerseyTest()
.target(String.format("/v1/accounts/sms/code/%s", number))
.queryParam("challenge", challenge)
.request()
.header("X-Forwarded-For", NICE_HOST)
.get();
if (expectSendCode) {
assertThat(response.getStatus()).isEqualTo(200);
if (enrolledInVerifyExperiment) {
verify(smsSender).deliverSmsVerificationWithTwilioVerify(eq(number), any(), any(), any());
} else {
verify(smsSender).deliverSmsVerification(eq(number), any(), any());
}
} else {
assertThat(response.getStatus()).isEqualTo(402);
verifyNoMoreInteractions(smsSender);
}
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testSendRestrictedIn(final boolean enrolledInVerifyExperiment) throws Exception {
@@ -1068,7 +1126,7 @@ class AccountControllerTest {
verify(accountsManager).create(eq(SENDER), eq("bar"), any(), any(), anyList());
if (enrolledInVerifyExperiment) {
verify(smsSender).reportVerificationSucceeded("VerificationSid");
verify(smsSender).reportVerificationSucceeded(eq("VerificationSid"), any(), eq("registration"));
}
}
@@ -1580,7 +1638,7 @@ class AccountControllerTest {
assertThat(pinCapture.getValue()).isNotEmpty();
assertThat(pinSaltCapture.getValue()).isNotEmpty();
assertThat(pinCapture.getValue().length()).isEqualTo(40);
assertThat(pinCapture.getValue().length()).isEqualTo(66);
}
@Test
@@ -1683,6 +1741,63 @@ class AccountControllerTest {
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
}
@Test
void testReserveUsername() throws UsernameNotAvailableException {
when(accountsManager.reserveUsername(any(), eq("n00bkiller")))
.thenReturn(new AccountsManager.UsernameReservation(null, "n00bkiller#1234", RESERVATION_TOKEN));
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/reserved")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ReserveUsernameRequest("n00bkiller")));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(ReserveUsernameResponse.class))
.satisfies(r -> r.username().equals("n00bkiller#1234"))
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
}
@Test
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
Account account = mock(Account.class);
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
}
@Test
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameReservationNotFoundException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller#1234"), eq(RESERVATION_TOKEN)))
.thenThrow(new UsernameNotAvailableException());
Response response =
resources.getJerseyTest()
.target("/v1/accounts/username/confirm")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller#1234", RESERVATION_TOKEN)));
assertThat(response.getStatus()).isEqualTo(410);
}
@Test
void testSetTakenUsername() {
Response response =

View File

@@ -35,13 +35,10 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2;
import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.entities.AttachmentUri;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
@@ -83,7 +80,6 @@ class AttachmentControllerTest {
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new AttachmentControllerV1(rateLimiters, "accessKey", "accessSecret", "attachment-bucket"))
.addResource(new AttachmentControllerV2(rateLimiters, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket"))
.addResource(new AttachmentControllerV3(rateLimiters, "some-cdn.signal.org", "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM))
.build();
@@ -198,53 +194,4 @@ class AttachmentControllerTest {
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void testAcceleratedPut() {
AttachmentDescriptorV1 descriptor = resources.getJerseyTest()
.target("/v1/attachments/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(AttachmentDescriptorV1.class);
assertThat(descriptor.getLocation()).startsWith("https://attachment-bucket.s3-accelerate.amazonaws.com");
assertThat(descriptor.getId()).isGreaterThan(0);
assertThat(descriptor.getIdString()).isNotBlank();
}
@Test
void testUnacceleratedPut() {
AttachmentDescriptorV1 descriptor = resources.getJerseyTest()
.target("/v1/attachments/")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))
.get(AttachmentDescriptorV1.class);
assertThat(descriptor.getLocation()).startsWith("https://s3.amazonaws.com");
assertThat(descriptor.getId()).isGreaterThan(0);
assertThat(descriptor.getIdString()).isNotBlank();
}
@Test
void testAcceleratedGet() throws MalformedURLException {
AttachmentUri uri = resources.getJerseyTest()
.target("/v1/attachments/1234")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get(AttachmentUri.class);
assertThat(uri.getLocation().getHost()).isEqualTo("attachment-bucket.s3-accelerate.amazonaws.com");
}
@Test
void testUnacceleratedGet() throws MalformedURLException {
AttachmentUri uri = resources.getJerseyTest()
.target("/v1/attachments/1234")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))
.get(AttachmentUri.class);
assertThat(uri.getLocation().getHost()).isEqualTo("s3.amazonaws.com");
}
}

View File

@@ -95,18 +95,19 @@ class FaultTolerantHttpClientTest {
@Test
void testNetworkFailureCircuitBreaker() throws InterruptedException {
CircuitBreakerConfiguration circuitBreakerConfiguration = new CircuitBreakerConfiguration();
circuitBreakerConfiguration.setRingBufferSizeInClosedState(2);
circuitBreakerConfiguration.setRingBufferSizeInHalfOpenState(1);
circuitBreakerConfiguration.setSlidingWindowSize(2);
circuitBreakerConfiguration.setSlidingWindowMinimumNumberOfCalls(2);
circuitBreakerConfiguration.setPermittedNumberOfCallsInHalfOpenState(1);
circuitBreakerConfiguration.setFailureRateThreshold(50);
circuitBreakerConfiguration.setWaitDurationInOpenStateInSeconds(1);
FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder()
.withCircuitBreaker(circuitBreakerConfiguration)
.withRetry(new RetryConfiguration())
.withExecutor(Executors.newSingleThreadExecutor())
.withName("test")
.withVersion(HttpClient.Version.HTTP_2)
.build();
.withCircuitBreaker(circuitBreakerConfiguration)
.withRetry(new RetryConfiguration())
.withExecutor(Executors.newSingleThreadExecutor())
.withName("test")
.withVersion(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + 39873 + "/failure"))

View File

@@ -118,17 +118,19 @@ class ReplicatedJedisPoolTest {
void testCircuitBreakerOpen() {
CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration();
configuration.setFailureRateThreshold(50);
configuration.setRingBufferSizeInClosedState(2);
configuration.setSlidingWindowSize(2);
configuration.setSlidingWindowMinimumNumberOfCalls(2);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
when(master.getResource()).thenReturn(null);
when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!"));
when(slaveTwo.getResource()).thenThrow(new JedisException("Also failed!"));
ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerOpen", master, Arrays.asList(slaveOne, slaveTwo), configuration);
ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerOpen", master,
Arrays.asList(slaveOne, slaveTwo), configuration);
replicatedJedisPool.getWriteResource();
when(master.getResource()).thenThrow(new JedisException("Master broken!"));
@@ -152,13 +154,14 @@ class ReplicatedJedisPoolTest {
void testCircuitBreakerHalfOpen() throws InterruptedException {
CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration();
configuration.setFailureRateThreshold(50);
configuration.setRingBufferSizeInClosedState(2);
configuration.setRingBufferSizeInHalfOpenState(1);
configuration.setSlidingWindowSize(2);
configuration.setSlidingWindowMinimumNumberOfCalls(2);
configuration.setPermittedNumberOfCallsInHalfOpenState(1);
configuration.setWaitDurationInOpenStateInSeconds(1);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
JedisPool master = mock(JedisPool.class);
JedisPool slaveOne = mock(JedisPool.class);
JedisPool slaveTwo = mock(JedisPool.class);
when(master.getResource()).thenThrow(new JedisException("Master broken!"));
when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!"));

View File

@@ -20,6 +20,7 @@ import java.util.UUID;
import java.util.function.Consumer;
import org.mockito.MockingDetails;
import org.mockito.stubbing.Stubbing;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
@@ -69,10 +70,13 @@ public class AccountsHelper {
});
when(mockAccountsManager.updateDeviceLastSeen(any(), any(), anyLong())).thenAnswer(answer -> {
answer.getArgument(1, Device.class).setLastSeen(answer.getArgument(2, Long.class));
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {
});
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});
});
when(mockAccountsManager.updateDeviceAuthentication(any(), any(), any())).thenAnswer(answer -> {
answer.getArgument(1, Device.class).setAuthenticationCredentials(answer.getArgument(2, AuthenticationCredentials.class));
return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});
});
}

View File

@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.tests.util;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
@@ -25,6 +26,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;

View File

@@ -1,33 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.util;
import static org.assertj.core.api.Assertions.assertThat;
import com.amazonaws.HttpMethod;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.s3.UrlSigner;
class UrlSignerTest {
@Test
void testTransferAcceleration() {
UrlSigner signer = new UrlSigner("foo", "bar", "attachments-test");
URL url = signer.getPreSignedUrl(1234, HttpMethod.GET, false);
assertThat(url).hasHost("attachments-test.s3-accelerate.amazonaws.com");
}
@Test
void testTransferUnaccelerated() {
UrlSigner signer = new UrlSigner("foo", "bar", "attachments-test");
URL url = signer.getPreSignedUrl(1234, HttpMethod.GET, true);
assertThat(url).hasHost("s3.amazonaws.com");
}
}

View File

@@ -5,10 +5,10 @@ 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.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
@@ -18,6 +18,8 @@ import static org.assertj.core.api.Assertions.assertThat;
public class UsernameGeneratorTest {
private static final Duration TTL = Duration.ofMinutes(5);
@ParameterizedTest(name = "[{index}]:{0} ({2})")
@MethodSource
public void nicknameValidation(String nickname, boolean valid, String testCaseName) {
@@ -63,18 +65,27 @@ public class UsernameGeneratorTest {
);
}
@Test
public void zeroPadDiscriminators() {
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1, TTL);
assertThat(generator.fromParts("test", 1)).isEqualTo("test#0001");
assertThat(generator.fromParts("test", 123)).isEqualTo("test#0123");
assertThat(generator.fromParts("test", 9999)).isEqualTo("test#9999");
assertThat(generator.fromParts("test", 99999)).isEqualTo("test#99999");
}
@Test
public void expectedWidth() throws UsernameNotAvailableException {
String username = new UsernameGenerator(1, 6, 1).generateAvailableUsername("test", t -> true);
String username = new UsernameGenerator(1, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(10);
username = new UsernameGenerator(2, 6, 1).generateAvailableUsername("test", t -> true);
username = new UsernameGenerator(2, 6, 1, TTL).generateAvailableUsername("test", t -> true);
assertThat(extractDiscriminator(username)).isGreaterThan(0).isLessThan(100);
}
@Test
public void expandDiscriminator() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(10000).isLessThan(100000);
@@ -82,7 +93,7 @@ public class UsernameGeneratorTest {
@Test
public void expandDiscriminatorToMax() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final String username = ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 100000));
int discriminator = extractDiscriminator(username);
assertThat(discriminator).isGreaterThanOrEqualTo(100000).isLessThan(1000000);
@@ -90,7 +101,7 @@ public class UsernameGeneratorTest {
@Test
public void exhaustDiscriminator() {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
Assertions.assertThrows(UsernameNotAvailableException.class, () -> {
// allow greater than our max width
ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 1000000));
@@ -99,7 +110,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMinWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 1000 && seen.size() < 9; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", ignored -> true)));
@@ -112,7 +123,7 @@ public class UsernameGeneratorTest {
@Test
public void randomCoverageMidWidth() throws UsernameNotAvailableException {
UsernameGenerator ug = new UsernameGenerator(1, 6, 10);
UsernameGenerator ug = new UsernameGenerator(1, 6, 10, TTL);
final Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 100000 && seen.size() < 90; i++) {
seen.add(extractDiscriminator(ug.generateAvailableUsername("test", allowDiscriminator(d -> d >= 10))));

View File

@@ -59,7 +59,6 @@ class ValidNumberTest {
@ValueSource(strings = {
"+4407700900111",
"+49493023125000", // double country code - this e164 is "possible"
"+494915110000000", // double country code - this e164 is "possible" and "valid"
"+1 415 123 1234",
"+1 (415) 123-1234",
"+1 415)123-1234",