mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-11 01:40:22 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e54e0724 | ||
|
|
544e4fb89a | ||
|
|
966c3a8f47 | ||
|
|
c2ab72c77e | ||
|
|
4468ee3142 | ||
|
|
c82c2c0ba4 | ||
|
|
6e595a0959 | ||
|
|
a79d709039 | ||
|
|
538a07542e | ||
|
|
07ed765250 | ||
|
|
2e497b5834 | ||
|
|
61b3cecd17 | ||
|
|
a4a666bb80 | ||
|
|
c14621a09f | ||
|
|
d0a8899daf | ||
|
|
65dbcb3e5f | ||
|
|
7f725b67c4 | ||
|
|
e25252dc69 | ||
|
|
8b65c11e1e | ||
|
|
320c5eac53 | ||
|
|
8199e0d2d5 | ||
|
|
53387f5a0c | ||
|
|
7d171a79d7 | ||
|
|
3b99bb9e78 | ||
|
|
132f026c75 | ||
|
|
abd0f9630c | ||
|
|
a4508ec84f | ||
|
|
6119b6ab89 | ||
|
|
307ac47ce0 | ||
|
|
4032ddd4fd | ||
|
|
98c8dc05f1 | ||
|
|
4c677ec2da | ||
|
|
c05692e417 | ||
|
|
1e7aa89664 | ||
|
|
ae1edf3c5c | ||
|
|
b17f41c3e8 | ||
|
|
08db4ba54b | ||
|
|
cb6cc39679 | ||
|
|
b6bf6c994c |
@@ -1135,7 +1135,7 @@ ij_kotlin_field_annotation_wrap = split_into_lines
|
||||
ij_kotlin_finally_on_new_line = false
|
||||
ij_kotlin_if_rparen_on_new_line = false
|
||||
ij_kotlin_import_nested_classes = false
|
||||
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
|
||||
ij_kotlin_imports_layout = *
|
||||
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
|
||||
ij_kotlin_keep_blank_lines_before_right_brace = 2
|
||||
ij_kotlin_keep_blank_lines_in_code = 2
|
||||
@@ -1151,9 +1151,9 @@ ij_kotlin_method_call_chain_wrap = off
|
||||
ij_kotlin_method_parameters_new_line_after_left_paren = false
|
||||
ij_kotlin_method_parameters_right_paren_on_new_line = false
|
||||
ij_kotlin_method_parameters_wrap = off
|
||||
ij_kotlin_name_count_to_use_star_import = 5
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 3
|
||||
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
|
||||
ij_kotlin_name_count_to_use_star_import = 999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||
ij_kotlin_packages_to_use_import_on_demand =
|
||||
ij_kotlin_parameter_annotation_wrap = off
|
||||
ij_kotlin_space_after_comma = true
|
||||
ij_kotlin_space_after_extend_colon = true
|
||||
|
||||
Submodule abusive-message-filter updated: d7af85dca5...9061d37fca
77
event-logger/pom.xml
Normal file
77
event-logger/pom.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2022 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>TextSecureServer</artifactId>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<version>JGITVER</version>
|
||||
</parent>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>event-logger</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-logging</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-serialization-json</artifactId>
|
||||
<version>${kotlinx-serialization.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
|
||||
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<goals>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<compilerPlugins>
|
||||
<plugin>kotlinx-serialization</plugin>
|
||||
</compilerPlugins>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-serialization</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
40
event-logger/src/main/kotlin/events.kt
Normal file
40
event-logger/src/main/kotlin/events.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import java.util.Collections
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import kotlinx.serialization.modules.subclass
|
||||
|
||||
val module = SerializersModule {
|
||||
polymorphic(Event::class) {
|
||||
subclass(RemoteConfigSetEvent::class)
|
||||
subclass(RemoteConfigDeleteEvent::class)
|
||||
}
|
||||
}
|
||||
val jsonFormat = Json { serializersModule = module }
|
||||
|
||||
sealed interface Event
|
||||
|
||||
@Serializable
|
||||
data class RemoteConfigSetEvent(
|
||||
val token: String,
|
||||
val name: String,
|
||||
val percentage: Int,
|
||||
val defaultValue: String? = null,
|
||||
val value: String? = null,
|
||||
val hashKey: String? = null,
|
||||
val uuids: Collection<String> = Collections.emptyList(),
|
||||
) : Event
|
||||
|
||||
@Serializable
|
||||
data class RemoteConfigDeleteEvent(
|
||||
val token: String,
|
||||
val name: String,
|
||||
) : Event
|
||||
41
event-logger/src/main/kotlin/loggers.kt
Normal file
41
event-logger/src/main/kotlin/loggers.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import com.google.cloud.logging.LogEntry
|
||||
import com.google.cloud.logging.Logging
|
||||
import com.google.cloud.logging.MonitoredResourceUtil
|
||||
import com.google.cloud.logging.Payload.JsonPayload
|
||||
import com.google.cloud.logging.Severity
|
||||
import com.google.protobuf.Struct
|
||||
import com.google.protobuf.util.JsonFormat
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
interface AdminEventLogger {
|
||||
fun logEvent(event: Event, labels: Map<String, String>?)
|
||||
fun logEvent(event: Event) = logEvent(event, null)
|
||||
}
|
||||
|
||||
class NoOpAdminEventLogger : AdminEventLogger {
|
||||
override fun logEvent(event: Event, labels: Map<String, String>?) {}
|
||||
}
|
||||
|
||||
class GoogleCloudAdminEventLogger(private val logging: Logging, private val projectId: String, private val logName: String) : AdminEventLogger {
|
||||
override fun logEvent(event: Event, labels: Map<String, String>?) {
|
||||
val structBuilder = Struct.newBuilder()
|
||||
JsonFormat.parser().merge(jsonFormat.encodeToString(event), structBuilder)
|
||||
val struct = structBuilder.build()
|
||||
|
||||
val logEntryBuilder = LogEntry.newBuilder(JsonPayload.of(struct))
|
||||
.setLogName(logName)
|
||||
.setSeverity(Severity.NOTICE)
|
||||
.setResource(MonitoredResourceUtil.getResource(projectId, "project"));
|
||||
if (labels != null) {
|
||||
logEntryBuilder.setLabels(labels);
|
||||
}
|
||||
logging.write(listOf(logEntryBuilder.build()))
|
||||
}
|
||||
}
|
||||
8
pom.xml
8
pom.xml
@@ -35,6 +35,7 @@
|
||||
</pluginRepositories>
|
||||
|
||||
<modules>
|
||||
<module>event-logger</module>
|
||||
<module>redis-dispatch</module>
|
||||
<module>websocket-resources</module>
|
||||
<module>service</module>
|
||||
@@ -53,6 +54,8 @@
|
||||
<jackson.version>2.13.3</jackson.version>
|
||||
<jaxb.version>2.3.1</jaxb.version>
|
||||
<jedis.version>2.9.0</jedis.version>
|
||||
<kotlin.version>1.7.10</kotlin.version>
|
||||
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
|
||||
<lettuce.version>6.1.9.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||
<logstash.logback.version>7.0.1</logstash.logback.version>
|
||||
@@ -62,7 +65,7 @@
|
||||
<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>21.2.0</stripe.version>
|
||||
@@ -115,7 +118,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>libraries-bom</artifactId>
|
||||
<version>20.9.0</version>
|
||||
<version>26.1.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -133,7 +136,6 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.eatthepath</groupId>
|
||||
<artifactId>pushy</artifactId>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
# `unset` values will need to be set to work properly.
|
||||
# Most other values are technically valid for a local/demonstration environment, but are probably not production-ready.
|
||||
|
||||
adminEventLoggingConfiguration:
|
||||
credentials: |
|
||||
Some credentials text
|
||||
blah blah blah
|
||||
projectId: some-project-id
|
||||
logName: some-log-name
|
||||
|
||||
stripe:
|
||||
apiKey: unset
|
||||
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>event-logger</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>redis-dispatch</artifactId>
|
||||
@@ -289,10 +294,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>
|
||||
@@ -408,14 +409,14 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.uuid</groupId>
|
||||
<artifactId>java-uuid-generator</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>4.0.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>DynamoDBLocal</artifactId>
|
||||
<version>1.17.2</version>
|
||||
<version>1.19.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.AbusiveMessageFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||
@@ -53,6 +54,11 @@ import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
||||
public class WhisperServerConfiguration extends Configuration {
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AdminEventLoggingConfiguration adminEventLoggingConfiguration;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -257,6 +263,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
||||
|
||||
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
|
||||
return adminEventLoggingConfiguration;
|
||||
}
|
||||
|
||||
public StripeConfiguration getStripe() {
|
||||
return stripe;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.cloud.logging.LoggingOptions;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.Lists;
|
||||
@@ -34,7 +36,9 @@ import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||
import io.micrometer.datadog.DatadogMeterRegistry;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
@@ -55,6 +59,8 @@ import javax.servlet.FilterRegistration;
|
||||
import javax.servlet.ServletRegistration;
|
||||
import org.eclipse.jetty.servlets.CrossOriginFilter;
|
||||
import org.glassfish.jersey.server.ServerProperties;
|
||||
import org.signal.event.AdminEventLogger;
|
||||
import org.signal.event.GoogleCloudAdminEventLogger;
|
||||
import org.signal.i18n.HeaderControlledResourceBundleLookup;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
|
||||
@@ -80,7 +86,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;
|
||||
@@ -119,7 +124,6 @@ import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitChallengeExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.metrics.ApplicationShutdownMonitor;
|
||||
@@ -136,7 +140,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 +151,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;
|
||||
@@ -188,6 +192,7 @@ import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListe
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
|
||||
@@ -196,7 +201,6 @@ 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.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
@@ -338,7 +342,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());
|
||||
@@ -407,6 +411,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
|
||||
.build();
|
||||
|
||||
final AdminEventLogger adminEventLogger = new GoogleCloudAdminEventLogger(
|
||||
LoggingOptions.newBuilder().setProjectId(config.getAdminEventLoggingConfiguration().projectId())
|
||||
.setCredentials(GoogleCredentials.fromStream(new ByteArrayInputStream(
|
||||
config.getAdminEventLoggingConfiguration().credentials().getBytes(StandardCharsets.UTF_8))))
|
||||
.build().getService(),
|
||||
config.getAdminEventLoggingConfiguration().projectId(),
|
||||
config.getAdminEventLoggingConfiguration().logName());
|
||||
|
||||
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
|
||||
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
|
||||
|
||||
@@ -448,7 +460,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 +644,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),
|
||||
@@ -646,7 +657,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
new RemoteConfigController(remoteConfigsManager, adminEventLogger, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
new SecureBackupController(backupCredentialsGenerator),
|
||||
new SecureStorageController(storageCredentialsGenerator),
|
||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||
@@ -705,13 +716,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
registerCorsFilter(environment);
|
||||
registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);
|
||||
|
||||
RateLimitChallengeExceptionMapper rateLimitChallengeExceptionMapper =
|
||||
new RateLimitChallengeExceptionMapper(rateLimitChallengeOptionManager);
|
||||
|
||||
environment.jersey().register(rateLimitChallengeExceptionMapper);
|
||||
webSocketEnvironment.jersey().register(rateLimitChallengeExceptionMapper);
|
||||
provisioningEnvironment.jersey().register(rateLimitChallengeExceptionMapper);
|
||||
|
||||
environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||
provisioningEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public record AdminEventLoggingConfiguration(
|
||||
@NotEmpty String credentials,
|
||||
@NotEmpty String projectId,
|
||||
@NotEmpty String logName) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,15 @@ 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);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration stories = new RateLimitConfiguration(10_000, 10_000 / (24.0 * 60.0));
|
||||
|
||||
public RateLimitConfiguration getAutoBlock() {
|
||||
return autoBlock;
|
||||
}
|
||||
@@ -137,10 +143,16 @@ public class RateLimitsConfiguration {
|
||||
return usernameSet;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getUsernameReserve() {
|
||||
return usernameReserve;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getCheckAccountExistence() {
|
||||
return checkAccountExistence;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getStories() { return stories; }
|
||||
|
||||
public static class RateLimitConfiguration {
|
||||
@JsonProperty
|
||||
private int bucketSize;
|
||||
|
||||
@@ -7,12 +7,13 @@ package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.Min;
|
||||
import java.time.Duration;
|
||||
|
||||
public class UsernameConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int discriminatorInitialWidth = 4;
|
||||
private int discriminatorInitialWidth = 2;
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
@@ -68,11 +69,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,11 +96,13 @@ 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;
|
||||
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.Optionals;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
||||
@@ -121,6 +127,7 @@ public class AccountController {
|
||||
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
|
||||
|
||||
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
|
||||
private static final String TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME = name(AccountController.class, "twilioUndelivered");
|
||||
|
||||
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
||||
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||
@@ -130,6 +137,7 @@ public class AccountController {
|
||||
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 SCORE_TAG_NAME = "score";
|
||||
|
||||
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
||||
|
||||
@@ -211,6 +219,7 @@ public class AccountController {
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/{transport}/code/{number}")
|
||||
@FilterAbusiveMessages
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response createAccount(@PathParam("transport") String transport,
|
||||
@PathParam("number") String number,
|
||||
@@ -227,23 +236,39 @@ public class AccountController {
|
||||
String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
|
||||
Optional<StoredVerificationCode> storedChallenge = pendingAccounts.getCodeForNumber(number);
|
||||
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, sourceHost, captcha,
|
||||
storedChallenge, pushChallenge, userAgent);
|
||||
|
||||
if (requirement.isCaptchaRequired()) {
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
// if there's a captcha, assess it, otherwise check if we need a captcha
|
||||
final Optional<RecaptchaClient.AssessmentResult> assessmentResult = captcha
|
||||
.map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost));
|
||||
|
||||
assessmentResult.ifPresent(result ->
|
||||
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
|
||||
Tag.of("success", String.valueOf(result.valid())),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
|
||||
Tag.of(REGION_TAG_NAME, region),
|
||||
Tag.of(SCORE_TAG_NAME, result.score())))
|
||||
.increment());
|
||||
|
||||
boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, storedChallenge);
|
||||
if (pushChallenge.isPresent() && !pushChallengeMatch) {
|
||||
throw new WebApplicationException(Response.status(403).build());
|
||||
}
|
||||
|
||||
final boolean requiresCaptcha = assessmentResult
|
||||
.map(result -> !result.valid())
|
||||
.orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, pushChallengeMatch));
|
||||
|
||||
if (requiresCaptcha) {
|
||||
captchaRequiredMeter.mark();
|
||||
|
||||
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);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
|
||||
return Response.status(402).build();
|
||||
}
|
||||
|
||||
@@ -311,6 +336,14 @@ public class AccountController {
|
||||
logger.warn("Error with Twilio Verify", throwable);
|
||||
return;
|
||||
}
|
||||
if (enrolledInVerifyExperiment && maybeVerificationSid.isEmpty() && assessmentResult.isPresent()) {
|
||||
Metrics.counter(TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME, Tags.of(
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
|
||||
Tag.of(REGION_TAG_NAME, region),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(SCORE_TAG_NAME, assessmentResult.get().score())))
|
||||
.increment();
|
||||
}
|
||||
maybeVerificationSid.ifPresent(twilioVerificationSid -> {
|
||||
StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode(
|
||||
storedVerificationCode.getCode(),
|
||||
@@ -642,6 +675,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 +731,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 +760,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)
|
||||
@@ -760,64 +827,35 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
|
||||
String sourceHost,
|
||||
Optional<String> captchaToken,
|
||||
Optional<StoredVerificationCode> storedVerificationCode,
|
||||
Optional<String> pushChallenge,
|
||||
String userAgent)
|
||||
{
|
||||
private boolean pushChallengeMatches(
|
||||
final String number,
|
||||
final Optional<String> pushChallenge,
|
||||
final Optional<StoredVerificationCode> storedVerificationCode) {
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::getPushCode);
|
||||
boolean match = Optionals.zipWith(pushChallenge, storedPushChallenge, String::equals).orElse(false);
|
||||
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,
|
||||
COUNTRY_CODE_TAG_NAME, countryCode,
|
||||
REGION_TAG_NAME, region,
|
||||
CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallenge.isPresent()),
|
||||
CHALLENGE_MATCH_TAG_NAME, Boolean.toString(match))
|
||||
.increment();
|
||||
return match;
|
||||
}
|
||||
|
||||
private boolean requiresCaptcha(String number, String transport, String forwardedFor, String sourceHost, boolean pushChallengeMatch) {
|
||||
if (testDevices.containsKey(number)) {
|
||||
return new CaptchaRequirement(false, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pushChallengeMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
if (captchaToken.isPresent()) {
|
||||
boolean validToken = recaptchaClient.verify(captchaToken.get(), sourceHost);
|
||||
|
||||
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);
|
||||
} else {
|
||||
return new CaptchaRequirement(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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()) {
|
||||
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "true"));
|
||||
|
||||
Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::getPushCode);
|
||||
|
||||
if (!pushChallenge.get().equals(storedPushChallenge.orElse(null))) {
|
||||
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "false"));
|
||||
return new CaptchaRequirement(true, false);
|
||||
} else {
|
||||
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "true"));
|
||||
}
|
||||
} else {
|
||||
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "false"));
|
||||
|
||||
return new CaptchaRequirement(true, false);
|
||||
}
|
||||
} finally {
|
||||
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, tags).increment();
|
||||
}
|
||||
}
|
||||
|
||||
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
|
||||
.getCaptchaConfiguration();
|
||||
|
||||
@@ -832,7 +870,7 @@ public class AccountController {
|
||||
// would be caught by country filter as well
|
||||
countryFilterApplicable.mark();
|
||||
}
|
||||
return new CaptchaRequirement(true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -840,7 +878,11 @@ public class AccountController {
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||
rateLimitedHostMeter.mark();
|
||||
return new CaptchaRequirement(true, true);
|
||||
if (shouldAutoBlock(sourceHost)) {
|
||||
logger.info("Auto-block: {}", sourceHost);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -848,15 +890,18 @@ public class AccountController {
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||
rateLimitedPrefixMeter.mark();
|
||||
return new CaptchaRequirement(true, true);
|
||||
if (shouldAutoBlock(sourceHost)) {
|
||||
logger.info("Auto-block: {}", sourceHost);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (countryFiltered) {
|
||||
countryFilteredHostMeter.mark();
|
||||
return new CaptchaRequirement(true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return new CaptchaRequirement(false, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -866,6 +911,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);
|
||||
@@ -894,22 +948,4 @@ public class AccountController {
|
||||
|
||||
return Hex.toStringCondensed(challenge);
|
||||
}
|
||||
|
||||
private static class CaptchaRequirement {
|
||||
private final boolean captchaRequired;
|
||||
private final boolean autoBlock;
|
||||
|
||||
private CaptchaRequirement(boolean captchaRequired, boolean autoBlock) {
|
||||
this.captchaRequired = captchaRequired;
|
||||
this.autoBlock = autoBlock;
|
||||
}
|
||||
|
||||
boolean isCaptchaRequired() {
|
||||
return captchaRequired;
|
||||
}
|
||||
|
||||
boolean isAutoBlock() {
|
||||
return autoBlock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -33,7 +34,9 @@ import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
@@ -72,7 +75,6 @@ import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
@@ -93,6 +95,7 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||
import org.whispersystems.websocket.Stories;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/messages")
|
||||
@@ -162,10 +165,11 @@ public class MessageController {
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||
@PathParam("destination") UUID destinationUuid,
|
||||
@QueryParam("story") boolean isStory,
|
||||
@NotNull @Valid IncomingMessageList messages)
|
||||
throws RateLimitExceededException, RateLimitChallengeException {
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (source.isEmpty() && accessKey.isEmpty()) {
|
||||
if (source.isEmpty() && accessKey.isEmpty() && !isStory) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -205,11 +209,18 @@ public class MessageController {
|
||||
destination = source.map(AuthenticatedAccount::getAccount);
|
||||
}
|
||||
|
||||
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
|
||||
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||
if (!isStory) {
|
||||
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
|
||||
}
|
||||
assert (destination.isPresent());
|
||||
|
||||
if (source.isPresent() && !isSyncMessage) {
|
||||
checkRateLimit(source.get(), destination.get(), userAgent);
|
||||
checkMessageRateLimit(source.get(), destination.get(), userAgent);
|
||||
}
|
||||
|
||||
if (isStory) {
|
||||
checkStoryRateLimit(destination.get());
|
||||
}
|
||||
|
||||
final Set<Long> excludedDeviceIds;
|
||||
@@ -239,12 +250,12 @@ public class MessageController {
|
||||
|
||||
if (destinationDevice.isPresent()) {
|
||||
Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment();
|
||||
sendMessage(source, destination.get(), destinationDevice.get(), destinationUuid, messages.timestamp(), messages.online(), messages.urgent(), incomingMessage, userAgent);
|
||||
sendIndividualMessage(source, destination.get(), destinationDevice.get(), destinationUuid, messages.timestamp(), messages.online(), isStory, messages.urgent(), incomingMessage, userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok(new SendMessageResponse(
|
||||
!isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1)).build();
|
||||
boolean needsSync = !isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1;
|
||||
return Response.ok(new SendMessageResponse(needsSync)).build();
|
||||
} catch (NoSuchUserException e) {
|
||||
throw new WebApplicationException(Response.status(404).build());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
@@ -261,6 +272,35 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build mapping of accounts to devices/registration IDs.
|
||||
* <p>
|
||||
* Messages that are stories will only be sent to the subset of recipients who have indicated they want to receive
|
||||
* stories.
|
||||
*
|
||||
* @param multiRecipientMessage
|
||||
* @param uuidToAccountMap
|
||||
* @return
|
||||
*/
|
||||
private Map<Account, Set<Pair<Long, Integer>>> buildDeviceIdAndRegistrationIdMap(
|
||||
MultiRecipientMessage multiRecipientMessage,
|
||||
Map<UUID, Account> uuidToAccountMap
|
||||
) {
|
||||
|
||||
Stream<Recipient> recipients = Arrays.stream(multiRecipientMessage.getRecipients());
|
||||
|
||||
return recipients.collect(Collectors.toMap(
|
||||
recipient -> uuidToAccountMap.get(recipient.getUuid()),
|
||||
recipient -> new HashSet<>(
|
||||
Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))),
|
||||
(a, b) -> {
|
||||
a.addAll(b);
|
||||
return a;
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@Timed
|
||||
@Path("/multi_recipient")
|
||||
@PUT
|
||||
@@ -268,43 +308,51 @@ public class MessageController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@FilterAbusiveMessages
|
||||
public Response sendMultiRecipientMessage(
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||
@QueryParam("online") boolean online,
|
||||
@QueryParam("ts") long timestamp,
|
||||
@QueryParam("story") boolean isStory,
|
||||
@NotNull @Valid MultiRecipientMessage multiRecipientMessage) {
|
||||
|
||||
Map<UUID, Account> uuidToAccountMap = Arrays.stream(multiRecipientMessage.getRecipients())
|
||||
.map(Recipient::getUuid)
|
||||
.distinct()
|
||||
.collect(Collectors.toUnmodifiableMap(Function.identity(), uuid -> {
|
||||
Optional<Account> account = accountsManager.getByAccountIdentifier(uuid);
|
||||
if (account.isEmpty()) {
|
||||
throw new WebApplicationException(Status.NOT_FOUND);
|
||||
}
|
||||
return account.get();
|
||||
}));
|
||||
checkAccessKeys(accessKeys, uuidToAccountMap);
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
Function.identity(),
|
||||
uuid -> accountsManager
|
||||
.getByAccountIdentifier(uuid)
|
||||
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND))));
|
||||
|
||||
final Map<Account, HashSet<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
|
||||
Arrays
|
||||
.stream(multiRecipientMessage.getRecipients())
|
||||
.collect(Collectors.toMap(
|
||||
recipient -> uuidToAccountMap.get(recipient.getUuid()),
|
||||
recipient -> new HashSet<>(
|
||||
Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))),
|
||||
(a, b) -> {
|
||||
a.addAll(b);
|
||||
return a;
|
||||
}
|
||||
));
|
||||
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||
if (!isStory) {
|
||||
checkAccessKeys(accessKeys, uuidToAccountMap);
|
||||
}
|
||||
|
||||
final Map<Account, Set<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
|
||||
buildDeviceIdAndRegistrationIdMap(multiRecipientMessage, uuidToAccountMap);
|
||||
|
||||
// We might filter out all the recipients of a story (if none have enabled stories).
|
||||
// In this case there is no error so we should just return 200 now.
|
||||
if (isStory && accountToDeviceIdAndRegistrationIdMap.isEmpty()) {
|
||||
return Response.ok(new SendMultiRecipientMessageResponse(new LinkedList<>())).build();
|
||||
}
|
||||
|
||||
Collection<AccountMismatchedDevices> accountMismatchedDevices = new ArrayList<>();
|
||||
Collection<AccountStaleDevices> accountStaleDevices = new ArrayList<>();
|
||||
uuidToAccountMap.values().forEach(account -> {
|
||||
final Set<Long> deviceIds = accountToDeviceIdAndRegistrationIdMap.get(account).stream().map(Pair::first)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (isStory) {
|
||||
checkStoryRateLimit(account);
|
||||
}
|
||||
|
||||
Set<Long> deviceIds = accountToDeviceIdAndRegistrationIdMap
|
||||
.getOrDefault(account, Collections.emptySet())
|
||||
.stream()
|
||||
.map(Pair::first)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
try {
|
||||
DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, Collections.emptySet());
|
||||
|
||||
@@ -352,8 +400,8 @@ public class MessageController {
|
||||
Device destinationDevice = destinationAccount.getDevice(recipient.getDeviceId()).orElseThrow();
|
||||
sentMessageCounter.increment();
|
||||
try {
|
||||
sendMessage(destinationAccount, destinationDevice, timestamp, online, recipient,
|
||||
multiRecipientMessage.getCommonPayload());
|
||||
sendCommonPayloadMessage(destinationAccount, destinationDevice, timestamp, online, isStory,
|
||||
recipient, multiRecipientMessage.getCommonPayload());
|
||||
} catch (NoSuchUserException e) {
|
||||
uuids404.add(destinationAccount.getUuid());
|
||||
}
|
||||
@@ -368,6 +416,10 @@ public class MessageController {
|
||||
}
|
||||
|
||||
private void checkAccessKeys(CombinedUnidentifiedSenderAccessKeys accessKeys, Map<UUID, Account> uuidToAccountMap) {
|
||||
// We should not have null access keys when checking access; bail out early.
|
||||
if (accessKeys == null) {
|
||||
throw new WebApplicationException(Status.UNAUTHORIZED);
|
||||
}
|
||||
AtomicBoolean throwUnauthorized = new AtomicBoolean(false);
|
||||
byte[] empty = new byte[16];
|
||||
final Optional<byte[]> UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY = Optional.of(new byte[16]);
|
||||
@@ -406,8 +458,11 @@ public class MessageController {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public OutgoingMessageEntityList getPendingMessages(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(Stories.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
|
||||
@HeaderParam("User-Agent") String userAgent) {
|
||||
|
||||
boolean shouldReceiveStories = Stories.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
|
||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
||||
|
||||
final OutgoingMessageEntityList outgoingMessages;
|
||||
@@ -417,7 +472,12 @@ public class MessageController {
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
false);
|
||||
|
||||
outgoingMessages = new OutgoingMessageEntityList(messagesAndHasMore.first().stream()
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
|
||||
outgoingMessages = new OutgoingMessageEntityList(envelopes
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.peek(outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
|
||||
outgoingMessageEntity))
|
||||
@@ -515,12 +575,13 @@ public class MessageController {
|
||||
.build();
|
||||
}
|
||||
|
||||
private void sendMessage(Optional<AuthenticatedAccount> source,
|
||||
private void sendIndividualMessage(Optional<AuthenticatedAccount> source,
|
||||
Account destinationAccount,
|
||||
Device destinationDevice,
|
||||
UUID destinationUuid,
|
||||
long timestamp,
|
||||
boolean online,
|
||||
boolean story,
|
||||
boolean urgent,
|
||||
IncomingMessage incomingMessage,
|
||||
String userAgentString)
|
||||
@@ -533,6 +594,7 @@ public class MessageController {
|
||||
source.map(AuthenticatedAccount::getAccount).orElse(null),
|
||||
source.map(authenticatedAccount -> authenticatedAccount.getAuthenticatedDevice().getId()).orElse(null),
|
||||
timestamp == 0 ? System.currentTimeMillis() : timestamp,
|
||||
story,
|
||||
urgent);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
logger.warn("Received bad envelope type {} from {}", incomingMessage.type(), userAgentString);
|
||||
@@ -546,10 +608,11 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessage(Account destinationAccount,
|
||||
private void sendCommonPayloadMessage(Account destinationAccount,
|
||||
Device destinationDevice,
|
||||
long timestamp,
|
||||
boolean online,
|
||||
boolean story,
|
||||
Recipient recipient,
|
||||
byte[] commonPayload) throws NoSuchUserException {
|
||||
try {
|
||||
@@ -567,6 +630,7 @@ public class MessageController {
|
||||
.setTimestamp(timestamp == 0 ? serverTimestamp : timestamp)
|
||||
.setServerTimestamp(serverTimestamp)
|
||||
.setContent(ByteString.copyFrom(payload))
|
||||
.setStory(story)
|
||||
.setDestinationUuid(destinationAccount.getUuid().toString());
|
||||
|
||||
messageSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online);
|
||||
@@ -579,7 +643,14 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRateLimit(AuthenticatedAccount source, Account destination, String userAgent)
|
||||
private void checkStoryRateLimit(Account destination) {
|
||||
try {
|
||||
rateLimiters.getMessagesLimiter().validate(destination.getUuid());
|
||||
} catch (final RateLimitExceededException e) {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMessageRateLimit(AuthenticatedAccount source, Account destination, String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
final String senderCountryCode = Util.getCountryCode(source.getAccount().getNumber());
|
||||
|
||||
|
||||
@@ -387,10 +387,32 @@ public class ProfileController {
|
||||
|
||||
private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,
|
||||
Collection<BatchIdentityCheckResponse.Element> responseElements, MessageDigest md) {
|
||||
accountsManager.getByAccountIdentifier(element.aci()).ifPresent(account -> {
|
||||
|
||||
final Optional<Account> maybeAccount;
|
||||
final boolean usePhoneNumberIdentity;
|
||||
if (element.aci() != null) {
|
||||
maybeAccount = accountsManager.getByAccountIdentifier(element.aci());
|
||||
usePhoneNumberIdentity = false;
|
||||
} else {
|
||||
final Optional<Account> maybeAciAccount = accountsManager.getByAccountIdentifier(element.uuid());
|
||||
|
||||
if (maybeAciAccount.isEmpty()) {
|
||||
maybeAccount = accountsManager.getByPhoneNumberIdentifier(element.uuid());
|
||||
usePhoneNumberIdentity = true;
|
||||
} else {
|
||||
maybeAccount = maybeAciAccount;
|
||||
usePhoneNumberIdentity = false;
|
||||
}
|
||||
}
|
||||
|
||||
maybeAccount.ifPresent(account -> {
|
||||
if (account.getIdentityKey() == null || account.getPhoneNumberIdentityKey() == null) {
|
||||
return;
|
||||
}
|
||||
byte[] identityKeyBytes;
|
||||
try {
|
||||
identityKeyBytes = Base64.getDecoder().decode(account.getIdentityKey());
|
||||
identityKeyBytes = Base64.getDecoder().decode(usePhoneNumberIdentity ? account.getPhoneNumberIdentityKey()
|
||||
: account.getIdentityKey());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return;
|
||||
}
|
||||
@@ -399,7 +421,7 @@ public class ProfileController {
|
||||
byte[] fingerprint = Util.truncate(digest, 4);
|
||||
|
||||
if (!Arrays.equals(fingerprint, element.fingerprint())) {
|
||||
responseElements.add(new BatchIdentityCheckResponse.Element(element.aci(), identityKeyBytes));
|
||||
responseElements.add(new BatchIdentityCheckResponse.Element(element.aci(), element.uuid(), identityKeyBytes));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -31,6 +32,9 @@ import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.signal.event.AdminEventLogger;
|
||||
import org.signal.event.RemoteConfigDeleteEvent;
|
||||
import org.signal.event.RemoteConfigSetEvent;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
|
||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
@@ -42,13 +46,15 @@ import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
public class RemoteConfigController {
|
||||
|
||||
private final RemoteConfigsManager remoteConfigsManager;
|
||||
private final List<String> configAuthTokens;
|
||||
private final Map<String, String> globalConfig;
|
||||
private final AdminEventLogger adminEventLogger;
|
||||
private final List<String> configAuthTokens;
|
||||
private final Map<String, String> globalConfig;
|
||||
|
||||
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
||||
|
||||
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, List<String> configAuthTokens, Map<String, String> globalConfig) {
|
||||
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, List<String> configAuthTokens, Map<String, String> globalConfig) {
|
||||
this.remoteConfigsManager = remoteConfigsManager;
|
||||
this.adminEventLogger = Objects.requireNonNull(adminEventLogger);
|
||||
this.configAuthTokens = configAuthTokens;
|
||||
this.globalConfig = globalConfig;
|
||||
}
|
||||
@@ -88,6 +94,15 @@ public class RemoteConfigController {
|
||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
adminEventLogger.logEvent(
|
||||
new RemoteConfigSetEvent(
|
||||
configToken,
|
||||
config.getName(),
|
||||
config.getPercentage(),
|
||||
config.getDefaultValue(),
|
||||
config.getValue(),
|
||||
config.getHashKey(),
|
||||
config.getUuids().stream().map(UUID::toString).collect(Collectors.toList())));
|
||||
remoteConfigsManager.set(config);
|
||||
}
|
||||
|
||||
@@ -103,6 +118,7 @@ public class RemoteConfigController {
|
||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name));
|
||||
remoteConfigsManager.delete(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ public class AccountMismatchedDevices {
|
||||
@JsonProperty
|
||||
public final MismatchedDevices devices;
|
||||
|
||||
public String toString() {
|
||||
return "AccountMismatchedDevices(" + uuid + ", " + devices + ")";
|
||||
}
|
||||
|
||||
public AccountMismatchedDevices(final UUID uuid, final MismatchedDevices devices) {
|
||||
this.uuid = uuid;
|
||||
this.devices = devices;
|
||||
|
||||
@@ -15,6 +15,10 @@ public class AccountStaleDevices {
|
||||
@JsonProperty
|
||||
public final StaleDevices devices;
|
||||
|
||||
public String toString() {
|
||||
return "AccountStaleDevices(" + uuid + ", " + devices + ")";
|
||||
}
|
||||
|
||||
public AccountStaleDevices(final UUID uuid, final StaleDevices devices) {
|
||||
this.uuid = uuid;
|
||||
this.devices = devices;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
@@ -15,11 +16,22 @@ import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
public record BatchIdentityCheckRequest(@Valid @NotNull @Size(max = 1000) List<Element> elements) {
|
||||
|
||||
/**
|
||||
* @param aci account id
|
||||
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519
|
||||
* public key prefixed with 0x05)
|
||||
* @param uuid account id or phone number id
|
||||
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public
|
||||
* key prefixed with 0x05)
|
||||
*/
|
||||
public record Element(@NotNull UUID aci, @NotNull @ExactlySize(4) byte[] fingerprint) {
|
||||
public record Element(@Deprecated @Nullable UUID aci,
|
||||
@Nullable UUID uuid,
|
||||
@NotNull @ExactlySize(4) byte[] fingerprint) {
|
||||
|
||||
public Element {
|
||||
if (aci == null && uuid == null) {
|
||||
throw new IllegalArgumentException("aci and uuid cannot both be null");
|
||||
}
|
||||
|
||||
if (aci != null && uuid != null) {
|
||||
throw new IllegalArgumentException("aci and uuid cannot both be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,28 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public record BatchIdentityCheckResponse(@Valid List<Element> elements) {
|
||||
public record Element(@NotNull UUID aci, @NotNull @ExactlySize(33) byte[] identityKey) {}
|
||||
|
||||
public record Element(@Deprecated @JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID aci,
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID uuid,
|
||||
@NotNull @ExactlySize(33) byte[] identityKey) {
|
||||
|
||||
public Element {
|
||||
if (aci == null && uuid == null) {
|
||||
throw new IllegalArgumentException("aci and uuid cannot both be null");
|
||||
}
|
||||
|
||||
if (aci != null && uuid != null) {
|
||||
throw new IllegalArgumentException("aci and uuid cannot both be non-null");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -17,6 +17,7 @@ public record IncomingMessage(int type, long destinationDeviceId, int destinatio
|
||||
@Nullable Account sourceAccount,
|
||||
@Nullable Long sourceDeviceId,
|
||||
final long timestamp,
|
||||
final boolean story,
|
||||
final boolean urgent) {
|
||||
|
||||
final MessageProtos.Envelope.Type envelopeType = MessageProtos.Envelope.Type.forNumber(type());
|
||||
@@ -31,6 +32,7 @@ public record IncomingMessage(int type, long destinationDeviceId, int destinatio
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(System.currentTimeMillis())
|
||||
.setDestinationUuid(destinationUuid.toString())
|
||||
.setStory(story)
|
||||
.setUrgent(urgent);
|
||||
|
||||
if (sourceAccount != null && sourceDeviceId != null) {
|
||||
|
||||
@@ -21,6 +21,10 @@ public class MismatchedDevices {
|
||||
@VisibleForTesting
|
||||
public MismatchedDevices() {}
|
||||
|
||||
public String toString() {
|
||||
return "MismatchedDevices(" + missingDevices + ", " + extraDevices + ")";
|
||||
}
|
||||
|
||||
public MismatchedDevices(List<Long> missingDevices, List<Long> extraDevices) {
|
||||
this.missingDevices = missingDevices;
|
||||
this.extraDevices = extraDevices;
|
||||
|
||||
@@ -13,7 +13,7 @@ import javax.annotation.Nullable;
|
||||
|
||||
public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullable UUID sourceUuid, int sourceDevice,
|
||||
UUID destinationUuid, @Nullable UUID updatedPni, byte[] content,
|
||||
long serverTimestamp, boolean urgent) {
|
||||
long serverTimestamp, boolean urgent, boolean story) {
|
||||
|
||||
public MessageProtos.Envelope toEnvelope() {
|
||||
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
|
||||
@@ -22,6 +22,7 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
|
||||
.setServerTimestamp(serverTimestamp())
|
||||
.setDestinationUuid(destinationUuid().toString())
|
||||
.setServerGuid(guid().toString())
|
||||
.setStory(story)
|
||||
.setUrgent(urgent);
|
||||
|
||||
if (sourceUuid() != null) {
|
||||
@@ -51,7 +52,8 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
|
||||
envelope.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null,
|
||||
envelope.getContent().toByteArray(),
|
||||
envelope.getServerTimestamp(),
|
||||
envelope.getUrgent());
|
||||
envelope.getUrgent(),
|
||||
envelope.getStory());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -63,16 +65,23 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
|
||||
return false;
|
||||
}
|
||||
final OutgoingMessageEntity that = (OutgoingMessageEntity) o;
|
||||
return type == that.type && timestamp == that.timestamp && sourceDevice == that.sourceDevice
|
||||
&& serverTimestamp == that.serverTimestamp && guid.equals(that.guid)
|
||||
&& Objects.equals(sourceUuid, that.sourceUuid) && destinationUuid.equals(that.destinationUuid)
|
||||
&& Objects.equals(updatedPni, that.updatedPni) && Arrays.equals(content, that.content) && urgent == that.urgent;
|
||||
return guid.equals(that.guid) &&
|
||||
type == that.type &&
|
||||
timestamp == that.timestamp &&
|
||||
Objects.equals(sourceUuid, that.sourceUuid) &&
|
||||
sourceDevice == that.sourceDevice &&
|
||||
destinationUuid.equals(that.destinationUuid) &&
|
||||
Objects.equals(updatedPni, that.updatedPni) &&
|
||||
Arrays.equals(content, that.content) &&
|
||||
serverTimestamp == that.serverTimestamp &&
|
||||
urgent == that.urgent &&
|
||||
story == that.story;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hash(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni,
|
||||
serverTimestamp, urgent);
|
||||
serverTimestamp, urgent, story);
|
||||
result = 31 * result + Arrays.hashCode(content);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import org.whispersystems.textsecuregcm.util.Nickname;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
public record ReserveUsernameRequest(@Valid @Nickname String nickname) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record ReserveUsernameResponse(String username, UUID reservationToken) {}
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -16,6 +17,15 @@ public class SendMultiRecipientMessageResponse {
|
||||
public SendMultiRecipientMessageResponse() {
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "SendMultiRecipientMessageResponse(" + uuids404 + ")";
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public List<UUID> getUUIDs404() {
|
||||
return this.uuids404;
|
||||
}
|
||||
|
||||
public SendMultiRecipientMessageResponse(final List<UUID> uuids404) {
|
||||
this.uuids404 = uuids404;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ public class StaleDevices {
|
||||
|
||||
public StaleDevices() {}
|
||||
|
||||
public String toString() {
|
||||
return "StaleDevices(" + staleDevices + ")";
|
||||
}
|
||||
|
||||
public StaleDevices(List<Long> staleDevices) {
|
||||
this.staleDevices = staleDevices;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
public class RateLimitChallengeException extends Exception {
|
||||
|
||||
private final Account account;
|
||||
private final Duration retryAfter;
|
||||
|
||||
public RateLimitChallengeException(final Account account, final Duration retryAfter) {
|
||||
this.account = account;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
public Duration getRetryAfter() {
|
||||
return retryAfter;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public class RateLimitChallengeManager {
|
||||
|
||||
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid());
|
||||
|
||||
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp);
|
||||
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp).valid();
|
||||
|
||||
final Tags tags = Tags.of(
|
||||
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),
|
||||
|
||||
@@ -33,8 +33,12 @@ public class RateLimiters {
|
||||
private final RateLimiter usernameLookupLimiter;
|
||||
private final RateLimiter usernameSetLimiter;
|
||||
|
||||
private final RateLimiter usernameReserveLimiter;
|
||||
|
||||
private final RateLimiter checkAccountExistenceLimiter;
|
||||
|
||||
private final RateLimiter storiesLimiter;
|
||||
|
||||
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
|
||||
this.smsDestinationLimiter = new RateLimiter(cacheCluster, "smsDestination",
|
||||
config.getSmsDestination().getBucketSize(),
|
||||
@@ -108,9 +112,18 @@ 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());
|
||||
|
||||
this.storiesLimiter = new RateLimiter(cacheCluster, "stories",
|
||||
config.getStories().getBucketSize(),
|
||||
config.getStories().getLeakRatePerMinute());
|
||||
}
|
||||
|
||||
public RateLimiter getAllocateDeviceLimiter() {
|
||||
@@ -185,7 +198,13 @@ public class RateLimiters {
|
||||
return usernameSetLimiter;
|
||||
}
|
||||
|
||||
public RateLimiter getUsernameReserveLimiter() {
|
||||
return usernameReserveLimiter;
|
||||
}
|
||||
|
||||
public RateLimiter getCheckAccountExistenceLimiter() {
|
||||
return checkAccountExistenceLimiter;
|
||||
}
|
||||
|
||||
public RateLimiter getStoriesLimiter() { return storiesLimiter; }
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.mappers;
|
||||
|
||||
import java.util.UUID;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.entities.RateLimitChallenge;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeOptionManager;
|
||||
|
||||
public class RateLimitChallengeExceptionMapper implements ExceptionMapper<RateLimitChallengeException> {
|
||||
|
||||
private final RateLimitChallengeOptionManager rateLimitChallengeOptionManager;
|
||||
|
||||
public RateLimitChallengeExceptionMapper(final RateLimitChallengeOptionManager rateLimitChallengeOptionManager) {
|
||||
this.rateLimitChallengeOptionManager = rateLimitChallengeOptionManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response toResponse(final RateLimitChallengeException exception) {
|
||||
return Response.status(428)
|
||||
.entity(new RateLimitChallenge(UUID.randomUUID().toString(),
|
||||
rateLimitChallengeOptionManager.getChallengeOptions(exception.getAccount())))
|
||||
.header("Retry-After", exception.getRetryAfter().toSeconds())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSetting
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.recaptchaenterprise.v1.Assessment;
|
||||
import com.google.recaptchaenterprise.v1.Event;
|
||||
import com.google.recaptchaenterprise.v1.RiskAnalysis;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
@@ -22,10 +23,13 @@ import java.util.Objects;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
|
||||
public class RecaptchaClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class);
|
||||
|
||||
@VisibleForTesting
|
||||
static final String SEPARATOR = ".";
|
||||
@@ -33,6 +37,9 @@ public class RecaptchaClient {
|
||||
static final String V2_PREFIX = "signal-recaptcha-v2" + RecaptchaClient.SEPARATOR;
|
||||
private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments");
|
||||
|
||||
private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason");
|
||||
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason");
|
||||
|
||||
private final String projectPath;
|
||||
private final RecaptchaEnterpriseServiceClient client;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
@@ -77,7 +84,29 @@ public class RecaptchaClient {
|
||||
return parts;
|
||||
}
|
||||
|
||||
public boolean verify(final String input, final String ip) {
|
||||
/**
|
||||
* A captcha assessment
|
||||
*
|
||||
* @param valid whether the captcha was passed
|
||||
* @param score string representation of the risk level
|
||||
*/
|
||||
public record AssessmentResult(boolean valid, String score) {
|
||||
public static AssessmentResult invalid() {
|
||||
return new AssessmentResult(false, "");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* recaptcha enterprise scores are from [0.0, 1.0] in increments of .1
|
||||
* map to [0, 100] for easier interpretation
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static String scoreString(final float score) {
|
||||
return Integer.toString((int) (score * 100));
|
||||
}
|
||||
|
||||
|
||||
public AssessmentResult verify(final String input, final String ip) {
|
||||
final String[] parts = parseInputToken(input);
|
||||
|
||||
final String sitekey = parts[0];
|
||||
@@ -101,12 +130,27 @@ public class RecaptchaClient {
|
||||
"valid", String.valueOf(assessment.getTokenProperties().getValid()))
|
||||
.increment();
|
||||
|
||||
if (assessment.getTokenProperties().getValid()) {
|
||||
return assessment.getRiskAnalysis().getScore() >=
|
||||
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue();
|
||||
|
||||
if (assessment.getTokenProperties().getValid()) {
|
||||
final float score = assessment.getRiskAnalysis().getScore();
|
||||
log.debug("assessment for {} was valid, score: {}", expectedAction, score);
|
||||
for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) {
|
||||
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
|
||||
"action", String.valueOf(expectedAction),
|
||||
"score", scoreString(score),
|
||||
"reason", reason.name())
|
||||
.increment();
|
||||
}
|
||||
return new AssessmentResult(
|
||||
score >=
|
||||
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue(),
|
||||
scoreString(score));
|
||||
} else {
|
||||
return false;
|
||||
Metrics.counter(INVALID_REASON_COUNTER_NAME,
|
||||
"action", String.valueOf(expectedAction),
|
||||
"reason", assessment.getTokenProperties().getInvalidReason().name())
|
||||
.increment();
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -60,6 +60,8 @@ public class TwilioSmsSender {
|
||||
static final String SERVICE_NAME_TAG = "service";
|
||||
static final String STATUS_CODE_TAG_NAME = "statusCode";
|
||||
static final String ERROR_CODE_TAG_NAME = "errorCode";
|
||||
static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||
static final String REGION_TAG_NAME = "region";
|
||||
|
||||
private final String accountId;
|
||||
private final String accountToken;
|
||||
@@ -213,14 +215,19 @@ public class TwilioSmsSender {
|
||||
return true;
|
||||
} else if (response != null && response.isFailure()) {
|
||||
|
||||
String countryCode = Util.getCountryCode(destination);
|
||||
String region = Util.getRegion(destination);
|
||||
|
||||
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();
|
||||
ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code),
|
||||
COUNTRY_CODE_TAG_NAME, countryCode,
|
||||
REGION_TAG_NAME, region).increment();
|
||||
|
||||
logger.info("Failed with code={}, country={}",
|
||||
response.failureResponse.code,
|
||||
Util.getCountryCode(destination));
|
||||
countryCode);
|
||||
|
||||
return false;
|
||||
} else if (throwable != null) {
|
||||
|
||||
@@ -157,14 +157,19 @@ class TwilioVerifySender {
|
||||
}
|
||||
|
||||
if (twilioVerifyResponse.isFailure()) {
|
||||
String countryCode = Util.getCountryCode(destination);
|
||||
String region = Util.getRegion(destination);
|
||||
|
||||
Metrics.counter(TwilioSmsSender.FAILED_REQUEST_COUNTER_NAME,
|
||||
TwilioSmsSender.SERVICE_NAME_TAG, "verify",
|
||||
TwilioSmsSender.STATUS_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.status),
|
||||
TwilioSmsSender.ERROR_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.code)).increment();
|
||||
TwilioSmsSender.ERROR_CODE_TAG_NAME, String.valueOf(twilioVerifyResponse.failureResponse.code),
|
||||
TwilioSmsSender.COUNTRY_CODE_TAG_NAME, countryCode,
|
||||
TwilioSmsSender.REGION_TAG_NAME, region).increment();
|
||||
|
||||
logger.info("Failed with code={}, country={}",
|
||||
twilioVerifyResponse.failureResponse.code,
|
||||
Util.getCountryCode(destination));
|
||||
countryCode);
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
public class Optionals {
|
||||
|
||||
private Optionals() {}
|
||||
|
||||
/**
|
||||
* Apply a function to two optional arguments, returning empty if either argument is empty
|
||||
*
|
||||
* @param optionalT Optional of type T
|
||||
* @param optionalU Optional of type U
|
||||
* @param fun Function of T and U that returns R
|
||||
* @return The function applied to the values of optionalT and optionalU, or empty
|
||||
*/
|
||||
public static <T, U, R> Optional<R> zipWith(Optional<T> optionalT, Optional<U> optionalU, BiFunction<T, U, R> fun) {
|
||||
return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -33,7 +34,7 @@ public class UsernameGenerator {
|
||||
* Usernames typically consist of a nickname and an integer discriminator
|
||||
*/
|
||||
public static final Pattern NICKNAME_PATTERN = Pattern.compile("^[_a-z][_a-z0-9]{2,31}$");
|
||||
public static final String SEPARATOR = "#";
|
||||
public static final String SEPARATOR = ".";
|
||||
|
||||
private static final Counter USERNAME_NOT_AVAILABLE_COUNTER = Metrics.counter(name(UsernameGenerator.class, "usernameNotAvailable"));
|
||||
private static final DistributionSummary DISCRIMINATOR_ATTEMPT_COUNTER = Metrics.summary(name(UsernameGenerator.class, "discriminatorAttempts"));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +112,11 @@ public class UsernameGenerator {
|
||||
throw new IllegalArgumentException("Invalid nickname " + nickname);
|
||||
}
|
||||
// zero pad discriminators less than the discriminator initial width
|
||||
return String.format("%s#%0" + initialWidth + "d", nickname, discriminator);
|
||||
return String.format("%s%s%0" + initialWidth + "d", nickname, SEPARATOR, discriminator);
|
||||
}
|
||||
|
||||
public Duration getReservationTtl() {
|
||||
return reservationTtl;
|
||||
}
|
||||
|
||||
public static boolean isValidNickname(final String nickname) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
|
||||
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoField;
|
||||
@@ -22,7 +21,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public class Util {
|
||||
|
||||
@@ -81,7 +80,6 @@ public class Util {
|
||||
else return "0";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getRegion(final String number) {
|
||||
try {
|
||||
final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
|
||||
@@ -332,7 +332,16 @@ public class WebSocketConnection implements MessageAvailabilityListener, Displac
|
||||
final Envelope envelope = messages.get(i);
|
||||
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
|
||||
|
||||
if (envelope.getSerializedSize() > MAX_DESKTOP_MESSAGE_SIZE && isDesktopClient) {
|
||||
final boolean discard;
|
||||
if (isDesktopClient && envelope.getSerializedSize() > MAX_DESKTOP_MESSAGE_SIZE) {
|
||||
discard = true;
|
||||
} else if (envelope.getStory() && !client.shouldDeliverStories()) {
|
||||
discard = true;
|
||||
} else {
|
||||
discard = false;
|
||||
}
|
||||
|
||||
if (discard) {
|
||||
messagesManager.delete(auth.getAccount().getUuid(), device.getId(), messageGuid, envelope.getServerTimestamp());
|
||||
discardedMessagesMeter.mark();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ message Envelope {
|
||||
optional string destination_uuid = 13;
|
||||
optional bool urgent = 14 [default=true];
|
||||
optional string updated_pni = 15;
|
||||
// next: 16
|
||||
optional bool story = 16; // indicates that the content is a story.
|
||||
// next: 17
|
||||
}
|
||||
|
||||
message ProvisioningUuid {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,15 +32,21 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.client.Invocation;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
@@ -62,11 +68,13 @@ import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
|
||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||
@@ -80,6 +88,7 @@ import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.websocket.Stories;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class MessageControllerTest {
|
||||
@@ -87,10 +96,20 @@ class MessageControllerTest {
|
||||
private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111";
|
||||
private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID();
|
||||
private static final UUID SINGLE_DEVICE_PNI = UUID.randomUUID();
|
||||
private static final int SINGLE_DEVICE_ID1 = 1;
|
||||
private static final int SINGLE_DEVICE_REG_ID1 = 111;
|
||||
|
||||
private static final String MULTI_DEVICE_RECIPIENT = "+14152222222";
|
||||
private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID();
|
||||
private static final UUID MULTI_DEVICE_PNI = UUID.randomUUID();
|
||||
private static final int MULTI_DEVICE_ID1 = 1;
|
||||
private static final int MULTI_DEVICE_ID2 = 2;
|
||||
private static final int MULTI_DEVICE_ID3 = 3;
|
||||
private static final int MULTI_DEVICE_REG_ID1 = 222;
|
||||
private static final int MULTI_DEVICE_REG_ID2 = 333;
|
||||
private static final int MULTI_DEVICE_REG_ID3 = 444;
|
||||
|
||||
private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes();
|
||||
|
||||
private static final String INTERNATIONAL_RECIPIENT = "+61123456789";
|
||||
private static final UUID INTERNATIONAL_UUID = UUID.randomUUID();
|
||||
@@ -116,6 +135,7 @@ class MessageControllerTest {
|
||||
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
|
||||
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
|
||||
.addProvider(RateLimitExceededExceptionMapper.class)
|
||||
.addProvider(MultiRecipientMessageProvider.class)
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(
|
||||
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager,
|
||||
@@ -125,18 +145,18 @@ class MessageControllerTest {
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
final List<Device> singleDeviceList = List.of(
|
||||
generateTestDevice(1, 111, 1111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis())
|
||||
generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, 1111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis())
|
||||
);
|
||||
|
||||
final List<Device> multiDeviceList = List.of(
|
||||
generateTestDevice(1, 222, 2222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
generateTestDevice(2, 333, 3333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
generateTestDevice(3, 444, 4444, null, System.currentTimeMillis(), System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))
|
||||
generateTestDevice(MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, 2222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
generateTestDevice(MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, 3333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()),
|
||||
generateTestDevice(MULTI_DEVICE_ID3, MULTI_DEVICE_REG_ID3, 4444, null, System.currentTimeMillis(), System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31))
|
||||
);
|
||||
|
||||
Account singleDeviceAccount = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, "1234".getBytes());
|
||||
Account multiDeviceAccount = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, "1234".getBytes());
|
||||
internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID, UUID.randomUUID(), singleDeviceList, "1234".getBytes());
|
||||
Account singleDeviceAccount = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);
|
||||
Account multiDeviceAccount = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, UNIDENTIFIED_ACCESS_BYTES);
|
||||
internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID, UUID.randomUUID(), singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);
|
||||
|
||||
when(accountsManager.getByAccountIdentifier(eq(SINGLE_DEVICE_UUID))).thenReturn(Optional.of(singleDeviceAccount));
|
||||
when(accountsManager.getByPhoneNumberIdentifier(SINGLE_DEVICE_PNI)).thenReturn(Optional.of(singleDeviceAccount));
|
||||
@@ -171,7 +191,8 @@ class MessageControllerTest {
|
||||
rateLimiters,
|
||||
rateLimiter,
|
||||
pushNotificationManager,
|
||||
reportMessageManager
|
||||
reportMessageManager,
|
||||
multiRecipientMessageExecutor
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,7 +291,7 @@ class MessageControllerTest {
|
||||
resources.getJerseyTest()
|
||||
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
|
||||
.request()
|
||||
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString("1234".getBytes()))
|
||||
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES))
|
||||
.put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"),
|
||||
IncomingMessageList.class),
|
||||
MediaType.APPLICATION_JSON_TYPE));
|
||||
@@ -412,8 +433,9 @@ class MessageControllerTest {
|
||||
verifyNoMoreInteractions(messageSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetMessages() {
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testGetMessages(boolean receiveStories) {
|
||||
|
||||
final long timestampOne = 313377;
|
||||
final long timestampTwo = 313388;
|
||||
@@ -424,19 +446,15 @@ class MessageControllerTest {
|
||||
|
||||
final UUID updatedPniOne = UUID.randomUUID();
|
||||
|
||||
List<Envelope> messages = List.of(
|
||||
List<Envelope> envelopes = List.of(
|
||||
generateEnvelope(messageGuidOne, Envelope.Type.CIPHERTEXT_VALUE, timestampOne, sourceUuid, 2,
|
||||
AuthHelper.VALID_UUID, updatedPniOne, "hi there".getBytes(), 0),
|
||||
AuthHelper.VALID_UUID, updatedPniOne, "hi there".getBytes(), 0, false),
|
||||
generateEnvelope(messageGuidTwo, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo, sourceUuid, 2,
|
||||
AuthHelper.VALID_UUID, null, null, 0)
|
||||
AuthHelper.VALID_UUID, null, null, 0, true)
|
||||
);
|
||||
|
||||
OutgoingMessageEntityList messagesList = new OutgoingMessageEntityList(messages.stream()
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.toList(), false);
|
||||
|
||||
when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(1L), anyBoolean()))
|
||||
.thenReturn(new Pair<>(messages, false));
|
||||
.thenReturn(new Pair<>(envelopes, false));
|
||||
|
||||
final String userAgent = "Test-UA";
|
||||
|
||||
@@ -444,27 +462,39 @@ class MessageControllerTest {
|
||||
resources.getJerseyTest().target("/v1/messages/")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.header(Stories.X_SIGNAL_RECEIVE_STORIES, receiveStories ? "true" : "false")
|
||||
.header("USer-Agent", userAgent)
|
||||
.accept(MediaType.APPLICATION_JSON_TYPE)
|
||||
.get(OutgoingMessageEntityList.class);
|
||||
|
||||
assertEquals(response.messages().size(), 2);
|
||||
List<OutgoingMessageEntity> messages = response.messages();
|
||||
int expectedSize = receiveStories ? 2 : 1;
|
||||
assertEquals(expectedSize, messages.size());
|
||||
|
||||
assertEquals(response.messages().get(0).timestamp(), timestampOne);
|
||||
assertEquals(response.messages().get(1).timestamp(), timestampTwo);
|
||||
OutgoingMessageEntity first = messages.get(0);
|
||||
assertEquals(first.timestamp(), timestampOne);
|
||||
assertEquals(first.guid(), messageGuidOne);
|
||||
assertEquals(first.sourceUuid(), sourceUuid);
|
||||
assertEquals(updatedPniOne, first.updatedPni());
|
||||
|
||||
assertEquals(response.messages().get(0).guid(), messageGuidOne);
|
||||
assertEquals(response.messages().get(1).guid(), messageGuidTwo);
|
||||
|
||||
assertEquals(response.messages().get(0).sourceUuid(), sourceUuid);
|
||||
assertEquals(response.messages().get(1).sourceUuid(), sourceUuid);
|
||||
|
||||
assertEquals(updatedPniOne, response.messages().get(0).updatedPni());
|
||||
assertNull(response.messages().get(1).updatedPni());
|
||||
if (receiveStories) {
|
||||
OutgoingMessageEntity second = messages.get(1);
|
||||
assertEquals(second.timestamp(), timestampTwo);
|
||||
assertEquals(second.guid(), messageGuidTwo);
|
||||
assertEquals(second.sourceUuid(), sourceUuid);
|
||||
assertNull(second.updatedPni());
|
||||
}
|
||||
|
||||
verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> testGetMessages() {
|
||||
return Stream.of(
|
||||
Arguments.of(true),
|
||||
Arguments.of(false)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetMessagesBadAuth() {
|
||||
final long timestampOne = 313377;
|
||||
@@ -644,7 +674,7 @@ class MessageControllerTest {
|
||||
resources.getJerseyTest()
|
||||
.target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID))
|
||||
.request()
|
||||
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString("1234".getBytes()))
|
||||
.header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES))
|
||||
.put(Entity.entity(new IncomingMessageList(
|
||||
List.of(new IncomingMessage(1, 1L, 1, new String(contentBytes))), false, true,
|
||||
System.currentTimeMillis()),
|
||||
@@ -686,14 +716,166 @@ class MessageControllerTest {
|
||||
);
|
||||
}
|
||||
|
||||
private void writeMultiPayloadRecipient(ByteBuffer bb, long msb, long lsb, int deviceId, int regId) throws Exception {
|
||||
bb.putLong(msb); // uuid (first 8 bytes)
|
||||
bb.putLong(lsb); // uuid (last 8 bytes)
|
||||
int x = deviceId;
|
||||
// write the device-id in the 7-bit varint format we use, least significant bytes first.
|
||||
do {
|
||||
bb.put((byte)(x & 0x7f));
|
||||
x = x >>> 7;
|
||||
} while (x != 0);
|
||||
bb.putShort((short) regId); // registration id short
|
||||
bb.put(new byte[48]); // key material (48 bytes)
|
||||
}
|
||||
|
||||
private InputStream initializeMultiPayload(UUID recipientUUID, byte[] buffer) throws Exception {
|
||||
// initialize a binary payload according to our wire format
|
||||
ByteBuffer bb = ByteBuffer.wrap(buffer);
|
||||
bb.order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// determine how many recipient/device pairs we will be writing
|
||||
int count;
|
||||
if (recipientUUID == MULTI_DEVICE_UUID) { count = 2; }
|
||||
else if (recipientUUID == SINGLE_DEVICE_UUID) { count = 1; }
|
||||
else { throw new Exception("unknown UUID: " + recipientUUID); }
|
||||
|
||||
// first write the header header
|
||||
bb.put(MultiRecipientMessageProvider.VERSION); // version byte
|
||||
bb.put((byte)count); // count varint, # of active devices for this user
|
||||
|
||||
long msb = recipientUUID.getMostSignificantBits();
|
||||
long lsb = recipientUUID.getLeastSignificantBits();
|
||||
|
||||
// write the recipient data for each recipient/device pair
|
||||
if (recipientUUID == MULTI_DEVICE_UUID) {
|
||||
writeMultiPayloadRecipient(bb, msb, lsb, MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1);
|
||||
writeMultiPayloadRecipient(bb, msb, lsb, MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2);
|
||||
} else {
|
||||
writeMultiPayloadRecipient(bb, msb, lsb, SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1);
|
||||
}
|
||||
|
||||
// now write the actual message body (empty for now)
|
||||
bb.put(new byte[39]); // payload (variable but >= 32, 39 bytes here)
|
||||
|
||||
// return the input stream
|
||||
return new ByteArrayInputStream(buffer, 0, bb.position());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testMultiRecipientMessage(UUID recipientUUID, boolean authorize, boolean isStory) throws Exception {
|
||||
|
||||
// initialize our binary payload and create an input stream
|
||||
byte[] buffer = new byte[2048];
|
||||
InputStream stream = initializeMultiPayload(recipientUUID, buffer);
|
||||
|
||||
// set up the entity to use in our PUT request
|
||||
Entity<InputStream> entity = Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE);
|
||||
|
||||
// start building the request
|
||||
Invocation.Builder bldr = resources
|
||||
.getJerseyTest()
|
||||
.target("/v1/messages/multi_recipient")
|
||||
.queryParam("online", true)
|
||||
.queryParam("ts", 1663798405641L)
|
||||
.queryParam("story", isStory)
|
||||
.request()
|
||||
.header("User-Agent", "FIXME");
|
||||
|
||||
// add access header if needed
|
||||
if (authorize) {
|
||||
String encodedBytes = Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES);
|
||||
bldr = bldr.header(OptionalAccess.UNIDENTIFIED, encodedBytes);
|
||||
}
|
||||
|
||||
// make the PUT request
|
||||
Response response = bldr.put(entity);
|
||||
|
||||
// We have a 2x2x2 grid of possible situations based on:
|
||||
// - recipient enabled stories?
|
||||
// - sender is authorized?
|
||||
// - message is a story?
|
||||
|
||||
if (recipientUUID == MULTI_DEVICE_UUID) {
|
||||
// This is the case where the recipient has enabled stories.
|
||||
if(isStory) {
|
||||
// We are sending a story, so we ignore access checks and expect this
|
||||
// to go out to both the recipient's devices.
|
||||
checkGoodMultiRecipientResponse(response, 2);
|
||||
} else {
|
||||
// We are not sending a story, so we need to do access checks.
|
||||
if (authorize) {
|
||||
// When authorized we send a message to the recipient's devices.
|
||||
checkGoodMultiRecipientResponse(response, 2);
|
||||
} else {
|
||||
// When forbidden, we return a 401 error.
|
||||
checkBadMultiRecipientResponse(response, 401);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is the case where the recipient has not enabled stories.
|
||||
if (isStory) {
|
||||
// We are sending a story, so we ignore access checks.
|
||||
// this recipient has one device.
|
||||
checkGoodMultiRecipientResponse(response, 1);
|
||||
} else {
|
||||
// We are not sending a story so check access.
|
||||
if (authorize) {
|
||||
// If allowed, send a message to the recipient's one device.
|
||||
checkGoodMultiRecipientResponse(response, 1);
|
||||
} else {
|
||||
// If forbidden, return a 401 error.
|
||||
checkBadMultiRecipientResponse(response, 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arguments here are: recipient-UUID, is-authorized?, is-story?
|
||||
private static Stream<Arguments> testMultiRecipientMessage() {
|
||||
return Stream.of(
|
||||
Arguments.of(MULTI_DEVICE_UUID, false, true),
|
||||
Arguments.of(MULTI_DEVICE_UUID, false, false),
|
||||
Arguments.of(SINGLE_DEVICE_UUID, false, true),
|
||||
Arguments.of(SINGLE_DEVICE_UUID, false, false),
|
||||
Arguments.of(MULTI_DEVICE_UUID, true, true),
|
||||
Arguments.of(MULTI_DEVICE_UUID, true, false),
|
||||
Arguments.of(SINGLE_DEVICE_UUID, true, true),
|
||||
Arguments.of(SINGLE_DEVICE_UUID, true, false)
|
||||
);
|
||||
}
|
||||
|
||||
private void checkBadMultiRecipientResponse(Response response, int expectedCode) throws Exception {
|
||||
assertThat("Unexpected response", response.getStatus(), is(equalTo(expectedCode)));
|
||||
verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean());
|
||||
verify(multiRecipientMessageExecutor, never()).invokeAll(any());
|
||||
}
|
||||
|
||||
private void checkGoodMultiRecipientResponse(Response response, int expectedCount) throws Exception {
|
||||
assertThat("Unexpected response", response.getStatus(), is(equalTo(200)));
|
||||
verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean());
|
||||
ArgumentCaptor<List<Callable<Void>>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(multiRecipientMessageExecutor, times(1)).invokeAll(captor.capture());
|
||||
assert (captor.getValue().size() == expectedCount);
|
||||
SendMultiRecipientMessageResponse smrmr = response.readEntity(SendMultiRecipientMessageResponse.class);
|
||||
assert (smrmr.getUUIDs404().isEmpty());
|
||||
}
|
||||
|
||||
private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,
|
||||
int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp) {
|
||||
return generateEnvelope(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, content, serverTimestamp, false);
|
||||
}
|
||||
|
||||
private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,
|
||||
int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp, boolean story) {
|
||||
|
||||
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
|
||||
.setType(MessageProtos.Envelope.Type.forNumber(type))
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(serverTimestamp)
|
||||
.setDestinationUuid(destinationUuid.toString())
|
||||
.setStory(story)
|
||||
.setServerGuid(guid.toString());
|
||||
|
||||
if (sourceUuid != null) {
|
||||
|
||||
@@ -120,14 +120,19 @@ class ProfileControllerTest {
|
||||
private static final RateLimiter usernameRateLimiter = mock(RateLimiter.class);
|
||||
|
||||
private static final S3Client s3client = mock(S3Client.class);
|
||||
private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey");
|
||||
private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket",
|
||||
"accessKey");
|
||||
private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
|
||||
private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);
|
||||
|
||||
private static final byte[] UNIDENTIFIED_ACCESS_KEY = "test-uak".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final String ACCOUNT_IDENTITY_KEY = "barz";
|
||||
private static final String ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = "bazz";
|
||||
private static final String ACCOUNT_TWO_IDENTITY_KEY = "bar";
|
||||
private static final String ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY = "baz";
|
||||
@SuppressWarnings("unchecked")
|
||||
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
||||
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(
|
||||
DynamicConfigurationManager.class);
|
||||
|
||||
private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
|
||||
private Account profileAccount;
|
||||
@@ -183,8 +188,8 @@ class ProfileControllerTest {
|
||||
|
||||
profileAccount = mock(Account.class);
|
||||
|
||||
when(profileAccount.getIdentityKey()).thenReturn("bar");
|
||||
when(profileAccount.getPhoneNumberIdentityKey()).thenReturn("baz");
|
||||
when(profileAccount.getIdentityKey()).thenReturn(ACCOUNT_TWO_IDENTITY_KEY);
|
||||
when(profileAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);
|
||||
when(profileAccount.getUuid()).thenReturn(AuthHelper.VALID_UUID_TWO);
|
||||
when(profileAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI_TWO);
|
||||
when(profileAccount.isEnabled()).thenReturn(true);
|
||||
@@ -199,7 +204,8 @@ class ProfileControllerTest {
|
||||
|
||||
Account capabilitiesAccount = mock(Account.class);
|
||||
|
||||
when(capabilitiesAccount.getIdentityKey()).thenReturn("barz");
|
||||
when(capabilitiesAccount.getIdentityKey()).thenReturn(ACCOUNT_IDENTITY_KEY);
|
||||
when(capabilitiesAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_PHONE_NUMBER_IDENTITY_KEY);
|
||||
when(capabilitiesAccount.isEnabled()).thenReturn(true);
|
||||
when(capabilitiesAccount.isGroupsV2Supported()).thenReturn(true);
|
||||
when(capabilitiesAccount.isGv1MigrationSupported()).thenReturn(true);
|
||||
@@ -242,7 +248,7 @@ class ProfileControllerTest {
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.get(BaseProfileResponse.class);
|
||||
|
||||
assertThat(profile.getIdentityKey()).isEqualTo("bar");
|
||||
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
|
||||
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
|
||||
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
|
||||
|
||||
@@ -272,7 +278,7 @@ class ProfileControllerTest {
|
||||
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
|
||||
.get(BaseProfileResponse.class);
|
||||
|
||||
assertThat(profile.getIdentityKey()).isEqualTo("bar");
|
||||
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
|
||||
assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(
|
||||
badge -> "Test Badge".equals(badge.getName()), "has badge with expected name"));
|
||||
|
||||
@@ -310,7 +316,7 @@ class ProfileControllerTest {
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.get(BaseProfileResponse.class);
|
||||
|
||||
assertThat(profile.getIdentityKey()).isEqualTo("baz");
|
||||
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);
|
||||
assertThat(profile.getBadges()).isEmpty();
|
||||
assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_PNI_TWO);
|
||||
assertThat(profile.getCapabilities()).isNotNull();
|
||||
@@ -742,7 +748,7 @@ class ProfileControllerTest {
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.get(VersionedProfileResponse.class);
|
||||
|
||||
assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo("bar");
|
||||
assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
|
||||
assertThat(profile.getName()).isEqualTo("validname");
|
||||
assertThat(profile.getAbout()).isEqualTo("about");
|
||||
assertThat(profile.getAboutEmoji()).isEqualTo("emoji");
|
||||
@@ -1227,9 +1233,14 @@ class ProfileControllerTest {
|
||||
void testBatchIdentityCheck() {
|
||||
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
|
||||
.post(Entity.json(new BatchIdentityCheckRequest(List.of(
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("barz")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("bar")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("baz"))
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null,
|
||||
convertStringToFingerprint(ACCOUNT_IDENTITY_KEY)),
|
||||
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO,
|
||||
convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY)),
|
||||
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO,
|
||||
convertStringToFingerprint(ACCOUNT_TWO_IDENTITY_KEY)),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null,
|
||||
convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY))
|
||||
))))) {
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
@@ -1238,37 +1249,44 @@ class ProfileControllerTest {
|
||||
assertThat(identityCheckResponse.elements()).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
Condition<BatchIdentityCheckResponse.Element> isEitherUuid1orUuid2 = new Condition<>(element -> {
|
||||
if (AuthHelper.VALID_UUID.equals(element.aci())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode("barz"), element.identityKey());
|
||||
} else if (AuthHelper.VALID_UUID_TWO.equals(element.aci())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode("bar"), element.identityKey());
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, "is either UUID 1 or UUID 2 with the correct identity key");
|
||||
Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid = new Condition<>(element -> {
|
||||
if (AuthHelper.VALID_UUID.equals(element.aci())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey());
|
||||
} else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey());
|
||||
} else if (AuthHelper.VALID_UUID_TWO.equals(element.uuid())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_IDENTITY_KEY), element.identityKey());
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, "is an expected UUID with the correct identity key");
|
||||
|
||||
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
|
||||
.post(Entity.json(new BatchIdentityCheckRequest(List.of(
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("else1234")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("another1")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("456"))
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")),
|
||||
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO,
|
||||
convertStringToFingerprint("another1")),
|
||||
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO,
|
||||
convertStringToFingerprint("another2")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456"))
|
||||
))))) {
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);
|
||||
assertThat(identityCheckResponse).isNotNull();
|
||||
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
|
||||
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isEitherUuid1orUuid2);
|
||||
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isEitherUuid1orUuid2);
|
||||
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(3);
|
||||
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
|
||||
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
|
||||
assertThat(identityCheckResponse.elements()).element(2).isNotNull().is(isAnExpectedUuid);
|
||||
}
|
||||
|
||||
List<BatchIdentityCheckRequest.Element> largeElementList = new ArrayList<>(List.of(
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, convertStringToFingerprint("else1234")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID_TWO, convertStringToFingerprint("another1")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, convertStringToFingerprint("456"))));
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")),
|
||||
new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO, convertStringToFingerprint("another1")),
|
||||
new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456"))));
|
||||
for (int i = 0; i < 900; i++) {
|
||||
largeElementList.add(new BatchIdentityCheckRequest.Element(UUID.randomUUID(), convertStringToFingerprint("abcd")));
|
||||
largeElementList.add(
|
||||
new BatchIdentityCheckRequest.Element(UUID.randomUUID(), null, convertStringToFingerprint("abcd")));
|
||||
}
|
||||
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
|
||||
.post(Entity.json(new BatchIdentityCheckRequest(largeElementList)))) {
|
||||
@@ -1277,12 +1295,104 @@ class ProfileControllerTest {
|
||||
BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);
|
||||
assertThat(identityCheckResponse).isNotNull();
|
||||
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
|
||||
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isEitherUuid1orUuid2);
|
||||
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isEitherUuid1orUuid2);
|
||||
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
|
||||
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] convertStringToFingerprint(String base64) {
|
||||
@Test
|
||||
void testBatchIdentityCheckDeserialization() throws Exception {
|
||||
|
||||
Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid = new Condition<>(element -> {
|
||||
if (AuthHelper.VALID_UUID.equals(element.aci())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey());
|
||||
} else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) {
|
||||
return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey());
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, "is an expected UUID with the correct identity key");
|
||||
|
||||
// null properties are ok to omit
|
||||
String json = String.format("""
|
||||
{
|
||||
"elements": [
|
||||
{ "aci": "%s", "fingerprint": "%s" },
|
||||
{ "uuid": "%s", "fingerprint": "%s" },
|
||||
{ "aci": "%s", "fingerprint": "%s" }
|
||||
]
|
||||
}
|
||||
""", AuthHelper.VALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234")),
|
||||
AuthHelper.VALID_PNI_TWO, Base64.getEncoder().encodeToString(convertStringToFingerprint("another1")),
|
||||
AuthHelper.INVALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("456")));
|
||||
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
|
||||
.post(Entity.entity(json, "application/json"))) {
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
String responseJson = response.readEntity(String.class);
|
||||
|
||||
// `null` properties should be omitted from the response
|
||||
assertThat(responseJson).doesNotContain("null");
|
||||
|
||||
BatchIdentityCheckResponse identityCheckResponse = SystemMapper.getMapper()
|
||||
.readValue(responseJson, BatchIdentityCheckResponse.class);
|
||||
assertThat(identityCheckResponse).isNotNull();
|
||||
assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);
|
||||
assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);
|
||||
assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testBatchIdentityCheckDeserializationBadRequest(final String json) {
|
||||
try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request()
|
||||
.post(Entity.entity(json, "application/json"))) {
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
}
|
||||
}
|
||||
|
||||
static Stream<Arguments> testBatchIdentityCheckDeserializationBadRequest() {
|
||||
return Stream.of(
|
||||
Arguments.of( // aci and uuid cannot both be null
|
||||
"""
|
||||
{
|
||||
"elements": [
|
||||
{ "aci": null, "uuid": null, "fingerprint": "%s" }
|
||||
]
|
||||
}
|
||||
"""),
|
||||
Arguments.of( // an empty string is also invalid
|
||||
"""
|
||||
{
|
||||
"elements": [
|
||||
{ "aci": "", "uuid": null, "fingerprint": "%s" }
|
||||
]
|
||||
}
|
||||
"""
|
||||
),
|
||||
Arguments.of( // as is a blank string
|
||||
"""
|
||||
{
|
||||
"elements": [
|
||||
{ "aci": null, "uuid": " ", "fingerprint": "%s" }
|
||||
]
|
||||
}
|
||||
"""),
|
||||
Arguments.of( // aci and uuid cannot both be non-null
|
||||
String.format("""
|
||||
{
|
||||
"elements": [
|
||||
{ "aci": "%s", "uuid": "%s", "fingerprint": "%s" }
|
||||
]
|
||||
}
|
||||
""", AuthHelper.VALID_UUID, AuthHelper.VALID_PNI,
|
||||
Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234"))))
|
||||
);
|
||||
}
|
||||
|
||||
private static byte[] convertStringToFingerprint(String base64) {
|
||||
MessageDigest sha256;
|
||||
try {
|
||||
sha256 = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -36,7 +36,8 @@ class OutgoingMessageEntityTest {
|
||||
updatedPni,
|
||||
messageContent,
|
||||
serverTimestamp,
|
||||
true);
|
||||
true,
|
||||
false);
|
||||
|
||||
assertEquals(outgoingMessageEntity, OutgoingMessageEntity.fromEnvelope(outgoingMessageEntity.toEnvelope()));
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ class RateLimitChallengeManagerTest {
|
||||
when(account.getNumber()).thenReturn("+18005551234");
|
||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
||||
|
||||
when(recaptchaClient.verify(any(), any())).thenReturn(successfulChallenge);
|
||||
when(recaptchaClient.verify(any(), any()))
|
||||
.thenReturn(successfulChallenge
|
||||
? new RecaptchaClient.AssessmentResult(true, "")
|
||||
: RecaptchaClient.AssessmentResult.invalid());
|
||||
|
||||
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
|
||||
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));
|
||||
|
||||
@@ -70,7 +70,7 @@ class MessageMetricsTest {
|
||||
}
|
||||
|
||||
private OutgoingMessageEntity createOutgoingMessageEntity(UUID destinationUuid) {
|
||||
return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationUuid, null, new byte[]{}, 1, true);
|
||||
return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationUuid, null, new byte[]{}, 1, true, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.recaptcha;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient.SEPARATOR;
|
||||
@@ -43,6 +44,21 @@ class RecaptchaClientTest {
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void scoreString(float score, String expected) {
|
||||
assertThat(RecaptchaClient.scoreString(score)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
static Stream<Arguments> scoreString() {
|
||||
return Stream.of(
|
||||
Arguments.of(0.3f, "30"),
|
||||
Arguments.of(0.0f, "0"),
|
||||
Arguments.of(0.333f, "33"),
|
||||
Arguments.of(Float.NaN, "0")
|
||||
);
|
||||
}
|
||||
|
||||
static Stream<Arguments> parseInputToken() {
|
||||
return Stream.of(
|
||||
Arguments.of(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -71,7 +72,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 +88,8 @@ class AccountsManagerTest {
|
||||
return null;
|
||||
};
|
||||
|
||||
private static final UUID RESERVATION_TOKEN = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws InterruptedException {
|
||||
accounts = mock(Accounts.class);
|
||||
@@ -95,7 +98,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 +152,7 @@ class AccountsManagerTest {
|
||||
directoryQueue,
|
||||
keys,
|
||||
messagesManager,
|
||||
reservedUsernames,
|
||||
prohibitedUsernames,
|
||||
profilesManager,
|
||||
mock(StoredVerificationCodeManager.class),
|
||||
storageClient,
|
||||
@@ -737,11 +740,70 @@ 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]);
|
||||
final String nickname = "test";
|
||||
account.setUsername(nickname + "#123");
|
||||
account.setUsername(nickname + ".123");
|
||||
|
||||
// should be treated as a replayed request
|
||||
assertDoesNotThrow(() -> accountsManager.setUsername(account, nickname, null));
|
||||
@@ -752,7 +814,7 @@ class AccountsManagerTest {
|
||||
void testSetUsernameReroll() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
final String username = nickname + "#ZZZ";
|
||||
final String username = nickname + ".ZZZ";
|
||||
account.setUsername(username);
|
||||
|
||||
// given the correct old username, should reroll discriminator even if the nick matches
|
||||
@@ -761,12 +823,34 @@ 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";
|
||||
|
||||
ArgumentMatcher<String> isWide = (String username) -> {
|
||||
String[] spl = username.split(UsernameGenerator.SEPARATOR);
|
||||
String[] spl = username.split(Pattern.quote(UsernameGenerator.SEPARATOR));
|
||||
assertEquals(spl.length, 2);
|
||||
int discriminator = Integer.parseInt(spl[1]);
|
||||
// require a 7 digit discriminator
|
||||
@@ -775,16 +859,22 @@ 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
|
||||
void testChangeUsername() throws UsernameNotAvailableException {
|
||||
final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]);
|
||||
final String nickname = "test";
|
||||
account.setUsername("old#123");
|
||||
accountsManager.setUsername(account, nickname, "old#123");
|
||||
account.setUsername("old.123");
|
||||
accountsManager.setUsername(account, nickname, "old.123");
|
||||
verify(accounts).setUsername(eq(account), startsWith(nickname));
|
||||
}
|
||||
|
||||
@@ -801,7 +891,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 +913,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -110,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);
|
||||
|
||||
@@ -127,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")
|
||||
@@ -141,8 +151,6 @@ class AccountsManagerUsernameIntegrationTest {
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);
|
||||
when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME)))
|
||||
.thenReturn(true);
|
||||
|
||||
usernameGenerator = new UsernameGenerator(1, 2, 10);
|
||||
accountsManager = new AccountsManager(
|
||||
accounts,
|
||||
phoneNumberIdentifiers,
|
||||
@@ -151,7 +159,7 @@ 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),
|
||||
@@ -163,7 +171,7 @@ class AccountsManagerUsernameIntegrationTest {
|
||||
}
|
||||
|
||||
private static int discriminator(String username) {
|
||||
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
|
||||
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -190,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();
|
||||
}
|
||||
|
||||
@@ -237,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import static org.mockito.Mockito.when;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.uuid.UUIDComparator;
|
||||
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;
|
||||
@@ -26,6 +29,7 @@ import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Supplier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
@@ -74,6 +78,7 @@ class AccountsTest {
|
||||
.build())
|
||||
.build();
|
||||
|
||||
private Clock mockClock;
|
||||
private DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager;
|
||||
private Accounts accounts;
|
||||
|
||||
@@ -130,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(),
|
||||
@@ -608,7 +617,7 @@ class AccountsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetUsername() throws UsernameNotAvailableException {
|
||||
void testSetUsername() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
@@ -679,7 +688,7 @@ class AccountsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearUsername() throws UsernameNotAvailableException {
|
||||
void testClearUsername() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
@@ -704,7 +713,7 @@ class AccountsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearUsernameVersionMismatch() throws UsernameNotAvailableException {
|
||||
void testClearUsernameVersionMismatch() {
|
||||
final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID());
|
||||
accounts.create(account);
|
||||
|
||||
@@ -719,6 +728,156 @@ 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);
|
||||
final String username = "snowball.02";
|
||||
|
||||
accounts.reserveUsername(account1, username, Duration.ofDays(2));
|
||||
|
||||
Supplier<UUID> take = () -> accounts.reserveUsername(account2, username, 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, username, Duration.ofDays(2)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.setUsername(account1, username));
|
||||
|
||||
accounts.confirmUsername(account2, username, token);
|
||||
assertThat(accounts.getByUsername(username).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);
|
||||
final String username = "simon.123";
|
||||
|
||||
accounts.reserveUsername(account1, username, Duration.ofDays(2));
|
||||
|
||||
Runnable take = () -> accounts.setUsername(account2, username);
|
||||
|
||||
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, username, Duration.ofDays(2)));
|
||||
assertThrows(ContestedOptimisticLockException.class,
|
||||
() -> accounts.setUsername(account1, username));
|
||||
assertThat(accounts.getByUsername(username).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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
@@ -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
|
||||
|
||||
@@ -73,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;
|
||||
@@ -99,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;
|
||||
@@ -121,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";
|
||||
@@ -144,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);
|
||||
@@ -208,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());
|
||||
@@ -293,8 +300,10 @@ class AccountControllerTest {
|
||||
when(abusiveHostRules.isBlocked(eq(ABUSIVE_HOST))).thenReturn(true);
|
||||
when(abusiveHostRules.isBlocked(eq(NICE_HOST))).thenReturn(false);
|
||||
|
||||
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())).thenReturn(false);
|
||||
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString())).thenReturn(true);
|
||||
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString()))
|
||||
.thenReturn(RecaptchaClient.AssessmentResult.invalid());
|
||||
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString()))
|
||||
.thenReturn(new RecaptchaClient.AssessmentResult(true, ""));
|
||||
|
||||
doThrow(new RateLimitExceededException(Duration.ZERO)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
|
||||
|
||||
@@ -319,6 +328,7 @@ class AccountControllerTest {
|
||||
smsVoicePrefixLimiter,
|
||||
autoBlockLimiter,
|
||||
usernameSetLimiter,
|
||||
usernameReserveLimiter,
|
||||
usernameLookupLimiter,
|
||||
smsSender,
|
||||
turnTokenGenerator,
|
||||
@@ -695,7 +705,7 @@ class AccountControllerTest {
|
||||
.header("X-Forwarded-For", NICE_HOST)
|
||||
.get();
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(402);
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
|
||||
verifyNoMoreInteractions(smsSender);
|
||||
verifyNoMoreInteractions(abusiveHostRules);
|
||||
@@ -1630,7 +1640,7 @@ class AccountControllerTest {
|
||||
assertThat(pinCapture.getValue()).isNotEmpty();
|
||||
assertThat(pinSaltCapture.getValue()).isNotEmpty();
|
||||
|
||||
assertThat(pinCapture.getValue().length()).isEqualTo(40);
|
||||
assertThat(pinCapture.getValue().length()).isEqualTo(66);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1720,7 +1730,7 @@ class AccountControllerTest {
|
||||
@Test
|
||||
void testSetUsername() throws UsernameNotAvailableException {
|
||||
Account account = mock(Account.class);
|
||||
when(account.getUsername()).thenReturn(Optional.of("n00bkiller#1234"));
|
||||
when(account.getUsername()).thenReturn(Optional.of("n00bkiller.1234"));
|
||||
when(accountsManager.setUsername(any(), eq("n00bkiller"), isNull()))
|
||||
.thenReturn(account);
|
||||
Response response =
|
||||
@@ -1730,7 +1740,64 @@ class AccountControllerTest {
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new UsernameRequest("n00bkiller", null)));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller#1234");
|
||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller.1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReserveUsername() throws UsernameNotAvailableException {
|
||||
when(accountsManager.reserveUsername(any(), eq("n00bkiller")))
|
||||
.thenReturn(new AccountsManager.UsernameReservation(null, "n00bkiller.1234", RESERVATION_TOKEN));
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/reserved")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ReserveUsernameRequest("n00bkiller")));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(ReserveUsernameResponse.class))
|
||||
.satisfies(r -> r.username().equals("n00bkiller.1234"))
|
||||
.satisfies(r -> r.reservationToken().equals(RESERVATION_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
Account account = mock(Account.class);
|
||||
when(account.getUsername()).thenReturn(Optional.of("n00bkiller.1234"));
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN))).thenReturn(account);
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(UsernameResponse.class).username()).isEqualTo("n00bkiller.1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitUnreservedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
|
||||
.thenThrow(new UsernameReservationNotFoundException());
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(409);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommitLapsedUsername() throws UsernameNotAvailableException, UsernameReservationNotFoundException {
|
||||
when(accountsManager.confirmReservedUsername(any(), eq("n00bkiller.1234"), eq(RESERVATION_TOKEN)))
|
||||
.thenThrow(new UsernameNotAvailableException());
|
||||
Response response =
|
||||
resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/confirm")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.put(Entity.json(new ConfirmUsernameRequest("n00bkiller.1234", RESERVATION_TOKEN)));
|
||||
assertThat(response.getStatus()).isEqualTo(410);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -2003,9 +2070,9 @@ class AccountControllerTest {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
when(account.getUuid()).thenReturn(uuid);
|
||||
|
||||
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.of(account));
|
||||
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.of(account));
|
||||
Response response = resources.getJerseyTest()
|
||||
.target("v1/accounts/username/n00bkiller#1234")
|
||||
.target("v1/accounts/username/n00bkiller.1234")
|
||||
.request()
|
||||
.header("X-Forwarded-For", "127.0.0.1")
|
||||
.get();
|
||||
@@ -2015,9 +2082,9 @@ class AccountControllerTest {
|
||||
|
||||
@Test
|
||||
void testLookupUsernameDoesNotExist() {
|
||||
when(accountsManager.getByUsername(eq("n00bkiller#1234"))).thenReturn(Optional.empty());
|
||||
when(accountsManager.getByUsername(eq("n00bkiller.1234"))).thenReturn(Optional.empty());
|
||||
assertThat(resources.getJerseyTest()
|
||||
.target("v1/accounts/username/n00bkiller#1234")
|
||||
.target("v1/accounts/username/n00bkiller.1234")
|
||||
.request()
|
||||
.header("X-Forwarded-For", "127.0.0.1")
|
||||
.get().getStatus()).isEqualTo(404);
|
||||
@@ -2027,7 +2094,7 @@ class AccountControllerTest {
|
||||
void testLookupUsernameRateLimited() throws RateLimitExceededException {
|
||||
doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(usernameLookupLimiter).validate("127.0.0.1");
|
||||
final Response response = resources.getJerseyTest()
|
||||
.target("/v1/accounts/username/test#123")
|
||||
.target("/v1/accounts/username/test.123")
|
||||
.request()
|
||||
.header("X-Forwarded-For", "127.0.0.1")
|
||||
.get();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.signal.event.NoOpAdminEventLogger;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
|
||||
@@ -57,7 +58,7 @@ class RemoteConfigControllerTest {
|
||||
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addProvider(new DeviceLimitExceededExceptionMapper())
|
||||
.addResource(new RemoteConfigController(remoteConfigsManager, remoteConfigsAuth, Map.of("maxGroupSize", "42")))
|
||||
.addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, Map.of("maxGroupSize", "42")))
|
||||
.build();
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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!"));
|
||||
|
||||
@@ -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 -> {});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||
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;
|
||||
@@ -17,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) {
|
||||
@@ -31,6 +34,7 @@ public class UsernameGeneratorTest {
|
||||
Arguments.of("ab\uD83D\uDC1B", false, "illegal character"),
|
||||
Arguments.of("1test", false, "illegal start"),
|
||||
Arguments.of("test#123", false, "illegal character"),
|
||||
Arguments.of("test.123", false, "illegal character"),
|
||||
Arguments.of("ab", false, "too short"),
|
||||
Arguments.of("", false, ""),
|
||||
Arguments.of("_123456789_123456789_123456789123", false, "33 characters"),
|
||||
@@ -51,38 +55,38 @@ public class UsernameGeneratorTest {
|
||||
|
||||
static Stream<Arguments> nonStandardUsernames() {
|
||||
return Stream.of(
|
||||
Arguments.of("Test#123", false),
|
||||
Arguments.of("test#-123", false),
|
||||
Arguments.of("test#0", false),
|
||||
Arguments.of("test#", false),
|
||||
Arguments.of("test#1_00", false),
|
||||
Arguments.of("Test.123", false),
|
||||
Arguments.of("test.-123", false),
|
||||
Arguments.of("test.0", false),
|
||||
Arguments.of("test.", false),
|
||||
Arguments.of("test.1_00", false),
|
||||
|
||||
Arguments.of("test#1", true),
|
||||
Arguments.of("abc#1234", true)
|
||||
Arguments.of("test.1", true),
|
||||
Arguments.of("abc.1234", true)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroPadDiscriminators() {
|
||||
final UsernameGenerator generator = new UsernameGenerator(4, 5, 1);
|
||||
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");
|
||||
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);
|
||||
@@ -90,7 +94,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);
|
||||
@@ -98,7 +102,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));
|
||||
@@ -107,7 +111,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)));
|
||||
@@ -120,7 +124,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))));
|
||||
@@ -136,6 +140,6 @@ public class UsernameGeneratorTest {
|
||||
}
|
||||
|
||||
private static int extractDiscriminator(final String username) {
|
||||
return Integer.parseInt(username.split(UsernameGenerator.SEPARATOR)[1]);
|
||||
return Integer.parseInt(username.substring(username.indexOf(UsernameGenerator.SEPARATOR) + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.whispersystems.websocket;
|
||||
|
||||
/**
|
||||
* Class containing constants and shared logic for handling stories.
|
||||
* <p>
|
||||
* In particular, it defines the way we interpret the X-Signal-Receive-Stories header
|
||||
* which is used by both WebSockets and by the REST API.
|
||||
*/
|
||||
public class Stories {
|
||||
public final static String X_SIGNAL_RECEIVE_STORIES = "X-Signal-Receive-Stories";
|
||||
|
||||
public static boolean parseReceiveStoriesHeader(String s) {
|
||||
return "true".equals(s);
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,12 @@ public class WebSocketClient {
|
||||
|
||||
public WebSocketClient(Session session, RemoteEndpoint remoteEndpoint,
|
||||
WebSocketMessageFactory messageFactory,
|
||||
Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper)
|
||||
{
|
||||
this.session = session;
|
||||
this.remoteEndpoint = remoteEndpoint;
|
||||
this.messageFactory = messageFactory;
|
||||
Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper) {
|
||||
this.session = session;
|
||||
this.remoteEndpoint = remoteEndpoint;
|
||||
this.messageFactory = messageFactory;
|
||||
this.pendingRequestMapper = pendingRequestMapper;
|
||||
this.created = System.currentTimeMillis();
|
||||
this.created = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public CompletableFuture<WebSocketResponseMessage> sendRequest(String verb, String path,
|
||||
@@ -92,6 +91,11 @@ public class WebSocketClient {
|
||||
session.close(code, message);
|
||||
}
|
||||
|
||||
public boolean shouldDeliverStories() {
|
||||
String value = session.getUpgradeRequest().getHeader(Stories.X_SIGNAL_RECEIVE_STORIES);
|
||||
return Stories.parseReceiveStoriesHeader(value);
|
||||
}
|
||||
|
||||
public void hardDisconnectQuietly() {
|
||||
try {
|
||||
session.disconnect();
|
||||
|
||||
@@ -653,12 +653,14 @@ class WebSocketResourceProviderTest {
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("Sec-WebSocket-Key")).isFalse();
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("User-Agent")).isTrue();
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Forwarded-For")).isTrue();
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Signal-Receive-Stories")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShouldIncludeRequestMessageHeader() {
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Forwarded-For")).isFalse();
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("User-Agent")).isTrue();
|
||||
assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Signal-Receive-Stories")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user