mirror of
https://github.com/signalapp/Signal-Server.git
synced 2025-12-15 02:00:48 +00:00
Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d5006f70 | ||
|
|
2697872bdd | ||
|
|
7b331edcde | ||
|
|
e4da59c236 | ||
|
|
48ebafa4e0 | ||
|
|
f5726f63bd | ||
|
|
391b070cff | ||
|
|
781cd0ca3f | ||
|
|
84355963f9 | ||
|
|
3ccfeb490b | ||
|
|
0cc84131de | ||
|
|
4fa08fb189 | ||
|
|
2a551d1d41 | ||
|
|
391aa9c518 | ||
|
|
39d9fd0317 | ||
|
|
18b1fcd724 | ||
|
|
f5c62a3d85 | ||
|
|
6075d5137b | ||
|
|
890293e429 | ||
|
|
05b43a878b | ||
|
|
fe9c3982a1 | ||
|
|
82baa892f7 | ||
|
|
ee53260d72 | ||
|
|
a8eb27940d | ||
|
|
fd8918eaff | ||
|
|
a3a7d7108b | ||
|
|
cd27fe0409 | ||
|
|
35606a9afd | ||
|
|
2052e62c01 | ||
|
|
8ccab5c1e0 | ||
|
|
292f69256e | ||
|
|
fbdcb942e8 | ||
|
|
c14ef7e6cf | ||
|
|
a04fe133b6 | ||
|
|
483e444174 | ||
|
|
ebf8aa7b15 | ||
|
|
7c52be2ac1 | ||
|
|
203a49975c | ||
|
|
7d45838a1e | ||
|
|
2683f1c6e7 | ||
|
|
d13413aff2 | ||
|
|
4c85e7ba66 | ||
|
|
46fef4082c | ||
|
|
c06313dd2e | ||
|
|
59bc2c5535 | ||
|
|
437bc1358b | ||
|
|
99e651e902 | ||
|
|
757ce42a35 | ||
|
|
179f3df847 | ||
|
|
8a889516b0 | ||
|
|
7de5c0a27d | ||
|
|
71d234e1e4 | ||
|
|
b5fb33e21e | ||
|
|
2be22c2a8e | ||
|
|
db198237f3 | ||
|
|
d0ccae129a | ||
|
|
ecbef9c6ee | ||
|
|
ef2cc6620e | ||
|
|
b8f363b187 | ||
|
|
c3f4956ead | ||
|
|
047f4a1c00 | ||
|
|
41c0fe9ffa | ||
|
|
6edb0d49e9 | ||
|
|
a5e3b81a50 | ||
|
|
b9b4e3fdd8 | ||
|
|
6ee9c6ad46 | ||
|
|
6d6556eee5 | ||
|
|
7529c35013 | ||
|
|
378b32d44d | ||
|
|
e1fcd3e3f6 | ||
|
|
d7ad8dd448 | ||
|
|
859f2302a9 | ||
|
|
a6d11789e9 | ||
|
|
43f83076fa | ||
|
|
71c0fc8d4a | ||
|
|
d2f723de12 | ||
|
|
1f4f926ce6 | ||
|
|
35286f838e | ||
|
|
e1ea3795bb | ||
|
|
95237a22a9 | ||
|
|
11c93c5f53 | ||
|
|
b59b8621c5 | ||
|
|
44c61d9a58 | ||
|
|
63a17bc14b | ||
|
|
f4f93bb24d | ||
|
|
7561622bc8 | ||
|
|
b041566aba | ||
|
|
cb72158abc | ||
|
|
5c432d094f | ||
|
|
24ac48b3b1 | ||
|
|
c03060fe3c | ||
|
|
3ebd5141ae | ||
|
|
c16006dc4b | ||
|
|
8fc465b3e8 | ||
|
|
ce689bdff3 | ||
|
|
e23386ddc7 | ||
|
|
0f17d63774 | ||
|
|
4fc3949367 | ||
|
|
e19c04377b | ||
|
|
7c3f429c56 | ||
|
|
7558489ad0 | ||
|
|
4a3880b5ae | ||
|
|
ca7a4abd30 | ||
|
|
a4a45de161 | ||
|
|
358a286523 | ||
|
|
3bbab0027b | ||
|
|
8afe917a6c | ||
|
|
f5fec5e6bb | ||
|
|
0b81743683 | ||
|
|
9f715c3224 | ||
|
|
24f515ccb4 | ||
|
|
fd531242c9 | ||
|
|
3855bd257d | ||
|
|
c98b54ff15 | ||
|
|
d93d50d038 | ||
|
|
448365c7a0 | ||
|
|
515a863195 | ||
|
|
8d0e23bde1 | ||
|
|
dc8f62a4ad | ||
|
|
896e65545e | ||
|
|
cd4a4b1dcf | ||
|
|
38a0737afb | ||
|
|
4a2768b81d | ||
|
|
00e08b8402 | ||
|
|
48e8584e13 | ||
|
|
a89e30fe75 | ||
|
|
a01fcdad28 | ||
|
|
2a99529921 | ||
|
|
c934405a3e | ||
|
|
b8d922fcb7 | ||
|
|
eb499833c6 | ||
|
|
dd98f7f043 | ||
|
|
e8978ef91c | ||
|
|
669ff1cadf | ||
|
|
4ce85fdb19 | ||
|
|
035ddc4834 | ||
|
|
c2f40b8503 | ||
|
|
cf738a1c14 | ||
|
|
52d40c2321 | ||
|
|
cbf12d6b46 | ||
|
|
ab26a65b6a | ||
|
|
ee5aaf5383 | ||
|
|
1c1714b2c2 | ||
|
|
accb017ec5 | ||
|
|
304782d583 | ||
|
|
f361f436d8 | ||
|
|
a34b5a6122 | ||
|
|
f75ea18ccb | ||
|
|
9a06c40a28 | ||
|
|
e6ab97dc5a | ||
|
|
ba73f757e2 | ||
|
|
30f131096d | ||
|
|
b8ce922f92 | ||
|
|
11b62345e1 | ||
|
|
77289ecb51 | ||
|
|
dfb0b68997 | ||
|
|
d545f60fc4 | ||
|
|
5cda6e9d84 | ||
|
|
7caba89210 | ||
|
|
b8967b75c6 | ||
|
|
74d9849472 | ||
|
|
96b753cfd0 | ||
|
|
5a89e66fc0 | ||
|
|
b4a143b9de | ||
|
|
050035dd52 | ||
|
|
7018062606 | ||
|
|
9e1485de0a | ||
|
|
4e358b891f | ||
|
|
4044a9df30 | ||
|
|
5a7b675001 | ||
|
|
3be4e4bc57 | ||
|
|
5de51919bb | ||
|
|
b02b00818b | ||
|
|
010f88a2ad | ||
|
|
60edf4835f | ||
|
|
a60450d931 | ||
|
|
d138fa45df | ||
|
|
2c2c497c12 | ||
|
|
cb5d3840d9 | ||
|
|
9aceaa7a4d | ||
|
|
636c8ba384 | ||
|
|
ac78eb1425 | ||
|
|
65ad3fe623 | ||
|
|
dcec90fc52 | ||
|
|
24ac32e6e6 | ||
|
|
26f5ffdde3 | ||
|
|
a883426402 | ||
|
|
2f21e930e2 | ||
|
|
5fb158635c | ||
|
|
6f844f9ebb | ||
|
|
d88e358016 | ||
|
|
9cf2635528 | ||
|
|
d0e7579f13 | ||
|
|
cda82b0ea0 | ||
|
|
2ecbb18fe5 | ||
|
|
d40d2389a9 | ||
|
|
df8fb5cab7 | ||
|
|
99ad211c01 | ||
|
|
fb4ed20ff5 | ||
|
|
cb50b44d8f | ||
|
|
ae57853ec4 | ||
|
|
2881c0fd7e | ||
|
|
483fb0968b | ||
|
|
4d37418c15 | ||
|
|
e8ee4b50ff | ||
|
|
4f8aa2eee2 | ||
|
|
397d3cb45a | ||
|
|
e883d727fb | ||
|
|
986545a140 | ||
|
|
836307b0c7 | ||
|
|
b5a75d3079 | ||
|
|
c32067759c | ||
|
|
7fb7abb593 | ||
|
|
0d50b58c60 | ||
|
|
bdf4e24266 | ||
|
|
f41bdf1acb | ||
|
|
77d691df59 | ||
|
|
12300761ab | ||
|
|
25efcbda81 | ||
|
|
a01f96e0e4 | ||
|
|
1d1e3ba79d | ||
|
|
2c9c50711f | ||
|
|
d3f0ab8c6d | ||
|
|
80a3a8a43c | ||
|
|
e6e6eb323d |
33
.github/workflows/documentation.yml
vendored
Normal file
33
.github/workflows/documentation.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Update Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
cache: 'maven'
|
||||
- name: Compile and Build OpenAPI file
|
||||
run: ./mvnw compile
|
||||
- name: Update Documentation
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cp -r api-doc/target/openapi/signal-server-openapi.yaml /tmp/
|
||||
git config user.email "github@signal.org"
|
||||
git config user.name "Documentation Updater"
|
||||
git fetch origin gh-pages
|
||||
git checkout gh-pages
|
||||
cp /tmp/signal-server-openapi.yaml .
|
||||
git diff --quiet || git commit -a -m "Updating documentation"
|
||||
git push origin gh-pages -q
|
||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Service CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- gh-pages
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ deployer.log
|
||||
!/service/src/main/resources/org/signal/badges/Badges_en.properties
|
||||
/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties
|
||||
!/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties
|
||||
.project
|
||||
.classpath
|
||||
.settings
|
||||
|
||||
8
.gitmodules
vendored
8
.gitmodules
vendored
@@ -1,11 +1,11 @@
|
||||
# Note that the implementation of the abusive message filter is private; internal
|
||||
# Note that the implementation of the spam filter is private; internal
|
||||
# developers will need to override this URL with:
|
||||
#
|
||||
# ```
|
||||
# git config submodule.abusive-message-filter.url PRIVATE_URL
|
||||
# git config submodule.spam-filter.url PRIVATE_URL
|
||||
# ```
|
||||
#
|
||||
# External developers may safely ignore this submodule.
|
||||
[submodule "abusive-message-filter"]
|
||||
path = abusive-message-filter
|
||||
[submodule "spam-filter"]
|
||||
path = spam-filter
|
||||
url = REDACTED
|
||||
|
||||
8
.mvn/wrapper/maven-wrapper.properties
vendored
8
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -5,14 +5,14 @@
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.5/apache-maven-3.8.5-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
|
||||
|
||||
Submodule abusive-message-filter deleted from 83c6ac4236
45
api-doc/pom.xml
Normal file
45
api-doc/pom.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>api-doc</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>service</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-maven-plugin</artifactId>
|
||||
<version>2.2.8</version>
|
||||
<configuration>
|
||||
<outputFileName>signal-server-openapi</outputFileName>
|
||||
<outputPath>${project.build.directory}/openapi</outputPath>
|
||||
<outputFormat>YAML</outputFormat>
|
||||
<configurationFilePath>${project.basedir}/src/main/resources/openapi/openapi-configuration.yaml
|
||||
</configurationFilePath>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>resolve</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
110
api-doc/src/main/java/org/signal/openapi/OpenApiExtension.java
Normal file
110
api-doc/src/main/java/org/signal/openapi/OpenApiExtension.java
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.openapi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.type.SimpleType;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.jaxrs2.ResolvedParameter;
|
||||
import io.swagger.v3.jaxrs2.ext.AbstractOpenAPIExtension;
|
||||
import io.swagger.v3.jaxrs2.ext.OpenAPIExtension;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
import javax.ws.rs.Consumes;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
|
||||
/**
|
||||
* One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations
|
||||
* of the {@link AbstractOpenAPIExtension} class.
|
||||
* <p/>
|
||||
* The purpose of this extension is to customize certain aspects of the OpenAPI model generation on a lower level.
|
||||
* This extension works in coordination with {@link OpenApiReader} that has access to the model on a higher level.
|
||||
* <p/>
|
||||
* The extension is enabled by being listed in {@code META-INF/services/io.swagger.v3.jaxrs2.ext.OpenAPIExtension} file.
|
||||
* @see ServiceLoader
|
||||
* @see OpenApiReader
|
||||
* @see <a href="https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Extensions">Swagger 2.X Extensions</a>
|
||||
*/
|
||||
public class OpenApiExtension extends AbstractOpenAPIExtension {
|
||||
|
||||
public static final ResolvedParameter AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
public static final ResolvedParameter OPTIONAL_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
public static final ResolvedParameter DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
public static final ResolvedParameter OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();
|
||||
|
||||
/**
|
||||
* When parsing endpoint methods, Swagger will treat the first parameter not annotated as header/path/query param
|
||||
* as a request body (and will ignore other not annotated parameters). In our case, this behavior conflicts with
|
||||
* the {@code @Auth}-annotated parameters. Here we're checking if parameters are known to be anything other than
|
||||
* a body and return an appropriate {@link ResolvedParameter} representation.
|
||||
*/
|
||||
@Override
|
||||
public ResolvedParameter extractParameters(
|
||||
final List<Annotation> annotations,
|
||||
final Type type,
|
||||
final Set<Type> typesToSkip,
|
||||
final Components components,
|
||||
final Consumes classConsumes,
|
||||
final Consumes methodConsumes,
|
||||
final boolean includeRequestBody,
|
||||
final JsonView jsonViewAnnotation,
|
||||
final Iterator<OpenAPIExtension> chain) {
|
||||
|
||||
if (annotations.stream().anyMatch(a -> a.annotationType().equals(Auth.class))) {
|
||||
// this is the case of authenticated endpoint,
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& simpleType.getRawClass().equals(AuthenticatedAccount.class)) {
|
||||
return AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& simpleType.getRawClass().equals(DisabledPermittedAuthenticatedAccount.class)) {
|
||||
return DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& isOptionalOfType(simpleType, AuthenticatedAccount.class)) {
|
||||
return OPTIONAL_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
if (type instanceof SimpleType simpleType
|
||||
&& isOptionalOfType(simpleType, DisabledPermittedAuthenticatedAccount.class)) {
|
||||
return OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
}
|
||||
}
|
||||
|
||||
return super.extractParameters(
|
||||
annotations,
|
||||
type,
|
||||
typesToSkip,
|
||||
components,
|
||||
classConsumes,
|
||||
methodConsumes,
|
||||
includeRequestBody,
|
||||
jsonViewAnnotation,
|
||||
chain);
|
||||
}
|
||||
|
||||
private static boolean isOptionalOfType(final SimpleType simpleType, final Class<?> expectedType) {
|
||||
if (!simpleType.getRawClass().equals(Optional.class)) {
|
||||
return false;
|
||||
}
|
||||
final List<JavaType> typeParameters = simpleType.getBindings().getTypeParameters();
|
||||
if (typeParameters.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return typeParameters.get(0) instanceof SimpleType optionalParameterType
|
||||
&& optionalParameterType.getRawClass().equals(expectedType);
|
||||
}
|
||||
}
|
||||
73
api-doc/src/main/java/org/signal/openapi/OpenApiReader.java
Normal file
73
api-doc/src/main/java/org/signal/openapi/OpenApiReader.java
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.openapi;
|
||||
|
||||
import static com.google.common.base.MoreObjects.firstNonNull;
|
||||
import static org.signal.openapi.OpenApiExtension.AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.OPTIONAL_AUTHENTICATED_ACCOUNT;
|
||||
import static org.signal.openapi.OpenApiExtension.OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import io.swagger.v3.jaxrs2.Reader;
|
||||
import io.swagger.v3.jaxrs2.ResolvedParameter;
|
||||
import io.swagger.v3.oas.models.Operation;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import javax.ws.rs.Consumes;
|
||||
|
||||
/**
|
||||
* One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations
|
||||
* of the {@link Reader} class.
|
||||
* <p/>
|
||||
* The purpose of this extension is to customize certain aspects of the OpenAPI model generation on a higher level.
|
||||
* This extension works in coordination with {@link OpenApiExtension} that has access to the model on a lower level.
|
||||
* <p/>
|
||||
* The extension is enabled by being listed in {@code resources/openapi/openapi-configuration.yaml} file.
|
||||
* @see OpenApiExtension
|
||||
* @see <a href="https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Extensions">Swagger 2.X Extensions</a>
|
||||
*/
|
||||
public class OpenApiReader extends Reader {
|
||||
|
||||
private static final String AUTHENTICATED_ACCOUNT_AUTH_SCHEMA = "authenticatedAccount";
|
||||
|
||||
|
||||
/**
|
||||
* Overriding this method allows converting a resolved parameter into other operation entities,
|
||||
* in this case, into security requirements.
|
||||
*/
|
||||
@Override
|
||||
protected ResolvedParameter getParameters(
|
||||
final Type type,
|
||||
final List<Annotation> annotations,
|
||||
final Operation operation,
|
||||
final Consumes classConsumes,
|
||||
final Consumes methodConsumes,
|
||||
final JsonView jsonViewAnnotation) {
|
||||
final ResolvedParameter resolved = super.getParameters(
|
||||
type, annotations, operation, classConsumes, methodConsumes, jsonViewAnnotation);
|
||||
|
||||
if (resolved == AUTHENTICATED_ACCOUNT || resolved == DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
|
||||
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
|
||||
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
|
||||
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
|
||||
.build());
|
||||
}
|
||||
if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT || resolved == OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
|
||||
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
|
||||
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
|
||||
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
|
||||
.add(new SecurityRequirement())
|
||||
.build());
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.signal.openapi.OpenApiExtension
|
||||
@@ -0,0 +1,25 @@
|
||||
resourcePackages:
|
||||
- org.whispersystems.textsecuregcm
|
||||
prettyPrint: true
|
||||
cacheTTL: 0
|
||||
readerClass: org.signal.openapi.OpenApiReader
|
||||
openAPI:
|
||||
info:
|
||||
title: Signal Server API
|
||||
license:
|
||||
name: AGPL-3.0-only
|
||||
url: https://www.gnu.org/licenses/agpl-3.0.txt
|
||||
servers:
|
||||
- url: https://chat.signal.org
|
||||
description: Production service
|
||||
- url: https://chat.staging.signal.org
|
||||
description: Staging service
|
||||
components:
|
||||
securitySchemes:
|
||||
authenticatedAccount:
|
||||
type: http
|
||||
scheme: basic
|
||||
description: |
|
||||
Account authentication is based on Basic authentication schema,
|
||||
where `username` has a format of `<user_id>[.<device_id>]`. If `device_id` is not specified,
|
||||
user's `main` device is assumed.
|
||||
@@ -24,7 +24,16 @@
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<!--
|
||||
depends on an outdated version (13.0) for JDK 6 compatibility, but it’s safe to override
|
||||
https://youtrack.jetbrains.com/issue/KT-25047
|
||||
-->
|
||||
<artifactId>annotations</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.event
|
||||
|
||||
import com.google.cloud.logging.Logging
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
class GoogleCloudAdminEventLoggerTest {
|
||||
|
||||
@Test
|
||||
fun logEvent() {
|
||||
val logging = mock(Logging::class.java)
|
||||
val logger = GoogleCloudAdminEventLogger(logging, "my-project", "test")
|
||||
|
||||
val event = RemoteConfigDeleteEvent("token", "test")
|
||||
logger.logEvent(event)
|
||||
}
|
||||
}
|
||||
92
pom.xml
92
pom.xml
@@ -14,11 +14,6 @@
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>dynamodb-local-oregon</id>
|
||||
<name>DynamoDB Local Release Repository</name>
|
||||
<url>https://s3-us-west-2.amazonaws.com/dynamodb-local/release</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<pluginRepositories>
|
||||
@@ -35,37 +30,39 @@
|
||||
</pluginRepositories>
|
||||
|
||||
<modules>
|
||||
<module>api-doc</module>
|
||||
<module>event-logger</module>
|
||||
<module>redis-dispatch</module>
|
||||
<module>websocket-resources</module>
|
||||
<module>service</module>
|
||||
<module>websocket-resources</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<aws.sdk.version>1.12.287</aws.sdk.version>
|
||||
<aws.sdk2.version>2.17.258</aws.sdk2.version>
|
||||
<commons-codec.version>1.15</commons-codec.version>
|
||||
<commons-csv.version>1.8</commons-csv.version>
|
||||
<aws.sdk.version>1.12.376</aws.sdk.version>
|
||||
<aws.sdk2.version>2.19.8</aws.sdk2.version>
|
||||
<braintree.version>3.19.0</braintree.version>
|
||||
<commons-csv.version>1.9.0</commons-csv.version>
|
||||
<commons-io.version>2.9.0</commons-io.version>
|
||||
<dropwizard.version>2.0.32</dropwizard.version>
|
||||
<dropwizard.version>2.0.34</dropwizard.version>
|
||||
<dropwizard-metrics-datadog.version>1.1.13</dropwizard-metrics-datadog.version>
|
||||
<grpc.version>1.49.2</grpc.version>
|
||||
<google-cloud-libraries.version>26.1.3</google-cloud-libraries.version>
|
||||
<grpc.version>1.51.1</grpc.version> <!-- this should be kept in sync with the value from Google’s libraries-bom -->
|
||||
<gson.version>2.9.0</gson.version>
|
||||
<guava.version>30.1.1-jre</guava.version>
|
||||
<jackson.version>2.13.4</jackson.version>
|
||||
<jaxb.version>2.3.1</jaxb.version>
|
||||
<jedis.version>2.9.0</jedis.version>
|
||||
<kotlin.version>1.7.10</kotlin.version>
|
||||
<kotlinx-serialization.version>1.4.0</kotlinx-serialization.version>
|
||||
<lettuce.version>6.2.0.RELEASE</lettuce.version>
|
||||
<kotlin.version>1.8.0</kotlin.version>
|
||||
<kotlinx-serialization.version>1.4.1</kotlinx-serialization.version>
|
||||
<lettuce.version>6.2.1.RELEASE</lettuce.version>
|
||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||
<logstash.logback.version>7.0.1</logstash.logback.version>
|
||||
<micrometer.version>1.9.3</micrometer.version>
|
||||
<mockito.version>4.7.0</mockito.version>
|
||||
<logstash.logback.version>7.2</logstash.logback.version>
|
||||
<luajava.version>3.4.0</luajava.version>
|
||||
<micrometer.version>1.10.3</micrometer.version>
|
||||
<mockito.version>4.11.0</mockito.version>
|
||||
<netty.version>4.1.82.Final</netty.version>
|
||||
<opentest4j.version>1.2.0</opentest4j.version>
|
||||
<protobuf.version>3.21.7</protobuf.version>
|
||||
<pushy.version>0.15.1</pushy.version>
|
||||
<pushy.version>0.15.2</pushy.version>
|
||||
<resilience4j.version>1.7.0</resilience4j.version>
|
||||
<semver4j.version>3.1.0</semver4j.version>
|
||||
<slf4j.version>1.7.30</slf4j.version>
|
||||
@@ -95,13 +92,6 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-bom</artifactId>
|
||||
<version>${grpc.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Needed for gRPC with Java 9+ -->
|
||||
<dependency>
|
||||
<groupId>org.apache.tomcat</groupId>
|
||||
@@ -133,7 +123,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>libraries-bom</artifactId>
|
||||
<version>26.1.0</version>
|
||||
<version>${google-cloud-libraries.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -154,7 +144,14 @@
|
||||
<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 -->
|
||||
<version>2022.0.3</version> <!-- 3.5.x, see https://github.com/reactor/reactor#bom-versioning-scheme -->
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-bom</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -168,11 +165,6 @@
|
||||
<artifactId>pushy-dropwizard-metrics-listener</artifactId>
|
||||
<version>${pushy.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
@@ -188,11 +180,6 @@
|
||||
<artifactId>semver4j</artifactId>
|
||||
<version>${semver4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
<version>${commons-codec.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
@@ -284,6 +271,11 @@
|
||||
<artifactId>stripe-java</artifactId>
|
||||
<version>${stripe.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.braintreepayments.gateway</groupId>
|
||||
<artifactId>braintree-java</artifactId>
|
||||
<version>${braintree.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
@@ -298,7 +290,7 @@
|
||||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>libsignal-server</artifactId>
|
||||
<version>0.21.1</version>
|
||||
<version>0.22.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
@@ -320,7 +312,7 @@
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-jre8</artifactId>
|
||||
<version>2.34.0</version>
|
||||
<version>2.35.0</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
@@ -349,27 +341,33 @@
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit-pioneer</groupId>
|
||||
<artifactId>junit-pioneer</artifactId>
|
||||
<version>1.9.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>include-abusive-message-filter</id>
|
||||
<id>include-spam-filter</id>
|
||||
<activation>
|
||||
<file>
|
||||
<exists>abusive-message-filter/pom.xml</exists>
|
||||
<exists>spam-filter/pom.xml</exists>
|
||||
</file>
|
||||
</activation>
|
||||
<modules>
|
||||
<module>abusive-message-filter</module>
|
||||
<module>spam-filter</module>
|
||||
</modules>
|
||||
</profile>
|
||||
|
||||
<profile>
|
||||
<id>exclude-abusive-message-filter</id>
|
||||
<id>exclude-spam-filter</id>
|
||||
<activation>
|
||||
<file>
|
||||
<missing>abusive-message-filter/pom.xml</missing>
|
||||
<missing>spam-filter/pom.xml</missing>
|
||||
</file>
|
||||
</activation>
|
||||
</profile>
|
||||
@@ -475,7 +473,7 @@
|
||||
<rules>
|
||||
<dependencyConvergence/>
|
||||
<requireMavenVersion>
|
||||
<version>3.8.3</version>
|
||||
<version>3.8.6</version>
|
||||
</requireMavenVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
|
||||
@@ -15,6 +15,25 @@ stripe:
|
||||
idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash
|
||||
boostDescription: >
|
||||
Example
|
||||
supportedCurrencies:
|
||||
- xts
|
||||
# - ...
|
||||
# - Nth supported currency
|
||||
|
||||
|
||||
braintree:
|
||||
merchantId: unset
|
||||
publicKey: unset
|
||||
privateKey: unset
|
||||
environment: unset
|
||||
graphqlUrl: unset
|
||||
merchantAccounts:
|
||||
# ISO 4217 currency code and its corresponding sub-merchant account
|
||||
'xts': unset
|
||||
supportedCurrencies:
|
||||
- xts
|
||||
# - ...
|
||||
# - Nth supported currency
|
||||
|
||||
dynamoDbClientConfiguration:
|
||||
region: us-west-2 # AWS Region
|
||||
@@ -53,6 +72,9 @@ dynamoDbTables:
|
||||
redeemedReceipts:
|
||||
tableName: Example_RedeemedReceipts
|
||||
expiration: P30D # Duration of time until rows expire
|
||||
registrationRecovery:
|
||||
tableName: Example_RegistrationRecovery
|
||||
expiration: P300D # Duration of time until rows expire
|
||||
remoteConfig:
|
||||
tableName: Example_RemoteConfig
|
||||
reportMessage:
|
||||
@@ -61,6 +83,8 @@ dynamoDbTables:
|
||||
tableName: Example_ReservedUsernames
|
||||
subscriptions:
|
||||
tableName: Example_Subscriptions
|
||||
verificationSessions:
|
||||
tableName: Example_VerificationSessions
|
||||
|
||||
cacheCluster: # Redis server configuration for cache cluster
|
||||
configurationUri: redis://redis.example.com:6379/
|
||||
@@ -121,6 +145,36 @@ directoryV2:
|
||||
userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users
|
||||
userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users
|
||||
|
||||
svr2:
|
||||
enabled: false
|
||||
uri: svr2.example.com
|
||||
userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users
|
||||
userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users
|
||||
svrCaCertificates:
|
||||
- |
|
||||
-----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-----
|
||||
|
||||
|
||||
messageCache: # Redis server configuration for message store cache
|
||||
persistDelayMinutes: 1
|
||||
cluster:
|
||||
@@ -206,15 +260,13 @@ unidentifiedDelivery:
|
||||
privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA
|
||||
expiresDays: 7
|
||||
|
||||
voiceVerification:
|
||||
url: https://cdn-ca.signal.org/verification/
|
||||
locales:
|
||||
- en
|
||||
|
||||
recaptcha:
|
||||
projectPath: projects/example
|
||||
credentialConfigurationJson: "{ }" # service account configuration for backend authentication
|
||||
|
||||
hCaptcha:
|
||||
apiKey: unset
|
||||
|
||||
storageService:
|
||||
uri: storage.example.com
|
||||
userAuthenticationTokenSharedSecret: 00000f
|
||||
@@ -290,10 +342,17 @@ remoteConfig:
|
||||
paymentsService:
|
||||
userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users
|
||||
fixerApiKey: unset
|
||||
coinMarketCapApiKey: unset
|
||||
coinMarketCapCurrencyIds:
|
||||
MOB: 7878
|
||||
paymentCurrencies:
|
||||
# list of symbols for supported currencies
|
||||
- MOB
|
||||
|
||||
artService:
|
||||
userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret not shared with any external service, but used in ArtController
|
||||
userAuthenticationTokenUserIdSecret: 00000f # hex-encoded secret to obscure user phone numbers from Sticker Creator
|
||||
|
||||
badges:
|
||||
badges:
|
||||
- id: TEST
|
||||
@@ -323,29 +382,31 @@ subscription: # configuration for Stripe subscriptions
|
||||
# list of ISO 4217 currency codes and amounts for the given badge level
|
||||
xts:
|
||||
amount: '10'
|
||||
id: price_example # stripe ID
|
||||
processorIds:
|
||||
STRIPE: price_example # stripe Price ID
|
||||
BRAINTREE: plan_example # braintree Plan ID
|
||||
|
||||
boost:
|
||||
level: 1
|
||||
expiration: P90D
|
||||
badge: EXAMPLE
|
||||
oneTimeDonations:
|
||||
boost:
|
||||
level: 1
|
||||
expiration: P90D
|
||||
badge: EXAMPLE
|
||||
gift:
|
||||
level: 10
|
||||
expiration: P90D
|
||||
badge: EXAMPLE
|
||||
currencies:
|
||||
# ISO 4217 currency codes and amounts in those currencies
|
||||
xts:
|
||||
- '1'
|
||||
- '2'
|
||||
- '4'
|
||||
- '8'
|
||||
- '20'
|
||||
- '40'
|
||||
|
||||
gift:
|
||||
level: 10
|
||||
expiration: P90D
|
||||
badge: EXAMPLE
|
||||
currencies:
|
||||
# ISO 4217 currency codes and amounts in those currencies
|
||||
xts: '2'
|
||||
minimum: '0.5'
|
||||
gift: '2'
|
||||
boosts:
|
||||
- '1'
|
||||
- '2'
|
||||
- '4'
|
||||
- '8'
|
||||
- '20'
|
||||
- '40'
|
||||
|
||||
registrationService:
|
||||
host: registration.example.com
|
||||
@@ -372,3 +433,6 @@ registrationService:
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz
|
||||
AAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
callLink:
|
||||
userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with calling frontend to generate auth tokens for Signal users
|
||||
|
||||
131
service/pom.xml
131
service/pom.xml
@@ -11,6 +11,18 @@
|
||||
<artifactId>service</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-jaxrs2</artifactId>
|
||||
<version>2.2.8</version>
|
||||
<exclusions>
|
||||
<!-- org.yaml:snakeyaml is causing a dependency convergence error -->
|
||||
<exclusion>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
@@ -160,6 +172,26 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>party.iroiro.luajava</groupId>
|
||||
<artifactId>luajava</artifactId>
|
||||
<version>${luajava.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>party.iroiro.luajava</groupId>
|
||||
<artifactId>lua51</artifactId>
|
||||
<version>${luajava.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>party.iroiro.luajava</groupId>
|
||||
<artifactId>lua51-platform</artifactId>
|
||||
<version>${luajava.version}</version>
|
||||
<classifier>natives-desktop</classifier>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.websocket</groupId>
|
||||
<artifactId>websocket-api</artifactId>
|
||||
@@ -173,10 +205,6 @@
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-csv</artifactId>
|
||||
@@ -185,34 +213,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.firebase</groupId>
|
||||
<artifactId>firebase-admin</artifactId>
|
||||
<version>9.0.0</version>
|
||||
|
||||
<!-- firebase-admin has conflicting versions of these artifacts in its dependency tree; for firebase-admin
|
||||
9.0.0, we'll need to depend directly on com.google.api-client:google-api-client:1.35.1 and
|
||||
com.google.oauth-client:google-oauth-client:1.34.1 -->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.api-client</groupId>
|
||||
<artifactId>google-api-client</artifactId>
|
||||
</exclusion>
|
||||
|
||||
<exclusion>
|
||||
<groupId>com.google.oauth-client</groupId>
|
||||
<artifactId>google-oauth-client</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.api-client</groupId>
|
||||
<artifactId>google-api-client</artifactId>
|
||||
<version>1.35.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.oauth-client</groupId>
|
||||
<artifactId>google-oauth-client</artifactId>
|
||||
<version>1.34.1</version>
|
||||
<version>9.1.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -412,6 +413,10 @@
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core-micrometer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vavr</groupId>
|
||||
<artifactId>vavr</artifactId>
|
||||
@@ -444,7 +449,14 @@
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>DynamoDBLocal</artifactId>
|
||||
<version>1.19.0</version>
|
||||
<version>1.21.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.ganadist.sqlite4java</groupId>
|
||||
<artifactId>libsqlite4java-osx-aarch64</artifactId>
|
||||
<version>1.0.392</version>
|
||||
<type>dylib</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -457,11 +469,23 @@
|
||||
<groupId>com.stripe</groupId>
|
||||
<artifactId>stripe-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.braintreepayments.gateway</groupId>
|
||||
<artifactId>braintree-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.apollographql.apollo3</groupId>
|
||||
<artifactId>apollo-api-jvm</artifactId>
|
||||
<version>3.7.1</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>exclude-abusive-message-filter</id>
|
||||
<id>exclude-spam-filter</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
@@ -538,9 +562,9 @@
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.signal</groupId>
|
||||
<groupId>com.bazaarvoice.maven.plugins</groupId>
|
||||
<artifactId>s3-upload-maven-plugin</artifactId>
|
||||
<version>1.6-SNAPSHOT</version>
|
||||
<version>2.0.1</version>
|
||||
<configuration>
|
||||
<source>${project.build.directory}/${project.build.finalName}-bin.tar.gz</source>
|
||||
<bucketName>${deploy.bucketName}</bucketName>
|
||||
@@ -579,6 +603,16 @@
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M7</version>
|
||||
<configuration>
|
||||
<!-- work around PATCH not being a supported method on HttpUrlConnection -->
|
||||
<argLine>--add-opens=java.base/java.net=ALL-UNNAMED</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
@@ -612,6 +646,31 @@
|
||||
</arguments>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>com.github.aoudiamoncef</groupId>
|
||||
<artifactId>apollo-client-maven-plugin</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>generate</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<services>
|
||||
<braintree>
|
||||
<compilationUnit>
|
||||
<name>braintree</name>
|
||||
<compilerParams>
|
||||
<schemaPackageName>com.braintree.graphql.client</schemaPackageName>
|
||||
</compilerParams>
|
||||
</compilationUnit>
|
||||
</braintree>
|
||||
</services>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# https://graphql.braintreepayments.com/reference/#Mutation--chargePaymentMethod
|
||||
mutation ChargePayPalOneTimePayment($input: ChargePaymentMethodInput!) {
|
||||
chargePaymentMethod(input: $input) {
|
||||
transaction {
|
||||
id,
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation CreatePayPalBillingAgreement($input: CreatePayPalBillingAgreementInput!) {
|
||||
createPayPalBillingAgreement(input: $input) {
|
||||
approvalUrl,
|
||||
billingAgreementToken
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# https://graphql.braintreepayments.com/reference/#Mutation--createPayPalOneTimePayment
|
||||
mutation CreatePayPalOneTimePayment($input: CreatePayPalOneTimePaymentInput!) {
|
||||
createPayPalOneTimePayment(input: $input) {
|
||||
approvalUrl,
|
||||
paymentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation TokenizePayPalBillingAgreement($input: TokenizePayPalBillingAgreementInput!) {
|
||||
tokenizePayPalBillingAgreement(input: $input) {
|
||||
paymentMethod {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# https://graphql.braintreepayments.com/reference/#Mutation--tokenizePayPalOneTimePayment
|
||||
mutation TokenizePayPalOneTimePayment($input: TokenizePayPalOneTimePaymentInput!) {
|
||||
tokenizePayPalOneTimePayment(input: $input) {
|
||||
paymentMethod {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation VaultPaymentMethod($input: VaultPaymentMethodInput!) {
|
||||
vaultPaymentMethod(input: $input) {
|
||||
paymentMethod {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
35093
service/src/main/graphql/braintree/schema.json
Normal file
35093
service/src/main/graphql/braintree/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm;
|
||||
@@ -12,14 +12,15 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.AbusiveMessageFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BoostConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration;
|
||||
@@ -28,11 +29,11 @@ import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguratio
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbTables;
|
||||
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GiftConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.HCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||
@@ -41,13 +42,14 @@ import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.UsernameConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.VoiceVerificationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiterConfig;
|
||||
import org.whispersystems.websocket.configuration.WebSocketConfiguration;
|
||||
|
||||
/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */
|
||||
@@ -63,6 +65,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private StripeConfiguration stripe;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private BraintreeConfiguration braintree;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -118,6 +125,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private DirectoryV2Configuration directoryV2;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private SecureValueRecovery2Configuration svr2;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -156,7 +168,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RateLimitsConfiguration limits = new RateLimitsConfiguration();
|
||||
private Map<String, RateLimiterConfig> limits = new HashMap<>();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -181,12 +193,12 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private VoiceVerificationConfiguration voiceVerification;
|
||||
private RecaptchaConfiguration recaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RecaptchaConfiguration recaptcha;
|
||||
private HCaptchaConfiguration hCaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -203,6 +215,16 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private PaymentsServiceConfiguration paymentsService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private ArtServiceConfiguration artService;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private CallLinkConfiguration callLink;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -231,12 +253,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private BoostConfiguration boost;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private GiftConfiguration gift;
|
||||
private OneTimeDonationConfiguration oneTimeDonations;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -244,13 +261,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private UsernameConfiguration username = new UsernameConfiguration();
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AbusiveMessageFilterConfiguration abusiveMessageFilter;
|
||||
private SpamFilterConfiguration spamFilterConfiguration;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -265,6 +277,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return stripe;
|
||||
}
|
||||
|
||||
public BraintreeConfiguration getBraintree() {
|
||||
return braintree;
|
||||
}
|
||||
|
||||
public DynamoDbClientConfiguration getDynamoDbClientConfiguration() {
|
||||
return dynamoDbClientConfiguration;
|
||||
}
|
||||
@@ -277,8 +293,8 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return recaptcha;
|
||||
}
|
||||
|
||||
public VoiceVerificationConfiguration getVoiceVerificationConfiguration() {
|
||||
return voiceVerification;
|
||||
public HCaptchaConfiguration getHCaptchaConfiguration() {
|
||||
return hCaptcha;
|
||||
}
|
||||
|
||||
public WebSocketConfiguration getWebSocketConfiguration() {
|
||||
@@ -309,6 +325,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return directory;
|
||||
}
|
||||
|
||||
public SecureValueRecovery2Configuration getSvr2Configuration() {
|
||||
return svr2;
|
||||
}
|
||||
|
||||
public DirectoryV2Configuration getDirectoryV2Configuration() {
|
||||
return directoryV2;
|
||||
}
|
||||
@@ -337,7 +357,7 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return rateLimitersCluster;
|
||||
}
|
||||
|
||||
public RateLimitsConfiguration getLimitsConfiguration() {
|
||||
public Map<String, RateLimiterConfig> getLimitsConfiguration() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
@@ -357,6 +377,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return datadog;
|
||||
}
|
||||
|
||||
public CallLinkConfiguration getCallLinkConfiguration() {
|
||||
return callLink;
|
||||
}
|
||||
|
||||
public UnidentifiedDeliveryConfiguration getDeliveryCertificate() {
|
||||
return unidentifiedDelivery;
|
||||
}
|
||||
@@ -391,6 +415,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return paymentsService;
|
||||
}
|
||||
|
||||
public ArtServiceConfiguration getArtServiceConfiguration() {
|
||||
return artService;
|
||||
}
|
||||
|
||||
public ZkConfig getZkConfig() {
|
||||
return zkConfig;
|
||||
}
|
||||
@@ -411,24 +439,16 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public BoostConfiguration getBoost() {
|
||||
return boost;
|
||||
}
|
||||
|
||||
public GiftConfiguration getGift() {
|
||||
return gift;
|
||||
public OneTimeDonationConfiguration getOneTimeDonations() {
|
||||
return oneTimeDonations;
|
||||
}
|
||||
|
||||
public ReportMessageConfiguration getReportMessageConfiguration() {
|
||||
return reportMessage;
|
||||
}
|
||||
|
||||
public AbusiveMessageFilterConfiguration getAbusiveMessageFilterConfiguration() {
|
||||
return abusiveMessageFilter;
|
||||
}
|
||||
|
||||
public UsernameConfiguration getUsername() {
|
||||
return username;
|
||||
public SpamFilterConfiguration getSpamFilterConfiguration() {
|
||||
return spamFilterConfiguration;
|
||||
}
|
||||
|
||||
public RegistrationServiceConfiguration getRegistrationServiceConfiguration() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm;
|
||||
@@ -11,9 +11,6 @@ import com.amazonaws.auth.InstanceProfileCredentialsProvider;
|
||||
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
|
||||
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.cloud.logging.LoggingOptions;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
@@ -27,12 +24,11 @@ import io.dropwizard.auth.basic.BasicCredentialAuthFilter;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import io.dropwizard.setup.Bootstrap;
|
||||
import io.dropwizard.setup.Environment;
|
||||
import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder;
|
||||
import io.lettuce.core.metrics.MicrometerOptions;
|
||||
import io.lettuce.core.resource.ClientResources;
|
||||
import io.micrometer.core.instrument.Meter.Id;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||
import io.micrometer.datadog.DatadogMeterRegistry;
|
||||
@@ -70,24 +66,30 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.dispatch.DispatchManager;
|
||||
import org.whispersystems.textsecuregcm.abuse.AbusiveMessageFilter;
|
||||
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
|
||||
import org.whispersystems.textsecuregcm.abuse.RateLimitChallengeListener;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
|
||||
import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter;
|
||||
import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
|
||||
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
|
||||
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountControllerV2;
|
||||
import org.whispersystems.textsecuregcm.controllers.ArtController;
|
||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
||||
import org.whispersystems.textsecuregcm.controllers.CallLinkController;
|
||||
import org.whispersystems.textsecuregcm.controllers.CertificateController;
|
||||
import org.whispersystems.textsecuregcm.controllers.ChallengeController;
|
||||
import org.whispersystems.textsecuregcm.controllers.DeviceController;
|
||||
@@ -100,20 +102,21 @@ import org.whispersystems.textsecuregcm.controllers.MessageController;
|
||||
import org.whispersystems.textsecuregcm.controllers.PaymentsController;
|
||||
import org.whispersystems.textsecuregcm.controllers.ProfileController;
|
||||
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RegistrationController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureBackupController;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
|
||||
import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;
|
||||
import org.whispersystems.textsecuregcm.controllers.StickerController;
|
||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
||||
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
||||
import org.whispersystems.textsecuregcm.controllers.VerificationController;
|
||||
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
|
||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||
import org.whispersystems.textsecuregcm.currency.FixerClient;
|
||||
import org.whispersystems.textsecuregcm.currency.FtxClient;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.filters.ContentLengthFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||
import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters;
|
||||
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
@@ -122,8 +125,10 @@ import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapp
|
||||
import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.metrics.ApplicationShutdownMonitor;
|
||||
import org.whispersystems.textsecuregcm.metrics.BufferPoolGauges;
|
||||
@@ -131,10 +136,10 @@ import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge;
|
||||
import org.whispersystems.textsecuregcm.metrics.FileDescriptorGauge;
|
||||
import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge;
|
||||
import org.whispersystems.textsecuregcm.metrics.GarbageCollectionGauges;
|
||||
import org.whispersystems.textsecuregcm.metrics.LettuceMetricsMeterFilter;
|
||||
import org.whispersystems.textsecuregcm.metrics.MaxFileDescriptorGauge;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsRequestEventListener;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.MicrometerRegistryManager;
|
||||
import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge;
|
||||
import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge;
|
||||
@@ -153,7 +158,6 @@ import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
||||
import org.whispersystems.textsecuregcm.push.PushLatencyManager;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
|
||||
@@ -162,8 +166,13 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient;
|
||||
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
|
||||
import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecovery2Client;
|
||||
import org.whispersystems.textsecuregcm.spam.FilterSpam;
|
||||
import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener;
|
||||
import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider;
|
||||
import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamFilter;
|
||||
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
|
||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountCleaner;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawler;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerCache;
|
||||
@@ -189,11 +198,12 @@ import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListe
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.Profiles;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames;
|
||||
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||
@@ -201,11 +211,15 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;
|
||||
import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;
|
||||
@@ -220,9 +234,11 @@ import org.whispersystems.textsecuregcm.workers.ServerVersionCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.SetCrawlerAccelerationTask;
|
||||
import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;
|
||||
import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.UnlinkDeviceCommand;
|
||||
import org.whispersystems.textsecuregcm.workers.ZkParamsCommand;
|
||||
import org.whispersystems.websocket.WebSocketResourceProviderFactory;
|
||||
import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -245,6 +261,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
bootstrap.addCommand(new SetUserDiscoverabilityCommand());
|
||||
bootstrap.addCommand(new ReserveUsernameCommand());
|
||||
bootstrap.addCommand(new AssignUsernameCommand());
|
||||
bootstrap.addCommand(new UnlinkDeviceCommand());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -279,7 +296,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME))
|
||||
.meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME))
|
||||
.meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME))
|
||||
.meterFilter(new LettuceMetricsMeterFilter())
|
||||
.meterFilter(new MeterFilter() {
|
||||
@Override
|
||||
public DistributionStatisticConfig configure(final Id id, final DistributionStatisticConfig config) {
|
||||
@@ -292,9 +308,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
environment.lifecycle().manage(new MicrometerRegistryManager(Metrics.globalRegistry));
|
||||
|
||||
environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
|
||||
environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
|
||||
SystemMapper.configureMapper(environment.getObjectMapper());
|
||||
|
||||
HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup =
|
||||
new HeaderControlledResourceBundleLookup();
|
||||
@@ -330,14 +344,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getAppConfig().getConfigurationName(),
|
||||
DynamicConfiguration.class);
|
||||
|
||||
BlockingQueue<Runnable> messageDeletionQueue = new ArrayBlockingQueue<>(10_000);
|
||||
BlockingQueue<Runnable> messageDeletionQueue = new LinkedBlockingQueue<>();
|
||||
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(
|
||||
dynamoDbClient,
|
||||
dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getAccounts().getTableName(),
|
||||
@@ -347,8 +361,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDynamoDbTables().getAccounts().getScanPageSize());
|
||||
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient,
|
||||
config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());
|
||||
ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient,
|
||||
config.getDynamoDbTables().getReservedUsernames().getTableName());
|
||||
Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getProfiles().getTableName());
|
||||
Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName());
|
||||
@@ -367,18 +379,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getDynamoDbTables().getPendingAccounts().getTableName());
|
||||
VerificationCodeStore pendingDevices = new VerificationCodeStore(dynamoDbClient,
|
||||
config.getDynamoDbTables().getPendingDevices().getTableName());
|
||||
RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
|
||||
config.getDynamoDbTables().getRegistrationRecovery().getTableName(),
|
||||
config.getDynamoDbTables().getRegistrationRecovery().getExpiration(),
|
||||
dynamoDbClient,
|
||||
dynamoDbAsyncClient
|
||||
);
|
||||
|
||||
reactor.util.Metrics.MicrometerConfiguration.useRegistry(Metrics.globalRegistry);
|
||||
Schedulers.enableMetrics();
|
||||
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbAsyncClient,
|
||||
config.getDynamoDbTables().getVerificationSessions().getTableName(), clock);
|
||||
|
||||
RedisClientFactory pubSubClientFactory = new RedisClientFactory("pubsub_cache",
|
||||
config.getPubsubCacheConfiguration().getUrl(), config.getPubsubCacheConfiguration().getReplicaUrls(),
|
||||
config.getPubsubCacheConfiguration().getCircuitBreakerConfiguration());
|
||||
ReplicatedJedisPool pubsubClient = pubSubClientFactory.getRedisClientPool();
|
||||
|
||||
MicrometerOptions options = MicrometerOptions.builder().build();
|
||||
ClientResources redisClientResources = ClientResources.builder()
|
||||
.commandLatencyRecorder(new MicrometerCommandLatencyRecorder(Metrics.globalRegistry, options)).build();
|
||||
ClientResources redisClientResources = ClientResources.builder().build();
|
||||
ConnectionEventLogger.logConnectionEvents(redisClientResources);
|
||||
|
||||
FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster", config.getCacheClusterConfiguration(), redisClientResources);
|
||||
@@ -389,30 +405,48 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters", config.getRateLimitersCluster(), redisClientResources);
|
||||
|
||||
final BlockingQueue<Runnable> keyspaceNotificationDispatchQueue = new ArrayBlockingQueue<>(100_000);
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "keyspaceNotificationDispatchQueueSize"), Collections.emptyList(), keyspaceNotificationDispatchQueue);
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "keyspaceNotificationDispatchQueueSize"), Collections.emptyList(),
|
||||
keyspaceNotificationDispatchQueue);
|
||||
final BlockingQueue<Runnable> receiptSenderQueue = new LinkedBlockingQueue<>();
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "receiptSenderQueue"), Collections.emptyList(), receiptSenderQueue);
|
||||
|
||||
final BlockingQueue<Runnable> fcmSenderQueue = new LinkedBlockingQueue<>();
|
||||
Metrics.gaugeCollectionSize(name(getClass(), "fcmSenderQueue"), Collections.emptyList(), fcmSenderQueue);
|
||||
final BlockingQueue<Runnable> messageDeliveryQueue = new LinkedBlockingQueue<>();
|
||||
Metrics.gaugeCollectionSize(MetricsUtil.name(getClass(), "messageDeliveryQueue"), Collections.emptyList(),
|
||||
messageDeliveryQueue);
|
||||
|
||||
ScheduledExecutorService recurringJobExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "recurringJob-%d")).threads(6).build();
|
||||
ScheduledExecutorService websocketScheduledExecutor = environment.lifecycle().scheduledExecutorService(name(getClass(), "websocket-%d")).threads(8).build();
|
||||
ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle().executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(16).workQueue(keyspaceNotificationDispatchQueue).build();
|
||||
ExecutorService apnSenderExecutor = environment.lifecycle().executorService(name(getClass(), "apnSender-%d")).maxThreads(1).minThreads(1).build();
|
||||
ExecutorService fcmSenderExecutor = environment.lifecycle().executorService(name(getClass(), "fcmSender-%d")).maxThreads(32).minThreads(32).workQueue(fcmSenderQueue).build();
|
||||
ExecutorService backupServiceExecutor = environment.lifecycle().executorService(name(getClass(), "backupService-%d")).maxThreads(1).minThreads(1).build();
|
||||
ExecutorService storageServiceExecutor = environment.lifecycle().executorService(name(getClass(), "storageService-%d")).maxThreads(1).minThreads(1).build();
|
||||
ExecutorService fcmSenderExecutor = environment.lifecycle().executorService(name(getClass(), "fcmSender-%d"))
|
||||
.maxThreads(32).minThreads(32).workQueue(fcmSenderQueue).build();
|
||||
ExecutorService secureValueRecoveryServiceExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "secureValueRecoveryService-%d")).maxThreads(1).minThreads(1).build();
|
||||
ExecutorService storageServiceExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "storageService-%d")).maxThreads(1).minThreads(1).build();
|
||||
ExecutorService accountDeletionExecutor = environment.lifecycle().executorService(name(getClass(), "accountCleaner-%d")).maxThreads(16).minThreads(16).build();
|
||||
|
||||
// using 80 threads to match Schedulers.boundedElastic() behavior
|
||||
Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService(
|
||||
ExecutorServiceMetrics.monitor(Metrics.globalRegistry,
|
||||
environment.lifecycle().executorService(name(getClass(), "messageDelivery-%d"))
|
||||
.minThreads(80)
|
||||
.maxThreads(80)
|
||||
.workQueue(messageDeliveryQueue)
|
||||
.build(),
|
||||
MetricsUtil.name(getClass(), "messageDeliveryExecutor"), MetricsUtil.PREFIX),
|
||||
"messageDelivery");
|
||||
// TODO: generally speaking this is a DynamoDB I/O executor for the accounts table; we should eventually have a general executor for speaking to the accounts table, but most of the server is still synchronous so this isn't widely useful yet
|
||||
ExecutorService batchIdentityCheckExecutor = environment.lifecycle().executorService(name(getClass(), "batchIdentityCheck-%d")).minThreads(32).maxThreads(32).build();
|
||||
ExecutorService multiRecipientMessageExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "multiRecipientMessage-%d")).minThreads(64).maxThreads(64).build();
|
||||
ExecutorService stripeExecutor = environment.lifecycle().executorService(name(getClass(), "stripe-%d")).
|
||||
maxThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||
minThreads(availableProcessors). // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||
allowCoreThreadTimeOut(true).
|
||||
ExecutorService subscriptionProcessorExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "subscriptionProcessor-%d"))
|
||||
.maxThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||
.minThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best
|
||||
.allowCoreThreadTimeOut(true).
|
||||
build();
|
||||
ExecutorService receiptSenderExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "receiptSender-%d"))
|
||||
@@ -435,51 +469,69 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getAdminEventLoggingConfiguration().projectId(),
|
||||
config.getAdminEventLoggingConfiguration().logName());
|
||||
|
||||
StripeManager stripeManager = new StripeManager(config.getStripe().getApiKey(), stripeExecutor,
|
||||
config.getStripe().getIdempotencyKeyGenerator(), config.getStripe().getBoostDescription());
|
||||
StripeManager stripeManager = new StripeManager(config.getStripe().apiKey(), subscriptionProcessorExecutor,
|
||||
config.getStripe().idempotencyKeyGenerator(), config.getStripe().boostDescription(), config.getStripe()
|
||||
.supportedCurrencies());
|
||||
BraintreeManager braintreeManager = new BraintreeManager(config.getBraintree().merchantId(),
|
||||
config.getBraintree().publicKey(), config.getBraintree().privateKey(), config.getBraintree().environment(),
|
||||
config.getBraintree().supportedCurrencies(), config.getBraintree().merchantAccounts(),
|
||||
config.getBraintree().graphqlUrl(), config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor);
|
||||
|
||||
ExternalServiceCredentialGenerator directoryCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenSharedSecret(),
|
||||
config.getDirectoryConfiguration().getDirectoryClientConfiguration().getUserAuthenticationTokenUserIdSecret());
|
||||
ExternalServiceCredentialGenerator directoryV2CredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration().getUserAuthenticationTokenSharedSecret(),
|
||||
config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration().getUserIdTokenSharedSecret(),
|
||||
true, false);
|
||||
ExternalServiceCredentialsGenerator directoryCredentialsGenerator = DirectoryController.credentialsGenerator(
|
||||
config.getDirectoryConfiguration().getDirectoryClientConfiguration());
|
||||
ExternalServiceCredentialsGenerator directoryV2CredentialsGenerator = DirectoryV2Controller.credentialsGenerator(
|
||||
config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration());
|
||||
ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator(
|
||||
config.getSecureStorageServiceConfiguration());
|
||||
ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator(
|
||||
config.getSecureBackupServiceConfiguration());
|
||||
ExternalServiceCredentialsGenerator paymentsCredentialsGenerator = PaymentsController.credentialsGenerator(
|
||||
config.getPaymentsServiceConfiguration());
|
||||
ExternalServiceCredentialsGenerator artCredentialsGenerator = ArtController.credentialsGenerator(
|
||||
config.getArtServiceConfiguration());
|
||||
ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(
|
||||
config.getSvr2Configuration());
|
||||
ExternalServiceCredentialsGenerator callLinkCredentialsGenerator = CallLinkController.credentialsGenerator(
|
||||
config.getCallLinkConfiguration()
|
||||
);
|
||||
|
||||
dynamicConfigurationManager.start();
|
||||
|
||||
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);
|
||||
ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(
|
||||
dynamicConfigurationManager);
|
||||
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = new RegistrationRecoveryPasswordsManager(
|
||||
registrationRecoveryPasswords);
|
||||
UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();
|
||||
|
||||
ExternalServiceCredentialGenerator storageCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getSecureStorageServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||
ExternalServiceCredentialGenerator backupCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getSecureBackupServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||
ExternalServiceCredentialGenerator paymentsCredentialsGenerator = new ExternalServiceCredentialGenerator(
|
||||
config.getPaymentsServiceConfiguration().getUserAuthenticationTokenSharedSecret(), true);
|
||||
|
||||
AbusiveHostRules abusiveHostRules = new AbusiveHostRules(rateLimitersCluster, dynamicConfigurationManager);
|
||||
RegistrationServiceClient registrationServiceClient = new RegistrationServiceClient(config.getRegistrationServiceConfiguration().getHost(), config.getRegistrationServiceConfiguration().getPort(), config.getRegistrationServiceConfiguration().getApiKey(), config.getRegistrationServiceConfiguration().getRegistrationCaCertificate(), registrationCallbackExecutor);
|
||||
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, config.getSecureBackupServiceConfiguration());
|
||||
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, storageServiceExecutor, config.getSecureStorageServiceConfiguration());
|
||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor, keyspaceNotificationDispatchExecutor);
|
||||
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
||||
RegistrationServiceClient registrationServiceClient = new RegistrationServiceClient(
|
||||
config.getRegistrationServiceConfiguration().getHost(), config.getRegistrationServiceConfiguration().getPort(),
|
||||
config.getRegistrationServiceConfiguration().getApiKey(),
|
||||
config.getRegistrationServiceConfiguration().getRegistrationCaCertificate(), registrationCallbackExecutor);
|
||||
SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator,
|
||||
secureValueRecoveryServiceExecutor, config.getSecureBackupServiceConfiguration());
|
||||
SecureValueRecovery2Client secureValueRecovery2Client = new SecureValueRecovery2Client(svr2CredentialsGenerator,
|
||||
secureValueRecoveryServiceExecutor, config.getSvr2Configuration());
|
||||
SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator,
|
||||
storageServiceExecutor, config.getSecureStorageServiceConfiguration());
|
||||
ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor,
|
||||
keyspaceNotificationDispatchExecutor);
|
||||
DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration());
|
||||
StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts);
|
||||
StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices);
|
||||
ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster);
|
||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, Clock.systemUTC(),
|
||||
keyspaceNotificationDispatchExecutor, messageDeletionAsyncExecutor);
|
||||
MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster,
|
||||
keyspaceNotificationDispatchExecutor, messageDeliveryScheduler, messageDeletionAsyncExecutor, clock);
|
||||
PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager);
|
||||
ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster,
|
||||
config.getReportMessageConfiguration().getCounterTtl());
|
||||
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager,
|
||||
messageDeletionAsyncExecutor);
|
||||
UsernameGenerator usernameGenerator = new UsernameGenerator(config.getUsername());
|
||||
DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts,
|
||||
deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
|
||||
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, prohibitedUsernames, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, usernameGenerator,
|
||||
experimentEnrollmentManager, clock);
|
||||
deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager,
|
||||
pendingAccountsManager, secureStorageClient, secureBackupClient, secureValueRecovery2Client, clientPresenceManager,
|
||||
experimentEnrollmentManager, registrationRecoveryPasswordsManager, clock);
|
||||
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
|
||||
DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty());
|
||||
PubSubManager pubSubManager = new PubSubManager(pubsubClient, dispatchManager);
|
||||
@@ -487,8 +539,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials());
|
||||
ApnPushNotificationScheduler apnPushNotificationScheduler = new ApnPushNotificationScheduler(pushSchedulerCluster, apnSender, accountsManager);
|
||||
PushNotificationManager pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender, apnPushNotificationScheduler, pushLatencyManager, dynamicConfigurationManager);
|
||||
RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), rateLimitersCluster);
|
||||
DynamicRateLimiters dynamicRateLimiters = new DynamicRateLimiters(rateLimitersCluster, dynamicConfigurationManager);
|
||||
RateLimiters rateLimiters = RateLimiters.createAndValidate(config.getLimitsConfiguration(), dynamicConfigurationManager, rateLimitersCluster);
|
||||
ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager);
|
||||
IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(
|
||||
config.getDynamoDbTables().getIssuedReceipts().getTableName(),
|
||||
@@ -503,22 +554,36 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
SubscriptionManager subscriptionManager = new SubscriptionManager(
|
||||
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);
|
||||
|
||||
ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(accountsManager);
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
|
||||
accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters);
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager(
|
||||
registrationServiceClient, registrationRecoveryPasswordsManager);
|
||||
|
||||
final ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(
|
||||
accountsManager);
|
||||
reportMessageManager.addListener(reportedMessageMetricsListener);
|
||||
|
||||
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
|
||||
final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
final DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(
|
||||
accountsManager);
|
||||
|
||||
final MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager,
|
||||
pushNotificationManager,
|
||||
pushLatencyManager);
|
||||
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||
|
||||
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
|
||||
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||
RecaptchaClient recaptchaClient = new RecaptchaClient(
|
||||
config.getRecaptchaConfiguration().getProjectPath(),
|
||||
config.getRecaptchaConfiguration().getCredentialConfigurationJson(),
|
||||
dynamicConfigurationManager);
|
||||
HttpClient hcaptchaHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
||||
HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey(), hcaptchaHttpClient, dynamicConfigurationManager);
|
||||
CaptchaChecker captchaChecker = new CaptchaChecker(List.of(recaptchaClient, hCaptchaClient));
|
||||
|
||||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb);
|
||||
RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||
recaptchaClient, dynamicRateLimiters);
|
||||
captchaChecker, rateLimiters);
|
||||
|
||||
MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes()));
|
||||
ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager);
|
||||
@@ -554,7 +619,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new AccountDatabaseCrawlerCache(cacheCluster, AccountDatabaseCrawlerCache.ACCOUNT_CLEANER_PREFIX);
|
||||
AccountDatabaseCrawler accountCleanerAccountDatabaseCrawler = new AccountDatabaseCrawler("Account cleaner crawler",
|
||||
accountsManager,
|
||||
accountCleanerAccountDatabaseCrawlerCache, List.of(new AccountCleaner(accountsManager)),
|
||||
accountCleanerAccountDatabaseCrawlerCache, List.of(new AccountCleaner(accountsManager, accountDeletionExecutor)),
|
||||
config.getAccountDatabaseCrawlerConfiguration().getChunkSize(),
|
||||
config.getAccountDatabaseCrawlerConfiguration().getChunkIntervalMs()
|
||||
);
|
||||
@@ -577,10 +642,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
DeletedAccountsTableCrawler deletedAccountsTableCrawler = new DeletedAccountsTableCrawler(deletedAccountsManager, deletedAccountsDirectoryReconcilers, cacheCluster, recurringJobExecutor);
|
||||
|
||||
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
||||
FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey());
|
||||
FtxClient ftxClient = new FtxClient(currencyClient);
|
||||
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, ftxClient, config.getPaymentsServiceConfiguration().getPaymentCurrencies());
|
||||
HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();
|
||||
FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey());
|
||||
CoinMarketCapClient coinMarketCapClient = new CoinMarketCapClient(currencyClient, config.getPaymentsServiceConfiguration().getCoinMarketCapApiKey(), config.getPaymentsServiceConfiguration().getCoinMarketCapCurrencyIds());
|
||||
CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinMarketCapClient,
|
||||
cacheCluster, config.getPaymentsServiceConfiguration().getPaymentCurrencies(), Clock.systemUTC());
|
||||
|
||||
environment.lifecycle().manage(apnSender);
|
||||
environment.lifecycle().manage(apnPushNotificationScheduler);
|
||||
@@ -596,11 +662,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
environment.lifecycle().manage(directoryQueue);
|
||||
environment.lifecycle().manage(registrationServiceClient);
|
||||
|
||||
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker,
|
||||
rateLimiters, config.getTestDevices(), dynamicConfigurationManager);
|
||||
|
||||
StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider
|
||||
.create(AwsBasicCredentials.create(
|
||||
config.getCdnConfiguration().getAccessKey(),
|
||||
config.getCdnConfiguration().getAccessSecret()));
|
||||
S3Client cdnS3Client = S3Client.builder()
|
||||
S3Client cdnS3Client = S3Client.builder()
|
||||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().getRegion()))
|
||||
.build();
|
||||
@@ -623,7 +692,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.addFilter("RemoteDeprecationFilter", new RemoteDeprecationFilter(dynamicConfigurationManager))
|
||||
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
|
||||
|
||||
environment.jersey().register(new ContentLengthFilter(TrafficSource.HTTP));
|
||||
environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP));
|
||||
environment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
environment.jersey().register(new MetricsApplicationEventListener(TrafficSource.HTTP));
|
||||
environment.jersey()
|
||||
@@ -633,8 +702,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)));
|
||||
environment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||
environment.jersey().register(new TimestampResponseFilter());
|
||||
environment.jersey().register(new VoiceVerificationController(config.getVoiceVerificationConfiguration().getUrl(),
|
||||
config.getVoiceVerificationConfiguration().getLocales()));
|
||||
|
||||
///
|
||||
WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment = new WebSocketEnvironment<>(environment,
|
||||
@@ -642,72 +709,51 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
||||
webSocketEnvironment.setConnectListener(
|
||||
new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager,
|
||||
clientPresenceManager, websocketScheduledExecutor, experimentEnrollmentManager));
|
||||
clientPresenceManager, websocketScheduledExecutor, messageDeliveryScheduler));
|
||||
webSocketEnvironment.jersey()
|
||||
.register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||
webSocketEnvironment.jersey().register(new ContentLengthFilter(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(new KeepAliveController(clientPresenceManager));
|
||||
|
||||
// these should be common, but use @Auth DisabledPermittedAccount, which isn’t supported yet on websocket
|
||||
environment.jersey().register(
|
||||
new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters,
|
||||
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
|
||||
recaptchaClient, pushNotificationManager, changeNumberManager, backupCredentialsGenerator));
|
||||
new AccountController(pendingAccountsManager, accountsManager, rateLimiters,
|
||||
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator,
|
||||
registrationCaptchaManager, pushNotificationManager, changeNumberManager,
|
||||
registrationLockVerificationManager, registrationRecoveryPasswordsManager, usernameHashZkProofVerifier, clock));
|
||||
|
||||
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
|
||||
|
||||
final List<Object> commonControllers = Lists.newArrayList(
|
||||
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()),
|
||||
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()),
|
||||
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock),
|
||||
new ChallengeController(rateLimitChallengeManager),
|
||||
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
|
||||
new DirectoryController(directoryCredentialsGenerator),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new),
|
||||
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor),
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RemoteConfigController(remoteConfigsManager, adminEventLogger, config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
new SecureBackupController(backupCredentialsGenerator),
|
||||
new SecureStorageController(storageCredentialsGenerator),
|
||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
|
||||
config.getCdnConfiguration().getBucket())
|
||||
);
|
||||
if (config.getSubscription() != null && config.getBoost() != null) {
|
||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getBoost(),
|
||||
config.getGift(), subscriptionManager, stripeManager, zkReceiptOperations, issuedReceiptsManager,
|
||||
profileBadgeConverter, resourceBundleLevelTranslator));
|
||||
}
|
||||
boolean registeredSpamFilter = false;
|
||||
ReportSpamTokenProvider reportSpamTokenProvider = null;
|
||||
|
||||
for (Object controller : commonControllers) {
|
||||
environment.jersey().register(controller);
|
||||
webSocketEnvironment.jersey().register(controller);
|
||||
}
|
||||
|
||||
boolean registeredAbusiveMessageFilter = false;
|
||||
|
||||
for (final AbusiveMessageFilter filter : ServiceLoader.load(AbusiveMessageFilter.class)) {
|
||||
if (filter.getClass().isAnnotationPresent(FilterAbusiveMessages.class)) {
|
||||
for (final SpamFilter filter : ServiceLoader.load(SpamFilter.class)) {
|
||||
if (filter.getClass().isAnnotationPresent(FilterSpam.class)) {
|
||||
try {
|
||||
filter.configure(config.getAbusiveMessageFilterConfiguration().getEnvironment());
|
||||
filter.configure(config.getSpamFilterConfiguration().getEnvironment());
|
||||
|
||||
ReportSpamTokenProvider thisProvider = filter.getReportSpamTokenProvider();
|
||||
if (reportSpamTokenProvider == null) {
|
||||
reportSpamTokenProvider = thisProvider;
|
||||
} else if (thisProvider != null) {
|
||||
log.info("Multiple spam report token providers found. Using the first.");
|
||||
}
|
||||
|
||||
filter.getReportedMessageListeners().forEach(reportMessageManager::addListener);
|
||||
|
||||
environment.lifecycle().manage(filter);
|
||||
environment.jersey().register(filter);
|
||||
webSocketEnvironment.jersey().register(filter);
|
||||
|
||||
log.info("Registered abusive message filter: {}", filter.getClass().getName());
|
||||
registeredAbusiveMessageFilter = true;
|
||||
log.info("Registered spam filter: {}", filter.getClass().getName());
|
||||
registeredSpamFilter = true;
|
||||
} catch (final Exception e) {
|
||||
log.warn("Failed to register abusive message filter: {}", filter.getClass().getName(), e);
|
||||
log.warn("Failed to register spam filter: {}", filter.getClass().getName(), e);
|
||||
}
|
||||
} else {
|
||||
log.warn("Abusive message filter {} not annotated with @FilterAbusiveMessages and will not be installed",
|
||||
log.warn("Spam filter {} not annotated with @FilterSpam and will not be installed",
|
||||
filter.getClass().getName());
|
||||
}
|
||||
|
||||
@@ -717,10 +763,64 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
if (!registeredAbusiveMessageFilter) {
|
||||
log.warn("No abusive message filters installed");
|
||||
if (!registeredSpamFilter) {
|
||||
log.warn("No spam filters installed");
|
||||
}
|
||||
|
||||
if (reportSpamTokenProvider == null) {
|
||||
log.warn("No spam-reporting token providers found; using default (no-op) provider as a default");
|
||||
reportSpamTokenProvider = ReportSpamTokenProvider.noop();
|
||||
}
|
||||
|
||||
final List<Object> commonControllers = Lists.newArrayList(
|
||||
new AccountControllerV2(accountsManager, changeNumberManager, phoneVerificationTokenManager,
|
||||
registrationLockVerificationManager, rateLimiters),
|
||||
new ArtController(rateLimiters, artCredentialsGenerator),
|
||||
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 CallLinkController(callLinkCredentialsGenerator),
|
||||
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock),
|
||||
new ChallengeController(rateLimitChallengeManager),
|
||||
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()),
|
||||
new DirectoryController(directoryCredentialsGenerator),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new),
|
||||
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager,
|
||||
messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor,
|
||||
messageDeliveryScheduler, reportSpamTokenProvider),
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
|
||||
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
|
||||
config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
|
||||
rateLimiters),
|
||||
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
|
||||
config.getRemoteConfigConfiguration().getAuthorizedTokens(),
|
||||
config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
||||
new SecureStorageController(storageCredentialsGenerator),
|
||||
new SecureValueRecovery2Controller(svr2CredentialsGenerator, config.getSvr2Configuration()),
|
||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
|
||||
config.getCdnConfiguration().getBucket()),
|
||||
new VerificationController(registrationServiceClient, new VerificationSessionManager(verificationSessions),
|
||||
pushNotificationManager, registrationCaptchaManager, registrationRecoveryPasswordsManager, rateLimiters,
|
||||
accountsManager, clock)
|
||||
);
|
||||
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
|
||||
resourceBundleLevelTranslator));
|
||||
}
|
||||
|
||||
for (Object controller : commonControllers) {
|
||||
environment.jersey().register(controller);
|
||||
webSocketEnvironment.jersey().register(controller);
|
||||
}
|
||||
|
||||
|
||||
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment = new WebSocketEnvironment<>(environment,
|
||||
webSocketEnvironment.getRequestLog(), 60000);
|
||||
provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
|
||||
@@ -730,6 +830,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
registerCorsFilter(environment);
|
||||
registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);
|
||||
registerProviders(environment, webSocketEnvironment, provisioningEnvironment);
|
||||
|
||||
environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
|
||||
@@ -771,6 +872,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
GarbageCollectionGauges.registerMetrics();
|
||||
}
|
||||
|
||||
|
||||
private void registerProviders(Environment environment,
|
||||
WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment,
|
||||
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment) {
|
||||
environment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
|
||||
webSocketEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
|
||||
provisioningEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
|
||||
}
|
||||
|
||||
private void registerExceptionMappers(Environment environment,
|
||||
WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment,
|
||||
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment) {
|
||||
@@ -784,7 +894,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new DeviceLimitExceededExceptionMapper(),
|
||||
new ServerRejectedExceptionMapper(),
|
||||
new ImpossiblePhoneNumberExceptionMapper(),
|
||||
new NonNormalizedPhoneNumberExceptionMapper()
|
||||
new NonNormalizedPhoneNumberExceptionMapper(),
|
||||
new RegistrationServiceSenderExceptionMapper(),
|
||||
new JsonMappingExceptionMapper()
|
||||
).forEach(exceptionMapper -> {
|
||||
environment.jersey().register(exceptionMapper);
|
||||
webSocketEnvironment.jersey().register(exceptionMapper);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.abuse;
|
||||
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import javax.ws.rs.container.ContainerRequestFilter;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* An abusive message filter is a {@link ContainerRequestFilter} that filters requests to message-sending endpoints to
|
||||
* detect and respond to patterns of abusive behavior.
|
||||
* <p/>
|
||||
* Abusive message filters are managed components that are generally loaded dynamically via a
|
||||
* {@link java.util.ServiceLoader}. Their {@link #configure(String)} method will be called prior to be adding to the
|
||||
* server's pool of {@link Managed} objects.
|
||||
* <p/>
|
||||
* Abusive message filters must be annotated with {@link FilterAbusiveMessages}, a name binding annotation that
|
||||
* restricts the endpoints to which the filter may apply.
|
||||
*/
|
||||
public interface AbusiveMessageFilter extends ContainerRequestFilter, Managed {
|
||||
|
||||
/**
|
||||
* Configures this abusive message filter. This method will be called before the filter is added to the server's pool
|
||||
* of managed objects and before the server processes any requests.
|
||||
*
|
||||
* @param environmentName the name of the environment in which this filter is running (e.g. "staging" or "production")
|
||||
* @throws IOException if the filter could not read its configuration source for any reason
|
||||
*/
|
||||
void configure(String environmentName) throws IOException;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class AuthenticationCredentials {
|
||||
private static final String V2_PREFIX = "2.";
|
||||
|
||||
private final String hashedAuthenticationToken;
|
||||
private final String salt;
|
||||
|
||||
public enum Version {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
public static final Version CURRENT_VERSION = Version.V2;
|
||||
|
||||
public AuthenticationCredentials(String hashedAuthenticationToken, String salt) {
|
||||
this.hashedAuthenticationToken = hashedAuthenticationToken;
|
||||
this.salt = salt;
|
||||
}
|
||||
|
||||
public AuthenticationCredentials(String authenticationToken) {
|
||||
this.salt = String.valueOf(Math.abs(new SecureRandom().nextInt()));
|
||||
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() {
|
||||
return hashedAuthenticationToken;
|
||||
}
|
||||
|
||||
public String getSalt() {
|
||||
return salt;
|
||||
}
|
||||
|
||||
public boolean verify(String authenticationToken) {
|
||||
final String theirValue = switch (getVersion()) {
|
||||
case V1 -> getV1HashedValue(salt, authenticationToken);
|
||||
case V2 -> getV2HashedValue(salt, authenticationToken);
|
||||
};
|
||||
return MessageDigest.isEqual(theirValue.getBytes(StandardCharsets.UTF_8), this.hashedAuthenticationToken.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String getV1HashedValue(String salt, String token) {
|
||||
try {
|
||||
return new String(Hex.encodeHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -27,9 +27,11 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
public class BaseAccountAuthenticator {
|
||||
|
||||
private static final String AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "authentication");
|
||||
private static final String ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class,
|
||||
"enabledNotRequiredAuthentication");
|
||||
private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded";
|
||||
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";
|
||||
private static final String AUTHENTICATION_ENABLED_REQUIRED_TAG_NAME = "enabledRequired";
|
||||
private static final String ENABLED_TAG_NAME = "enabled";
|
||||
private static final String AUTHENTICATION_HAS_STORY_CAPABILITY = "hasStoryCapability";
|
||||
|
||||
private static final String STORY_ADOPTION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "storyAdoption");
|
||||
@@ -37,6 +39,9 @@ public class BaseAccountAuthenticator {
|
||||
private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(BaseAccountAuthenticator.class, "daysSinceLastSeen");
|
||||
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";
|
||||
|
||||
@VisibleForTesting
|
||||
static final char DEVICE_ID_SEPARATOR = '.';
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -54,7 +59,7 @@ public class BaseAccountAuthenticator {
|
||||
final String identifier;
|
||||
final long deviceId;
|
||||
|
||||
final int deviceIdSeparatorIndex = basicUsername.indexOf('.');
|
||||
final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR);
|
||||
|
||||
if (deviceIdSeparatorIndex == -1) {
|
||||
identifier = basicUsername;
|
||||
@@ -99,26 +104,34 @@ public class BaseAccountAuthenticator {
|
||||
}
|
||||
|
||||
if (enabledRequired) {
|
||||
if (!device.get().isEnabled()) {
|
||||
final boolean deviceDisabled = !device.get().isEnabled();
|
||||
if (deviceDisabled) {
|
||||
failureReason = "deviceDisabled";
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (!account.get().isEnabled()) {
|
||||
final boolean accountDisabled = !account.get().isEnabled();
|
||||
if (accountDisabled) {
|
||||
failureReason = "accountDisabled";
|
||||
}
|
||||
if (accountDisabled || deviceDisabled) {
|
||||
return Optional.empty();
|
||||
}
|
||||
} else {
|
||||
Metrics.counter(ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME,
|
||||
ENABLED_TAG_NAME, String.valueOf(device.get().isEnabled() && account.get().isEnabled()),
|
||||
IS_PRIMARY_DEVICE_TAG, String.valueOf(device.get().isMaster()))
|
||||
.increment();
|
||||
}
|
||||
|
||||
AuthenticationCredentials deviceAuthenticationCredentials = device.get().getAuthenticationCredentials();
|
||||
if (deviceAuthenticationCredentials.verify(basicCredentials.getPassword())) {
|
||||
SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash();
|
||||
if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) {
|
||||
succeeded = true;
|
||||
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
|
||||
if (deviceAuthenticationCredentials.getVersion() != AuthenticationCredentials.CURRENT_VERSION) {
|
||||
if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) {
|
||||
authenticatedAccount = accountsManager.updateDeviceAuthentication(
|
||||
authenticatedAccount,
|
||||
device.get(),
|
||||
new AuthenticationCredentials(basicCredentials.getPassword())); // new credentials have current version
|
||||
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
|
||||
}
|
||||
return Optional.of(new AuthenticatedAccount(
|
||||
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
|
||||
@@ -130,8 +143,7 @@ public class BaseAccountAuthenticator {
|
||||
return Optional.empty();
|
||||
} finally {
|
||||
Tags tags = Tags.of(
|
||||
AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded),
|
||||
AUTHENTICATION_ENABLED_REQUIRED_TAG_NAME, String.valueOf(enabledRequired));
|
||||
AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded));
|
||||
|
||||
if (StringUtils.isNotBlank(failureReason)) {
|
||||
tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason);
|
||||
@@ -146,9 +158,18 @@ public class BaseAccountAuthenticator {
|
||||
|
||||
@VisibleForTesting
|
||||
public Account updateLastSeen(Account account, Device device) {
|
||||
final long lastSeenOffsetSeconds = Math.abs(account.getUuid().getLeastSignificantBits()) % ChronoUnit.DAYS.getDuration().toSeconds();
|
||||
// compute a non-negative integer between 0 and 86400.
|
||||
long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits());
|
||||
final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds();
|
||||
|
||||
// produce a truncated timestamp which is either today at UTC midnight
|
||||
// or yesterday at UTC midnight, based on per-user randomized offset used.
|
||||
final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock, Duration.ofSeconds(lastSeenOffsetSeconds).negated());
|
||||
|
||||
// only update the device's last seen time when it falls behind the truncated timestamp.
|
||||
// this ensure a few things:
|
||||
// (1) each account will only update last-seen at most once per day
|
||||
// (2) these updates will occur throughout the day rather than all occurring at UTC midnight.
|
||||
if (device.getLastSeen() < todayInMillisWithOffset) {
|
||||
Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isMaster()))
|
||||
.record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays());
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class ExternalServiceCredentialGenerator {
|
||||
|
||||
private final byte[] key;
|
||||
private final byte[] userIdKey;
|
||||
private final boolean usernameDerivation;
|
||||
private final boolean prependUsername;
|
||||
private final Clock clock;
|
||||
|
||||
public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey) {
|
||||
this(key, userIdKey, true, true);
|
||||
}
|
||||
|
||||
public ExternalServiceCredentialGenerator(byte[] key, boolean prependUsername) {
|
||||
this(key, new byte[0], false, prependUsername);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation) {
|
||||
this(key, userIdKey, usernameDerivation, true);
|
||||
}
|
||||
|
||||
public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation,
|
||||
boolean prependUsername) {
|
||||
this(key, userIdKey, usernameDerivation, prependUsername, Clock.systemUTC());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation,
|
||||
boolean prependUsername, Clock clock) {
|
||||
this.key = key;
|
||||
this.userIdKey = userIdKey;
|
||||
this.usernameDerivation = usernameDerivation;
|
||||
this.prependUsername = prependUsername;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public ExternalServiceCredentials generateFor(String identity) {
|
||||
Mac mac = getMacInstance();
|
||||
String username = getUserId(identity, mac, usernameDerivation);
|
||||
long currentTimeSeconds = clock.millis() / 1000;
|
||||
String prefix = username + ":" + currentTimeSeconds;
|
||||
String output = Hex.encodeHexString(Util.truncate(getHmac(key, prefix.getBytes(), mac), 10));
|
||||
String token = (prependUsername ? prefix : currentTimeSeconds) + ":" + output;
|
||||
|
||||
return new ExternalServiceCredentials(username, token);
|
||||
}
|
||||
|
||||
private String getUserId(String number, Mac mac, boolean usernameDerivation) {
|
||||
if (usernameDerivation) return Hex.encodeHexString(Util.truncate(getHmac(userIdKey, number.getBytes(), mac), 10));
|
||||
else return number;
|
||||
}
|
||||
|
||||
private Mac getMacInstance() {
|
||||
try {
|
||||
return Mac.getInstance("HmacSHA256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getHmac(byte[] key, byte[] input, Mac mac) {
|
||||
try {
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return mac.doFinal(input);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,28 +6,6 @@
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
public record ExternalServiceCredentials(String username, String password) {
|
||||
|
||||
public class ExternalServiceCredentials {
|
||||
|
||||
@JsonProperty
|
||||
private String username;
|
||||
|
||||
@JsonProperty
|
||||
private String password;
|
||||
|
||||
public ExternalServiceCredentials(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public ExternalServiceCredentials() {}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256ToHexString;
|
||||
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString;
|
||||
import static org.whispersystems.textsecuregcm.util.HmacUtils.hmacHexStringsEqual;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
|
||||
public class ExternalServiceCredentialsGenerator {
|
||||
|
||||
private static final String DELIMITER = ":";
|
||||
|
||||
private static final int TRUNCATED_SIGNATURE_LENGTH = 10;
|
||||
|
||||
private final byte[] key;
|
||||
|
||||
private final byte[] userDerivationKey;
|
||||
|
||||
private final boolean prependUsername;
|
||||
|
||||
private final boolean truncateSignature;
|
||||
|
||||
private final String usernameTimestampPrefix;
|
||||
|
||||
private final Function<Instant, Instant> usernameTimestampTruncator;
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
private final int derivedUsernameTruncateLength;
|
||||
|
||||
public static ExternalServiceCredentialsGenerator.Builder builder(final byte[] key) {
|
||||
return new Builder(key);
|
||||
}
|
||||
|
||||
private ExternalServiceCredentialsGenerator(
|
||||
final byte[] key,
|
||||
final byte[] userDerivationKey,
|
||||
final boolean prependUsername,
|
||||
final boolean truncateSignature,
|
||||
final int derivedUsernameTruncateLength,
|
||||
final String usernameTimestampPrefix,
|
||||
final Function<Instant, Instant> usernameTimestampTruncator,
|
||||
final Clock clock) {
|
||||
this.key = requireNonNull(key);
|
||||
this.userDerivationKey = requireNonNull(userDerivationKey);
|
||||
this.prependUsername = prependUsername;
|
||||
this.truncateSignature = truncateSignature;
|
||||
this.usernameTimestampPrefix = usernameTimestampPrefix;
|
||||
this.usernameTimestampTruncator = usernameTimestampTruncator;
|
||||
this.clock = requireNonNull(clock);
|
||||
this.derivedUsernameTruncateLength = derivedUsernameTruncateLength;
|
||||
|
||||
if (hasUsernameTimestampPrefix() ^ hasUsernameTimestampTruncator()) {
|
||||
throw new RuntimeException("Configured to have only one of (usernameTimestampPrefix, usernameTimestampTruncator)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience method for the case of identity in the form of {@link UUID}.
|
||||
* @param uuid identity to generate credentials for
|
||||
* @return an instance of {@link ExternalServiceCredentials}
|
||||
*/
|
||||
public ExternalServiceCredentials generateForUuid(final UUID uuid) {
|
||||
return generateFor(uuid.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates `ExternalServiceCredentials` for the given identity following this generator's configuration.
|
||||
* @param identity identity string to generate credentials for
|
||||
* @return an instance of {@link ExternalServiceCredentials}
|
||||
*/
|
||||
public ExternalServiceCredentials generateFor(final String identity) {
|
||||
if (usernameIsTimestamp()) {
|
||||
throw new RuntimeException("Configured to use timestamp as username");
|
||||
}
|
||||
|
||||
return generate(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates `ExternalServiceCredentials` using a prefix concatenated with a truncated timestamp as the username, following this generator's configuration.
|
||||
* @return an instance of {@link ExternalServiceCredentials}
|
||||
*/
|
||||
public ExternalServiceCredentials generateWithTimestampAsUsername() {
|
||||
if (!usernameIsTimestamp()) {
|
||||
throw new RuntimeException("Not configured to use timestamp as username");
|
||||
}
|
||||
|
||||
final String truncatedTimestampSeconds = String.valueOf(usernameTimestampTruncator.apply(clock.instant()).getEpochSecond());
|
||||
return generate(usernameTimestampPrefix + DELIMITER + truncatedTimestampSeconds);
|
||||
}
|
||||
|
||||
private ExternalServiceCredentials generate(final String identity) {
|
||||
final String username = shouldDeriveUsername()
|
||||
? hmac256TruncatedToHexString(userDerivationKey, identity, derivedUsernameTruncateLength)
|
||||
: identity;
|
||||
|
||||
final long currentTimeSeconds = currentTimeSeconds();
|
||||
|
||||
final String dataToSign = usernameIsTimestamp() ? username : username + DELIMITER + currentTimeSeconds;
|
||||
|
||||
final String signature = truncateSignature
|
||||
? hmac256TruncatedToHexString(key, dataToSign, TRUNCATED_SIGNATURE_LENGTH)
|
||||
: hmac256ToHexString(key, dataToSign);
|
||||
|
||||
final String token = (prependUsername ? dataToSign : currentTimeSeconds) + DELIMITER + signature;
|
||||
|
||||
return new ExternalServiceCredentials(username, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* In certain cases, identity (as it was passed to `generate` method)
|
||||
* is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself.
|
||||
* For such cases, this method returns the value of the identity string.
|
||||
* @param password `password` part of `ExternalServiceCredentials`
|
||||
* @return non-empty optional with an identity string value, or empty if value can't be extracted.
|
||||
*/
|
||||
public Optional<String> identityFromSignature(final String password) {
|
||||
// for some generators, identity in the clear is just not a part of the password
|
||||
if (!prependUsername || shouldDeriveUsername() || StringUtils.isBlank(password)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// checking for the case of unexpected format
|
||||
if (StringUtils.countMatches(password, DELIMITER) == 2) {
|
||||
if (usernameIsTimestamp()) {
|
||||
final int indexOfSecondDelimiter = password.indexOf(DELIMITER, password.indexOf(DELIMITER) + 1);
|
||||
return Optional.of(password.substring(0, indexOfSecondDelimiter));
|
||||
} else {
|
||||
return Optional.of(password.substring(0, password.indexOf(DELIMITER)));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an instance of {@link ExternalServiceCredentials} object, checks that the password
|
||||
* matches the username taking into accound this generator's configuration.
|
||||
* @param credentials an instance of {@link ExternalServiceCredentials}
|
||||
* @return An optional with a timestamp (seconds) of when the credentials were generated,
|
||||
* or an empty optional if the password doesn't match the username for any reason (including malformed data)
|
||||
*/
|
||||
public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials credentials) {
|
||||
final String[] parts = requireNonNull(credentials).password().split(DELIMITER);
|
||||
final String timestampSeconds;
|
||||
final String actualSignature;
|
||||
|
||||
// making sure password format matches our expectations based on the generator configuration
|
||||
if (parts.length == 3 && prependUsername) {
|
||||
final String username = usernameIsTimestamp() ? parts[0] + DELIMITER + parts[1] : parts[0];
|
||||
// username has to match the one from `credentials`
|
||||
if (!credentials.username().equals(username)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
timestampSeconds = parts[1];
|
||||
actualSignature = parts[2];
|
||||
} else if (parts.length == 2 && !prependUsername) {
|
||||
timestampSeconds = parts[0];
|
||||
actualSignature = parts[1];
|
||||
} else {
|
||||
// unexpected password format
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final String signedData = usernameIsTimestamp() ? credentials.username() : credentials.username() + DELIMITER + timestampSeconds;
|
||||
final String expectedSignature = truncateSignature
|
||||
? hmac256TruncatedToHexString(key, signedData, TRUNCATED_SIGNATURE_LENGTH)
|
||||
: hmac256ToHexString(key, signedData);
|
||||
|
||||
// if the signature is valid it's safe to parse the `timestampSeconds` string into Long
|
||||
return hmacHexStringsEqual(expectedSignature, actualSignature)
|
||||
? Optional.of(Long.valueOf(timestampSeconds))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an instance of {@link ExternalServiceCredentials} object and the max allowed age for those credentials,
|
||||
* checks if credentials are valid and not expired.
|
||||
* @param credentials an instance of {@link ExternalServiceCredentials}
|
||||
* @param maxAgeSeconds age in seconds
|
||||
* @return An optional with a timestamp (seconds) of when the credentials were generated,
|
||||
* or an empty optional if the password doesn't match the username for any reason (including malformed data)
|
||||
*/
|
||||
public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials credentials, final long maxAgeSeconds) {
|
||||
return validateAndGetTimestamp(credentials)
|
||||
.filter(ts -> currentTimeSeconds() - ts <= maxAgeSeconds);
|
||||
}
|
||||
|
||||
private boolean shouldDeriveUsername() {
|
||||
return userDerivationKey.length > 0;
|
||||
}
|
||||
|
||||
private boolean hasUsernameTimestampPrefix() {
|
||||
return usernameTimestampPrefix != null;
|
||||
}
|
||||
|
||||
private boolean hasUsernameTimestampTruncator() {
|
||||
return usernameTimestampTruncator != null;
|
||||
}
|
||||
|
||||
private boolean usernameIsTimestamp() {
|
||||
return hasUsernameTimestampPrefix() && hasUsernameTimestampTruncator();
|
||||
}
|
||||
|
||||
private long currentTimeSeconds() {
|
||||
return clock.instant().getEpochSecond();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private final byte[] key;
|
||||
|
||||
private byte[] userDerivationKey = new byte[0];
|
||||
|
||||
private boolean prependUsername = true;
|
||||
|
||||
private boolean truncateSignature = true;
|
||||
|
||||
private int derivedUsernameTruncateLength = 10;
|
||||
|
||||
private String usernameTimestampPrefix = null;
|
||||
|
||||
private Function<Instant, Instant> usernameTimestampTruncator = null;
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
|
||||
private Builder(final byte[] key) {
|
||||
this.key = requireNonNull(key);
|
||||
}
|
||||
|
||||
public Builder withUserDerivationKey(final byte[] userDerivationKey) {
|
||||
Validate.isTrue(requireNonNull(userDerivationKey).length > 0, "userDerivationKey must not be empty");
|
||||
this.userDerivationKey = userDerivationKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withClock(final Clock clock) {
|
||||
this.clock = requireNonNull(clock);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withDerivedUsernameTruncateLength(int truncateLength) {
|
||||
Validate.inclusiveBetween(10, 32, truncateLength);
|
||||
this.derivedUsernameTruncateLength = truncateLength;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder prependUsername(final boolean prependUsername) {
|
||||
this.prependUsername = prependUsername;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder truncateSignature(final boolean truncateSignature) {
|
||||
this.truncateSignature = truncateSignature;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withUsernameTimestampTruncatorAndPrefix(final Function<Instant, Instant> truncator, final String prefix) {
|
||||
this.usernameTimestampTruncator = truncator;
|
||||
this.usernameTimestampPrefix = prefix;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ExternalServiceCredentialsGenerator build() {
|
||||
return new ExternalServiceCredentialsGenerator(
|
||||
key, userDerivationKey, prependUsername, truncateSignature, derivedUsernameTruncateLength, usernameTimestampPrefix, usernameTimestampTruncator, clock);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.ServerErrorException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
|
||||
public class PhoneVerificationTokenManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PhoneVerificationTokenManager.class);
|
||||
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds();
|
||||
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
|
||||
public PhoneVerificationTokenManager(final RegistrationServiceClient registrationServiceClient,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a {@link PhoneVerificationRequest} has a token that verifies the caller has confirmed access to the e164
|
||||
* number
|
||||
*
|
||||
* @param number the e164 presented for verification
|
||||
* @param request the request with exactly one verification token (RegistrationService sessionId or registration
|
||||
* recovery password)
|
||||
* @return if verification was successful, returns the verification type
|
||||
* @throws BadRequestException if the number does not match the sessionId’s number, or the remote service rejects
|
||||
* the session ID as invalid
|
||||
* @throws NotAuthorizedException if the session is not verified
|
||||
* @throws ForbiddenException if the recovery password is not valid
|
||||
* @throws InterruptedException if verification did not complete before a timeout
|
||||
*/
|
||||
public PhoneVerificationRequest.VerificationType verify(final String number, final PhoneVerificationRequest request)
|
||||
throws InterruptedException {
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = request.verificationType();
|
||||
switch (verificationType) {
|
||||
case SESSION -> verifyBySessionId(number, request.decodeSessionId());
|
||||
case RECOVERY_PASSWORD -> verifyByRecoveryPassword(number, request.recoveryPassword());
|
||||
}
|
||||
|
||||
return verificationType;
|
||||
}
|
||||
|
||||
private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {
|
||||
try {
|
||||
final RegistrationServiceSession session = registrationServiceClient
|
||||
.getSession(sessionId, REGISTRATION_RPC_TIMEOUT)
|
||||
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.orElseThrow(() -> new NotAuthorizedException("session not verified"));
|
||||
|
||||
if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) {
|
||||
throw new BadRequestException("number does not match session");
|
||||
}
|
||||
if (!session.verified()) {
|
||||
throw new NotAuthorizedException("session not verified");
|
||||
}
|
||||
} catch (final ExecutionException e) {
|
||||
|
||||
if (e.getCause() instanceof StatusRuntimeException grpcRuntimeException) {
|
||||
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Registration service failure", e);
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
|
||||
|
||||
} catch (final CancellationException | TimeoutException e) {
|
||||
|
||||
logger.error("Registration service failure", e);
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyByRecoveryPassword(final String number, final byte[] recoveryPassword)
|
||||
throws InterruptedException {
|
||||
try {
|
||||
final boolean verified = registrationRecoveryPasswordsManager.verify(number, recoveryPassword)
|
||||
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!verified) {
|
||||
throw new ForbiddenException("recoveryPassword couldn't be verified");
|
||||
}
|
||||
} catch (final ExecutionException | TimeoutException e) {
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
public class RegistrationLockVerificationManager {
|
||||
public enum Flow {
|
||||
REGISTRATION,
|
||||
CHANGE_NUMBER
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static final int FAILURE_HTTP_STATUS = 423;
|
||||
|
||||
private static final String EXPIRED_REGISTRATION_LOCK_COUNTER_NAME =
|
||||
name(RegistrationLockVerificationManager.class, "expiredRegistrationLock");
|
||||
private static final String REQUIRED_REGISTRATION_LOCK_COUNTER_NAME =
|
||||
name(RegistrationLockVerificationManager.class, "requiredRegistrationLock");
|
||||
private static final String ALREADY_LOCKED_TAG_NAME = "alreadyLocked";
|
||||
private static final String REGISTRATION_LOCK_VERIFICATION_FLOW_TAG_NAME = "flow";
|
||||
private static final String REGISTRATION_LOCK_MATCHES_TAG_NAME = "registrationLockMatches";
|
||||
private static final String PHONE_VERIFICATION_TYPE_TAG_NAME = "phoneVerificationType";
|
||||
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final ClientPresenceManager clientPresenceManager;
|
||||
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public RegistrationLockVerificationManager(
|
||||
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
|
||||
final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator, final RateLimiters rateLimiters) {
|
||||
this.accounts = accounts;
|
||||
this.clientPresenceManager = clientPresenceManager;
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the given registration lock credentials against the account’s current registration lock, if any
|
||||
*
|
||||
* @param account
|
||||
* @param clientRegistrationLock
|
||||
* @throws RateLimitExceededException
|
||||
* @throws WebApplicationException
|
||||
*/
|
||||
public void verifyRegistrationLock(final Account account, @Nullable final String clientRegistrationLock,
|
||||
final String userAgent,
|
||||
final Flow flow,
|
||||
final PhoneVerificationRequest.VerificationType phoneVerificationType
|
||||
) throws RateLimitExceededException, WebApplicationException {
|
||||
|
||||
final Tags expiredTags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(REGISTRATION_LOCK_VERIFICATION_FLOW_TAG_NAME, flow.name()),
|
||||
Tag.of(PHONE_VERIFICATION_TYPE_TAG_NAME, phoneVerificationType.name())
|
||||
);
|
||||
|
||||
final StoredRegistrationLock existingRegistrationLock = account.getRegistrationLock();
|
||||
|
||||
switch (existingRegistrationLock.getStatus()) {
|
||||
case EXPIRED:
|
||||
Metrics.counter(EXPIRED_REGISTRATION_LOCK_COUNTER_NAME, expiredTags).increment();
|
||||
return;
|
||||
case ABSENT:
|
||||
return;
|
||||
case REQUIRED:
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException("Unexpected status: " + existingRegistrationLock.getStatus());
|
||||
}
|
||||
|
||||
if (!Util.isEmpty(clientRegistrationLock)) {
|
||||
rateLimiters.getPinLimiter().validate(account.getNumber());
|
||||
}
|
||||
|
||||
final String phoneNumber = account.getNumber();
|
||||
final boolean registrationLockMatches = existingRegistrationLock.verify(clientRegistrationLock);
|
||||
final boolean alreadyLocked = account.hasLockedCredentials();
|
||||
|
||||
final Tags additionalTags = expiredTags.and(
|
||||
REGISTRATION_LOCK_MATCHES_TAG_NAME, Boolean.toString(registrationLockMatches),
|
||||
ALREADY_LOCKED_TAG_NAME, Boolean.toString(alreadyLocked)
|
||||
);
|
||||
|
||||
Metrics.counter(REQUIRED_REGISTRATION_LOCK_COUNTER_NAME, additionalTags).increment();
|
||||
|
||||
final DistributionSummary registrationLockIdleDays = DistributionSummary
|
||||
.builder(name(RegistrationLockVerificationManager.class, "registrationLockIdleDays"))
|
||||
.tags(additionalTags)
|
||||
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(Metrics.globalRegistry);
|
||||
|
||||
final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
|
||||
final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
|
||||
|
||||
registrationLockIdleDays.record(timeSinceLastSeen.toDays());
|
||||
|
||||
if (!registrationLockMatches) {
|
||||
// At this point, the client verified ownership of the phone number but doesn’t have the reglock PIN.
|
||||
// Freezing the existing account credentials will definitively start the reglock timeout.
|
||||
// Until the timeout, the current reglock can still be supplied,
|
||||
// along with phone number verification, to restore access.
|
||||
/*
|
||||
|
||||
final Account updatedAccount;
|
||||
if (!alreadyLocked) {
|
||||
updatedAccount = accounts.update(existingAccount, Account::lockAuthenticationCredentials);
|
||||
} else {
|
||||
updatedAccount = existingAccount;
|
||||
}
|
||||
|
||||
List<Long> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
|
||||
clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds);
|
||||
*/
|
||||
final ExternalServiceCredentials existingBackupCredentials =
|
||||
backupServiceCredentialGenerator.generateForUuid(account.getUuid());
|
||||
|
||||
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
|
||||
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining().toMillis(),
|
||||
existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null))
|
||||
.build());
|
||||
}
|
||||
|
||||
rateLimiters.getPinLimiter().clear(phoneNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
|
||||
public record SaltedTokenHash(String hash, String salt) {
|
||||
|
||||
public enum Version {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
public static final Version CURRENT_VERSION = Version.V2;
|
||||
|
||||
private static final String V2_PREFIX = "2.";
|
||||
|
||||
private static final byte[] AUTH_TOKEN_HKDF_INFO = "authtoken".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final int SALT_SIZE = 16;
|
||||
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
|
||||
public static SaltedTokenHash generateFor(final String token) {
|
||||
final String salt = generateSalt();
|
||||
final String hash = calculateV2Hash(salt, token);
|
||||
return new SaltedTokenHash(hash, salt);
|
||||
}
|
||||
|
||||
public Version getVersion() {
|
||||
return hash.startsWith(V2_PREFIX) ? Version.V2 : Version.V1;
|
||||
}
|
||||
|
||||
public boolean verify(final String token) {
|
||||
final String theirValue = switch (getVersion()) {
|
||||
case V1 -> calculateV1Hash(salt, token);
|
||||
case V2 -> calculateV2Hash(salt, token);
|
||||
};
|
||||
return MessageDigest.isEqual(
|
||||
theirValue.getBytes(StandardCharsets.UTF_8),
|
||||
hash.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String generateSalt() {
|
||||
final byte[] salt = new byte[SALT_SIZE];
|
||||
SECURE_RANDOM.nextBytes(salt);
|
||||
return HexFormat.of().formatHex(salt);
|
||||
}
|
||||
|
||||
private static String calculateV1Hash(final String salt, final String token) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String calculateV2Hash(final String salt, final String token) {
|
||||
final byte[] secret = HKDF.deriveSecrets(
|
||||
token.getBytes(StandardCharsets.UTF_8), // key
|
||||
salt.getBytes(StandardCharsets.UTF_8), // salt
|
||||
AUTH_TOKEN_HKDF_INFO,
|
||||
32);
|
||||
return V2_PREFIX + HexFormat.of().formatHex(secret);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,81 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class StoredRegistrationLock {
|
||||
public enum Status {
|
||||
REQUIRED,
|
||||
EXPIRED,
|
||||
ABSENT
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static final Duration REGISTRATION_LOCK_EXPIRATION_DAYS = Duration.ofDays(7);
|
||||
|
||||
private final Optional<String> registrationLock;
|
||||
|
||||
private final Optional<String> registrationLockSalt;
|
||||
|
||||
private final long lastSeen;
|
||||
private final Instant lastSeen;
|
||||
|
||||
public StoredRegistrationLock(Optional<String> registrationLock, Optional<String> registrationLockSalt, long lastSeen) {
|
||||
/**
|
||||
* @return milliseconds since the last time the account was seen.
|
||||
*/
|
||||
private long timeSinceLastSeen() {
|
||||
return System.currentTimeMillis() - lastSeen.toEpochMilli();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the registration lock and salt are both set.
|
||||
*/
|
||||
private boolean hasLockAndSalt() {
|
||||
return registrationLock.isPresent() && registrationLockSalt.isPresent();
|
||||
}
|
||||
|
||||
public boolean isPresent() {
|
||||
return hasLockAndSalt();
|
||||
}
|
||||
|
||||
public StoredRegistrationLock(Optional<String> registrationLock, Optional<String> registrationLockSalt, Instant lastSeen) {
|
||||
this.registrationLock = registrationLock;
|
||||
this.registrationLockSalt = registrationLockSalt;
|
||||
this.lastSeen = lastSeen;
|
||||
}
|
||||
|
||||
public boolean requiresClientRegistrationLock() {
|
||||
return registrationLock.isPresent() && registrationLockSalt.isPresent() && System.currentTimeMillis() - lastSeen < TimeUnit.DAYS.toMillis(7);
|
||||
public Status getStatus() {
|
||||
if (!isPresent()) {
|
||||
return Status.ABSENT;
|
||||
}
|
||||
if (getTimeRemaining().toMillis() > 0) {
|
||||
return Status.REQUIRED;
|
||||
}
|
||||
return Status.EXPIRED;
|
||||
}
|
||||
|
||||
public boolean needsFailureCredentials() {
|
||||
return registrationLock.isPresent() && registrationLockSalt.isPresent();
|
||||
return hasLockAndSalt();
|
||||
}
|
||||
|
||||
public long getTimeRemaining() {
|
||||
return TimeUnit.DAYS.toMillis(7) - (System.currentTimeMillis() - lastSeen);
|
||||
public Duration getTimeRemaining() {
|
||||
return REGISTRATION_LOCK_EXPIRATION_DAYS.minus(timeSinceLastSeen(), ChronoUnit.MILLIS);
|
||||
}
|
||||
|
||||
public boolean verify(@Nullable String clientRegistrationLock) {
|
||||
if (Util.isEmpty(clientRegistrationLock)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registrationLock.isPresent() && registrationLockSalt.isPresent() && !Util.isEmpty(clientRegistrationLock)) {
|
||||
return new AuthenticationCredentials(registrationLock.get(), registrationLockSalt.get()).verify(clientRegistrationLock);
|
||||
if (hasLockAndSalt() && Util.nonEmpty(clientRegistrationLock)) {
|
||||
SaltedTokenHash credentials = new SaltedTokenHash(registrationLock.get(), registrationLockSalt.get());
|
||||
return credentials.verify(clientRegistrationLock);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -53,6 +83,6 @@ public class StoredRegistrationLock {
|
||||
|
||||
@VisibleForTesting
|
||||
public StoredRegistrationLock forTime(long timestamp) {
|
||||
return new StoredRegistrationLock(registrationLock, registrationLockSalt, timestamp);
|
||||
return new StoredRegistrationLock(registrationLock, registrationLockSalt, Instant.ofEpochMilli(timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@ import java.time.Duration;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public record StoredVerificationCode(String code,
|
||||
public record StoredVerificationCode(@Nullable String code,
|
||||
long timestamp,
|
||||
String pushCode,
|
||||
@Nullable String twilioVerificationSid,
|
||||
@Nullable String pushCode,
|
||||
@Nullable byte[] sessionId) {
|
||||
|
||||
public static final Duration EXPIRATION = Duration.ofMinutes(10);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.util.WeightedRandomSelect;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
@@ -36,7 +37,7 @@ public class TurnTokenGenerator {
|
||||
List<String> urls = urls(e164);
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
long validUntilSeconds = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)) / 1000;
|
||||
long user = Math.abs(new SecureRandom().nextInt());
|
||||
long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt());
|
||||
String userTime = validUntilSeconds + ":" + user;
|
||||
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA1"));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public enum Action {
|
||||
CHALLENGE("challenge"),
|
||||
REGISTRATION("registration");
|
||||
|
||||
private final String actionName;
|
||||
|
||||
Action(String actionName) {
|
||||
this.actionName = actionName;
|
||||
}
|
||||
|
||||
public String getActionName() {
|
||||
return actionName;
|
||||
}
|
||||
|
||||
private static final Map<String, Action> ENUM_MAP = Arrays
|
||||
.stream(Action.values())
|
||||
.collect(Collectors.toMap(
|
||||
a -> a.actionName,
|
||||
Function.identity()));
|
||||
@JsonCreator
|
||||
public static Action fromString(String key) {
|
||||
return ENUM_MAP.get(key.toLowerCase(Locale.ROOT).strip());
|
||||
}
|
||||
|
||||
static Optional<Action> parse(final String action) {
|
||||
return Optional.ofNullable(fromString(action));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public class AssessmentResult {
|
||||
|
||||
private final boolean solved;
|
||||
private final float actualScore;
|
||||
private final float defaultScoreThreshold;
|
||||
private final String scoreString;
|
||||
|
||||
/**
|
||||
* A captcha assessment
|
||||
*
|
||||
* @param solved if false, the captcha was not successfully completed
|
||||
* @param actualScore float representation of the risk level from [0, 1.0], with 1.0 being the least risky
|
||||
* @param defaultScoreThreshold the score threshold which the score will be evaluated against by default
|
||||
* @param scoreString a quantized string representation of the risk level, suitable for use in metrics
|
||||
*/
|
||||
private AssessmentResult(boolean solved, float actualScore, float defaultScoreThreshold, final String scoreString) {
|
||||
this.solved = solved;
|
||||
this.actualScore = actualScore;
|
||||
this.defaultScoreThreshold = defaultScoreThreshold;
|
||||
this.scoreString = scoreString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an {@link AssessmentResult} from a captcha evaluation score
|
||||
*
|
||||
* @param actualScore the score
|
||||
* @param defaultScoreThreshold the threshold to compare the score against by default
|
||||
*/
|
||||
public static AssessmentResult fromScore(float actualScore, float defaultScoreThreshold) {
|
||||
if (actualScore < 0 || actualScore > 1.0 || defaultScoreThreshold < 0 || defaultScoreThreshold > 1.0) {
|
||||
throw new IllegalArgumentException("invalid captcha score");
|
||||
}
|
||||
return new AssessmentResult(true, actualScore, defaultScoreThreshold, AssessmentResult.scoreString(actualScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a captcha assessment that will always be invalid
|
||||
*/
|
||||
public static AssessmentResult invalid() {
|
||||
return new AssessmentResult(false, 0.0f, 0.0f, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a captcha assessment that will always be valid
|
||||
*/
|
||||
public static AssessmentResult alwaysValid() {
|
||||
return new AssessmentResult(true, 1.0f, 0.0f, "1.0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the captcha assessment should be accepted using the default score threshold
|
||||
*
|
||||
* @return true if this assessment should be accepted under the default score threshold
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return isValid(Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the captcha assessment should be accepted
|
||||
*
|
||||
* @param scoreThreshold the minimum score the assessment requires to pass, uses default if empty
|
||||
* @return true if the assessment scored higher than the provided scoreThreshold
|
||||
*/
|
||||
public boolean isValid(Optional<Float> scoreThreshold) {
|
||||
if (!solved) {
|
||||
return false;
|
||||
}
|
||||
return this.actualScore >= scoreThreshold.orElse(this.defaultScoreThreshold);
|
||||
}
|
||||
|
||||
public String getScoreString() {
|
||||
return scoreString;
|
||||
}
|
||||
|
||||
public float getScore() {
|
||||
return this.actualScore;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics
|
||||
*/
|
||||
private static String scoreString(final float score) {
|
||||
final int x = Math.round(score * 10); // [0, 10]
|
||||
return Integer.toString(x * 10); // [0, 100] in increments of 10
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
AssessmentResult that = (AssessmentResult) o;
|
||||
return solved == that.solved && Float.compare(that.actualScore, actualScore) == 0
|
||||
&& Float.compare(that.defaultScoreThreshold, defaultScoreThreshold) == 0 && Objects.equals(scoreString,
|
||||
that.scoreString);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(solved, actualScore, defaultScoreThreshold, scoreString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class CaptchaChecker {
|
||||
private static final Logger logger = LoggerFactory.getLogger(CaptchaChecker.class);
|
||||
private static final String INVALID_SITEKEY_COUNTER_NAME = name(CaptchaChecker.class, "invalidSiteKey");
|
||||
private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments");
|
||||
private static final String INVALID_ACTION_COUNTER_NAME = name(CaptchaChecker.class, "invalidActions");
|
||||
|
||||
@VisibleForTesting
|
||||
static final String SEPARATOR = ".";
|
||||
|
||||
private final Map<String, CaptchaClient> captchaClientMap;
|
||||
|
||||
public CaptchaChecker(final List<CaptchaClient> captchaClients) {
|
||||
this.captchaClientMap = captchaClients.stream()
|
||||
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a solved captcha should be accepted
|
||||
*
|
||||
* @param expectedAction the {@link Action} for which this captcha solution is intended
|
||||
* @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The
|
||||
* expected format is {@code version-prefix.sitekey.action.token}
|
||||
* @param ip IP of the solver
|
||||
* @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be
|
||||
* used for metrics
|
||||
* @throws IOException if there is an error validating the captcha with the underlying service
|
||||
* @throws BadRequestException if input is not in the expected format
|
||||
*/
|
||||
public AssessmentResult verify(
|
||||
final Action expectedAction,
|
||||
final String input,
|
||||
final String ip) throws IOException {
|
||||
final String[] parts = input.split("\\" + SEPARATOR, 4);
|
||||
|
||||
// we allow missing actions, if we're missing 1 part, assume it's the action
|
||||
if (parts.length < 4) {
|
||||
throw new BadRequestException("too few parts");
|
||||
}
|
||||
|
||||
final String prefix = parts[0];
|
||||
final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();
|
||||
final String action = parts[2];
|
||||
final String token = parts[3];
|
||||
|
||||
final CaptchaClient client = this.captchaClientMap.get(prefix);
|
||||
if (client == null) {
|
||||
throw new BadRequestException("invalid captcha scheme");
|
||||
}
|
||||
|
||||
final Action parsedAction = Action.parse(action)
|
||||
.orElseThrow(() -> {
|
||||
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
|
||||
throw new BadRequestException("invalid captcha action");
|
||||
});
|
||||
|
||||
if (!parsedAction.equals(expectedAction)) {
|
||||
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
|
||||
throw new BadRequestException("invalid captcha action");
|
||||
}
|
||||
|
||||
final Set<String> allowedSiteKeys = client.validSiteKeys(parsedAction);
|
||||
if (!allowedSiteKeys.contains(siteKey)) {
|
||||
logger.debug("invalid site-key {}, action={}, token={}", siteKey, action, token);
|
||||
Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, "action", action).increment();
|
||||
throw new BadRequestException("invalid captcha site-key");
|
||||
}
|
||||
|
||||
final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip);
|
||||
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
|
||||
"action", action,
|
||||
"score", result.getScoreString(),
|
||||
"provider", prefix)
|
||||
.increment();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public interface CaptchaClient {
|
||||
|
||||
|
||||
/**
|
||||
* @return the identifying captcha scheme that this CaptchaClient handles
|
||||
*/
|
||||
String scheme();
|
||||
|
||||
/**
|
||||
* @param action the action to retrieve site keys for
|
||||
* @return siteKeys this client is willing to accept
|
||||
*/
|
||||
Set<String> validSiteKeys(final Action action);
|
||||
|
||||
/**
|
||||
* Verify a provided captcha solution
|
||||
*
|
||||
* @param siteKey identifying string for the captcha service
|
||||
* @param action an action indicating the purpose of the captcha
|
||||
* @param token the captcha solution that will be verified
|
||||
* @param ip the ip of the captcha solver
|
||||
* @return An {@link AssessmentResult} indicating whether the solution should be accepted
|
||||
* @throws IOException if the underlying captcha provider returns an error
|
||||
*/
|
||||
AssessmentResult verify(
|
||||
final String siteKey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class HCaptchaClient implements CaptchaClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HCaptchaClient.class);
|
||||
private static final String PREFIX = "signal-hcaptcha";
|
||||
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason");
|
||||
private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason");
|
||||
private final String apiKey;
|
||||
private final HttpClient client;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
public HCaptchaClient(
|
||||
final String apiKey,
|
||||
final HttpClient client,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.apiKey = apiKey;
|
||||
this.client = client;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String scheme() {
|
||||
return PREFIX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
if (!config.isAllowHCaptcha()) {
|
||||
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return Optional
|
||||
.ofNullable(config.getHCaptchaSiteKeys().get(action))
|
||||
.orElse(Collections.emptySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssessmentResult verify(
|
||||
final String siteKey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip)
|
||||
throws IOException {
|
||||
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
final String body = String.format("response=%s&secret=%s&remoteip=%s",
|
||||
URLEncoder.encode(token, StandardCharsets.UTF_8),
|
||||
URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
|
||||
ip);
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://hcaptcha.com/siteverify"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
response = this.client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (InterruptedException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (response.statusCode() != Response.Status.OK.getStatusCode()) {
|
||||
logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response);
|
||||
throw new IOException("hCaptcha http failure : " + response.statusCode());
|
||||
}
|
||||
|
||||
final HCaptchaResponse hCaptchaResponse = SystemMapper.jsonMapper()
|
||||
.readValue(response.body(), HCaptchaResponse.class);
|
||||
|
||||
logger.debug("received hCaptcha response: {}", hCaptchaResponse);
|
||||
|
||||
if (!hCaptchaResponse.success) {
|
||||
for (String errorCode : hCaptchaResponse.errorCodes) {
|
||||
Metrics.counter(INVALID_REASON_COUNTER_NAME,
|
||||
"action", action.getActionName(),
|
||||
"reason", errorCode).increment();
|
||||
}
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
|
||||
// hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
|
||||
final float score = 1.0f - hCaptchaResponse.score;
|
||||
if (score < 0.0f || score > 1.0f) {
|
||||
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
|
||||
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
|
||||
|
||||
for (String reason : hCaptchaResponse.scoreReasons) {
|
||||
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
|
||||
"action", action.getActionName(),
|
||||
"reason", reason,
|
||||
"score", assessmentResult.getScoreString()).increment();
|
||||
}
|
||||
return assessmentResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Verify response returned by hcaptcha
|
||||
* <p>
|
||||
* see <a href="https://docs.hcaptcha.com/#verify-the-user-response-server-side">...</a>
|
||||
*/
|
||||
public class HCaptchaResponse {
|
||||
|
||||
@JsonProperty
|
||||
boolean success;
|
||||
|
||||
@JsonProperty(value = "challenge-ts")
|
||||
Duration challengeTs;
|
||||
|
||||
@JsonProperty
|
||||
String hostname;
|
||||
|
||||
@JsonProperty
|
||||
boolean credit;
|
||||
|
||||
@JsonProperty(value = "error-codes")
|
||||
List<String> errorCodes = Collections.emptyList();
|
||||
|
||||
@JsonProperty
|
||||
float score;
|
||||
|
||||
@JsonProperty(value = "score-reasons")
|
||||
List<String> scoreReasons = Collections.emptyList();
|
||||
|
||||
public HCaptchaResponse() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HCaptchaResponse{" +
|
||||
"success=" + success +
|
||||
", challengeTs=" + challengeTs +
|
||||
", hostname='" + hostname + '\'' +
|
||||
", credit=" + credit +
|
||||
", errorCodes=" + errorCodes +
|
||||
", score=" + score +
|
||||
", scoreReasons=" + scoreReasons +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2021-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.api.gax.core.FixedCredentialsProvider;
|
||||
import com.google.api.gax.rpc.ApiException;
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceClient;
|
||||
import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSettings;
|
||||
import com.google.recaptchaenterprise.v1.Assessment;
|
||||
import com.google.recaptchaenterprise.v1.Event;
|
||||
import com.google.recaptchaenterprise.v1.RiskAnalysis;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
|
||||
public class RecaptchaClient implements CaptchaClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class);
|
||||
|
||||
private static final String V2_PREFIX = "signal-recaptcha-v2";
|
||||
private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason");
|
||||
private static final String INVALID_SITEKEY_COUNTER_NAME = name(RecaptchaClient.class, "invalidSiteKey");
|
||||
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason");
|
||||
|
||||
|
||||
private final String projectPath;
|
||||
private final RecaptchaEnterpriseServiceClient client;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
public RecaptchaClient(
|
||||
@Nonnull final String projectPath,
|
||||
@Nonnull final String recaptchaCredentialConfigurationJson,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
try {
|
||||
this.projectPath = Objects.requireNonNull(projectPath);
|
||||
this.client = RecaptchaEnterpriseServiceClient.create(RecaptchaEnterpriseServiceSettings.newBuilder()
|
||||
.setCredentialsProvider(FixedCredentialsProvider.create(GoogleCredentials.fromStream(
|
||||
new ByteArrayInputStream(recaptchaCredentialConfigurationJson.getBytes(StandardCharsets.UTF_8)))))
|
||||
.build());
|
||||
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String scheme() {
|
||||
return V2_PREFIX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
if (!config.isAllowRecaptcha()) {
|
||||
log.warn("Received request to verify a recaptcha, but recaptcha is not enabled");
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return Optional
|
||||
.ofNullable(config.getRecaptchaSiteKeys().get(action))
|
||||
.orElse(Collections.emptySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public org.whispersystems.textsecuregcm.captcha.AssessmentResult verify(
|
||||
final String sitekey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip) throws IOException {
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
final Set<String> allowedSiteKeys = config.getRecaptchaSiteKeys().get(action);
|
||||
if (allowedSiteKeys != null && !allowedSiteKeys.contains(sitekey)) {
|
||||
log.info("invalid recaptcha sitekey {}, action={}, token={}", action, token);
|
||||
Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, "action", action.getActionName()).increment();
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
|
||||
Event.Builder eventBuilder = Event.newBuilder()
|
||||
.setSiteKey(sitekey)
|
||||
.setToken(token)
|
||||
.setUserIpAddress(ip);
|
||||
|
||||
if (action != null) {
|
||||
eventBuilder.setExpectedAction(action.getActionName());
|
||||
}
|
||||
|
||||
final Event event = eventBuilder.build();
|
||||
final Assessment assessment;
|
||||
try {
|
||||
assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build());
|
||||
} catch (ApiException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (assessment.getTokenProperties().getValid()) {
|
||||
final float score = assessment.getRiskAnalysis().getScore();
|
||||
log.debug("assessment for {} was valid, score: {}", action.getActionName(), score);
|
||||
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
|
||||
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
|
||||
for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) {
|
||||
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
|
||||
"action", action.getActionName(),
|
||||
"score", assessmentResult.getScoreString(),
|
||||
"reason", reason.name())
|
||||
.increment();
|
||||
}
|
||||
return assessmentResult;
|
||||
} else {
|
||||
Metrics.counter(INVALID_REASON_COUNTER_NAME,
|
||||
"action", action.getActionName(),
|
||||
"reason", assessment.getTokenProperties().getInvalidReason().name())
|
||||
.increment();
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.Meter;
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class RegistrationCaptchaManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RegistrationCaptchaManager.class);
|
||||
|
||||
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||
private final Meter countryFilteredHostMeter = metricRegistry.meter(
|
||||
name(AccountController.class, "country_limited_host"));
|
||||
private final Meter rateLimitedHostMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_host"));
|
||||
private final Meter rateLimitedPrefixMeter = metricRegistry.meter(
|
||||
name(AccountController.class, "rate_limited_prefix"));
|
||||
|
||||
private final CaptchaChecker captchaChecker;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final Map<String, Integer> testDevices;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
|
||||
public RegistrationCaptchaManager(final CaptchaChecker captchaChecker, final RateLimiters rateLimiters,
|
||||
final Map<String, Integer> testDevices,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.captchaChecker = captchaChecker;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.testDevices = testDevices;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public Optional<AssessmentResult> assessCaptcha(final Optional<String> captcha, final String sourceHost)
|
||||
throws IOException {
|
||||
return captcha.isPresent()
|
||||
? Optional.of(captchaChecker.verify(Action.REGISTRATION, captcha.get(), sourceHost))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
public boolean requiresCaptcha(final String number, final String forwardedFor, String sourceHost,
|
||||
final boolean pushChallengeMatch) {
|
||||
if (testDevices.containsKey(number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pushChallengeMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
|
||||
.getCaptchaConfiguration();
|
||||
|
||||
boolean countryFiltered = captchaConfig.getSignupCountryCodes().contains(countryCode) ||
|
||||
captchaConfig.getSignupRegions().contains(region);
|
||||
|
||||
try {
|
||||
rateLimiters.getSmsVoiceIpLimiter().validate(sourceHost);
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Rate limit exceeded: {}, {} ({})", number, sourceHost, forwardedFor);
|
||||
rateLimitedHostMeter.mark();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
rateLimiters.getSmsVoicePrefixLimiter().validate(Util.getNumberPrefix(number));
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Prefix rate limit exceeded: {}, {} ({})", number, sourceHost, forwardedFor);
|
||||
rateLimitedPrefixMeter.mark();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (countryFiltered) {
|
||||
countryFilteredHostMeter.mark();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class ArtServiceConfiguration {
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String userAuthenticationTokenSharedSecret;
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String userAuthenticationTokenUserIdSecret;
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private Duration tokenExpiration = Duration.ofDays(1);
|
||||
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret);
|
||||
}
|
||||
|
||||
public byte[] getUserAuthenticationTokenUserIdSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenUserIdSecret);
|
||||
}
|
||||
|
||||
public Duration getTokenExpiration() {
|
||||
return tokenExpiration;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public class BoostConfiguration {
|
||||
|
||||
private final long level;
|
||||
private final Duration expiration;
|
||||
private final Map<String, List<BigDecimal>> currencies;
|
||||
private final String badge;
|
||||
|
||||
@JsonCreator
|
||||
public BoostConfiguration(
|
||||
@JsonProperty("level") long level,
|
||||
@JsonProperty("expiration") Duration expiration,
|
||||
@JsonProperty("currencies") Map<String, List<BigDecimal>> currencies,
|
||||
@JsonProperty("badge") String badge) {
|
||||
this.level = level;
|
||||
this.expiration = expiration;
|
||||
this.currencies = currencies;
|
||||
this.badge = badge;
|
||||
}
|
||||
|
||||
public long getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Duration getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
public Map<@NotEmpty String, @Valid @ExactlySize(6) List<@DecimalMin("0.01") @NotNull BigDecimal>> getCurrencies() {
|
||||
return currencies;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
public String getBadge() {
|
||||
return badge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @param merchantId the Braintree merchant ID
|
||||
* @param publicKey the Braintree API public key
|
||||
* @param privateKey the Braintree API private key
|
||||
* @param environment the Braintree environment ("production" or "sandbox")
|
||||
* @param supportedCurrencies the set of supported currencies
|
||||
* @param graphqlUrl the Braintree GraphQL URl to use (this must match the environment)
|
||||
* @param merchantAccounts merchant account within the merchant for processing individual currencies
|
||||
* @param circuitBreaker configuration for the circuit breaker used by the GraphQL HTTP client
|
||||
*/
|
||||
public record BraintreeConfiguration(@NotBlank String merchantId,
|
||||
@NotBlank String publicKey,
|
||||
@NotBlank String privateKey,
|
||||
@NotBlank String environment,
|
||||
@NotEmpty Set<@NotBlank String> supportedCurrencies,
|
||||
@NotBlank String graphqlUrl,
|
||||
@NotEmpty Map<String, String> merchantAccounts,
|
||||
@NotNull
|
||||
@Valid
|
||||
CircuitBreakerConfiguration circuitBreaker) {
|
||||
|
||||
public BraintreeConfiguration {
|
||||
if (circuitBreaker == null) {
|
||||
// It’s a little counter-intuitive, but this compact constructor allows a default value
|
||||
// to be used when one isn’t specified (e.g. in YAML), allowing the field to still be
|
||||
// validated as @NotNull
|
||||
circuitBreaker = new CircuitBreakerConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.HexFormat;
|
||||
|
||||
public record CallLinkConfiguration (@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret) {
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.HexFormat;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
|
||||
public class DirectoryClientConfiguration {
|
||||
|
||||
@@ -19,12 +18,12 @@ public class DirectoryClientConfiguration {
|
||||
@JsonProperty
|
||||
private String userAuthenticationTokenUserIdSecret;
|
||||
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException {
|
||||
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret);
|
||||
}
|
||||
|
||||
public byte[] getUserAuthenticationTokenUserIdSecret() throws DecoderException {
|
||||
return Hex.decodeHex(userAuthenticationTokenUserIdSecret.toCharArray());
|
||||
public byte[] getUserAuthenticationTokenUserIdSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenUserIdSecret);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2013-2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public class DirectoryV2ClientConfiguration {
|
||||
|
||||
private final byte[] userAuthenticationTokenSharedSecret;
|
||||
private final byte[] userIdTokenSharedSecret;
|
||||
|
||||
@JsonCreator
|
||||
public DirectoryV2ClientConfiguration(
|
||||
@JsonProperty("userAuthenticationTokenSharedSecret") final byte[] userAuthenticationTokenSharedSecret,
|
||||
@JsonProperty("userIdTokenSharedSecret") final byte[] userIdTokenSharedSecret) {
|
||||
this.userAuthenticationTokenSharedSecret = userAuthenticationTokenSharedSecret;
|
||||
this.userIdTokenSharedSecret = userIdTokenSharedSecret;
|
||||
}
|
||||
|
||||
@ExactlySize({32})
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() {
|
||||
return userAuthenticationTokenSharedSecret;
|
||||
}
|
||||
|
||||
@ExactlySize({32})
|
||||
public byte[] getUserIdTokenSharedSecret() {
|
||||
return userIdTokenSharedSecret;
|
||||
}
|
||||
public record DirectoryV2ClientConfiguration(@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret,
|
||||
@ExactlySize({32}) byte[] userIdTokenSharedSecret) {
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@ public class DynamoDbTables {
|
||||
private final Table profiles;
|
||||
private final Table pushChallenge;
|
||||
private final TableWithExpiration redeemedReceipts;
|
||||
private final TableWithExpiration registrationRecovery;
|
||||
private final Table remoteConfig;
|
||||
private final Table reportMessage;
|
||||
private final Table reservedUsernames;
|
||||
private final Table subscriptions;
|
||||
private final Table verificationSessions;
|
||||
|
||||
public DynamoDbTables(
|
||||
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
|
||||
@@ -76,10 +78,12 @@ public class DynamoDbTables {
|
||||
@JsonProperty("profiles") final Table profiles,
|
||||
@JsonProperty("pushChallenge") final Table pushChallenge,
|
||||
@JsonProperty("redeemedReceipts") final TableWithExpiration redeemedReceipts,
|
||||
@JsonProperty("registrationRecovery") final TableWithExpiration registrationRecovery,
|
||||
@JsonProperty("remoteConfig") final Table remoteConfig,
|
||||
@JsonProperty("reportMessage") final Table reportMessage,
|
||||
@JsonProperty("reservedUsernames") final Table reservedUsernames,
|
||||
@JsonProperty("subscriptions") final Table subscriptions) {
|
||||
@JsonProperty("subscriptions") final Table subscriptions,
|
||||
@JsonProperty("verificationSessions") final Table verificationSessions) {
|
||||
|
||||
this.accounts = accounts;
|
||||
this.deletedAccounts = deletedAccounts;
|
||||
@@ -93,10 +97,12 @@ public class DynamoDbTables {
|
||||
this.profiles = profiles;
|
||||
this.pushChallenge = pushChallenge;
|
||||
this.redeemedReceipts = redeemedReceipts;
|
||||
this.registrationRecovery = registrationRecovery;
|
||||
this.remoteConfig = remoteConfig;
|
||||
this.reportMessage = reportMessage;
|
||||
this.reservedUsernames = reservedUsernames;
|
||||
this.subscriptions = subscriptions;
|
||||
this.verificationSessions = verificationSessions;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@@ -171,6 +177,12 @@ public class DynamoDbTables {
|
||||
return redeemedReceipts;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public TableWithExpiration getRegistrationRecovery() {
|
||||
return registrationRecovery;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getRemoteConfig() {
|
||||
@@ -194,4 +206,10 @@ public class DynamoDbTables {
|
||||
public Table getSubscriptions() {
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
public Table getVerificationSessions() {
|
||||
return verificationSessions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public record GiftConfiguration(
|
||||
long level,
|
||||
@NotNull Duration expiration,
|
||||
@Valid @NotNull Map<@NotEmpty String, @DecimalMin("0.01") @NotNull BigDecimal> currencies,
|
||||
@NotEmpty String badge) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2021-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public record HCaptchaConfiguration(@NotBlank String apiKey) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Positive;
|
||||
|
||||
/**
|
||||
* @param boost configuration for individual donations
|
||||
* @param gift configuration for gift donations
|
||||
* @param currencies map of lower-cased ISO 3 currency codes and the suggested donation amounts in that currency
|
||||
*/
|
||||
public record OneTimeDonationConfiguration(@Valid ExpiringLevelConfiguration boost,
|
||||
@Valid ExpiringLevelConfiguration gift,
|
||||
Map<String, @Valid OneTimeDonationCurrencyConfiguration> currencies) {
|
||||
|
||||
/**
|
||||
* @param badge the numeric donation level ID
|
||||
* @param level the badge ID associated with the level
|
||||
* @param expiration the duration after which the level expires
|
||||
*/
|
||||
public record ExpiringLevelConfiguration(@NotEmpty String badge, @Positive long level, Duration expiration) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
/**
|
||||
* One-time donation configuration for a given currency
|
||||
*
|
||||
* @param minimum the minimum amount permitted to be charged in this currency
|
||||
* @param gift the suggested gift donation amount
|
||||
* @param boosts the list of suggested one-time donation amounts
|
||||
*/
|
||||
public record OneTimeDonationCurrencyConfiguration(
|
||||
@NotNull @DecimalMin("0.01") BigDecimal minimum,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal gift,
|
||||
@Valid
|
||||
@ExactlySize(6)
|
||||
@NotNull
|
||||
List<@NotNull @DecimalMin("0.01") BigDecimal> boosts) {
|
||||
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class PaymentsServiceConfiguration {
|
||||
|
||||
@@ -18,6 +18,14 @@ public class PaymentsServiceConfiguration {
|
||||
@JsonProperty
|
||||
private String userAuthenticationTokenSharedSecret;
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty
|
||||
private String coinMarketCapApiKey;
|
||||
|
||||
@JsonProperty
|
||||
@NotEmpty
|
||||
private Map<@NotBlank String, Integer> coinMarketCapCurrencyIds;
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String fixerApiKey;
|
||||
@@ -26,8 +34,16 @@ public class PaymentsServiceConfiguration {
|
||||
@JsonProperty
|
||||
private List<String> paymentCurrencies;
|
||||
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException {
|
||||
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret);
|
||||
}
|
||||
|
||||
public String getCoinMarketCapApiKey() {
|
||||
return coinMarketCapApiKey;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getCoinMarketCapCurrencyIds() {
|
||||
return coinMarketCapCurrencyIds;
|
||||
}
|
||||
|
||||
public String getFixerApiKey() {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class RateLimitsConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration smsDestination = new RateLimitConfiguration(2, 2);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration voiceDestination = new RateLimitConfiguration(2, 1.0 / 2.0);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration voiceDestinationDaily = new RateLimitConfiguration(10, 10.0 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration smsVoiceIp = new RateLimitConfiguration(1000, 1000);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration smsVoicePrefix = new RateLimitConfiguration(1000, 1000);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration autoBlock = new RateLimitConfiguration(500, 500);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration verifyNumber = new RateLimitConfiguration(2, 2);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration verifyPin = new RateLimitConfiguration(10, 1 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration attachments = new RateLimitConfiguration(50, 50);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration prekeys = new RateLimitConfiguration(6, 1.0 / 10.0);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration messages = new RateLimitConfiguration(60, 60);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration allocateDevice = new RateLimitConfiguration(2, 1.0 / 2.0);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration verifyDevice = new RateLimitConfiguration(6, 1.0 / 10.0);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration turnAllocations = new RateLimitConfiguration(60, 60);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration profile = new RateLimitConfiguration(4320, 3);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration stickerPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration usernameLookup = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration stories = new RateLimitConfiguration(10_000, 10_000 / (24.0 * 60.0));
|
||||
|
||||
public RateLimitConfiguration getAutoBlock() {
|
||||
return autoBlock;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getAllocateDevice() {
|
||||
return allocateDevice;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getVerifyDevice() {
|
||||
return verifyDevice;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getPreKeys() {
|
||||
return prekeys;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getAttachments() {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getSmsDestination() {
|
||||
return smsDestination;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getVoiceDestination() {
|
||||
return voiceDestination;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getVoiceDestinationDaily() {
|
||||
return voiceDestinationDaily;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getSmsVoiceIp() {
|
||||
return smsVoiceIp;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getSmsVoicePrefix() {
|
||||
return smsVoicePrefix;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getVerifyNumber() {
|
||||
return verifyNumber;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getVerifyPin() {
|
||||
return verifyPin;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getTurnAllocations() {
|
||||
return turnAllocations;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getStickerPack() {
|
||||
return stickerPack;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getUsernameLookup() {
|
||||
return usernameLookup;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getUsernameSet() {
|
||||
return usernameSet;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getUsernameReserve() {
|
||||
return usernameReserve;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getCheckAccountExistence() {
|
||||
return checkAccountExistence;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getStories() { return stories; }
|
||||
|
||||
public static class RateLimitConfiguration {
|
||||
@JsonProperty
|
||||
private int bucketSize;
|
||||
|
||||
@JsonProperty
|
||||
private double leakRatePerMinute;
|
||||
|
||||
public RateLimitConfiguration(int bucketSize, double leakRatePerMinute) {
|
||||
this.bucketSize = bucketSize;
|
||||
this.leakRatePerMinute = leakRatePerMinute;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration() {}
|
||||
|
||||
public int getBucketSize() {
|
||||
return bucketSize;
|
||||
}
|
||||
|
||||
public double getLeakRatePerMinute() {
|
||||
return leakRatePerMinute;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,13 @@
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import java.util.List;
|
||||
|
||||
public class SecureBackupServiceConfiguration {
|
||||
|
||||
@@ -39,8 +38,8 @@ public class SecureBackupServiceConfiguration {
|
||||
@JsonProperty
|
||||
private RetryConfiguration retry = new RetryConfiguration();
|
||||
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException {
|
||||
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
|
||||
@@ -1,71 +1,32 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2013 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.HexFormat;
|
||||
import java.util.List;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import java.util.List;
|
||||
|
||||
public class SecureStorageServiceConfiguration {
|
||||
public record SecureStorageServiceConfiguration(@NotEmpty String userAuthenticationTokenSharedSecret,
|
||||
@NotBlank String uri,
|
||||
@NotEmpty List<@NotBlank String> storageCaCertificates,
|
||||
@Valid CircuitBreakerConfiguration circuitBreaker,
|
||||
@Valid RetryConfiguration retry) {
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private String userAuthenticationTokenSharedSecret;
|
||||
|
||||
@NotBlank
|
||||
@JsonProperty
|
||||
private String uri;
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty
|
||||
private List<@NotBlank String> storageCaCertificates;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration();
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private RetryConfiguration retry = new RetryConfiguration();
|
||||
|
||||
public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException {
|
||||
return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray());
|
||||
public SecureStorageServiceConfiguration {
|
||||
if (circuitBreaker == null) {
|
||||
circuitBreaker = new CircuitBreakerConfiguration();
|
||||
}
|
||||
if (retry == null) {
|
||||
retry = new RetryConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setUri(final String uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public String getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setStorageCaCertificates(final List<String> certificatePem) {
|
||||
this.storageCaCertificates = certificatePem;
|
||||
}
|
||||
|
||||
public List<String> getStorageCaCertificates() {
|
||||
return storageCaCertificates;
|
||||
}
|
||||
|
||||
public CircuitBreakerConfiguration getCircuitBreakerConfiguration() {
|
||||
return circuitBreaker;
|
||||
}
|
||||
|
||||
public RetryConfiguration getRetryConfiguration() {
|
||||
return retry;
|
||||
public byte[] decodeUserAuthenticationTokenSharedSecret() {
|
||||
return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import java.util.List;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public record SecureValueRecovery2Configuration(
|
||||
boolean enabled,
|
||||
@NotBlank String uri,
|
||||
@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret,
|
||||
@ExactlySize({32}) byte[] userIdTokenSharedSecret,
|
||||
@NotEmpty List<@NotBlank String> svrCaCertificates,
|
||||
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
|
||||
@NotNull @Valid RetryConfiguration retry) {
|
||||
|
||||
public SecureValueRecovery2Configuration {
|
||||
if (circuitBreaker == null) {
|
||||
circuitBreaker = new CircuitBreakerConfiguration();
|
||||
}
|
||||
|
||||
if (retry == null) {
|
||||
retry = new RetryConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,14 @@ import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
public class AbusiveMessageFilterConfiguration {
|
||||
public class SpamFilterConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@NotBlank
|
||||
private final String environment;
|
||||
|
||||
@JsonCreator
|
||||
public AbusiveMessageFilterConfiguration(@JsonProperty("environment") final String environment) {
|
||||
public SpamFilterConfiguration(@JsonProperty("environment") final String environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@@ -5,38 +5,13 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Set;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public class StripeConfiguration {
|
||||
public record StripeConfiguration(@NotBlank String apiKey,
|
||||
@NotEmpty byte[] idempotencyKeyGenerator,
|
||||
@NotBlank String boostDescription,
|
||||
@NotEmpty Set<@NotBlank String> supportedCurrencies) {
|
||||
|
||||
private final String apiKey;
|
||||
private final byte[] idempotencyKeyGenerator;
|
||||
private final String boostDescription;
|
||||
|
||||
@JsonCreator
|
||||
public StripeConfiguration(
|
||||
@JsonProperty("apiKey") final String apiKey,
|
||||
@JsonProperty("idempotencyKeyGenerator") final byte[] idempotencyKeyGenerator,
|
||||
@JsonProperty("boostDescription") final String boostDescription) {
|
||||
this.apiKey = apiKey;
|
||||
this.idempotencyKeyGenerator = idempotencyKeyGenerator;
|
||||
this.boostDescription = boostDescription;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
public byte[] getIdempotencyKeyGenerator() {
|
||||
return idempotencyKeyGenerator;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
public String getBoostDescription() {
|
||||
return boostDescription;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* Copyright 2021-2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -15,16 +15,13 @@ import javax.validation.constraints.NotNull;
|
||||
public class SubscriptionLevelConfiguration {
|
||||
|
||||
private final String badge;
|
||||
private final String product;
|
||||
private final Map<String, SubscriptionPriceConfiguration> prices;
|
||||
|
||||
@JsonCreator
|
||||
public SubscriptionLevelConfiguration(
|
||||
@JsonProperty("badge") @NotEmpty String badge,
|
||||
@JsonProperty("product") @NotEmpty String product,
|
||||
@JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) {
|
||||
this.badge = badge;
|
||||
this.product = product;
|
||||
this.prices = prices;
|
||||
}
|
||||
|
||||
@@ -32,10 +29,6 @@ public class SubscriptionLevelConfiguration {
|
||||
return badge;
|
||||
}
|
||||
|
||||
public String getProduct() {
|
||||
return product;
|
||||
}
|
||||
|
||||
public Map<String, SubscriptionPriceConfiguration> getPrices() {
|
||||
return prices;
|
||||
}
|
||||
|
||||
@@ -5,31 +5,16 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
|
||||
public class SubscriptionPriceConfiguration {
|
||||
public record SubscriptionPriceConfiguration(@Valid @NotEmpty Map<SubscriptionProcessor, @NotBlank String> processorIds,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal amount) {
|
||||
|
||||
private final String id;
|
||||
private final BigDecimal amount;
|
||||
|
||||
@JsonCreator
|
||||
public SubscriptionPriceConfiguration(
|
||||
@JsonProperty("id") @NotEmpty String id,
|
||||
@JsonProperty("amount") @NotNull @DecimalMin("0.01") BigDecimal amount) {
|
||||
this.id = id;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.Min;
|
||||
import java.time.Duration;
|
||||
|
||||
public class UsernameConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int discriminatorInitialWidth = 2;
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int discriminatorMaxWidth = 9;
|
||||
|
||||
@JsonProperty
|
||||
@Min(1)
|
||||
private int attemptsPerWidth = 10;
|
||||
|
||||
@JsonProperty
|
||||
private Duration reservationTtl = Duration.ofMinutes(5);
|
||||
|
||||
public int getDiscriminatorInitialWidth() {
|
||||
return discriminatorInitialWidth;
|
||||
}
|
||||
|
||||
public int getDiscriminatorMaxWidth() {
|
||||
return discriminatorMaxWidth;
|
||||
}
|
||||
|
||||
public int getAttemptsPerWidth() {
|
||||
return attemptsPerWidth;
|
||||
}
|
||||
|
||||
public Duration getReservationTtl() {
|
||||
return reservationTtl;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class VoiceVerificationConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
@NotEmpty
|
||||
private String url;
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
@NotNull
|
||||
private List<String> locales;
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public Set<String> getLocales() {
|
||||
return new HashSet<>(locales);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Duration;
|
||||
|
||||
public class DynamicAbusiveHostRulesConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
private Duration expirationTime = Duration.ofDays(1);
|
||||
|
||||
public Duration getExpirationTime() {
|
||||
return expirationTime;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.whispersystems.textsecuregcm.captcha.Action;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.validation.constraints.DecimalMax;
|
||||
import javax.validation.constraints.DecimalMin;
|
||||
@@ -17,6 +25,24 @@ public class DynamicCaptchaConfiguration {
|
||||
@NotNull
|
||||
private BigDecimal scoreFloor;
|
||||
|
||||
@JsonProperty
|
||||
private boolean allowHCaptcha = false;
|
||||
|
||||
@JsonProperty
|
||||
private boolean allowRecaptcha = true;
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private Map<Action, Set<String>> hCaptchaSiteKeys = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private Map<Action, Set<String>> recaptchaSiteKeys = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private Map<Action, BigDecimal> scoreFloorByAction = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private Set<String> signupCountryCodes = Collections.emptySet();
|
||||
@@ -46,4 +72,46 @@ public class DynamicCaptchaConfiguration {
|
||||
public Set<String> getSignupRegions() {
|
||||
return signupRegions;
|
||||
}
|
||||
|
||||
public boolean isAllowHCaptcha() {
|
||||
return allowHCaptcha;
|
||||
}
|
||||
|
||||
public boolean isAllowRecaptcha() {
|
||||
return allowRecaptcha;
|
||||
}
|
||||
|
||||
public Map<Action, BigDecimal> getScoreFloorByAction() {
|
||||
return scoreFloorByAction;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setAllowHCaptcha(final boolean allowHCaptcha) {
|
||||
this.allowHCaptcha = allowHCaptcha;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setScoreFloor(final BigDecimal scoreFloor) {
|
||||
this.scoreFloor = scoreFloor;
|
||||
}
|
||||
|
||||
public Map<Action, Set<String>> getHCaptchaSiteKeys() {
|
||||
return hCaptchaSiteKeys;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setHCaptchaSiteKeys(final Map<Action, Set<String>> hCaptchaSiteKeys) {
|
||||
this.hCaptchaSiteKeys = hCaptchaSiteKeys;
|
||||
}
|
||||
|
||||
public Map<Action, Set<String>> getRecaptchaSiteKeys() {
|
||||
return recaptchaSiteKeys;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setRecaptchaSiteKeys(final Map<Action, Set<String>> recaptchaSiteKeys) {
|
||||
this.recaptchaSiteKeys = recaptchaSiteKeys;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiterConfig;
|
||||
|
||||
public class DynamicConfiguration {
|
||||
|
||||
@@ -19,7 +25,7 @@ public class DynamicConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
private DynamicRateLimitsConfiguration limits = new DynamicRateLimitsConfiguration();
|
||||
private Map<String, RateLimiterConfig> limits = new HashMap<>();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
@@ -48,10 +54,6 @@ public class DynamicConfiguration {
|
||||
@Valid
|
||||
private DynamicTurnConfiguration turn = new DynamicTurnConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
DynamicAbusiveHostRulesConfiguration abusiveHostRules = new DynamicAbusiveHostRulesConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration();
|
||||
@@ -70,7 +72,7 @@ public class DynamicConfiguration {
|
||||
return Optional.ofNullable(preRegistrationExperiments.get(experimentName));
|
||||
}
|
||||
|
||||
public DynamicRateLimitsConfiguration getLimits() {
|
||||
public Map<String, RateLimiterConfig> getLimits() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
@@ -102,10 +104,6 @@ public class DynamicConfiguration {
|
||||
return turn;
|
||||
}
|
||||
|
||||
public DynamicAbusiveHostRulesConfiguration getAbusiveHostRules() {
|
||||
return abusiveHostRules;
|
||||
}
|
||||
|
||||
public DynamicMessagePersisterConfiguration getMessagePersisterConfiguration() {
|
||||
return messagePersister;
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration;
|
||||
|
||||
public class DynamicRateLimitsConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration rateLimitReset = new RateLimitConfiguration(2, 2.0 / (60 * 24));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration recaptchaChallengeAttempt = new RateLimitConfiguration(10, 10.0 / (60 * 24));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration recaptchaChallengeSuccess = new RateLimitConfiguration(2, 2.0 / (60 * 24));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration pushChallengeAttempt = new RateLimitConfiguration(10, 10.0 / (60 * 24));
|
||||
|
||||
@JsonProperty
|
||||
private RateLimitConfiguration pushChallengeSuccess = new RateLimitConfiguration(2, 2.0 / (60 * 24));
|
||||
|
||||
public RateLimitConfiguration getRateLimitReset() {
|
||||
return rateLimitReset;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getRecaptchaChallengeAttempt() {
|
||||
return recaptchaChallengeAttempt;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getRecaptchaChallengeSuccess() {
|
||||
return recaptchaChallengeSuccess;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getPushChallengeAttempt() {
|
||||
return pushChallengeAttempt;
|
||||
}
|
||||
|
||||
public RateLimitConfiguration getPushChallengeSuccess() {
|
||||
return pushChallengeSuccess;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
@@ -11,20 +11,26 @@ import com.codahale.metrics.MetricRegistry;
|
||||
import com.codahale.metrics.SharedMetricRegistries;
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
@@ -47,76 +53,74 @@ import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
|
||||
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceName;
|
||||
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotification;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.registration.ClientType;
|
||||
import org.whispersystems.textsecuregcm.registration.MessageTransport;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.storage.AbusiveHostRules;
|
||||
import org.whispersystems.textsecuregcm.spam.Extract;
|
||||
import org.whispersystems.textsecuregcm.spam.FilterSpam;
|
||||
import org.whispersystems.textsecuregcm.spam.ScoreThreshold;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||
import org.whispersystems.textsecuregcm.util.Optionals;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/accounts")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
|
||||
public class AccountController {
|
||||
|
||||
public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;
|
||||
public static final int USERNAME_HASH_LENGTH = 32;
|
||||
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
|
||||
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
|
||||
private final Meter blockedHostMeter = metricRegistry.meter(name(AccountController.class, "blocked_host" ));
|
||||
private final Meter countryFilterApplicable = metricRegistry.meter(name(AccountController.class, "country_filter_applicable"));
|
||||
private final Meter countryFilteredHostMeter = metricRegistry.meter(name(AccountController.class, "country_limited_host" ));
|
||||
private final Meter rateLimitedHostMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_host" ));
|
||||
private final Meter rateLimitedPrefixMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_prefix" ));
|
||||
private final Meter captchaRequiredMeter = metricRegistry.meter(name(AccountController.class, "captcha_required" ));
|
||||
|
||||
private static final String PUSH_CHALLENGE_COUNTER_NAME = name(AccountController.class, "pushChallenge");
|
||||
@@ -125,7 +129,11 @@ public class AccountController {
|
||||
private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(AccountController.class, "captcha");
|
||||
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
|
||||
|
||||
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||
private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary
|
||||
.builder(name(AccountController.class, "reregistrationIdleDays"))
|
||||
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(Metrics.globalRegistry);
|
||||
|
||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||
@@ -141,48 +149,52 @@ public class AccountController {
|
||||
private static final String SCORE_TAG_NAME = "score";
|
||||
|
||||
|
||||
private final StoredVerificationCodeManager pendingAccounts;
|
||||
private final AccountsManager accounts;
|
||||
private final AbusiveHostRules abusiveHostRules;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final StoredVerificationCodeManager pendingAccounts;
|
||||
private final AccountsManager accounts;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final TurnTokenGenerator turnTokenGenerator;
|
||||
private final Map<String, Integer> testDevices;
|
||||
private final RecaptchaClient recaptchaClient;
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator;
|
||||
|
||||
private final TurnTokenGenerator turnTokenGenerator;
|
||||
private final RegistrationCaptchaManager registrationCaptchaManager;
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
private final RegistrationLockVerificationManager registrationLockVerificationManager;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
private final ChangeNumberManager changeNumberManager;
|
||||
private final Clock clock;
|
||||
private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;
|
||||
|
||||
|
||||
@VisibleForTesting
|
||||
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||
|
||||
public AccountController(StoredVerificationCodeManager pendingAccounts,
|
||||
AccountsManager accounts,
|
||||
AbusiveHostRules abusiveHostRules,
|
||||
RateLimiters rateLimiters,
|
||||
RegistrationServiceClient registrationServiceClient,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
TurnTokenGenerator turnTokenGenerator,
|
||||
Map<String, Integer> testDevices,
|
||||
RecaptchaClient recaptchaClient,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
ChangeNumberManager changeNumberManager,
|
||||
ExternalServiceCredentialGenerator backupServiceCredentialGenerator)
|
||||
{
|
||||
this.pendingAccounts = pendingAccounts;
|
||||
this.accounts = accounts;
|
||||
this.abusiveHostRules = abusiveHostRules;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.testDevices = testDevices;
|
||||
this.turnTokenGenerator = turnTokenGenerator;
|
||||
this.recaptchaClient = recaptchaClient;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
public AccountController(
|
||||
StoredVerificationCodeManager pendingAccounts,
|
||||
AccountsManager accounts,
|
||||
RateLimiters rateLimiters,
|
||||
RegistrationServiceClient registrationServiceClient,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
TurnTokenGenerator turnTokenGenerator,
|
||||
RegistrationCaptchaManager registrationCaptchaManager,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
ChangeNumberManager changeNumberManager,
|
||||
RegistrationLockVerificationManager registrationLockVerificationManager,
|
||||
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
UsernameHashZkProofVerifier usernameHashZkProofVerifier,
|
||||
Clock clock
|
||||
) {
|
||||
this.pendingAccounts = pendingAccounts;
|
||||
this.accounts = accounts;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.turnTokenGenerator = turnTokenGenerator;
|
||||
this.registrationCaptchaManager = registrationCaptchaManager;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.registrationLockVerificationManager = registrationLockVerificationManager;
|
||||
this.changeNumberManager = changeNumberManager;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -193,7 +205,7 @@ public class AccountController {
|
||||
@PathParam("token") String pushToken,
|
||||
@PathParam("number") String number,
|
||||
@QueryParam("voip") @DefaultValue("true") boolean useVoip)
|
||||
throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {
|
||||
throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, RateLimitExceededException {
|
||||
|
||||
final PushNotification.TokenType tokenType = switch(pushType) {
|
||||
case "apn" -> useVoip ? PushNotification.TokenType.APN_VOIP : PushNotification.TokenType.APN;
|
||||
@@ -203,9 +215,36 @@ public class AccountController {
|
||||
|
||||
Util.requireNormalizedNumber(number);
|
||||
|
||||
String pushChallenge = generatePushChallenge();
|
||||
StoredVerificationCode storedVerificationCode =
|
||||
new StoredVerificationCode(null, System.currentTimeMillis(), pushChallenge, null, null);
|
||||
final Phonenumber.PhoneNumber phoneNumber;
|
||||
try {
|
||||
phoneNumber = PhoneNumberUtil.getInstance().parse(number, null);
|
||||
} catch (final NumberParseException e) {
|
||||
// This should never happen since we just verified that the number is already normalized
|
||||
throw new BadRequestException("Bad phone number");
|
||||
}
|
||||
|
||||
final StoredVerificationCode storedVerificationCode;
|
||||
{
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
if (maybeStoredVerificationCode.isPresent()) {
|
||||
final StoredVerificationCode existingStoredVerificationCode = maybeStoredVerificationCode.get();
|
||||
|
||||
if (StringUtils.isBlank(existingStoredVerificationCode.pushCode())) {
|
||||
storedVerificationCode = new StoredVerificationCode(
|
||||
existingStoredVerificationCode.code(),
|
||||
existingStoredVerificationCode.timestamp(),
|
||||
generatePushChallenge(),
|
||||
existingStoredVerificationCode.sessionId());
|
||||
} else {
|
||||
storedVerificationCode = existingStoredVerificationCode;
|
||||
}
|
||||
} else {
|
||||
final byte[] sessionId = createRegistrationSession(phoneNumber, accounts.getByE164(number).isPresent());
|
||||
storedVerificationCode = new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
|
||||
new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
pendingAccounts.store(number, storedVerificationCode);
|
||||
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode());
|
||||
@@ -216,38 +255,38 @@ public class AccountController {
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/{transport}/code/{number}")
|
||||
@FilterAbusiveMessages
|
||||
@FilterSpam
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response createAccount(@PathParam("transport") String transport,
|
||||
@PathParam("number") String number,
|
||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam("Accept-Language") Optional<String> acceptLanguage,
|
||||
@QueryParam("client") Optional<String> client,
|
||||
@QueryParam("captcha") Optional<String> captcha,
|
||||
@QueryParam("challenge") Optional<String> pushChallenge)
|
||||
throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {
|
||||
public Response createAccount(@PathParam("transport") String transport,
|
||||
@PathParam("number") String number,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage,
|
||||
@QueryParam("client") Optional<String> client,
|
||||
@QueryParam("captcha") Optional<String> captcha,
|
||||
@QueryParam("challenge") Optional<String> pushChallenge,
|
||||
@Extract ScoreThreshold captchaScoreThreshold)
|
||||
throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException {
|
||||
|
||||
Util.requireNormalizedNumber(number);
|
||||
|
||||
final String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
// if there's a captcha, assess it, otherwise check if we need a captcha
|
||||
final Optional<RecaptchaClient.AssessmentResult> assessmentResult = captcha
|
||||
.map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost));
|
||||
final Optional<AssessmentResult> assessmentResult = registrationCaptchaManager.assessCaptcha(captcha, sourceHost);
|
||||
|
||||
assessmentResult.ifPresent(result ->
|
||||
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
|
||||
Tag.of("success", String.valueOf(result.valid())),
|
||||
Tag.of("success", String.valueOf(result.isValid(captchaScoreThreshold.getScoreThreshold()))),
|
||||
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())))
|
||||
Tag.of(SCORE_TAG_NAME, result.getScoreString())))
|
||||
.increment());
|
||||
|
||||
final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode);
|
||||
@@ -257,8 +296,9 @@ public class AccountController {
|
||||
}
|
||||
|
||||
final boolean requiresCaptcha = assessmentResult
|
||||
.map(result -> !result.valid())
|
||||
.orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, pushChallengeMatch));
|
||||
.map(result -> !result.isValid(captchaScoreThreshold.getScoreThreshold()))
|
||||
.orElseGet(
|
||||
() -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch));
|
||||
|
||||
if (requiresCaptcha) {
|
||||
captchaRequiredMeter.mark();
|
||||
@@ -306,14 +346,17 @@ public class AccountController {
|
||||
}
|
||||
}).orElse(ClientType.UNKNOWN);
|
||||
|
||||
final byte[] sessionId = registrationServiceClient.sendRegistrationCode(phoneNumber,
|
||||
messageTransport, clientType, acceptLanguage.orElse(null), REGISTRATION_RPC_TIMEOUT).join();
|
||||
// During the transition to explicit session creation, some previously-stored records may not have a session ID;
|
||||
// after the transition, we can assume that any existing record has an associated session ID.
|
||||
final byte[] sessionId = maybeStoredVerificationCode.isPresent() && maybeStoredVerificationCode.get().sessionId() != null
|
||||
? maybeStoredVerificationCode.get().sessionId()
|
||||
: createRegistrationSession(phoneNumber, accounts.getByE164(number).isPresent());
|
||||
|
||||
sendVerificationCode(sessionId, messageTransport, clientType, acceptLanguage);
|
||||
|
||||
final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
|
||||
System.currentTimeMillis(),
|
||||
maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null),
|
||||
null,
|
||||
sessionId);
|
||||
clock.millis(),
|
||||
maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), sessionId);
|
||||
|
||||
pendingAccounts.store(number, storedVerificationCode);
|
||||
|
||||
@@ -333,9 +376,9 @@ public class AccountController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/code/{verification_code}")
|
||||
public AccountIdentityResponse verifyAccount(@PathParam("verification_code") String verificationCode,
|
||||
@HeaderParam("Authorization") BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam("X-Signal-Agent") String signalAgent,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String signalAgent,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@QueryParam("transfer") Optional<Boolean> availableForTransfer,
|
||||
@NotNull @Valid AccountAttributes accountAttributes)
|
||||
throws RateLimitExceededException, InterruptedException {
|
||||
@@ -348,14 +391,14 @@ public class AccountController {
|
||||
// Note that successful verification depends on being able to find a stored verification code for the given number.
|
||||
// We check that numbers are normalized before we store verification codes, and so don't need to re-assert
|
||||
// normalization here.
|
||||
final boolean codeVerified;
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
final boolean codeVerified = maybeStoredVerificationCode.map(storedVerificationCode ->
|
||||
storedVerificationCode.sessionId() != null ?
|
||||
registrationServiceClient.checkVerificationCode(storedVerificationCode.sessionId(),
|
||||
verificationCode, REGISTRATION_RPC_TIMEOUT).join() :
|
||||
storedVerificationCode.isValid(verificationCode))
|
||||
.orElse(false);
|
||||
if (maybeStoredVerificationCode.isPresent()) {
|
||||
codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), verificationCode);
|
||||
} else {
|
||||
codeVerified = false;
|
||||
}
|
||||
|
||||
if (!codeVerified) {
|
||||
throw new WebApplicationException(Response.status(403).build());
|
||||
@@ -363,8 +406,17 @@ public class AccountController {
|
||||
|
||||
Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
existingAccount.ifPresent(account -> {
|
||||
Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
|
||||
Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
|
||||
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());
|
||||
});
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
verifyRegistrationLock(existingAccount.get(), accountAttributes.getRegistrationLock());
|
||||
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
|
||||
accountAttributes.getRegistrationLock(),
|
||||
userAgent, RegistrationLockVerificationManager.Flow.REGISTRATION,
|
||||
PhoneVerificationRequest.VerificationType.SESSION);
|
||||
}
|
||||
|
||||
if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) {
|
||||
@@ -376,8 +428,6 @@ public class AccountController {
|
||||
Account account = accounts.create(number, password, signalAgent, accountAttributes,
|
||||
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
|
||||
|
||||
metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark();
|
||||
|
||||
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
||||
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
|
||||
@@ -387,7 +437,7 @@ public class AccountController {
|
||||
return new AccountIdentityResponse(account.getUuid(),
|
||||
account.getNumber(),
|
||||
account.getPhoneNumberIdentifier(),
|
||||
account.getUsername().orElse(null),
|
||||
account.getUsernameHash().orElse(null),
|
||||
existingAccount.map(Account::isStorageSupported).orElse(false));
|
||||
}
|
||||
|
||||
@@ -397,7 +447,7 @@ public class AccountController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
|
||||
@NotNull @Valid final ChangePhoneNumberRequest request,
|
||||
@HeaderParam("User-Agent") String userAgent)
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException, InterruptedException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {
|
||||
|
||||
if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) {
|
||||
@@ -412,10 +462,14 @@ public class AccountController {
|
||||
|
||||
rateLimiters.getVerifyLimiter().validate(number);
|
||||
|
||||
final boolean codeVerified = pendingAccounts.getCodeForNumber(number).map(storedVerificationCode ->
|
||||
registrationServiceClient.checkVerificationCode(storedVerificationCode.sessionId(),
|
||||
request.code(), REGISTRATION_RPC_TIMEOUT).join())
|
||||
.orElse(false);
|
||||
final boolean codeVerified;
|
||||
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
|
||||
|
||||
if (maybeStoredVerificationCode.isPresent()) {
|
||||
codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), request.code());
|
||||
} else {
|
||||
codeVerified = false;
|
||||
}
|
||||
|
||||
if (!codeVerified) {
|
||||
throw new ForbiddenException();
|
||||
@@ -424,7 +478,8 @@ public class AccountController {
|
||||
final Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
verifyRegistrationLock(existingAccount.get(), request.registrationLock());
|
||||
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock(),
|
||||
userAgent, RegistrationLockVerificationManager.Flow.CHANGE_NUMBER, PhoneVerificationRequest.VerificationType.SESSION);
|
||||
}
|
||||
|
||||
rateLimiters.getVerifyLimiter().clear(number);
|
||||
@@ -444,7 +499,7 @@ public class AccountController {
|
||||
updatedAccount.getUuid(),
|
||||
updatedAccount.getNumber(),
|
||||
updatedAccount.getPhoneNumberIdentifier(),
|
||||
updatedAccount.getUsername().orElse(null),
|
||||
updatedAccount.getUsernameHash().orElse(null),
|
||||
updatedAccount.isStorageSupported());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(409)
|
||||
@@ -475,6 +530,7 @@ public class AccountController {
|
||||
@PUT
|
||||
@Path("/gcm/")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@ChangesDeviceEnabledState
|
||||
public void setGcmRegistrationId(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth,
|
||||
@NotNull @Valid GcmRegistrationId registrationId) {
|
||||
@@ -551,10 +607,10 @@ public class AccountController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/registration_lock")
|
||||
public void setRegistrationLock(@Auth AuthenticatedAccount auth, @NotNull @Valid RegistrationLock accountLock) {
|
||||
AuthenticationCredentials credentials = new AuthenticationCredentials(accountLock.getRegistrationLock());
|
||||
SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
|
||||
|
||||
accounts.update(auth.getAccount(),
|
||||
a -> a.setRegistrationLock(credentials.getHashedAuthenticationToken(), credentials.getSalt()));
|
||||
a -> a.setRegistrationLock(credentials.hash(), credentials.salt()));
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -585,13 +641,14 @@ public class AccountController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@ChangesDeviceEnabledState
|
||||
public void setAccountAttributes(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth,
|
||||
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||
public void setAccountAttributes(
|
||||
@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid AccountAttributes attributes) {
|
||||
Account account = disabledPermittedAuth.getAccount();
|
||||
long deviceId = disabledPermittedAuth.getAuthenticatedDevice().getId();
|
||||
final Account account = disabledPermittedAuth.getAccount();
|
||||
final long deviceId = disabledPermittedAuth.getAuthenticatedDevice().getId();
|
||||
|
||||
accounts.update(account, a -> {
|
||||
final Account updatedAccount = accounts.update(account, a -> {
|
||||
a.getDevice(deviceId).ifPresent(d -> {
|
||||
d.setFetchesMessages(attributes.getFetchesMessages());
|
||||
d.setName(attributes.getName());
|
||||
@@ -607,112 +664,108 @@ public class AccountController {
|
||||
a.setUnrestrictedUnidentifiedAccess(attributes.isUnrestrictedUnidentifiedAccess());
|
||||
a.setDiscoverableByPhoneNumber(attributes.isDiscoverableByPhoneNumber());
|
||||
});
|
||||
|
||||
// if registration recovery password was sent to us, store it (or refresh its expiration)
|
||||
attributes.recoveryPassword().ifPresent(registrationRecoveryPassword ->
|
||||
registrationRecoveryPasswordsManager.storeForCurrentNumber(updatedAccount.getNumber(), registrationRecoveryPassword));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse getMe(@Auth AuthenticatedAccount auth) {
|
||||
return whoAmI(auth);
|
||||
public AccountIdentityResponse getMe(@Auth DisabledPermittedAuthenticatedAccount auth) {
|
||||
return buildAccountIdentityResponse(auth);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/whoami")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse whoAmI(@Auth AuthenticatedAccount auth) {
|
||||
return buildAccountIdentityResponse(auth);
|
||||
}
|
||||
|
||||
private AccountIdentityResponse buildAccountIdentityResponse(AccountAndAuthenticatedDeviceHolder auth) {
|
||||
return new AccountIdentityResponse(auth.getAccount().getUuid(),
|
||||
auth.getAccount().getNumber(),
|
||||
auth.getAccount().getPhoneNumberIdentifier(),
|
||||
auth.getAccount().getUsername().orElse(null),
|
||||
auth.getAccount().getUsernameHash().orElse(null),
|
||||
auth.getAccount().isStorageSupported());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/username")
|
||||
@Path("/username_hash")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void deleteUsername(@Auth AuthenticatedAccount auth) {
|
||||
accounts.clearUsername(auth.getAccount());
|
||||
public void deleteUsernameHash(@Auth AuthenticatedAccount auth) {
|
||||
accounts.clearUsernameHash(auth.getAccount());
|
||||
}
|
||||
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username/reserved")
|
||||
@Path("/username_hash/reserve")
|
||||
@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 {
|
||||
public ReserveUsernameHashResponse reserveUsernameHash(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
|
||||
|
||||
for (byte[] hash : usernameRequest.usernameHashes()) {
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final AccountsManager.UsernameReservation reservation = accounts.reserveUsername(
|
||||
final AccountsManager.UsernameReservation reservation = accounts.reserveUsernameHash(
|
||||
auth.getAccount(),
|
||||
usernameRequest.nickname()
|
||||
usernameRequest.usernameHashes()
|
||||
);
|
||||
return new ReserveUsernameResponse(reservation.reservedUsername(), reservation.reservationToken());
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
return new ReserveUsernameHashResponse(reservation.reservedUsernameHash());
|
||||
} catch (final UsernameHashNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username/confirm")
|
||||
@Path("/username_hash/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 {
|
||||
public UsernameHashResponse confirmUsernameHash(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException {
|
||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||
|
||||
try {
|
||||
final Account account = accounts.confirmReservedUsername(auth.getAccount(), confirmRequest.usernameToConfirm(), confirmRequest.reservationToken());
|
||||
usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash());
|
||||
} catch (final BaseUsernameException e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
try {
|
||||
final Account account = accounts.confirmReservedUsernameHash(auth.getAccount(), confirmRequest.usernameHash());
|
||||
return account
|
||||
.getUsername()
|
||||
.map(UsernameResponse::new)
|
||||
.getUsernameHash()
|
||||
.map(UsernameHashResponse::new)
|
||||
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||
} catch (final UsernameReservationNotFoundException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
} catch (final UsernameHashNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/username")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public UsernameResponse setUsername(
|
||||
@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam("X-Signal-Agent") String userAgent,
|
||||
@NotNull @Valid UsernameRequest usernameRequest) throws RateLimitExceededException {
|
||||
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
|
||||
checkUsername(usernameRequest.existingUsername(), userAgent);
|
||||
|
||||
try {
|
||||
final Account account = accounts.setUsername(auth.getAccount(), usernameRequest.nickname(),
|
||||
usernameRequest.existingUsername());
|
||||
return account
|
||||
.getUsername()
|
||||
.map(UsernameResponse::new)
|
||||
.orElseThrow(() -> new IllegalStateException("Could not get username after setting"));
|
||||
} catch (final UsernameNotAvailableException e) {
|
||||
throw new WebApplicationException(Status.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/username/{username}")
|
||||
@Path("/username_hash/{usernameHash}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentifierResponse lookupUsername(
|
||||
@HeaderParam("X-Signal-Agent") final String userAgent,
|
||||
@HeaderParam("X-Forwarded-For") final String forwardedFor,
|
||||
@PathParam("username") final String username,
|
||||
@RateLimitedByIp(RateLimiters.For.USERNAME_LOOKUP)
|
||||
public AccountIdentifierResponse lookupUsernameHash(
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
|
||||
@PathParam("usernameHash") final String usernameHash,
|
||||
@Context final HttpServletRequest request) throws RateLimitExceededException {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -722,10 +775,19 @@ public class AccountController {
|
||||
|
||||
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
|
||||
|
||||
checkUsername(username, userAgent);
|
||||
final byte[] hash;
|
||||
try {
|
||||
hash = Base64.getUrlDecoder().decode(usernameHash);
|
||||
} catch (IllegalArgumentException | AssertionError e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
return accounts
|
||||
.getByUsername(username)
|
||||
.getByUsernameHash(hash)
|
||||
.map(Account::getUuid)
|
||||
.map(AccountIdentifierResponse::new)
|
||||
.orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND));
|
||||
@@ -733,8 +795,8 @@ public class AccountController {
|
||||
|
||||
@HEAD
|
||||
@Path("/account/{uuid}")
|
||||
@RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)
|
||||
public Response accountExists(
|
||||
@HeaderParam("X-Forwarded-For") final String forwardedFor,
|
||||
@PathParam("uuid") final UUID uuid,
|
||||
@Context HttpServletRequest request) throws RateLimitExceededException {
|
||||
|
||||
@@ -742,7 +804,6 @@ public class AccountController {
|
||||
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
rateLimitByClientIp(rateLimiters.getCheckAccountExistenceLimiter(), forwardedFor);
|
||||
|
||||
final Status status = accounts.getByAccountIdentifier(uuid)
|
||||
.or(() -> accounts.getByPhoneNumberIdentifier(uuid))
|
||||
@@ -752,41 +813,18 @@ public class AccountController {
|
||||
}
|
||||
|
||||
private void rateLimitByClientIp(final RateLimiter rateLimiter, final String forwardedFor) throws RateLimitExceededException {
|
||||
final String mostRecentProxy = ForwardedIpUtil.getMostRecentProxy(forwardedFor)
|
||||
final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor)
|
||||
.orElseThrow(() -> {
|
||||
// Missing/malformed Forwarded-For, so we cannot check for a rate-limit.
|
||||
// This shouldn't happen, so conservatively assume we're over the rate-limit
|
||||
// and indicate that the client should retry
|
||||
logger.error("Missing/bad Forwarded-For: {}", forwardedFor);
|
||||
return new RateLimitExceededException(Duration.ofHours(1));
|
||||
return new RateLimitExceededException(Duration.ofHours(1), true);
|
||||
});
|
||||
|
||||
rateLimiter.validate(mostRecentProxy);
|
||||
}
|
||||
|
||||
private void verifyRegistrationLock(final Account existingAccount, @Nullable final String clientRegistrationLock)
|
||||
throws RateLimitExceededException, WebApplicationException {
|
||||
|
||||
final StoredRegistrationLock existingRegistrationLock = existingAccount.getRegistrationLock();
|
||||
final ExternalServiceCredentials existingBackupCredentials =
|
||||
backupServiceCredentialGenerator.generateFor(existingAccount.getUuid().toString());
|
||||
|
||||
if (existingRegistrationLock.requiresClientRegistrationLock()) {
|
||||
if (!Util.isEmpty(clientRegistrationLock)) {
|
||||
rateLimiters.getPinLimiter().validate(existingAccount.getNumber());
|
||||
}
|
||||
|
||||
if (!existingRegistrationLock.verify(clientRegistrationLock)) {
|
||||
throw new WebApplicationException(Response.status(423)
|
||||
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining(),
|
||||
existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null))
|
||||
.build());
|
||||
}
|
||||
|
||||
rateLimiters.getPinLimiter().clear(existingAccount.getNumber());
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static boolean pushChallengeMatches(
|
||||
final String number,
|
||||
@@ -810,97 +848,81 @@ public class AccountController {
|
||||
return match;
|
||||
}
|
||||
|
||||
private boolean requiresCaptcha(String number, String transport, String forwardedFor, String sourceHost, boolean pushChallengeMatch) {
|
||||
if (testDevices.containsKey(number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pushChallengeMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final String countryCode = Util.getCountryCode(number);
|
||||
final String region = Util.getRegion(number);
|
||||
|
||||
DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration()
|
||||
.getCaptchaConfiguration();
|
||||
|
||||
boolean countryFiltered = captchaConfig.getSignupCountryCodes().contains(countryCode) ||
|
||||
captchaConfig.getSignupRegions().contains(region);
|
||||
|
||||
if (abusiveHostRules.isBlocked(sourceHost)) {
|
||||
blockedHostMeter.mark();
|
||||
logger.info("Blocked host: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||
if (countryFiltered) {
|
||||
// this host was caught in the abusiveHostRules filter, but
|
||||
// would be caught by country filter as well
|
||||
countryFilterApplicable.mark();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
rateLimiters.getSmsVoiceIpLimiter().validate(sourceHost);
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||
rateLimitedHostMeter.mark();
|
||||
if (shouldAutoBlock(sourceHost)) {
|
||||
logger.info("Auto-block: {}", sourceHost);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
rateLimiters.getSmsVoicePrefixLimiter().validate(Util.getNumberPrefix(number));
|
||||
} catch (RateLimitExceededException e) {
|
||||
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||
rateLimitedPrefixMeter.mark();
|
||||
if (shouldAutoBlock(sourceHost)) {
|
||||
logger.info("Auto-block: {}", sourceHost);
|
||||
abusiveHostRules.setBlockedHost(sourceHost);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (countryFiltered) {
|
||||
countryFilteredHostMeter.mark();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/me")
|
||||
public void deleteAccount(@Auth AuthenticatedAccount auth) throws InterruptedException {
|
||||
public void deleteAccount(@Auth DisabledPermittedAuthenticatedAccount auth) throws InterruptedException {
|
||||
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
|
||||
}
|
||||
|
||||
private void checkUsername(final String username, final String userAgent) {
|
||||
if (StringUtils.isNotBlank(username) && !UsernameGenerator.isStandardFormat(username)) {
|
||||
// Technically, a username may not be in the nickname#discriminator format
|
||||
// if created through some out-of-band mechanism, but it is atypical.
|
||||
Metrics.counter(NONSTANDARD_USERNAME_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldAutoBlock(String sourceHost) {
|
||||
try {
|
||||
rateLimiters.getAutoBlockLimiter().validate(sourceHost);
|
||||
} catch (RateLimitExceededException e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String generatePushChallenge() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] challenge = new byte[16];
|
||||
random.nextBytes(challenge);
|
||||
|
||||
return Hex.toStringCondensed(challenge);
|
||||
return HexFormat.of().formatHex(challenge);
|
||||
}
|
||||
|
||||
private byte[] createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber,
|
||||
final boolean accountExistsWithPhoneNumber) throws RateLimitExceededException {
|
||||
|
||||
try {
|
||||
return registrationServiceClient.createRegistrationSession(phoneNumber, accountExistsWithPhoneNumber, REGISTRATION_RPC_TIMEOUT).join();
|
||||
} catch (final CompletionException e) {
|
||||
rethrowRateLimitException(e);
|
||||
|
||||
logger.debug("Failed to create session", e);
|
||||
|
||||
// Meet legacy client expectations by "swallowing" session creation exceptions and proceeding as if we had created
|
||||
// a new session. Future operations on this "session" will always fail, but that's the legacy behavior.
|
||||
return new byte[16];
|
||||
}
|
||||
}
|
||||
|
||||
private void sendVerificationCode(final byte[] sessionId,
|
||||
final MessageTransport messageTransport,
|
||||
final ClientType clientType,
|
||||
final Optional<String> acceptLanguage) throws RateLimitExceededException {
|
||||
|
||||
try {
|
||||
registrationServiceClient.sendRegistrationCode(sessionId,
|
||||
messageTransport,
|
||||
clientType,
|
||||
acceptLanguage.orElse(null),
|
||||
REGISTRATION_RPC_TIMEOUT).join();
|
||||
} catch (final CompletionException e) {
|
||||
// Note that, to meet legacy client expectations, we'll ONLY rethrow rate limit exceptions. All others will be
|
||||
// swallowed silently.
|
||||
rethrowRateLimitException(e);
|
||||
|
||||
logger.debug("Failed to send verification code", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkVerificationCode(final byte[] sessionId, final String verificationCode)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
try {
|
||||
return registrationServiceClient.checkVerificationCode(sessionId, verificationCode, REGISTRATION_RPC_TIMEOUT).join();
|
||||
} catch (final CompletionException e) {
|
||||
rethrowRateLimitException(e);
|
||||
|
||||
// For legacy API compatibility, funnel all errors into the same return value
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void rethrowRateLimitException(final CompletionException completionException)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
Throwable cause = completionException;
|
||||
|
||||
while (cause instanceof CompletionException) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
|
||||
if (cause instanceof RateLimitExceededException rateLimitExceededException) {
|
||||
throw rateLimitExceededException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountDataReportResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
|
||||
@Path("/v2/accounts")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
|
||||
public class AccountControllerV2 {
|
||||
|
||||
private static final String CHANGE_NUMBER_COUNTER_NAME = name(AccountControllerV2.class, "changeNumber");
|
||||
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final ChangeNumberManager changeNumberManager;
|
||||
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
|
||||
private final RegistrationLockVerificationManager registrationLockVerificationManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public AccountControllerV2(final AccountsManager accountsManager, final ChangeNumberManager changeNumberManager,
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager,
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.changeNumberManager = changeNumberManager;
|
||||
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
|
||||
this.registrationLockVerificationManager = registrationLockVerificationManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/number")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
|
||||
@NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent)
|
||||
throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
final String number = request.number();
|
||||
|
||||
// Only verify and check reglock if there's a data change to be made...
|
||||
if (!authenticatedAccount.getAccount().getNumber().equals(number)) {
|
||||
|
||||
RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number));
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number,
|
||||
request);
|
||||
|
||||
final Optional<Account> existingAccount = accountsManager.getByE164(number);
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock(),
|
||||
userAgent, RegistrationLockVerificationManager.Flow.CHANGE_NUMBER, verificationType);
|
||||
}
|
||||
|
||||
Metrics.counter(CHANGE_NUMBER_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))
|
||||
.increment();
|
||||
}
|
||||
|
||||
// ...but always attempt to make the change in case a client retries and needs to re-send messages
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.changeNumber(
|
||||
authenticatedAccount.getAccount(),
|
||||
request.number(),
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
request.deviceMessages(),
|
||||
request.pniRegistrationIds());
|
||||
|
||||
return new AccountIdentityResponse(
|
||||
updatedAccount.getUuid(),
|
||||
updatedAccount.getNumber(),
|
||||
updatedAccount.getPhoneNumberIdentifier(),
|
||||
updatedAccount.getUsernameHash().orElse(null),
|
||||
updatedAccount.isStorageSupported());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(409)
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new MismatchedDevices(e.getMissingDevices(),
|
||||
e.getExtraDevices()))
|
||||
.build());
|
||||
} catch (StaleDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(410)
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(new StaleDevices(e.getStaleDevices()))
|
||||
.build());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/phone_number_discoverability")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setPhoneNumberDiscoverability(
|
||||
@Auth AuthenticatedAccount auth,
|
||||
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability
|
||||
) {
|
||||
accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber(
|
||||
phoneNumberDiscoverability.discoverableByPhoneNumber()));
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/data_report")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Produces a report of non-ephemeral account data stored by the service")
|
||||
@ApiResponse(responseCode = "200",
|
||||
description = "Response with data report. A plain text representation is a field in the response.",
|
||||
useReturnTypeSchema = true)
|
||||
public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedAccount auth) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
|
||||
return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(),
|
||||
new AccountDataReportResponse.AccountAndDevicesDataReport(
|
||||
new AccountDataReportResponse.AccountDataReport(
|
||||
account.getNumber(),
|
||||
account.getBadges().stream().map(AccountDataReportResponse.BadgeDataReport::new).toList(),
|
||||
account.isUnrestrictedUnidentifiedAccess(),
|
||||
account.isDiscoverableByPhoneNumber()),
|
||||
account.getDevices().stream().map(device ->
|
||||
new AccountDataReportResponse.DeviceDataReport(
|
||||
device.getId(),
|
||||
Instant.ofEpochMilli(device.getLastSeen()),
|
||||
Instant.ofEpochMilli(device.getCreated()),
|
||||
device.getUserAgent())).toList()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.UUID;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
|
||||
@Path("/v1/art")
|
||||
@Tag(name = "Art")
|
||||
public class ArtController {
|
||||
private final ExternalServiceCredentialsGenerator artServiceCredentialsGenerator;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final ArtServiceConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.getUserAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.getUserAuthenticationTokenUserIdSecret())
|
||||
.prependUsername(false)
|
||||
.truncateSignature(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
public ArtController(RateLimiters rateLimiters,
|
||||
ExternalServiceCredentialsGenerator artServiceCredentialsGenerator) {
|
||||
this.artServiceCredentialsGenerator = artServiceCredentialsGenerator;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth)
|
||||
throws RateLimitExceededException {
|
||||
final UUID uuid = auth.getAccount().getUuid();
|
||||
rateLimiters.getArtPackLimiter().validate(uuid);
|
||||
return artServiceCredentialsGenerator.generateForUuid(uuid);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
@@ -24,6 +25,7 @@ import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
@Path("/v2/attachments")
|
||||
@Tag(name = "Attachments")
|
||||
public class AttachmentControllerV2 {
|
||||
|
||||
private final PostPolicyGenerator policyGenerator;
|
||||
@@ -32,7 +34,7 @@ public class AttachmentControllerV2 {
|
||||
|
||||
public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region,
|
||||
String bucket) {
|
||||
this.rateLimiter = rateLimiters.getAttachmentLimiter();
|
||||
this.rateLimiter = rateLimiters.getAttachmentLimiter();
|
||||
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
|
||||
this.policySigner = new PolicySigner(accessSecret, region);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.io.IOException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.SecureRandom;
|
||||
@@ -29,6 +30,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
|
||||
@Path("/v3/attachments")
|
||||
@Tag(name = "Attachments")
|
||||
public class AttachmentControllerV3 {
|
||||
|
||||
@Nonnull
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.time.Clock;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
@Path("/v1/call-link")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink")
|
||||
public class CallLinkController {
|
||||
@VisibleForTesting
|
||||
public static final String ANONYMOUS_CREDENTIAL_PREFIX = "anon";
|
||||
|
||||
private final ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator;
|
||||
|
||||
public CallLinkController(
|
||||
ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator
|
||||
) {
|
||||
this.callingFrontendServiceCredentialGenerator = callingFrontendServiceCredentialGenerator;
|
||||
}
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final CallLinkConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), ANONYMOUS_CREDENTIAL_PREFIX)
|
||||
.build();
|
||||
}
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(
|
||||
summary = "Generate credentials for calling frontend",
|
||||
description = """
|
||||
These credentials enable clients to prove to calling frontend that they were a Signal user within the last day.
|
||||
For client privacy, timestamps are truncated to 1 day granularity and the token does not include or derive from an ACI.
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
|
||||
return callingFrontendServiceCredentialGenerator.generateWithTimestampAsUsername();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
@@ -42,6 +43,7 @@ import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/certificate")
|
||||
@Tag(name = "Certificate")
|
||||
public class CertificateController {
|
||||
|
||||
private final CertificateGenerator certificateGenerator;
|
||||
|
||||
@@ -8,18 +8,27 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.util.NoSuchElementException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
@@ -29,9 +38,10 @@ import org.whispersystems.textsecuregcm.entities.AnswerRecaptchaChallengeRequest
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.util.ForwardedIpUtil;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
@Path("/v1/challenge")
|
||||
@Tag(name = "Challenge")
|
||||
public class ChallengeController {
|
||||
|
||||
private final RateLimitChallengeManager rateLimitChallengeManager;
|
||||
@@ -47,10 +57,27 @@ public class ChallengeController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(
|
||||
summary = "Submit proof of a challenge completion",
|
||||
description = """
|
||||
Some server endpoints (the "send message" endpoint, for example) may return a 428 response indicating the client must complete a challenge before continuing.
|
||||
Clients may use this endpoint to provide proof of a completed challenge. If successful, the client may then
|
||||
continue their original operation.
|
||||
""",
|
||||
requestBody = @RequestBody(content = {@Content(schema = @Schema(oneOf = {AnswerPushChallengeRequest.class,
|
||||
AnswerRecaptchaChallengeRequest.class}))})
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Indicates the challenge proof was accepted")
|
||||
@ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth,
|
||||
@Valid final AnswerChallengeRequest answerRequest,
|
||||
@HeaderParam("X-Forwarded-For") final String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException {
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
|
||||
|
||||
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
|
||||
|
||||
@@ -59,19 +86,20 @@ public class ChallengeController {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
|
||||
|
||||
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
|
||||
} else if (answerRequest instanceof AnswerRecaptchaChallengeRequest) {
|
||||
} else if (answerRequest instanceof AnswerRecaptchaChallengeRequest recaptchaChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "recaptcha");
|
||||
|
||||
try {
|
||||
final AnswerRecaptchaChallengeRequest recaptchaChallengeRequest = (AnswerRecaptchaChallengeRequest) answerRequest;
|
||||
final String mostRecentProxy = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(() -> new BadRequestException());
|
||||
boolean success = rateLimitChallengeManager.answerRecaptchaChallenge(
|
||||
auth.getAccount(),
|
||||
recaptchaChallengeRequest.getCaptcha(),
|
||||
mostRecentProxy,
|
||||
userAgent);
|
||||
|
||||
rateLimitChallengeManager.answerRecaptchaChallenge(auth.getAccount(), recaptchaChallengeRequest.getCaptcha(),
|
||||
mostRecentProxy, userAgent);
|
||||
|
||||
} catch (final NoSuchElementException e) {
|
||||
return Response.status(400).build();
|
||||
if (!success) {
|
||||
return Response.status(428).build();
|
||||
}
|
||||
|
||||
} else {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "unrecognized");
|
||||
}
|
||||
@@ -85,6 +113,47 @@ public class ChallengeController {
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/push")
|
||||
@Operation(
|
||||
summary = "Request a push challenge",
|
||||
description = """
|
||||
Clients may proactively request a push challenge by making an empty POST request. Push challenges will only be
|
||||
sent to the requesting account’s main device. When the push is received it may be provided as proof of completed
|
||||
challenge to /v1/challenge.
|
||||
APNs challenge payloads will be formatted as follows:
|
||||
```
|
||||
{
|
||||
"aps": {
|
||||
"sound": "default",
|
||||
"alert": {
|
||||
"loc-key": "APN_Message"
|
||||
}
|
||||
},
|
||||
"rateLimitChallenge": "{CHALLENGE_TOKEN}"
|
||||
}
|
||||
```
|
||||
FCM challenge payloads will be formatted as follows:
|
||||
```
|
||||
{"rateLimitChallenge": "{CHALLENGE_TOKEN}"}
|
||||
```
|
||||
|
||||
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
|
||||
implement an exponential back-off system and limit the total number of retries.
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = """
|
||||
Indicates a payload to the account's primary device has been attempted. When clients receive a challenge push
|
||||
notification, they may issue a PUT request to /v1/challenge.
|
||||
""")
|
||||
@ApiResponse(responseCode = "404", description = """
|
||||
The server does not have a push notification token for the authenticated account’s main device; clients may add a push
|
||||
token and try again
|
||||
""")
|
||||
@ApiResponse(responseCode = "413", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response requestPushChallenge(@Auth final AuthenticatedAccount auth) {
|
||||
try {
|
||||
rateLimitChallengeManager.sendPushChallenge(auth.getAccount());
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/*
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -29,9 +31,9 @@ import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthEnablementRefreshRequirementProvider;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticationCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
|
||||
@@ -47,10 +49,9 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.util.VerificationCode;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
|
||||
@Path("/v1/devices")
|
||||
@Tag(name = "Devices")
|
||||
public class DeviceController {
|
||||
|
||||
private static final int MAX_DEVICES = 6;
|
||||
@@ -134,7 +135,7 @@ public class DeviceController {
|
||||
|
||||
VerificationCode verificationCode = generateVerificationCode();
|
||||
StoredVerificationCode storedVerificationCode =
|
||||
new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null, null);
|
||||
new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null);
|
||||
|
||||
pendingDevices.store(account.getNumber(), storedVerificationCode);
|
||||
|
||||
@@ -148,8 +149,8 @@ public class DeviceController {
|
||||
@Path("/{verification_code}")
|
||||
@ChangesDeviceEnabledState
|
||||
public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode,
|
||||
@HeaderParam("Authorization") BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@NotNull @Valid AccountAttributes accountAttributes,
|
||||
@Context ContainerRequest containerRequest)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
@@ -187,13 +188,13 @@ public class DeviceController {
|
||||
}
|
||||
|
||||
final DeviceCapabilities capabilities = accountAttributes.getCapabilities();
|
||||
if (capabilities != null && isCapabilityDowngrade(account.get(), capabilities, userAgent)) {
|
||||
if (capabilities != null && isCapabilityDowngrade(account.get(), capabilities)) {
|
||||
throw new WebApplicationException(Response.status(409).build());
|
||||
}
|
||||
|
||||
Device device = new Device();
|
||||
device.setName(accountAttributes.getName());
|
||||
device.setAuthenticationCredentials(new AuthenticationCredentials(password));
|
||||
device.setAuthTokenHash(SaltedTokenHash.generateFor(password));
|
||||
device.setFetchesMessages(accountAttributes.getFetchesMessages());
|
||||
device.setRegistrationId(accountAttributes.getRegistrationId());
|
||||
accountAttributes.getPhoneNumberIdentityRegistrationId().ifPresent(device::setPhoneNumberIdentityRegistrationId);
|
||||
@@ -223,7 +224,7 @@ public class DeviceController {
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/capabilities")
|
||||
public void setCapabiltities(@Auth AuthenticatedAccount auth, @NotNull @Valid DeviceCapabilities capabilities) {
|
||||
public void setCapabilities(@Auth AuthenticatedAccount auth, @NotNull @Valid DeviceCapabilities capabilities) {
|
||||
assert (auth.getAuthenticatedDevice() != null);
|
||||
final long deviceId = auth.getAuthenticatedDevice().getId();
|
||||
accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities));
|
||||
@@ -235,7 +236,7 @@ public class DeviceController {
|
||||
return new VerificationCode(randomInt);
|
||||
}
|
||||
|
||||
private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities, String userAgent) {
|
||||
private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) {
|
||||
boolean isDowngrade = false;
|
||||
|
||||
isDowngrade |= account.isStoriesSupported() && !capabilities.isStories();
|
||||
@@ -243,35 +244,8 @@ public class DeviceController {
|
||||
isDowngrade |= account.isChangeNumberSupported() && !capabilities.isChangeNumber();
|
||||
isDowngrade |= account.isAnnouncementGroupSupported() && !capabilities.isAnnouncementGroup();
|
||||
isDowngrade |= account.isSenderKeySupported() && !capabilities.isSenderKey();
|
||||
isDowngrade |= account.isGv1MigrationSupported() && !capabilities.isGv1Migration();
|
||||
isDowngrade |= account.isGiftBadgesSupported() && !capabilities.isGiftBadges();
|
||||
|
||||
if (account.isGroupsV2Supported()) {
|
||||
try {
|
||||
switch (UserAgentUtil.parseUserAgentString(userAgent).getPlatform()) {
|
||||
case DESKTOP:
|
||||
case ANDROID: {
|
||||
if (!capabilities.isGv2_3()) {
|
||||
isDowngrade = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case IOS: {
|
||||
if (!capabilities.isGv2_2() && !capabilities.isGv2_3()) {
|
||||
isDowngrade = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (final UnrecognizedUserAgentException e) {
|
||||
// If we can't parse the UA string, the client is for sure too old to support groups V2
|
||||
isDowngrade = true;
|
||||
}
|
||||
}
|
||||
|
||||
return isDowngrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.PUT;
|
||||
@@ -14,14 +15,23 @@ import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryClientConfiguration;
|
||||
|
||||
@Path("/v1/directory")
|
||||
@Tag(name = "Directory")
|
||||
public class DirectoryController {
|
||||
|
||||
private final ExternalServiceCredentialGenerator directoryServiceTokenGenerator;
|
||||
private final ExternalServiceCredentialsGenerator directoryServiceTokenGenerator;
|
||||
|
||||
public DirectoryController(ExternalServiceCredentialGenerator userTokenGenerator) {
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryClientConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.getUserAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.getUserAuthenticationTokenUserIdSecret())
|
||||
.build();
|
||||
}
|
||||
|
||||
public DirectoryController(ExternalServiceCredentialsGenerator userTokenGenerator) {
|
||||
this.directoryServiceTokenGenerator = userTokenGenerator;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
@@ -13,15 +16,32 @@ import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;
|
||||
|
||||
@Path("/v2/directory")
|
||||
@Tag(name = "Directory")
|
||||
public class DirectoryV2Controller {
|
||||
|
||||
private final ExternalServiceCredentialGenerator directoryServiceTokenGenerator;
|
||||
private final ExternalServiceCredentialsGenerator directoryServiceTokenGenerator;
|
||||
|
||||
public DirectoryV2Controller(ExternalServiceCredentialGenerator userTokenGenerator) {
|
||||
@VisibleForTesting
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg,
|
||||
final Clock clock) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
|
||||
.prependUsername(false)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg) {
|
||||
return credentialsGenerator(cfg, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public DirectoryV2Controller(ExternalServiceCredentialsGenerator userTokenGenerator) {
|
||||
this.directoryServiceTokenGenerator = userTokenGenerator;
|
||||
}
|
||||
|
||||
@@ -31,7 +51,7 @@ public class DirectoryV2Controller {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response getAuthToken(@Auth AuthenticatedAccount auth) {
|
||||
final UUID uuid = auth.getAccount().getUuid();
|
||||
final ExternalServiceCredentials credentials = directoryServiceTokenGenerator.generateFor(uuid.toString());
|
||||
final ExternalServiceCredentials credentials = directoryServiceTokenGenerator.generateForUuid(uuid);
|
||||
return Response.ok().entity(credentials).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
@@ -42,6 +43,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
|
||||
@Path("/v1/donation")
|
||||
@Tag(name = "Donations")
|
||||
public class DonationController {
|
||||
|
||||
public interface ReceiptCredentialPresentationFactory {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
public class InvalidDestinationException extends Exception {
|
||||
public InvalidDestinationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -10,22 +10,22 @@ import static com.codahale.metrics.MetricRegistry.name;
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.core.Response;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.session.WebSocketSession;
|
||||
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
||||
|
||||
|
||||
@Path("/v1/keepalive")
|
||||
@Tag(name = "Keep Alive")
|
||||
public class KeepAliveController {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(KeepAliveController.class);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
@@ -7,9 +7,11 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
@@ -49,21 +51,18 @@ import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.Keys;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v2/keys")
|
||||
@Tag(name = "Keys")
|
||||
public class KeysController {
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
private final Keys keys;
|
||||
private final AccountsManager accounts;
|
||||
|
||||
private static final String PREKEY_REQUEST_COUNTER_NAME = name(KeysController.class, "preKeyGet");
|
||||
private static final String IDENTITY_KEY_CHANGE_FORBIDDEN_COUNTER_NAME = name(KeysController.class, "identityKeyChangeForbidden");
|
||||
|
||||
private static final String SOURCE_COUNTRY_TAG_NAME = "sourceCountry";
|
||||
private static final String INTERNATIONAL_TAG_NAME = "international";
|
||||
private static final String IDENTITY_TYPE_TAG_NAME = "identityType";
|
||||
private static final String HAS_IDENTITY_KEY_TAG_NAME = "hasIdentityKey";
|
||||
|
||||
@@ -80,10 +79,6 @@ public class KeysController {
|
||||
|
||||
int count = keys.getCount(getIdentifier(auth.getAccount(), identityType), auth.getAuthenticatedDevice().getId());
|
||||
|
||||
if (count > 0) {
|
||||
count = count - 1;
|
||||
}
|
||||
|
||||
return new PreKeyCount(count);
|
||||
}
|
||||
|
||||
@@ -94,7 +89,7 @@ public class KeysController {
|
||||
public void setKeys(@Auth final DisabledPermittedAuthenticatedAccount disabledPermittedAuth,
|
||||
@NotNull @Valid final PreKeyState preKeys,
|
||||
@QueryParam("identity") final Optional<String> identityType,
|
||||
@HeaderParam("User-Agent") String userAgent) {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
|
||||
Account account = disabledPermittedAuth.getAccount();
|
||||
Device device = disabledPermittedAuth.getAuthenticatedDevice();
|
||||
boolean updateAccount = false;
|
||||
@@ -151,7 +146,7 @@ public class KeysController {
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@PathParam("identifier") UUID targetUuid,
|
||||
@PathParam("device_id") String deviceId,
|
||||
@HeaderParam("User-Agent") String userAgent)
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (!auth.isPresent() && !accessKey.isPresent()) {
|
||||
@@ -170,16 +165,6 @@ public class KeysController {
|
||||
target = maybeTarget.orElseThrow();
|
||||
}
|
||||
|
||||
{
|
||||
final String sourceCountryCode = account.map(a -> Util.getCountryCode(a.getNumber())).orElse("0");
|
||||
final String targetCountryCode = Util.getCountryCode(target.getNumber());
|
||||
|
||||
Metrics.counter(PREKEY_REQUEST_COUNTER_NAME, Tags.of(
|
||||
SOURCE_COUNTRY_TAG_NAME, sourceCountryCode,
|
||||
INTERNATIONAL_TAG_NAME, String.valueOf(!sourceCountryCode.equals(targetCountryCode))
|
||||
)).increment();
|
||||
}
|
||||
|
||||
if (account.isPresent()) {
|
||||
rateLimiters.getPreKeysLimiter().validate(
|
||||
account.get().getUuid() + "." + auth.get().getAuthenticatedDevice().getId() + "__" + targetUuid
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
@@ -8,6 +8,7 @@ import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.dropwizard.util.DataSize;
|
||||
@@ -16,6 +17,7 @@ import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
@@ -53,12 +55,13 @@ import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.abuse.FilterAbusiveMessages;
|
||||
import org.whispersystems.textsecuregcm.auth.Anonymous;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.CombinedUnidentifiedSenderAccessKeys;
|
||||
@@ -76,6 +79,7 @@ import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
|
||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SpamReport;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
|
||||
@@ -85,6 +89,8 @@ import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||
import org.whispersystems.textsecuregcm.spam.FilterSpam;
|
||||
import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager;
|
||||
@@ -98,9 +104,11 @@ import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||
import org.whispersystems.websocket.Stories;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/messages")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Messages")
|
||||
public class MessageController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MessageController.class);
|
||||
@@ -114,6 +122,8 @@ public class MessageController {
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
private final ReportMessageManager reportMessageManager;
|
||||
private final ExecutorService multiRecipientMessageExecutor;
|
||||
private final Scheduler messageDeliveryScheduler;
|
||||
private final ReportSpamTokenProvider reportSpamTokenProvider;
|
||||
|
||||
private static final String REJECT_OVERSIZE_MESSAGE_COUNTER = name(MessageController.class, "rejectOversizeMessage");
|
||||
private static final String SENT_MESSAGE_COUNTER_NAME = name(MessageController.class, "sentMessages");
|
||||
@@ -144,7 +154,9 @@ public class MessageController {
|
||||
MessagesManager messagesManager,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
ReportMessageManager reportMessageManager,
|
||||
@Nonnull ExecutorService multiRecipientMessageExecutor) {
|
||||
@Nonnull ExecutorService multiRecipientMessageExecutor,
|
||||
Scheduler messageDeliveryScheduler,
|
||||
@Nonnull ReportSpamTokenProvider reportSpamTokenProvider) {
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.messageSender = messageSender;
|
||||
this.receiptSender = receiptSender;
|
||||
@@ -154,6 +166,8 @@ public class MessageController {
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.reportMessageManager = reportMessageManager;
|
||||
this.multiRecipientMessageExecutor = Objects.requireNonNull(multiRecipientMessageExecutor);
|
||||
this.messageDeliveryScheduler = messageDeliveryScheduler;
|
||||
this.reportSpamTokenProvider = reportSpamTokenProvider;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -161,14 +175,16 @@ public class MessageController {
|
||||
@PUT
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@FilterAbusiveMessages
|
||||
@FilterSpam
|
||||
public Response sendMessage(@Auth Optional<AuthenticatedAccount> source,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
|
||||
@PathParam("destination") UUID destinationUuid,
|
||||
@QueryParam("story") boolean isStory,
|
||||
@NotNull @Valid IncomingMessageList messages)
|
||||
@NotNull @Valid IncomingMessageList messages,
|
||||
@Context ContainerRequestContext context
|
||||
)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (source.isEmpty() && accessKey.isEmpty() && !isStory) {
|
||||
@@ -187,6 +203,13 @@ public class MessageController {
|
||||
senderType = SENDER_TYPE_UNIDENTIFIED;
|
||||
}
|
||||
|
||||
final Optional<byte[]> spamReportToken;
|
||||
if (senderType.equals(SENDER_TYPE_IDENTIFIED)) {
|
||||
spamReportToken = reportSpamTokenProvider.makeReportSpamToken(context);
|
||||
} else {
|
||||
spamReportToken = Optional.empty();
|
||||
}
|
||||
|
||||
for (final IncomingMessage message : messages.messages()) {
|
||||
|
||||
int contentLength = 0;
|
||||
@@ -264,7 +287,18 @@ public class MessageController {
|
||||
|
||||
if (destinationDevice.isPresent()) {
|
||||
Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment();
|
||||
sendIndividualMessage(source, destination.get(), destinationDevice.get(), destinationUuid, messages.timestamp(), messages.online(), isStory, messages.urgent(), incomingMessage, userAgent);
|
||||
sendIndividualMessage(
|
||||
source,
|
||||
destination.get(),
|
||||
destinationDevice.get(),
|
||||
destinationUuid,
|
||||
messages.timestamp(),
|
||||
messages.online(),
|
||||
isStory,
|
||||
messages.urgent(),
|
||||
incomingMessage,
|
||||
userAgent,
|
||||
spamReportToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,11 +353,11 @@ public class MessageController {
|
||||
@PUT
|
||||
@Consumes(MultiRecipientMessageProvider.MEDIA_TYPE)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@FilterAbusiveMessages
|
||||
@FilterSpam
|
||||
public Response sendMultiRecipientMessage(
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam("X-Forwarded-For") String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
|
||||
@QueryParam("online") boolean online,
|
||||
@QueryParam("ts") long timestamp,
|
||||
@QueryParam("urgent") @DefaultValue("true") final boolean isUrgent,
|
||||
@@ -482,47 +516,48 @@ public class MessageController {
|
||||
@Timed
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public OutgoingMessageEntityList getPendingMessages(@Auth AuthenticatedAccount auth,
|
||||
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedAccount auth,
|
||||
@HeaderParam(Stories.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
|
||||
@HeaderParam("User-Agent") String userAgent) {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
|
||||
|
||||
boolean shouldReceiveStories = Stories.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
|
||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
||||
|
||||
final OutgoingMessageEntityList outgoingMessages;
|
||||
{
|
||||
final Pair<List<Envelope>, Boolean> messagesAndHasMore = messagesManager.getMessagesForDevice(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
false);
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.peek(
|
||||
outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
|
||||
outgoingMessageEntity))
|
||||
.collect(Collectors.toList()),
|
||||
messagesAndHasMore.second());
|
||||
|
||||
outgoingMessages = new OutgoingMessageEntityList(envelopes
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.peek(outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(),
|
||||
outgoingMessageEntity))
|
||||
.collect(Collectors.toList()),
|
||||
messagesAndHasMore.second());
|
||||
}
|
||||
String platform;
|
||||
|
||||
{
|
||||
String platform;
|
||||
try {
|
||||
platform = UserAgentUtil.parseUserAgentString(userAgent).getPlatform().name().toLowerCase();
|
||||
} catch (final UnrecognizedUserAgentException ignored) {
|
||||
platform = "unrecognized";
|
||||
}
|
||||
|
||||
try {
|
||||
platform = UserAgentUtil.parseUserAgentString(userAgent).getPlatform().name().toLowerCase();
|
||||
} catch (final UnrecognizedUserAgentException ignored) {
|
||||
platform = "unrecognized";
|
||||
}
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, "platform", platform)
|
||||
.record(estimateMessageListSizeBytes(messages));
|
||||
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, "platform", platform).record(estimateMessageListSizeBytes(outgoingMessages));
|
||||
}
|
||||
|
||||
return outgoingMessages;
|
||||
return messages;
|
||||
})
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
}
|
||||
|
||||
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
|
||||
@@ -566,9 +601,15 @@ public class MessageController {
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Path("/report/{source}/{messageGuid}")
|
||||
public Response reportMessage(@Auth AuthenticatedAccount auth, @PathParam("source") String source,
|
||||
@PathParam("messageGuid") UUID messageGuid) {
|
||||
public Response reportSpamMessage(
|
||||
@Auth AuthenticatedAccount auth,
|
||||
@PathParam("source") String source,
|
||||
@PathParam("messageGuid") UUID messageGuid,
|
||||
@Nullable @Valid SpamReport spamReport,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent
|
||||
) {
|
||||
|
||||
final Optional<String> sourceNumber;
|
||||
final Optional<UUID> sourceAci;
|
||||
@@ -598,13 +639,22 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
reportMessageManager.report(sourceNumber, sourceAci, sourcePni, messageGuid, auth.getAccount().getUuid());
|
||||
UUID spamReporterUuid = auth.getAccount().getUuid();
|
||||
|
||||
// spam report token is optional, but if provided ensure it is valid base64 and non-empty.
|
||||
final Optional<byte[]> maybeSpamReportToken =
|
||||
Optional.ofNullable(spamReport)
|
||||
.flatMap(r -> Optional.ofNullable(r.token()))
|
||||
.filter(t -> t.length > 0);
|
||||
|
||||
reportMessageManager.report(sourceNumber, sourceAci, sourcePni, messageGuid, spamReporterUuid, maybeSpamReportToken, userAgent);
|
||||
|
||||
return Response.status(Status.ACCEPTED)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void sendIndividualMessage(Optional<AuthenticatedAccount> source,
|
||||
private void sendIndividualMessage(
|
||||
Optional<AuthenticatedAccount> source,
|
||||
Account destinationAccount,
|
||||
Device destinationDevice,
|
||||
UUID destinationUuid,
|
||||
@@ -613,18 +663,23 @@ public class MessageController {
|
||||
boolean story,
|
||||
boolean urgent,
|
||||
IncomingMessage incomingMessage,
|
||||
String userAgentString)
|
||||
String userAgentString,
|
||||
Optional<byte[]> spamReportToken)
|
||||
throws NoSuchUserException {
|
||||
try {
|
||||
final Envelope envelope;
|
||||
|
||||
try {
|
||||
envelope = incomingMessage.toEnvelope(destinationUuid,
|
||||
source.map(AuthenticatedAccount::getAccount).orElse(null),
|
||||
source.map(authenticatedAccount -> authenticatedAccount.getAuthenticatedDevice().getId()).orElse(null),
|
||||
Account sourceAccount = source.map(AuthenticatedAccount::getAccount).orElse(null);
|
||||
Long sourceDeviceId = source.map(account -> account.getAuthenticatedDevice().getId()).orElse(null);
|
||||
envelope = incomingMessage.toEnvelope(
|
||||
destinationUuid,
|
||||
sourceAccount,
|
||||
sourceDeviceId,
|
||||
timestamp == 0 ? System.currentTimeMillis() : timestamp,
|
||||
story,
|
||||
urgent);
|
||||
urgent,
|
||||
spamReportToken.orElse(null));
|
||||
} catch (final IllegalArgumentException e) {
|
||||
logger.warn("Received bad envelope type {} from {}", incomingMessage.type(), userAgentString);
|
||||
throw new BadRequestException(e);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,40 +7,52 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
||||
|
||||
@Path("/v1/payments")
|
||||
@Tag(name = "Payments")
|
||||
public class PaymentsController {
|
||||
|
||||
private final ExternalServiceCredentialGenerator paymentsServiceCredentialGenerator;
|
||||
private final CurrencyConversionManager currencyManager;
|
||||
private final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator;
|
||||
private final CurrencyConversionManager currencyManager;
|
||||
|
||||
public PaymentsController(CurrencyConversionManager currencyManager, ExternalServiceCredentialGenerator paymentsServiceCredentialGenerator) {
|
||||
this.currencyManager = currencyManager;
|
||||
this.paymentsServiceCredentialGenerator = paymentsServiceCredentialGenerator;
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final PaymentsServiceConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.getUserAuthenticationTokenSharedSecret())
|
||||
.prependUsername(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public PaymentsController(final CurrencyConversionManager currencyManager,
|
||||
final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator) {
|
||||
this.currencyManager = currencyManager;
|
||||
this.paymentsServiceCredentialsGenerator = paymentsServiceCredentialsGenerator;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
|
||||
return paymentsServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) {
|
||||
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/conversions")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CurrencyConversionEntityList getConversions(@Auth AuthenticatedAccount auth) {
|
||||
public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedAccount auth) {
|
||||
return currencyManager.getCurrencyConversions().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.vavr.Tuple;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -29,6 +30,7 @@ import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -63,9 +65,6 @@ import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
@@ -113,6 +112,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/profile")
|
||||
@Tag(name = "Profile")
|
||||
public class ProfileController {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
|
||||
@@ -179,24 +179,27 @@ public class ProfileController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response setProfile(@Auth AuthenticatedAccount auth, @NotNull @Valid CreateProfileRequest request) {
|
||||
|
||||
final Optional<VersionedProfile> currentProfile = profilesManager.get(auth.getAccount().getUuid(),
|
||||
request.getVersion());
|
||||
|
||||
if (StringUtils.isNotBlank(request.getPaymentAddress())) {
|
||||
final boolean hasDisallowedPrefix =
|
||||
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
|
||||
.anyMatch(prefix -> auth.getAccount().getNumber().startsWith(prefix));
|
||||
|
||||
if (hasDisallowedPrefix) {
|
||||
return Response.status(Status.FORBIDDEN).build();
|
||||
if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::getPaymentAddress).isEmpty()) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<VersionedProfile> currentProfile = profilesManager.get(auth.getAccount().getUuid(), request.getVersion());
|
||||
|
||||
Optional<String> currentAvatar = Optional.empty();
|
||||
if (currentProfile.isPresent() && currentProfile.get().getAvatar() != null && currentProfile.get().getAvatar().startsWith("profiles/")) {
|
||||
if (currentProfile.isPresent() && currentProfile.get().getAvatar() != null && currentProfile.get().getAvatar()
|
||||
.startsWith("profiles/")) {
|
||||
currentAvatar = Optional.of(currentProfile.get().getAvatar());
|
||||
}
|
||||
|
||||
String avatar = switch (request.getAvatarChange()) {
|
||||
final String avatar = switch (request.getAvatarChange()) {
|
||||
case UNCHANGED -> currentAvatar.orElse(null);
|
||||
case CLEAR -> null;
|
||||
case UPDATE -> generateAvatarObjectName();
|
||||
@@ -219,7 +222,7 @@ public class ProfileController {
|
||||
.build()));
|
||||
}
|
||||
|
||||
List<AccountBadge> updatedBadges = request.getBadges()
|
||||
final List<AccountBadge> updatedBadges = request.getBadges()
|
||||
.map(badges -> mergeBadgeIdsWithExistingAccountBadges(badges, auth.getAccount().getBadges()))
|
||||
.orElseGet(() -> auth.getAccount().getBadges());
|
||||
|
||||
@@ -317,7 +320,7 @@ public class ProfileController {
|
||||
@Auth Optional<AuthenticatedAccount> auth,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@HeaderParam("User-Agent") String userAgent,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@PathParam("identifier") UUID identifier,
|
||||
@QueryParam("ca") boolean useCaCertificate)
|
||||
throws RateLimitExceededException {
|
||||
@@ -530,10 +533,11 @@ public class ProfileController {
|
||||
final UUID uuid) {
|
||||
try {
|
||||
final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment());
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest));
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(
|
||||
HexFormat.of().parseHex(encodedProfileCredentialRequest));
|
||||
|
||||
return zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment);
|
||||
} catch (DecoderException | VerificationFailedException | InvalidInputException e) {
|
||||
} catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) {
|
||||
throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build());
|
||||
}
|
||||
}
|
||||
@@ -545,10 +549,11 @@ public class ProfileController {
|
||||
|
||||
try {
|
||||
final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment());
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedCredentialRequest));
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(
|
||||
HexFormat.of().parseHex(encodedCredentialRequest));
|
||||
|
||||
return zkProfileOperations.issuePniCredential(request, accountIdentifier, phoneNumberIdentifier, commitment);
|
||||
} catch (DecoderException | VerificationFailedException | InvalidInputException e) {
|
||||
} catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) {
|
||||
throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build());
|
||||
}
|
||||
}
|
||||
@@ -561,10 +566,11 @@ public class ProfileController {
|
||||
|
||||
try {
|
||||
final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment());
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedCredentialRequest));
|
||||
final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(
|
||||
HexFormat.of().parseHex(encodedCredentialRequest));
|
||||
|
||||
return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration);
|
||||
} catch (DecoderException | VerificationFailedException | InvalidInputException e) {
|
||||
} catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) {
|
||||
throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.Base64;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
@@ -25,6 +26,7 @@ import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
||||
import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress;
|
||||
|
||||
@Path("/v1/provisioning")
|
||||
@Tag(name = "Provisioning")
|
||||
public class ProvisioningController {
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
@@ -48,7 +50,7 @@ public class ProvisioningController {
|
||||
rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid());
|
||||
|
||||
if (!provisioningManager.sendProvisioningMessage(new ProvisioningAddress(destinationName, 0),
|
||||
Base64.getMimeDecoder().decode(message.getBody()))) {
|
||||
Base64.getMimeDecoder().decode(message.body()))) {
|
||||
throw new WebApplicationException(Response.Status.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,33 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class RateLimitExceededException extends Exception {
|
||||
|
||||
private final @Nullable
|
||||
Duration retryDuration;
|
||||
@Nullable
|
||||
private final Duration retryDuration;
|
||||
private final boolean legacy;
|
||||
|
||||
/**
|
||||
* Constructs a new exception indicating when it may become safe to retry
|
||||
*
|
||||
* @param retryDuration A duration to wait before retrying, null if no duration can be indicated
|
||||
* @param legacy whether to use a legacy status code when mapping the exception to an HTTP response
|
||||
*/
|
||||
public RateLimitExceededException(final @Nullable Duration retryDuration) {
|
||||
public RateLimitExceededException(@Nullable final Duration retryDuration, final boolean legacy) {
|
||||
super(null, null, true, false);
|
||||
this.retryDuration = retryDuration;
|
||||
this.legacy = legacy;
|
||||
}
|
||||
|
||||
public Optional<Duration> getRetryDuration() {
|
||||
return Optional.ofNullable(retryDuration);
|
||||
}
|
||||
|
||||
public boolean isLegacy() {
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@Path("/v1/registration")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Registration")
|
||||
public class RegistrationController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RegistrationController.class);
|
||||
|
||||
private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary
|
||||
.builder(name(RegistrationController.class, "reregistrationIdleDays"))
|
||||
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(Metrics.globalRegistry);
|
||||
|
||||
private static final String ACCOUNT_CREATED_COUNTER_NAME = name(RegistrationController.class, "accountCreated");
|
||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||
private static final String REGION_CODE_TAG_NAME = "regionCode";
|
||||
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
|
||||
private final RegistrationLockVerificationManager registrationLockVerificationManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public RegistrationController(final AccountsManager accounts,
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager,
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) {
|
||||
this.accounts = accounts;
|
||||
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
|
||||
this.registrationLockVerificationManager = registrationLockVerificationManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse register(
|
||||
@HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull final BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String signalAgent,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @Valid final RegistrationRequest registrationRequest) throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
final String number = authorizationHeader.getUsername();
|
||||
final String password = authorizationHeader.getPassword();
|
||||
|
||||
RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number));
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number,
|
||||
registrationRequest);
|
||||
|
||||
final Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
existingAccount.ifPresent(account -> {
|
||||
final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
|
||||
final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
|
||||
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());
|
||||
});
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
|
||||
registrationRequest.accountAttributes().getRegistrationLock(),
|
||||
userAgent, RegistrationLockVerificationManager.Flow.REGISTRATION, verificationType);
|
||||
}
|
||||
|
||||
if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(Account::isTransferSupported).orElse(false)) {
|
||||
// If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user)
|
||||
// before we'll let them create a new account "from scratch"
|
||||
throw new WebApplicationException(Response.status(409, "device transfer available").build());
|
||||
}
|
||||
|
||||
final Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(),
|
||||
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
|
||||
|
||||
Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
||||
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)),
|
||||
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))
|
||||
.increment();
|
||||
|
||||
return new AccountIdentityResponse(account.getUuid(),
|
||||
account.getNumber(),
|
||||
account.getPhoneNumberIdentifier(),
|
||||
account.getUsernameHash().orElse(null),
|
||||
existingAccount.map(Account::isStorageSupported).orElse(false));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2022 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
@@ -41,8 +42,10 @@ import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfig;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@Path("/v1/config")
|
||||
@Tag(name = "Remote Config")
|
||||
public class RemoteConfigController {
|
||||
|
||||
private final RemoteConfigsManager remoteConfigsManager;
|
||||
@@ -133,7 +136,7 @@ public class RemoteConfigController {
|
||||
digest.update(bb.array());
|
||||
|
||||
byte[] hash = digest.digest(hashKey);
|
||||
int bucket = (int)(Math.abs(Conversions.byteArrayToLong(hash)) % 100);
|
||||
int bucket = (int)(Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);
|
||||
|
||||
return bucket < configPercentage;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,175 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.Clock;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Predicate;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
|
||||
@Path("/v1/backup")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
public class SecureBackupController {
|
||||
|
||||
private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator;
|
||||
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
|
||||
|
||||
public SecureBackupController(ExternalServiceCredentialGenerator backupServiceCredentialGenerator) {
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
private final ExternalServiceCredentialsGenerator credentialsGenerator;
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureBackupServiceConfiguration cfg) {
|
||||
return credentialsGenerator(cfg, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(
|
||||
final SecureBackupServiceConfiguration cfg,
|
||||
final Clock clock) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.getUserAuthenticationTokenSharedSecret())
|
||||
.prependUsername(true)
|
||||
.withClock(clock)
|
||||
.build();
|
||||
}
|
||||
|
||||
public SecureBackupController(
|
||||
final ExternalServiceCredentialsGenerator credentialsGenerator,
|
||||
final AccountsManager accountsManager) {
|
||||
this.credentialsGenerator = requireNonNull(credentialsGenerator);
|
||||
this.accountsManager = requireNonNull(accountsManager);
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
@Operation(
|
||||
summary = "Generate credentials for SVR",
|
||||
description = """
|
||||
Generate SVR service credentials. Generated credentials have an expiration time of 30 days
|
||||
(however, the TTL is fully controlled by the server side and may change even for already generated credentials).
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) {
|
||||
return credentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/auth/check")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)
|
||||
@Operation(
|
||||
summary = "Check SVR credentials",
|
||||
description = """
|
||||
Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage.
|
||||
To determine which set is most current and should be used to communicate with SVR to retrieve a master password
|
||||
(from which a registration recovery password can be derived), clients should call this endpoint
|
||||
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR.
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "422", description = "Provided list of KBS credentials could not be parsed")
|
||||
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||
final Map<String, AuthCheckResponse.Result> results = new HashMap<>();
|
||||
final Map<String, Pair<UUID, Long>> tokenToUuid = new HashMap<>();
|
||||
final Map<UUID, Long> uuidToLatestTimestamp = new HashMap<>();
|
||||
|
||||
// first pass -- filter out all tokens that contain invalid credentials
|
||||
// (this could be either legit but expired or illegitimate for any reason)
|
||||
request.passwords().forEach(token -> {
|
||||
// each token is supposed to be in a "${username}:${password}" form,
|
||||
// (note that password part may also contain ':' characters)
|
||||
final String[] parts = token.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS);
|
||||
final Optional<UUID> maybeUuid = UUIDUtil.fromStringSafe(credentials.username());
|
||||
if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
// now that we validated signature and token age, we will also find the latest of the tokens
|
||||
// for each username
|
||||
final Long timestamp = maybeTimestamp.get();
|
||||
final UUID uuid = maybeUuid.get();
|
||||
tokenToUuid.put(token, Pair.of(uuid, timestamp));
|
||||
final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L);
|
||||
if (timestamp > latestTimestamp) {
|
||||
uuidToLatestTimestamp.put(uuid, timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
// as a result of the first pass we now have some tokens that are marked invalid,
|
||||
// and for others we now know if for any username the list contains multiple tokens
|
||||
// we also know all distinct usernames from the list
|
||||
|
||||
// if it so happens that all tokens are invalid -- respond right away
|
||||
if (tokenToUuid.isEmpty()) {
|
||||
return new AuthCheckResponse(results);
|
||||
}
|
||||
|
||||
final Predicate<UUID> uuidMatches = accountsManager
|
||||
.getByE164(request.number())
|
||||
.map(account -> (Predicate<UUID>) candidateUuid -> account.getUuid().equals(candidateUuid))
|
||||
.orElse(candidateUuid -> false);
|
||||
|
||||
// second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any)
|
||||
request.passwords().forEach(token -> {
|
||||
if (results.containsKey(token)) {
|
||||
// result already calculated
|
||||
return;
|
||||
}
|
||||
final Pair<UUID, Long> uuidAndTime = requireNonNull(tokenToUuid.get(token));
|
||||
final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft()));
|
||||
// check if a newer version available
|
||||
if (uuidAndTime.getRight() < latestTimestamp) {
|
||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
||||
return;
|
||||
}
|
||||
results.put(token, uuidMatches.test(uuidAndTime.getLeft())
|
||||
? AuthCheckResponse.Result.MATCH
|
||||
: AuthCheckResponse.Result.NO_MATCH);
|
||||
});
|
||||
|
||||
return new AuthCheckResponse(results);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -7,21 +7,31 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
|
||||
@Path("/v1/storage")
|
||||
@Tag(name = "Secure Storage")
|
||||
public class SecureStorageController {
|
||||
|
||||
private final ExternalServiceCredentialGenerator storageServiceCredentialGenerator;
|
||||
private final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator;
|
||||
|
||||
public SecureStorageController(ExternalServiceCredentialGenerator storageServiceCredentialGenerator) {
|
||||
this.storageServiceCredentialGenerator = storageServiceCredentialGenerator;
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureStorageServiceConfiguration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.decodeUserAuthenticationTokenSharedSecret())
|
||||
.prependUsername(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public SecureStorageController(ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator) {
|
||||
this.storageServiceCredentialsGenerator = storageServiceCredentialsGenerator;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -29,6 +39,6 @@ public class SecureStorageController {
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) {
|
||||
return storageServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||
|
||||
@Path("/v2/backup")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
public class SecureValueRecovery2Controller {
|
||||
|
||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
|
||||
return ExternalServiceCredentialsGenerator
|
||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
|
||||
.prependUsername(false)
|
||||
.withDerivedUsernameTruncateLength(16)
|
||||
.build();
|
||||
}
|
||||
|
||||
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
|
||||
private final boolean enabled;
|
||||
|
||||
public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
|
||||
final SecureValueRecovery2Configuration cfg) {
|
||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||
this.enabled = cfg.enabled();
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(
|
||||
summary = "Generate credentials for SVR2",
|
||||
description = """
|
||||
Generate SVR2 service credentials. Generated credentials have an expiration time of 30 days
|
||||
(however, the TTL is fully controlled by the server side and may change even for already generated credentials).
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public ExternalServiceCredentials getAuth(@Auth final AuthenticatedAccount auth) {
|
||||
if (!enabled) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* Copyright 2013 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.Max;
|
||||
@@ -25,10 +27,10 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
@Path("/v1/sticker")
|
||||
@Tag(name = "Stickers")
|
||||
public class StickerController {
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
@@ -78,7 +80,7 @@ public class StickerController {
|
||||
byte[] object = new byte[16];
|
||||
new SecureRandom().nextBytes(object);
|
||||
|
||||
return Hex.toStringCondensed(object);
|
||||
return HexFormat.of().formatHex(object);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.ClientErrorException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.PATCH;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.ServerErrorException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
|
||||
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotification;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.registration.ClientType;
|
||||
import org.whispersystems.textsecuregcm.registration.MessageTransport;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceException;
|
||||
import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;
|
||||
import org.whispersystems.textsecuregcm.registration.VerificationSession;
|
||||
import org.whispersystems.textsecuregcm.spam.Extract;
|
||||
import org.whispersystems.textsecuregcm.spam.FilterSpam;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.spam.ScoreThreshold;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@Path("/v1/verification")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Verification")
|
||||
public class VerificationController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VerificationController.class);
|
||||
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private static final String PUSH_CHALLENGE_COUNTER_NAME = name(VerificationController.class, "pushChallenge");
|
||||
private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
|
||||
private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
|
||||
private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(VerificationController.class, "captcha");
|
||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||
private static final String REGION_CODE_TAG_NAME = "regionCode";
|
||||
private static final String SCORE_TAG_NAME = "score";
|
||||
private static final String CODE_REQUESTED_COUNTER_NAME = name(VerificationController.class, "codeRequested");
|
||||
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
|
||||
private static final String VERIFIED_COUNTER_NAME = name(VerificationController.class, "verified");
|
||||
private static final String SUCCESS_TAG_NAME = "success";
|
||||
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final VerificationSessionManager verificationSessionManager;
|
||||
private final PushNotificationManager pushNotificationManager;
|
||||
private final RegistrationCaptchaManager registrationCaptchaManager;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
public VerificationController(final RegistrationServiceClient registrationServiceClient,
|
||||
final VerificationSessionManager verificationSessionManager,
|
||||
final PushNotificationManager pushNotificationManager,
|
||||
final RegistrationCaptchaManager registrationCaptchaManager,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final AccountsManager accountsManager,
|
||||
final Clock clock) {
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.verificationSessionManager = verificationSessionManager;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.registrationCaptchaManager = registrationCaptchaManager;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.accountsManager = accountsManager;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/session")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public VerificationSessionResponse createSession(@NotNull @Valid CreateVerificationSessionRequest request)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(
|
||||
request.getUpdateVerificationSessionRequest());
|
||||
|
||||
final Phonenumber.PhoneNumber phoneNumber;
|
||||
try {
|
||||
phoneNumber = PhoneNumberUtil.getInstance().parse(request.getNumber(), null);
|
||||
} catch (final NumberParseException e) {
|
||||
throw new ServerErrorException("could not parse already validated number", Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
final RegistrationServiceSession registrationServiceSession;
|
||||
try {
|
||||
registrationServiceSession = registrationServiceClient.createRegistrationSessionSession(phoneNumber,
|
||||
accountsManager.getByE164(request.getNumber()).isPresent(),
|
||||
REGISTRATION_RPC_TIMEOUT).join();
|
||||
} catch (final CancellationException e) {
|
||||
|
||||
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
|
||||
} catch (final CompletionException e) {
|
||||
|
||||
if (ExceptionUtils.unwrap(e) instanceof RateLimitExceededException re) {
|
||||
RateLimiter.adaptLegacyException(() -> {
|
||||
throw re;
|
||||
});
|
||||
}
|
||||
|
||||
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, e);
|
||||
}
|
||||
|
||||
VerificationSession verificationSession = new VerificationSession(null, new ArrayList<>(),
|
||||
Collections.emptyList(), false,
|
||||
clock.millis(), clock.millis(), registrationServiceSession.expiration());
|
||||
|
||||
verificationSession = handlePushToken(pushTokenAndType, verificationSession);
|
||||
// unconditionally request a captcha -- it will either be the only requested information, or a fallback
|
||||
// if a push challenge sent in `handlePushToken` doesn't arrive in time
|
||||
verificationSession.requestedInformation().add(VerificationSession.Information.CAPTCHA);
|
||||
|
||||
storeVerificationSession(registrationServiceSession, verificationSession);
|
||||
|
||||
return buildResponse(registrationServiceSession, verificationSession);
|
||||
}
|
||||
|
||||
@Timed
|
||||
@FilterSpam
|
||||
@PATCH
|
||||
@Path("/session/{sessionId}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public VerificationSessionResponse updateSession(@PathParam("sessionId") final String encodedSessionId,
|
||||
@HeaderParam(com.google.common.net.HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest,
|
||||
@NotNull @Extract final ScoreThreshold captchaScoreThreshold) {
|
||||
|
||||
final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||
|
||||
final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(
|
||||
updateVerificationSessionRequest);
|
||||
|
||||
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
|
||||
VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
|
||||
|
||||
try {
|
||||
// these handle* methods ordered from least likely to fail to most, so take care when considering a change
|
||||
verificationSession = handlePushToken(pushTokenAndType, verificationSession);
|
||||
|
||||
verificationSession = handlePushChallenge(updateVerificationSessionRequest, registrationServiceSession,
|
||||
verificationSession);
|
||||
|
||||
verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession,
|
||||
verificationSession, userAgent, captchaScoreThreshold.getScoreThreshold());
|
||||
} catch (final RateLimitExceededException e) {
|
||||
|
||||
final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession,
|
||||
e.getRetryDuration());
|
||||
throw new ClientErrorException(response);
|
||||
|
||||
} catch (final ForbiddenException e) {
|
||||
|
||||
throw new ClientErrorException(Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(buildResponse(registrationServiceSession, verificationSession))
|
||||
.build());
|
||||
|
||||
} finally {
|
||||
// Each of the handle* methods may update requestedInformation, submittedInformation, and allowedToRequestCode,
|
||||
// and we want to be sure to store a changes, even if a later method throws
|
||||
updateStoredVerificationSession(registrationServiceSession, verificationSession);
|
||||
}
|
||||
|
||||
return buildResponse(registrationServiceSession, verificationSession);
|
||||
}
|
||||
|
||||
private void storeVerificationSession(final RegistrationServiceSession registrationServiceSession,
|
||||
final VerificationSession verificationSession) {
|
||||
verificationSessionManager.insert(registrationServiceSession.encodedSessionId(), verificationSession)
|
||||
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
|
||||
.join();
|
||||
}
|
||||
|
||||
private void updateStoredVerificationSession(final RegistrationServiceSession registrationServiceSession,
|
||||
final VerificationSession verificationSession) {
|
||||
verificationSessionManager.update(registrationServiceSession.encodedSessionId(), verificationSession)
|
||||
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
|
||||
.join();
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@code pushTokenAndType} values are not {@code null}, sends a push challenge. If there is no existing push
|
||||
* challenge in the session, one will be created, set on the returned session record, and
|
||||
* {@link VerificationSession#requestedInformation()} will be updated.
|
||||
*/
|
||||
private VerificationSession handlePushToken(
|
||||
final Pair<String, PushNotification.TokenType> pushTokenAndType, VerificationSession verificationSession) {
|
||||
|
||||
if (pushTokenAndType.first() != null) {
|
||||
|
||||
if (verificationSession.pushChallenge() == null) {
|
||||
|
||||
final List<VerificationSession.Information> requestedInformation = new ArrayList<>();
|
||||
requestedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);
|
||||
requestedInformation.addAll(verificationSession.requestedInformation());
|
||||
|
||||
verificationSession = new VerificationSession(generatePushChallenge(), requestedInformation,
|
||||
verificationSession.submittedInformation(), verificationSession.allowedToRequestCode(),
|
||||
verificationSession.createdTimestamp(), clock.millis(), verificationSession.remoteExpirationSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
pushNotificationManager.sendRegistrationChallengeNotification(pushTokenAndType.first(), pushTokenAndType.second(),
|
||||
verificationSession.pushChallenge());
|
||||
}
|
||||
|
||||
return verificationSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a push challenge value is present, compares against the stored value. If they match, then
|
||||
* {@link VerificationSession.Information#PUSH_CHALLENGE} is removed from requested information, added to submitted
|
||||
* information, and {@link VerificationSession#allowedToRequestCode()} is re-evaluated.
|
||||
*
|
||||
* @throws ForbiddenException if values to not match.
|
||||
* @throws RateLimitExceededException if too many push challenges have been submitted
|
||||
*/
|
||||
private VerificationSession handlePushChallenge(
|
||||
final UpdateVerificationSessionRequest updateVerificationSessionRequest,
|
||||
final RegistrationServiceSession registrationServiceSession,
|
||||
VerificationSession verificationSession) throws RateLimitExceededException {
|
||||
|
||||
if (verificationSession.submittedInformation()
|
||||
.contains(VerificationSession.Information.PUSH_CHALLENGE)) {
|
||||
// skip if a challenge has already been submitted
|
||||
return verificationSession;
|
||||
}
|
||||
|
||||
final boolean pushChallengePresent = updateVerificationSessionRequest.pushChallenge() != null;
|
||||
if (pushChallengePresent) {
|
||||
RateLimiter.adaptLegacyException(
|
||||
() -> rateLimiters.getVerificationPushChallengeLimiter()
|
||||
.validate(registrationServiceSession.encodedSessionId()));
|
||||
}
|
||||
|
||||
final boolean pushChallengeMatches;
|
||||
if (pushChallengePresent && verificationSession.pushChallenge() != null) {
|
||||
pushChallengeMatches = MessageDigest.isEqual(
|
||||
updateVerificationSessionRequest.pushChallenge().getBytes(StandardCharsets.UTF_8),
|
||||
verificationSession.pushChallenge().getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
pushChallengeMatches = false;
|
||||
}
|
||||
|
||||
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,
|
||||
COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number()),
|
||||
REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number()),
|
||||
CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallengePresent),
|
||||
CHALLENGE_MATCH_TAG_NAME, Boolean.toString(pushChallengeMatches))
|
||||
.increment();
|
||||
|
||||
if (pushChallengeMatches) {
|
||||
final List<VerificationSession.Information> submittedInformation = new ArrayList<>(
|
||||
verificationSession.submittedInformation());
|
||||
submittedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);
|
||||
|
||||
final List<VerificationSession.Information> requestedInformation = new ArrayList<>(
|
||||
verificationSession.requestedInformation());
|
||||
// a push challenge satisfies a requested captcha
|
||||
requestedInformation.remove(VerificationSession.Information.CAPTCHA);
|
||||
final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()
|
||||
|| requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE))
|
||||
&& requestedInformation.isEmpty();
|
||||
|
||||
verificationSession = new VerificationSession(verificationSession.pushChallenge(), requestedInformation,
|
||||
submittedInformation, allowedToRequestCode, verificationSession.createdTimestamp(), clock.millis(),
|
||||
verificationSession.remoteExpirationSeconds());
|
||||
|
||||
} else if (pushChallengePresent) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return verificationSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a captcha value is present, it is assessed. If it is valid, then {@link VerificationSession.Information#CAPTCHA}
|
||||
* is removed from requested information, added to submitted information, and
|
||||
* {@link VerificationSession#allowedToRequestCode()} is re-evaluated.
|
||||
*
|
||||
* @throws ForbiddenException if assessment is not valid.
|
||||
* @throws RateLimitExceededException if too many captchas have been submitted
|
||||
*/
|
||||
private VerificationSession handleCaptcha(
|
||||
final String sourceHost,
|
||||
final UpdateVerificationSessionRequest updateVerificationSessionRequest,
|
||||
final RegistrationServiceSession registrationServiceSession,
|
||||
VerificationSession verificationSession,
|
||||
final String userAgent,
|
||||
final Optional<Float> captchaScoreThreshold) throws RateLimitExceededException {
|
||||
|
||||
if (updateVerificationSessionRequest.captcha() == null) {
|
||||
return verificationSession;
|
||||
}
|
||||
|
||||
RateLimiter.adaptLegacyException(
|
||||
() -> rateLimiters.getVerificationCaptchaLimiter().validate(registrationServiceSession.encodedSessionId()));
|
||||
|
||||
final AssessmentResult assessmentResult;
|
||||
try {
|
||||
|
||||
assessmentResult = registrationCaptchaManager.assessCaptcha(
|
||||
Optional.of(updateVerificationSessionRequest.captcha()), sourceHost)
|
||||
.orElseThrow(() -> new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR));
|
||||
|
||||
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
|
||||
Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.isValid(captchaScoreThreshold))),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
|
||||
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
|
||||
Tag.of(SCORE_TAG_NAME, assessmentResult.getScoreString())))
|
||||
.increment();
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
if (assessmentResult.isValid(captchaScoreThreshold)) {
|
||||
final List<VerificationSession.Information> submittedInformation = new ArrayList<>(
|
||||
verificationSession.submittedInformation());
|
||||
submittedInformation.add(VerificationSession.Information.CAPTCHA);
|
||||
|
||||
final List<VerificationSession.Information> requestedInformation = new ArrayList<>(
|
||||
verificationSession.requestedInformation());
|
||||
// a captcha satisfies a push challenge, in case of push deliverability issues
|
||||
requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE);
|
||||
final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()
|
||||
|| requestedInformation.remove(VerificationSession.Information.CAPTCHA))
|
||||
&& requestedInformation.isEmpty();
|
||||
|
||||
verificationSession = new VerificationSession(verificationSession.pushChallenge(), requestedInformation,
|
||||
submittedInformation, allowedToRequestCode, verificationSession.createdTimestamp(), clock.millis(),
|
||||
verificationSession.remoteExpirationSeconds());
|
||||
} else {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return verificationSession;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@GET
|
||||
@Path("/session/{sessionId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public VerificationSessionResponse getSession(@PathParam("sessionId") final String encodedSessionId) {
|
||||
|
||||
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
|
||||
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
|
||||
|
||||
return buildResponse(registrationServiceSession, verificationSession);
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/session/{sessionId}/code")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public VerificationSessionResponse requestVerificationCode(@PathParam("sessionId") final String encodedSessionId,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage,
|
||||
@NotNull @Valid VerificationCodeRequest verificationCodeRequest) throws Throwable {
|
||||
|
||||
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
|
||||
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
|
||||
|
||||
if (registrationServiceSession.verified()) {
|
||||
throw new ClientErrorException(
|
||||
Response.status(Response.Status.CONFLICT)
|
||||
.entity(buildResponse(registrationServiceSession, verificationSession))
|
||||
.build());
|
||||
}
|
||||
|
||||
if (!verificationSession.allowedToRequestCode()) {
|
||||
final Response.Status status = verificationSession.requestedInformation().isEmpty()
|
||||
? Response.Status.TOO_MANY_REQUESTS
|
||||
: Response.Status.CONFLICT;
|
||||
|
||||
throw new ClientErrorException(
|
||||
Response.status(status)
|
||||
.entity(buildResponse(registrationServiceSession, verificationSession))
|
||||
.build());
|
||||
}
|
||||
|
||||
final MessageTransport messageTransport = verificationCodeRequest.transport().toMessageTransport();
|
||||
|
||||
final ClientType clientType = switch (verificationCodeRequest.client()) {
|
||||
case "ios" -> ClientType.IOS;
|
||||
case "android-2021-03" -> ClientType.ANDROID_WITH_FCM;
|
||||
default -> {
|
||||
if (StringUtils.startsWithIgnoreCase(verificationCodeRequest.client(), "android")) {
|
||||
yield ClientType.ANDROID_WITHOUT_FCM;
|
||||
}
|
||||
yield ClientType.UNKNOWN;
|
||||
}
|
||||
};
|
||||
|
||||
final RegistrationServiceSession resultSession;
|
||||
try {
|
||||
resultSession = registrationServiceClient.sendVerificationCode(registrationServiceSession.id(),
|
||||
messageTransport,
|
||||
clientType,
|
||||
acceptLanguage.orElse(null), REGISTRATION_RPC_TIMEOUT).join();
|
||||
} catch (final CancellationException e) {
|
||||
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
|
||||
} catch (final CompletionException e) {
|
||||
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
|
||||
if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {
|
||||
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
|
||||
final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),
|
||||
ve.getRetryDuration());
|
||||
throw new ClientErrorException(response);
|
||||
}
|
||||
|
||||
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null), false);
|
||||
} else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {
|
||||
|
||||
throw registrationServiceException.getRegistrationSession()
|
||||
.map(s -> buildResponse(s, verificationSession))
|
||||
.map(verificationSessionResponse -> new ClientErrorException(
|
||||
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
|
||||
.orElseGet(NotFoundException::new);
|
||||
|
||||
} else if (unwrappedException instanceof RegistrationServiceSenderException) {
|
||||
|
||||
throw unwrappedException;
|
||||
|
||||
} else {
|
||||
logger.error("Registration service failure", unwrappedException);
|
||||
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
Metrics.counter(CODE_REQUESTED_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
|
||||
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
|
||||
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, verificationCodeRequest.transport().toString())))
|
||||
.increment();
|
||||
|
||||
return buildResponse(resultSession, verificationSession);
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/session/{sessionId}/code")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public VerificationSessionResponse verifyCode(@PathParam("sessionId") final String encodedSessionId,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
|
||||
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
|
||||
|
||||
if (registrationServiceSession.verified()) {
|
||||
final VerificationSessionResponse verificationSessionResponse = buildResponse(registrationServiceSession,
|
||||
verificationSession);
|
||||
|
||||
throw new ClientErrorException(
|
||||
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build());
|
||||
}
|
||||
|
||||
final RegistrationServiceSession resultSession;
|
||||
try {
|
||||
resultSession = registrationServiceClient.checkVerificationCodeSession(registrationServiceSession.id(),
|
||||
submitVerificationCodeRequest.code(),
|
||||
REGISTRATION_RPC_TIMEOUT)
|
||||
.join();
|
||||
} catch (final CancellationException e) {
|
||||
logger.warn("Unexpected cancellation from registration service", e);
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
|
||||
} catch (final CompletionException e) {
|
||||
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
|
||||
if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {
|
||||
|
||||
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
|
||||
final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),
|
||||
ve.getRetryDuration());
|
||||
throw new ClientErrorException(response);
|
||||
}
|
||||
|
||||
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null), false);
|
||||
|
||||
} else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {
|
||||
|
||||
throw registrationServiceException.getRegistrationSession()
|
||||
.map(s -> buildResponse(s, verificationSession))
|
||||
.map(verificationSessionResponse -> new ClientErrorException(
|
||||
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
|
||||
.orElseGet(NotFoundException::new);
|
||||
|
||||
} else {
|
||||
logger.error("Registration service failure", unwrappedException);
|
||||
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultSession.verified()) {
|
||||
registrationRecoveryPasswordsManager.removeForNumber(registrationServiceSession.number());
|
||||
}
|
||||
|
||||
Metrics.counter(VERIFIED_COUNTER_NAME, Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
|
||||
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
|
||||
Tag.of(SUCCESS_TAG_NAME, Boolean.toString(resultSession.verified()))))
|
||||
.increment();
|
||||
|
||||
return buildResponse(resultSession, verificationSession);
|
||||
}
|
||||
|
||||
private Response buildResponseForRateLimitExceeded(final VerificationSession verificationSession,
|
||||
final RegistrationServiceSession registrationServiceSession,
|
||||
final Optional<Duration> retryDuration) {
|
||||
|
||||
final Response.ResponseBuilder responseBuilder = Response.status(Response.Status.TOO_MANY_REQUESTS)
|
||||
.entity(buildResponse(registrationServiceSession, verificationSession));
|
||||
|
||||
retryDuration
|
||||
.filter(d -> !d.isNegative())
|
||||
.ifPresent(d -> responseBuilder.header(HttpHeaders.RETRY_AFTER, d.toSeconds()));
|
||||
|
||||
return responseBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ClientErrorException with {@code 422} status if the ID cannot be decoded
|
||||
* @throws javax.ws.rs.NotFoundException if the ID cannot be found
|
||||
*/
|
||||
private RegistrationServiceSession retrieveRegistrationServiceSession(final String encodedSessionId) {
|
||||
final byte[] sessionId;
|
||||
|
||||
try {
|
||||
sessionId = decodeSessionId(encodedSessionId);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
try {
|
||||
final RegistrationServiceSession registrationServiceSession = registrationServiceClient.getSession(sessionId,
|
||||
REGISTRATION_RPC_TIMEOUT).join()
|
||||
.orElseThrow(NotFoundException::new);
|
||||
|
||||
if (registrationServiceSession.verified()) {
|
||||
registrationRecoveryPasswordsManager.removeForNumber(registrationServiceSession.number());
|
||||
}
|
||||
|
||||
return registrationServiceSession;
|
||||
|
||||
} catch (final CompletionException | CancellationException e) {
|
||||
final Throwable unwrapped = ExceptionUtils.unwrap(e);
|
||||
|
||||
if (unwrapped.getCause() instanceof StatusRuntimeException grpcRuntimeException) {
|
||||
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
logger.error("Registration service failure", e);
|
||||
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotFoundException if the session is has no record
|
||||
*/
|
||||
private VerificationSession retrieveVerificationSession(final RegistrationServiceSession registrationServiceSession) {
|
||||
|
||||
return verificationSessionManager.findForId(registrationServiceSession.encodedSessionId())
|
||||
.orTimeout(5, TimeUnit.SECONDS)
|
||||
.join().orElseThrow(NotFoundException::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ClientErrorException with {@code 422} status if the only one of token and type are present
|
||||
*/
|
||||
private Pair<String, PushNotification.TokenType> validateAndExtractPushToken(
|
||||
final UpdateVerificationSessionRequest request) {
|
||||
|
||||
final String pushToken;
|
||||
final PushNotification.TokenType pushTokenType;
|
||||
if (Objects.isNull(request.pushToken())
|
||||
!= Objects.isNull(request.pushTokenType())) {
|
||||
throw new WebApplicationException("must specify both pushToken and pushTokenType or neither",
|
||||
HttpStatus.SC_UNPROCESSABLE_ENTITY);
|
||||
} else {
|
||||
pushToken = request.pushToken();
|
||||
pushTokenType = pushToken == null
|
||||
? null
|
||||
: request.pushTokenType().toTokenType();
|
||||
}
|
||||
|
||||
return new Pair<>(pushToken, pushTokenType);
|
||||
}
|
||||
|
||||
private VerificationSessionResponse buildResponse(final RegistrationServiceSession registrationServiceSession,
|
||||
final VerificationSession verificationSession) {
|
||||
return new VerificationSessionResponse(registrationServiceSession.encodedSessionId(),
|
||||
registrationServiceSession.nextSms(),
|
||||
registrationServiceSession.nextVoiceCall(), registrationServiceSession.nextVerificationAttempt(),
|
||||
verificationSession.allowedToRequestCode(), verificationSession.requestedInformation(),
|
||||
registrationServiceSession.verified());
|
||||
}
|
||||
|
||||
public static byte[] decodeSessionId(final String sessionId) {
|
||||
return Base64.getUrlDecoder().decode(sessionId);
|
||||
}
|
||||
|
||||
private static String generatePushChallenge() {
|
||||
final byte[] challenge = new byte[16];
|
||||
RANDOM.nextBytes(challenge);
|
||||
|
||||
return HexFormat.of().formatHex(challenge);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import java.time.Duration;
|
||||
|
||||
public class VerificationSessionRateLimitExceededException extends RateLimitExceededException {
|
||||
|
||||
private final RegistrationServiceSession registrationServiceSession;
|
||||
|
||||
/**
|
||||
* Constructs a new exception indicating when it may become safe to retry
|
||||
*
|
||||
* @param registrationServiceSession the associated registration session
|
||||
* @param retryDuration A duration to wait before retrying, null if no duration can be indicated
|
||||
* @param legacy whether to use a legacy status code when mapping the exception to an HTTP
|
||||
* response
|
||||
*/
|
||||
public VerificationSessionRateLimitExceededException(
|
||||
final RegistrationServiceSession registrationServiceSession, @Nullable final Duration retryDuration,
|
||||
final boolean legacy) {
|
||||
super(retryDuration, legacy);
|
||||
this.registrationServiceSession = registrationServiceSession;
|
||||
}
|
||||
|
||||
public RegistrationServiceSession getRegistrationSession() {
|
||||
return registrationServiceSession;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user