mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-11 01:40:22 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0178fa0ea | ||
|
|
c6a79ca176 | ||
|
|
6426e6cc49 | ||
|
|
b13cb098ce | ||
|
|
afda5ca98f | ||
|
|
eb57d87513 | ||
|
|
fbf6b9826e | ||
|
|
a01b29a6bd | ||
|
|
102992b095 | ||
|
|
bd69905f2e | ||
|
|
ce5a4bd94a | ||
|
|
f65a613815 | ||
|
|
d87c8468bd | ||
|
|
aa829af43b | ||
|
|
c10fda8363 | ||
|
|
4252284405 | ||
|
|
74d65b37a8 | ||
|
|
78f95e4859 | ||
|
|
91626dea45 | ||
|
|
5868d9969a | ||
|
|
90490c9c84 | ||
|
|
8ea794baef | ||
|
|
70a6c3e8e5 | ||
|
|
4813803c49 | ||
|
|
fe60cf003f | ||
|
|
0c357bc340 | ||
|
|
b711288faa | ||
|
|
44a5d86641 | ||
|
|
e7048aa9cf | ||
|
|
0120a85c39 | ||
|
|
a41d047f58 | ||
|
|
cccccb4dd6 | ||
|
|
0a64e31625 | ||
|
|
3c6c6c3706 | ||
|
|
8088b58b3b | ||
|
|
a7d5d51fb4 | ||
|
|
378d7987a8 | ||
|
|
3e0baf82a4 | ||
|
|
7a2683a06b | ||
|
|
17a3c90286 | ||
|
|
6341770768 | ||
|
|
308437ec93 | ||
|
|
d3d4916d6c | ||
|
|
d2fa00f0c6 | ||
|
|
d6c9652a70 | ||
|
|
0d20b73e76 | ||
|
|
3c655cdd5a | ||
|
|
fc5cd3a9ca | ||
|
|
83ab926f96 | ||
|
|
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
|
||||
|
||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@@ -5,14 +5,19 @@ on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: ubuntu:22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@3bc31aaf88e8fc94dc1e632d48af61be5ca8721c
|
||||
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # v3.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
cache: 'maven'
|
||||
env:
|
||||
# work around an issue with actions/runner setting an incorrect HOME in containers, which breaks maven caching
|
||||
# https://github.com/actions/setup-java/issues/356
|
||||
HOME: /root
|
||||
- name: Build with Maven
|
||||
run: mvn -e -B verify
|
||||
run: ./mvnw -e -B verify
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,6 +16,7 @@ config/deploy.properties
|
||||
/service/config/testing.yml
|
||||
/service/config/deploy.properties
|
||||
/service/dependency-reduced-pom.xml
|
||||
.java-version
|
||||
.opsmanage
|
||||
put.sh
|
||||
deployer-staging.properties
|
||||
@@ -25,4 +26,3 @@ deployer.log
|
||||
!/service/src/main/resources/org/signal/badges/Badges_en.properties
|
||||
/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties
|
||||
!/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties
|
||||
/.tx/config
|
||||
|
||||
Submodule abusive-message-filter updated: d7af85dca5...83c6ac4236
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()))
|
||||
}
|
||||
}
|
||||
51
pom.xml
51
pom.xml
@@ -35,6 +35,7 @@
|
||||
</pluginRepositories>
|
||||
|
||||
<modules>
|
||||
<module>event-logger</module>
|
||||
<module>redis-dispatch</module>
|
||||
<module>websocket-resources</module>
|
||||
<module>service</module>
|
||||
@@ -48,21 +49,24 @@
|
||||
<commons-io.version>2.9.0</commons-io.version>
|
||||
<dropwizard.version>2.0.32</dropwizard.version>
|
||||
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.version>
|
||||
<grpc.version>1.49.2</grpc.version>
|
||||
<gson.version>2.9.0</gson.version>
|
||||
<guava.version>30.1.1-jre</guava.version>
|
||||
<jackson.version>2.13.3</jackson.version>
|
||||
<jackson.version>2.13.4</jackson.version>
|
||||
<jaxb.version>2.3.1</jaxb.version>
|
||||
<jedis.version>2.9.0</jedis.version>
|
||||
<lettuce.version>6.1.9.RELEASE</lettuce.version>
|
||||
<kotlin.version>1.7.10</kotlin.version>
|
||||
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
|
||||
<lettuce.version>6.2.0.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||
<logstash.logback.version>7.0.1</logstash.logback.version>
|
||||
<micrometer.version>1.9.3</micrometer.version>
|
||||
<mockito.version>4.7.0</mockito.version>
|
||||
<netty.version>4.1.79.Final</netty.version>
|
||||
<netty.version>4.1.82.Final</netty.version>
|
||||
<opentest4j.version>1.2.0</opentest4j.version>
|
||||
<protobuf.version>3.19.4</protobuf.version>
|
||||
<protobuf.version>3.21.7</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>
|
||||
@@ -80,7 +84,7 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>2.13.3</version>
|
||||
<version>${jackson.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -91,6 +95,20 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-bom</artifactId>
|
||||
<version>${grpc.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Needed for gRPC with Java 9+ -->
|
||||
<dependency>
|
||||
<groupId>org.apache.tomcat</groupId>
|
||||
<artifactId>annotations-api</artifactId>
|
||||
<version>6.0.53</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-bom</artifactId>
|
||||
@@ -115,7 +133,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 +151,13 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-bom</artifactId>
|
||||
<version>2020.0.23</version> <!-- 3.4.x, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.eatthepath</groupId>
|
||||
<artifactId>pushy</artifactId>
|
||||
@@ -274,7 +298,7 @@
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
<version>0.18.0</version>
|
||||
<version>0.21.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
@@ -296,7 +320,7 @@
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-jre8</artifactId>
|
||||
<version>2.33.2</version>
|
||||
<version>2.34.0</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
@@ -366,13 +390,16 @@
|
||||
<artifactId>protobuf-maven-plugin</artifactId>
|
||||
<version>0.6.1</version>
|
||||
<configuration>
|
||||
<protocArtifact>com.google.protobuf:protoc:3.18.0:exe:${os.detected.classifier}</protocArtifact>
|
||||
<checkStaleness>true</checkStaleness>
|
||||
<checkStaleness>false</checkStaleness>
|
||||
<protocArtifact>com.google.protobuf:protoc:3.21.1:exe:${os.detected.classifier}</protocArtifact>
|
||||
<pluginId>grpc-java</pluginId>
|
||||
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
<goal>compile-custom</goal>
|
||||
<goal>test-compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
|
||||
@@ -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
|
||||
@@ -55,29 +62,6 @@ dynamoDbTables:
|
||||
subscriptions:
|
||||
tableName: Example_Subscriptions
|
||||
|
||||
twilio: # Twilio gateway configuration
|
||||
accountId: unset
|
||||
accountToken: unset
|
||||
nanpaMessagingServiceSid: unset # Twilio SID for the messaging service to use for NANPA.
|
||||
messagingServiceSid: unset # Twilio SID for the message service to use for non-NANPA.
|
||||
verifyServiceSid: unset # Twilio SID for a Verify service
|
||||
localDomain: example.com # Domain Twilio can connect back to for calls. Should be domain of your service.
|
||||
defaultClientVerificationTexts:
|
||||
ios: example %1$s # Text to use for the verification message on iOS. Will be passed to String.format with the verification code as argument 1.
|
||||
androidNg: example %1$s # Text to use for the verification message on android-ng client types. Will be passed to String.format with the verification code as argument 1.
|
||||
android202001: example %1$s # Text to use for the verification message on android-2020-01 client types. Will be passed to String.format with the verification code as argument 1.
|
||||
android202103: example %1$s # Text to use for the verification message on android-2021-03 client types. Will be passed to String.format with the verification code as argument 1.
|
||||
generic: example %1$s # Text to use when the client type is unrecognized. Will be passed to String.format with the verification code as argument 1.
|
||||
regionalClientVerificationTexts: # Map of country codes to custom texts
|
||||
999: # example country code
|
||||
ios: example %1$s # all keys from defaultClientVerificationTexts are required
|
||||
androidNg: example %1$s
|
||||
android202001: example %1$s
|
||||
android202103: example %1$s
|
||||
generic: example %1$s
|
||||
androidAppHash: example # Hash appended to Android
|
||||
verifyServiceFriendlyName: example # Service name used in template. Requires Twilio account rep to enable
|
||||
|
||||
cacheCluster: # Redis server configuration for cache cluster
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
|
||||
@@ -108,28 +92,29 @@ directory:
|
||||
- replicationName: example # CDS replication name
|
||||
replicationUrl: cds.example.com # CDS replication endpoint base url
|
||||
replicationPassword: example # CDS replication endpoint password
|
||||
replicationCaCertificate: | # CDS replication endpoint TLS certificate trust root
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
replicationCaCertificates: # CDS replication endpoint TLS certificate trust root
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
directoryV2:
|
||||
client: # Configuration for interfacing with Contact Discovery Service v2 cluster
|
||||
@@ -233,54 +218,56 @@ recaptcha:
|
||||
storageService:
|
||||
uri: storage.example.com
|
||||
userAuthenticationTokenSharedSecret: 00000f
|
||||
storageCaCertificate: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
storageCaCertificates:
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
backupService:
|
||||
uri: backup.example.com
|
||||
userAuthenticationTokenSharedSecret: 00000f
|
||||
backupCaCertificate: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
backupCaCertificates:
|
||||
- |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
zkConfig:
|
||||
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
@@ -307,14 +294,6 @@ paymentsService:
|
||||
# list of symbols for supported currencies
|
||||
- MOB
|
||||
|
||||
donation:
|
||||
uri: donation.example.com # value
|
||||
supportedCurrencies:
|
||||
- # 1st supported currency
|
||||
- # 2nd supported currency
|
||||
- # ...
|
||||
- # Nth supported currency
|
||||
|
||||
badges:
|
||||
badges:
|
||||
- id: TEST
|
||||
@@ -367,3 +346,29 @@ gift:
|
||||
currencies:
|
||||
# ISO 4217 currency codes and amounts in those currencies
|
||||
xts: '2'
|
||||
|
||||
registrationService:
|
||||
host: registration.example.com
|
||||
apiKey: EXAMPLE
|
||||
registrationCaCertificate: | # Registration service TLS certificate trust root
|
||||
-----BEGIN CERTIFICATE-----
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -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>
|
||||
@@ -223,6 +228,30 @@
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-retry</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-reactor</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-netty-shaded</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-protobuf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-stub</artifactId>
|
||||
</dependency>
|
||||
<!-- Needed for gRPC with Java 9+ -->
|
||||
<dependency>
|
||||
<groupId>org.apache.tomcat</groupId>
|
||||
<artifactId>annotations-api</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
@@ -289,10 +318,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>
|
||||
@@ -386,7 +411,6 @@
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core</artifactId>
|
||||
<version>3.3.22.RELEASE</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vavr</groupId>
|
||||
@@ -399,6 +423,11 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>embedded-redis</artifactId>
|
||||
@@ -408,14 +437,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;
|
||||
@@ -23,7 +24,6 @@ import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
||||
@@ -36,6 +36,7 @@ import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
@@ -43,7 +44,6 @@ import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfig
|
||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
||||
@@ -53,6 +53,11 @@ import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
||||
public class WhisperServerConfiguration extends Configuration {
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AdminEventLoggingConfiguration adminEventLoggingConfiguration;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -68,11 +73,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private DynamoDbTables dynamoDbTables;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private TwilioConfiguration twilio;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -218,11 +218,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private AppConfigConfiguration appConfig;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private DonationConfiguration donation;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -257,6 +252,15 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RegistrationServiceConfiguration registrationService;
|
||||
|
||||
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
|
||||
return adminEventLoggingConfiguration;
|
||||
}
|
||||
|
||||
public StripeConfiguration getStripe() {
|
||||
return stripe;
|
||||
}
|
||||
@@ -281,10 +285,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
public TwilioConfiguration getTwilioConfiguration() {
|
||||
return twilio;
|
||||
}
|
||||
|
||||
public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() {
|
||||
return awsAttachments;
|
||||
}
|
||||
@@ -403,10 +403,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
public DonationConfiguration getDonationConfiguration() {
|
||||
return donation;
|
||||
}
|
||||
|
||||
public BadgesConfiguration getBadges() {
|
||||
return badges;
|
||||
}
|
||||
@@ -434,4 +430,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
public UsernameConfiguration getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
|
||||
return registrationService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -111,7 +116,6 @@ import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||
import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters;
|
||||
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeOptionManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
||||
@@ -119,7 +123,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 +139,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,19 +150,18 @@ 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;
|
||||
import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||
import org.whispersystems.textsecuregcm.sms.SmsSender;
|
||||
import org.whispersystems.textsecuregcm.sms.TwilioSmsSender;
|
||||
import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountCleaner;
|
||||
@@ -188,6 +189,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,11 +198,10 @@ 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;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||
@@ -222,6 +223,7 @@ import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.ZkParamsCommand;
|
||||
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
|
||||
import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
@@ -328,6 +330,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getAppConfig().getConfigurationName(),
|
||||
DynamicConfiguration.class);
|
||||
|
||||
BlockingQueue<Runnable> messageDeletionQueue = new ArrayBlockingQueue<>(10_000);
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "messageDeletionQueueSize"), Collections.emptyList(),
|
||||
messageDeletionQueue);
|
||||
ExecutorService messageDeletionAsyncExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "messageDeletionAsyncExecutor-%d")).maxThreads(16)
|
||||
.workQueue(messageDeletionQueue).build();
|
||||
|
||||
Accounts accounts = new Accounts(dynamicConfigurationManager,
|
||||
dynamoDbClient,
|
||||
dynamoDbAsyncClient,
|
||||
@@ -338,14 +347,15 @@ 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());
|
||||
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
|
||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient,
|
||||
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getMessages().getTableName(),
|
||||
config.getDynamoDbTables().getMessages().getExpiration());
|
||||
config.getDynamoDbTables().getMessages().getExpiration(),
|
||||
messageDeletionAsyncExecutor);
|
||||
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
|
||||
config.getDynamoDbTables().getRemoteConfig().getTableName());
|
||||
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
|
||||
@@ -358,8 +368,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
VerificationCodeStore pendingDevices = new VerificationCodeStore(dynamoDbClient,
|
||||
config.getDynamoDbTables().getPendingDevices().getTableName());
|
||||
|
||||
RedisClientFactory pubSubClientFactory = new RedisClientFactory("pubsub_cache", config.getPubsubCacheConfiguration().getUrl(), config.getPubsubCacheConfiguration().getReplicaUrls(), config.getPubsubCacheConfiguration().getCircuitBreakerConfiguration());
|
||||
ReplicatedJedisPool pubsubClient = pubSubClientFactory.getRedisClientPool();
|
||||
reactor.util.Metrics.MicrometerConfiguration.useRegistry(Metrics.globalRegistry);
|
||||
Schedulers.enableMetrics();
|
||||
|
||||
RedisClientFactory pubSubClientFactory = new RedisClientFactory("pubsub_cache",
|
||||
config.getPubsubCacheConfiguration().getUrl(), config.getPubsubCacheConfiguration().getReplicaUrls(),
|
||||
config.getPubsubCacheConfiguration().getCircuitBreakerConfiguration());
|
||||
ReplicatedJedisPool pubsubClient = pubSubClientFactory.getRedisClientPool();
|
||||
|
||||
MicrometerOptions options = MicrometerOptions.builder().build();
|
||||
ClientResources redisClientResources = ClientResources.builder()
|
||||
@@ -406,6 +421,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.workQueue(receiptSenderQueue)
|
||||
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
|
||||
.build();
|
||||
ExecutorService registrationCallbackExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "registration-%d"))
|
||||
.maxThreads(2)
|
||||
.minThreads(2)
|
||||
.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());
|
||||
@@ -422,9 +450,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
|
||||
|
||||
TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager = new TwilioVerifyExperimentEnrollmentManager(
|
||||
config.getVoiceVerificationConfiguration(), experimentEnrollmentManager);
|
||||
|
||||
ExternalServiceCredentialGenerator storageCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getSecureStorageServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||
ExternalServiceCredentialGenerator backupCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
@@ -433,22 +458,26 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getPaymentsServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||
|
||||
AbusiveHostRules abusiveHostRules = new AbusiveHostRules(rateLimitersCluster, dynamicConfigurationManager);
|
||||
RegistrationServiceClient registrationServiceClient = new RegistrationServiceClient(config.getRegistrationServiceConfiguration().getHost(), config.getRegistrationServiceConfiguration().getPort(), config.getRegistrationServiceConfiguration().getApiKey(), config.getRegistrationServiceConfiguration().getRegistrationCaCertificate(), registrationCallbackExecutor);
|
||||
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, config.getSecureBackupServiceConfiguration());
|
||||
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, storageServiceExecutor, config.getSecureStorageServiceConfiguration());
|
||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor, keyspaceNotificationDispatchExecutor);
|
||||
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, keyspaceNotificationDispatchExecutor);
|
||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, Clock.systemUTC(),
|
||||
keyspaceNotificationDispatchExecutor, messageDeletionAsyncExecutor);
|
||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster,
|
||||
config.getReportMessageConfiguration().getCounterTtl());
|
||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager,
|
||||
messageDeletionAsyncExecutor);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||
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);
|
||||
@@ -480,8 +509,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
|
||||
|
||||
TwilioSmsSender twilioSmsSender = new TwilioSmsSender(config.getTwilioConfiguration(), dynamicConfigurationManager);
|
||||
SmsSender smsSender = new SmsSender(twilioSmsSender);
|
||||
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
|
||||
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||
@@ -492,8 +519,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
||||
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||
recaptchaClient, dynamicRateLimiters);
|
||||
RateLimitChallengeOptionManager rateLimitChallengeOptionManager =
|
||||
new RateLimitChallengeOptionManager(dynamicRateLimiters, dynamicConfigurationManager);
|
||||
|
||||
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||
@@ -569,6 +594,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
environment.lifecycle().manage(clientPresenceManager);
|
||||
environment.lifecycle().manage(currencyManager);
|
||||
environment.lifecycle().manage(directoryQueue);
|
||||
environment.lifecycle().manage(registrationServiceClient);
|
||||
|
||||
StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider
|
||||
.create(AwsBasicCredentials.create(
|
||||
@@ -616,8 +642,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
||||
webSocketEnvironment.setConnectListener(
|
||||
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
||||
clientPresenceManager, websocketScheduledExecutor));
|
||||
webSocketEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||
clientPresenceManager, websocketScheduledExecutor, experimentEnrollmentManager));
|
||||
webSocketEnvironment.jersey()
|
||||
.register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET));
|
||||
@@ -626,13 +653,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
// these should be common, but use @Auth DisabledPermittedAccount, which isn’t supported yet on websocket
|
||||
environment.jersey().register(
|
||||
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
|
||||
smsSender, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
|
||||
recaptchaClient, pushNotificationManager, verifyExperimentEnrollmentManager,
|
||||
changeNumberManager, backupCredentialsGenerator));
|
||||
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
|
||||
recaptchaClient, pushNotificationManager, changeNumberManager, backupCredentialsGenerator));
|
||||
|
||||
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),
|
||||
@@ -641,12 +667,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new DirectoryController(directoryCredentialsGenerator),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new, stripeExecutor, config.getDonationConfiguration(), config.getStripe()),
|
||||
ReceiptCredentialPresentation::new),
|
||||
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor),
|
||||
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 +731,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ public class BaseAccountAuthenticator {
|
||||
private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded";
|
||||
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";
|
||||
private static final String AUTHENTICATION_ENABLED_REQUIRED_TAG_NAME = "enabledRequired";
|
||||
private static final String AUTHENTICATION_HAS_STORY_CAPABILITY = "hasStoryCapability";
|
||||
|
||||
private static final String STORY_ADOPTION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "storyAdoption");
|
||||
|
||||
private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(BaseAccountAuthenticator.class, "daysSinceLastSeen");
|
||||
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
||||
@@ -43,8 +46,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) {
|
||||
@@ -67,6 +70,7 @@ public class BaseAccountAuthenticator {
|
||||
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials, boolean enabledRequired) {
|
||||
boolean succeeded = false;
|
||||
String failureReason = null;
|
||||
boolean hasStoryCapability = false;
|
||||
|
||||
try {
|
||||
final UUID accountUuid;
|
||||
@@ -85,6 +89,8 @@ public class BaseAccountAuthenticator {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
hasStoryCapability = account.map(Account::isStoriesSupported).orElse(false);
|
||||
|
||||
Optional<Device> device = account.get().getDevice(deviceId);
|
||||
|
||||
if (device.isEmpty()) {
|
||||
@@ -104,9 +110,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)));
|
||||
}
|
||||
@@ -125,6 +138,9 @@ public class BaseAccountAuthenticator {
|
||||
}
|
||||
|
||||
Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();
|
||||
|
||||
Tags storyTags = Tags.of(AUTHENTICATION_HAS_STORY_CAPABILITY, String.valueOf(hasStoryCapability));
|
||||
Metrics.counter(STORY_ADOPTION_COUNTER_NAME, storyTags).increment();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,5 +158,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
|
||||
|
||||
@@ -5,60 +5,19 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class StoredVerificationCode {
|
||||
|
||||
@JsonProperty
|
||||
private final String code;
|
||||
|
||||
@JsonProperty
|
||||
private final long timestamp;
|
||||
|
||||
@JsonProperty
|
||||
private final String pushCode;
|
||||
|
||||
@JsonProperty
|
||||
@Nullable
|
||||
private final String twilioVerificationSid;
|
||||
public record StoredVerificationCode(String code,
|
||||
long timestamp,
|
||||
String pushCode,
|
||||
@Nullable String twilioVerificationSid,
|
||||
@Nullable byte[] sessionId) {
|
||||
|
||||
public static final Duration EXPIRATION = Duration.ofMinutes(10);
|
||||
|
||||
@JsonCreator
|
||||
public StoredVerificationCode(
|
||||
@JsonProperty("code") final String code,
|
||||
@JsonProperty("timestamp") final long timestamp,
|
||||
@JsonProperty("pushCode") final String pushCode,
|
||||
@JsonProperty("twilioVerificationSid") @Nullable final String twilioVerificationSid) {
|
||||
|
||||
this.code = code;
|
||||
this.timestamp = timestamp;
|
||||
this.pushCode = pushCode;
|
||||
this.twilioVerificationSid = twilioVerificationSid;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public String getPushCode() {
|
||||
return pushCode;
|
||||
}
|
||||
|
||||
public Optional<String> getTwilioVerificationSid() {
|
||||
return Optional.ofNullable(twilioVerificationSid);
|
||||
}
|
||||
|
||||
public boolean isValid(String theirCodeString) {
|
||||
if (Util.isEmpty(code) || Util.isEmpty(theirCodeString)) {
|
||||
return false;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public class DirectoryServerConfiguration {
|
||||
|
||||
@@ -23,7 +25,7 @@ public class DirectoryServerConfiguration {
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String replicationCaCertificate;
|
||||
private List<@NotBlank String> replicationCaCertificates;
|
||||
|
||||
public String getReplicationName() {
|
||||
return replicationName;
|
||||
@@ -37,8 +39,8 @@ public class DirectoryServerConfiguration {
|
||||
return replicationPassword;
|
||||
}
|
||||
|
||||
public String getReplicationCaCertificate() {
|
||||
return replicationCaCertificate;
|
||||
public List<String> getReplicationCaCertificates() {
|
||||
return replicationCaCertificates;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class DonationConfiguration {
|
||||
|
||||
private String uri;
|
||||
private String description;
|
||||
private Set<String> supportedCurrencies;
|
||||
private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration();
|
||||
private RetryConfiguration retry = new RetryConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
public String getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setUri(final String uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
@JsonProperty
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setDescription(final String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
public Set<String> getSupportedCurrencies() {
|
||||
return supportedCurrencies;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setSupportedCurrencies(final Set<String> supportedCurrencies) {
|
||||
this.supportedCurrencies = supportedCurrencies;
|
||||
}
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@Valid
|
||||
public CircuitBreakerConfiguration getCircuitBreaker() {
|
||||
return circuitBreaker;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setCircuitBreaker(final CircuitBreakerConfiguration circuitBreaker) {
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
}
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@Valid
|
||||
public RetryConfiguration getRetry() {
|
||||
return retry;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setRetry(final RetryConfiguration retry) {
|
||||
this.retry = retry;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class RegistrationServiceConfiguration {
|
||||
|
||||
@NotBlank
|
||||
private String host;
|
||||
|
||||
private int port = 443;
|
||||
|
||||
@NotBlank
|
||||
private String apiKey;
|
||||
|
||||
@NotBlank
|
||||
private String registrationCaCertificate;
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public void setHost(final String host) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public void setPort(final int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(final String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getRegistrationCaCertificate() {
|
||||
return registrationCaCertificate;
|
||||
}
|
||||
|
||||
public void setRegistrationCaCertificate(final String registrationCaCertificate) {
|
||||
this.registrationCaCertificate = registrationCaCertificate;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import javax.validation.constraints.NotNull;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import java.util.List;
|
||||
|
||||
public class SecureBackupServiceConfiguration {
|
||||
|
||||
@@ -24,9 +25,9 @@ public class SecureBackupServiceConfiguration {
|
||||
@JsonProperty
|
||||
private String uri;
|
||||
|
||||
@NotBlank
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String backupCaCertificate;
|
||||
private List<@NotBlank String> backupCaCertificates;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@@ -52,12 +53,12 @@ public class SecureBackupServiceConfiguration {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setBackupCaCertificate(final String backupCaCertificate) {
|
||||
this.backupCaCertificate = backupCaCertificate;
|
||||
public void setBackupCaCertificates(final List<String> backupCaCertificates) {
|
||||
this.backupCaCertificates = backupCaCertificates;
|
||||
}
|
||||
|
||||
public String getBackupCaCertificate() {
|
||||
return backupCaCertificate;
|
||||
public List<String> getBackupCaCertificates() {
|
||||
return backupCaCertificates;
|
||||
}
|
||||
|
||||
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import java.util.List;
|
||||
|
||||
public class SecureStorageServiceConfiguration {
|
||||
|
||||
@@ -24,9 +25,9 @@ public class SecureStorageServiceConfiguration {
|
||||
@JsonProperty
|
||||
private String uri;
|
||||
|
||||
@NotBlank
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String storageCaCertificate;
|
||||
private List<@NotBlank String> storageCaCertificates;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@@ -52,12 +53,12 @@ public class SecureStorageServiceConfiguration {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setStorageCaCertificate(final String certificatePem) {
|
||||
this.storageCaCertificate = certificatePem;
|
||||
public void setStorageCaCertificates(final List<String> certificatePem) {
|
||||
this.storageCaCertificates = certificatePem;
|
||||
}
|
||||
|
||||
public String getStorageCaCertificate() {
|
||||
return storageCaCertificate;
|
||||
public List<String> getStorageCaCertificates() {
|
||||
return storageCaCertificates;
|
||||
}
|
||||
|
||||
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class TwilioConfiguration {
|
||||
|
||||
@NotEmpty
|
||||
private String accountId;
|
||||
|
||||
@NotEmpty
|
||||
private String accountToken;
|
||||
|
||||
@NotEmpty
|
||||
private String localDomain;
|
||||
|
||||
@NotEmpty
|
||||
private String messagingServiceSid;
|
||||
|
||||
@NotEmpty
|
||||
private String nanpaMessagingServiceSid;
|
||||
|
||||
@NotEmpty
|
||||
private String verifyServiceSid;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration();
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
private RetryConfiguration retry = new RetryConfiguration();
|
||||
|
||||
@Valid
|
||||
private TwilioVerificationTextConfiguration defaultClientVerificationTexts;
|
||||
|
||||
@Valid
|
||||
private Map<String,TwilioVerificationTextConfiguration> regionalClientVerificationTexts = Collections.emptyMap();
|
||||
|
||||
@NotEmpty
|
||||
private String androidAppHash;
|
||||
|
||||
@NotEmpty
|
||||
private String verifyServiceFriendlyName;
|
||||
|
||||
public String getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setAccountId(String accountId) {
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
public String getAccountToken() {
|
||||
return accountToken;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setAccountToken(String accountToken) {
|
||||
this.accountToken = accountToken;
|
||||
}
|
||||
public String getLocalDomain() {
|
||||
return localDomain;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setLocalDomain(String localDomain) {
|
||||
this.localDomain = localDomain;
|
||||
}
|
||||
|
||||
public String getMessagingServiceSid() {
|
||||
return messagingServiceSid;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setMessagingServiceSid(String messagingServiceSid) {
|
||||
this.messagingServiceSid = messagingServiceSid;
|
||||
}
|
||||
|
||||
public String getNanpaMessagingServiceSid() {
|
||||
return nanpaMessagingServiceSid;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setNanpaMessagingServiceSid(String nanpaMessagingServiceSid) {
|
||||
this.nanpaMessagingServiceSid = nanpaMessagingServiceSid;
|
||||
}
|
||||
|
||||
public String getVerifyServiceSid() {
|
||||
return verifyServiceSid;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setVerifyServiceSid(String verifyServiceSid) {
|
||||
this.verifyServiceSid = verifyServiceSid;
|
||||
}
|
||||
|
||||
public CircuitBreakerConfiguration getCircuitBreaker() {
|
||||
return circuitBreaker;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setCircuitBreaker(CircuitBreakerConfiguration circuitBreaker) {
|
||||
this.circuitBreaker = circuitBreaker;
|
||||
}
|
||||
|
||||
public RetryConfiguration getRetry() {
|
||||
return retry;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setRetry(RetryConfiguration retry) {
|
||||
this.retry = retry;
|
||||
}
|
||||
|
||||
public TwilioVerificationTextConfiguration getDefaultClientVerificationTexts() {
|
||||
return defaultClientVerificationTexts;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setDefaultClientVerificationTexts(TwilioVerificationTextConfiguration defaultClientVerificationTexts) {
|
||||
this.defaultClientVerificationTexts = defaultClientVerificationTexts;
|
||||
}
|
||||
|
||||
|
||||
public Map<String,TwilioVerificationTextConfiguration> getRegionalClientVerificationTexts() {
|
||||
return regionalClientVerificationTexts;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setRegionalClientVerificationTexts(final Map<String,TwilioVerificationTextConfiguration> regionalClientVerificationTexts) {
|
||||
this.regionalClientVerificationTexts = regionalClientVerificationTexts;
|
||||
}
|
||||
|
||||
public String getAndroidAppHash() {
|
||||
return androidAppHash;
|
||||
}
|
||||
|
||||
public void setAndroidAppHash(String androidAppHash) {
|
||||
this.androidAppHash = androidAppHash;
|
||||
}
|
||||
|
||||
public void setVerifyServiceFriendlyName(String serviceFriendlyName) {
|
||||
this.verifyServiceFriendlyName = serviceFriendlyName;
|
||||
}
|
||||
|
||||
public String getVerifyServiceFriendlyName() {
|
||||
return verifyServiceFriendlyName;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class TwilioCountrySenderIdConfiguration {
|
||||
@NotEmpty
|
||||
private String countryCode;
|
||||
|
||||
@NotEmpty
|
||||
private String senderId;
|
||||
|
||||
public String getCountryCode() {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setCountryCode(String countryCode) {
|
||||
this.countryCode = countryCode;
|
||||
}
|
||||
|
||||
public String getSenderId() {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setSenderId(String senderId) {
|
||||
this.senderId = senderId;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class TwilioVerificationTextConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String ios;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String androidNg;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String android202001;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String android202103;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private String generic;
|
||||
|
||||
public String getIosText() {
|
||||
return ios;
|
||||
}
|
||||
|
||||
public void setIosText(String ios) {
|
||||
this.ios = ios;
|
||||
}
|
||||
|
||||
public String getAndroidNgText() {
|
||||
return androidNg;
|
||||
}
|
||||
|
||||
public void setAndroidNgText(final String androidNg) {
|
||||
this.androidNg = androidNg;
|
||||
}
|
||||
|
||||
public String getAndroid202001Text() {
|
||||
return android202001;
|
||||
}
|
||||
|
||||
public void setAndroid202001Text(final String android202001) {
|
||||
this.android202001 = android202001;
|
||||
}
|
||||
|
||||
public String getAndroid202103Text() {
|
||||
return android202103;
|
||||
}
|
||||
|
||||
public void setAndroid202103Text(final String android202103) {
|
||||
this.android202103 = android202103;
|
||||
}
|
||||
|
||||
public String getGenericText() {
|
||||
return generic;
|
||||
}
|
||||
|
||||
public void setGenericText(final String generic) {
|
||||
this.generic = generic;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@ public class DynamicConfiguration {
|
||||
@Valid
|
||||
private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
private DynamicTwilioConfiguration twilio = new DynamicTwilioConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
private DynamicCaptchaConfiguration captcha = new DynamicCaptchaConfiguration();
|
||||
@@ -86,15 +82,6 @@ public class DynamicConfiguration {
|
||||
return payments;
|
||||
}
|
||||
|
||||
public DynamicTwilioConfiguration getTwilioConfiguration() {
|
||||
return twilio;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setTwilioConfiguration(DynamicTwilioConfiguration twilioConfiguration) {
|
||||
this.twilio = twilioConfiguration;
|
||||
}
|
||||
|
||||
public DynamicCaptchaConfiguration getCaptchaConfiguration() {
|
||||
return captcha;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class DynamicTwilioConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private List<String> numbers = Collections.emptyList();
|
||||
|
||||
public List<String> getNumbers() {
|
||||
return numbers;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setNumbers(List<String> numbers) {
|
||||
this.numbers = numbers;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
@@ -18,13 +21,9 @@ import io.micrometer.core.instrument.Tags;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.validation.Valid;
|
||||
@@ -50,6 +49,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 +68,14 @@ import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||
@@ -82,8 +85,9 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotification;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.sms.SmsSender;
|
||||
import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.registration.ClientType;
|
||||
import org.whispersystems.textsecuregcm.registration.MessageTransport;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
@@ -92,14 +96,15 @@ 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;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/accounts")
|
||||
@@ -120,25 +125,27 @@ public class AccountController {
|
||||
private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(AccountController.class, "captcha");
|
||||
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 INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
||||
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||
|
||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||
private static final String REGION_TAG_NAME = "region";
|
||||
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
|
||||
|
||||
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
||||
/**
|
||||
* @deprecated "region" conflicts with cloud provider region tags; prefer "regionCode" instead
|
||||
*/
|
||||
@Deprecated
|
||||
private static final String REGION_TAG_NAME = "region";
|
||||
private static final String REGION_CODE_TAG_NAME = "regionCode";
|
||||
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
|
||||
private static final String SCORE_TAG_NAME = "score";
|
||||
|
||||
|
||||
private final StoredVerificationCodeManager pendingAccounts;
|
||||
private final AccountsManager accounts;
|
||||
private final AbusiveHostRules abusiveHostRules;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final SmsSender smsSender;
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final TurnTokenGenerator turnTokenGenerator;
|
||||
private final Map<String, Integer> testDevices;
|
||||
@@ -146,20 +153,21 @@ public class AccountController {
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator;
|
||||
|
||||
private final TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager;
|
||||
private final ChangeNumberManager changeNumberManager;
|
||||
|
||||
@VisibleForTesting
|
||||
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||
|
||||
public AccountController(StoredVerificationCodeManager pendingAccounts,
|
||||
AccountsManager accounts,
|
||||
AbusiveHostRules abusiveHostRules,
|
||||
RateLimiters rateLimiters,
|
||||
SmsSender smsSenderFactory,
|
||||
RegistrationServiceClient registrationServiceClient,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
TurnTokenGenerator turnTokenGenerator,
|
||||
Map<String, Integer> testDevices,
|
||||
RecaptchaClient recaptchaClient,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager,
|
||||
ChangeNumberManager changeNumberManager,
|
||||
ExternalServiceCredentialGenerator backupServiceCredentialGenerator)
|
||||
{
|
||||
@@ -167,13 +175,12 @@ public class AccountController {
|
||||
this.accounts = accounts;
|
||||
this.abusiveHostRules = abusiveHostRules;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.smsSender = smsSenderFactory;
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.testDevices = testDevices;
|
||||
this.turnTokenGenerator = turnTokenGenerator;
|
||||
this.recaptchaClient = recaptchaClient;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.verifyExperimentEnrollmentManager = verifyExperimentEnrollmentManager;
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
this.changeNumberManager = changeNumberManager;
|
||||
}
|
||||
@@ -196,14 +203,12 @@ public class AccountController {
|
||||
|
||||
Util.requireNormalizedNumber(number);
|
||||
|
||||
String pushChallenge = generatePushChallenge();
|
||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
|
||||
System.currentTimeMillis(),
|
||||
pushChallenge,
|
||||
null);
|
||||
String pushChallenge = generatePushChallenge();
|
||||
StoredVerificationCode storedVerificationCode =
|
||||
new StoredVerificationCode(null, System.currentTimeMillis(), pushChallenge, null, null);
|
||||
|
||||
pendingAccounts.store(number, storedVerificationCode);
|
||||
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.getPushCode());
|
||||
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode());
|
||||
|
||||
return Response.ok().build();
|
||||
}
|
||||
@@ -211,6 +216,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,
|
||||
@@ -224,26 +230,44 @@ public class AccountController {
|
||||
|
||||
Util.requireNormalizedNumber(number);
|
||||
|
||||
String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
final String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
Optional<StoredVerificationCode> storedChallenge = pendingAccounts.getCodeForNumber(number);
|
||||
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, sourceHost, captcha,
|
||||
storedChallenge, pushChallenge, userAgent);
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
if (requirement.isCaptchaRequired()) {
|
||||
// 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(REGION_CODE_TAG_NAME, region),
|
||||
Tag.of(SCORE_TAG_NAME, result.score())))
|
||||
.increment());
|
||||
|
||||
final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode);
|
||||
|
||||
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))))
|
||||
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
|
||||
Tag.of(REGION_CODE_TAG_NAME, region)))
|
||||
.increment();
|
||||
|
||||
if (requirement.isAutoBlock() && shouldAutoBlock(sourceHost)) {
|
||||
logger.info("Auto-block: {}", sourceHost);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
|
||||
return Response.status(402).build();
|
||||
}
|
||||
|
||||
@@ -256,80 +280,48 @@ public class AccountController {
|
||||
default -> throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
VerificationCode verificationCode = generateVerificationCode(number);
|
||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
|
||||
final Phonenumber.PhoneNumber phoneNumber;
|
||||
|
||||
try {
|
||||
phoneNumber = PhoneNumberUtil.getInstance().parse(number, null);
|
||||
} catch (final NumberParseException e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
final MessageTransport messageTransport = switch (transport) {
|
||||
case "sms" -> MessageTransport.SMS;
|
||||
case "voice" -> MessageTransport.VOICE;
|
||||
default -> throw new WebApplicationException(Response.status(422).build());
|
||||
};
|
||||
|
||||
final ClientType clientType = client.map(clientTypeString -> {
|
||||
if ("ios".equalsIgnoreCase(clientTypeString)) {
|
||||
return ClientType.IOS;
|
||||
} else if ("android-2021-03".equalsIgnoreCase(clientTypeString)) {
|
||||
return ClientType.ANDROID_WITH_FCM;
|
||||
} else if (StringUtils.startsWithIgnoreCase(clientTypeString, "android")) {
|
||||
return ClientType.ANDROID_WITHOUT_FCM;
|
||||
} else {
|
||||
return ClientType.UNKNOWN;
|
||||
}
|
||||
}).orElse(ClientType.UNKNOWN);
|
||||
|
||||
final byte[] sessionId = registrationServiceClient.sendRegistrationCode(phoneNumber,
|
||||
messageTransport, clientType, acceptLanguage.orElse(null), REGISTRATION_RPC_TIMEOUT).join();
|
||||
|
||||
final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
|
||||
System.currentTimeMillis(),
|
||||
storedChallenge.map(StoredVerificationCode::getPushCode).orElse(null),
|
||||
storedChallenge.flatMap(StoredVerificationCode::getTwilioVerificationSid).orElse(null));
|
||||
maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null),
|
||||
null,
|
||||
sessionId);
|
||||
|
||||
pendingAccounts.store(number, storedVerificationCode);
|
||||
|
||||
List<Locale.LanguageRange> languageRanges;
|
||||
try {
|
||||
languageRanges = acceptLanguage.map(Locale.LanguageRange::parse).orElse(Collections.emptyList());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}",
|
||||
acceptLanguage.orElse(""),
|
||||
userAgent,
|
||||
e);
|
||||
|
||||
Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment();
|
||||
languageRanges = Collections.emptyList();
|
||||
}
|
||||
|
||||
final boolean enrolledInVerifyExperiment = verifyExperimentEnrollmentManager.isEnrolled(client, number, languageRanges, transport);
|
||||
final CompletableFuture<Optional<String>> sendVerificationWithTwilioVerifyFuture;
|
||||
|
||||
if (testDevices.containsKey(number)) {
|
||||
// noop
|
||||
sendVerificationWithTwilioVerifyFuture = CompletableFuture.completedFuture(Optional.empty());
|
||||
} else if (transport.equals("sms")) {
|
||||
|
||||
if (enrolledInVerifyExperiment) {
|
||||
sendVerificationWithTwilioVerifyFuture = smsSender.deliverSmsVerificationWithTwilioVerify(number, client, verificationCode.getVerificationCode(), languageRanges);
|
||||
} else {
|
||||
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
|
||||
sendVerificationWithTwilioVerifyFuture = CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
} else if (transport.equals("voice")) {
|
||||
|
||||
if (enrolledInVerifyExperiment) {
|
||||
sendVerificationWithTwilioVerifyFuture = smsSender.deliverVoxVerificationWithTwilioVerify(number, verificationCode.getVerificationCode(), languageRanges);
|
||||
} else {
|
||||
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCode(), languageRanges);
|
||||
sendVerificationWithTwilioVerifyFuture = CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
|
||||
} else {
|
||||
sendVerificationWithTwilioVerifyFuture = CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
|
||||
sendVerificationWithTwilioVerifyFuture.whenComplete((maybeVerificationSid, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Metrics.counter(TWILIO_VERIFY_ERROR_COUNTER_NAME).increment();
|
||||
|
||||
logger.warn("Error with Twilio Verify", throwable);
|
||||
return;
|
||||
}
|
||||
maybeVerificationSid.ifPresent(twilioVerificationSid -> {
|
||||
StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode(
|
||||
storedVerificationCode.getCode(),
|
||||
storedVerificationCode.getTimestamp(),
|
||||
storedVerificationCode.getPushCode(),
|
||||
twilioVerificationSid);
|
||||
pendingAccounts.store(number, storedVerificationCodeWithVerificationSid);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO Remove this meter when external dependencies have been resolved
|
||||
metricRegistry.meter(name(AccountController.class, "create", Util.getCountryCode(number))).mark();
|
||||
|
||||
Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
||||
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
|
||||
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport),
|
||||
Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(enrolledInVerifyExperiment))))
|
||||
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport)))
|
||||
.increment();
|
||||
|
||||
return Response.ok().build();
|
||||
@@ -356,16 +348,19 @@ public class AccountController {
|
||||
// Note that successful verification depends on being able to find a stored verification code for the given number.
|
||||
// We check that numbers are normalized before we store verification codes, and so don't need to re-assert
|
||||
// normalization here.
|
||||
Optional<StoredVerificationCode> storedVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
if (storedVerificationCode.isEmpty() || !storedVerificationCode.get().isValid(verificationCode)) {
|
||||
final boolean codeVerified = maybeStoredVerificationCode.map(storedVerificationCode ->
|
||||
storedVerificationCode.sessionId() != null ?
|
||||
registrationServiceClient.checkVerificationCode(storedVerificationCode.sessionId(),
|
||||
verificationCode, REGISTRATION_RPC_TIMEOUT).join() :
|
||||
storedVerificationCode.isValid(verificationCode))
|
||||
.orElse(false);
|
||||
|
||||
if (!codeVerified) {
|
||||
throw new WebApplicationException(Response.status(403).build());
|
||||
}
|
||||
|
||||
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
|
||||
.ifPresent(
|
||||
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "registration"));
|
||||
|
||||
Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
@@ -386,7 +381,7 @@ public class AccountController {
|
||||
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
||||
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
|
||||
Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(storedVerificationCode.get().getTwilioVerificationSid().isPresent()))))
|
||||
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number))))
|
||||
.increment();
|
||||
|
||||
return new AccountIdentityResponse(account.getUuid(),
|
||||
@@ -417,17 +412,15 @@ public class AccountController {
|
||||
|
||||
rateLimiters.getVerifyLimiter().validate(number);
|
||||
|
||||
final Optional<StoredVerificationCode> storedVerificationCode =
|
||||
pendingAccounts.getCodeForNumber(number);
|
||||
final boolean codeVerified = pendingAccounts.getCodeForNumber(number).map(storedVerificationCode ->
|
||||
registrationServiceClient.checkVerificationCode(storedVerificationCode.sessionId(),
|
||||
request.code(), REGISTRATION_RPC_TIMEOUT).join())
|
||||
.orElse(false);
|
||||
|
||||
if (storedVerificationCode.isEmpty() || !storedVerificationCode.get().isValid(request.code())) {
|
||||
if (!codeVerified) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
|
||||
.ifPresent(
|
||||
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "changeNumber"));
|
||||
|
||||
final Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
@@ -642,6 +635,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 +691,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 +720,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 +787,41 @@ 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)
|
||||
{
|
||||
@VisibleForTesting
|
||||
static 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);
|
||||
final Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::pushCode);
|
||||
|
||||
final 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,
|
||||
REGION_CODE_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 +836,7 @@ public class AccountController {
|
||||
// would be caught by country filter as well
|
||||
countryFilterApplicable.mark();
|
||||
}
|
||||
return new CaptchaRequirement(true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -840,7 +844,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 +856,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 +877,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);
|
||||
@@ -876,17 +896,6 @@ public class AccountController {
|
||||
return false;
|
||||
}
|
||||
|
||||
@VisibleForTesting protected
|
||||
VerificationCode generateVerificationCode(String number) {
|
||||
if (testDevices.containsKey(number)) {
|
||||
return new VerificationCode(testDevices.get(number));
|
||||
}
|
||||
|
||||
SecureRandom random = new SecureRandom();
|
||||
int randomInt = 100000 + random.nextInt(900000);
|
||||
return new VerificationCode(randomInt);
|
||||
}
|
||||
|
||||
private String generatePushChallenge() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] challenge = new byte[16];
|
||||
@@ -894,22 +903,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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
@@ -66,14 +67,13 @@ public class CertificateController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/delivery")
|
||||
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedAccount auth,
|
||||
@QueryParam("includeE164") Optional<Boolean> maybeIncludeE164)
|
||||
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
|
||||
throws InvalidKeyException {
|
||||
|
||||
if (Util.isEmpty(auth.getAccount().getIdentityKey())) {
|
||||
throw new WebApplicationException(Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
final boolean includeE164 = maybeIncludeE164.orElse(true);
|
||||
|
||||
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
|
||||
.increment();
|
||||
|
||||
|
||||
@@ -132,11 +132,9 @@ public class DeviceController {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
VerificationCode verificationCode = generateVerificationCode();
|
||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
|
||||
System.currentTimeMillis(),
|
||||
null,
|
||||
null);
|
||||
VerificationCode verificationCode = generateVerificationCode();
|
||||
StoredVerificationCode storedVerificationCode =
|
||||
new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null, null);
|
||||
|
||||
pendingDevices.store(account.getNumber(), storedVerificationCode);
|
||||
|
||||
@@ -240,8 +238,7 @@ public class DeviceController {
|
||||
private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities, String userAgent) {
|
||||
boolean isDowngrade = false;
|
||||
|
||||
// TODO stories capability
|
||||
// isDowngrade |= account.isStoriesSupported() && !capabilities.isStories();
|
||||
isDowngrade |= account.isStoriesSupported() && !capabilities.isStories();
|
||||
isDowngrade |= account.isPniSupported() && !capabilities.isPni();
|
||||
isDowngrade |= account.isChangeNumberSupported() && !capabilities.isChangeNumber();
|
||||
isDowngrade |= account.isAnnouncementGroupSupported() && !capabilities.isAnnouncementGroup();
|
||||
|
||||
@@ -6,30 +6,13 @@
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.dropwizard.util.Strings;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient.Redirect;
|
||||
import java.net.http.HttpClient.Version;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpResponse.BodyHandlers;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
|
||||
import java.util.function.Function;
|
||||
@@ -52,18 +35,11 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@Path("/v1/donation")
|
||||
public class DonationController {
|
||||
@@ -80,11 +56,6 @@ public class DonationController {
|
||||
private final AccountsManager accountsManager;
|
||||
private final BadgesConfiguration badgesConfiguration;
|
||||
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
private final URI uri;
|
||||
private final String apiKey;
|
||||
private final String description;
|
||||
private final Set<String> supportedCurrencies;
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
|
||||
public DonationController(
|
||||
@Nonnull final Clock clock,
|
||||
@@ -92,30 +63,13 @@ public class DonationController {
|
||||
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
@Nonnull final AccountsManager accountsManager,
|
||||
@Nonnull final BadgesConfiguration badgesConfiguration,
|
||||
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
|
||||
@Nonnull final Executor httpClientExecutor,
|
||||
@Nonnull final DonationConfiguration configuration,
|
||||
@Nonnull final StripeConfiguration stripeConfiguration) {
|
||||
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
|
||||
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
|
||||
this.accountsManager = Objects.requireNonNull(accountsManager);
|
||||
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
|
||||
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
|
||||
this.uri = URI.create(configuration.getUri());
|
||||
this.apiKey = stripeConfiguration.getApiKey();
|
||||
this.description = configuration.getDescription();
|
||||
this.supportedCurrencies = configuration.getSupportedCurrencies();
|
||||
this.httpClient = FaultTolerantHttpClient.newBuilder()
|
||||
.withCircuitBreaker(configuration.getCircuitBreaker())
|
||||
.withRetry(configuration.getRetry())
|
||||
.withVersion(Version.HTTP_2)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withRedirect(Redirect.NEVER)
|
||||
.withExecutor(Objects.requireNonNull(httpClientExecutor))
|
||||
.withName("donation")
|
||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -188,55 +142,4 @@ public class DonationController {
|
||||
}).thenCompose(Function.identity());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/authorize-apple-pay")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> getApplePayAuthorization(@Auth AuthenticatedAccount auth, @NotNull @Valid ApplePayAuthorizationRequest request) {
|
||||
if (!supportedCurrencies.contains(request.getCurrency())) {
|
||||
return CompletableFuture.completedFuture(Response.status(422).build());
|
||||
}
|
||||
|
||||
final Map<String, String> formData = new HashMap<>();
|
||||
formData.put("amount", Long.toString(request.getAmount()));
|
||||
formData.put("currency", request.getCurrency());
|
||||
if (!Strings.isNullOrEmpty(description)) {
|
||||
formData.put("description", description);
|
||||
}
|
||||
final HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.POST(FormDataBodyPublisher.of(formData))
|
||||
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(
|
||||
(apiKey + ":").getBytes(StandardCharsets.UTF_8)))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.build();
|
||||
return httpClient.sendAsync(httpRequest, BodyHandlers.ofString())
|
||||
.thenApply(this::processApplePayAuthorizationRemoteResponse);
|
||||
}
|
||||
|
||||
private Response processApplePayAuthorizationRemoteResponse(HttpResponse<String> response) {
|
||||
ObjectMapper mapper = SystemMapper.getMapper();
|
||||
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300 &&
|
||||
MediaType.APPLICATION_JSON.equalsIgnoreCase(response.headers().firstValue("Content-Type").orElse(null))) {
|
||||
try {
|
||||
final JsonNode jsonResponse = mapper.readTree(response.body());
|
||||
final String id = jsonResponse.get("id").asText(null);
|
||||
final String clientSecret = jsonResponse.get("client_secret").asText(null);
|
||||
if (Strings.isNullOrEmpty(id) || Strings.isNullOrEmpty(clientSecret)) {
|
||||
logger.warn("missing fields in json response in donation controller");
|
||||
return Response.status(500).build();
|
||||
}
|
||||
final String responseJson = mapper.writeValueAsString(new ApplePayAuthorizationResponse(id, clientSecret));
|
||||
return Response.ok(responseJson, MediaType.APPLICATION_JSON_TYPE).build();
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("json processing error in donation controller", e);
|
||||
return Response.status(500).build();
|
||||
}
|
||||
} else {
|
||||
logger.warn("unexpected response code returned to donation controller");
|
||||
return Response.status(500).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
@@ -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;
|
||||
@@ -29,16 +30,20 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
@@ -72,7 +77,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 +97,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 +167,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 +211,30 @@ public class MessageController {
|
||||
destination = source.map(AuthenticatedAccount::getAccount);
|
||||
}
|
||||
|
||||
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
|
||||
assert (destination.isPresent());
|
||||
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||
if (!isStory) {
|
||||
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
|
||||
}
|
||||
|
||||
boolean needsSync = !isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1;
|
||||
|
||||
// We return 200 when stories are sent to a non-existent account. Since story sends bypass OptionalAccess.verify
|
||||
// we leak information about whether a destination UUID exists if we return any other code (e.g. 404) from
|
||||
// these requests.
|
||||
if (isStory && destination.isEmpty()) {
|
||||
return Response.ok(new SendMessageResponse(needsSync)).build();
|
||||
}
|
||||
|
||||
// if destination is empty we would either throw an exception in OptionalAccess.verify when isStory is false
|
||||
// or else return a 200 response when isStory is true.
|
||||
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 +264,11 @@ 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();
|
||||
return Response.ok(new SendMessageResponse(needsSync)).build();
|
||||
} catch (NoSuchUserException e) {
|
||||
throw new WebApplicationException(Response.status(404).build());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
@@ -261,6 +285,35 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build mapping of accounts to devices/registration IDs.
|
||||
*
|
||||
* @param multiRecipientMessage
|
||||
* @param uuidToAccountMap
|
||||
* @return
|
||||
*/
|
||||
private Map<Account, Set<Pair<Long, Integer>>> buildDeviceIdAndRegistrationIdMap(
|
||||
MultiRecipientMessage multiRecipientMessage,
|
||||
Map<UUID, Account> uuidToAccountMap
|
||||
) {
|
||||
|
||||
return Arrays.stream(multiRecipientMessage.getRecipients())
|
||||
// for normal messages, all recipients UUIDs are in the map,
|
||||
// but story messages might specify inactive UUIDs, which we
|
||||
// have previously filtered
|
||||
.filter(r -> uuidToAccountMap.containsKey(r.getUuid()))
|
||||
.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 +321,63 @@ 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("urgent") @DefaultValue("true") final boolean isUrgent,
|
||||
@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);
|
||||
// we skip "missing" accounts when story=true.
|
||||
// otherwise, we return a 404 status code.
|
||||
final Function<UUID, Stream<Account>> accountFinder = uuid -> {
|
||||
Optional<Account> res = accountsManager.getByAccountIdentifier(uuid);
|
||||
if (!isStory && res.isEmpty()) {
|
||||
throw new WebApplicationException(Status.NOT_FOUND);
|
||||
}
|
||||
return res.stream();
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
));
|
||||
// build a map from UUID to accounts
|
||||
Map<UUID, Account> uuidToAccountMap =
|
||||
Arrays.stream(multiRecipientMessage.getRecipients())
|
||||
.map(Recipient::getUuid)
|
||||
.distinct()
|
||||
.flatMap(accountFinder)
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
Account::getUuid,
|
||||
Function.identity()));
|
||||
|
||||
// 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 +425,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, isUrgent,
|
||||
recipient, multiRecipientMessage.getCommonPayload());
|
||||
} catch (NoSuchUserException e) {
|
||||
uuids404.add(destinationAccount.getUuid());
|
||||
}
|
||||
@@ -368,6 +441,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 +483,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 +497,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))
|
||||
@@ -454,25 +539,29 @@ public class MessageController {
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/uuid/{uuid}")
|
||||
public void removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
||||
messagesManager.delete(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
uuid,
|
||||
null).ifPresent(deletedMessage -> {
|
||||
public CompletableFuture<Void> removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
||||
return messagesManager.delete(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
uuid,
|
||||
null)
|
||||
.thenAccept(maybeDeletedMessage -> {
|
||||
maybeDeletedMessage.ifPresent(deletedMessage -> {
|
||||
|
||||
WebSocketConnection.recordMessageDeliveryDuration(deletedMessage.getTimestamp(), auth.getAuthenticatedDevice());
|
||||
WebSocketConnection.recordMessageDeliveryDuration(deletedMessage.getTimestamp(),
|
||||
auth.getAuthenticatedDevice());
|
||||
|
||||
if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||
try {
|
||||
receiptSender.sendReceipt(
|
||||
UUID.fromString(deletedMessage.getDestinationUuid()), auth.getAuthenticatedDevice().getId(),
|
||||
UUID.fromString(deletedMessage.getSourceUuid()), deletedMessage.getTimestamp());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to send delivery receipt", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||
try {
|
||||
receiptSender.sendReceipt(
|
||||
UUID.fromString(deletedMessage.getDestinationUuid()), auth.getAuthenticatedDevice().getId(),
|
||||
UUID.fromString(deletedMessage.getSourceUuid()), deletedMessage.getTimestamp());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to send delivery receipt", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -515,12 +604,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 +623,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 +637,12 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessage(Account destinationAccount,
|
||||
private void sendCommonPayloadMessage(Account destinationAccount,
|
||||
Device destinationDevice,
|
||||
long timestamp,
|
||||
boolean online,
|
||||
boolean story,
|
||||
boolean urgent,
|
||||
Recipient recipient,
|
||||
byte[] commonPayload) throws NoSuchUserException {
|
||||
try {
|
||||
@@ -567,6 +660,8 @@ public class MessageController {
|
||||
.setTimestamp(timestamp == 0 ? serverTimestamp : timestamp)
|
||||
.setServerTimestamp(serverTimestamp)
|
||||
.setContent(ByteString.copyFrom(payload))
|
||||
.setStory(story)
|
||||
.setUrgent(urgent)
|
||||
.setDestinationUuid(destinationAccount.getUuid().toString());
|
||||
|
||||
messageSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online);
|
||||
@@ -579,7 +674,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.base.Strings;
|
||||
import com.stripe.model.Charge;
|
||||
import com.stripe.model.Charge.Outcome;
|
||||
import com.stripe.model.Invoice;
|
||||
@@ -46,8 +45,10 @@ import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.ClientErrorException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.InternalServerErrorException;
|
||||
@@ -58,6 +59,7 @@ import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.ProcessingException;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.core.Context;
|
||||
@@ -87,7 +89,11 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
@Path("/v1/subscription")
|
||||
@@ -144,21 +150,22 @@ public class SubscriptionController {
|
||||
if (getResult == GetResult.NOT_STORED || getResult == GetResult.PASSWORD_MISMATCH) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
String customerId = getResult.record.customerId;
|
||||
if (Strings.isNullOrEmpty(customerId)) {
|
||||
throw new InternalServerErrorException("no customer id found");
|
||||
}
|
||||
return stripeManager.getCustomer(customerId).thenCompose(customer -> {
|
||||
if (customer == null) {
|
||||
throw new InternalServerErrorException("no customer record found for id " + customerId);
|
||||
}
|
||||
return stripeManager.listNonCanceledSubscriptions(customer);
|
||||
}).thenCompose(subscriptions -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
||||
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
||||
return CompletableFuture.allOf(futures);
|
||||
});
|
||||
return getResult.record.getProcessorCustomer()
|
||||
.map(processorCustomer -> stripeManager.getCustomer(processorCustomer.customerId())
|
||||
.thenCompose(customer -> {
|
||||
if (customer == null) {
|
||||
throw new InternalServerErrorException(
|
||||
"no customer record found for id " + processorCustomer.customerId());
|
||||
}
|
||||
return stripeManager.listNonCanceledSubscriptions(customer);
|
||||
}).thenCompose(subscriptions -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
||||
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
||||
return CompletableFuture.allOf(futures);
|
||||
}))
|
||||
// a missing customer ID is OK; it means the subscriber never started to add a payment method
|
||||
.orElseGet(() -> CompletableFuture.completedFuture(null));
|
||||
})
|
||||
.thenCompose(unused -> subscriptionManager.canceledAt(requestData.subscriberUser, requestData.now))
|
||||
.thenApply(unused -> Response.ok().build());
|
||||
@@ -179,15 +186,13 @@ public class SubscriptionController {
|
||||
throw new ForbiddenException("subscriberId mismatch");
|
||||
} else if (getResult == GetResult.NOT_STORED) {
|
||||
// create a customer and write it to ddb
|
||||
return stripeManager.createCustomer(requestData.subscriberUser).thenCompose(
|
||||
customer -> subscriptionManager.create(
|
||||
requestData.subscriberUser, requestData.hmac, customer.getId(), requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return updatedRecord;
|
||||
}));
|
||||
return subscriptionManager.create(requestData.subscriberUser, requestData.hmac, requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return updatedRecord;
|
||||
});
|
||||
} else {
|
||||
// already exists so just touch access time and return
|
||||
return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now)
|
||||
@@ -197,20 +202,8 @@ public class SubscriptionController {
|
||||
.thenApply(record -> Response.ok().build());
|
||||
}
|
||||
|
||||
public static class CreatePaymentMethodResponse {
|
||||
record CreatePaymentMethodResponse(String clientSecret, SubscriptionProcessor processor) {
|
||||
|
||||
private final String clientSecret;
|
||||
|
||||
@JsonCreator
|
||||
public CreatePaymentMethodResponse(
|
||||
@JsonProperty("clientSecret") String clientSecret) {
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public String getClientSecret() {
|
||||
return clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -220,12 +213,42 @@ public class SubscriptionController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPaymentMethod(
|
||||
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) {
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType) {
|
||||
|
||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
final SubscriptionProcessorManager subscriptionProcessorManager = getManagerForPaymentMethod(paymentMethodType);
|
||||
|
||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||
.thenApply(this::requireRecordFromGetResult)
|
||||
.thenCompose(record -> stripeManager.createSetupIntent(record.customerId))
|
||||
.thenApply(setupIntent -> Response.ok(new CreatePaymentMethodResponse(setupIntent.getClientSecret())).build());
|
||||
.thenCompose(record -> {
|
||||
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture =
|
||||
record.getProcessorCustomer()
|
||||
.map(ignored -> CompletableFuture.completedFuture(record))
|
||||
.orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
|
||||
.thenApply(ProcessorCustomer::customerId)
|
||||
.thenCompose(customerId -> subscriptionManager.updateProcessorAndCustomerId(record,
|
||||
new ProcessorCustomer(customerId, subscriptionProcessorManager.getProcessor()),
|
||||
Instant.now())));
|
||||
|
||||
return updatedRecordFuture.thenCompose(
|
||||
updatedRecord -> {
|
||||
final String customerId = updatedRecord.getProcessorCustomer()
|
||||
.orElseThrow(() -> new InternalServerErrorException("record should not be missing customer"))
|
||||
.customerId();
|
||||
return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId);
|
||||
});
|
||||
})
|
||||
.thenApply(
|
||||
token -> Response.ok(new CreatePaymentMethodResponse(token, subscriptionProcessorManager.getProcessor()))
|
||||
.build());
|
||||
}
|
||||
|
||||
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return switch (paymentMethod) {
|
||||
case CARD -> stripeManager;
|
||||
};
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -240,10 +263,15 @@ public class SubscriptionController {
|
||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||
.thenApply(this::requireRecordFromGetResult)
|
||||
.thenCompose(record -> stripeManager.setDefaultPaymentMethodForCustomer(record.customerId, paymentMethodId))
|
||||
.thenCompose(record -> record.getProcessorCustomer()
|
||||
.map(processorCustomer -> stripeManager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(),
|
||||
paymentMethodId))
|
||||
.orElseThrow(() ->
|
||||
// a missing customer ID indicates the client made requests out of order,
|
||||
// and needs to call create_payment_method to create a customer for the given payment method
|
||||
new ClientErrorException(Status.CONFLICT)))
|
||||
.thenApply(customer -> Response.ok().build());
|
||||
}
|
||||
|
||||
public static class SetSubscriptionLevelSuccessResponse {
|
||||
|
||||
private final long level;
|
||||
@@ -337,15 +365,22 @@ public class SubscriptionController {
|
||||
if (record.subscriptionId == null) {
|
||||
long lastSubscriptionCreatedAt =
|
||||
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
|
||||
// we don't have one yet so create it and then record the subscription id
|
||||
//
|
||||
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
||||
// retries this request
|
||||
return stripeManager.createSubscription(record.customerId, priceConfiguration.getId(), level,
|
||||
lastSubscriptionCreatedAt)
|
||||
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
|
||||
requestData.subscriberUser, subscription.getId(), requestData.now, level)
|
||||
.thenApply(unused -> subscription));
|
||||
|
||||
return record.getProcessorCustomer()
|
||||
.map(processorCustomer ->
|
||||
// we don't have a subscription yet so create it and then record the subscription id
|
||||
//
|
||||
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
||||
// retries this request
|
||||
stripeManager.createSubscription(processorCustomer.customerId(), priceConfiguration.getId(), level,
|
||||
lastSubscriptionCreatedAt)
|
||||
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
|
||||
requestData.subscriberUser, subscription.getId(), requestData.now, level)
|
||||
.thenApply(unused -> subscription)))
|
||||
.orElseThrow(() ->
|
||||
// a missing customer ID indicates the client made requests out of order,
|
||||
// and needs to call create_payment_method to create a customer for the given payment method
|
||||
new ClientErrorException(Status.CONFLICT));
|
||||
} else {
|
||||
// we already have a subscription in our records so let's check the level and change it if needed
|
||||
return stripeManager.getSubscription(record.subscriptionId).thenCompose(
|
||||
|
||||
@@ -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,42 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
public class ApplePayAuthorizationRequest {
|
||||
|
||||
private String currency;
|
||||
private long amount;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
@Size(min=3, max=3)
|
||||
@Pattern(regexp="[a-z]{3}")
|
||||
public String getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public void setCurrency(final String currency) {
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
@JsonProperty
|
||||
@Min(0)
|
||||
public long getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setAmount(final long amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.dropwizard.util.Strings;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class ApplePayAuthorizationResponse {
|
||||
|
||||
private final String id;
|
||||
private final String clientSecret;
|
||||
|
||||
@JsonCreator
|
||||
public ApplePayAuthorizationResponse(
|
||||
@JsonProperty("id") final String id,
|
||||
@JsonProperty("client_secret") final String clientSecret) {
|
||||
if (Strings.isNullOrEmpty(id)) {
|
||||
throw new IllegalArgumentException("id cannot be empty");
|
||||
}
|
||||
if (Strings.isNullOrEmpty(clientSecret)) {
|
||||
throw new IllegalArgumentException("clientSecret cannot be empty");
|
||||
}
|
||||
|
||||
this.id = id;
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
@JsonProperty("id")
|
||||
@NotEmpty
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@JsonProperty("client_secret")
|
||||
@NotEmpty
|
||||
public String getClientSecret() {
|
||||
return clientSecret;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Max;
|
||||
@@ -53,6 +54,37 @@ public class MultiRecipientMessage {
|
||||
public byte[] getPerRecipientKeyMaterial() {
|
||||
return perRecipientKeyMaterial;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
|
||||
Recipient recipient = (Recipient) o;
|
||||
|
||||
if (deviceId != recipient.deviceId)
|
||||
return false;
|
||||
if (registrationId != recipient.registrationId)
|
||||
return false;
|
||||
if (!uuid.equals(recipient.uuid))
|
||||
return false;
|
||||
return Arrays.equals(perRecipientKeyMaterial, recipient.perRecipientKeyMaterial);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = uuid.hashCode();
|
||||
result = 31 * result + (int) (deviceId ^ (deviceId >>> 32));
|
||||
result = 31 * result + registrationId;
|
||||
result = 31 * result + Arrays.hashCode(perRecipientKeyMaterial);
|
||||
return result;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "Recipient(" + uuid + ", " + deviceId + ", " + registrationId + ", " + Arrays.toString(perRecipientKeyMaterial) + ")";
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public class FaultTolerantHttpClient {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withTrustedServerCertificate(final String certificatePem) throws CertificateException {
|
||||
public Builder withTrustedServerCertificates(final String... certificatePem) throws CertificateException {
|
||||
this.trustStore = CertificateUtil.buildKeyStoreForPem(certificatePem);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.metrics;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class NstatCounters {
|
||||
|
||||
private final Map<String, Long> networkStatistics = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String[] NSTAT_COMMAND_LINE = new String[] { "nstat", "--zero", "--json", "--noupdate", "--ignore" };
|
||||
private static final String[] EXCLUDE_METRIC_NAME_PREFIXES = new String[] { "Icmp", "Udp", "Ip6" };
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NstatCounters.class);
|
||||
|
||||
@VisibleForTesting
|
||||
static class NetworkStatistics {
|
||||
private final Map<String, Long> kernelStatistics;
|
||||
|
||||
@JsonCreator
|
||||
private NetworkStatistics(@JsonProperty("kernel") final Map<String, Long> kernelStatistics) {
|
||||
this.kernelStatistics = kernelStatistics;
|
||||
}
|
||||
|
||||
public Map<String, Long> getKernelStatistics() {
|
||||
return kernelStatistics;
|
||||
}
|
||||
}
|
||||
|
||||
public void registerMetrics(final ScheduledExecutorService refreshService, final Duration refreshInterval) {
|
||||
refreshNetworkStatistics();
|
||||
|
||||
networkStatistics.keySet().stream()
|
||||
.filter(NstatCounters::shouldIncludeMetric)
|
||||
.forEach(metricName -> Metrics.globalRegistry.more().counter(name(getClass(), "kernel", metricName),
|
||||
Collections.emptyList(), networkStatistics, statistics -> statistics.get(metricName)));
|
||||
|
||||
refreshService.scheduleAtFixedRate(this::refreshNetworkStatistics,
|
||||
refreshInterval.toMillis(), refreshInterval.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void refreshNetworkStatistics() {
|
||||
try {
|
||||
networkStatistics.putAll(loadNetworkStatistics().getKernelStatistics());
|
||||
} catch (final InterruptedException | IOException e) {
|
||||
log.warn("Failed to refresh network statistics", e);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static boolean shouldIncludeMetric(final String metricName) {
|
||||
for (final String prefix : EXCLUDE_METRIC_NAME_PREFIXES) {
|
||||
if (metricName.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static NetworkStatistics loadNetworkStatistics() throws IOException, InterruptedException {
|
||||
final Process nstatProcess = Runtime.getRuntime().exec(NSTAT_COMMAND_LINE);
|
||||
|
||||
if (nstatProcess.waitFor() == 0) {
|
||||
return OBJECT_MAPPER.readValue(nstatProcess.getInputStream(), NetworkStatistics.class);
|
||||
} else {
|
||||
throw new IOException("nstat process did not exit normally");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,11 @@ public class MultiRecipientMessageProvider implements MessageBodyReader<MultiRec
|
||||
*
|
||||
* @return the varint value
|
||||
*/
|
||||
private long readVarint(InputStream stream) throws IOException, WebApplicationException {
|
||||
@VisibleForTesting
|
||||
public static long readVarint(InputStream stream) throws IOException, WebApplicationException {
|
||||
boolean hasMore = true;
|
||||
int currentOffset = 0;
|
||||
int result = 0;
|
||||
long result = 0;
|
||||
while (hasMore) {
|
||||
if (currentOffset >= 64) {
|
||||
throw new BadRequestException("varint is too large");
|
||||
@@ -123,7 +124,7 @@ public class MultiRecipientMessageProvider implements MessageBodyReader<MultiRec
|
||||
throw new BadRequestException("varint is too large");
|
||||
}
|
||||
hasMore = (b & 0x80) != 0;
|
||||
result |= (b & 0x7F) << currentOffset;
|
||||
result |= (b & 0x7FL) << currentOffset;
|
||||
currentOffset += 7;
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -39,6 +39,7 @@ public class MessageSender {
|
||||
private static final String EPHEMERAL_TAG_NAME = "ephemeral";
|
||||
private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline";
|
||||
private static final String URGENT_TAG_NAME = "urgent";
|
||||
private static final String STORY_TAG_NAME = "story";
|
||||
private static final String SEALED_SENDER_TAG_NAME = "sealedSender";
|
||||
|
||||
public MessageSender(ClientPresenceManager clientPresenceManager,
|
||||
@@ -101,6 +102,7 @@ public class MessageSender {
|
||||
EPHEMERAL_TAG_NAME, String.valueOf(online),
|
||||
CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent),
|
||||
URGENT_TAG_NAME, String.valueOf(message.getUrgent()),
|
||||
STORY_TAG_NAME, String.valueOf(message.getStory()),
|
||||
SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceUuid()))
|
||||
.increment();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.whispersystems.textsecuregcm.redis;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.lettuce.core.RedisException;
|
||||
import io.lettuce.core.RedisNoScriptException;
|
||||
import io.lettuce.core.ScriptOutputType;
|
||||
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
||||
@@ -15,9 +16,12 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class ClusterLuaScript {
|
||||
|
||||
@@ -73,11 +77,31 @@ public class ClusterLuaScript {
|
||||
execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||
}
|
||||
|
||||
public CompletableFuture<Object> executeAsync(final List<String> keys, final List<String> args) {
|
||||
return redisCluster.withCluster(connection ->
|
||||
executeAsync(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||
}
|
||||
|
||||
public Flux<Object> executeReactive(final List<String> keys, final List<String> args) {
|
||||
return redisCluster.withCluster(connection ->
|
||||
executeReactive(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
||||
}
|
||||
|
||||
public Object executeBinary(final List<byte[]> keys, final List<byte[]> args) {
|
||||
return redisCluster.withBinaryCluster(connection ->
|
||||
execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||
}
|
||||
|
||||
public CompletableFuture<Object> executeBinaryAsync(final List<byte[]> keys, final List<byte[]> args) {
|
||||
return redisCluster.withBinaryCluster(connection ->
|
||||
executeAsync(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||
}
|
||||
|
||||
public Flux<Object> executeBinaryReactive(final List<byte[]> keys, final List<byte[]> args) {
|
||||
return redisCluster.withBinaryCluster(connection ->
|
||||
executeReactive(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
||||
}
|
||||
|
||||
private <T> Object execute(final StatefulRedisClusterConnection<T, T> connection, final T[] keys, final T[] args) {
|
||||
try {
|
||||
try {
|
||||
@@ -90,4 +114,32 @@ public class ClusterLuaScript {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<Object> executeAsync(final StatefulRedisClusterConnection<T, T> connection,
|
||||
final T[] keys, final T[] args) {
|
||||
|
||||
return connection.async().evalsha(sha, scriptOutputType, keys, args)
|
||||
.exceptionallyCompose(throwable -> {
|
||||
if (throwable instanceof RedisNoScriptException) {
|
||||
return connection.async().eval(script, scriptOutputType, keys, args);
|
||||
}
|
||||
|
||||
log.warn("Failed to execute script", throwable);
|
||||
throw new RedisException(throwable);
|
||||
}).toCompletableFuture();
|
||||
}
|
||||
|
||||
private <T> Flux<Object> executeReactive(final StatefulRedisClusterConnection<T, T> connection,
|
||||
final T[] keys, final T[] args) {
|
||||
|
||||
return connection.reactive().evalsha(sha, scriptOutputType, keys, args)
|
||||
.onErrorResume(e -> {
|
||||
if (e instanceof RedisNoScriptException) {
|
||||
return connection.reactive().eval(script, scriptOutputType, keys, args);
|
||||
}
|
||||
|
||||
log.warn("Failed to execute script", e);
|
||||
return Mono.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.redis;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
|
||||
import io.github.resilience4j.reactor.retry.RetryOperator;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.lettuce.core.ClientOptions.DisconnectedBehavior;
|
||||
import io.lettuce.core.RedisCommandTimeoutException;
|
||||
@@ -24,11 +26,13 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* A fault-tolerant access manager for a Redis cluster. A fault-tolerant Redis cluster provides managed,
|
||||
@@ -49,96 +53,115 @@ public class FaultTolerantRedisCluster {
|
||||
private final Retry retry;
|
||||
|
||||
public FaultTolerantRedisCluster(final String name, final RedisClusterConfiguration clusterConfiguration, final ClientResources clientResources) {
|
||||
this(name,
|
||||
RedisClusterClient.create(clientResources, clusterConfiguration.getConfigurationUri()),
|
||||
clusterConfiguration.getTimeout(),
|
||||
clusterConfiguration.getCircuitBreakerConfiguration(),
|
||||
clusterConfiguration.getRetryConfiguration());
|
||||
this(name,
|
||||
RedisClusterClient.create(clientResources, clusterConfiguration.getConfigurationUri()),
|
||||
clusterConfiguration.getTimeout(),
|
||||
clusterConfiguration.getCircuitBreakerConfiguration(),
|
||||
clusterConfiguration.getRetryConfiguration());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
FaultTolerantRedisCluster(final String name, final RedisClusterClient clusterClient, final Duration commandTimeout, final CircuitBreakerConfiguration circuitBreakerConfiguration, final RetryConfiguration retryConfiguration) {
|
||||
this.name = name;
|
||||
this.name = name;
|
||||
|
||||
this.clusterClient = clusterClient;
|
||||
this.clusterClient.setDefaultTimeout(commandTimeout);
|
||||
this.clusterClient.setOptions(ClusterClientOptions.builder()
|
||||
.disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
|
||||
.validateClusterNodeMembership(false)
|
||||
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
|
||||
.enableAllAdaptiveRefreshTriggers()
|
||||
.build())
|
||||
.build());
|
||||
this.clusterClient = clusterClient;
|
||||
this.clusterClient.setDefaultTimeout(commandTimeout);
|
||||
this.clusterClient.setOptions(ClusterClientOptions.builder()
|
||||
.disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
|
||||
.validateClusterNodeMembership(false)
|
||||
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
|
||||
.enableAllAdaptiveRefreshTriggers()
|
||||
.build())
|
||||
.publishOnScheduler(true)
|
||||
.build());
|
||||
|
||||
this.stringConnection = clusterClient.connect();
|
||||
this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE);
|
||||
this.stringConnection = clusterClient.connect();
|
||||
this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE);
|
||||
|
||||
this.circuitBreaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig());
|
||||
this.retry = Retry.of(name + "-retry", retryConfiguration.toRetryConfigBuilder().retryOnException(exception -> exception instanceof RedisCommandTimeoutException).build());
|
||||
this.circuitBreaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig());
|
||||
this.retry = Retry.of(name + "-retry", retryConfiguration.toRetryConfigBuilder()
|
||||
.retryOnException(exception -> exception instanceof RedisCommandTimeoutException).build());
|
||||
|
||||
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), circuitBreaker, FaultTolerantRedisCluster.class);
|
||||
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), retry, FaultTolerantRedisCluster.class);
|
||||
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), circuitBreaker,
|
||||
FaultTolerantRedisCluster.class);
|
||||
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), retry,
|
||||
FaultTolerantRedisCluster.class);
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
stringConnection.close();
|
||||
binaryConnection.close();
|
||||
stringConnection.close();
|
||||
binaryConnection.close();
|
||||
|
||||
for (final StatefulRedisClusterPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {
|
||||
pubSubConnection.close();
|
||||
}
|
||||
for (final StatefulRedisClusterPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {
|
||||
pubSubConnection.close();
|
||||
}
|
||||
|
||||
clusterClient.shutdown();
|
||||
clusterClient.shutdown();
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void useCluster(final Consumer<StatefulRedisClusterConnection<String, String>> consumer) {
|
||||
useConnection(stringConnection, consumer);
|
||||
}
|
||||
public void useCluster(final Consumer<StatefulRedisClusterConnection<String, String>> consumer) {
|
||||
useConnection(stringConnection, consumer);
|
||||
}
|
||||
|
||||
public <T> T withCluster(final Function<StatefulRedisClusterConnection<String, String>, T> function) {
|
||||
return withConnection(stringConnection, function);
|
||||
}
|
||||
public <T> T withCluster(final Function<StatefulRedisClusterConnection<String, String>, T> function) {
|
||||
return withConnection(stringConnection, function);
|
||||
}
|
||||
|
||||
public void useBinaryCluster(final Consumer<StatefulRedisClusterConnection<byte[], byte[]>> consumer) {
|
||||
useConnection(binaryConnection, consumer);
|
||||
}
|
||||
public void useBinaryCluster(final Consumer<StatefulRedisClusterConnection<byte[], byte[]>> consumer) {
|
||||
useConnection(binaryConnection, consumer);
|
||||
}
|
||||
|
||||
public <T> T withBinaryCluster(final Function<StatefulRedisClusterConnection<byte[], byte[]>, T> function) {
|
||||
return withConnection(binaryConnection, function);
|
||||
}
|
||||
public <T> T withBinaryCluster(final Function<StatefulRedisClusterConnection<byte[], byte[]>, T> function) {
|
||||
return withConnection(binaryConnection, function);
|
||||
}
|
||||
|
||||
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection, final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
||||
try {
|
||||
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
||||
} catch (final Throwable t) {
|
||||
if (t instanceof RedisException) {
|
||||
throw (RedisException) t;
|
||||
} else {
|
||||
throw new RedisException(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
public <T> Publisher<T> withBinaryClusterReactive(
|
||||
final Function<StatefulRedisClusterConnection<byte[], byte[]>, Publisher<T>> function) {
|
||||
return withConnectionReactive(binaryConnection, function);
|
||||
}
|
||||
|
||||
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection, final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
||||
try {
|
||||
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
||||
} catch (final Throwable t) {
|
||||
if (t instanceof RedisException) {
|
||||
throw (RedisException) t;
|
||||
} else {
|
||||
throw new RedisException(t);
|
||||
}
|
||||
}
|
||||
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||
final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
||||
try {
|
||||
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
||||
} catch (final Throwable t) {
|
||||
if (t instanceof RedisException) {
|
||||
throw (RedisException) t;
|
||||
} else {
|
||||
throw new RedisException(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {
|
||||
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
|
||||
pubSubConnections.add(pubSubConnection);
|
||||
|
||||
return new FaultTolerantPubSubConnection<>(name, pubSubConnection, circuitBreaker, retry);
|
||||
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||
final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
||||
try {
|
||||
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
||||
} catch (final Throwable t) {
|
||||
if (t instanceof RedisException) {
|
||||
throw (RedisException) t;
|
||||
} else {
|
||||
throw new RedisException(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private <T, K, V> Publisher<T> withConnectionReactive(final StatefulRedisClusterConnection<K, V> connection,
|
||||
final Function<StatefulRedisClusterConnection<K, V>, Publisher<T>> function) {
|
||||
|
||||
return Flux.from(function.apply(connection))
|
||||
.transformDeferred(RetryOperator.of(retry))
|
||||
.transformDeferred(CircuitBreakerOperator.of(circuitBreaker));
|
||||
}
|
||||
|
||||
public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {
|
||||
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
|
||||
pubSubConnections.add(pubSubConnection);
|
||||
|
||||
return new FaultTolerantPubSubConnection<>(name, pubSubConnection, circuitBreaker, retry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.whispersystems.textsecuregcm.registration;
|
||||
|
||||
import io.grpc.CallCredentials;
|
||||
import io.grpc.Metadata;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
class ApiKeyCallCredentials extends CallCredentials {
|
||||
|
||||
private final String apiKey;
|
||||
|
||||
private static final Metadata.Key<String> API_KEY_METADATA_KEY =
|
||||
Metadata.Key.of("x-signal-api-key", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
ApiKeyCallCredentials(final String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyRequestMetadata(final RequestInfo requestInfo,
|
||||
final Executor appExecutor,
|
||||
final MetadataApplier applier) {
|
||||
|
||||
final Metadata metadata = new Metadata();
|
||||
metadata.put(API_KEY_METADATA_KEY, apiKey);
|
||||
|
||||
applier.apply(metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void thisUsesUnstableApi() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.registration;
|
||||
|
||||
public enum ClientType {
|
||||
IOS,
|
||||
ANDROID_WITH_FCM,
|
||||
ANDROID_WITHOUT_FCM,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.registration;
|
||||
|
||||
/**
|
||||
* A message transport is a medium via which verification codes can be delivered to a destination phone.
|
||||
*/
|
||||
public enum MessageTransport {
|
||||
SMS,
|
||||
VOICE
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package org.whispersystems.textsecuregcm.registration;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import io.grpc.ChannelCredentials;
|
||||
import io.grpc.Deadline;
|
||||
import io.grpc.Grpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.TlsChannelCredentials;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.checkerframework.checker.nullness.qual.Nullable;
|
||||
import org.signal.registration.rpc.CheckVerificationCodeRequest;
|
||||
import org.signal.registration.rpc.CheckVerificationCodeResponse;
|
||||
import org.signal.registration.rpc.RegistrationServiceGrpc;
|
||||
import org.signal.registration.rpc.SendVerificationCodeRequest;
|
||||
|
||||
public class RegistrationServiceClient implements Managed {
|
||||
|
||||
private final ManagedChannel channel;
|
||||
private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub;
|
||||
private final Executor callbackExecutor;
|
||||
|
||||
public RegistrationServiceClient(final String host,
|
||||
final int port,
|
||||
final String apiKey,
|
||||
final String caCertificatePem,
|
||||
final Executor callbackExecutor) throws IOException {
|
||||
|
||||
try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(caCertificatePem.getBytes(StandardCharsets.UTF_8))) {
|
||||
final ChannelCredentials tlsChannelCredentials = TlsChannelCredentials.newBuilder()
|
||||
.trustManager(certificateInputStream)
|
||||
.build();
|
||||
|
||||
this.channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials).build();
|
||||
}
|
||||
|
||||
this.stub = RegistrationServiceGrpc.newFutureStub(channel)
|
||||
.withCallCredentials(new ApiKeyCallCredentials(apiKey));
|
||||
|
||||
this.callbackExecutor = callbackExecutor;
|
||||
}
|
||||
|
||||
public CompletableFuture<byte[]> sendRegistrationCode(final Phonenumber.PhoneNumber phoneNumber,
|
||||
final MessageTransport messageTransport,
|
||||
final ClientType clientType,
|
||||
@Nullable final String acceptLanguage,
|
||||
final Duration timeout) {
|
||||
|
||||
final long e164 = Long.parseLong(
|
||||
PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1));
|
||||
|
||||
final SendVerificationCodeRequest.Builder requestBuilder = SendVerificationCodeRequest.newBuilder()
|
||||
.setE164(e164)
|
||||
.setTransport(getRpcMessageTransport(messageTransport))
|
||||
.setClientType(getRpcClientType(clientType));
|
||||
|
||||
if (StringUtils.isNotBlank(acceptLanguage)) {
|
||||
requestBuilder.setAcceptLanguage(acceptLanguage);
|
||||
}
|
||||
|
||||
return toCompletableFuture(stub.withDeadline(toDeadline(timeout))
|
||||
.sendVerificationCode(requestBuilder.build()))
|
||||
.thenApply(response -> response.getSessionId().toByteArray());
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> checkVerificationCode(final byte[] sessionId,
|
||||
final String verificationCode,
|
||||
final Duration timeout) {
|
||||
|
||||
return toCompletableFuture(stub.withDeadline(toDeadline(timeout))
|
||||
.checkVerificationCode(CheckVerificationCodeRequest.newBuilder()
|
||||
.setSessionId(ByteString.copyFrom(sessionId))
|
||||
.setVerificationCode(verificationCode)
|
||||
.build()))
|
||||
.thenApply(CheckVerificationCodeResponse::getVerified);
|
||||
}
|
||||
|
||||
private static Deadline toDeadline(final Duration timeout) {
|
||||
return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private static org.signal.registration.rpc.ClientType getRpcClientType(final ClientType clientType) {
|
||||
return switch (clientType) {
|
||||
case IOS -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_IOS;
|
||||
case ANDROID_WITH_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITH_FCM;
|
||||
case ANDROID_WITHOUT_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITHOUT_FCM;
|
||||
case UNKNOWN -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_UNSPECIFIED;
|
||||
};
|
||||
}
|
||||
|
||||
private static org.signal.registration.rpc.MessageTransport getRpcMessageTransport(final MessageTransport transport) {
|
||||
return switch (transport) {
|
||||
case SMS -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_SMS;
|
||||
case VOICE -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_VOICE;
|
||||
};
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<T> toCompletableFuture(final ListenableFuture<T> listenableFuture) {
|
||||
final CompletableFuture<T> completableFuture = new CompletableFuture<>();
|
||||
|
||||
Futures.addCallback(listenableFuture, new FutureCallback<T>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable final T result) {
|
||||
completableFuture.complete(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(final Throwable throwable) {
|
||||
completableFuture.completeExceptionally(throwable);
|
||||
}
|
||||
}, callbackExecutor);
|
||||
|
||||
return completableFuture;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws Exception {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
if (channel != null) {
|
||||
channel.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public class SecureBackupClient {
|
||||
.withExecutor(executor)
|
||||
.withName("secure-backup")
|
||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2)
|
||||
.withTrustedServerCertificate(configuration.getBackupCaCertificate())
|
||||
.withTrustedServerCertificates(configuration.getBackupCaCertificates().toArray(new String[0]))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ public class SecureStorageClient {
|
||||
.withExecutor(executor)
|
||||
.withName("secure-storage")
|
||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
|
||||
.withTrustedServerCertificate(configuration.getStorageCaCertificate())
|
||||
.withTrustedServerCertificates(configuration.getStorageCaCertificates().toArray(new String[0]))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.sms;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale.LanguageRange;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class SmsSender {
|
||||
|
||||
private final TwilioSmsSender twilioSender;
|
||||
|
||||
public SmsSender(TwilioSmsSender twilioSender) {
|
||||
this.twilioSender = twilioSender;
|
||||
}
|
||||
|
||||
public void deliverSmsVerification(String destination, Optional<String> clientType, String verificationCode) {
|
||||
// Fix up mexico numbers to 'mobile' format just for SMS delivery.
|
||||
if (destination.startsWith("+52") && !destination.startsWith("+521")) {
|
||||
destination = "+521" + destination.substring("+52".length());
|
||||
}
|
||||
|
||||
twilioSender.deliverSmsVerification(destination, clientType, verificationCode);
|
||||
}
|
||||
|
||||
public void deliverVoxVerification(String destination, String verificationCode, List<LanguageRange> languageRanges) {
|
||||
twilioSender.deliverVoxVerification(destination, verificationCode, languageRanges);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> deliverSmsVerificationWithTwilioVerify(String destination,
|
||||
Optional<String> clientType,
|
||||
String verificationCode, List<LanguageRange> languageRanges) {
|
||||
// Fix up mexico numbers to 'mobile' format just for SMS delivery.
|
||||
if (destination.startsWith("+52") && !destination.startsWith("+521")) {
|
||||
destination = "+521" + destination.substring(3);
|
||||
}
|
||||
|
||||
return twilioSender.deliverSmsVerificationWithVerify(destination, clientType, verificationCode, languageRanges);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> deliverVoxVerificationWithTwilioVerify(String destination,
|
||||
String verificationCode,
|
||||
List<LanguageRange> languageRanges) {
|
||||
|
||||
return twilioSender.deliverVoxVerificationWithVerify(destination, verificationCode, languageRanges);
|
||||
}
|
||||
|
||||
public void reportVerificationSucceeded(String verificationSid, @Nullable String userAgent, String context) {
|
||||
twilioSender.reportVerificationSucceeded(verificationSid, userAgent, context);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.sms;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Locale.LanguageRange;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TwilioVerificationTextConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.ExecutorUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class TwilioSmsSender {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TwilioSmsSender.class);
|
||||
|
||||
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||
private final Meter smsMeter = metricRegistry.meter(name(getClass(), "sms", "delivered"));
|
||||
private final Meter voxMeter = metricRegistry.meter(name(getClass(), "vox", "delivered"));
|
||||
private final Meter priceMeter = metricRegistry.meter(name(getClass(), "price"));
|
||||
|
||||
static final String FAILED_REQUEST_COUNTER_NAME = name(TwilioSmsSender.class, "failedRequest");
|
||||
static final String SERVICE_NAME_TAG = "service";
|
||||
static final String STATUS_CODE_TAG_NAME = "statusCode";
|
||||
static final String ERROR_CODE_TAG_NAME = "errorCode";
|
||||
|
||||
private final String accountId;
|
||||
private final String accountToken;
|
||||
private final String messagingServiceSid;
|
||||
private final String nanpaMessagingServiceSid;
|
||||
private final String localDomain;
|
||||
private final Random random;
|
||||
|
||||
private final TwilioVerificationTextConfiguration defaultClientVerificationTexts;
|
||||
private final Map<String,TwilioVerificationTextConfiguration> regionalClientVerificationTexts;
|
||||
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
private final URI smsUri;
|
||||
private final URI voxUri;
|
||||
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
private final TwilioVerifySender twilioVerifySender;
|
||||
|
||||
@VisibleForTesting
|
||||
public TwilioSmsSender(String baseUri,
|
||||
String baseVerifyUri,
|
||||
TwilioConfiguration twilioConfiguration,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
|
||||
Executor executor = ExecutorUtils.newFixedThreadBoundedQueueExecutor(10, 100);
|
||||
|
||||
this.accountId = twilioConfiguration.getAccountId();
|
||||
this.accountToken = twilioConfiguration.getAccountToken();
|
||||
this.localDomain = twilioConfiguration.getLocalDomain();
|
||||
this.messagingServiceSid = twilioConfiguration.getMessagingServiceSid();
|
||||
this.nanpaMessagingServiceSid = twilioConfiguration.getNanpaMessagingServiceSid();
|
||||
this.random = new Random(System.currentTimeMillis());
|
||||
this.smsUri = URI.create(baseUri + "/2010-04-01/Accounts/" + accountId + "/Messages.json");
|
||||
this.voxUri = URI.create(baseUri + "/2010-04-01/Accounts/" + accountId + "/Calls.json" );
|
||||
this.httpClient = FaultTolerantHttpClient.newBuilder()
|
||||
.withCircuitBreaker(twilioConfiguration.getCircuitBreaker())
|
||||
.withRetry(twilioConfiguration.getRetry())
|
||||
.withVersion(HttpClient.Version.HTTP_2)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withRedirect(HttpClient.Redirect.NEVER)
|
||||
.withExecutor(executor)
|
||||
.withName("twilio")
|
||||
.build();
|
||||
|
||||
this.defaultClientVerificationTexts = twilioConfiguration.getDefaultClientVerificationTexts();
|
||||
this.regionalClientVerificationTexts = twilioConfiguration.getRegionalClientVerificationTexts();
|
||||
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.twilioVerifySender = new TwilioVerifySender(baseVerifyUri, httpClient, twilioConfiguration);
|
||||
}
|
||||
|
||||
public TwilioSmsSender(TwilioConfiguration twilioConfiguration, DynamicConfigurationManager dynamicConfigurationManager) {
|
||||
this("https://api.twilio.com", "https://verify.twilio.com", twilioConfiguration, dynamicConfigurationManager);
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> deliverSmsVerification(String destination, Optional<String> clientType, String verificationCode) {
|
||||
|
||||
Map<String, String> requestParameters = new HashMap<>();
|
||||
requestParameters.put("To", destination);
|
||||
requestParameters.put("MessagingServiceSid", "1".equals(Util.getCountryCode(destination)) ? nanpaMessagingServiceSid : messagingServiceSid);
|
||||
requestParameters.put("Body", String.format(Locale.US, getBodyFormatString(destination, clientType.orElse(null)), verificationCode));
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(smsUri)
|
||||
.POST(FormDataBodyPublisher.of(requestParameters))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes(StandardCharsets.UTF_8)))
|
||||
.build();
|
||||
|
||||
smsMeter.mark();
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(this::parseResponse)
|
||||
.handle((response, throwable) -> processResponse(response, throwable, destination));
|
||||
}
|
||||
|
||||
private String getBodyFormatString(@Nonnull String destination, @Nullable String clientType) {
|
||||
|
||||
final String countryCode = Util.getCountryCode(destination);
|
||||
|
||||
final TwilioVerificationTextConfiguration verificationTexts = regionalClientVerificationTexts
|
||||
.getOrDefault(countryCode, defaultClientVerificationTexts);
|
||||
|
||||
final String result;
|
||||
if ("ios".equals(clientType)) {
|
||||
result = verificationTexts.getIosText();
|
||||
} else if ("android-ng".equals(clientType)) {
|
||||
result = verificationTexts.getAndroidNgText();
|
||||
} else if ("android-2020-01".equals(clientType)) {
|
||||
result = verificationTexts.getAndroid202001Text();
|
||||
} else if ("android-2021-03".equals(clientType)) {
|
||||
result = verificationTexts.getAndroid202103Text();
|
||||
} else {
|
||||
result = verificationTexts.getGenericText();
|
||||
}
|
||||
if ("86".equals(countryCode)) { // is China
|
||||
return result + "\u2008";
|
||||
// Twilio recommends adding this character to the end of strings delivered to China because some carriers in
|
||||
// China are blocking GSM-7 encoding and this will force Twilio to send using UCS-2 instead.
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> deliverVoxVerification(String destination, String verificationCode, List<LanguageRange> languageRanges) {
|
||||
String url = "https://" + localDomain + "/v1/voice/description/" + verificationCode;
|
||||
|
||||
final String languageQueryParams = languageRanges.stream()
|
||||
.map(range -> Locale.forLanguageTag(range.getRange()))
|
||||
.map(locale -> {
|
||||
if (StringUtils.isNotBlank(locale.getCountry())) {
|
||||
return locale.getLanguage().toLowerCase() + "-" + locale.getCountry().toUpperCase();
|
||||
} else {
|
||||
return locale.getLanguage().toLowerCase();
|
||||
}
|
||||
})
|
||||
.map(languageTag -> "l=" + languageTag)
|
||||
.collect(Collectors.joining("&"));
|
||||
|
||||
if (StringUtils.isNotBlank(languageQueryParams)) {
|
||||
url += "?" + languageQueryParams;
|
||||
}
|
||||
|
||||
Map<String, String> requestParameters = new HashMap<>();
|
||||
requestParameters.put("Url", url);
|
||||
requestParameters.put("To", destination);
|
||||
requestParameters.put("From", getRandom(random, dynamicConfigurationManager.getConfiguration().getTwilioConfiguration().getNumbers()));
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(voxUri)
|
||||
.POST(FormDataBodyPublisher.of(requestParameters))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
|
||||
.build();
|
||||
|
||||
voxMeter.mark();
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(this::parseResponse)
|
||||
.handle((response, throwable) -> processResponse(response, throwable, destination));
|
||||
}
|
||||
|
||||
private String getRandom(Random random, List<String> elements) {
|
||||
return elements.get(random.nextInt(elements.size()));
|
||||
}
|
||||
|
||||
private boolean processResponse(TwilioResponse response, Throwable throwable, String destination) {
|
||||
if (response != null && response.isSuccess()) {
|
||||
priceMeter.mark((long) (response.successResponse.price * 1000));
|
||||
return true;
|
||||
} else if (response != null && response.isFailure()) {
|
||||
|
||||
Metrics.counter(FAILED_REQUEST_COUNTER_NAME,
|
||||
SERVICE_NAME_TAG, "classic",
|
||||
STATUS_CODE_TAG_NAME, String.valueOf(response.failureResponse.status),
|
||||
ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code)).increment();
|
||||
|
||||
logger.info("Failed with code={}, country={}",
|
||||
response.failureResponse.code,
|
||||
Util.getCountryCode(destination));
|
||||
|
||||
return false;
|
||||
} else if (throwable != null) {
|
||||
logger.info("Twilio request failed", throwable);
|
||||
return false;
|
||||
} else {
|
||||
logger.warn("No response or throwable!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private TwilioResponse parseResponse(HttpResponse<String> response) {
|
||||
ObjectMapper mapper = SystemMapper.getMapper();
|
||||
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) {
|
||||
return new TwilioResponse(TwilioResponse.TwilioSuccessResponse.fromBody(mapper, response.body()));
|
||||
} else {
|
||||
return new TwilioResponse(new TwilioResponse.TwilioSuccessResponse());
|
||||
}
|
||||
}
|
||||
|
||||
if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) {
|
||||
return new TwilioResponse(TwilioResponse.TwilioFailureResponse.fromBody(mapper, response.body()));
|
||||
} else {
|
||||
return new TwilioResponse(new TwilioResponse.TwilioFailureResponse());
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> deliverSmsVerificationWithVerify(String destination,
|
||||
Optional<String> clientType, String verificationCode, List<LanguageRange> languageRanges) {
|
||||
|
||||
smsMeter.mark();
|
||||
|
||||
return twilioVerifySender.deliverSmsVerificationWithVerify(destination, clientType, verificationCode,
|
||||
languageRanges);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination,
|
||||
String verificationCode, List<LanguageRange> languageRanges) {
|
||||
|
||||
voxMeter.mark();
|
||||
|
||||
return twilioVerifySender.deliverVoxVerificationWithVerify(destination, verificationCode, languageRanges);
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid, @Nullable String userAgent,
|
||||
String context) {
|
||||
|
||||
return twilioVerifySender.reportVerificationSucceeded(verificationSid, userAgent, context);
|
||||
}
|
||||
|
||||
public static class TwilioResponse {
|
||||
|
||||
private TwilioSuccessResponse successResponse;
|
||||
private TwilioFailureResponse failureResponse;
|
||||
|
||||
TwilioResponse(TwilioSuccessResponse successResponse) {
|
||||
this.successResponse = successResponse;
|
||||
}
|
||||
|
||||
TwilioResponse(TwilioFailureResponse failureResponse) {
|
||||
this.failureResponse = failureResponse;
|
||||
}
|
||||
|
||||
boolean isSuccess() {
|
||||
return successResponse != null;
|
||||
}
|
||||
|
||||
boolean isFailure() {
|
||||
return failureResponse != null;
|
||||
}
|
||||
|
||||
private static class TwilioSuccessResponse {
|
||||
@JsonProperty
|
||||
private double price;
|
||||
|
||||
static TwilioSuccessResponse fromBody(ObjectMapper mapper, String body) {
|
||||
try {
|
||||
return mapper.readValue(body, TwilioSuccessResponse.class);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error parsing twilio success response: " + e);
|
||||
return new TwilioSuccessResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class TwilioFailureResponse {
|
||||
@JsonProperty
|
||||
private int status;
|
||||
|
||||
@JsonProperty
|
||||
private String message;
|
||||
|
||||
@JsonProperty
|
||||
private int code;
|
||||
|
||||
static TwilioFailureResponse fromBody(ObjectMapper mapper, String body) {
|
||||
try {
|
||||
return mapper.readValue(body, TwilioFailureResponse.class);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error parsing twilio success response: " + e);
|
||||
return new TwilioFailureResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.sms;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.List;
|
||||
import java.util.Locale.LanguageRange;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
|
||||
public class TwilioVerifyExperimentEnrollmentManager {
|
||||
|
||||
@VisibleForTesting
|
||||
static final String EXPERIMENT_NAME = "twilio_verify_v1";
|
||||
|
||||
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||
|
||||
private static final Set<String> INELIGIBLE_CLIENTS = Set.of("android-ng", "android-2020-01");
|
||||
|
||||
private final Set<String> signalExclusiveVoiceVerificationLanguages;
|
||||
|
||||
public TwilioVerifyExperimentEnrollmentManager(final VoiceVerificationConfiguration voiceVerificationConfiguration,
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager) {
|
||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||
|
||||
// Signal voice verification supports several languages that Verify does not. We want to honor
|
||||
// clients that prioritize these languages, even if they would normally be enrolled in the experiment
|
||||
signalExclusiveVoiceVerificationLanguages = voiceVerificationConfiguration.getLocales().stream()
|
||||
.map(loc -> loc.split("-")[0])
|
||||
.filter(language -> !TwilioVerifySender.TWILIO_VERIFY_LANGUAGES.contains(language))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public boolean isEnrolled(Optional<String> clientType, String number, List<LanguageRange> languageRanges,
|
||||
String transport) {
|
||||
|
||||
final boolean clientEligible = clientType.map(client -> !INELIGIBLE_CLIENTS.contains(client))
|
||||
.orElse(true);
|
||||
|
||||
final boolean languageEligible;
|
||||
|
||||
if ("sms".equals(transport)) {
|
||||
// Signal only sends SMS in en, while Verify supports en + many other languages
|
||||
languageEligible = true;
|
||||
} else {
|
||||
|
||||
boolean clientPreferredLanguageOnlySupportedBySignal = false;
|
||||
|
||||
for (LanguageRange languageRange : languageRanges) {
|
||||
final String language = languageRange.getRange().split("-")[0];
|
||||
|
||||
if (signalExclusiveVoiceVerificationLanguages.contains(language)) {
|
||||
// Support is exclusive to Signal.
|
||||
// Since this is the first match in the priority list, so let's break and honor it
|
||||
clientPreferredLanguageOnlySupportedBySignal = true;
|
||||
break;
|
||||
}
|
||||
if (TwilioVerifySender.TWILIO_VERIFY_LANGUAGES.contains(language)) {
|
||||
// Twilio supports it, so we can stop looping
|
||||
break;
|
||||
}
|
||||
|
||||
// the language is supported by neither, so let's loop again
|
||||
}
|
||||
|
||||
languageEligible = !clientPreferredLanguageOnlySupportedBySignal;
|
||||
}
|
||||
final boolean enrolled = experimentEnrollmentManager.isEnrolled(number, EXPERIMENT_NAME);
|
||||
|
||||
return clientEligible && languageEligible && enrolled;
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.sms;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale.LanguageRange;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
class TwilioVerifySender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TwilioVerifySender.class);
|
||||
|
||||
private static final String VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME = name(TwilioVerifySender.class,
|
||||
"verificationSucceeded");
|
||||
|
||||
private static final String CONTEXT_TAG_NAME = "context";
|
||||
private static final String STATUS_CODE_TAG_NAME = "statusCode";
|
||||
private static final String ERROR_CODE_TAG_NAME = "errorCode";
|
||||
|
||||
static final Set<String> TWILIO_VERIFY_LANGUAGES = Set.of(
|
||||
"af",
|
||||
"ar",
|
||||
"ca",
|
||||
"zh",
|
||||
"zh-CN",
|
||||
"zh-HK",
|
||||
"hr",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"en-GB",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"ms",
|
||||
"nb",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"ru",
|
||||
"es",
|
||||
"sv",
|
||||
"tl",
|
||||
"th",
|
||||
"tr",
|
||||
"vi");
|
||||
|
||||
private final String accountId;
|
||||
private final String accountToken;
|
||||
|
||||
private final URI verifyServiceUri;
|
||||
private final URI verifyApprovalBaseUri;
|
||||
private final String androidAppHash;
|
||||
private final String verifyServiceFriendlyName;
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
|
||||
TwilioVerifySender(String baseUri, FaultTolerantHttpClient httpClient, TwilioConfiguration twilioConfiguration) {
|
||||
|
||||
this.accountId = twilioConfiguration.getAccountId();
|
||||
this.accountToken = twilioConfiguration.getAccountToken();
|
||||
|
||||
this.verifyServiceUri = URI
|
||||
.create(baseUri + "/v2/Services/" + twilioConfiguration.getVerifyServiceSid() + "/Verifications");
|
||||
this.verifyApprovalBaseUri = URI
|
||||
.create(baseUri + "/v2/Services/" + twilioConfiguration.getVerifyServiceSid() + "/Verifications/");
|
||||
|
||||
this.androidAppHash = twilioConfiguration.getAndroidAppHash();
|
||||
this.verifyServiceFriendlyName = twilioConfiguration.getVerifyServiceFriendlyName();
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
CompletableFuture<Optional<String>> deliverSmsVerificationWithVerify(String destination, Optional<String> clientType,
|
||||
String verificationCode, List<LanguageRange> languageRanges) {
|
||||
|
||||
HttpRequest request = buildVerifyRequest("sms", destination, verificationCode, findBestLocale(languageRanges),
|
||||
clientType);
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(this::parseResponse)
|
||||
.handle((response, throwable) -> extractVerifySid(response, throwable, destination));
|
||||
}
|
||||
|
||||
private Optional<String> findBestLocale(List<LanguageRange> priorityList) {
|
||||
return Util.findBestLocale(priorityList, TwilioVerifySender.TWILIO_VERIFY_LANGUAGES);
|
||||
}
|
||||
|
||||
private TwilioVerifyResponse parseResponse(HttpResponse<String> response) {
|
||||
ObjectMapper mapper = SystemMapper.getMapper();
|
||||
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) {
|
||||
return new TwilioVerifyResponse(TwilioVerifyResponse.SuccessResponse.fromBody(mapper, response.body()));
|
||||
} else {
|
||||
return new TwilioVerifyResponse(new TwilioVerifyResponse.SuccessResponse());
|
||||
}
|
||||
}
|
||||
|
||||
if ("application/json".equals(response.headers().firstValue("Content-Type").orElse(null))) {
|
||||
return new TwilioVerifyResponse(TwilioVerifyResponse.FailureResponse.fromBody(mapper, response.body()));
|
||||
} else {
|
||||
return new TwilioVerifyResponse(new TwilioVerifyResponse.FailureResponse());
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<Optional<String>> deliverVoxVerificationWithVerify(String destination,
|
||||
String verificationCode, List<LanguageRange> languageRanges) {
|
||||
|
||||
HttpRequest request = buildVerifyRequest("call", destination, verificationCode, findBestLocale(languageRanges),
|
||||
Optional.empty());
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(this::parseResponse)
|
||||
.handle((response, throwable) -> extractVerifySid(response, throwable, destination));
|
||||
}
|
||||
|
||||
private Optional<String> extractVerifySid(TwilioVerifyResponse twilioVerifyResponse, Throwable throwable,
|
||||
String destination) {
|
||||
|
||||
if (throwable != null) {
|
||||
logger.warn("Failed to send Twilio request", throwable);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (twilioVerifyResponse.isFailure()) {
|
||||
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();
|
||||
|
||||
logger.info("Failed with code={}, country={}",
|
||||
twilioVerifyResponse.failureResponse.code,
|
||||
Util.getCountryCode(destination));
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.ofNullable(twilioVerifyResponse.successResponse.getSid());
|
||||
}
|
||||
|
||||
private HttpRequest buildVerifyRequest(String channel, String destination, String verificationCode,
|
||||
Optional<String> locale, Optional<String> clientType) {
|
||||
|
||||
final Map<String, String> requestParameters = new HashMap<>();
|
||||
requestParameters.put("To", destination);
|
||||
requestParameters.put("CustomCode", verificationCode);
|
||||
requestParameters.put("Channel", channel);
|
||||
requestParameters.put("CustomFriendlyName", verifyServiceFriendlyName);
|
||||
locale.ifPresent(loc -> requestParameters.put("Locale", loc));
|
||||
clientType.filter(client -> client.startsWith("android"))
|
||||
.ifPresent(ignored -> requestParameters.put("AppHash", androidAppHash));
|
||||
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(verifyServiceUri)
|
||||
.POST(FormDataBodyPublisher.of(requestParameters))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization",
|
||||
"Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> reportVerificationSucceeded(String verificationSid, @Nullable String userAgent,
|
||||
String context) {
|
||||
|
||||
final Map<String, String> requestParameters = new HashMap<>();
|
||||
requestParameters.put("Status", "approved");
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(verifyApprovalBaseUri.resolve(verificationSid))
|
||||
.POST(FormDataBodyPublisher.of(requestParameters))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization",
|
||||
"Basic " + Base64.getEncoder().encodeToString((accountId + ":" + accountToken).getBytes()))
|
||||
.build();
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(this::parseResponse)
|
||||
.handle((response, throwable) -> processVerificationSucceededResponse(response, throwable, userAgent, context));
|
||||
}
|
||||
|
||||
private boolean processVerificationSucceededResponse(@Nullable final TwilioVerifyResponse response,
|
||||
@Nullable final Throwable throwable,
|
||||
final String userAgent,
|
||||
final String context) {
|
||||
|
||||
if (throwable == null) {
|
||||
|
||||
assert response != null;
|
||||
|
||||
final Tags tags = Tags.of(Tag.of(CONTEXT_TAG_NAME, context), UserAgentTagUtil.getPlatformTag(userAgent));
|
||||
|
||||
if (response.isSuccess() && "approved".equals(response.successResponse.getStatus())) {
|
||||
// the other possible values of `status` are `pending` or `canceled`, but these can never happen in a response
|
||||
// to this POST, so we don‘t consider them
|
||||
Metrics.counter(VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME, tags)
|
||||
.increment();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// at this point, response.isFailure() == true
|
||||
Metrics.counter(
|
||||
VERIFICATION_SUCCEEDED_RESPONSE_COUNTER_NAME,
|
||||
Tags.of(ERROR_CODE_TAG_NAME, String.valueOf(response.failureResponse.code),
|
||||
STATUS_CODE_TAG_NAME, String.valueOf(response.failureResponse.status))
|
||||
.and(tags))
|
||||
.increment();
|
||||
} else {
|
||||
logger.warn("Failed to send verification succeeded", throwable);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TwilioVerifyResponse {
|
||||
|
||||
private SuccessResponse successResponse;
|
||||
private FailureResponse failureResponse;
|
||||
|
||||
TwilioVerifyResponse(SuccessResponse successResponse) {
|
||||
this.successResponse = successResponse;
|
||||
}
|
||||
|
||||
TwilioVerifyResponse(FailureResponse failureResponse) {
|
||||
this.failureResponse = failureResponse;
|
||||
}
|
||||
|
||||
boolean isSuccess() {
|
||||
return successResponse != null;
|
||||
}
|
||||
|
||||
boolean isFailure() {
|
||||
return failureResponse != null;
|
||||
}
|
||||
|
||||
private static class SuccessResponse {
|
||||
|
||||
@NotEmpty
|
||||
public String sid;
|
||||
|
||||
@NotEmpty
|
||||
public String status;
|
||||
|
||||
static SuccessResponse fromBody(ObjectMapper mapper, String body) {
|
||||
try {
|
||||
return mapper.readValue(body, SuccessResponse.class);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error parsing twilio success response: " + e);
|
||||
return new SuccessResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public String getSid() {
|
||||
return sid;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FailureResponse {
|
||||
|
||||
@JsonProperty
|
||||
private int status;
|
||||
|
||||
@JsonProperty
|
||||
private String message;
|
||||
|
||||
@JsonProperty
|
||||
private int code;
|
||||
|
||||
static FailureResponse fromBody(ObjectMapper mapper, String body) {
|
||||
try {
|
||||
return mapper.readValue(body, FailureResponse.class);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error parsing twilio response: " + e);
|
||||
return new FailureResponse();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
|
||||
|
||||
public class AbstractDynamoDbStore {
|
||||
public abstract class AbstractDynamoDbStore {
|
||||
|
||||
private final DynamoDbClient dynamoDbClient;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -209,9 +225,7 @@ public class Account {
|
||||
|
||||
return devices.stream()
|
||||
.filter(Device::isEnabled)
|
||||
// TODO stories capability
|
||||
// .allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
||||
.anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
||||
.allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
||||
}
|
||||
|
||||
public boolean isGiftBadgesSupported() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.CertificateException;
|
||||
import javax.net.ssl.SSLContext;
|
||||
@@ -19,9 +16,7 @@ import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse;
|
||||
import org.whispersystems.textsecuregcm.util.CertificateExpirationGauge;
|
||||
import org.whispersystems.textsecuregcm.util.CertificateUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
|
||||
public class DirectoryReconciliationClient {
|
||||
|
||||
@@ -33,10 +28,6 @@ public class DirectoryReconciliationClient {
|
||||
{
|
||||
this.replicationUrl = directoryServerConfiguration.getReplicationUrl();
|
||||
this.client = initializeClient(directoryServerConfiguration);
|
||||
|
||||
SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME)
|
||||
.register(name(getClass(), directoryServerConfiguration.getReplicationName(), "days_until_certificate_expiration"),
|
||||
new CertificateExpirationGauge(CertificateUtil.getCertificate(directoryServerConfiguration.getReplicationCaCertificate())));
|
||||
}
|
||||
|
||||
public DirectoryReconciliationResponse add(DirectoryReconciliationRequest request) {
|
||||
@@ -63,7 +54,7 @@ public class DirectoryReconciliationClient {
|
||||
private static Client initializeClient(DirectoryServerConfiguration directoryServerConfiguration)
|
||||
throws CertificateException {
|
||||
KeyStore trustStore = CertificateUtil.buildKeyStoreForPem(
|
||||
directoryServerConfiguration.getReplicationCaCertificate());
|
||||
directoryServerConfiguration.getReplicationCaCertificates().toArray(new String[0]));
|
||||
SSLContext sslContext = SslConfigurator.newInstance()
|
||||
.securityProtocol("TLSv1.2")
|
||||
.trustStore(trustStore)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
@@ -34,23 +35,33 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.redis.ClusterLuaScript;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.RedisClusterUtil;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
public class MessagesCache extends RedisClusterPubSubAdapter<String, String> implements Managed {
|
||||
|
||||
private final FaultTolerantRedisCluster readDeleteCluster;
|
||||
private final FaultTolerantPubSubConnection<String, String> pubSubConnection;
|
||||
private final Clock clock;
|
||||
|
||||
private final ExecutorService notificationExecutorService;
|
||||
private final ExecutorService messageDeletionExecutorService;
|
||||
|
||||
private final ClusterLuaScript insertScript;
|
||||
private final ClusterLuaScript removeByGuidScript;
|
||||
@@ -79,22 +90,24 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||
private static final String QUEUE_KEYSPACE_PREFIX = "__keyspace@0__:user_queue::";
|
||||
private static final String PERSISTING_KEYSPACE_PREFIX = "__keyspace@0__:user_queue_persisting::";
|
||||
|
||||
private static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10);
|
||||
@VisibleForTesting
|
||||
static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10);
|
||||
|
||||
private static final String REMOVE_TIMER_NAME = name(MessagesCache.class, "remove");
|
||||
|
||||
private static final String REMOVE_METHOD_TAG = "method";
|
||||
private static final String REMOVE_METHOD_UUID = "uuid";
|
||||
private static final String GET_FLUX_NAME = MetricsUtil.name(MessagesCache.class, "get");
|
||||
private static final int PAGE_SIZE = 100;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);
|
||||
|
||||
public MessagesCache(final FaultTolerantRedisCluster insertCluster, final FaultTolerantRedisCluster readDeleteCluster,
|
||||
final ExecutorService notificationExecutorService) throws IOException {
|
||||
final Clock clock, final ExecutorService notificationExecutorService,
|
||||
final ExecutorService messageDeletionExecutorService) throws IOException {
|
||||
|
||||
this.readDeleteCluster = readDeleteCluster;
|
||||
this.pubSubConnection = readDeleteCluster.createPubSubConnection();
|
||||
this.clock = clock;
|
||||
|
||||
this.notificationExecutorService = notificationExecutorService;
|
||||
this.messageDeletionExecutorService = messageDeletionExecutorService;
|
||||
|
||||
this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER);
|
||||
this.removeByGuidScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_item_by_guid.lua",
|
||||
@@ -147,33 +160,39 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||
guid.toString().getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
|
||||
public Optional<MessageProtos.Envelope> remove(final UUID destinationUuid, final long destinationDevice,
|
||||
public CompletableFuture<Optional<MessageProtos.Envelope>> remove(final UUID destinationUuid,
|
||||
final long destinationDevice,
|
||||
final UUID messageGuid) {
|
||||
return remove(destinationUuid, destinationDevice, List.of(messageGuid)).stream().findFirst();
|
||||
|
||||
return remove(destinationUuid, destinationDevice, List.of(messageGuid))
|
||||
.thenApply(removed -> removed.isEmpty() ? Optional.empty() : Optional.of(removed.get(0)));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<MessageProtos.Envelope> remove(final UUID destinationUuid, final long destinationDevice,
|
||||
public CompletableFuture<List<MessageProtos.Envelope>> remove(final UUID destinationUuid,
|
||||
final long destinationDevice,
|
||||
final List<UUID> messageGuids) {
|
||||
final List<byte[]> serialized = (List<byte[]>) Metrics.timer(REMOVE_TIMER_NAME, REMOVE_METHOD_TAG,
|
||||
REMOVE_METHOD_UUID).record(() ->
|
||||
removeByGuidScript.executeBinary(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||
|
||||
return removeByGuidScript.executeBinaryAsync(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||
getMessageQueueMetadataKey(destinationUuid, destinationDevice),
|
||||
getQueueIndexKey(destinationUuid, destinationDevice)),
|
||||
messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8))
|
||||
.collect(Collectors.toList())));
|
||||
.collect(Collectors.toList()))
|
||||
.thenApplyAsync(result -> {
|
||||
List<byte[]> serialized = (List<byte[]>) result;
|
||||
|
||||
final List<MessageProtos.Envelope> removedMessages = new ArrayList<>(serialized.size());
|
||||
final List<MessageProtos.Envelope> removedMessages = new ArrayList<>(serialized.size());
|
||||
|
||||
for (final byte[] bytes : serialized) {
|
||||
try {
|
||||
removedMessages.add(MessageProtos.Envelope.parseFrom(bytes));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.warn("Failed to parse envelope", e);
|
||||
}
|
||||
}
|
||||
for (final byte[] bytes : serialized) {
|
||||
try {
|
||||
removedMessages.add(MessageProtos.Envelope.parseFrom(bytes));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.warn("Failed to parse envelope", e);
|
||||
}
|
||||
}
|
||||
|
||||
return removedMessages;
|
||||
return removedMessages;
|
||||
}, messageDeletionExecutorService);
|
||||
}
|
||||
|
||||
public boolean hasMessages(final UUID destinationUuid, final long destinationDevice) {
|
||||
@@ -181,50 +200,111 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||
connection -> connection.sync().zcard(getMessageQueueKey(destinationUuid, destinationDevice)) > 0);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDevice, final int limit) {
|
||||
return getMessagesTimer.record(() -> {
|
||||
final List<byte[]> queueItems = (List<byte[]>) getItemsScript.executeBinary(
|
||||
List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||
getPersistInProgressKey(destinationUuid, destinationDevice)),
|
||||
List.of(String.valueOf(limit).getBytes(StandardCharsets.UTF_8)));
|
||||
public Publisher<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDevice) {
|
||||
|
||||
final long earliestAllowableEphemeralTimestamp =
|
||||
System.currentTimeMillis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
||||
final long earliestAllowableEphemeralTimestamp =
|
||||
clock.millis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
||||
|
||||
final List<MessageProtos.Envelope> messageEntities;
|
||||
final List<UUID> staleEphemeralMessageGuids = new ArrayList<>();
|
||||
final Flux<MessageProtos.Envelope> allMessages = getAllMessages(destinationUuid, destinationDevice)
|
||||
.publish()
|
||||
// We expect exactly two subscribers to this base flux:
|
||||
// 1. the websocket that delivers messages to clients
|
||||
// 2. an internal process to discard stale ephemeral messages
|
||||
// The discard subscriber will subscribe immediately, but we don’t want to do any work if the
|
||||
// websocket never subscribes.
|
||||
.autoConnect(2);
|
||||
|
||||
if (queueItems.size() % 2 == 0) {
|
||||
messageEntities = new ArrayList<>(queueItems.size() / 2);
|
||||
final Flux<MessageProtos.Envelope> messagesToPublish = allMessages
|
||||
.filter(Predicate.not(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp)));
|
||||
|
||||
for (int i = 0; i < queueItems.size() - 1; i += 2) {
|
||||
try {
|
||||
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
|
||||
if (message.getEphemeral() && message.getTimestamp() < earliestAllowableEphemeralTimestamp) {
|
||||
staleEphemeralMessageGuids.add(UUID.fromString(message.getServerGuid()));
|
||||
continue;
|
||||
}
|
||||
final Flux<MessageProtos.Envelope> staleEphemeralMessages = allMessages
|
||||
.filter(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp));
|
||||
|
||||
messageEntities.add(message);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
logger.warn("Failed to parse envelope", e);
|
||||
discardStaleEphemeralMessages(destinationUuid, destinationDevice, staleEphemeralMessages);
|
||||
|
||||
return messagesToPublish.name(GET_FLUX_NAME)
|
||||
.metrics();
|
||||
}
|
||||
|
||||
private static boolean isStaleEphemeralMessage(final MessageProtos.Envelope message,
|
||||
long earliestAllowableTimestamp) {
|
||||
return message.hasEphemeral() && message.getEphemeral() && message.getTimestamp() < earliestAllowableTimestamp;
|
||||
}
|
||||
|
||||
private void discardStaleEphemeralMessages(final UUID destinationUuid, final long destinationDevice,
|
||||
Flux<MessageProtos.Envelope> staleEphemeralMessages) {
|
||||
staleEphemeralMessages
|
||||
.map(e -> UUID.fromString(e.getServerGuid()))
|
||||
.buffer(PAGE_SIZE)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(staleEphemeralMessageGuids ->
|
||||
remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids)
|
||||
.thenAccept(removedMessages -> staleEphemeralMessagesCounter.increment(removedMessages.size())),
|
||||
e -> logger.warn("Could not remove stale ephemeral messages from cache", e));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
Flux<MessageProtos.Envelope> getAllMessages(final UUID destinationUuid, final long destinationDevice) {
|
||||
|
||||
// fetch messages by page
|
||||
return getNextMessagePage(destinationUuid, destinationDevice, -1)
|
||||
.expand(queueItemsAndLastMessageId -> {
|
||||
// expand() is breadth-first, so each page will be published in order
|
||||
if (queueItemsAndLastMessageId.first().isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("\"Get messages\" operation returned a list with a non-even number of elements.");
|
||||
messageEntities = Collections.emptyList();
|
||||
}
|
||||
|
||||
try {
|
||||
remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids);
|
||||
staleEphemeralMessagesCounter.increment(staleEphemeralMessageGuids.size());
|
||||
} catch (final Throwable e) {
|
||||
logger.warn("Could not remove stale ephemeral messages from cache", e);
|
||||
}
|
||||
return getNextMessagePage(destinationUuid, destinationDevice, queueItemsAndLastMessageId.second());
|
||||
})
|
||||
.limitRate(1)
|
||||
// we want to ensure we don’t accidentally block the Lettuce/netty i/o executors
|
||||
.publishOn(Schedulers.boundedElastic())
|
||||
.map(Pair::first)
|
||||
.flatMapIterable(queueItems -> {
|
||||
final List<MessageProtos.Envelope> envelopes = new ArrayList<>(queueItems.size() / 2);
|
||||
|
||||
return messageEntities;
|
||||
});
|
||||
for (int i = 0; i < queueItems.size() - 1; i += 2) {
|
||||
try {
|
||||
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
|
||||
|
||||
envelopes.add(message);
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
logger.warn("Failed to parse envelope", e);
|
||||
}
|
||||
}
|
||||
|
||||
return envelopes;
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<Pair<List<byte[]>, Long>> getNextMessagePage(final UUID destinationUuid, final long destinationDevice,
|
||||
long messageId) {
|
||||
|
||||
return getItemsScript.executeBinaryReactive(
|
||||
List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||
getPersistInProgressKey(destinationUuid, destinationDevice)),
|
||||
List.of(String.valueOf(PAGE_SIZE).getBytes(StandardCharsets.UTF_8),
|
||||
String.valueOf(messageId).getBytes(StandardCharsets.UTF_8)))
|
||||
.map(result -> {
|
||||
logger.trace("Processing page: {}", messageId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<byte[]> queueItems = (List<byte[]>) result;
|
||||
|
||||
if (queueItems.isEmpty()) {
|
||||
return new Pair<>(Collections.emptyList(), null);
|
||||
}
|
||||
|
||||
if (queueItems.size() % 2 != 0) {
|
||||
logger.error("\"Get messages\" operation returned a list with a non-even number of elements.");
|
||||
return new Pair<>(Collections.emptyList(), null);
|
||||
}
|
||||
|
||||
final long lastMessageId = Long.parseLong(
|
||||
new String(queueItems.get(queueItems.size() - 1), StandardCharsets.UTF_8));
|
||||
|
||||
return new Pair<>(queueItems, lastMessageId);
|
||||
});
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -307,12 +387,13 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
||||
}
|
||||
|
||||
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
||||
@Nullable final String queueName = queueNamesByMessageListener.remove(listener);
|
||||
@Nullable final String queueName = queueNamesByMessageListener.get(listener);
|
||||
|
||||
if (queueName != null) {
|
||||
unsubscribeFromKeyspaceNotifications(queueName);
|
||||
|
||||
synchronized (messageListenersByQueueName) {
|
||||
queueNamesByMessageListener.remove(listener);
|
||||
messageListenersByQueueName.remove(queueName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* Copyright 2021-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -17,19 +17,26 @@ import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Predicate;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.DeleteRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
||||
@@ -48,22 +55,27 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||
private static final String KEY_ENVELOPE_BYTES = "EB";
|
||||
|
||||
private final Timer storeTimer = timer(name(getClass(), "store"));
|
||||
private final Timer loadTimer = timer(name(getClass(), "load"));
|
||||
private final Timer deleteByGuid = timer(name(getClass(), "delete", "guid"));
|
||||
private final Timer deleteByKey = timer(name(getClass(), "delete", "key"));
|
||||
private final Timer deleteByAccount = timer(name(getClass(), "delete", "account"));
|
||||
private final Timer deleteByDevice = timer(name(getClass(), "delete", "device"));
|
||||
|
||||
private final DynamoDbAsyncClient dbAsyncClient;
|
||||
private final String tableName;
|
||||
private final Duration timeToLive;
|
||||
private final ExecutorService messageDeletionExecutor;
|
||||
private final Scheduler messageDeletionScheduler;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);
|
||||
|
||||
public MessagesDynamoDb(DynamoDbClient dynamoDb, String tableName, Duration timeToLive) {
|
||||
public MessagesDynamoDb(DynamoDbClient dynamoDb, DynamoDbAsyncClient dynamoDbAsyncClient, String tableName,
|
||||
Duration timeToLive, ExecutorService messageDeletionExecutor) {
|
||||
super(dynamoDb);
|
||||
|
||||
this.dbAsyncClient = dynamoDbAsyncClient;
|
||||
this.tableName = tableName;
|
||||
this.timeToLive = timeToLive;
|
||||
|
||||
this.messageDeletionExecutor = messageDeletionExecutor;
|
||||
this.messageDeletionScheduler = Schedulers.fromExecutor(messageDeletionExecutor);
|
||||
}
|
||||
|
||||
public void store(final List<MessageProtos.Envelope> messages, final UUID destinationAccountUuid, final long destinationDeviceId) {
|
||||
@@ -95,105 +107,106 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||
executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems));
|
||||
}
|
||||
|
||||
public List<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId, final int requestedNumberOfMessagesToFetch) {
|
||||
return loadTimer.record(() -> {
|
||||
final int numberOfMessagesToFetch = Math.min(requestedNumberOfMessagesToFetch, RESULT_SET_CHUNK_SIZE);
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final QueryRequest queryRequest = QueryRequest.builder()
|
||||
.tableName(tableName)
|
||||
.consistentRead(true)
|
||||
.keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#part", KEY_PARTITION,
|
||||
"#sort", KEY_SORT))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":part", partitionKey,
|
||||
":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId)))
|
||||
.limit(numberOfMessagesToFetch)
|
||||
.build();
|
||||
List<MessageProtos.Envelope> messageEntities = new ArrayList<>(numberOfMessagesToFetch);
|
||||
for (Map<String, AttributeValue> message : db().queryPaginator(queryRequest).items()) {
|
||||
try {
|
||||
messageEntities.add(convertItemToEnvelope(message));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
}
|
||||
public Publisher<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId,
|
||||
final Integer limit) {
|
||||
|
||||
if (messageEntities.size() == numberOfMessagesToFetch) {
|
||||
// queryPaginator() uses limit() as the page size, not as an absolute limit
|
||||
// …but a page might be smaller than limit, because a page is capped at 1 MB
|
||||
break;
|
||||
}
|
||||
}
|
||||
return messageEntities;
|
||||
});
|
||||
}
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final QueryRequest.Builder queryRequestBuilder = QueryRequest.builder()
|
||||
.tableName(tableName)
|
||||
.consistentRead(true)
|
||||
.keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#part", KEY_PARTITION,
|
||||
"#sort", KEY_SORT))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":part", partitionKey,
|
||||
":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId)));
|
||||
|
||||
public Optional<MessageProtos.Envelope> deleteMessageByDestinationAndGuid(final UUID destinationAccountUuid,
|
||||
final UUID messageUuid) {
|
||||
return deleteByGuid.record(() -> {
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final QueryRequest queryRequest = QueryRequest.builder()
|
||||
.tableName(tableName)
|
||||
.indexName(LOCAL_INDEX_MESSAGE_UUID_NAME)
|
||||
.projectionExpression(KEY_SORT)
|
||||
.consistentRead(true)
|
||||
.keyConditionExpression("#part = :part AND #uuid = :uuid")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#part", KEY_PARTITION,
|
||||
"#uuid", LOCAL_INDEX_MESSAGE_UUID_KEY_SORT))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":part", partitionKey,
|
||||
":uuid", convertLocalIndexMessageUuidSortKey(messageUuid)))
|
||||
.build();
|
||||
return deleteItemsMatchingQueryAndReturnFirstOneActuallyDeleted(partitionKey, queryRequest);
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<MessageProtos.Envelope> deleteMessage(final UUID destinationAccountUuid,
|
||||
final long destinationDeviceId, final UUID messageUuid, final long serverTimestamp) {
|
||||
return deleteByKey.record(() -> {
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final AttributeValue sortKey = convertSortKey(destinationDeviceId, serverTimestamp, messageUuid);
|
||||
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, sortKey))
|
||||
.returnValues(ReturnValue.ALL_OLD);
|
||||
final DeleteItemResponse deleteItemResponse = db().deleteItem(deleteItemRequest.build());
|
||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||
try {
|
||||
return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Optional<MessageProtos.Envelope> deleteItemsMatchingQueryAndReturnFirstOneActuallyDeleted(AttributeValue partitionKey, QueryRequest queryRequest) {
|
||||
Optional<MessageProtos.Envelope> result = Optional.empty();
|
||||
for (Map<String, AttributeValue> item : db().queryPaginator(queryRequest).items()) {
|
||||
final byte[] rangeKeyValue = item.get(KEY_SORT).b().asByteArray();
|
||||
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, AttributeValues.fromByteArray(rangeKeyValue)));
|
||||
if (result.isEmpty()) {
|
||||
deleteItemRequest.returnValues(ReturnValue.ALL_OLD);
|
||||
}
|
||||
final DeleteItemResponse deleteItemResponse = db().deleteItem(deleteItemRequest.build());
|
||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||
try {
|
||||
result = Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
}
|
||||
}
|
||||
if (limit != null) {
|
||||
// some callers don’t take advantage of reactive streams, so we want to support limiting the fetch size. Otherwise,
|
||||
// we could fetch up to 1 MB (likely >1,000 messages) and discard 90% of them
|
||||
queryRequestBuilder.limit(Math.min(RESULT_SET_CHUNK_SIZE, limit));
|
||||
}
|
||||
return result;
|
||||
|
||||
final QueryRequest queryRequest = queryRequestBuilder.build();
|
||||
|
||||
return dbAsyncClient.queryPaginator(queryRequest).items()
|
||||
.map(message -> {
|
||||
try {
|
||||
return convertItemToEnvelope(message);
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Predicate.not(Objects::isNull));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<MessageProtos.Envelope>> deleteMessageByDestinationAndGuid(
|
||||
final UUID destinationAccountUuid, final UUID messageUuid) {
|
||||
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final QueryRequest queryRequest = QueryRequest.builder()
|
||||
.tableName(tableName)
|
||||
.indexName(LOCAL_INDEX_MESSAGE_UUID_NAME)
|
||||
.projectionExpression(KEY_SORT)
|
||||
.consistentRead(true)
|
||||
.keyConditionExpression("#part = :part AND #uuid = :uuid")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#part", KEY_PARTITION,
|
||||
"#uuid", LOCAL_INDEX_MESSAGE_UUID_KEY_SORT))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":part", partitionKey,
|
||||
":uuid", convertLocalIndexMessageUuidSortKey(messageUuid)))
|
||||
.build();
|
||||
|
||||
// because we are filtering on message UUID, this query should return at most one item,
|
||||
// but it’s simpler to handle the full stream and return the “last” item
|
||||
return Flux.from(dbAsyncClient.queryPaginator(queryRequest).items())
|
||||
.flatMap(item -> Mono.fromCompletionStage(dbAsyncClient.deleteItem(DeleteItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT,
|
||||
AttributeValues.fromByteArray(item.get(KEY_SORT).b().asByteArray())))
|
||||
.returnValues(ReturnValue.ALL_OLD)
|
||||
.build())))
|
||||
.mapNotNull(deleteItemResponse -> {
|
||||
try {
|
||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||
return convertItemToEnvelope(deleteItemResponse.attributes());
|
||||
}
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.map(Optional::ofNullable)
|
||||
.subscribeOn(messageDeletionScheduler)
|
||||
.last(Optional.empty()) // if the flux is empty, last() will throw without a default
|
||||
.toFuture();
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<MessageProtos.Envelope>> deleteMessage(final UUID destinationAccountUuid,
|
||||
final long destinationDeviceId, final UUID messageUuid, final long serverTimestamp) {
|
||||
|
||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||
final AttributeValue sortKey = convertSortKey(destinationDeviceId, serverTimestamp, messageUuid);
|
||||
DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()
|
||||
.tableName(tableName)
|
||||
.key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, sortKey))
|
||||
.returnValues(ReturnValue.ALL_OLD);
|
||||
|
||||
return dbAsyncClient.deleteItem(deleteItemRequest.build())
|
||||
.thenApplyAsync(deleteItemResponse -> {
|
||||
if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {
|
||||
try {
|
||||
return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes()));
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
logger.error("Failed to parse envelope", e);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}, messageDeletionExecutor);
|
||||
}
|
||||
|
||||
public void deleteAllMessagesForAccount(final UUID destinationAccountUuid) {
|
||||
@@ -248,7 +261,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
||||
KEY_PARTITION, partitionKey,
|
||||
KEY_SORT, item.get(KEY_SORT))).build())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
executeTableWriteItemsUntilComplete(Map.of(tableName, deletes));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
@@ -9,18 +9,32 @@ import static com.codahale.metrics.MetricRegistry.name;
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public class MessagesManager {
|
||||
|
||||
private static final int RESULT_SET_CHUNK_SIZE = 100;
|
||||
final String GET_MESSAGES_FOR_DEVICE_FLUX_NAME = MetricsUtil.name(MessagesManager.class, "getMessagesForDevice");
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MessagesManager.class);
|
||||
|
||||
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||
private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid"));
|
||||
@@ -31,14 +45,17 @@ public class MessagesManager {
|
||||
private final MessagesDynamoDb messagesDynamoDb;
|
||||
private final MessagesCache messagesCache;
|
||||
private final ReportMessageManager reportMessageManager;
|
||||
private final ExecutorService messageDeletionExecutor;
|
||||
|
||||
public MessagesManager(
|
||||
final MessagesDynamoDb messagesDynamoDb,
|
||||
final MessagesCache messagesCache,
|
||||
final ReportMessageManager reportMessageManager) {
|
||||
final ReportMessageManager reportMessageManager,
|
||||
final ExecutorService messageDeletionExecutor) {
|
||||
this.messagesDynamoDb = messagesDynamoDb;
|
||||
this.messagesCache = messagesCache;
|
||||
this.reportMessageManager = reportMessageManager;
|
||||
this.messageDeletionExecutor = messageDeletionExecutor;
|
||||
}
|
||||
|
||||
public void insert(UUID destinationUuid, long destinationDevice, Envelope message) {
|
||||
@@ -55,18 +72,34 @@ public class MessagesManager {
|
||||
return messagesCache.hasMessages(destinationUuid, destinationDevice);
|
||||
}
|
||||
|
||||
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice, final boolean cachedMessagesOnly) {
|
||||
List<Envelope> messageList = new ArrayList<>();
|
||||
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice,
|
||||
boolean cachedMessagesOnly) {
|
||||
|
||||
if (!cachedMessagesOnly) {
|
||||
messageList.addAll(messagesDynamoDb.load(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE));
|
||||
}
|
||||
final List<Envelope> envelopes = Flux.from(
|
||||
getMessagesForDevice(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE, cachedMessagesOnly))
|
||||
.take(RESULT_SET_CHUNK_SIZE, true)
|
||||
.collectList()
|
||||
.blockOptional().orElse(Collections.emptyList());
|
||||
|
||||
if (messageList.size() < RESULT_SET_CHUNK_SIZE) {
|
||||
messageList.addAll(messagesCache.get(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE - messageList.size()));
|
||||
}
|
||||
return new Pair<>(envelopes, envelopes.size() >= RESULT_SET_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
return new Pair<>(messageList, messageList.size() >= RESULT_SET_CHUNK_SIZE);
|
||||
public Publisher<Envelope> getMessagesForDeviceReactive(UUID destinationUuid, long destinationDevice,
|
||||
final boolean cachedMessagesOnly) {
|
||||
|
||||
return getMessagesForDevice(destinationUuid, destinationDevice, null, cachedMessagesOnly);
|
||||
}
|
||||
|
||||
private Publisher<Envelope> getMessagesForDevice(UUID destinationUuid, long destinationDevice,
|
||||
@Nullable Integer limit, final boolean cachedMessagesOnly) {
|
||||
|
||||
final Publisher<Envelope> dynamoPublisher =
|
||||
cachedMessagesOnly ? Flux.empty() : messagesDynamoDb.load(destinationUuid, destinationDevice, limit);
|
||||
final Publisher<Envelope> cachePublisher = messagesCache.get(destinationUuid, destinationDevice);
|
||||
|
||||
return Flux.concat(dynamoPublisher, cachePublisher)
|
||||
.name(GET_MESSAGES_FOR_DEVICE_FLUX_NAME)
|
||||
.metrics();
|
||||
}
|
||||
|
||||
public void clear(UUID destinationUuid) {
|
||||
@@ -79,21 +112,25 @@ public class MessagesManager {
|
||||
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId);
|
||||
}
|
||||
|
||||
public Optional<Envelope> delete(UUID destinationUuid, long destinationDeviceId, UUID guid, Long serverTimestamp) {
|
||||
Optional<Envelope> removed = messagesCache.remove(destinationUuid, destinationDeviceId, guid);
|
||||
public CompletableFuture<Optional<Envelope>> delete(UUID destinationUuid, long destinationDeviceId, UUID guid,
|
||||
@Nullable Long serverTimestamp) {
|
||||
return messagesCache.remove(destinationUuid, destinationDeviceId, guid)
|
||||
.thenComposeAsync(removed -> {
|
||||
|
||||
if (removed.isEmpty()) {
|
||||
if (serverTimestamp == null) {
|
||||
removed = messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid);
|
||||
} else {
|
||||
removed = messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp);
|
||||
}
|
||||
cacheMissByGuidMeter.mark();
|
||||
} else {
|
||||
cacheHitByGuidMeter.mark();
|
||||
}
|
||||
if (removed.isPresent()) {
|
||||
cacheHitByGuidMeter.mark();
|
||||
return CompletableFuture.completedFuture(removed);
|
||||
}
|
||||
|
||||
return removed;
|
||||
cacheMissByGuidMeter.mark();
|
||||
|
||||
if (serverTimestamp == null) {
|
||||
return messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid);
|
||||
} else {
|
||||
return messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp);
|
||||
}
|
||||
|
||||
}, messageDeletionExecutor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,10 +149,15 @@ public class MessagesManager {
|
||||
|
||||
final List<UUID> messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
||||
.collect(Collectors.toList());
|
||||
int messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids).size();
|
||||
|
||||
persistMessageMeter.mark(nonEphemeralMessages.size());
|
||||
int messagesRemovedFromCache = 0;
|
||||
try {
|
||||
messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids)
|
||||
.get(30, TimeUnit.SECONDS).size();
|
||||
persistMessageMeter.mark(nonEphemeralMessages.size());
|
||||
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||
logger.warn("Failed to remove messages from cache", e);
|
||||
}
|
||||
return messagesRemovedFromCache;
|
||||
}
|
||||
|
||||
@@ -129,4 +171,5 @@ public class MessagesManager {
|
||||
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
||||
messagesCache.removeMessageAvailabilityListener(listener);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -6,23 +6,34 @@
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.m;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Throwables;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
@@ -35,8 +46,11 @@ public class SubscriptionManager {
|
||||
|
||||
public static final String KEY_USER = "U"; // B (Hash Key)
|
||||
public static final String KEY_PASSWORD = "P"; // B
|
||||
@Deprecated
|
||||
public static final String KEY_CUSTOMER_ID = "C"; // S (GSI Hash Key of `c_to_u` index)
|
||||
public static final String KEY_PROCESSOR_ID_CUSTOMER_ID = "PC"; // B (GSI Hash Key of `pc_to_u` index)
|
||||
public static final String KEY_CREATED_AT = "R"; // N
|
||||
public static final String KEY_PROCESSOR_CUSTOMER_IDS_MAP = "PCI"; // M
|
||||
public static final String KEY_SUBSCRIPTION_ID = "S"; // S
|
||||
public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N
|
||||
public static final String KEY_SUBSCRIPTION_LEVEL = "L";
|
||||
@@ -51,8 +65,11 @@ public class SubscriptionManager {
|
||||
|
||||
public final byte[] user;
|
||||
public final byte[] password;
|
||||
public final String customerId;
|
||||
public final Instant createdAt;
|
||||
@VisibleForTesting
|
||||
@Nullable
|
||||
ProcessorCustomer processorCustomer;
|
||||
public Map<SubscriptionProcessor, String> processorsToCustomerIds;
|
||||
public String subscriptionId;
|
||||
public Instant subscriptionCreatedAt;
|
||||
public Long subscriptionLevel;
|
||||
@@ -61,31 +78,76 @@ public class SubscriptionManager {
|
||||
public Instant canceledAt;
|
||||
public Instant currentPeriodEndsAt;
|
||||
|
||||
private Record(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private Record(byte[] user, byte[] password, Instant createdAt) {
|
||||
this.user = checkUserLength(user);
|
||||
this.password = Objects.requireNonNull(password);
|
||||
this.customerId = Objects.requireNonNull(customerId);
|
||||
this.createdAt = Objects.requireNonNull(createdAt);
|
||||
}
|
||||
|
||||
public static Record from(byte[] user, Map<String, AttributeValue> item) {
|
||||
Record self = new Record(
|
||||
Record record = new Record(
|
||||
user,
|
||||
item.get(KEY_PASSWORD).b().asByteArray(),
|
||||
item.get(KEY_CUSTOMER_ID).s(),
|
||||
getInstant(item, KEY_CREATED_AT));
|
||||
self.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
||||
self.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
|
||||
self.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
|
||||
self.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);
|
||||
self.accessedAt = getInstant(item, KEY_ACCESSED_AT);
|
||||
self.canceledAt = getInstant(item, KEY_CANCELED_AT);
|
||||
self.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);
|
||||
return self;
|
||||
|
||||
final Pair<SubscriptionProcessor, String> processorCustomerId = getProcessorAndCustomer(item);
|
||||
if (processorCustomerId != null) {
|
||||
record.processorCustomer = new ProcessorCustomer(processorCustomerId.second(), processorCustomerId.first());
|
||||
}
|
||||
record.processorsToCustomerIds = getProcessorsToCustomerIds(item);
|
||||
record.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
||||
record.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
|
||||
record.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
|
||||
record.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);
|
||||
record.accessedAt = getInstant(item, KEY_ACCESSED_AT);
|
||||
record.canceledAt = getInstant(item, KEY_CANCELED_AT);
|
||||
record.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);
|
||||
return record;
|
||||
}
|
||||
|
||||
public Map<String, AttributeValue> asKey() {
|
||||
return Map.of(KEY_USER, b(user));
|
||||
public Optional<ProcessorCustomer> getProcessorCustomer() {
|
||||
return Optional.ofNullable(processorCustomer);
|
||||
}
|
||||
|
||||
private static Map<SubscriptionProcessor, String> getProcessorsToCustomerIds(Map<String, AttributeValue> item) {
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_CUSTOMER_IDS_MAP);
|
||||
final Map<String, AttributeValue> attribute =
|
||||
attributeValue == null ? Collections.emptyMap() : attributeValue.m();
|
||||
|
||||
final Map<SubscriptionProcessor, String> processorsToCustomerIds = new HashMap<>();
|
||||
attribute.forEach((processorName, customerId) ->
|
||||
processorsToCustomerIds.put(SubscriptionProcessor.valueOf(processorName), customerId.s()));
|
||||
|
||||
return processorsToCustomerIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the active processor and customer from a single attribute value in the given item.
|
||||
* <p>
|
||||
* Until existing data is migrated, this may return {@code null}.
|
||||
*/
|
||||
@Nullable
|
||||
private static Pair<SubscriptionProcessor, String> getProcessorAndCustomer(Map<String, AttributeValue> item) {
|
||||
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_ID_CUSTOMER_ID);
|
||||
|
||||
if (attributeValue == null) {
|
||||
// temporarily allow null values
|
||||
return null;
|
||||
}
|
||||
|
||||
final byte[] processorAndCustomerId = attributeValue.b().asByteArray();
|
||||
final byte processorId = processorAndCustomerId[0];
|
||||
|
||||
final SubscriptionProcessor processor = SubscriptionProcessor.forId(processorId);
|
||||
if (processor == null) {
|
||||
throw new IllegalStateException("unknown processor id: " + processorId);
|
||||
}
|
||||
|
||||
final String customerId = new String(processorAndCustomerId, 1, processorAndCustomerId.length - 1,
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
return new Pair<>(processor, customerId);
|
||||
}
|
||||
|
||||
private static String getString(Map<String, AttributeValue> item, String key) {
|
||||
@@ -181,14 +243,7 @@ public class SubscriptionManager {
|
||||
* Looks up a record with the given {@code user} and validates the {@code hmac} before returning it.
|
||||
*/
|
||||
public CompletableFuture<GetResult> get(byte[] user, byte[] hmac) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
return client.getItem(request).thenApply(getItemResponse -> {
|
||||
return getUser(user).thenApply(getItemResponse -> {
|
||||
if (!getItemResponse.hasItem()) {
|
||||
return GetResult.NOT_STORED;
|
||||
}
|
||||
@@ -201,7 +256,19 @@ public class SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private CompletableFuture<GetItemResponse> getUser(byte[] user) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
|
||||
return client.getItem(request);
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, Instant createdAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
@@ -211,20 +278,23 @@ public class SubscriptionManager {
|
||||
.conditionExpression("attribute_not_exists(#user) OR #password = :password")
|
||||
.updateExpression("SET "
|
||||
+ "#password = if_not_exists(#password, :password), "
|
||||
+ "#customer_id = if_not_exists(#customer_id, :customer_id), "
|
||||
+ "#created_at = if_not_exists(#created_at, :created_at), "
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at)")
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at), "
|
||||
+ "#processors_to_customer_ids = if_not_exists(#processors_to_customer_ids, :initial_empty_map)"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#user", KEY_USER,
|
||||
"#password", KEY_PASSWORD,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#created_at", KEY_CREATED_AT,
|
||||
"#accessed_at", KEY_ACCESSED_AT))
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP)
|
||||
)
|
||||
.expressionAttributeValues(Map.of(
|
||||
":password", b(password),
|
||||
":customer_id", s(customerId),
|
||||
":created_at", n(createdAt.getEpochSecond()),
|
||||
":accessed_at", n(createdAt.getEpochSecond())))
|
||||
":accessed_at", n(createdAt.getEpochSecond()),
|
||||
":initial_empty_map", m(Map.of()))
|
||||
)
|
||||
.build();
|
||||
return client.updateItem(request).handle((updateItemResponse, throwable) -> {
|
||||
if (throwable != null) {
|
||||
@@ -239,6 +309,55 @@ public class SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the active processor and customer ID for the given user record.
|
||||
*
|
||||
* @return the updated user record.
|
||||
*/
|
||||
public CompletableFuture<Record> updateProcessorAndCustomerId(Record userRecord,
|
||||
ProcessorCustomer activeProcessorCustomer, Instant updatedAt) {
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(userRecord.user)))
|
||||
.returnValues(ReturnValue.ALL_NEW)
|
||||
.conditionExpression(
|
||||
// there is no active processor+customer attribute
|
||||
"attribute_not_exists(#processor_customer_id) " +
|
||||
// or an attribute in the map with an inactive processor+customer
|
||||
"AND attribute_not_exists(#processors_to_customer_ids.#processor_name)"
|
||||
)
|
||||
.updateExpression("SET "
|
||||
+ "#customer_id = :customer_id, "
|
||||
+ "#processor_customer_id = :processor_customer_id, "
|
||||
+ "#processors_to_customer_ids.#processor_name = :customer_id, "
|
||||
+ "#accessed_at = :accessed_at"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID,
|
||||
"#processor_name", activeProcessorCustomer.processor().name(),
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP
|
||||
))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":accessed_at", n(updatedAt.getEpochSecond()),
|
||||
":customer_id", s(activeProcessorCustomer.customerId()),
|
||||
":processor_customer_id", b(activeProcessorCustomer.toDynamoBytes())
|
||||
)).build();
|
||||
|
||||
return client.updateItem(request)
|
||||
.thenApply(updateItemResponse -> Record.from(userRecord.user, updateItemResponse.attributes()))
|
||||
.exceptionallyCompose(throwable -> {
|
||||
if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {
|
||||
return getUser(userRecord.user).thenApply(getItemResponse ->
|
||||
Record.from(userRecord.user, getItemResponse.item()));
|
||||
}
|
||||
Throwables.throwIfUnchecked(throwable);
|
||||
throw new CompletionException(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class VerificationCodeStore {
|
||||
}
|
||||
|
||||
private long getExpirationTimestamp(final StoredVerificationCode storedVerificationCode) {
|
||||
return Instant.ofEpochMilli(storedVerificationCode.getTimestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond();
|
||||
return Instant.ofEpochMilli(storedVerificationCode.timestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond();
|
||||
}
|
||||
|
||||
public Optional<StoredVerificationCode> findForNumber(final String number) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
public enum PaymentMethod {
|
||||
/**
|
||||
* A credit card or debit card, including those from Apple Pay and Google Pay
|
||||
*/
|
||||
CARD,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.whispersystems.dispatch.util.Util;
|
||||
|
||||
public record ProcessorCustomer(String customerId, SubscriptionProcessor processor) {
|
||||
|
||||
public byte[] toDynamoBytes() {
|
||||
return Util.combine(new byte[]{processor.getId()}, customerId.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.stripe;
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Lists;
|
||||
@@ -61,7 +61,7 @@ import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
|
||||
public class StripeManager {
|
||||
public class StripeManager implements SubscriptionProcessorManager {
|
||||
|
||||
private static final String METADATA_KEY_LEVEL = "level";
|
||||
|
||||
@@ -87,6 +87,16 @@ public class StripeManager {
|
||||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SubscriptionProcessor getProcessor() {
|
||||
return SubscriptionProcessor.STRIPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return paymentMethod == PaymentMethod.CARD;
|
||||
}
|
||||
|
||||
private RequestOptions commonOptions() {
|
||||
return commonOptions(null);
|
||||
}
|
||||
@@ -98,17 +108,19 @@ public class StripeManager {
|
||||
.build();
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> createCustomer(byte[] subscriberUser) {
|
||||
@Override
|
||||
public CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(customer -> new ProcessorCustomer(customer.getId(), getProcessor()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> getCustomer(String customerId) {
|
||||
@@ -139,17 +151,19 @@ public class StripeManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<SetupIntent> createSetupIntent(String customerId) {
|
||||
@Override
|
||||
public CompletableFuture<String> createPaymentMethodSetupToken(String customerId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(SetupIntent::getClientSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +210,7 @@ public class StripeManager {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.setOffSession(true)
|
||||
.addItem(SubscriptionCreateParams.Item.builder()
|
||||
.setPrice(priceId)
|
||||
.build())
|
||||
@@ -234,6 +249,7 @@ public class StripeManager {
|
||||
// not prorated
|
||||
.setProrationBehavior(ProrationBehavior.NONE)
|
||||
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
||||
.setOffSession(true)
|
||||
.addAllItem(items)
|
||||
.build();
|
||||
try {
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A set of payment providers used for donations
|
||||
*/
|
||||
public enum SubscriptionProcessor {
|
||||
// because provider IDs are stored, they should not be reused, and great care
|
||||
// must be used if a provider is removed from the list
|
||||
STRIPE(1),
|
||||
;
|
||||
|
||||
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
||||
|
||||
static {
|
||||
Arrays.stream(SubscriptionProcessor.values())
|
||||
.forEach(provider -> IDS_TO_PROCESSORS.put((int) provider.id, provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the provider associated with the given ID, or {@code null} if none exists
|
||||
*/
|
||||
public static SubscriptionProcessor forId(byte id) {
|
||||
return IDS_TO_PROCESSORS.get((int) id);
|
||||
}
|
||||
|
||||
private final byte id;
|
||||
|
||||
SubscriptionProcessor(int id) {
|
||||
if (id > 256) {
|
||||
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
||||
}
|
||||
|
||||
this.id = (byte) id;
|
||||
}
|
||||
|
||||
public byte getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface SubscriptionProcessorManager {
|
||||
|
||||
SubscriptionProcessor getProcessor();
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
|
||||
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
|
||||
public class AttributeValues {
|
||||
@@ -37,6 +37,9 @@ public class AttributeValues {
|
||||
return AttributeValue.builder().s(value).build();
|
||||
}
|
||||
|
||||
public static AttributeValue m(Map<String, AttributeValue> value) {
|
||||
return AttributeValue.builder().m(value).build();
|
||||
}
|
||||
|
||||
// More opinionated methods
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class ByteUtil {
|
||||
|
||||
public static byte[] combine(byte[]... elements) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
for (byte[] element : elements) {
|
||||
baos.write(element);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user