mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-15 02:00:48 +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_finally_on_new_line = false
|
||||||
ij_kotlin_if_rparen_on_new_line = false
|
ij_kotlin_if_rparen_on_new_line = false
|
||||||
ij_kotlin_import_nested_classes = 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_insert_whitespaces_in_simple_one_line_method = true
|
||||||
ij_kotlin_keep_blank_lines_before_right_brace = 2
|
ij_kotlin_keep_blank_lines_before_right_brace = 2
|
||||||
ij_kotlin_keep_blank_lines_in_code = 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_new_line_after_left_paren = false
|
||||||
ij_kotlin_method_parameters_right_paren_on_new_line = false
|
ij_kotlin_method_parameters_right_paren_on_new_line = false
|
||||||
ij_kotlin_method_parameters_wrap = off
|
ij_kotlin_method_parameters_wrap = off
|
||||||
ij_kotlin_name_count_to_use_star_import = 5
|
ij_kotlin_name_count_to_use_star_import = 999
|
||||||
ij_kotlin_name_count_to_use_star_import_for_members = 3
|
ij_kotlin_name_count_to_use_star_import_for_members = 999
|
||||||
ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.**
|
ij_kotlin_packages_to_use_import_on_demand =
|
||||||
ij_kotlin_parameter_annotation_wrap = off
|
ij_kotlin_parameter_annotation_wrap = off
|
||||||
ij_kotlin_space_after_comma = true
|
ij_kotlin_space_after_comma = true
|
||||||
ij_kotlin_space_after_extend_colon = 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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container: ubuntu:22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@3bc31aaf88e8fc94dc1e632d48af61be5ca8721c
|
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # v3.6.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: 17
|
java-version: 17
|
||||||
cache: 'maven'
|
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
|
- 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/testing.yml
|
||||||
/service/config/deploy.properties
|
/service/config/deploy.properties
|
||||||
/service/dependency-reduced-pom.xml
|
/service/dependency-reduced-pom.xml
|
||||||
|
.java-version
|
||||||
.opsmanage
|
.opsmanage
|
||||||
put.sh
|
put.sh
|
||||||
deployer-staging.properties
|
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/badges/Badges_en.properties
|
||||||
/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties
|
/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties
|
||||||
!/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.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>
|
</pluginRepositories>
|
||||||
|
|
||||||
<modules>
|
<modules>
|
||||||
|
<module>event-logger</module>
|
||||||
<module>redis-dispatch</module>
|
<module>redis-dispatch</module>
|
||||||
<module>websocket-resources</module>
|
<module>websocket-resources</module>
|
||||||
<module>service</module>
|
<module>service</module>
|
||||||
@@ -48,21 +49,24 @@
|
|||||||
<commons-io.version>2.9.0</commons-io.version>
|
<commons-io.version>2.9.0</commons-io.version>
|
||||||
<dropwizard.version>2.0.32</dropwizard.version>
|
<dropwizard.version>2.0.32</dropwizard.version>
|
||||||
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.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>
|
<gson.version>2.9.0</gson.version>
|
||||||
<guava.version>30.1.1-jre</guava.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>
|
<jaxb.version>2.3.1</jaxb.version>
|
||||||
<jedis.version>2.9.0</jedis.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>
|
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||||
<logstash.logback.version>7.0.1</logstash.logback.version>
|
<logstash.logback.version>7.0.1</logstash.logback.version>
|
||||||
<micrometer.version>1.9.3</micrometer.version>
|
<micrometer.version>1.9.3</micrometer.version>
|
||||||
<mockito.version>4.7.0</mockito.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>
|
<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>
|
<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>
|
<semver4j.version>3.1.0</semver4j.version>
|
||||||
<slf4j.version>1.7.30</slf4j.version>
|
<slf4j.version>1.7.30</slf4j.version>
|
||||||
<stripe.version>21.2.0</stripe.version>
|
<stripe.version>21.2.0</stripe.version>
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson</groupId>
|
<groupId>com.fasterxml.jackson</groupId>
|
||||||
<artifactId>jackson-bom</artifactId>
|
<artifactId>jackson-bom</artifactId>
|
||||||
<version>2.13.3</version>
|
<version>${jackson.version}</version>
|
||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
@@ -91,6 +95,20 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>io.netty</groupId>
|
<groupId>io.netty</groupId>
|
||||||
<artifactId>netty-bom</artifactId>
|
<artifactId>netty-bom</artifactId>
|
||||||
@@ -115,7 +133,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.cloud</groupId>
|
<groupId>com.google.cloud</groupId>
|
||||||
<artifactId>libraries-bom</artifactId>
|
<artifactId>libraries-bom</artifactId>
|
||||||
<version>20.9.0</version>
|
<version>26.1.0</version>
|
||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
@@ -133,7 +151,13 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>com.eatthepath</groupId>
|
<groupId>com.eatthepath</groupId>
|
||||||
<artifactId>pushy</artifactId>
|
<artifactId>pushy</artifactId>
|
||||||
@@ -274,7 +298,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.signal</groupId>
|
<groupId>org.signal</groupId>
|
||||||
<artifactId>libsignal-server</artifactId>
|
<artifactId>libsignal-server</artifactId>
|
||||||
<version>0.18.0</version>
|
<version>0.21.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.logging.log4j</groupId>
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
@@ -296,7 +320,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.tomakehurst</groupId>
|
<groupId>com.github.tomakehurst</groupId>
|
||||||
<artifactId>wiremock-jre8</artifactId>
|
<artifactId>wiremock-jre8</artifactId>
|
||||||
<version>2.33.2</version>
|
<version>2.34.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<exclusion>
|
<exclusion>
|
||||||
@@ -366,13 +390,16 @@
|
|||||||
<artifactId>protobuf-maven-plugin</artifactId>
|
<artifactId>protobuf-maven-plugin</artifactId>
|
||||||
<version>0.6.1</version>
|
<version>0.6.1</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<protocArtifact>com.google.protobuf:protoc:3.18.0:exe:${os.detected.classifier}</protocArtifact>
|
<checkStaleness>false</checkStaleness>
|
||||||
<checkStaleness>true</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>
|
</configuration>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>compile</goal>
|
<goal>compile</goal>
|
||||||
|
<goal>compile-custom</goal>
|
||||||
<goal>test-compile</goal>
|
<goal>test-compile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
</execution>
|
</execution>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
# `unset` values will need to be set to work properly.
|
# `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.
|
# 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:
|
stripe:
|
||||||
apiKey: unset
|
apiKey: unset
|
||||||
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
||||||
@@ -55,29 +62,6 @@ dynamoDbTables:
|
|||||||
subscriptions:
|
subscriptions:
|
||||||
tableName: Example_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
|
cacheCluster: # Redis server configuration for cache cluster
|
||||||
configurationUri: redis://redis.example.com:6379/
|
configurationUri: redis://redis.example.com:6379/
|
||||||
|
|
||||||
@@ -108,28 +92,29 @@ directory:
|
|||||||
- replicationName: example # CDS replication name
|
- replicationName: example # CDS replication name
|
||||||
replicationUrl: cds.example.com # CDS replication endpoint base url
|
replicationUrl: cds.example.com # CDS replication endpoint base url
|
||||||
replicationPassword: example # CDS replication endpoint password
|
replicationPassword: example # CDS replication endpoint password
|
||||||
replicationCaCertificate: | # CDS replication endpoint TLS certificate trust root
|
replicationCaCertificates: # CDS replication endpoint TLS certificate trust root
|
||||||
-----BEGIN CERTIFICATE-----
|
- |
|
||||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
-----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
|
||||||
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
|
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||||
-----END CERTIFICATE-----
|
AAAAAAAAAAAAAAAAAAAA
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
directoryV2:
|
directoryV2:
|
||||||
client: # Configuration for interfacing with Contact Discovery Service v2 cluster
|
client: # Configuration for interfacing with Contact Discovery Service v2 cluster
|
||||||
@@ -233,54 +218,56 @@ recaptcha:
|
|||||||
storageService:
|
storageService:
|
||||||
uri: storage.example.com
|
uri: storage.example.com
|
||||||
userAuthenticationTokenSharedSecret: 00000f
|
userAuthenticationTokenSharedSecret: 00000f
|
||||||
storageCaCertificate: |
|
storageCaCertificates:
|
||||||
-----BEGIN CERTIFICATE-----
|
- |
|
||||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
-----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
|
||||||
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
|
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||||
-----END CERTIFICATE-----
|
AAAAAAAAAAAAAAAAAAAA
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
backupService:
|
backupService:
|
||||||
uri: backup.example.com
|
uri: backup.example.com
|
||||||
userAuthenticationTokenSharedSecret: 00000f
|
userAuthenticationTokenSharedSecret: 00000f
|
||||||
backupCaCertificate: |
|
backupCaCertificates:
|
||||||
-----BEGIN CERTIFICATE-----
|
- |
|
||||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
-----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
|
||||||
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
|
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||||
-----END CERTIFICATE-----
|
AAAAAAAAAAAAAAAAAAAA
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
zkConfig:
|
zkConfig:
|
||||||
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||||
@@ -307,14 +294,6 @@ paymentsService:
|
|||||||
# list of symbols for supported currencies
|
# list of symbols for supported currencies
|
||||||
- MOB
|
- MOB
|
||||||
|
|
||||||
donation:
|
|
||||||
uri: donation.example.com # value
|
|
||||||
supportedCurrencies:
|
|
||||||
- # 1st supported currency
|
|
||||||
- # 2nd supported currency
|
|
||||||
- # ...
|
|
||||||
- # Nth supported currency
|
|
||||||
|
|
||||||
badges:
|
badges:
|
||||||
badges:
|
badges:
|
||||||
- id: TEST
|
- id: TEST
|
||||||
@@ -367,3 +346,29 @@ gift:
|
|||||||
currencies:
|
currencies:
|
||||||
# ISO 4217 currency codes and amounts in those currencies
|
# ISO 4217 currency codes and amounts in those currencies
|
||||||
xts: '2'
|
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>
|
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.whispersystems.textsecure</groupId>
|
||||||
|
<artifactId>event-logger</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.whispersystems.textsecure</groupId>
|
<groupId>org.whispersystems.textsecure</groupId>
|
||||||
<artifactId>redis-dispatch</artifactId>
|
<artifactId>redis-dispatch</artifactId>
|
||||||
@@ -223,6 +228,30 @@
|
|||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>io.github.resilience4j</groupId>
|
||||||
<artifactId>resilience4j-retry</artifactId>
|
<artifactId>resilience4j-retry</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>io.micrometer</groupId>
|
<groupId>io.micrometer</groupId>
|
||||||
@@ -289,10 +318,6 @@
|
|||||||
<groupId>com.amazonaws</groupId>
|
<groupId>com.amazonaws</groupId>
|
||||||
<artifactId>aws-java-sdk-core</artifactId>
|
<artifactId>aws-java-sdk-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>com.amazonaws</groupId>
|
|
||||||
<artifactId>aws-java-sdk-s3</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.amazonaws</groupId>
|
<groupId>com.amazonaws</groupId>
|
||||||
<artifactId>dynamodb-lock-client</artifactId>
|
<artifactId>dynamodb-lock-client</artifactId>
|
||||||
@@ -386,7 +411,6 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.projectreactor</groupId>
|
<groupId>io.projectreactor</groupId>
|
||||||
<artifactId>reactor-core</artifactId>
|
<artifactId>reactor-core</artifactId>
|
||||||
<version>3.3.22.RELEASE</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.vavr</groupId>
|
<groupId>io.vavr</groupId>
|
||||||
@@ -399,6 +423,11 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-test</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.signal</groupId>
|
<groupId>org.signal</groupId>
|
||||||
<artifactId>embedded-redis</artifactId>
|
<artifactId>embedded-redis</artifactId>
|
||||||
@@ -408,14 +437,14 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.uuid</groupId>
|
<groupId>com.fasterxml.uuid</groupId>
|
||||||
<artifactId>java-uuid-generator</artifactId>
|
<artifactId>java-uuid-generator</artifactId>
|
||||||
<version>3.2.0</version>
|
<version>4.0.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.amazonaws</groupId>
|
<groupId>com.amazonaws</groupId>
|
||||||
<artifactId>DynamoDBLocal</artifactId>
|
<artifactId>DynamoDBLocal</artifactId>
|
||||||
<version>1.17.2</version>
|
<version>1.19.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import javax.validation.Valid;
|
|||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import org.whispersystems.textsecuregcm.configuration.AbusiveMessageFilterConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.AbusiveMessageFilterConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
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.DatadogConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
|
||||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||||
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
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.RecaptchaConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.RegistrationServiceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
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.StripeConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
|
|
||||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
||||||
@@ -53,6 +53,11 @@ import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
|||||||
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
||||||
public class WhisperServerConfiguration extends Configuration {
|
public class WhisperServerConfiguration extends Configuration {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Valid
|
||||||
|
@JsonProperty
|
||||||
|
private AdminEventLoggingConfiguration adminEventLoggingConfiguration;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@@ -68,11 +73,6 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
private DynamoDbTables dynamoDbTables;
|
private DynamoDbTables dynamoDbTables;
|
||||||
|
|
||||||
@NotNull
|
|
||||||
@Valid
|
|
||||||
@JsonProperty
|
|
||||||
private TwilioConfiguration twilio;
|
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@@ -218,11 +218,6 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
private AppConfigConfiguration appConfig;
|
private AppConfigConfiguration appConfig;
|
||||||
|
|
||||||
@Valid
|
|
||||||
@NotNull
|
|
||||||
@JsonProperty
|
|
||||||
private DonationConfiguration donation;
|
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull
|
@NotNull
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@@ -257,6 +252,15 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotNull
|
||||||
|
@JsonProperty
|
||||||
|
private RegistrationServiceConfiguration registrationService;
|
||||||
|
|
||||||
|
public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() {
|
||||||
|
return adminEventLoggingConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
public StripeConfiguration getStripe() {
|
public StripeConfiguration getStripe() {
|
||||||
return stripe;
|
return stripe;
|
||||||
}
|
}
|
||||||
@@ -281,10 +285,6 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
return webSocket;
|
return webSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TwilioConfiguration getTwilioConfiguration() {
|
|
||||||
return twilio;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() {
|
public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() {
|
||||||
return awsAttachments;
|
return awsAttachments;
|
||||||
}
|
}
|
||||||
@@ -403,10 +403,6 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
return appConfig;
|
return appConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DonationConfiguration getDonationConfiguration() {
|
|
||||||
return donation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BadgesConfiguration getBadges() {
|
public BadgesConfiguration getBadges() {
|
||||||
return badges;
|
return badges;
|
||||||
}
|
}
|
||||||
@@ -434,4 +430,8 @@ public class WhisperServerConfiguration extends Configuration {
|
|||||||
public UsernameConfiguration getUsername() {
|
public UsernameConfiguration getUsername() {
|
||||||
return username;
|
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.JsonAutoDetect;
|
||||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
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.ImmutableMap;
|
||||||
import com.google.common.collect.ImmutableSet;
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.common.collect.Lists;
|
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.config.MeterFilter;
|
||||||
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||||
import io.micrometer.datadog.DatadogMeterRegistry;
|
import io.micrometer.datadog.DatadogMeterRegistry;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -55,6 +59,8 @@ import javax.servlet.FilterRegistration;
|
|||||||
import javax.servlet.ServletRegistration;
|
import javax.servlet.ServletRegistration;
|
||||||
import org.eclipse.jetty.servlets.CrossOriginFilter;
|
import org.eclipse.jetty.servlets.CrossOriginFilter;
|
||||||
import org.glassfish.jersey.server.ServerProperties;
|
import org.glassfish.jersey.server.ServerProperties;
|
||||||
|
import org.signal.event.AdminEventLogger;
|
||||||
|
import org.signal.event.GoogleCloudAdminEventLogger;
|
||||||
import org.signal.i18n.HeaderControlledResourceBundleLookup;
|
import org.signal.i18n.HeaderControlledResourceBundleLookup;
|
||||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||||
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
|
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.DirectoryServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
|
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
||||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
||||||
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
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.DynamicRateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeOptionManager;
|
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
|
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.ImpossiblePhoneNumberExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.RateLimitChallengeExceptionMapper;
|
|
||||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
|
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
|
||||||
import org.whispersystems.textsecuregcm.metrics.ApplicationShutdownMonitor;
|
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.NetworkReceivedGauge;
|
||||||
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
|
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
|
||||||
import org.whispersystems.textsecuregcm.metrics.OperatingSystemMemoryGauge;
|
import org.whispersystems.textsecuregcm.metrics.OperatingSystemMemoryGauge;
|
||||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
|
||||||
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
|
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
|
||||||
import org.whispersystems.textsecuregcm.metrics.TrafficSource;
|
import org.whispersystems.textsecuregcm.metrics.TrafficSource;
|
||||||
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
|
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.FcmSender;
|
||||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||||
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
||||||
|
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
||||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||||
import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;
|
import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
|
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
|
||||||
|
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
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.sqs.DirectoryQueue;
|
||||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountCleaner;
|
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.PhoneNumberIdentifiers;
|
||||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||||
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
|
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.RemoteConfigsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
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.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
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.textsecuregcm.workers.ZkParamsCommand;
|
||||||
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
|
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
|
||||||
import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||||
import software.amazon.awssdk.regions.Region;
|
import software.amazon.awssdk.regions.Region;
|
||||||
@@ -328,6 +330,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
config.getAppConfig().getConfigurationName(),
|
config.getAppConfig().getConfigurationName(),
|
||||||
DynamicConfiguration.class);
|
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,
|
Accounts accounts = new Accounts(dynamicConfigurationManager,
|
||||||
dynamoDbClient,
|
dynamoDbClient,
|
||||||
dynamoDbAsyncClient,
|
dynamoDbAsyncClient,
|
||||||
@@ -338,14 +347,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
||||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||||
ReservedUsernames reservedUsernames = new ReservedUsernames(dynamoDbClient,
|
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||||
config.getDynamoDbTables().getProfiles().getTableName());
|
config.getDynamoDbTables().getProfiles().getTableName());
|
||||||
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().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().getTableName(),
|
||||||
config.getDynamoDbTables().getMessages().getExpiration());
|
config.getDynamoDbTables().getMessages().getExpiration(),
|
||||||
|
messageDeletionAsyncExecutor);
|
||||||
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
|
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getRemoteConfig().getTableName());
|
config.getDynamoDbTables().getRemoteConfig().getTableName());
|
||||||
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
|
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
|
||||||
@@ -358,8 +368,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
VerificationCodeStore pendingDevices = new VerificationCodeStore(dynamoDbClient,
|
VerificationCodeStore pendingDevices = new VerificationCodeStore(dynamoDbClient,
|
||||||
config.getDynamoDbTables().getPendingDevices().getTableName());
|
config.getDynamoDbTables().getPendingDevices().getTableName());
|
||||||
|
|
||||||
RedisClientFactory pubSubClientFactory = new RedisClientFactory("pubsub_cache", config.getPubsubCacheConfiguration().getUrl(), config.getPubsubCacheConfiguration().getReplicaUrls(), config.getPubsubCacheConfiguration().getCircuitBreakerConfiguration());
|
reactor.util.Metrics.MicrometerConfiguration.useRegistry(Metrics.globalRegistry);
|
||||||
ReplicatedJedisPool pubsubClient = pubSubClientFactory.getRedisClientPool();
|
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();
|
MicrometerOptions options = MicrometerOptions.builder().build();
|
||||||
ClientResources redisClientResources = ClientResources.builder()
|
ClientResources redisClientResources = ClientResources.builder()
|
||||||
@@ -406,6 +421,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
.workQueue(receiptSenderQueue)
|
.workQueue(receiptSenderQueue)
|
||||||
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
|
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
|
||||||
.build();
|
.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,
|
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
|
||||||
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
|
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
|
||||||
@@ -422,9 +450,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
|
|
||||||
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
|
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
|
||||||
|
|
||||||
TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager = new TwilioVerifyExperimentEnrollmentManager(
|
|
||||||
config.getVoiceVerificationConfiguration(), experimentEnrollmentManager);
|
|
||||||
|
|
||||||
ExternalServiceCredentialGenerator storageCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
ExternalServiceCredentialGenerator storageCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||||
config.getSecureStorageServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
config.getSecureStorageServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||||
ExternalServiceCredentialGenerator backupCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
ExternalServiceCredentialGenerator backupCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||||
@@ -433,22 +458,26 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
config.getPaymentsServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
config.getPaymentsServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||||
|
|
||||||
AbusiveHostRules abusiveHostRules = new AbusiveHostRules(rateLimitersCluster, dynamicConfigurationManager);
|
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());
|
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, config.getSecureBackupServiceConfiguration());
|
||||||
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, storageServiceExecutor, config.getSecureStorageServiceConfiguration());
|
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, storageServiceExecutor, config.getSecureStorageServiceConfiguration());
|
||||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor, keyspaceNotificationDispatchExecutor);
|
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor, keyspaceNotificationDispatchExecutor);
|
||||||
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
|
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
|
||||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||||
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
||||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, keyspaceNotificationDispatchExecutor);
|
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, Clock.systemUTC(),
|
||||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
keyspaceNotificationDispatchExecutor, messageDeletionAsyncExecutor);
|
||||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, config.getReportMessageConfiguration().getCounterTtl());
|
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager);
|
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster,
|
||||||
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
config.getReportMessageConfiguration().getCounterTtl());
|
||||||
|
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager,
|
||||||
|
messageDeletionAsyncExecutor);
|
||||||
|
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||||
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||||
deletedAccountsManager, directoryQueue, keys, messagesManager, reservedUsernames, profilesManager,
|
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||||
experimentEnrollmentManager, clock);
|
experimentEnrollmentManager, clock);
|
||||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||||
@@ -480,8 +509,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||||
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(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);
|
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
|
||||||
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||||
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||||
@@ -492,8 +519,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
||||||
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||||
recaptchaClient, dynamicRateLimiters);
|
recaptchaClient, dynamicRateLimiters);
|
||||||
RateLimitChallengeOptionManager rateLimitChallengeOptionManager =
|
|
||||||
new RateLimitChallengeOptionManager(dynamicRateLimiters, dynamicConfigurationManager);
|
|
||||||
|
|
||||||
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
||||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||||
@@ -569,6 +594,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
environment.lifecycle().manage(clientPresenceManager);
|
environment.lifecycle().manage(clientPresenceManager);
|
||||||
environment.lifecycle().manage(currencyManager);
|
environment.lifecycle().manage(currencyManager);
|
||||||
environment.lifecycle().manage(directoryQueue);
|
environment.lifecycle().manage(directoryQueue);
|
||||||
|
environment.lifecycle().manage(registrationServiceClient);
|
||||||
|
|
||||||
StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider
|
StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider
|
||||||
.create(AwsBasicCredentials.create(
|
.create(AwsBasicCredentials.create(
|
||||||
@@ -616,8 +642,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
||||||
webSocketEnvironment.setConnectListener(
|
webSocketEnvironment.setConnectListener(
|
||||||
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
||||||
clientPresenceManager, websocketScheduledExecutor));
|
clientPresenceManager, websocketScheduledExecutor, experimentEnrollmentManager));
|
||||||
webSocketEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
webSocketEnvironment.jersey()
|
||||||
|
.register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||||
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
||||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||||
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET));
|
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
|
// these should be common, but use @Auth DisabledPermittedAccount, which isn’t supported yet on websocket
|
||||||
environment.jersey().register(
|
environment.jersey().register(
|
||||||
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
|
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
|
||||||
smsSender, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
|
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
|
||||||
recaptchaClient, pushNotificationManager, verifyExperimentEnrollmentManager,
|
recaptchaClient, pushNotificationManager, changeNumberManager, backupCredentialsGenerator));
|
||||||
changeNumberManager, backupCredentialsGenerator));
|
|
||||||
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
|
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
|
||||||
|
|
||||||
final List<Object> commonControllers = Lists.newArrayList(
|
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 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 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),
|
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 DirectoryController(directoryCredentialsGenerator),
|
||||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
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 MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor),
|
||||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
||||||
new ProvisioningController(rateLimiters, provisioningManager),
|
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 SecureBackupController(backupCredentialsGenerator),
|
||||||
new SecureStorageController(storageCredentialsGenerator),
|
new SecureStorageController(storageCredentialsGenerator),
|
||||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||||
@@ -705,13 +731,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||||||
registerCorsFilter(environment);
|
registerCorsFilter(environment);
|
||||||
registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);
|
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);
|
environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||||
webSocketEnvironment.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);
|
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.Authenticator;
|
||||||
import io.dropwizard.auth.basic.BasicCredentials;
|
import io.dropwizard.auth.basic.BasicCredentials;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
|
||||||
public class AccountAuthenticator extends BaseAccountAuthenticator implements
|
public class AccountAuthenticator extends BaseAccountAuthenticator implements
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.auth;
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
@@ -12,10 +14,18 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
public class AuthenticationCredentials {
|
public class AuthenticationCredentials {
|
||||||
|
private static final String V2_PREFIX = "2.";
|
||||||
|
|
||||||
private final String hashedAuthenticationToken;
|
private final String hashedAuthenticationToken;
|
||||||
private final String salt;
|
private final String salt;
|
||||||
|
|
||||||
|
public enum Version {
|
||||||
|
V1,
|
||||||
|
V2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Version CURRENT_VERSION = Version.V2;
|
||||||
|
|
||||||
public AuthenticationCredentials(String hashedAuthenticationToken, String salt) {
|
public AuthenticationCredentials(String hashedAuthenticationToken, String salt) {
|
||||||
this.hashedAuthenticationToken = hashedAuthenticationToken;
|
this.hashedAuthenticationToken = hashedAuthenticationToken;
|
||||||
this.salt = salt;
|
this.salt = salt;
|
||||||
@@ -23,7 +33,20 @@ public class AuthenticationCredentials {
|
|||||||
|
|
||||||
public AuthenticationCredentials(String authenticationToken) {
|
public AuthenticationCredentials(String authenticationToken) {
|
||||||
this.salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
|
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() {
|
public String getHashedAuthenticationToken() {
|
||||||
@@ -35,11 +58,14 @@ public class AuthenticationCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean verify(String authenticationToken) {
|
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));
|
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 {
|
try {
|
||||||
return new String(Hex.encodeHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))));
|
return new String(Hex.encodeHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))));
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} 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_SUCCEEDED_TAG_NAME = "succeeded";
|
||||||
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";
|
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_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 DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(BaseAccountAuthenticator.class, "daysSinceLastSeen");
|
||||||
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
||||||
@@ -43,8 +46,8 @@ public class BaseAccountAuthenticator {
|
|||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) {
|
public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) {
|
||||||
this.accountsManager = accountsManager;
|
this.accountsManager = accountsManager;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Pair<String, Long> getIdentifierAndDeviceId(final String basicUsername) {
|
static Pair<String, Long> getIdentifierAndDeviceId(final String basicUsername) {
|
||||||
@@ -67,6 +70,7 @@ public class BaseAccountAuthenticator {
|
|||||||
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials, boolean enabledRequired) {
|
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials, boolean enabledRequired) {
|
||||||
boolean succeeded = false;
|
boolean succeeded = false;
|
||||||
String failureReason = null;
|
String failureReason = null;
|
||||||
|
boolean hasStoryCapability = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final UUID accountUuid;
|
final UUID accountUuid;
|
||||||
@@ -85,6 +89,8 @@ public class BaseAccountAuthenticator {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasStoryCapability = account.map(Account::isStoriesSupported).orElse(false);
|
||||||
|
|
||||||
Optional<Device> device = account.get().getDevice(deviceId);
|
Optional<Device> device = account.get().getDevice(deviceId);
|
||||||
|
|
||||||
if (device.isEmpty()) {
|
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;
|
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(
|
return Optional.of(new AuthenticatedAccount(
|
||||||
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
|
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
|
||||||
}
|
}
|
||||||
@@ -125,6 +138,9 @@ public class BaseAccountAuthenticator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();
|
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;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.auth;
|
|||||||
import io.dropwizard.auth.Authenticator;
|
import io.dropwizard.auth.Authenticator;
|
||||||
import io.dropwizard.auth.basic.BasicCredentials;
|
import io.dropwizard.auth.basic.BasicCredentials;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
|
||||||
public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements
|
public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements
|
||||||
|
|||||||
@@ -5,60 +5,19 @@
|
|||||||
|
|
||||||
package org.whispersystems.textsecuregcm.auth;
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
|
|
||||||
public class StoredVerificationCode {
|
public record StoredVerificationCode(String code,
|
||||||
|
long timestamp,
|
||||||
@JsonProperty
|
String pushCode,
|
||||||
private final String code;
|
@Nullable String twilioVerificationSid,
|
||||||
|
@Nullable byte[] sessionId) {
|
||||||
@JsonProperty
|
|
||||||
private final long timestamp;
|
|
||||||
|
|
||||||
@JsonProperty
|
|
||||||
private final String pushCode;
|
|
||||||
|
|
||||||
@JsonProperty
|
|
||||||
@Nullable
|
|
||||||
private final String twilioVerificationSid;
|
|
||||||
|
|
||||||
public static final Duration EXPIRATION = Duration.ofMinutes(10);
|
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) {
|
public boolean isValid(String theirCodeString) {
|
||||||
if (Util.isEmpty(code) || Util.isEmpty(theirCodeString)) {
|
if (Util.isEmpty(code) || Util.isEmpty(theirCodeString)) {
|
||||||
return false;
|
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
|
@JsonProperty
|
||||||
@NotNull
|
@NotNull
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int ringBufferSizeInHalfOpenState = 10;
|
private int permittedNumberOfCallsInHalfOpenState = 10;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@NotNull
|
@NotNull
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int ringBufferSizeInClosedState = 100;
|
private int slidingWindowSize = 100;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@NotNull
|
||||||
|
@Min(1)
|
||||||
|
private int slidingWindowMinimumNumberOfCalls = 100;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@NotNull
|
@NotNull
|
||||||
@@ -47,28 +52,32 @@ public class CircuitBreakerConfiguration {
|
|||||||
return failureRateThreshold;
|
return failureRateThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getRingBufferSizeInHalfOpenState() {
|
public int getPermittedNumberOfCallsInHalfOpenState() {
|
||||||
return ringBufferSizeInHalfOpenState;
|
return permittedNumberOfCallsInHalfOpenState;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getRingBufferSizeInClosedState() {
|
public int getSlidingWindowSize() {
|
||||||
return ringBufferSizeInClosedState;
|
return slidingWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSlidingWindowMinimumNumberOfCalls() {
|
||||||
|
return slidingWindowMinimumNumberOfCalls;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getWaitDurationInOpenStateInSeconds() {
|
public long getWaitDurationInOpenStateInSeconds() {
|
||||||
return waitDurationInOpenStateInSeconds;
|
return waitDurationInOpenStateInSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Class> getIgnoredExceptions() {
|
public List<Class<?>> getIgnoredExceptions() {
|
||||||
return ignoredExceptions.stream()
|
return ignoredExceptions.stream()
|
||||||
.map(name -> {
|
.map(name -> {
|
||||||
try {
|
try {
|
||||||
return Class.forName(name);
|
return Class.forName(name);
|
||||||
} catch (final ClassNotFoundException e) {
|
} catch (final ClassNotFoundException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@@ -77,13 +86,18 @@ public class CircuitBreakerConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public void setRingBufferSizeInClosedState(int size) {
|
public void setSlidingWindowSize(int size) {
|
||||||
this.ringBufferSizeInClosedState = size;
|
this.slidingWindowSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public void setRingBufferSizeInHalfOpenState(int size) {
|
public void setSlidingWindowMinimumNumberOfCalls(int size) {
|
||||||
this.ringBufferSizeInHalfOpenState = size;
|
this.slidingWindowMinimumNumberOfCalls = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public void setPermittedNumberOfCallsInHalfOpenState(int size) {
|
||||||
|
this.permittedNumberOfCallsInHalfOpenState = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@@ -98,11 +112,12 @@ public class CircuitBreakerConfiguration {
|
|||||||
|
|
||||||
public CircuitBreakerConfig toCircuitBreakerConfig() {
|
public CircuitBreakerConfig toCircuitBreakerConfig() {
|
||||||
return CircuitBreakerConfig.custom()
|
return CircuitBreakerConfig.custom()
|
||||||
.failureRateThreshold(getFailureRateThreshold())
|
.failureRateThreshold(getFailureRateThreshold())
|
||||||
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
|
.ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))
|
||||||
.ringBufferSizeInHalfOpenState(getRingBufferSizeInHalfOpenState())
|
.permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState())
|
||||||
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
|
.waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds()))
|
||||||
.ringBufferSizeInClosedState(getRingBufferSizeInClosedState())
|
.slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(),
|
||||||
.build();
|
CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class DirectoryServerConfiguration {
|
public class DirectoryServerConfiguration {
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ public class DirectoryServerConfiguration {
|
|||||||
|
|
||||||
@NotEmpty
|
@NotEmpty
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String replicationCaCertificate;
|
private List<@NotBlank String> replicationCaCertificates;
|
||||||
|
|
||||||
public String getReplicationName() {
|
public String getReplicationName() {
|
||||||
return replicationName;
|
return replicationName;
|
||||||
@@ -37,8 +39,8 @@ public class DirectoryServerConfiguration {
|
|||||||
return replicationPassword;
|
return replicationPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getReplicationCaCertificate() {
|
public List<String> getReplicationCaCertificates() {
|
||||||
return replicationCaCertificate;
|
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
|
@JsonProperty
|
||||||
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
|
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() {
|
public RateLimitConfiguration getAutoBlock() {
|
||||||
return autoBlock;
|
return autoBlock;
|
||||||
}
|
}
|
||||||
@@ -137,10 +143,16 @@ public class RateLimitsConfiguration {
|
|||||||
return usernameSet;
|
return usernameSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimitConfiguration getUsernameReserve() {
|
||||||
|
return usernameReserve;
|
||||||
|
}
|
||||||
|
|
||||||
public RateLimitConfiguration getCheckAccountExistence() {
|
public RateLimitConfiguration getCheckAccountExistence() {
|
||||||
return checkAccountExistence;
|
return checkAccountExistence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimitConfiguration getStories() { return stories; }
|
||||||
|
|
||||||
public static class RateLimitConfiguration {
|
public static class RateLimitConfiguration {
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private int bucketSize;
|
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 com.google.common.annotations.VisibleForTesting;
|
||||||
import org.apache.commons.codec.DecoderException;
|
import org.apache.commons.codec.DecoderException;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class SecureBackupServiceConfiguration {
|
public class SecureBackupServiceConfiguration {
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ public class SecureBackupServiceConfiguration {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String uri;
|
private String uri;
|
||||||
|
|
||||||
@NotBlank
|
@NotEmpty
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String backupCaCertificate;
|
private List<@NotBlank String> backupCaCertificates;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@@ -52,12 +53,12 @@ public class SecureBackupServiceConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public void setBackupCaCertificate(final String backupCaCertificate) {
|
public void setBackupCaCertificates(final List<String> backupCaCertificates) {
|
||||||
this.backupCaCertificate = backupCaCertificate;
|
this.backupCaCertificates = backupCaCertificates;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getBackupCaCertificate() {
|
public List<String> getBackupCaCertificates() {
|
||||||
return backupCaCertificate;
|
return backupCaCertificates;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import javax.validation.constraints.NotEmpty;
|
|||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import org.apache.commons.codec.DecoderException;
|
import org.apache.commons.codec.DecoderException;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class SecureStorageServiceConfiguration {
|
public class SecureStorageServiceConfiguration {
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ public class SecureStorageServiceConfiguration {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String uri;
|
private String uri;
|
||||||
|
|
||||||
@NotBlank
|
@NotEmpty
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String storageCaCertificate;
|
private List<@NotBlank String> storageCaCertificates;
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Valid
|
@Valid
|
||||||
@@ -52,12 +53,12 @@ public class SecureStorageServiceConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public void setStorageCaCertificate(final String certificatePem) {
|
public void setStorageCaCertificates(final List<String> certificatePem) {
|
||||||
this.storageCaCertificate = certificatePem;
|
this.storageCaCertificates = certificatePem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStorageCaCertificate() {
|
public List<String> getStorageCaCertificates() {
|
||||||
return storageCaCertificate;
|
return storageCaCertificates;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
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 com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import javax.validation.constraints.Min;
|
import javax.validation.constraints.Min;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
public class UsernameConfiguration {
|
public class UsernameConfiguration {
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int discriminatorInitialWidth = 4;
|
private int discriminatorInitialWidth = 2;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -22,6 +23,9 @@ public class UsernameConfiguration {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
private int attemptsPerWidth = 10;
|
private int attemptsPerWidth = 10;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
private Duration reservationTtl = Duration.ofMinutes(5);
|
||||||
|
|
||||||
public int getDiscriminatorInitialWidth() {
|
public int getDiscriminatorInitialWidth() {
|
||||||
return discriminatorInitialWidth;
|
return discriminatorInitialWidth;
|
||||||
}
|
}
|
||||||
@@ -33,4 +37,8 @@ public class UsernameConfiguration {
|
|||||||
public int getAttemptsPerWidth() {
|
public int getAttemptsPerWidth() {
|
||||||
return attemptsPerWidth;
|
return attemptsPerWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Duration getReservationTtl() {
|
||||||
|
return reservationTtl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ public class DynamicConfiguration {
|
|||||||
@Valid
|
@Valid
|
||||||
private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration();
|
private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration();
|
||||||
|
|
||||||
@JsonProperty
|
|
||||||
@Valid
|
|
||||||
private DynamicTwilioConfiguration twilio = new DynamicTwilioConfiguration();
|
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@Valid
|
@Valid
|
||||||
private DynamicCaptchaConfiguration captcha = new DynamicCaptchaConfiguration();
|
private DynamicCaptchaConfiguration captcha = new DynamicCaptchaConfiguration();
|
||||||
@@ -86,15 +82,6 @@ public class DynamicConfiguration {
|
|||||||
return payments;
|
return payments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DynamicTwilioConfiguration getTwilioConfiguration() {
|
|
||||||
return twilio;
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
public void setTwilioConfiguration(DynamicTwilioConfiguration twilioConfiguration) {
|
|
||||||
this.twilio = twilioConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DynamicCaptchaConfiguration getCaptchaConfiguration() {
|
public DynamicCaptchaConfiguration getCaptchaConfiguration() {
|
||||||
return captcha;
|
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.SharedMetricRegistries;
|
||||||
import com.codahale.metrics.annotation.Timed;
|
import com.codahale.metrics.annotation.Timed;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
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.dropwizard.auth.Auth;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
import io.micrometer.core.instrument.Metrics;
|
||||||
import io.micrometer.core.instrument.Tag;
|
import io.micrometer.core.instrument.Tag;
|
||||||
@@ -18,13 +21,9 @@ import io.micrometer.core.instrument.Tags;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
@@ -50,6 +49,7 @@ import javax.ws.rs.core.Response.Status;
|
|||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
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.AccountIdentityResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
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.StaleDevices;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
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.PushNotification;
|
||||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||||
import org.whispersystems.textsecuregcm.sms.SmsSender;
|
import org.whispersystems.textsecuregcm.registration.ClientType;
|
||||||
import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager;
|
import org.whispersystems.textsecuregcm.registration.MessageTransport;
|
||||||
|
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||||
import org.whispersystems.textsecuregcm.storage.Account;
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
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.DynamicConfigurationManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Hex;
|
import org.whispersystems.textsecuregcm.util.Hex;
|
||||||
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||||
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Optionals;
|
||||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||||
import org.whispersystems.textsecuregcm.util.Util;
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
|
||||||
|
|
||||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
@Path("/v1/accounts")
|
@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 CAPTCHA_ATTEMPT_COUNTER_NAME = name(AccountController.class, "captcha");
|
||||||
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
|
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 NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||||
|
|
||||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
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 StoredVerificationCodeManager pendingAccounts;
|
||||||
private final AccountsManager accounts;
|
private final AccountsManager accounts;
|
||||||
private final AbusiveHostRules abusiveHostRules;
|
private final AbusiveHostRules abusiveHostRules;
|
||||||
private final RateLimiters rateLimiters;
|
private final RateLimiters rateLimiters;
|
||||||
private final SmsSender smsSender;
|
private final RegistrationServiceClient registrationServiceClient;
|
||||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||||
private final TurnTokenGenerator turnTokenGenerator;
|
private final TurnTokenGenerator turnTokenGenerator;
|
||||||
private final Map<String, Integer> testDevices;
|
private final Map<String, Integer> testDevices;
|
||||||
@@ -146,20 +153,21 @@ public class AccountController {
|
|||||||
private final PushNotificationManager pushNotificationManager;
|
private final PushNotificationManager pushNotificationManager;
|
||||||
private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator;
|
private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator;
|
||||||
|
|
||||||
private final TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager;
|
|
||||||
private final ChangeNumberManager changeNumberManager;
|
private final ChangeNumberManager changeNumberManager;
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
|
||||||
public AccountController(StoredVerificationCodeManager pendingAccounts,
|
public AccountController(StoredVerificationCodeManager pendingAccounts,
|
||||||
AccountsManager accounts,
|
AccountsManager accounts,
|
||||||
AbusiveHostRules abusiveHostRules,
|
AbusiveHostRules abusiveHostRules,
|
||||||
RateLimiters rateLimiters,
|
RateLimiters rateLimiters,
|
||||||
SmsSender smsSenderFactory,
|
RegistrationServiceClient registrationServiceClient,
|
||||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||||
TurnTokenGenerator turnTokenGenerator,
|
TurnTokenGenerator turnTokenGenerator,
|
||||||
Map<String, Integer> testDevices,
|
Map<String, Integer> testDevices,
|
||||||
RecaptchaClient recaptchaClient,
|
RecaptchaClient recaptchaClient,
|
||||||
PushNotificationManager pushNotificationManager,
|
PushNotificationManager pushNotificationManager,
|
||||||
TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager,
|
|
||||||
ChangeNumberManager changeNumberManager,
|
ChangeNumberManager changeNumberManager,
|
||||||
ExternalServiceCredentialGenerator backupServiceCredentialGenerator)
|
ExternalServiceCredentialGenerator backupServiceCredentialGenerator)
|
||||||
{
|
{
|
||||||
@@ -167,13 +175,12 @@ public class AccountController {
|
|||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.abusiveHostRules = abusiveHostRules;
|
this.abusiveHostRules = abusiveHostRules;
|
||||||
this.rateLimiters = rateLimiters;
|
this.rateLimiters = rateLimiters;
|
||||||
this.smsSender = smsSenderFactory;
|
this.registrationServiceClient = registrationServiceClient;
|
||||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||||
this.testDevices = testDevices;
|
this.testDevices = testDevices;
|
||||||
this.turnTokenGenerator = turnTokenGenerator;
|
this.turnTokenGenerator = turnTokenGenerator;
|
||||||
this.recaptchaClient = recaptchaClient;
|
this.recaptchaClient = recaptchaClient;
|
||||||
this.pushNotificationManager = pushNotificationManager;
|
this.pushNotificationManager = pushNotificationManager;
|
||||||
this.verifyExperimentEnrollmentManager = verifyExperimentEnrollmentManager;
|
|
||||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||||
this.changeNumberManager = changeNumberManager;
|
this.changeNumberManager = changeNumberManager;
|
||||||
}
|
}
|
||||||
@@ -196,14 +203,12 @@ public class AccountController {
|
|||||||
|
|
||||||
Util.requireNormalizedNumber(number);
|
Util.requireNormalizedNumber(number);
|
||||||
|
|
||||||
String pushChallenge = generatePushChallenge();
|
String pushChallenge = generatePushChallenge();
|
||||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
|
StoredVerificationCode storedVerificationCode =
|
||||||
System.currentTimeMillis(),
|
new StoredVerificationCode(null, System.currentTimeMillis(), pushChallenge, null, null);
|
||||||
pushChallenge,
|
|
||||||
null);
|
|
||||||
|
|
||||||
pendingAccounts.store(number, storedVerificationCode);
|
pendingAccounts.store(number, storedVerificationCode);
|
||||||
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.getPushCode());
|
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode());
|
||||||
|
|
||||||
return Response.ok().build();
|
return Response.ok().build();
|
||||||
}
|
}
|
||||||
@@ -211,6 +216,7 @@ public class AccountController {
|
|||||||
@Timed
|
@Timed
|
||||||
@GET
|
@GET
|
||||||
@Path("/{transport}/code/{number}")
|
@Path("/{transport}/code/{number}")
|
||||||
|
@FilterAbusiveMessages
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public Response createAccount(@PathParam("transport") String transport,
|
public Response createAccount(@PathParam("transport") String transport,
|
||||||
@PathParam("number") String number,
|
@PathParam("number") String number,
|
||||||
@@ -224,26 +230,44 @@ public class AccountController {
|
|||||||
|
|
||||||
Util.requireNormalizedNumber(number);
|
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);
|
final String countryCode = Util.getCountryCode(number);
|
||||||
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, sourceHost, captcha,
|
final String region = Util.getRegion(number);
|
||||||
storedChallenge, pushChallenge, userAgent);
|
|
||||||
|
|
||||||
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();
|
captchaRequiredMeter.mark();
|
||||||
|
|
||||||
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
|
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
|
||||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
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();
|
.increment();
|
||||||
|
|
||||||
if (requirement.isAutoBlock() && shouldAutoBlock(sourceHost)) {
|
|
||||||
logger.info("Auto-block: {}", sourceHost);
|
|
||||||
abusiveHostRules.setBlockedHost(sourceHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.status(402).build();
|
return Response.status(402).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,80 +280,48 @@ public class AccountController {
|
|||||||
default -> throw new WebApplicationException(Response.status(422).build());
|
default -> throw new WebApplicationException(Response.status(422).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
VerificationCode verificationCode = generateVerificationCode(number);
|
final Phonenumber.PhoneNumber phoneNumber;
|
||||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
|
|
||||||
|
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(),
|
System.currentTimeMillis(),
|
||||||
storedChallenge.map(StoredVerificationCode::getPushCode).orElse(null),
|
maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null),
|
||||||
storedChallenge.flatMap(StoredVerificationCode::getTwilioVerificationSid).orElse(null));
|
null,
|
||||||
|
sessionId);
|
||||||
|
|
||||||
pendingAccounts.store(number, storedVerificationCode);
|
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(
|
Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of(
|
||||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
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(VERIFICATION_TRANSPORT_TAG_NAME, transport),
|
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport)))
|
||||||
Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(enrolledInVerifyExperiment))))
|
|
||||||
.increment();
|
.increment();
|
||||||
|
|
||||||
return Response.ok().build();
|
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.
|
// 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
|
// We check that numbers are normalized before we store verification codes, and so don't need to re-assert
|
||||||
// normalization here.
|
// 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());
|
throw new WebApplicationException(Response.status(403).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
|
|
||||||
.ifPresent(
|
|
||||||
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "registration"));
|
|
||||||
|
|
||||||
Optional<Account> existingAccount = accounts.getByE164(number);
|
Optional<Account> existingAccount = accounts.getByE164(number);
|
||||||
|
|
||||||
if (existingAccount.isPresent()) {
|
if (existingAccount.isPresent()) {
|
||||||
@@ -386,7 +381,7 @@ public class AccountController {
|
|||||||
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
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(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(storedVerificationCode.get().getTwilioVerificationSid().isPresent()))))
|
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number))))
|
||||||
.increment();
|
.increment();
|
||||||
|
|
||||||
return new AccountIdentityResponse(account.getUuid(),
|
return new AccountIdentityResponse(account.getUuid(),
|
||||||
@@ -417,17 +412,15 @@ public class AccountController {
|
|||||||
|
|
||||||
rateLimiters.getVerifyLimiter().validate(number);
|
rateLimiters.getVerifyLimiter().validate(number);
|
||||||
|
|
||||||
final Optional<StoredVerificationCode> storedVerificationCode =
|
final boolean codeVerified = pendingAccounts.getCodeForNumber(number).map(storedVerificationCode ->
|
||||||
pendingAccounts.getCodeForNumber(number);
|
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();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid)
|
|
||||||
.ifPresent(
|
|
||||||
verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "changeNumber"));
|
|
||||||
|
|
||||||
final Optional<Account> existingAccount = accounts.getByE164(number);
|
final Optional<Account> existingAccount = accounts.getByE164(number);
|
||||||
|
|
||||||
if (existingAccount.isPresent()) {
|
if (existingAccount.isPresent()) {
|
||||||
@@ -642,6 +635,52 @@ public class AccountController {
|
|||||||
accounts.clearUsername(auth.getAccount());
|
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
|
@Timed
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/username")
|
@Path("/username")
|
||||||
@@ -652,14 +691,7 @@ public class AccountController {
|
|||||||
@HeaderParam("X-Signal-Agent") String userAgent,
|
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||||
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||||
|
checkUsername(usernameRequest.existingUsername(), userAgent);
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
||||||
@@ -688,15 +720,10 @@ public class AccountController {
|
|||||||
throw new BadRequestException();
|
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);
|
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
|
||||||
|
|
||||||
|
checkUsername(username, userAgent);
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
.getByUsername(username)
|
.getByUsername(username)
|
||||||
.map(Account::getUuid)
|
.map(Account::getUuid)
|
||||||
@@ -760,64 +787,41 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
|
@VisibleForTesting
|
||||||
String sourceHost,
|
static boolean pushChallengeMatches(
|
||||||
Optional<String> captchaToken,
|
final String number,
|
||||||
Optional<StoredVerificationCode> storedVerificationCode,
|
final Optional<String> pushChallenge,
|
||||||
Optional<String> pushChallenge,
|
final Optional<StoredVerificationCode> storedVerificationCode) {
|
||||||
String userAgent)
|
|
||||||
{
|
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)) {
|
if (testDevices.containsKey(number)) {
|
||||||
return new CaptchaRequirement(false, false);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushChallengeMatch) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String countryCode = Util.getCountryCode(number);
|
final String countryCode = Util.getCountryCode(number);
|
||||||
final String region = Util.getRegion(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()
|
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
|
||||||
.getCaptchaConfiguration();
|
.getCaptchaConfiguration();
|
||||||
|
|
||||||
@@ -832,7 +836,7 @@ public class AccountController {
|
|||||||
// would be caught by country filter as well
|
// would be caught by country filter as well
|
||||||
countryFilterApplicable.mark();
|
countryFilterApplicable.mark();
|
||||||
}
|
}
|
||||||
return new CaptchaRequirement(true, false);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -840,7 +844,11 @@ public class AccountController {
|
|||||||
} catch (RateLimitExceededException e) {
|
} catch (RateLimitExceededException e) {
|
||||||
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||||
rateLimitedHostMeter.mark();
|
rateLimitedHostMeter.mark();
|
||||||
return new CaptchaRequirement(true, true);
|
if (shouldAutoBlock(sourceHost)) {
|
||||||
|
logger.info("Auto-block: {}", sourceHost);
|
||||||
|
abusiveHostRules.setBlockedHost(sourceHost);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -848,15 +856,18 @@ public class AccountController {
|
|||||||
} catch (RateLimitExceededException e) {
|
} catch (RateLimitExceededException e) {
|
||||||
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||||
rateLimitedPrefixMeter.mark();
|
rateLimitedPrefixMeter.mark();
|
||||||
return new CaptchaRequirement(true, true);
|
if (shouldAutoBlock(sourceHost)) {
|
||||||
|
logger.info("Auto-block: {}", sourceHost);
|
||||||
|
abusiveHostRules.setBlockedHost(sourceHost);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countryFiltered) {
|
if (countryFiltered) {
|
||||||
countryFilteredHostMeter.mark();
|
countryFilteredHostMeter.mark();
|
||||||
return new CaptchaRequirement(true, false);
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
return new CaptchaRequirement(false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
@@ -866,6 +877,15 @@ public class AccountController {
|
|||||||
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
|
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) {
|
private boolean shouldAutoBlock(String sourceHost) {
|
||||||
try {
|
try {
|
||||||
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
|
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
|
||||||
@@ -876,17 +896,6 @@ public class AccountController {
|
|||||||
return false;
|
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() {
|
private String generatePushChallenge() {
|
||||||
SecureRandom random = new SecureRandom();
|
SecureRandom random = new SecureRandom();
|
||||||
byte[] challenge = new byte[16];
|
byte[] challenge = new byte[16];
|
||||||
@@ -894,22 +903,4 @@ public class AccountController {
|
|||||||
|
|
||||||
return Hex.toStringCondensed(challenge);
|
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 com.codahale.metrics.annotation.Timed;
|
||||||
import io.dropwizard.auth.Auth;
|
import io.dropwizard.auth.Auth;
|
||||||
|
import java.security.SecureRandom;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import javax.ws.rs.GET;
|
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.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
|
|
||||||
@Path("/v2/attachments")
|
@Path("/v2/attachments")
|
||||||
public class AttachmentControllerV2 extends AttachmentControllerBase {
|
public class AttachmentControllerV2 {
|
||||||
|
|
||||||
private final PostPolicyGenerator policyGenerator;
|
private final PostPolicyGenerator policyGenerator;
|
||||||
private final PolicySigner policySigner;
|
private final PolicySigner policySigner;
|
||||||
private final RateLimiter rateLimiter;
|
private final RateLimiter rateLimiter;
|
||||||
|
|
||||||
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
|
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region,
|
||||||
this.rateLimiter = rateLimiters.getAttachmentLimiter();
|
String bucket) {
|
||||||
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
|
this.rateLimiter = rateLimiters.getAttachmentLimiter();
|
||||||
this.policySigner = new PolicySigner(accessSecret, region);
|
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
|
||||||
|
this.policySigner = new PolicySigner(accessSecret, region);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
@@ -54,5 +57,12 @@ public class AttachmentControllerV2 extends AttachmentControllerBase {
|
|||||||
policy.second(), signature);
|
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;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
|
|
||||||
@Path("/v3/attachments")
|
@Path("/v3/attachments")
|
||||||
public class AttachmentControllerV3 extends AttachmentControllerBase {
|
public class AttachmentControllerV3 {
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
private final RateLimiter rateLimiter;
|
private final RateLimiter rateLimiter;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
|
import javax.ws.rs.DefaultValue;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.Path;
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.PathParam;
|
import javax.ws.rs.PathParam;
|
||||||
@@ -66,14 +67,13 @@ public class CertificateController {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Path("/delivery")
|
@Path("/delivery")
|
||||||
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedAccount auth,
|
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedAccount auth,
|
||||||
@QueryParam("includeE164") Optional<Boolean> maybeIncludeE164)
|
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
|
||||||
throws InvalidKeyException {
|
throws InvalidKeyException {
|
||||||
|
|
||||||
if (Util.isEmpty(auth.getAccount().getIdentityKey())) {
|
if (Util.isEmpty(auth.getAccount().getIdentityKey())) {
|
||||||
throw new WebApplicationException(Response.Status.BAD_REQUEST);
|
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))
|
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
|
||||||
.increment();
|
.increment();
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,9 @@ public class DeviceController {
|
|||||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
VerificationCode verificationCode = generateVerificationCode();
|
VerificationCode verificationCode = generateVerificationCode();
|
||||||
StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(),
|
StoredVerificationCode storedVerificationCode =
|
||||||
System.currentTimeMillis(),
|
new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null, null);
|
||||||
null,
|
|
||||||
null);
|
|
||||||
|
|
||||||
pendingDevices.store(account.getNumber(), storedVerificationCode);
|
pendingDevices.store(account.getNumber(), storedVerificationCode);
|
||||||
|
|
||||||
@@ -240,8 +238,7 @@ public class DeviceController {
|
|||||||
private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities, String userAgent) {
|
private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities, String userAgent) {
|
||||||
boolean isDowngrade = false;
|
boolean isDowngrade = false;
|
||||||
|
|
||||||
// TODO stories capability
|
isDowngrade |= account.isStoriesSupported() && !capabilities.isStories();
|
||||||
// isDowngrade |= account.isStoriesSupported() && !capabilities.isStories();
|
|
||||||
isDowngrade |= account.isPniSupported() && !capabilities.isPni();
|
isDowngrade |= account.isPniSupported() && !capabilities.isPni();
|
||||||
isDowngrade |= account.isChangeNumberSupported() && !capabilities.isChangeNumber();
|
isDowngrade |= account.isChangeNumberSupported() && !capabilities.isChangeNumber();
|
||||||
isDowngrade |= account.isAnnouncementGroupSupported() && !capabilities.isAnnouncementGroup();
|
isDowngrade |= account.isAnnouncementGroupSupported() && !capabilities.isAnnouncementGroup();
|
||||||
|
|||||||
@@ -6,30 +6,13 @@
|
|||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
import com.codahale.metrics.annotation.Timed;
|
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.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.Clock;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionStage;
|
import java.util.concurrent.CompletionStage;
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.concurrent.ForkJoinPool;
|
import java.util.concurrent.ForkJoinPool;
|
||||||
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
|
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
@@ -52,18 +35,11 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
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.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.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
|
||||||
|
|
||||||
@Path("/v1/donation")
|
@Path("/v1/donation")
|
||||||
public class DonationController {
|
public class DonationController {
|
||||||
@@ -80,11 +56,6 @@ public class DonationController {
|
|||||||
private final AccountsManager accountsManager;
|
private final AccountsManager accountsManager;
|
||||||
private final BadgesConfiguration badgesConfiguration;
|
private final BadgesConfiguration badgesConfiguration;
|
||||||
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
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(
|
public DonationController(
|
||||||
@Nonnull final Clock clock,
|
@Nonnull final Clock clock,
|
||||||
@@ -92,30 +63,13 @@ public class DonationController {
|
|||||||
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
|
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||||
@Nonnull final AccountsManager accountsManager,
|
@Nonnull final AccountsManager accountsManager,
|
||||||
@Nonnull final BadgesConfiguration badgesConfiguration,
|
@Nonnull final BadgesConfiguration badgesConfiguration,
|
||||||
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
|
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
|
||||||
@Nonnull final Executor httpClientExecutor,
|
|
||||||
@Nonnull final DonationConfiguration configuration,
|
|
||||||
@Nonnull final StripeConfiguration stripeConfiguration) {
|
|
||||||
this.clock = Objects.requireNonNull(clock);
|
this.clock = Objects.requireNonNull(clock);
|
||||||
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
|
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
|
||||||
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
|
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
|
||||||
this.accountsManager = Objects.requireNonNull(accountsManager);
|
this.accountsManager = Objects.requireNonNull(accountsManager);
|
||||||
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
|
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
|
||||||
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
|
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
|
@Timed
|
||||||
@@ -188,55 +142,4 @@ public class DonationController {
|
|||||||
}).thenCompose(Function.identity());
|
}).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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
@@ -22,6 +22,7 @@ import java.util.Base64;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -29,16 +30,20 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.DELETE;
|
import javax.ws.rs.DELETE;
|
||||||
|
import javax.ws.rs.DefaultValue;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.HeaderParam;
|
import javax.ws.rs.HeaderParam;
|
||||||
import javax.ws.rs.POST;
|
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.SendMessageResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
|
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
|
||||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeException;
|
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
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.UnrecognizedUserAgentException;
|
||||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||||
|
import org.whispersystems.websocket.Stories;
|
||||||
|
|
||||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
@Path("/v1/messages")
|
@Path("/v1/messages")
|
||||||
@@ -162,10 +167,11 @@ public class MessageController {
|
|||||||
@HeaderParam("User-Agent") String userAgent,
|
@HeaderParam("User-Agent") String userAgent,
|
||||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||||
@PathParam("destination") UUID destinationUuid,
|
@PathParam("destination") UUID destinationUuid,
|
||||||
|
@QueryParam("story") boolean isStory,
|
||||||
@NotNull @Valid IncomingMessageList messages)
|
@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);
|
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +211,30 @@ public class MessageController {
|
|||||||
destination = source.map(AuthenticatedAccount::getAccount);
|
destination = source.map(AuthenticatedAccount::getAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
|
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||||
assert (destination.isPresent());
|
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) {
|
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;
|
final Set<Long> excludedDeviceIds;
|
||||||
@@ -239,12 +264,11 @@ public class MessageController {
|
|||||||
|
|
||||||
if (destinationDevice.isPresent()) {
|
if (destinationDevice.isPresent()) {
|
||||||
Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment();
|
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(
|
return Response.ok(new SendMessageResponse(needsSync)).build();
|
||||||
!isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1)).build();
|
|
||||||
} catch (NoSuchUserException e) {
|
} catch (NoSuchUserException e) {
|
||||||
throw new WebApplicationException(Response.status(404).build());
|
throw new WebApplicationException(Response.status(404).build());
|
||||||
} catch (MismatchedDevicesException e) {
|
} 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
|
@Timed
|
||||||
@Path("/multi_recipient")
|
@Path("/multi_recipient")
|
||||||
@PUT
|
@PUT
|
||||||
@@ -268,43 +321,63 @@ public class MessageController {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@FilterAbusiveMessages
|
@FilterAbusiveMessages
|
||||||
public Response sendMultiRecipientMessage(
|
public Response sendMultiRecipientMessage(
|
||||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) CombinedUnidentifiedSenderAccessKeys accessKeys,
|
@HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||||
@HeaderParam("User-Agent") String userAgent,
|
@HeaderParam("User-Agent") String userAgent,
|
||||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||||
@QueryParam("online") boolean online,
|
@QueryParam("online") boolean online,
|
||||||
@QueryParam("ts") long timestamp,
|
@QueryParam("ts") long timestamp,
|
||||||
|
@QueryParam("urgent") @DefaultValue("true") final boolean isUrgent,
|
||||||
|
@QueryParam("story") boolean isStory,
|
||||||
@NotNull @Valid MultiRecipientMessage multiRecipientMessage) {
|
@NotNull @Valid MultiRecipientMessage multiRecipientMessage) {
|
||||||
|
|
||||||
Map<UUID, Account> uuidToAccountMap = Arrays.stream(multiRecipientMessage.getRecipients())
|
// we skip "missing" accounts when story=true.
|
||||||
.map(Recipient::getUuid)
|
// otherwise, we return a 404 status code.
|
||||||
.distinct()
|
final Function<UUID, Stream<Account>> accountFinder = uuid -> {
|
||||||
.collect(Collectors.toUnmodifiableMap(Function.identity(), uuid -> {
|
Optional<Account> res = accountsManager.getByAccountIdentifier(uuid);
|
||||||
Optional<Account> account = accountsManager.getByAccountIdentifier(uuid);
|
if (!isStory && res.isEmpty()) {
|
||||||
if (account.isEmpty()) {
|
throw new WebApplicationException(Status.NOT_FOUND);
|
||||||
throw new WebApplicationException(Status.NOT_FOUND);
|
}
|
||||||
}
|
return res.stream();
|
||||||
return account.get();
|
};
|
||||||
}));
|
|
||||||
checkAccessKeys(accessKeys, uuidToAccountMap);
|
|
||||||
|
|
||||||
final Map<Account, HashSet<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
|
// build a map from UUID to accounts
|
||||||
Arrays
|
Map<UUID, Account> uuidToAccountMap =
|
||||||
.stream(multiRecipientMessage.getRecipients())
|
Arrays.stream(multiRecipientMessage.getRecipients())
|
||||||
.collect(Collectors.toMap(
|
.map(Recipient::getUuid)
|
||||||
recipient -> uuidToAccountMap.get(recipient.getUuid()),
|
.distinct()
|
||||||
recipient -> new HashSet<>(
|
.flatMap(accountFinder)
|
||||||
Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))),
|
.collect(Collectors.toUnmodifiableMap(
|
||||||
(a, b) -> {
|
Account::getUuid,
|
||||||
a.addAll(b);
|
Function.identity()));
|
||||||
return a;
|
|
||||||
}
|
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||||
));
|
if (!isStory) {
|
||||||
|
checkAccessKeys(accessKeys, uuidToAccountMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Account, Set<Pair<Long, Integer>>> accountToDeviceIdAndRegistrationIdMap =
|
||||||
|
buildDeviceIdAndRegistrationIdMap(multiRecipientMessage, uuidToAccountMap);
|
||||||
|
|
||||||
|
// We might filter out all the recipients of a story (if none have enabled stories).
|
||||||
|
// In this case there is no error so we should just return 200 now.
|
||||||
|
if (isStory && accountToDeviceIdAndRegistrationIdMap.isEmpty()) {
|
||||||
|
return Response.ok(new SendMultiRecipientMessageResponse(new LinkedList<>())).build();
|
||||||
|
}
|
||||||
|
|
||||||
Collection<AccountMismatchedDevices> accountMismatchedDevices = new ArrayList<>();
|
Collection<AccountMismatchedDevices> accountMismatchedDevices = new ArrayList<>();
|
||||||
Collection<AccountStaleDevices> accountStaleDevices = new ArrayList<>();
|
Collection<AccountStaleDevices> accountStaleDevices = new ArrayList<>();
|
||||||
uuidToAccountMap.values().forEach(account -> {
|
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 {
|
try {
|
||||||
DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, Collections.emptySet());
|
DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, Collections.emptySet());
|
||||||
|
|
||||||
@@ -352,8 +425,8 @@ public class MessageController {
|
|||||||
Device destinationDevice = destinationAccount.getDevice(recipient.getDeviceId()).orElseThrow();
|
Device destinationDevice = destinationAccount.getDevice(recipient.getDeviceId()).orElseThrow();
|
||||||
sentMessageCounter.increment();
|
sentMessageCounter.increment();
|
||||||
try {
|
try {
|
||||||
sendMessage(destinationAccount, destinationDevice, timestamp, online, recipient,
|
sendCommonPayloadMessage(destinationAccount, destinationDevice, timestamp, online, isStory, isUrgent,
|
||||||
multiRecipientMessage.getCommonPayload());
|
recipient, multiRecipientMessage.getCommonPayload());
|
||||||
} catch (NoSuchUserException e) {
|
} catch (NoSuchUserException e) {
|
||||||
uuids404.add(destinationAccount.getUuid());
|
uuids404.add(destinationAccount.getUuid());
|
||||||
}
|
}
|
||||||
@@ -368,6 +441,10 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void checkAccessKeys(CombinedUnidentifiedSenderAccessKeys accessKeys, Map<UUID, Account> uuidToAccountMap) {
|
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);
|
AtomicBoolean throwUnauthorized = new AtomicBoolean(false);
|
||||||
byte[] empty = new byte[16];
|
byte[] empty = new byte[16];
|
||||||
final Optional<byte[]> UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY = Optional.of(new byte[16]);
|
final Optional<byte[]> UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY = Optional.of(new byte[16]);
|
||||||
@@ -406,8 +483,11 @@ public class MessageController {
|
|||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public OutgoingMessageEntityList getPendingMessages(@Auth AuthenticatedAccount auth,
|
public OutgoingMessageEntityList getPendingMessages(@Auth AuthenticatedAccount auth,
|
||||||
|
@HeaderParam(Stories.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
|
||||||
@HeaderParam("User-Agent") String userAgent) {
|
@HeaderParam("User-Agent") String userAgent) {
|
||||||
|
|
||||||
|
boolean shouldReceiveStories = Stories.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||||
|
|
||||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
||||||
|
|
||||||
final OutgoingMessageEntityList outgoingMessages;
|
final OutgoingMessageEntityList outgoingMessages;
|
||||||
@@ -417,7 +497,12 @@ public class MessageController {
|
|||||||
auth.getAuthenticatedDevice().getId(),
|
auth.getAuthenticatedDevice().getId(),
|
||||||
false);
|
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)
|
.map(OutgoingMessageEntity::fromEnvelope)
|
||||||
.peek(outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
|
.peek(outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
|
||||||
outgoingMessageEntity))
|
outgoingMessageEntity))
|
||||||
@@ -454,25 +539,29 @@ public class MessageController {
|
|||||||
@Timed
|
@Timed
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/uuid/{uuid}")
|
@Path("/uuid/{uuid}")
|
||||||
public void removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
public CompletableFuture<Void> removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) {
|
||||||
messagesManager.delete(
|
return messagesManager.delete(
|
||||||
auth.getAccount().getUuid(),
|
auth.getAccount().getUuid(),
|
||||||
auth.getAuthenticatedDevice().getId(),
|
auth.getAuthenticatedDevice().getId(),
|
||||||
uuid,
|
uuid,
|
||||||
null).ifPresent(deletedMessage -> {
|
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) {
|
if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||||
try {
|
try {
|
||||||
receiptSender.sendReceipt(
|
receiptSender.sendReceipt(
|
||||||
UUID.fromString(deletedMessage.getDestinationUuid()), auth.getAuthenticatedDevice().getId(),
|
UUID.fromString(deletedMessage.getDestinationUuid()), auth.getAuthenticatedDevice().getId(),
|
||||||
UUID.fromString(deletedMessage.getSourceUuid()), deletedMessage.getTimestamp());
|
UUID.fromString(deletedMessage.getSourceUuid()), deletedMessage.getTimestamp());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("Failed to send delivery receipt", e);
|
logger.warn("Failed to send delivery receipt", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
@@ -515,12 +604,13 @@ public class MessageController {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendMessage(Optional<AuthenticatedAccount> source,
|
private void sendIndividualMessage(Optional<AuthenticatedAccount> source,
|
||||||
Account destinationAccount,
|
Account destinationAccount,
|
||||||
Device destinationDevice,
|
Device destinationDevice,
|
||||||
UUID destinationUuid,
|
UUID destinationUuid,
|
||||||
long timestamp,
|
long timestamp,
|
||||||
boolean online,
|
boolean online,
|
||||||
|
boolean story,
|
||||||
boolean urgent,
|
boolean urgent,
|
||||||
IncomingMessage incomingMessage,
|
IncomingMessage incomingMessage,
|
||||||
String userAgentString)
|
String userAgentString)
|
||||||
@@ -533,6 +623,7 @@ public class MessageController {
|
|||||||
source.map(AuthenticatedAccount::getAccount).orElse(null),
|
source.map(AuthenticatedAccount::getAccount).orElse(null),
|
||||||
source.map(authenticatedAccount -> authenticatedAccount.getAuthenticatedDevice().getId()).orElse(null),
|
source.map(authenticatedAccount -> authenticatedAccount.getAuthenticatedDevice().getId()).orElse(null),
|
||||||
timestamp == 0 ? System.currentTimeMillis() : timestamp,
|
timestamp == 0 ? System.currentTimeMillis() : timestamp,
|
||||||
|
story,
|
||||||
urgent);
|
urgent);
|
||||||
} catch (final IllegalArgumentException e) {
|
} catch (final IllegalArgumentException e) {
|
||||||
logger.warn("Received bad envelope type {} from {}", incomingMessage.type(), userAgentString);
|
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,
|
Device destinationDevice,
|
||||||
long timestamp,
|
long timestamp,
|
||||||
boolean online,
|
boolean online,
|
||||||
|
boolean story,
|
||||||
|
boolean urgent,
|
||||||
Recipient recipient,
|
Recipient recipient,
|
||||||
byte[] commonPayload) throws NoSuchUserException {
|
byte[] commonPayload) throws NoSuchUserException {
|
||||||
try {
|
try {
|
||||||
@@ -567,6 +660,8 @@ public class MessageController {
|
|||||||
.setTimestamp(timestamp == 0 ? serverTimestamp : timestamp)
|
.setTimestamp(timestamp == 0 ? serverTimestamp : timestamp)
|
||||||
.setServerTimestamp(serverTimestamp)
|
.setServerTimestamp(serverTimestamp)
|
||||||
.setContent(ByteString.copyFrom(payload))
|
.setContent(ByteString.copyFrom(payload))
|
||||||
|
.setStory(story)
|
||||||
|
.setUrgent(urgent)
|
||||||
.setDestinationUuid(destinationAccount.getUuid().toString());
|
.setDestinationUuid(destinationAccount.getUuid().toString());
|
||||||
|
|
||||||
messageSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online);
|
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 {
|
throws RateLimitExceededException {
|
||||||
final String senderCountryCode = Util.getCountryCode(source.getAccount().getNumber());
|
final String senderCountryCode = Util.getCountryCode(source.getAccount().getNumber());
|
||||||
|
|
||||||
|
|||||||
@@ -387,10 +387,32 @@ public class ProfileController {
|
|||||||
|
|
||||||
private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,
|
private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,
|
||||||
Collection<BatchIdentityCheckResponse.Element> responseElements, MessageDigest md) {
|
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;
|
byte[] identityKeyBytes;
|
||||||
try {
|
try {
|
||||||
identityKeyBytes = Base64.getDecoder().decode(account.getIdentityKey());
|
identityKeyBytes = Base64.getDecoder().decode(usePhoneNumberIdentity ? account.getPhoneNumberIdentityKey()
|
||||||
|
: account.getIdentityKey());
|
||||||
} catch (IllegalArgumentException ignored) {
|
} catch (IllegalArgumentException ignored) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -399,7 +421,7 @@ public class ProfileController {
|
|||||||
byte[] fingerprint = Util.truncate(digest, 4);
|
byte[] fingerprint = Util.truncate(digest, 4);
|
||||||
|
|
||||||
if (!Arrays.equals(fingerprint, element.fingerprint())) {
|
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.security.NoSuchAlgorithmException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -31,6 +32,9 @@ import javax.ws.rs.Produces;
|
|||||||
import javax.ws.rs.WebApplicationException;
|
import javax.ws.rs.WebApplicationException;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import javax.ws.rs.core.Response;
|
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.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
|
import org.whispersystems.textsecuregcm.entities.UserRemoteConfig;
|
||||||
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||||
@@ -42,13 +46,15 @@ import org.whispersystems.textsecuregcm.util.Conversions;
|
|||||||
public class RemoteConfigController {
|
public class RemoteConfigController {
|
||||||
|
|
||||||
private final RemoteConfigsManager remoteConfigsManager;
|
private final RemoteConfigsManager remoteConfigsManager;
|
||||||
private final List<String> configAuthTokens;
|
private final AdminEventLogger adminEventLogger;
|
||||||
private final Map<String, String> globalConfig;
|
private final List<String> configAuthTokens;
|
||||||
|
private final Map<String, String> globalConfig;
|
||||||
|
|
||||||
private static final String GLOBAL_CONFIG_PREFIX = "global.";
|
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.remoteConfigsManager = remoteConfigsManager;
|
||||||
|
this.adminEventLogger = Objects.requireNonNull(adminEventLogger);
|
||||||
this.configAuthTokens = configAuthTokens;
|
this.configAuthTokens = configAuthTokens;
|
||||||
this.globalConfig = globalConfig;
|
this.globalConfig = globalConfig;
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,15 @@ public class RemoteConfigController {
|
|||||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
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);
|
remoteConfigsManager.set(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +118,7 @@ public class RemoteConfigController {
|
|||||||
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
throw new WebApplicationException(Response.Status.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name));
|
||||||
remoteConfigsManager.delete(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;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.google.common.base.Strings;
|
|
||||||
import com.stripe.model.Charge;
|
import com.stripe.model.Charge;
|
||||||
import com.stripe.model.Charge.Outcome;
|
import com.stripe.model.Charge.Outcome;
|
||||||
import com.stripe.model.Invoice;
|
import com.stripe.model.Invoice;
|
||||||
@@ -46,8 +45,10 @@ import javax.validation.constraints.Min;
|
|||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
|
import javax.ws.rs.ClientErrorException;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.DELETE;
|
import javax.ws.rs.DELETE;
|
||||||
|
import javax.ws.rs.DefaultValue;
|
||||||
import javax.ws.rs.ForbiddenException;
|
import javax.ws.rs.ForbiddenException;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.InternalServerErrorException;
|
import javax.ws.rs.InternalServerErrorException;
|
||||||
@@ -58,6 +59,7 @@ import javax.ws.rs.Path;
|
|||||||
import javax.ws.rs.PathParam;
|
import javax.ws.rs.PathParam;
|
||||||
import javax.ws.rs.ProcessingException;
|
import javax.ws.rs.ProcessingException;
|
||||||
import javax.ws.rs.Produces;
|
import javax.ws.rs.Produces;
|
||||||
|
import javax.ws.rs.QueryParam;
|
||||||
import javax.ws.rs.WebApplicationException;
|
import javax.ws.rs.WebApplicationException;
|
||||||
import javax.ws.rs.container.ContainerRequestContext;
|
import javax.ws.rs.container.ContainerRequestContext;
|
||||||
import javax.ws.rs.core.Context;
|
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.IssuedReceiptsManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
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;
|
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||||
|
|
||||||
@Path("/v1/subscription")
|
@Path("/v1/subscription")
|
||||||
@@ -144,21 +150,22 @@ public class SubscriptionController {
|
|||||||
if (getResult == GetResult.NOT_STORED || getResult == GetResult.PASSWORD_MISMATCH) {
|
if (getResult == GetResult.NOT_STORED || getResult == GetResult.PASSWORD_MISMATCH) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
String customerId = getResult.record.customerId;
|
return getResult.record.getProcessorCustomer()
|
||||||
if (Strings.isNullOrEmpty(customerId)) {
|
.map(processorCustomer -> stripeManager.getCustomer(processorCustomer.customerId())
|
||||||
throw new InternalServerErrorException("no customer id found");
|
.thenCompose(customer -> {
|
||||||
}
|
if (customer == null) {
|
||||||
return stripeManager.getCustomer(customerId).thenCompose(customer -> {
|
throw new InternalServerErrorException(
|
||||||
if (customer == null) {
|
"no customer record found for id " + processorCustomer.customerId());
|
||||||
throw new InternalServerErrorException("no customer record found for id " + customerId);
|
}
|
||||||
}
|
return stripeManager.listNonCanceledSubscriptions(customer);
|
||||||
return stripeManager.listNonCanceledSubscriptions(customer);
|
}).thenCompose(subscriptions -> {
|
||||||
}).thenCompose(subscriptions -> {
|
@SuppressWarnings("unchecked")
|
||||||
@SuppressWarnings("unchecked")
|
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
||||||
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
||||||
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
return CompletableFuture.allOf(futures);
|
||||||
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))
|
.thenCompose(unused -> subscriptionManager.canceledAt(requestData.subscriberUser, requestData.now))
|
||||||
.thenApply(unused -> Response.ok().build());
|
.thenApply(unused -> Response.ok().build());
|
||||||
@@ -179,15 +186,13 @@ public class SubscriptionController {
|
|||||||
throw new ForbiddenException("subscriberId mismatch");
|
throw new ForbiddenException("subscriberId mismatch");
|
||||||
} else if (getResult == GetResult.NOT_STORED) {
|
} else if (getResult == GetResult.NOT_STORED) {
|
||||||
// create a customer and write it to ddb
|
// create a customer and write it to ddb
|
||||||
return stripeManager.createCustomer(requestData.subscriberUser).thenCompose(
|
return subscriptionManager.create(requestData.subscriberUser, requestData.hmac, requestData.now)
|
||||||
customer -> subscriptionManager.create(
|
.thenApply(updatedRecord -> {
|
||||||
requestData.subscriberUser, requestData.hmac, customer.getId(), requestData.now)
|
if (updatedRecord == null) {
|
||||||
.thenApply(updatedRecord -> {
|
throw new ForbiddenException();
|
||||||
if (updatedRecord == null) {
|
}
|
||||||
throw new NotFoundException();
|
return updatedRecord;
|
||||||
}
|
});
|
||||||
return updatedRecord;
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
// already exists so just touch access time and return
|
// already exists so just touch access time and return
|
||||||
return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now)
|
return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now)
|
||||||
@@ -197,20 +202,8 @@ public class SubscriptionController {
|
|||||||
.thenApply(record -> Response.ok().build());
|
.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
|
@Timed
|
||||||
@@ -220,12 +213,42 @@ public class SubscriptionController {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public CompletableFuture<Response> createPaymentMethod(
|
public CompletableFuture<Response> createPaymentMethod(
|
||||||
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
@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);
|
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||||
|
|
||||||
|
final SubscriptionProcessorManager subscriptionProcessorManager = getManagerForPaymentMethod(paymentMethodType);
|
||||||
|
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
.thenCompose(record -> stripeManager.createSetupIntent(record.customerId))
|
.thenCompose(record -> {
|
||||||
.thenApply(setupIntent -> Response.ok(new CreatePaymentMethodResponse(setupIntent.getClientSecret())).build());
|
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
|
@Timed
|
||||||
@@ -240,10 +263,15 @@ public class SubscriptionController {
|
|||||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.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());
|
.thenApply(customer -> Response.ok().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SetSubscriptionLevelSuccessResponse {
|
public static class SetSubscriptionLevelSuccessResponse {
|
||||||
|
|
||||||
private final long level;
|
private final long level;
|
||||||
@@ -337,15 +365,22 @@ public class SubscriptionController {
|
|||||||
if (record.subscriptionId == null) {
|
if (record.subscriptionId == null) {
|
||||||
long lastSubscriptionCreatedAt =
|
long lastSubscriptionCreatedAt =
|
||||||
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
|
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
|
||||||
// we don't have one yet so create it and then record the subscription id
|
|
||||||
//
|
return record.getProcessorCustomer()
|
||||||
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
.map(processorCustomer ->
|
||||||
// retries this request
|
// we don't have a subscription yet so create it and then record the subscription id
|
||||||
return stripeManager.createSubscription(record.customerId, priceConfiguration.getId(), level,
|
//
|
||||||
lastSubscriptionCreatedAt)
|
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
||||||
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
|
// retries this request
|
||||||
requestData.subscriberUser, subscription.getId(), requestData.now, level)
|
stripeManager.createSubscription(processorCustomer.customerId(), priceConfiguration.getId(), level,
|
||||||
.thenApply(unused -> subscription));
|
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 {
|
} else {
|
||||||
// we already have a subscription in our records so let's check the level and change it if needed
|
// 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(
|
return stripeManager.getSubscription(record.subscriptionId).thenCompose(
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class AccountMismatchedDevices {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
public final MismatchedDevices devices;
|
public final MismatchedDevices devices;
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return "AccountMismatchedDevices(" + uuid + ", " + devices + ")";
|
||||||
|
}
|
||||||
|
|
||||||
public AccountMismatchedDevices(final UUID uuid, final MismatchedDevices devices) {
|
public AccountMismatchedDevices(final UUID uuid, final MismatchedDevices devices) {
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.devices = devices;
|
this.devices = devices;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class AccountStaleDevices {
|
|||||||
@JsonProperty
|
@JsonProperty
|
||||||
public final StaleDevices devices;
|
public final StaleDevices devices;
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return "AccountStaleDevices(" + uuid + ", " + devices + ")";
|
||||||
|
}
|
||||||
|
|
||||||
public AccountStaleDevices(final UUID uuid, final StaleDevices devices) {
|
public AccountStaleDevices(final UUID uuid, final StaleDevices devices) {
|
||||||
this.uuid = uuid;
|
this.uuid = uuid;
|
||||||
this.devices = devices;
|
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.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.validation.constraints.Size;
|
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) {
|
public record BatchIdentityCheckRequest(@Valid @NotNull @Size(max = 1000) List<Element> elements) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param aci account id
|
* @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
|
* @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public
|
||||||
* public key prefixed with 0x05)
|
* 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;
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||||
|
|
||||||
public record BatchIdentityCheckResponse(@Valid List<Element> elements) {
|
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 Account sourceAccount,
|
||||||
@Nullable Long sourceDeviceId,
|
@Nullable Long sourceDeviceId,
|
||||||
final long timestamp,
|
final long timestamp,
|
||||||
|
final boolean story,
|
||||||
final boolean urgent) {
|
final boolean urgent) {
|
||||||
|
|
||||||
final MessageProtos.Envelope.Type envelopeType = MessageProtos.Envelope.Type.forNumber(type());
|
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)
|
.setTimestamp(timestamp)
|
||||||
.setServerTimestamp(System.currentTimeMillis())
|
.setServerTimestamp(System.currentTimeMillis())
|
||||||
.setDestinationUuid(destinationUuid.toString())
|
.setDestinationUuid(destinationUuid.toString())
|
||||||
|
.setStory(story)
|
||||||
.setUrgent(urgent);
|
.setUrgent(urgent);
|
||||||
|
|
||||||
if (sourceAccount != null && sourceDeviceId != null) {
|
if (sourceAccount != null && sourceDeviceId != null) {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ public class MismatchedDevices {
|
|||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
public MismatchedDevices() {}
|
public MismatchedDevices() {}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return "MismatchedDevices(" + missingDevices + ", " + extraDevices + ")";
|
||||||
|
}
|
||||||
|
|
||||||
public MismatchedDevices(List<Long> missingDevices, List<Long> extraDevices) {
|
public MismatchedDevices(List<Long> missingDevices, List<Long> extraDevices) {
|
||||||
this.missingDevices = missingDevices;
|
this.missingDevices = missingDevices;
|
||||||
this.extraDevices = extraDevices;
|
this.extraDevices = extraDevices;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
package org.whispersystems.textsecuregcm.entities;
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.Max;
|
import javax.validation.constraints.Max;
|
||||||
@@ -53,6 +54,37 @@ public class MultiRecipientMessage {
|
|||||||
public byte[] getPerRecipientKeyMaterial() {
|
public byte[] getPerRecipientKeyMaterial() {
|
||||||
return perRecipientKeyMaterial;
|
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
|
@NotNull
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import javax.annotation.Nullable;
|
|||||||
|
|
||||||
public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullable UUID sourceUuid, int sourceDevice,
|
public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullable UUID sourceUuid, int sourceDevice,
|
||||||
UUID destinationUuid, @Nullable UUID updatedPni, byte[] content,
|
UUID destinationUuid, @Nullable UUID updatedPni, byte[] content,
|
||||||
long serverTimestamp, boolean urgent) {
|
long serverTimestamp, boolean urgent, boolean story) {
|
||||||
|
|
||||||
public MessageProtos.Envelope toEnvelope() {
|
public MessageProtos.Envelope toEnvelope() {
|
||||||
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
|
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
|
||||||
@@ -22,6 +22,7 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
|
|||||||
.setServerTimestamp(serverTimestamp())
|
.setServerTimestamp(serverTimestamp())
|
||||||
.setDestinationUuid(destinationUuid().toString())
|
.setDestinationUuid(destinationUuid().toString())
|
||||||
.setServerGuid(guid().toString())
|
.setServerGuid(guid().toString())
|
||||||
|
.setStory(story)
|
||||||
.setUrgent(urgent);
|
.setUrgent(urgent);
|
||||||
|
|
||||||
if (sourceUuid() != null) {
|
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.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null,
|
||||||
envelope.getContent().toByteArray(),
|
envelope.getContent().toByteArray(),
|
||||||
envelope.getServerTimestamp(),
|
envelope.getServerTimestamp(),
|
||||||
envelope.getUrgent());
|
envelope.getUrgent(),
|
||||||
|
envelope.getStory());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -63,16 +65,23 @@ public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullab
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final OutgoingMessageEntity that = (OutgoingMessageEntity) o;
|
final OutgoingMessageEntity that = (OutgoingMessageEntity) o;
|
||||||
return type == that.type && timestamp == that.timestamp && sourceDevice == that.sourceDevice
|
return guid.equals(that.guid) &&
|
||||||
&& serverTimestamp == that.serverTimestamp && guid.equals(that.guid)
|
type == that.type &&
|
||||||
&& Objects.equals(sourceUuid, that.sourceUuid) && destinationUuid.equals(that.destinationUuid)
|
timestamp == that.timestamp &&
|
||||||
&& Objects.equals(updatedPni, that.updatedPni) && Arrays.equals(content, that.content) && urgent == that.urgent;
|
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
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
int result = Objects.hash(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni,
|
int result = Objects.hash(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni,
|
||||||
serverTimestamp, urgent);
|
serverTimestamp, urgent, story);
|
||||||
result = 31 * result + Arrays.hashCode(content);
|
result = 31 * result + Arrays.hashCode(content);
|
||||||
return result;
|
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;
|
package org.whispersystems.textsecuregcm.entities;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -16,6 +17,15 @@ public class SendMultiRecipientMessageResponse {
|
|||||||
public SendMultiRecipientMessageResponse() {
|
public SendMultiRecipientMessageResponse() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return "SendMultiRecipientMessageResponse(" + uuids404 + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public List<UUID> getUUIDs404() {
|
||||||
|
return this.uuids404;
|
||||||
|
}
|
||||||
|
|
||||||
public SendMultiRecipientMessageResponse(final List<UUID> uuids404) {
|
public SendMultiRecipientMessageResponse(final List<UUID> uuids404) {
|
||||||
this.uuids404 = uuids404;
|
this.uuids404 = uuids404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public class StaleDevices {
|
|||||||
|
|
||||||
public StaleDevices() {}
|
public StaleDevices() {}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
return "StaleDevices(" + staleDevices + ")";
|
||||||
|
}
|
||||||
|
|
||||||
public StaleDevices(List<Long> staleDevices) {
|
public StaleDevices(List<Long> staleDevices) {
|
||||||
this.staleDevices = staleDevices;
|
this.staleDevices = staleDevices;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public class FaultTolerantHttpClient {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Builder withTrustedServerCertificate(final String certificatePem) throws CertificateException {
|
public Builder withTrustedServerCertificates(final String... certificatePem) throws CertificateException {
|
||||||
this.trustStore = CertificateUtil.buildKeyStoreForPem(certificatePem);
|
this.trustStore = CertificateUtil.buildKeyStoreForPem(certificatePem);
|
||||||
return this;
|
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());
|
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(
|
final Tags tags = Tags.of(
|
||||||
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),
|
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ public class RateLimiters {
|
|||||||
private final RateLimiter usernameLookupLimiter;
|
private final RateLimiter usernameLookupLimiter;
|
||||||
private final RateLimiter usernameSetLimiter;
|
private final RateLimiter usernameSetLimiter;
|
||||||
|
|
||||||
|
private final RateLimiter usernameReserveLimiter;
|
||||||
|
|
||||||
private final RateLimiter checkAccountExistenceLimiter;
|
private final RateLimiter checkAccountExistenceLimiter;
|
||||||
|
|
||||||
|
private final RateLimiter storiesLimiter;
|
||||||
|
|
||||||
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
|
public RateLimiters(RateLimitsConfiguration config, FaultTolerantRedisCluster cacheCluster) {
|
||||||
this.smsDestinationLimiter = new RateLimiter(cacheCluster, "smsDestination",
|
this.smsDestinationLimiter = new RateLimiter(cacheCluster, "smsDestination",
|
||||||
config.getSmsDestination().getBucketSize(),
|
config.getSmsDestination().getBucketSize(),
|
||||||
@@ -108,9 +112,18 @@ public class RateLimiters {
|
|||||||
config.getUsernameSet().getBucketSize(),
|
config.getUsernameSet().getBucketSize(),
|
||||||
config.getUsernameSet().getLeakRatePerMinute());
|
config.getUsernameSet().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
this.usernameReserveLimiter = new RateLimiter(cacheCluster, "usernameReserve",
|
||||||
|
config.getUsernameReserve().getBucketSize(),
|
||||||
|
config.getUsernameReserve().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
|
||||||
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
|
this.checkAccountExistenceLimiter = new RateLimiter(cacheCluster, "checkAccountExistence",
|
||||||
config.getCheckAccountExistence().getBucketSize(),
|
config.getCheckAccountExistence().getBucketSize(),
|
||||||
config.getCheckAccountExistence().getLeakRatePerMinute());
|
config.getCheckAccountExistence().getLeakRatePerMinute());
|
||||||
|
|
||||||
|
this.storiesLimiter = new RateLimiter(cacheCluster, "stories",
|
||||||
|
config.getStories().getBucketSize(),
|
||||||
|
config.getStories().getLeakRatePerMinute());
|
||||||
}
|
}
|
||||||
|
|
||||||
public RateLimiter getAllocateDeviceLimiter() {
|
public RateLimiter getAllocateDeviceLimiter() {
|
||||||
@@ -185,7 +198,13 @@ public class RateLimiters {
|
|||||||
return usernameSetLimiter;
|
return usernameSetLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RateLimiter getUsernameReserveLimiter() {
|
||||||
|
return usernameReserveLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
public RateLimiter getCheckAccountExistenceLimiter() {
|
public RateLimiter getCheckAccountExistenceLimiter() {
|
||||||
return checkAccountExistenceLimiter;
|
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
|
* @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;
|
boolean hasMore = true;
|
||||||
int currentOffset = 0;
|
int currentOffset = 0;
|
||||||
int result = 0;
|
long result = 0;
|
||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
if (currentOffset >= 64) {
|
if (currentOffset >= 64) {
|
||||||
throw new BadRequestException("varint is too large");
|
throw new BadRequestException("varint is too large");
|
||||||
@@ -123,7 +124,7 @@ public class MultiRecipientMessageProvider implements MessageBodyReader<MultiRec
|
|||||||
throw new BadRequestException("varint is too large");
|
throw new BadRequestException("varint is too large");
|
||||||
}
|
}
|
||||||
hasMore = (b & 0x80) != 0;
|
hasMore = (b & 0x80) != 0;
|
||||||
result |= (b & 0x7F) << currentOffset;
|
result |= (b & 0x7FL) << currentOffset;
|
||||||
currentOffset += 7;
|
currentOffset += 7;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class MessageSender {
|
|||||||
private static final String EPHEMERAL_TAG_NAME = "ephemeral";
|
private static final String EPHEMERAL_TAG_NAME = "ephemeral";
|
||||||
private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline";
|
private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline";
|
||||||
private static final String URGENT_TAG_NAME = "urgent";
|
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";
|
private static final String SEALED_SENDER_TAG_NAME = "sealedSender";
|
||||||
|
|
||||||
public MessageSender(ClientPresenceManager clientPresenceManager,
|
public MessageSender(ClientPresenceManager clientPresenceManager,
|
||||||
@@ -101,6 +102,7 @@ public class MessageSender {
|
|||||||
EPHEMERAL_TAG_NAME, String.valueOf(online),
|
EPHEMERAL_TAG_NAME, String.valueOf(online),
|
||||||
CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent),
|
CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent),
|
||||||
URGENT_TAG_NAME, String.valueOf(message.getUrgent()),
|
URGENT_TAG_NAME, String.valueOf(message.getUrgent()),
|
||||||
|
STORY_TAG_NAME, String.valueOf(message.getStory()),
|
||||||
SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceUuid()))
|
SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceUuid()))
|
||||||
.increment();
|
.increment();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSetting
|
|||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.recaptchaenterprise.v1.Assessment;
|
import com.google.recaptchaenterprise.v1.Assessment;
|
||||||
import com.google.recaptchaenterprise.v1.Event;
|
import com.google.recaptchaenterprise.v1.Event;
|
||||||
|
import com.google.recaptchaenterprise.v1.RiskAnalysis;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
import io.micrometer.core.instrument.Metrics;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -22,10 +23,13 @@ import java.util.Objects;
|
|||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
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.configuration.dynamic.DynamicConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||||
|
|
||||||
public class RecaptchaClient {
|
public class RecaptchaClient {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class);
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final String SEPARATOR = ".";
|
static final String SEPARATOR = ".";
|
||||||
@@ -33,6 +37,9 @@ public class RecaptchaClient {
|
|||||||
static final String V2_PREFIX = "signal-recaptcha-v2" + RecaptchaClient.SEPARATOR;
|
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 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 String projectPath;
|
||||||
private final RecaptchaEnterpriseServiceClient client;
|
private final RecaptchaEnterpriseServiceClient client;
|
||||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||||
@@ -77,7 +84,29 @@ public class RecaptchaClient {
|
|||||||
return parts;
|
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[] parts = parseInputToken(input);
|
||||||
|
|
||||||
final String sitekey = parts[0];
|
final String sitekey = parts[0];
|
||||||
@@ -101,12 +130,27 @@ public class RecaptchaClient {
|
|||||||
"valid", String.valueOf(assessment.getTokenProperties().getValid()))
|
"valid", String.valueOf(assessment.getTokenProperties().getValid()))
|
||||||
.increment();
|
.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 {
|
} 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;
|
package org.whispersystems.textsecuregcm.redis;
|
||||||
|
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import io.lettuce.core.RedisException;
|
||||||
import io.lettuce.core.RedisNoScriptException;
|
import io.lettuce.core.RedisNoScriptException;
|
||||||
import io.lettuce.core.ScriptOutputType;
|
import io.lettuce.core.ScriptOutputType;
|
||||||
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
|
||||||
@@ -15,9 +16,12 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
public class ClusterLuaScript {
|
public class ClusterLuaScript {
|
||||||
|
|
||||||
@@ -73,11 +77,31 @@ public class ClusterLuaScript {
|
|||||||
execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));
|
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) {
|
public Object executeBinary(final List<byte[]> keys, final List<byte[]> args) {
|
||||||
return redisCluster.withBinaryCluster(connection ->
|
return redisCluster.withBinaryCluster(connection ->
|
||||||
execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));
|
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) {
|
private <T> Object execute(final StatefulRedisClusterConnection<T, T> connection, final T[] keys, final T[] args) {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@@ -90,4 +114,32 @@ public class ClusterLuaScript {
|
|||||||
throw e;
|
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.codahale.metrics.SharedMetricRegistries;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
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.github.resilience4j.retry.Retry;
|
||||||
import io.lettuce.core.ClientOptions.DisconnectedBehavior;
|
import io.lettuce.core.ClientOptions.DisconnectedBehavior;
|
||||||
import io.lettuce.core.RedisCommandTimeoutException;
|
import io.lettuce.core.RedisCommandTimeoutException;
|
||||||
@@ -24,11 +26,13 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
|
import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
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,
|
* 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;
|
private final Retry retry;
|
||||||
|
|
||||||
public FaultTolerantRedisCluster(final String name, final RedisClusterConfiguration clusterConfiguration, final ClientResources clientResources) {
|
public FaultTolerantRedisCluster(final String name, final RedisClusterConfiguration clusterConfiguration, final ClientResources clientResources) {
|
||||||
this(name,
|
this(name,
|
||||||
RedisClusterClient.create(clientResources, clusterConfiguration.getConfigurationUri()),
|
RedisClusterClient.create(clientResources, clusterConfiguration.getConfigurationUri()),
|
||||||
clusterConfiguration.getTimeout(),
|
clusterConfiguration.getTimeout(),
|
||||||
clusterConfiguration.getCircuitBreakerConfiguration(),
|
clusterConfiguration.getCircuitBreakerConfiguration(),
|
||||||
clusterConfiguration.getRetryConfiguration());
|
clusterConfiguration.getRetryConfiguration());
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
FaultTolerantRedisCluster(final String name, final RedisClusterClient clusterClient, final Duration commandTimeout, final CircuitBreakerConfiguration circuitBreakerConfiguration, final RetryConfiguration retryConfiguration) {
|
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 = clusterClient;
|
||||||
this.clusterClient.setDefaultTimeout(commandTimeout);
|
this.clusterClient.setDefaultTimeout(commandTimeout);
|
||||||
this.clusterClient.setOptions(ClusterClientOptions.builder()
|
this.clusterClient.setOptions(ClusterClientOptions.builder()
|
||||||
.disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
|
.disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS)
|
||||||
.validateClusterNodeMembership(false)
|
.validateClusterNodeMembership(false)
|
||||||
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
|
.topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
|
||||||
.enableAllAdaptiveRefreshTriggers()
|
.enableAllAdaptiveRefreshTriggers()
|
||||||
.build())
|
.build())
|
||||||
.build());
|
.publishOnScheduler(true)
|
||||||
|
.build());
|
||||||
|
|
||||||
this.stringConnection = clusterClient.connect();
|
this.stringConnection = clusterClient.connect();
|
||||||
this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE);
|
this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE);
|
||||||
|
|
||||||
this.circuitBreaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig());
|
this.circuitBreaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig());
|
||||||
this.retry = Retry.of(name + "-retry", retryConfiguration.toRetryConfigBuilder().retryOnException(exception -> exception instanceof RedisCommandTimeoutException).build());
|
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), circuitBreaker,
|
||||||
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), retry, FaultTolerantRedisCluster.class);
|
FaultTolerantRedisCluster.class);
|
||||||
|
CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), retry,
|
||||||
|
FaultTolerantRedisCluster.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
void shutdown() {
|
void shutdown() {
|
||||||
stringConnection.close();
|
stringConnection.close();
|
||||||
binaryConnection.close();
|
binaryConnection.close();
|
||||||
|
|
||||||
for (final StatefulRedisClusterPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {
|
for (final StatefulRedisClusterPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {
|
||||||
pubSubConnection.close();
|
pubSubConnection.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient.shutdown();
|
clusterClient.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void useCluster(final Consumer<StatefulRedisClusterConnection<String, String>> consumer) {
|
public void useCluster(final Consumer<StatefulRedisClusterConnection<String, String>> consumer) {
|
||||||
useConnection(stringConnection, consumer);
|
useConnection(stringConnection, consumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T withCluster(final Function<StatefulRedisClusterConnection<String, String>, T> function) {
|
public <T> T withCluster(final Function<StatefulRedisClusterConnection<String, String>, T> function) {
|
||||||
return withConnection(stringConnection, function);
|
return withConnection(stringConnection, function);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void useBinaryCluster(final Consumer<StatefulRedisClusterConnection<byte[], byte[]>> consumer) {
|
public void useBinaryCluster(final Consumer<StatefulRedisClusterConnection<byte[], byte[]>> consumer) {
|
||||||
useConnection(binaryConnection, consumer);
|
useConnection(binaryConnection, consumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T withBinaryCluster(final Function<StatefulRedisClusterConnection<byte[], byte[]>, T> function) {
|
public <T> T withBinaryCluster(final Function<StatefulRedisClusterConnection<byte[], byte[]>, T> function) {
|
||||||
return withConnection(binaryConnection, function);
|
return withConnection(binaryConnection, function);
|
||||||
}
|
}
|
||||||
|
|
||||||
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection, final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
public <T> Publisher<T> withBinaryClusterReactive(
|
||||||
try {
|
final Function<StatefulRedisClusterConnection<byte[], byte[]>, Publisher<T>> function) {
|
||||||
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
return withConnectionReactive(binaryConnection, function);
|
||||||
} catch (final Throwable t) {
|
}
|
||||||
if (t instanceof RedisException) {
|
|
||||||
throw (RedisException) t;
|
|
||||||
} else {
|
|
||||||
throw new RedisException(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection, final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||||
try {
|
final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {
|
||||||
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
try {
|
||||||
} catch (final Throwable t) {
|
circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection)));
|
||||||
if (t instanceof RedisException) {
|
} catch (final Throwable t) {
|
||||||
throw (RedisException) t;
|
if (t instanceof RedisException) {
|
||||||
} else {
|
throw (RedisException) t;
|
||||||
throw new RedisException(t);
|
} else {
|
||||||
}
|
throw new RedisException(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {
|
private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection,
|
||||||
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
|
final Function<StatefulRedisClusterConnection<K, V>, T> function) {
|
||||||
pubSubConnections.add(pubSubConnection);
|
try {
|
||||||
|
return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection)));
|
||||||
return new FaultTolerantPubSubConnection<>(name, pubSubConnection, circuitBreaker, retry);
|
} 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)
|
.withExecutor(executor)
|
||||||
.withName("secure-backup")
|
.withName("secure-backup")
|
||||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2)
|
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2)
|
||||||
.withTrustedServerCertificate(configuration.getBackupCaCertificate())
|
.withTrustedServerCertificates(configuration.getBackupCaCertificates().toArray(new String[0]))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class SecureStorageClient {
|
|||||||
.withExecutor(executor)
|
.withExecutor(executor)
|
||||||
.withName("secure-storage")
|
.withName("secure-storage")
|
||||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
|
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
|
||||||
.withTrustedServerCertificate(configuration.getStorageCaCertificate())
|
.withTrustedServerCertificates(configuration.getStorageCaCertificates().toArray(new String[0]))
|
||||||
.build();
|
.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.ScanRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
|
import software.amazon.awssdk.services.dynamodb.model.WriteRequest;
|
||||||
|
|
||||||
public class AbstractDynamoDbStore {
|
public abstract class AbstractDynamoDbStore {
|
||||||
|
|
||||||
private final DynamoDbClient dynamoDbClient;
|
private final DynamoDbClient dynamoDbClient;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public class Account {
|
|||||||
@Nullable
|
@Nullable
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
@JsonProperty
|
||||||
|
@Nullable
|
||||||
|
private byte[] reservedUsernameHash;
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private List<Device> devices = new ArrayList<>();
|
private List<Device> devices = new ArrayList<>();
|
||||||
|
|
||||||
@@ -133,6 +137,18 @@ public class Account {
|
|||||||
this.username = username;
|
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) {
|
public void addDevice(Device device) {
|
||||||
requireNotStale();
|
requireNotStale();
|
||||||
|
|
||||||
@@ -209,9 +225,7 @@ public class Account {
|
|||||||
|
|
||||||
return devices.stream()
|
return devices.stream()
|
||||||
.filter(Device::isEnabled)
|
.filter(Device::isEnabled)
|
||||||
// TODO stories capability
|
.allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
||||||
// .allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
|
||||||
.anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isGiftBadgesSupported() {
|
public boolean isGiftBadgesSupported() {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import io.micrometer.core.instrument.Metrics;
|
|||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
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.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -70,7 +74,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
static final String ATTR_USERNAME = "N";
|
static final String ATTR_USERNAME = "N";
|
||||||
// unidentified access key; byte[] or null
|
// unidentified access key; byte[] or null
|
||||||
static final String ATTR_UAK = "UAK";
|
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 DynamoDbClient client;
|
||||||
private final DynamoDbAsyncClient asyncClient;
|
private final DynamoDbAsyncClient asyncClient;
|
||||||
|
|
||||||
@@ -81,9 +88,12 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
|
|
||||||
private final int scanPageSize;
|
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 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 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 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 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 UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update"));
|
||||||
private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber"));
|
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);
|
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,
|
DynamoDbClient client, DynamoDbAsyncClient asyncClient,
|
||||||
String accountsTableName, String phoneNumberConstraintTableName,
|
String accountsTableName, String phoneNumberConstraintTableName,
|
||||||
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
|
String phoneNumberIdentifierConstraintTableName, final String usernamesConstraintTableName,
|
||||||
final int scanPageSize) {
|
final int scanPageSize) {
|
||||||
|
|
||||||
super(client);
|
super(client);
|
||||||
|
this.clock = clock;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.asyncClient = asyncClient;
|
this.asyncClient = asyncClient;
|
||||||
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
|
this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;
|
||||||
@@ -112,6 +125,16 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
this.scanPageSize = scanPageSize;
|
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) {
|
public boolean create(Account account) {
|
||||||
return CREATE_TIMER.record(() -> {
|
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
|
* @return a reservation token that must be provided when {@link #confirmUsername(Account, String, UUID)} is called
|
||||||
* @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)
|
public UUID reserveUsername(
|
||||||
throws ContestedOptimisticLockException {
|
final Account account,
|
||||||
|
final String reservedUsername,
|
||||||
|
final Duration ttl) {
|
||||||
final long startNanos = System.nanoTime();
|
final long startNanos = System.nanoTime();
|
||||||
|
|
||||||
final Optional<String> maybeOriginalUsername = account.getUsername();
|
// if there is an existing old reservation it will be cleaned up via ttl
|
||||||
account.setUsername(username);
|
final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();
|
||||||
|
account.setReservedUsernameHash(reservedUsernameHash(account.getUuid(), reservedUsername));
|
||||||
|
|
||||||
boolean succeeded = false;
|
boolean succeeded = false;
|
||||||
|
|
||||||
|
long expirationTime = clock.instant().plus(ttl).getEpochSecond();
|
||||||
|
|
||||||
|
final UUID reservationToken = UUID.randomUUID();
|
||||||
try {
|
try {
|
||||||
final List<TransactWriteItem> writeItems = new ArrayList<>();
|
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()
|
writeItems.add(TransactWriteItem.builder()
|
||||||
.put(Put.builder()
|
.put(Put.builder()
|
||||||
.tableName(usernamesConstraintTableName)
|
.tableName(usernamesConstraintTableName)
|
||||||
.item(Map.of(
|
.item(Map.of(
|
||||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
|
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),
|
||||||
ATTR_USERNAME, AttributeValues.fromString(username)))
|
ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.conditionExpression("attribute_not_exists(#username)")
|
// it's not in the constraint table OR it's expired OR it was reserved by us
|
||||||
.expressionAttributeNames(Map.of("#username", ATTR_USERNAME))
|
.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)
|
.returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)
|
||||||
.build())
|
.build())
|
||||||
.build());
|
.build());
|
||||||
@@ -405,6 +545,7 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
account.setUsername(maybeOriginalUsername.orElse(null));
|
account.setUsername(maybeOriginalUsername.orElse(null));
|
||||||
|
account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));
|
||||||
}
|
}
|
||||||
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
|
||||||
}
|
}
|
||||||
@@ -553,11 +694,29 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean usernameAvailable(final String username) {
|
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()
|
final GetItemResponse response = client.getItem(GetItemRequest.builder()
|
||||||
.tableName(usernamesConstraintTableName)
|
.tableName(usernamesConstraintTableName)
|
||||||
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.build());
|
.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) {
|
public Optional<Account> getByE164(String number) {
|
||||||
@@ -583,7 +742,10 @@ public class Accounts extends AbstractDynamoDbStore {
|
|||||||
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
.key(Map.of(ATTR_USERNAME, AttributeValues.fromString(username)))
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
|
||||||
return Optional.ofNullable(response.item())
|
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(item -> item.get(KEY_ACCOUNT_UUID))
|
||||||
.map(this::accountByUuid)
|
.map(this::accountByUuid)
|
||||||
.map(Accounts::fromItem);
|
.map(Accounts::fromItem);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.codahale.metrics.Timer;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
import io.lettuce.core.RedisException;
|
import io.lettuce.core.RedisException;
|
||||||
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
import io.micrometer.core.instrument.Metrics;
|
||||||
@@ -20,6 +21,7 @@ import io.micrometer.core.instrument.Tags;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -85,7 +87,7 @@ public class AccountsManager {
|
|||||||
private final DirectoryQueue directoryQueue;
|
private final DirectoryQueue directoryQueue;
|
||||||
private final Keys keys;
|
private final Keys keys;
|
||||||
private final MessagesManager messagesManager;
|
private final MessagesManager messagesManager;
|
||||||
private final ReservedUsernames reservedUsernames;
|
private final ProhibitedUsernames prohibitedUsernames;
|
||||||
private final ProfilesManager profilesManager;
|
private final ProfilesManager profilesManager;
|
||||||
private final StoredVerificationCodeManager pendingAccounts;
|
private final StoredVerificationCodeManager pendingAccounts;
|
||||||
private final SecureStorageClient secureStorageClient;
|
private final SecureStorageClient secureStorageClient;
|
||||||
@@ -127,7 +129,7 @@ public class AccountsManager {
|
|||||||
final DirectoryQueue directoryQueue,
|
final DirectoryQueue directoryQueue,
|
||||||
final Keys keys,
|
final Keys keys,
|
||||||
final MessagesManager messagesManager,
|
final MessagesManager messagesManager,
|
||||||
final ReservedUsernames reservedUsernames,
|
final ProhibitedUsernames prohibitedUsernames,
|
||||||
final ProfilesManager profilesManager,
|
final ProfilesManager profilesManager,
|
||||||
final StoredVerificationCodeManager pendingAccounts,
|
final StoredVerificationCodeManager pendingAccounts,
|
||||||
final SecureStorageClient secureStorageClient,
|
final SecureStorageClient secureStorageClient,
|
||||||
@@ -148,7 +150,7 @@ public class AccountsManager {
|
|||||||
this.secureStorageClient = secureStorageClient;
|
this.secureStorageClient = secureStorageClient;
|
||||||
this.secureBackupClient = secureBackupClient;
|
this.secureBackupClient = secureBackupClient;
|
||||||
this.clientPresenceManager = clientPresenceManager;
|
this.clientPresenceManager = clientPresenceManager;
|
||||||
this.reservedUsernames = reservedUsernames;
|
this.prohibitedUsernames = prohibitedUsernames;
|
||||||
this.usernameGenerator = usernameGenerator;
|
this.usernameGenerator = usernameGenerator;
|
||||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||||
this.clock = Objects.requireNonNull(clock);
|
this.clock = Objects.requireNonNull(clock);
|
||||||
@@ -321,12 +323,112 @@ public class AccountsManager {
|
|||||||
return updatedAccount.get();
|
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 {
|
public Account setUsername(final Account account, final String requestedNickname, final @Nullable String expectedOldUsername) throws UsernameNotAvailableException {
|
||||||
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) {
|
||||||
throw new UsernameNotAvailableException();
|
throw new UsernameNotAvailableException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedUsernames.isReserved(requestedNickname, account.getUuid())) {
|
if (prohibitedUsernames.isProhibited(requestedNickname, account.getUuid())) {
|
||||||
throw new UsernameNotAvailableException();
|
throw new UsernameNotAvailableException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +447,9 @@ public class AccountsManager {
|
|||||||
account,
|
account,
|
||||||
a -> true,
|
a -> true,
|
||||||
// In the future, this may also check for any forbidden discriminators
|
// 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(),
|
() -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),
|
||||||
AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);
|
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 account account to update
|
||||||
* @param updater must return {@code true} if the account was actually updated
|
* @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) {
|
public Optional<Account> getByUsername(final String username) {
|
||||||
try (final Timer.Context ignored = getByUsernameTimer.time()) {
|
try (final Timer.Context ignored = getByUsernameTimer.time()) {
|
||||||
Optional<Account> account = redisGetByUsername(username);
|
Optional<Account> account = redisGetByUsername(username);
|
||||||
|
|
||||||
if (account.isEmpty()) {
|
if (account.isEmpty()) {
|
||||||
account = accounts.getByUsername(username);
|
account = accounts.getByUsername(username);
|
||||||
account.ifPresent(this::redisSet);
|
account.ifPresent(this::redisSet);
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.storage;
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
import static com.codahale.metrics.MetricRegistry.name;
|
|
||||||
|
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
|
||||||
import java.security.KeyStore;
|
import java.security.KeyStore;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import javax.net.ssl.SSLContext;
|
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.configuration.DirectoryServerConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest;
|
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse;
|
import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse;
|
||||||
import org.whispersystems.textsecuregcm.util.CertificateExpirationGauge;
|
|
||||||
import org.whispersystems.textsecuregcm.util.CertificateUtil;
|
import org.whispersystems.textsecuregcm.util.CertificateUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
|
||||||
|
|
||||||
public class DirectoryReconciliationClient {
|
public class DirectoryReconciliationClient {
|
||||||
|
|
||||||
@@ -33,10 +28,6 @@ public class DirectoryReconciliationClient {
|
|||||||
{
|
{
|
||||||
this.replicationUrl = directoryServerConfiguration.getReplicationUrl();
|
this.replicationUrl = directoryServerConfiguration.getReplicationUrl();
|
||||||
this.client = initializeClient(directoryServerConfiguration);
|
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) {
|
public DirectoryReconciliationResponse add(DirectoryReconciliationRequest request) {
|
||||||
@@ -63,7 +54,7 @@ public class DirectoryReconciliationClient {
|
|||||||
private static Client initializeClient(DirectoryServerConfiguration directoryServerConfiguration)
|
private static Client initializeClient(DirectoryServerConfiguration directoryServerConfiguration)
|
||||||
throws CertificateException {
|
throws CertificateException {
|
||||||
KeyStore trustStore = CertificateUtil.buildKeyStoreForPem(
|
KeyStore trustStore = CertificateUtil.buildKeyStoreForPem(
|
||||||
directoryServerConfiguration.getReplicationCaCertificate());
|
directoryServerConfiguration.getReplicationCaCertificates().toArray(new String[0]));
|
||||||
SSLContext sslContext = SslConfigurator.newInstance()
|
SSLContext sslContext = SslConfigurator.newInstance()
|
||||||
.securityProtocol("TLSv1.2")
|
.securityProtocol("TLSv1.2")
|
||||||
.trustStore(trustStore)
|
.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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import io.micrometer.core.instrument.Metrics;
|
|||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -34,23 +35,33 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.redis.ClusterLuaScript;
|
import org.whispersystems.textsecuregcm.redis.ClusterLuaScript;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;
|
||||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
import org.whispersystems.textsecuregcm.util.RedisClusterUtil;
|
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 {
|
public class MessagesCache extends RedisClusterPubSubAdapter<String, String> implements Managed {
|
||||||
|
|
||||||
private final FaultTolerantRedisCluster readDeleteCluster;
|
private final FaultTolerantRedisCluster readDeleteCluster;
|
||||||
private final FaultTolerantPubSubConnection<String, String> pubSubConnection;
|
private final FaultTolerantPubSubConnection<String, String> pubSubConnection;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
private final ExecutorService notificationExecutorService;
|
private final ExecutorService notificationExecutorService;
|
||||||
|
private final ExecutorService messageDeletionExecutorService;
|
||||||
|
|
||||||
private final ClusterLuaScript insertScript;
|
private final ClusterLuaScript insertScript;
|
||||||
private final ClusterLuaScript removeByGuidScript;
|
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 QUEUE_KEYSPACE_PREFIX = "__keyspace@0__:user_queue::";
|
||||||
private static final String PERSISTING_KEYSPACE_PREFIX = "__keyspace@0__:user_queue_persisting::";
|
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 GET_FLUX_NAME = MetricsUtil.name(MessagesCache.class, "get");
|
||||||
|
private static final int PAGE_SIZE = 100;
|
||||||
private static final String REMOVE_METHOD_TAG = "method";
|
|
||||||
private static final String REMOVE_METHOD_UUID = "uuid";
|
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);
|
private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);
|
||||||
|
|
||||||
public MessagesCache(final FaultTolerantRedisCluster insertCluster, final FaultTolerantRedisCluster readDeleteCluster,
|
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.readDeleteCluster = readDeleteCluster;
|
||||||
this.pubSubConnection = readDeleteCluster.createPubSubConnection();
|
this.pubSubConnection = readDeleteCluster.createPubSubConnection();
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
this.notificationExecutorService = notificationExecutorService;
|
this.notificationExecutorService = notificationExecutorService;
|
||||||
|
this.messageDeletionExecutorService = messageDeletionExecutorService;
|
||||||
|
|
||||||
this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER);
|
this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER);
|
||||||
this.removeByGuidScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_item_by_guid.lua",
|
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))));
|
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) {
|
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")
|
@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<UUID> messageGuids) {
|
||||||
final List<byte[]> serialized = (List<byte[]>) Metrics.timer(REMOVE_TIMER_NAME, REMOVE_METHOD_TAG,
|
|
||||||
REMOVE_METHOD_UUID).record(() ->
|
return removeByGuidScript.executeBinaryAsync(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
||||||
removeByGuidScript.executeBinary(List.of(getMessageQueueKey(destinationUuid, destinationDevice),
|
|
||||||
getMessageQueueMetadataKey(destinationUuid, destinationDevice),
|
getMessageQueueMetadataKey(destinationUuid, destinationDevice),
|
||||||
getQueueIndexKey(destinationUuid, destinationDevice)),
|
getQueueIndexKey(destinationUuid, destinationDevice)),
|
||||||
messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8))
|
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) {
|
for (final byte[] bytes : serialized) {
|
||||||
try {
|
try {
|
||||||
removedMessages.add(MessageProtos.Envelope.parseFrom(bytes));
|
removedMessages.add(MessageProtos.Envelope.parseFrom(bytes));
|
||||||
} catch (final InvalidProtocolBufferException e) {
|
} catch (final InvalidProtocolBufferException e) {
|
||||||
logger.warn("Failed to parse envelope", e);
|
logger.warn("Failed to parse envelope", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return removedMessages;
|
return removedMessages;
|
||||||
|
}, messageDeletionExecutorService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasMessages(final UUID destinationUuid, final long destinationDevice) {
|
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);
|
connection -> connection.sync().zcard(getMessageQueueKey(destinationUuid, destinationDevice)) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
public Publisher<MessageProtos.Envelope> get(final UUID destinationUuid, final long destinationDevice) {
|
||||||
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)));
|
|
||||||
|
|
||||||
final long earliestAllowableEphemeralTimestamp =
|
final long earliestAllowableEphemeralTimestamp =
|
||||||
System.currentTimeMillis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
clock.millis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis();
|
||||||
|
|
||||||
final List<MessageProtos.Envelope> messageEntities;
|
final Flux<MessageProtos.Envelope> allMessages = getAllMessages(destinationUuid, destinationDevice)
|
||||||
final List<UUID> staleEphemeralMessageGuids = new ArrayList<>();
|
.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) {
|
final Flux<MessageProtos.Envelope> messagesToPublish = allMessages
|
||||||
messageEntities = new ArrayList<>(queueItems.size() / 2);
|
.filter(Predicate.not(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp)));
|
||||||
|
|
||||||
for (int i = 0; i < queueItems.size() - 1; i += 2) {
|
final Flux<MessageProtos.Envelope> staleEphemeralMessages = allMessages
|
||||||
try {
|
.filter(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp));
|
||||||
final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i));
|
|
||||||
if (message.getEphemeral() && message.getTimestamp() < earliestAllowableEphemeralTimestamp) {
|
|
||||||
staleEphemeralMessageGuids.add(UUID.fromString(message.getServerGuid()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
messageEntities.add(message);
|
discardStaleEphemeralMessages(destinationUuid, destinationDevice, staleEphemeralMessages);
|
||||||
} catch (InvalidProtocolBufferException e) {
|
|
||||||
logger.warn("Failed to parse envelope", e);
|
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 {
|
return getNextMessagePage(destinationUuid, destinationDevice, queueItemsAndLastMessageId.second());
|
||||||
remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids);
|
})
|
||||||
staleEphemeralMessagesCounter.increment(staleEphemeralMessageGuids.size());
|
.limitRate(1)
|
||||||
} catch (final Throwable e) {
|
// we want to ensure we don’t accidentally block the Lettuce/netty i/o executors
|
||||||
logger.warn("Could not remove stale ephemeral messages from cache", e);
|
.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
|
@VisibleForTesting
|
||||||
@@ -307,12 +387,13 @@ public class MessagesCache extends RedisClusterPubSubAdapter<String, String> imp
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
||||||
@Nullable final String queueName = queueNamesByMessageListener.remove(listener);
|
@Nullable final String queueName = queueNamesByMessageListener.get(listener);
|
||||||
|
|
||||||
if (queueName != null) {
|
if (queueName != null) {
|
||||||
unsubscribeFromKeyspaceNotifications(queueName);
|
unsubscribeFromKeyspaceNotifications(queueName);
|
||||||
|
|
||||||
synchronized (messageListenersByQueueName) {
|
synchronized (messageListenersByQueueName) {
|
||||||
|
queueNamesByMessageListener.remove(listener);
|
||||||
messageListenersByQueueName.remove(queueName);
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,19 +17,26 @@ import java.time.Duration;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import javax.annotation.Nonnull;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
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.core.SdkBytes;
|
||||||
|
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
|
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.DeleteRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
|
import software.amazon.awssdk.services.dynamodb.model.PutRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
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 static final String KEY_ENVELOPE_BYTES = "EB";
|
||||||
|
|
||||||
private final Timer storeTimer = timer(name(getClass(), "store"));
|
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 deleteByAccount = timer(name(getClass(), "delete", "account"));
|
||||||
private final Timer deleteByDevice = timer(name(getClass(), "delete", "device"));
|
private final Timer deleteByDevice = timer(name(getClass(), "delete", "device"));
|
||||||
|
|
||||||
|
private final DynamoDbAsyncClient dbAsyncClient;
|
||||||
private final String tableName;
|
private final String tableName;
|
||||||
private final Duration timeToLive;
|
private final Duration timeToLive;
|
||||||
|
private final ExecutorService messageDeletionExecutor;
|
||||||
|
private final Scheduler messageDeletionScheduler;
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);
|
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);
|
super(dynamoDb);
|
||||||
|
|
||||||
|
this.dbAsyncClient = dynamoDbAsyncClient;
|
||||||
this.tableName = tableName;
|
this.tableName = tableName;
|
||||||
this.timeToLive = timeToLive;
|
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) {
|
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));
|
executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId, final int requestedNumberOfMessagesToFetch) {
|
public Publisher<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final long destinationDeviceId,
|
||||||
return loadTimer.record(() -> {
|
final Integer limit) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageEntities.size() == numberOfMessagesToFetch) {
|
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
||||||
// queryPaginator() uses limit() as the page size, not as an absolute limit
|
final QueryRequest.Builder queryRequestBuilder = QueryRequest.builder()
|
||||||
// …but a page might be smaller than limit, because a page is capped at 1 MB
|
.tableName(tableName)
|
||||||
break;
|
.consistentRead(true)
|
||||||
}
|
.keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )")
|
||||||
}
|
.expressionAttributeNames(Map.of(
|
||||||
return messageEntities;
|
"#part", KEY_PARTITION,
|
||||||
});
|
"#sort", KEY_SORT))
|
||||||
}
|
.expressionAttributeValues(Map.of(
|
||||||
|
":part", partitionKey,
|
||||||
|
":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId)));
|
||||||
|
|
||||||
public Optional<MessageProtos.Envelope> deleteMessageByDestinationAndGuid(final UUID destinationAccountUuid,
|
if (limit != null) {
|
||||||
final UUID messageUuid) {
|
// some callers don’t take advantage of reactive streams, so we want to support limiting the fetch size. Otherwise,
|
||||||
return deleteByGuid.record(() -> {
|
// we could fetch up to 1 MB (likely >1,000 messages) and discard 90% of them
|
||||||
final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid);
|
queryRequestBuilder.limit(Math.min(RESULT_SET_CHUNK_SIZE, limit));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
public void deleteAllMessagesForAccount(final UUID destinationAccountUuid) {
|
||||||
@@ -248,7 +261,7 @@ public class MessagesDynamoDb extends AbstractDynamoDbStore {
|
|||||||
KEY_PARTITION, partitionKey,
|
KEY_PARTITION, partitionKey,
|
||||||
KEY_SORT, item.get(KEY_SORT))).build())
|
KEY_SORT, item.get(KEY_SORT))).build())
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
executeTableWriteItemsUntilComplete(Map.of(tableName, deletes));
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
package org.whispersystems.textsecuregcm.storage;
|
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.Meter;
|
||||||
import com.codahale.metrics.MetricRegistry;
|
import com.codahale.metrics.MetricRegistry;
|
||||||
import com.codahale.metrics.SharedMetricRegistries;
|
import com.codahale.metrics.SharedMetricRegistries;
|
||||||
import java.util.ArrayList;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
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 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.entities.MessageProtos.Envelope;
|
||||||
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.util.Constants;
|
import org.whispersystems.textsecuregcm.util.Constants;
|
||||||
import org.whispersystems.textsecuregcm.util.Pair;
|
import org.whispersystems.textsecuregcm.util.Pair;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
public class MessagesManager {
|
public class MessagesManager {
|
||||||
|
|
||||||
private static final int RESULT_SET_CHUNK_SIZE = 100;
|
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 MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||||
private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid"));
|
private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid"));
|
||||||
@@ -31,14 +45,17 @@ public class MessagesManager {
|
|||||||
private final MessagesDynamoDb messagesDynamoDb;
|
private final MessagesDynamoDb messagesDynamoDb;
|
||||||
private final MessagesCache messagesCache;
|
private final MessagesCache messagesCache;
|
||||||
private final ReportMessageManager reportMessageManager;
|
private final ReportMessageManager reportMessageManager;
|
||||||
|
private final ExecutorService messageDeletionExecutor;
|
||||||
|
|
||||||
public MessagesManager(
|
public MessagesManager(
|
||||||
final MessagesDynamoDb messagesDynamoDb,
|
final MessagesDynamoDb messagesDynamoDb,
|
||||||
final MessagesCache messagesCache,
|
final MessagesCache messagesCache,
|
||||||
final ReportMessageManager reportMessageManager) {
|
final ReportMessageManager reportMessageManager,
|
||||||
|
final ExecutorService messageDeletionExecutor) {
|
||||||
this.messagesDynamoDb = messagesDynamoDb;
|
this.messagesDynamoDb = messagesDynamoDb;
|
||||||
this.messagesCache = messagesCache;
|
this.messagesCache = messagesCache;
|
||||||
this.reportMessageManager = reportMessageManager;
|
this.reportMessageManager = reportMessageManager;
|
||||||
|
this.messageDeletionExecutor = messageDeletionExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void insert(UUID destinationUuid, long destinationDevice, Envelope message) {
|
public void insert(UUID destinationUuid, long destinationDevice, Envelope message) {
|
||||||
@@ -55,18 +72,34 @@ public class MessagesManager {
|
|||||||
return messagesCache.hasMessages(destinationUuid, destinationDevice);
|
return messagesCache.hasMessages(destinationUuid, destinationDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice, final boolean cachedMessagesOnly) {
|
public Pair<List<Envelope>, Boolean> getMessagesForDevice(UUID destinationUuid, long destinationDevice,
|
||||||
List<Envelope> messageList = new ArrayList<>();
|
boolean cachedMessagesOnly) {
|
||||||
|
|
||||||
if (!cachedMessagesOnly) {
|
final List<Envelope> envelopes = Flux.from(
|
||||||
messageList.addAll(messagesDynamoDb.load(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE));
|
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) {
|
return new Pair<>(envelopes, envelopes.size() >= RESULT_SET_CHUNK_SIZE);
|
||||||
messageList.addAll(messagesCache.get(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE - messageList.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) {
|
public void clear(UUID destinationUuid) {
|
||||||
@@ -79,21 +112,25 @@ public class MessagesManager {
|
|||||||
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId);
|
messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Envelope> delete(UUID destinationUuid, long destinationDeviceId, UUID guid, Long serverTimestamp) {
|
public CompletableFuture<Optional<Envelope>> delete(UUID destinationUuid, long destinationDeviceId, UUID guid,
|
||||||
Optional<Envelope> removed = messagesCache.remove(destinationUuid, destinationDeviceId, guid);
|
@Nullable Long serverTimestamp) {
|
||||||
|
return messagesCache.remove(destinationUuid, destinationDeviceId, guid)
|
||||||
|
.thenComposeAsync(removed -> {
|
||||||
|
|
||||||
if (removed.isEmpty()) {
|
if (removed.isPresent()) {
|
||||||
if (serverTimestamp == null) {
|
cacheHitByGuidMeter.mark();
|
||||||
removed = messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid);
|
return CompletableFuture.completedFuture(removed);
|
||||||
} else {
|
}
|
||||||
removed = messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp);
|
|
||||||
}
|
|
||||||
cacheMissByGuidMeter.mark();
|
|
||||||
} else {
|
|
||||||
cacheHitByGuidMeter.mark();
|
|
||||||
}
|
|
||||||
|
|
||||||
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()))
|
final List<UUID> messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
int messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids).size();
|
int messagesRemovedFromCache = 0;
|
||||||
|
try {
|
||||||
persistMessageMeter.mark(nonEphemeralMessages.size());
|
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;
|
return messagesRemovedFromCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,4 +171,5 @@ public class MessagesManager {
|
|||||||
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) {
|
||||||
messagesCache.removeMessageAvailabilityListener(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.model.ScanResponse;
|
||||||
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
|
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
|
||||||
|
|
||||||
public class ReservedUsernames {
|
public class ProhibitedUsernames {
|
||||||
|
|
||||||
private final DynamoDbClient dynamoDbClient;
|
private final DynamoDbClient dynamoDbClient;
|
||||||
private final String tableName;
|
private final String tableName;
|
||||||
@@ -44,17 +44,17 @@ public class ReservedUsernames {
|
|||||||
static final String KEY_PATTERN = "P";
|
static final String KEY_PATTERN = "P";
|
||||||
private static final String ATTR_RESERVED_FOR_UUID = "U";
|
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.dynamoDbClient = dynamoDbClient;
|
||||||
this.tableName = tableName;
|
this.tableName = tableName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isReserved(final String nickname, final UUID accountIdentifier) {
|
public boolean isProhibited(final String nickname, final UUID accountIdentifier) {
|
||||||
return IS_RESERVED_TIMER.record(() -> {
|
return IS_PROHIBITED_TIMER.record(() -> {
|
||||||
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.build());
|
.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()
|
dynamoDbClient.putItem(PutItemRequest.builder()
|
||||||
.tableName(tableName)
|
.tableName(tableName)
|
||||||
.item(Map.of(
|
.item(Map.of(
|
||||||
@@ -6,23 +6,34 @@
|
|||||||
package org.whispersystems.textsecuregcm.storage;
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
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.n;
|
||||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.common.base.Throwables;
|
import com.google.common.base.Throwables;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.DynamoDbAsyncClient;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
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.QueryRequest;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
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_USER = "U"; // B (Hash Key)
|
||||||
public static final String KEY_PASSWORD = "P"; // B
|
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_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_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_ID = "S"; // S
|
||||||
public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N
|
public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N
|
||||||
public static final String KEY_SUBSCRIPTION_LEVEL = "L";
|
public static final String KEY_SUBSCRIPTION_LEVEL = "L";
|
||||||
@@ -51,8 +65,11 @@ public class SubscriptionManager {
|
|||||||
|
|
||||||
public final byte[] user;
|
public final byte[] user;
|
||||||
public final byte[] password;
|
public final byte[] password;
|
||||||
public final String customerId;
|
|
||||||
public final Instant createdAt;
|
public final Instant createdAt;
|
||||||
|
@VisibleForTesting
|
||||||
|
@Nullable
|
||||||
|
ProcessorCustomer processorCustomer;
|
||||||
|
public Map<SubscriptionProcessor, String> processorsToCustomerIds;
|
||||||
public String subscriptionId;
|
public String subscriptionId;
|
||||||
public Instant subscriptionCreatedAt;
|
public Instant subscriptionCreatedAt;
|
||||||
public Long subscriptionLevel;
|
public Long subscriptionLevel;
|
||||||
@@ -61,31 +78,76 @@ public class SubscriptionManager {
|
|||||||
public Instant canceledAt;
|
public Instant canceledAt;
|
||||||
public Instant currentPeriodEndsAt;
|
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.user = checkUserLength(user);
|
||||||
this.password = Objects.requireNonNull(password);
|
this.password = Objects.requireNonNull(password);
|
||||||
this.customerId = Objects.requireNonNull(customerId);
|
|
||||||
this.createdAt = Objects.requireNonNull(createdAt);
|
this.createdAt = Objects.requireNonNull(createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Record from(byte[] user, Map<String, AttributeValue> item) {
|
public static Record from(byte[] user, Map<String, AttributeValue> item) {
|
||||||
Record self = new Record(
|
Record record = new Record(
|
||||||
user,
|
user,
|
||||||
item.get(KEY_PASSWORD).b().asByteArray(),
|
item.get(KEY_PASSWORD).b().asByteArray(),
|
||||||
item.get(KEY_CUSTOMER_ID).s(),
|
|
||||||
getInstant(item, KEY_CREATED_AT));
|
getInstant(item, KEY_CREATED_AT));
|
||||||
self.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
|
||||||
self.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
|
final Pair<SubscriptionProcessor, String> processorCustomerId = getProcessorAndCustomer(item);
|
||||||
self.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
|
if (processorCustomerId != null) {
|
||||||
self.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);
|
record.processorCustomer = new ProcessorCustomer(processorCustomerId.second(), processorCustomerId.first());
|
||||||
self.accessedAt = getInstant(item, KEY_ACCESSED_AT);
|
}
|
||||||
self.canceledAt = getInstant(item, KEY_CANCELED_AT);
|
record.processorsToCustomerIds = getProcessorsToCustomerIds(item);
|
||||||
self.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);
|
record.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
||||||
return self;
|
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() {
|
public Optional<ProcessorCustomer> getProcessorCustomer() {
|
||||||
return Map.of(KEY_USER, b(user));
|
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) {
|
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.
|
* 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) {
|
public CompletableFuture<GetResult> get(byte[] user, byte[] hmac) {
|
||||||
checkUserLength(user);
|
return getUser(user).thenApply(getItemResponse -> {
|
||||||
|
|
||||||
GetItemRequest request = GetItemRequest.builder()
|
|
||||||
.consistentRead(Boolean.TRUE)
|
|
||||||
.tableName(table)
|
|
||||||
.key(Map.of(KEY_USER, b(user)))
|
|
||||||
.build();
|
|
||||||
return client.getItem(request).thenApply(getItemResponse -> {
|
|
||||||
if (!getItemResponse.hasItem()) {
|
if (!getItemResponse.hasItem()) {
|
||||||
return GetResult.NOT_STORED;
|
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);
|
checkUserLength(user);
|
||||||
|
|
||||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||||
@@ -211,20 +278,23 @@ public class SubscriptionManager {
|
|||||||
.conditionExpression("attribute_not_exists(#user) OR #password = :password")
|
.conditionExpression("attribute_not_exists(#user) OR #password = :password")
|
||||||
.updateExpression("SET "
|
.updateExpression("SET "
|
||||||
+ "#password = if_not_exists(#password, :password), "
|
+ "#password = if_not_exists(#password, :password), "
|
||||||
+ "#customer_id = if_not_exists(#customer_id, :customer_id), "
|
|
||||||
+ "#created_at = if_not_exists(#created_at, :created_at), "
|
+ "#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(
|
.expressionAttributeNames(Map.of(
|
||||||
"#user", KEY_USER,
|
"#user", KEY_USER,
|
||||||
"#password", KEY_PASSWORD,
|
"#password", KEY_PASSWORD,
|
||||||
"#customer_id", KEY_CUSTOMER_ID,
|
|
||||||
"#created_at", KEY_CREATED_AT,
|
"#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(
|
.expressionAttributeValues(Map.of(
|
||||||
":password", b(password),
|
":password", b(password),
|
||||||
":customer_id", s(customerId),
|
|
||||||
":created_at", n(createdAt.getEpochSecond()),
|
":created_at", n(createdAt.getEpochSecond()),
|
||||||
":accessed_at", n(createdAt.getEpochSecond())))
|
":accessed_at", n(createdAt.getEpochSecond()),
|
||||||
|
":initial_empty_map", m(Map.of()))
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
return client.updateItem(request).handle((updateItemResponse, throwable) -> {
|
return client.updateItem(request).handle((updateItemResponse, throwable) -> {
|
||||||
if (throwable != null) {
|
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) {
|
public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {
|
||||||
checkUserLength(user);
|
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) {
|
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) {
|
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
|
* 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.base.Strings;
|
||||||
import com.google.common.collect.Lists;
|
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.apache.commons.codec.binary.Hex;
|
||||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||||
|
|
||||||
public class StripeManager {
|
public class StripeManager implements SubscriptionProcessorManager {
|
||||||
|
|
||||||
private static final String METADATA_KEY_LEVEL = "level";
|
private static final String METADATA_KEY_LEVEL = "level";
|
||||||
|
|
||||||
@@ -87,6 +87,16 @@ public class StripeManager {
|
|||||||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
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() {
|
private RequestOptions commonOptions() {
|
||||||
return commonOptions(null);
|
return commonOptions(null);
|
||||||
}
|
}
|
||||||
@@ -98,17 +108,19 @@ public class StripeManager {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Customer> createCustomer(byte[] subscriberUser) {
|
@Override
|
||||||
|
public CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
throw new CompletionException(e);
|
throw new CompletionException(e);
|
||||||
}
|
}
|
||||||
}, executor);
|
}, executor)
|
||||||
|
.thenApply(customer -> new ProcessorCustomer(customer.getId(), getProcessor()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Customer> getCustomer(String customerId) {
|
public CompletableFuture<Customer> getCustomer(String customerId) {
|
||||||
@@ -139,17 +151,19 @@ public class StripeManager {
|
|||||||
}, executor);
|
}, executor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<SetupIntent> createSetupIntent(String customerId) {
|
@Override
|
||||||
|
public CompletableFuture<String> createPaymentMethodSetupToken(String customerId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||||
.setCustomer(customerId)
|
.setCustomer(customerId)
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
return SetupIntent.create(params, commonOptions());
|
return SetupIntent.create(params, commonOptions());
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
throw new CompletionException(e);
|
throw new CompletionException(e);
|
||||||
}
|
}
|
||||||
}, executor);
|
}, executor)
|
||||||
|
.thenApply(SetupIntent::getClientSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +210,7 @@ public class StripeManager {
|
|||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||||
.setCustomer(customerId)
|
.setCustomer(customerId)
|
||||||
|
.setOffSession(true)
|
||||||
.addItem(SubscriptionCreateParams.Item.builder()
|
.addItem(SubscriptionCreateParams.Item.builder()
|
||||||
.setPrice(priceId)
|
.setPrice(priceId)
|
||||||
.build())
|
.build())
|
||||||
@@ -234,6 +249,7 @@ public class StripeManager {
|
|||||||
// not prorated
|
// not prorated
|
||||||
.setProrationBehavior(ProrationBehavior.NONE)
|
.setProrationBehavior(ProrationBehavior.NONE)
|
||||||
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
||||||
|
.setOffSession(true)
|
||||||
.addAllItem(items)
|
.addAllItem(items)
|
||||||
.build();
|
.build();
|
||||||
try {
|
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;
|
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.nio.ByteBuffer;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
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. */
|
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
|
||||||
public class AttributeValues {
|
public class AttributeValues {
|
||||||
@@ -37,6 +37,9 @@ public class AttributeValues {
|
|||||||
return AttributeValue.builder().s(value).build();
|
return AttributeValue.builder().s(value).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AttributeValue m(Map<String, AttributeValue> value) {
|
||||||
|
return AttributeValue.builder().m(value).build();
|
||||||
|
}
|
||||||
|
|
||||||
// More opinionated methods
|
// 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