From 35286f838e8eb8a324489e83efa9a1b9bcabb352 Mon Sep 17 00:00:00 2001
From: Chris Eager <79161849+eager-signal@users.noreply.github.com>
Date: Wed, 22 Feb 2023 14:27:05 -0600
Subject: [PATCH] Add `/v1/verification`
---
service/config/sample.yml | 8 +-
service/pom.xml | 10 +
.../textsecuregcm/WhisperServerService.java | 24 +-
.../auth/PhoneVerificationTokenManager.java | 23 +-
.../auth/StoredVerificationCode.java | 4 +-
.../captcha/RegistrationCaptchaManager.java | 106 ++
.../configuration/DynamoDbTables.java | 19 +-
.../RateLimitsConfiguration.java | 14 +
.../controllers/AccountController.java | 71 +-
.../controllers/VerificationController.java | 676 +++++++++
...tionSessionRateLimitExceededException.java | 34 +
.../CreateVerificationSessionRequest.java | 35 +
.../entities/RegistrationServiceSession.java | 34 +
...ava => SubmitVerificationCodeRequest.java} | 4 +-
.../UpdateVerificationSessionRequest.java | 34 +
.../entities/VerificationCodeRequest.java | 28 +
.../entities/VerificationSessionResponse.java | 17 +
.../textsecuregcm/limits/RateLimiter.java | 1 +
.../textsecuregcm/limits/RateLimiters.java | 16 +-
...istrationServiceSenderExceptionMapper.java | 27 +
.../RegistrationServiceClient.java | 99 +-
.../RegistrationServiceException.java | 30 +
.../RegistrationServiceSenderException.java | 54 +
.../registration/VerificationSession.java | 49 +
.../SerializedExpireableJsonDynamoStore.java | 151 ++
.../storage/VerificationSessionManager.java | 32 +
.../storage/VerificationSessions.java | 17 +
.../src/main/proto/RegistrationService.proto | 79 +-
.../auth/StoredVerificationCodeTest.java | 3 +-
.../controllers/AccountControllerTest.java | 105 +-
.../controllers/AccountControllerV2Test.java | 27 +-
.../RegistrationControllerTest.java | 36 +-
.../VerificationControllerTest.java | 1268 +++++++++++++++++
...rializedExpireableJsonDynamoStoreTest.java | 183 +++
.../storage/VerificationCodeStoreTest.java | 12 +-
.../storage/VerificationSessionsTest.java | 99 ++
.../controllers/DeviceControllerTest.java | 3 -
37 files changed, 3255 insertions(+), 177 deletions(-)
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/captcha/RegistrationCaptchaManager.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationSessionRateLimitExceededException.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationServiceSession.java
rename service/src/main/java/org/whispersystems/textsecuregcm/entities/{RegistrationSession.java => SubmitVerificationCodeRequest.java} (55%)
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/UpdateVerificationSessionRequest.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationCodeRequest.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationSessionResponse.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/mappers/RegistrationServiceSenderExceptionMapper.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceException.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceSenderException.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/registration/VerificationSession.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStore.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessionManager.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessions.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStoreTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationSessionsTest.java
diff --git a/service/config/sample.yml b/service/config/sample.yml
index 920200c99..8e0191612 100644
--- a/service/config/sample.yml
+++ b/service/config/sample.yml
@@ -72,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:
@@ -80,9 +83,8 @@ dynamoDbTables:
tableName: Example_ReservedUsernames
subscriptions:
tableName: Example_Subscriptions
- registrationRecovery:
- tableName: Example_RegistrationRecovery
- expiration: P300D # Duration of time until rows expire
+ verificationSessions:
+ tableName: Example_VerificationSessions
cacheCluster: # Redis server configuration for cache cluster
configurationUri: redis://redis.example.com:6379/
diff --git a/service/pom.xml b/service/pom.xml
index ebc140546..59f30b8a9 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -567,6 +567,16 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0-M7
+
+
+ --add-opens=java.base/java.net=ALL-UNNAMED
+
+
+
org.apache.maven.plugins
maven-jar-plugin
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index 7c5f14f84..ac6fb87ae 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -85,6 +85,7 @@ 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;
@@ -111,6 +112,7 @@ 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.VerificationController;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
@@ -130,6 +132,7 @@ import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressException
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;
@@ -210,6 +213,8 @@ 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;
@@ -382,6 +387,8 @@ public class WhisperServerService extends Application {
environment.jersey().register(exceptionMapper);
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java
index 0c3d3b04a..d09469306 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java
@@ -5,6 +5,8 @@
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;
@@ -19,7 +21,7 @@ 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.RegistrationSession;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
@@ -46,7 +48,8 @@ public class PhoneVerificationTokenManager {
* @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
+ * @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
@@ -65,7 +68,7 @@ public class PhoneVerificationTokenManager {
private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {
try {
- final RegistrationSession session = registrationServiceClient
+ final RegistrationServiceSession session = registrationServiceClient
.getSession(sessionId, REGISTRATION_RPC_TIMEOUT)
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.orElseThrow(() -> new NotAuthorizedException("session not verified"));
@@ -76,7 +79,19 @@ public class PhoneVerificationTokenManager {
if (!session.verified()) {
throw new NotAuthorizedException("session not verified");
}
- } catch (final CancellationException | ExecutionException | TimeoutException e) {
+ } 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);
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java
index 4976ce66f..da610fd59 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java
@@ -10,9 +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 pushCode,
@Nullable byte[] sessionId) {
public static final Duration EXPIRATION = Duration.ofMinutes(10);
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RegistrationCaptchaManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RegistrationCaptchaManager.java
new file mode 100644
index 000000000..a6f0a9ba8
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RegistrationCaptchaManager.java
@@ -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 testDevices;
+ private final DynamicConfigurationManager dynamicConfigurationManager;
+
+
+ public RegistrationCaptchaManager(final CaptchaChecker captchaChecker, final RateLimiters rateLimiters,
+ final Map testDevices,
+ final DynamicConfigurationManager dynamicConfigurationManager) {
+ this.captchaChecker = captchaChecker;
+ this.rateLimiters = rateLimiters;
+ this.testDevices = testDevices;
+ this.dynamicConfigurationManager = dynamicConfigurationManager;
+ }
+
+ @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
+ public Optional assessCaptcha(final Optional captcha, final String sourceHost)
+ throws IOException {
+ return captcha.isPresent()
+ ? Optional.of(captchaChecker.verify(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;
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
index 14c4ea3dc..013cf78e6 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java
@@ -58,11 +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 TableWithExpiration registrationRecovery;
+ private final Table verificationSessions;
public DynamoDbTables(
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
@@ -77,11 +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("registrationRecovery") final TableWithExpiration registrationRecovery) {
+ @JsonProperty("verificationSessions") final Table verificationSessions) {
this.accounts = accounts;
this.deletedAccounts = deletedAccounts;
@@ -95,11 +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.registrationRecovery = registrationRecovery;
+ this.verificationSessions = verificationSessions;
}
@NotNull
@@ -174,6 +177,12 @@ public class DynamoDbTables {
return redeemedReceipts;
}
+ @NotNull
+ @Valid
+ public TableWithExpiration getRegistrationRecovery() {
+ return registrationRecovery;
+ }
+
@NotNull
@Valid
public Table getRemoteConfig() {
@@ -200,7 +209,7 @@ public class DynamoDbTables {
@NotNull
@Valid
- public TableWithExpiration getRegistrationRecovery() {
- return registrationRecovery;
+ public Table getVerificationSessions() {
+ return verificationSessions;
}
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java
index 1d598d10c..72c3b241a 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java
@@ -29,6 +29,12 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration verifyPin = new RateLimitConfiguration(10, 1 / (24.0 * 60.0));
+ @JsonProperty
+ private RateLimitConfiguration verificationCaptcha = new RateLimitConfiguration(10, 2);
+
+ @JsonProperty
+ private RateLimitConfiguration verificationPushChallenge = new RateLimitConfiguration(5, 2);
+
@JsonProperty
private RateLimitConfiguration registration = new RateLimitConfiguration(2, 2);
@@ -122,6 +128,14 @@ public class RateLimitsConfiguration {
return verifyPin;
}
+ public RateLimitConfiguration getVerificationCaptcha() {
+ return verificationCaptcha;
+ }
+
+ public RateLimitConfiguration getVerificationPushChallenge() {
+ return verificationPushChallenge;
+ }
+
public RateLimitConfiguration getRegistration() {
return registration;
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
index 8a2da844a..0e3315586 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java
@@ -28,7 +28,6 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
-import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletionException;
@@ -67,8 +66,7 @@ import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
-import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
-import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
+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;
@@ -118,9 +116,6 @@ public class AccountController {
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 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");
@@ -155,8 +150,7 @@ public class AccountController {
private final RegistrationServiceClient registrationServiceClient;
private final DynamicConfigurationManager dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator;
- private final Map testDevices;
- private final CaptchaChecker captchaChecker;
+ private final RegistrationCaptchaManager registrationCaptchaManager;
private final PushNotificationManager pushNotificationManager;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
@@ -175,8 +169,7 @@ public class AccountController {
RegistrationServiceClient registrationServiceClient,
DynamicConfigurationManager dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator,
- Map testDevices,
- CaptchaChecker captchaChecker,
+ RegistrationCaptchaManager registrationCaptchaManager,
PushNotificationManager pushNotificationManager,
ChangeNumberManager changeNumberManager,
RegistrationLockVerificationManager registrationLockVerificationManager,
@@ -189,9 +182,8 @@ public class AccountController {
this.rateLimiters = rateLimiters;
this.registrationServiceClient = registrationServiceClient;
this.dynamicConfigurationManager = dynamicConfigurationManager;
- this.testDevices = testDevices;
this.turnTokenGenerator = turnTokenGenerator;
- this.captchaChecker = captchaChecker;
+ this.registrationCaptchaManager = registrationCaptchaManager;
this.pushNotificationManager = pushNotificationManager;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.changeNumberManager = changeNumberManager;
@@ -245,6 +237,7 @@ public class AccountController {
} else {
final byte[] sessionId = createRegistrationSession(phoneNumber);
storedVerificationCode = new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
+ new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
}
}
@@ -278,9 +271,7 @@ public class AccountController {
final String region = Util.getRegion(number);
// if there's a captcha, assess it, otherwise check if we need a captcha
- final Optional assessmentResult = captcha.isPresent()
- ? Optional.of(captchaChecker.verify(captcha.get(), sourceHost))
- : Optional.empty();
+ final Optional assessmentResult = registrationCaptchaManager.assessCaptcha(captcha, sourceHost);
assessmentResult.ifPresent(result ->
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
@@ -300,7 +291,8 @@ public class AccountController {
final boolean requiresCaptcha = assessmentResult
.map(result -> !result.valid())
- .orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, pushChallengeMatch));
+ .orElseGet(
+ () -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch));
if (requiresCaptcha) {
captchaRequiredMeter.mark();
@@ -357,8 +349,7 @@ public class AccountController {
final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
clock.millis(),
- maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null),
- sessionId);
+ maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), sessionId);
pendingAccounts.store(number, storedVerificationCode);
@@ -844,50 +835,6 @@ 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);
-
- try {
- rateLimiters.getSmsVoiceIpLimiter().validate(sourceHost);
- } catch (RateLimitExceededException e) {
- logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
- rateLimitedHostMeter.mark();
-
- return true;
- }
-
- try {
- rateLimiters.getSmsVoicePrefixLimiter().validate(Util.getNumberPrefix(number));
- } catch (RateLimitExceededException e) {
- logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
- rateLimitedPrefixMeter.mark();
-
- return true;
- }
-
- if (countryFiltered) {
- countryFilteredHostMeter.mark();
- return true;
- }
-
- return false;
- }
-
@Timed
@DELETE
@Path("/me")
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java
new file mode 100644
index 000000000..41b18fa15
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java
@@ -0,0 +1,676 @@
+/*
+ * 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.FilterSpam;
+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")
+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 Clock clock;
+
+ public VerificationController(final RegistrationServiceClient registrationServiceClient,
+ final VerificationSessionManager verificationSessionManager,
+ final PushNotificationManager pushNotificationManager,
+ final RegistrationCaptchaManager registrationCaptchaManager,
+ final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, final RateLimiters rateLimiters,
+ final Clock clock) {
+ this.registrationServiceClient = registrationServiceClient;
+ this.verificationSessionManager = verificationSessionManager;
+ this.pushNotificationManager = pushNotificationManager;
+ this.registrationCaptchaManager = registrationCaptchaManager;
+ this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
+ this.rateLimiters = rateLimiters;
+ 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 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,
+ 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) {
+
+ final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow();
+
+ final Pair 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);
+ } 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 pushTokenAndType, VerificationSession verificationSession) {
+
+ if (pushTokenAndType.first() != null) {
+
+ if (verificationSession.pushChallenge() == null) {
+
+ final List 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 submittedInformation = new ArrayList<>(
+ verificationSession.submittedInformation());
+ submittedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);
+
+ final List 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) 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.valid())),
+ 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.score())))
+ .increment();
+
+ } catch (IOException e) {
+ throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
+ }
+
+ if (assessmentResult.valid()) {
+ final List submittedInformation = new ArrayList<>(
+ verificationSession.submittedInformation());
+ submittedInformation.add(VerificationSession.Information.CAPTCHA);
+
+ final List 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 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 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 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);
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationSessionRateLimitExceededException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationSessionRateLimitExceededException.java
new file mode 100644
index 000000000..8841a44b1
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationSessionRateLimitExceededException.java
@@ -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;
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
new file mode 100644
index 000000000..82eedb58e
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import org.whispersystems.textsecuregcm.util.E164;
+
+// Not a record, because Jackson does not support @JsonUnwrapped with records
+// https://github.com/FasterXML/jackson-databind/issues/1497
+public final class CreateVerificationSessionRequest {
+
+ @E164
+ @NotBlank
+ @JsonProperty
+ private String number;
+
+ @Valid
+ @JsonUnwrapped
+ private UpdateVerificationSessionRequest updateVerificationSessionRequest;
+
+ public String getNumber() {
+ return number;
+ }
+
+ public UpdateVerificationSessionRequest getUpdateVerificationSessionRequest() {
+ return updateVerificationSessionRequest;
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationServiceSession.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationServiceSession.java
new file mode 100644
index 000000000..2f581877c
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationServiceSession.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import java.util.Base64;
+import javax.annotation.Nullable;
+import org.signal.registration.rpc.RegistrationSessionMetadata;
+
+public record RegistrationServiceSession(byte[] id, String number, boolean verified,
+ @Nullable Long nextSms, @Nullable Long nextVoiceCall,
+ @Nullable Long nextVerificationAttempt,
+ long expiration) {
+
+
+ public String encodedSessionId() {
+ return encodeSessionId(id);
+ }
+
+ public static String encodeSessionId(final byte[] sessionId) {
+ return Base64.getUrlEncoder().encodeToString(sessionId);
+ }
+
+ public RegistrationServiceSession(byte[] id, String number, RegistrationSessionMetadata remoteSession) {
+ this(id, number, remoteSession.getVerified(),
+ remoteSession.getMayRequestSms() ? remoteSession.getNextSmsSeconds() : null,
+ remoteSession.getMayRequestVoiceCall() ? remoteSession.getNextVoiceCallSeconds() : null,
+ remoteSession.getMayCheckCode() ? remoteSession.getNextCodeCheckSeconds() : null,
+ remoteSession.getExpirationSeconds());
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SubmitVerificationCodeRequest.java
similarity index 55%
rename from service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java
rename to service/src/main/java/org/whispersystems/textsecuregcm/entities/SubmitVerificationCodeRequest.java
index d7e05bfc5..07bf48487 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SubmitVerificationCodeRequest.java
@@ -5,6 +5,8 @@
package org.whispersystems.textsecuregcm.entities;
-public record RegistrationSession(String number, boolean verified) {
+import javax.validation.constraints.NotBlank;
+
+public record SubmitVerificationCodeRequest(@NotBlank String code) {
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UpdateVerificationSessionRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UpdateVerificationSessionRequest.java
new file mode 100644
index 000000000..aca03d9ec
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UpdateVerificationSessionRequest.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import javax.annotation.Nullable;
+import org.whispersystems.textsecuregcm.push.PushNotification;
+
+public record UpdateVerificationSessionRequest(@Nullable String pushToken,
+ @Nullable PushTokenType pushTokenType,
+ @Nullable String pushChallenge,
+ @Nullable String captcha,
+ @Nullable String mcc,
+ @Nullable String mnc) {
+
+ public enum PushTokenType {
+ @JsonProperty("apn")
+ APN,
+ @JsonProperty("fcm")
+ FCM;
+
+ public PushNotification.TokenType toTokenType() {
+ return switch (this) {
+
+ case APN -> PushNotification.TokenType.APN;
+ case FCM -> PushNotification.TokenType.FCM;
+ };
+ }
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationCodeRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationCodeRequest.java
new file mode 100644
index 000000000..b85a2c04e
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationCodeRequest.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import javax.validation.constraints.NotNull;
+import org.whispersystems.textsecuregcm.registration.MessageTransport;
+
+public record VerificationCodeRequest(@NotNull Transport transport, @NotNull String client) {
+
+ public enum Transport {
+ @JsonProperty("sms")
+ SMS,
+ @JsonProperty("voice")
+ VOICE;
+
+ public MessageTransport toMessageTransport() {
+ return switch (this) {
+ case SMS -> MessageTransport.SMS;
+ case VOICE -> MessageTransport.VOICE;
+ };
+ }
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationSessionResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationSessionResponse.java
new file mode 100644
index 000000000..a3caaf972
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationSessionResponse.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.entities;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.whispersystems.textsecuregcm.registration.VerificationSession;
+
+public record VerificationSessionResponse(String id, @Nullable Long nextSms, @Nullable Long nextCall,
+ @Nullable Long nextVerificationAttempt, boolean allowedToRequestCode,
+ List requestedInformation,
+ boolean verified) {
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java
index 17486a63d..98876bd8c 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java
@@ -150,4 +150,5 @@ public class RateLimiter {
void validate() throws RateLimitExceededException;
}
+
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
index 15967b426..4342c0aa7 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java
@@ -42,6 +42,8 @@ public class RateLimiters {
private final RateLimiter smsVoiceIpLimiter;
private final RateLimiter smsVoicePrefixLimiter;
private final RateLimiter verifyLimiter;
+ private final RateLimiter verificationCaptchaLimiter;
+ private final RateLimiter verificationPushChallengeLimiter;
private final RateLimiter pinLimiter;
private final RateLimiter registrationLimiter;
private final RateLimiter attachmentLimiter;
@@ -61,10 +63,14 @@ public class RateLimiters {
public RateLimiters(final RateLimitsConfiguration config, final FaultTolerantRedisCluster cacheCluster) {
this.smsDestinationLimiter = fromConfig("smsDestination", config.getSmsDestination(), cacheCluster);
this.voiceDestinationLimiter = fromConfig("voxDestination", config.getVoiceDestination(), cacheCluster);
- this.voiceDestinationDailyLimiter = fromConfig("voxDestinationDaily", config.getVoiceDestinationDaily(), cacheCluster);
+ this.voiceDestinationDailyLimiter = fromConfig("voxDestinationDaily", config.getVoiceDestinationDaily(),
+ cacheCluster);
this.smsVoiceIpLimiter = fromConfig("smsVoiceIp", config.getSmsVoiceIp(), cacheCluster);
this.smsVoicePrefixLimiter = fromConfig("smsVoicePrefix", config.getSmsVoicePrefix(), cacheCluster);
this.verifyLimiter = fromConfig("verify", config.getVerifyNumber(), cacheCluster);
+ this.verificationCaptchaLimiter = fromConfig("verificationCaptcha", config.getVerificationCaptcha(), cacheCluster);
+ this.verificationPushChallengeLimiter = fromConfig("verificationPushChallenge",
+ config.getVerificationPushChallenge(), cacheCluster);
this.pinLimiter = fromConfig("pin", config.getVerifyPin(), cacheCluster);
this.registrationLimiter = fromConfig("registration", config.getRegistration(), cacheCluster);
this.attachmentLimiter = fromConfig("attachmentCreate", config.getAttachments(), cacheCluster);
@@ -134,6 +140,14 @@ public class RateLimiters {
return verifyLimiter;
}
+ public RateLimiter getVerificationCaptchaLimiter() {
+ return verificationCaptchaLimiter;
+ }
+
+ public RateLimiter getVerificationPushChallengeLimiter() {
+ return verificationPushChallengeLimiter;
+ }
+
public RateLimiter getPinLimiter() {
return pinLimiter;
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RegistrationServiceSenderExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RegistrationServiceSenderExceptionMapper.java
new file mode 100644
index 000000000..f0ee5fd3d
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RegistrationServiceSenderExceptionMapper.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.mappers;
+
+import com.google.common.annotations.VisibleForTesting;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;
+
+public class RegistrationServiceSenderExceptionMapper implements ExceptionMapper {
+
+ @Override
+ public Response toResponse(final RegistrationServiceSenderException exception) {
+ return Response.status(Response.Status.BAD_GATEWAY)
+ .entity(new SendVerificationCodeFailureResponse(exception.getReason(), exception.isPermanent()))
+ .build();
+ }
+
+ @VisibleForTesting
+ public record SendVerificationCodeFailureResponse(RegistrationServiceSenderException.Reason reason,
+ boolean permanentFailure) {
+
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java
index df6d30f2d..f93da9398 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java
@@ -28,9 +28,11 @@ import org.signal.registration.rpc.CheckVerificationCodeRequest;
import org.signal.registration.rpc.CreateRegistrationSessionRequest;
import org.signal.registration.rpc.GetRegistrationSessionMetadataRequest;
import org.signal.registration.rpc.RegistrationServiceGrpc;
+import org.signal.registration.rpc.RegistrationSessionMetadata;
import org.signal.registration.rpc.SendVerificationCodeRequest;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
-import org.whispersystems.textsecuregcm.entities.RegistrationSession;
+import org.whispersystems.textsecuregcm.controllers.VerificationSessionRateLimitExceededException;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
public class RegistrationServiceClient implements Managed {
@@ -76,7 +78,10 @@ public class RegistrationServiceClient implements Managed {
this.callbackExecutor = callbackExecutor;
}
- public CompletableFuture createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber, final Duration timeout) {
+ // The …Session suffix methods distinguish the new methods, which return Sessions, from the old.
+ // Once the deprecated methods are removed, the names can be streamlined.
+ public CompletableFuture createRegistrationSessionSession(
+ final Phonenumber.PhoneNumber phoneNumber, final Duration timeout) {
final long e164 = Long.parseLong(
PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1));
@@ -85,12 +90,14 @@ public class RegistrationServiceClient implements Managed {
.setE164(e164)
.build()))
.thenApply(response -> switch (response.getResponseCase()) {
- case SESSION_METADATA -> response.getSessionMetadata().getSessionId().toByteArray();
+ case SESSION_METADATA -> buildSessionResponseFromMetadata(response.getSessionMetadata());
case ERROR -> {
switch (response.getError().getErrorType()) {
case CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
- new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
+ new RateLimitExceededException(response.getError().getMayRetry()
+ ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())
+ : null,
true));
case CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER -> throw new IllegalArgumentException();
default -> throw new RuntimeException(
@@ -102,7 +109,14 @@ public class RegistrationServiceClient implements Managed {
});
}
- public CompletableFuture sendRegistrationCode(final byte[] sessionId,
+ @Deprecated
+ public CompletableFuture createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber,
+ final Duration timeout) {
+ return createRegistrationSessionSession(phoneNumber, timeout)
+ .thenApply(RegistrationServiceSession::id);
+ }
+
+ public CompletableFuture sendVerificationCode(final byte[] sessionId,
final MessageTransport messageTransport,
final ClientType clientType,
@Nullable final String acceptLanguage,
@@ -123,21 +137,57 @@ public class RegistrationServiceClient implements Managed {
if (response.hasError()) {
switch (response.getError().getErrorType()) {
case SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
- new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
+ new VerificationSessionRateLimitExceededException(
+ buildSessionResponseFromMetadata(response.getSessionMetadata()),
+ response.getError().getMayRetry()
+ ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())
+ : null,
true));
- default -> throw new CompletionException(new RuntimeException("Failed to send verification code: " + response.getError().getErrorType()));
+ case SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND -> throw new CompletionException(
+ new RegistrationServiceException(null));
+
+ case SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_ALREADY_VERIFIED -> throw new CompletionException(
+ new RegistrationServiceException(buildSessionResponseFromMetadata(response.getSessionMetadata())));
+
+ case SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_REJECTED -> throw new CompletionException(
+ RegistrationServiceSenderException.rejected(response.getError().getMayRetry()));
+ case SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_ILLEGAL_ARGUMENT -> throw new CompletionException(
+ RegistrationServiceSenderException.illegalArgument(response.getError().getMayRetry()));
+ case SEND_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED -> throw new CompletionException(
+ RegistrationServiceSenderException.unknown(response.getError().getMayRetry()));
+
+ default -> throw new CompletionException(
+ new RuntimeException("Failed to send verification code: " + response.getError().getErrorType()));
}
} else {
- return response.getSessionId().toByteArray();
+ return buildSessionResponseFromMetadata(response.getSessionMetadata());
}
- });
+ });
}
+ @Deprecated
+ public CompletableFuture sendRegistrationCode(final byte[] sessionId,
+ final MessageTransport messageTransport,
+ final ClientType clientType,
+ @Nullable final String acceptLanguage,
+ final Duration timeout) {
+ return sendVerificationCode(sessionId, messageTransport, clientType, acceptLanguage, timeout)
+ .thenApply(RegistrationServiceSession::id);
+ }
+
+ @Deprecated
public CompletableFuture checkVerificationCode(final byte[] sessionId,
final String verificationCode,
final Duration timeout) {
+ return checkVerificationCodeSession(sessionId, verificationCode, timeout)
+ .thenApply(RegistrationServiceSession::verified);
+ }
+
+ public CompletableFuture checkVerificationCodeSession(final byte[] sessionId,
+ final String verificationCode,
+ final Duration timeout) {
return toCompletableFuture(stub.withDeadline(toDeadline(timeout))
.checkVerificationCode(CheckVerificationCodeRequest.newBuilder()
.setSessionId(ByteString.copyFrom(sessionId))
@@ -147,18 +197,32 @@ public class RegistrationServiceClient implements Managed {
if (response.hasError()) {
switch (response.getError().getErrorType()) {
case CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
- new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
+ new VerificationSessionRateLimitExceededException(
+ buildSessionResponseFromMetadata(response.getSessionMetadata()),
+ response.getError().getMayRetry()
+ ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())
+ : null,
true));
- default -> throw new CompletionException(new RuntimeException("Failed to check verification code: " + response.getError().getErrorType()));
+ case CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT, CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPT_EXPIRED ->
+ throw new CompletionException(
+ new RegistrationServiceException(buildSessionResponseFromMetadata(response.getSessionMetadata()))
+ );
+
+ case CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND -> throw new CompletionException(
+ new RegistrationServiceException(null)
+ );
+
+ default -> throw new CompletionException(
+ new RuntimeException("Failed to check verification code: " + response.getError().getErrorType()));
}
} else {
- return response.getVerified() || response.getSessionMetadata().getVerified();
+ return buildSessionResponseFromMetadata(response.getSessionMetadata());
}
});
}
- public CompletableFuture> getSession(final byte[] sessionId,
+ public CompletableFuture> getSession(final byte[] sessionId,
final Duration timeout) {
return toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata(
GetRegistrationSessionMetadataRequest.newBuilder()
@@ -173,11 +237,16 @@ public class RegistrationServiceClient implements Managed {
}
}
- final String number = convertNumeralE164ToString(response.getSessionMetadata().getE164());
- return Optional.of(new RegistrationSession(number, response.getSessionMetadata().getVerified()));
+ return Optional.of(buildSessionResponseFromMetadata(response.getSessionMetadata()));
});
}
+ private static RegistrationServiceSession buildSessionResponseFromMetadata(
+ final RegistrationSessionMetadata sessionMetadata) {
+ return new RegistrationServiceSession(sessionMetadata.getSessionId().toByteArray(),
+ convertNumeralE164ToString(sessionMetadata.getE164()), sessionMetadata);
+ }
+
private static Deadline toDeadline(final Duration timeout) {
return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS);
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceException.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceException.java
new file mode 100644
index 000000000..69f7ad229
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.registration;
+
+import java.util.Optional;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
+
+/**
+ * When the Registration Service returns an error, it will also return the latest {@link RegistrationServiceSession}
+ * data, so that clients may have the latest details on requesting and submitting codes.
+ */
+public class RegistrationServiceException extends Exception {
+
+ private final RegistrationServiceSession registrationServiceSession;
+
+ public RegistrationServiceException(final RegistrationServiceSession registrationServiceSession) {
+ super(null, null, true, false);
+ this.registrationServiceSession = registrationServiceSession;
+ }
+
+ /**
+ * @return if empty, the session that encountered should be considered non-existent and may be discarded
+ */
+ public Optional getRegistrationSession() {
+ return Optional.ofNullable(registrationServiceSession);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceSenderException.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceSenderException.java
new file mode 100644
index 000000000..a2f9372ab
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceSenderException.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.registration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * An error from an SMS/voice provider (“sender”) downstream of Registration Service is mapped to a {@link Reason}, and
+ * may be permanent.
+ */
+public class RegistrationServiceSenderException extends Exception {
+
+ private final Reason reason;
+ private final boolean permanent;
+
+ public static RegistrationServiceSenderException illegalArgument(final boolean permanent) {
+ return new RegistrationServiceSenderException(Reason.ILLEGAL_ARGUMENT, permanent);
+ }
+
+ public static RegistrationServiceSenderException rejected(final boolean permanent) {
+ return new RegistrationServiceSenderException(Reason.PROVIDER_REJECTED, permanent);
+ }
+
+ public static RegistrationServiceSenderException unknown(final boolean permanent) {
+ return new RegistrationServiceSenderException(Reason.PROVIDER_UNAVAILABLE, permanent);
+ }
+
+ private RegistrationServiceSenderException(final Reason reason, final boolean permanent) {
+ super(null, null, true, false);
+ this.reason = reason;
+ this.permanent = permanent;
+ }
+
+ public Reason getReason() {
+ return reason;
+ }
+
+ public boolean isPermanent() {
+ return permanent;
+ }
+
+ public enum Reason {
+
+ @JsonProperty("providerUnavailable")
+ PROVIDER_UNAVAILABLE,
+ @JsonProperty("providerRejected")
+ PROVIDER_REJECTED,
+ @JsonProperty("illegalArgument")
+ ILLEGAL_ARGUMENT
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/VerificationSession.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/VerificationSession.java
new file mode 100644
index 000000000..d9f7c7810
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/VerificationSession.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.registration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.Instant;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.whispersystems.textsecuregcm.storage.SerializedExpireableJsonDynamoStore;
+
+/**
+ * Server-internal stored session object. Primarily used by
+ * {@link org.whispersystems.textsecuregcm.controllers.VerificationController} to manage the steps required to begin
+ * requesting codes from Registration Service, in order to get a verified session to be provided to
+ * {@link org.whispersystems.textsecuregcm.controllers.RegistrationController}.
+ *
+ * @param pushChallenge the value of a push challenge sent to a client, after it submitted a push token
+ * @param requestedInformation information requested that a client send to the server
+ * @param submittedInformation information that a client has submitted and that the server has verified
+ * @param allowedToRequestCode whether the client is allowed to request a code. This request will be forwarded to
+ * Registration Service
+ * @param createdTimestamp when this session was created
+ * @param updatedTimestamp when this session was updated
+ * @param remoteExpirationSeconds when the remote
+ * {@link org.whispersystems.textsecuregcm.entities.RegistrationServiceSession} expires
+ * @see org.whispersystems.textsecuregcm.entities.RegistrationServiceSession
+ * @see org.whispersystems.textsecuregcm.entities.VerificationSessionResponse
+ */
+public record VerificationSession(@Nullable String pushChallenge,
+ List requestedInformation, List submittedInformation,
+ boolean allowedToRequestCode, long createdTimestamp, long updatedTimestamp,
+ long remoteExpirationSeconds) implements
+ SerializedExpireableJsonDynamoStore.Expireable {
+
+ @Override
+ public long getExpirationEpochSeconds() {
+ return Instant.ofEpochMilli(updatedTimestamp).plusSeconds(remoteExpirationSeconds).getEpochSecond();
+ }
+
+ public enum Information {
+ @JsonProperty("pushChallenge")
+ PUSH_CHALLENGE,
+ @JsonProperty("captcha")
+ CAPTCHA
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStore.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStore.java
new file mode 100644
index 000000000..451ca5385
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStore.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.annotations.VisibleForTesting;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.time.Clock;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.textsecuregcm.util.AttributeValues;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
+import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
+
+public abstract class SerializedExpireableJsonDynamoStore {
+
+ public interface Expireable {
+
+ @JsonIgnore
+ long getExpirationEpochSeconds();
+ }
+
+ private final DynamoDbAsyncClient dynamoDbClient;
+ private final String tableName;
+ private final Clock clock;
+ private final Class deserializationTargetClass;
+
+ @VisibleForTesting
+ static final String KEY_KEY = "K";
+
+ private static final String ATTR_SERIALIZED_VALUE = "V";
+ private static final String ATTR_TTL = "E";
+
+ private static final Logger log = LoggerFactory.getLogger(VerificationCodeStore.class);
+
+ public SerializedExpireableJsonDynamoStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName,
+ final Clock clock) {
+ this.dynamoDbClient = dynamoDbClient;
+ this.tableName = tableName;
+ this.clock = clock;
+
+ if (getClass().getGenericSuperclass() instanceof ParameterizedType pt) {
+ // Extract the parameterized class declared by concrete implementations, so that it can
+ // be passed to future deserialization calls
+ final Type[] actualTypeArguments = pt.getActualTypeArguments();
+ if (actualTypeArguments.length != 1) {
+ throw new RuntimeException("Unexpected number of type arguments: " + actualTypeArguments.length);
+ }
+ deserializationTargetClass = (Class) actualTypeArguments[0];
+ } else {
+ throw new RuntimeException(
+ "Unable to determine target class for deserialization - generic superclass is not a ParameterizedType");
+ }
+ }
+
+ public CompletableFuture insert(final String key, final T v) {
+ return put(key, v, builder -> builder.expressionAttributeNames(Map.of(
+ "#key", KEY_KEY
+ )).conditionExpression("attribute_not_exists(#key)"));
+ }
+
+ public CompletableFuture update(final String key, final T v) {
+ return put(key, v, ignored -> {
+ });
+ }
+
+ private CompletableFuture put(final String key, final T v,
+ final Consumer putRequestCustomizer) {
+ try {
+ final Map attributeValueMap = new HashMap<>(Map.of(
+ KEY_KEY, AttributeValues.fromString(key),
+ ATTR_SERIALIZED_VALUE,
+ AttributeValues.fromString(SystemMapper.getMapper().writeValueAsString(v))));
+ if (v instanceof Expireable ev) {
+ attributeValueMap.put(ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(ev)));
+ }
+ final PutItemRequest.Builder builder = PutItemRequest.builder()
+ .tableName(tableName)
+ .item(attributeValueMap);
+ putRequestCustomizer.accept(builder);
+
+ return dynamoDbClient.putItem(builder.build())
+ .thenRun(() -> {
+ });
+ } catch (final JsonProcessingException e) {
+ // This should never happen when writing directly to a string except in cases of serious misconfiguration, which
+ // would be caught by tests.
+ throw new AssertionError(e);
+ }
+ }
+
+ private long getExpirationTimestamp(final Expireable v) {
+ return v.getExpirationEpochSeconds();
+ }
+
+ public CompletableFuture> findForKey(final String key) {
+ return dynamoDbClient.getItem(GetItemRequest.builder()
+ .tableName(tableName)
+ .consistentRead(true)
+ .key(Map.of(KEY_KEY, AttributeValues.fromString(key)))
+ .build())
+ .thenApply(response -> {
+ try {
+ return response.hasItem()
+ ? filterMaybeExpiredValue(
+ SystemMapper.getMapper()
+ .readValue(response.item().get(ATTR_SERIALIZED_VALUE).s(), deserializationTargetClass))
+ : Optional.empty();
+ } catch (final JsonProcessingException e) {
+ log.error("Failed to parse stored value", e);
+ return Optional.empty();
+ }
+ });
+ }
+
+ private Optional filterMaybeExpiredValue(T v) {
+ // It's possible for DynamoDB to return items after their expiration time (although it is very unlikely for small
+ // tables)
+ if (v instanceof Expireable ev) {
+ if (getExpirationTimestamp(ev) < clock.instant().getEpochSecond()) {
+ return Optional.empty();
+ }
+ }
+
+ return Optional.of(v);
+ }
+
+ public CompletableFuture remove(final String key) {
+ return dynamoDbClient.deleteItem(DeleteItemRequest.builder()
+ .tableName(tableName)
+ .key(Map.of(KEY_KEY, AttributeValues.fromString(key)))
+ .build())
+ .thenRun(() -> {
+ });
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessionManager.java
new file mode 100644
index 000000000..ed9d29cf4
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessionManager.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import org.whispersystems.textsecuregcm.registration.VerificationSession;
+
+public class VerificationSessionManager {
+
+ private final VerificationSessions verificationSessions;
+
+ public VerificationSessionManager(final VerificationSessions verificationSessions) {
+ this.verificationSessions = verificationSessions;
+ }
+
+ public CompletableFuture insert(final String encodedSessionId, final VerificationSession verificationSession) {
+ return verificationSessions.insert(encodedSessionId, verificationSession);
+ }
+
+ public CompletableFuture update(final String encodedSessionId, final VerificationSession verificationSession) {
+ return verificationSessions.update(encodedSessionId, verificationSession);
+ }
+
+ public CompletableFuture> findForId(final String encodedSessionId) {
+ return verificationSessions.findForKey(encodedSessionId);
+ }
+
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessions.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessions.java
new file mode 100644
index 000000000..5c543076b
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessions.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import java.time.Clock;
+import org.whispersystems.textsecuregcm.registration.VerificationSession;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
+
+public class VerificationSessions extends SerializedExpireableJsonDynamoStore {
+
+ public VerificationSessions(final DynamoDbAsyncClient dynamoDbClient, final String tableName, final Clock clock) {
+ super(dynamoDbClient, tableName, clock);
+ }
+}
diff --git a/service/src/main/proto/RegistrationService.proto b/service/src/main/proto/RegistrationService.proto
index deab91865..04d7bf4f9 100644
--- a/service/src/main/proto/RegistrationService.proto
+++ b/service/src/main/proto/RegistrationService.proto
@@ -66,6 +66,66 @@ message RegistrationSessionMetadata {
* The phone number associated with this registration session.
*/
uint64 e164 = 3;
+
+ /**
+ * Indicates whether the caller may request delivery of a verification code
+ * via SMS now or at some time in the future. If true, the time a caller must
+ * wait before requesting a verification code via SMS is given in the
+ * `next_sms_seconds` field.
+ */
+ bool may_request_sms = 4;
+
+ /**
+ * The duration, in seconds, after which a caller will next be allowed to
+ * request delivery of a verification code via SMS if `may_request_sms` is
+ * true. If zero, a caller may request a verification code via SMS
+ * immediately. If `may_request_sms` is false, this field has no meaning.
+ */
+ uint64 next_sms_seconds = 5;
+
+ /**
+ * Indicates whether the caller may request delivery of a verification code
+ * via a phone call now or at some time in the future. If true, the time a
+ * caller must wait before requesting a verification code via SMS is given in
+ * the `next_voice_call_seconds` field. If false, simply waiting will not
+ * allow the caller to request a phone call and the caller may need to
+ * perform some other action (like attempting verification code delivery via
+ * SMS) before requesting a voice call.
+ */
+ bool may_request_voice_call = 6;
+
+ /**
+ * The duration, in seconds, after which a caller will next be allowed to
+ * request delivery of a verification code via a phone call if
+ * `may_request_voice_call` is true. If zero, a caller may request a
+ * verification code via a phone call immediately. If `may_request_voice_call`
+ * is false, this field has no meaning.
+ */
+ uint64 next_voice_call_seconds = 7;
+
+ /**
+ * Indicates whether the caller may submit new verification codes now or at
+ * some time in the future. If true, the time a caller must wait before
+ * submitting a verification code is given in the `next_code_check_seconds`
+ * field. If false, simply waiting will not allow the caller to submit a
+ * verification code and the caller may need to perform some other action
+ * (like requesting delivery of a verification code) before checking a
+ * verification code.
+ */
+ bool may_check_code = 8;
+
+ /**
+ * The duration, in seconds, after which a caller will next be allowed to
+ * submit a verification code if `may_check_code` is true. If zero, a caller
+ * may submit a verification code immediately. If `may_check_code` is false,
+ * this field has no meaning.
+ */
+ uint64 next_code_check_seconds = 9;
+
+ /**
+ * The duration, in seconds, after which this session will expire.
+ */
+ uint64 expiration_seconds = 10;
}
message CreateRegistrationSessionError {
@@ -315,29 +375,30 @@ message CheckVerificationCodeError {
enum CheckVerificationCodeErrorType {
CHECK_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0;
- /**
- * The caller has made too many incorrect guesses within the scope of this
- * session and may not make any further guesses.
- */
- CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPTS_EXHAUSTED = 1;
-
/**
* The caller has attempted to submit a verification code even though no
* verification codes have been sent within the scope of this session. The
* caller must issue a "send code" request before trying again.
*/
- CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT = 2;
+ CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT = 1;
/**
* The caller has made too many guesses within some period of time. Callers
* should wait for the duration prescribed in the session metadata object
* elsewhere in the response before trying again.
*/
- CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 3;
+ CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 2;
/**
* The session identified in this request could not be found (possibly due to
* session expiration).
*/
- CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 4;
+ CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 3;
+
+ /**
+ * The session identified in this request is still active, but the most
+ * recently-sent code has expired. Callers should request a new code, then
+ * try again.
+ */
+ CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPT_EXPIRED = 4;
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java
index 091e70d81..130f27bbc 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java
@@ -22,8 +22,7 @@ class StoredVerificationCodeTest {
private static Stream isValid() {
return Stream.of(
- Arguments.of(
- new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "code", true),
+ Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "code", true),
Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "incorrect", false),
Arguments.of(new StoredVerificationCode("", System.currentTimeMillis(), null, null), "", false)
);
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java
index 30d162014..c4153be72 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java
@@ -75,6 +75,7 @@ import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
+import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
@@ -197,6 +198,8 @@ class AccountControllerTest {
private static final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters);
+ private static final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(
+ captchaChecker, rateLimiters, Map.of(TEST_NUMBER, 123456), dynamicConfigurationManager);
private static final ResourceExtension resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
@@ -217,8 +220,7 @@ class AccountControllerTest {
registrationServiceClient,
dynamicConfigurationManager,
turnTokenGenerator,
- Map.of(TEST_NUMBER, 123456),
- captchaChecker,
+ registrationCaptchaManager,
pushNotificationManager,
changeNumberManager,
registrationLockVerificationManager,
@@ -250,30 +252,43 @@ class AccountControllerTest {
when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);
when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
- when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
+ when(senderPinAccount.getRegistrationLock()).thenReturn(
+ new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID());
when(senderHasStorage.isStorageSupported()).thenReturn(true);
- when(senderHasStorage.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
+ when(senderHasStorage.getRegistrationLock()).thenReturn(
+ new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
- when(senderRegLockAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.of(registrationLockCredentials.hash()), Optional.of(registrationLockCredentials.salt()), System.currentTimeMillis()));
+ when(senderRegLockAccount.getRegistrationLock()).thenReturn(
+ new StoredRegistrationLock(Optional.of(registrationLockCredentials.hash()),
+ Optional.of(registrationLockCredentials.salt()), System.currentTimeMillis()));
when(senderRegLockAccount.getLastSeen()).thenReturn(System.currentTimeMillis());
when(senderRegLockAccount.getUuid()).thenReturn(SENDER_REG_LOCK_UUID);
when(senderRegLockAccount.getNumber()).thenReturn(SENDER_REG_LOCK);
- when(senderTransfer.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
+ when(senderTransfer.getRegistrationLock()).thenReturn(
+ new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis()));
when(senderTransfer.getUuid()).thenReturn(SENDER_TRANSFER_UUID);
when(senderTransfer.getNumber()).thenReturn(SENDER_TRANSFER);
- when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null)));
when(pendingAccountsManager.getCodeForNumber(SENDER_OLD)).thenReturn(Optional.empty());
- when(pendingAccountsManager.getCodeForNumber(SENDER_PIN)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PREFIX)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "validchallenge", null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
- when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_PIN)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PREFIX)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "validchallenge", null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null)));
when(accountsManager.getByE164(eq(SENDER_PIN))).thenReturn(Optional.of(senderPinAccount));
when(accountsManager.getByE164(eq(SENDER_REG_LOCK))).thenReturn(Optional.of(senderRegLockAccount));
@@ -953,7 +968,8 @@ class AccountControllerTest {
final String challenge = "challenge";
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), challenge, null)));
+ when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(
+ new StoredVerificationCode(null, System.currentTimeMillis(), challenge, null)));
when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(sessionId));
@@ -1103,8 +1119,8 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
+ .thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1137,8 +1153,8 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
+ .thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1164,8 +1180,8 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
+ .thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1191,8 +1207,8 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
+ .thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1224,8 +1240,7 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
+ .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1249,8 +1264,7 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
+ .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1274,8 +1288,7 @@ class AccountControllerTest {
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER))
- .thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
+ .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId)));
when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1299,8 +1312,8 @@ class AccountControllerTest {
final String code = "987654";
final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1400,8 +1413,8 @@ class AccountControllerTest {
final String code = "987654";
final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(false));
@@ -1426,8 +1439,8 @@ class AccountControllerTest {
final String code = "987654";
final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1460,8 +1473,8 @@ class AccountControllerTest {
final String code = "987654";
final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1539,8 +1552,8 @@ class AccountControllerTest {
final String reglock = "setec-astronomy";
final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8);
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(true));
@@ -1591,15 +1604,15 @@ class AccountControllerTest {
when(AuthHelper.VALID_ACCOUNT.getDevice(2L)).thenReturn(Optional.of(device2));
when(AuthHelper.VALID_ACCOUNT.getDevice(3L)).thenReturn(Optional.of(device3));
- when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of(
- new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
+ when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(
+ Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId)));
when(registrationServiceClient.checkVerificationCode(any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(true));
var deviceMessages = List.of(
- new IncomingMessage(1, 2, 2, "content2"),
- new IncomingMessage(1, 3, 3, "content3"));
+ new IncomingMessage(1, 2, 2, "content2"),
+ new IncomingMessage(1, 3, 3, "content3"));
var deviceKeys = Map.of(1L, new SignedPreKey(), 2L, new SignedPreKey(), 3L, new SignedPreKey());
final Map registrationIds = Map.of(1L, 17, 2L, 47, 3L, 89);
@@ -2231,8 +2244,10 @@ class AccountControllerTest {
Arguments.of("123456", null, false),
Arguments.of(null, new StoredVerificationCode(null, 0, null, null), false),
Arguments.of(null, new StoredVerificationCode(null, 0, "123456", null), false),
- Arguments.of("654321", new StoredVerificationCode(null, 0, "123456", null), false),
- Arguments.of("123456", new StoredVerificationCode(null, 0, "123456", null), true)
+ Arguments.of("654321", new StoredVerificationCode(null, 0, "123456", null),
+ false),
+ Arguments.of("123456", new StoredVerificationCode(null, 0, "123456", null),
+ true)
);
}
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java
index 41abe473f..b40718edf 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java
@@ -24,6 +24,7 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
@@ -59,7 +60,7 @@ import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;
-import org.whispersystems.textsecuregcm.entities.RegistrationSession;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
@@ -78,7 +79,9 @@ import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
class AccountControllerV2Test {
- public static final String NEW_NUMBER = PhoneNumberUtil.getInstance().format(
+ private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();
+
+ private static final String NEW_NUMBER = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164);
@@ -146,7 +149,9 @@ class AccountControllerV2Test {
void changeNumberSuccess() throws Exception {
when(registrationServiceClient.getSession(any(), any()))
- .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NEW_NUMBER, true))));
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
final AccountIdentityResponse accountIdentityResponse =
resources.getJerseyTest()
@@ -245,7 +250,7 @@ class AccountControllerV2Test {
@ParameterizedTest
@MethodSource
- void registrationServiceSessionCheck(@Nullable final RegistrationSession session, final int expectedStatus,
+ void registrationServiceSessionCheck(@Nullable final RegistrationServiceSession session, final int expectedStatus,
final String message) {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session)));
@@ -263,8 +268,14 @@ class AccountControllerV2Test {
static Stream registrationServiceSessionCheck() {
return Stream.of(
Arguments.of(null, 401, "session not found"),
- Arguments.of(new RegistrationSession("+18005551234", false), 400, "session number mismatch"),
- Arguments.of(new RegistrationSession(NEW_NUMBER, false), 401, "session not verified")
+ Arguments.of(new RegistrationServiceSession(new byte[16], "+18005551234", false, null, null, null,
+ SESSION_EXPIRATION_SECONDS), 400,
+ "session number mismatch"),
+ Arguments.of(
+ new RegistrationServiceSession(new byte[16], NEW_NUMBER, false, null, null, null,
+ SESSION_EXPIRATION_SECONDS),
+ 401,
+ "session not verified")
);
}
@@ -273,7 +284,9 @@ class AccountControllerV2Test {
void registrationLock(final RegistrationLockError error) throws Exception {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(
- CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NEW_NUMBER, true))));
+ CompletableFuture.completedFuture(
+ Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class)));
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java
index 720b8bac7..1a58caa53 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java
@@ -17,6 +17,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.Base64;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -43,7 +44,7 @@ import org.whispersystems.textsecuregcm.auth.RegistrationLockError;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
-import org.whispersystems.textsecuregcm.entities.RegistrationSession;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
@@ -59,11 +60,12 @@ import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
class RegistrationControllerTest {
+ private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();
+
private static final String NUMBER = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164);
-
- public static final String PASSWORD = "password";
+ private static final String PASSWORD = "password";
private final AccountsManager accountsManager = mock(AccountsManager.class);
private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);
@@ -187,7 +189,7 @@ class RegistrationControllerTest {
@ParameterizedTest
@MethodSource
- void registrationServiceSessionCheck(@Nullable final RegistrationSession session, final int expectedStatus,
+ void registrationServiceSessionCheck(@Nullable final RegistrationServiceSession session, final int expectedStatus,
final String message) {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session)));
@@ -204,8 +206,15 @@ class RegistrationControllerTest {
static Stream registrationServiceSessionCheck() {
return Stream.of(
Arguments.of(null, 401, "session not found"),
- Arguments.of(new RegistrationSession("+18005551234", false), 400, "session number mismatch"),
- Arguments.of(new RegistrationSession(NUMBER, false), 401, "session not verified")
+ Arguments.of(
+ new RegistrationServiceSession(new byte[16], "+18005551234", false, null, null, null,
+ SESSION_EXPIRATION_SECONDS),
+ 400,
+ "session number mismatch"),
+ Arguments.of(
+ new RegistrationServiceSession(new byte[16], NUMBER, false, null, null, null, SESSION_EXPIRATION_SECONDS),
+ 401,
+ "session not verified")
);
}
@@ -244,7 +253,10 @@ class RegistrationControllerTest {
@EnumSource(RegistrationLockError.class)
void registrationLock(final RegistrationLockError error) throws Exception {
when(registrationServiceClient.getSession(any(), any()))
- .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true))));
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class)));
@@ -275,7 +287,10 @@ class RegistrationControllerTest {
void deviceTransferAvailable(final boolean existingAccount, final boolean transferSupported,
final boolean skipDeviceTransfer, final int expectedStatus) throws Exception {
when(registrationServiceClient.getSession(any(), any()))
- .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true))));
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
final Optional maybeAccount;
if (existingAccount) {
@@ -301,7 +316,10 @@ class RegistrationControllerTest {
@Test
void registrationSuccess() throws Exception {
when(registrationServiceClient.getSession(any(), any()))
- .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true))));
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
when(accountsManager.create(any(), any(), any(), any(), any()))
.thenReturn(mock(Account.class));
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java
new file mode 100644
index 000000000..16a1e6574
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java
@@ -0,0 +1,1268 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.controllers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.net.HttpHeaders;
+import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
+import io.dropwizard.testing.junit5.ResourceExtension;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.stream.Stream;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.Invocation;
+import javax.ws.rs.core.Response;
+import liquibase.util.StringUtils;
+import org.apache.http.HttpStatus;
+import org.glassfish.jersey.server.ServerProperties;
+import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
+import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
+import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
+import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
+import org.whispersystems.textsecuregcm.limits.RateLimiter;
+import org.whispersystems.textsecuregcm.limits.RateLimiters;
+import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
+import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
+import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
+import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
+import org.whispersystems.textsecuregcm.push.PushNotificationManager;
+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.storage.RegistrationRecoveryPasswordsManager;
+import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
+import org.whispersystems.textsecuregcm.util.SystemMapper;
+
+@ExtendWith(DropwizardExtensionsSupport.class)
+class VerificationControllerTest {
+
+ private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();
+
+ private static final byte[] SESSION_ID = "session".getBytes(StandardCharsets.UTF_8);
+ private static final String NUMBER = "+18005551212";
+
+ private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);
+ private final VerificationSessionManager verificationSessionManager = mock(VerificationSessionManager.class);
+ private final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class);
+ private final RegistrationCaptchaManager registrationCaptchaManager = mock(RegistrationCaptchaManager.class);
+ private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(
+ RegistrationRecoveryPasswordsManager.class);
+ private final RateLimiters rateLimiters = mock(RateLimiters.class);
+ private final Clock clock = Clock.systemUTC();
+
+ private final RateLimiter captchaLimiter = mock(RateLimiter.class);
+ private final RateLimiter pushChallengeLimiter = mock(RateLimiter.class);
+
+ private final ResourceExtension resources = ResourceExtension.builder()
+ .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
+ .addProvider(new RateLimitExceededExceptionMapper())
+ .addProvider(new ImpossiblePhoneNumberExceptionMapper())
+ .addProvider(new NonNormalizedPhoneNumberExceptionMapper())
+ .addProvider(new RegistrationServiceSenderExceptionMapper())
+ .setMapper(SystemMapper.getMapper())
+ .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
+ .addResource(
+ new VerificationController(registrationServiceClient, verificationSessionManager, pushNotificationManager,
+ registrationCaptchaManager, registrationRecoveryPasswordsManager, rateLimiters, clock))
+ .build();
+
+ @BeforeEach
+ void setUp() {
+ when(rateLimiters.getVerificationCaptchaLimiter())
+ .thenReturn(captchaLimiter);
+ when(rateLimiters.getVerificationPushChallengeLimiter())
+ .thenReturn(pushChallengeLimiter);
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void createSessionUnprocessableRequestJson(final String number, final String pushToken, final String pushTokenType) {
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session")
+ .request();
+ try (Response response = request.post(
+ Entity.json(unprocessableCreateSessionJson(number, pushToken, pushTokenType)))) {
+ assertEquals(400, response.getStatus());
+ }
+
+ }
+
+ static Stream createSessionUnprocessableRequestJson() {
+ return Stream.of(
+ Arguments.of("[]", null, null),
+ Arguments.of(String.format("\"%s\"", NUMBER), "some-push-token", "invalid-token-type")
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void createSessionInvalidRequestJson(final String number, final String pushToken, final String pushTokenType) {
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(createSessionJson(number, pushToken, pushTokenType)))) {
+ assertEquals(422, response.getStatus());
+ }
+ }
+
+ static Stream createSessionInvalidRequestJson() {
+ return Stream.of(
+ Arguments.of(null, null, null),
+ Arguments.of("+1800", null, null),
+ Arguments.of(" ", null, null),
+ Arguments.of(NUMBER, null, "fcm"),
+ Arguments.of(NUMBER, "some-push-token", null)
+ );
+ }
+
+ @Test
+ void createSessionRateLimited() {
+ when(registrationServiceClient.createRegistrationSessionSession(any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null, true)));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(createSessionJson(NUMBER, null, null)))) {
+ assertEquals(429, response.getStatus());
+ }
+ }
+
+ @Test
+ void createSessionRegistrationServiceError() {
+ when(registrationServiceClient.createRegistrationSessionSession(any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("expected service error")));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(createSessionJson(NUMBER, null, null)))) {
+ assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, response.getStatus());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void createSessionSuccess(final String pushToken, final String pushTokenType,
+ final List expectedRequestedInformation) {
+ when(registrationServiceClient.createRegistrationSessionSession(any(), any()))
+ .thenReturn(
+ CompletableFuture.completedFuture(
+ new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,
+ SESSION_EXPIRATION_SECONDS)));
+ when(verificationSessionManager.insert(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(createSessionJson(NUMBER, pushToken, pushTokenType)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+ assertEquals(expectedRequestedInformation, verificationSessionResponse.requestedInformation());
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertFalse(verificationSessionResponse.verified());
+ }
+ }
+
+ static Stream createSessionSuccess() {
+ return Stream.of(
+ Arguments.of(null, null, List.of(VerificationSession.Information.CAPTCHA)),
+ Arguments.of("token", "fcm",
+ List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA))
+ );
+ }
+
+ @Test
+ void patchSessionMalformedId() {
+ final String invalidSessionId = "()()()";
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + invalidSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json("{}"))) {
+ assertEquals(HttpStatus.SC_UNPROCESSABLE_ENTITY, response.getStatus());
+ }
+ }
+
+ @Test
+ void patchSessionNotFound() {
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodeSessionId(SESSION_ID))
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json("{}"))) {
+ assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+ }
+ }
+
+ @Test
+ void patchSessionPushToken() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ new VerificationSession(null, List.of(VerificationSession.Information.CAPTCHA), Collections.emptyList(),
+ false, clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, null, "abcde", "fcm")))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),
+ updatedSession.requestedInformation());
+ assertTrue(updatedSession.submittedInformation().isEmpty());
+ assertNotNull(updatedSession.pushChallenge());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),
+ verificationSessionResponse.requestedInformation());
+ }
+ }
+
+ @Test
+ void patchSessionCaptchaRateLimited() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), false,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ doThrow(RateLimitExceededException.class)
+ .when(captchaLimiter).validate(anyString());
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson("captcha", null, null, null)))) {
+ assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void patchSessionPushChallengeRateLimited() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), false,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ doThrow(RateLimitExceededException.class)
+ .when(pushChallengeLimiter).validate(anyString());
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, "challenge", null, null)))) {
+ assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void patchSessionPushChallengeMismatch() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession("challenge", List.of(VerificationSession.Information.PUSH_CHALLENGE),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, "mismatched", null, null)))) {
+ assertEquals(HttpStatus.SC_FORBIDDEN, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertEquals(List.of(
+ VerificationSession.Information.PUSH_CHALLENGE), verificationSessionResponse.requestedInformation());
+ }
+ }
+
+ @Test
+ void patchSessionCaptchaInvalid() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, List.of(VerificationSession.Information.CAPTCHA),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ when(registrationCaptchaManager.assessCaptcha(any(), any()))
+ .thenReturn(Optional.of(AssessmentResult.invalid()));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson("captcha", null, null, null)))) {
+ assertEquals(HttpStatus.SC_FORBIDDEN, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.CAPTCHA),
+ updatedSession.requestedInformation());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertEquals(List.of(
+ VerificationSession.Information.CAPTCHA), verificationSessionResponse.requestedInformation());
+ }
+ }
+
+ @Test
+ void patchSessionPushChallengeAlreadySubmitted() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession("challenge",
+ List.of(VerificationSession.Information.CAPTCHA),
+ List.of(VerificationSession.Information.PUSH_CHALLENGE), false,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, "challenge", null, null)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE),
+ updatedSession.submittedInformation());
+ assertEquals(List.of(VerificationSession.Information.CAPTCHA), updatedSession.requestedInformation());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertEquals(List.of(
+ VerificationSession.Information.CAPTCHA), verificationSessionResponse.requestedInformation());
+ }
+ }
+
+ @Test
+ void patchSessionAlreadyVerified() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ true, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession("challenge", List.of(), List.of(), true, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, "challenge", null, null)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.verified());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+
+ verify(registrationRecoveryPasswordsManager).removeForNumber(registrationServiceSession.number());
+ }
+ }
+
+ @Test
+ void patchSessionPushChallengeSuccess() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession("challenge",
+ List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson(null, "challenge", null, null)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE),
+ updatedSession.submittedInformation());
+ assertTrue(updatedSession.requestedInformation().isEmpty());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void patchSessionCaptchaSuccess() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, List.of(VerificationSession.Information.CAPTCHA),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ when(registrationCaptchaManager.assessCaptcha(any(), any()))
+ .thenReturn(Optional.of(new AssessmentResult(true, "1")));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH", Entity.json(updateSessionJson("captcha", null, null, null)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.CAPTCHA),
+ updatedSession.submittedInformation());
+ assertTrue(updatedSession.requestedInformation().isEmpty());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void patchSessionPushAndCaptchaSuccess() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession("challenge",
+ List.of(VerificationSession.Information.CAPTCHA, VerificationSession.Information.CAPTCHA),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ when(registrationCaptchaManager.assessCaptcha(any(), any()))
+ .thenReturn(Optional.of(new AssessmentResult(true, "1")));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH",
+ Entity.json(updateSessionJson("captcha", "challenge", null, null)))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),
+ updatedSession.submittedInformation());
+ assertTrue(updatedSession.requestedInformation().isEmpty());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void patchSessionTokenUpdatedCaptchaError() throws Exception {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null,
+ List.of(VerificationSession.Information.CAPTCHA),
+ Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ when(registrationCaptchaManager.assessCaptcha(any(), any()))
+ .thenThrow(new IOException("expected service error"));
+
+ when(verificationSessionManager.update(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.method("PATCH",
+ Entity.json(updateSessionJson("captcha", null, "token", "fcm")))) {
+ assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());
+
+ final ArgumentCaptor verificationSessionArgumentCaptor = ArgumentCaptor.forClass(
+ VerificationSession.class);
+
+ verify(verificationSessionManager).update(any(), verificationSessionArgumentCaptor.capture());
+
+ final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();
+ assertTrue(updatedSession.submittedInformation().isEmpty());
+ assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),
+ updatedSession.requestedInformation());
+ assertNotNull(updatedSession.pushChallenge());
+ }
+ }
+
+ @Test
+ void getSessionMalformedId() {
+ final String invalidSessionId = "()()()";
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + invalidSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_UNPROCESSABLE_ENTITY, response.getStatus());
+ }
+ }
+
+ @Test
+ void getSessionNotFound() {
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+ when(verificationSessionManager.findForId(encodeSessionId(SESSION_ID)))
+ .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+ Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodeSessionId(SESSION_ID))
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+ }
+
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
+
+ request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodeSessionId(SESSION_ID))
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+ }
+ }
+
+ @Test
+ void getSessionError() {
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodeSessionId(SESSION_ID))
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());
+ }
+ }
+
+ @Test
+ void getSessionSuccess() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,
+ SESSION_EXPIRATION_SECONDS))));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(VerificationSession.class))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+ }
+ }
+
+ @Test
+ void getSessionSuccessAlreadyVerified() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ true, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(VerificationSession.class))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId)
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.get()) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ verify(registrationRecoveryPasswordsManager).removeForNumber(registrationServiceSession.number());
+ }
+ }
+
+ @Test
+ void requestVerificationCodeAlreadyVerified() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ true, null, null,
+ null, SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+ when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(registrationServiceSession));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("sms", "android")))) {
+ assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.verified());
+ }
+ }
+
+ @Test
+ void requestVerificationCodeNotAllowedInformationRequested() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(new VerificationSession(null, List.of(
+ VerificationSession.Information.CAPTCHA), Collections.emptyList(), false, clock.millis(), clock.millis(),
+ registrationServiceSession.expiration()))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("sms", "ios")))) {
+ assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertEquals(List.of(VerificationSession.Information.CAPTCHA),
+ verificationSessionResponse.requestedInformation());
+ }
+ }
+
+ @Test
+ void requestVerificationCodeNotAllowed() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, null,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(
+ registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), false,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("voice", "android")))) {
+ assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertFalse(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void requestVerificationCodeRateLimitExceeded() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null,
+ null, SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+ when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(
+ new CompletionException(new VerificationSessionRateLimitExceededException(registrationServiceSession,
+ Duration.ofMinutes(1), true))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("sms", "android")))) {
+ assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void requestVerificationCodeSuccess() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null,
+ null, SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+ when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(registrationServiceSession));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("sms", "android")))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void requestVerificationCodeExternalServiceRefused(final boolean expectedPermanent, final String expectedReason,
+ final RegistrationServiceSenderException exception) {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any()))
+ .thenReturn(
+ CompletableFuture.failedFuture(new CompletionException(exception)));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.post(Entity.json(requestVerificationCodeJson("voice", "ios")))) {
+ assertEquals(HttpStatus.SC_BAD_GATEWAY, response.getStatus());
+
+ final Map responseMap = response.readEntity(Map.class);
+
+ assertEquals(expectedReason, responseMap.get("reason"));
+ assertEquals(expectedPermanent, responseMap.get("permanentFailure"));
+ }
+ }
+
+ static Stream requestVerificationCodeExternalServiceRefused() {
+ return Stream.of(
+ Arguments.of(true, "illegalArgument", RegistrationServiceSenderException.illegalArgument(true)),
+ Arguments.of(true, "providerRejected", RegistrationServiceSenderException.rejected(true)),
+ Arguments.of(false, "providerUnavailable", RegistrationServiceSenderException.unknown(false))
+ );
+ }
+
+ @Test
+ void verifyCodeServerError() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(registrationServiceClient.checkVerificationCodeSession(any(), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new CompletionException(new RuntimeException())));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(Entity.json(submitVerificationCodeJson("123456")))) {
+ assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, response.getStatus());
+ }
+ }
+
+ @Test
+ void verifyCodeAlreadyVerified() {
+
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ true, null, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(
+ Entity.json(submitVerificationCodeJson("123456")))) {
+
+ verify(registrationServiceClient).getSession(any(), any());
+ verifyNoMoreInteractions(registrationServiceClient);
+
+ assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+ assertTrue(verificationSessionResponse.verified());
+
+ verify(registrationRecoveryPasswordsManager).removeForNumber(registrationServiceSession.number());
+ }
+ }
+
+ @Test
+ void verifyCodeNoCodeRequested() {
+
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, 0L, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ // There is no explicit indication in the exception that no code has been sent, but we treat all RegistrationServiceExceptions
+ // in which the response has a session object as conflicted state
+ when(registrationServiceClient.checkVerificationCodeSession(any(), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new CompletionException(
+ new RegistrationServiceException(new RegistrationServiceSession(SESSION_ID, NUMBER, false, 0L, null, null,
+ SESSION_EXPIRATION_SECONDS)))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(Entity.json(submitVerificationCodeJson("123456")))) {
+ assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertNotNull(verificationSessionResponse.nextSms());
+ assertNull(verificationSessionResponse.nextVerificationAttempt());
+ }
+ }
+
+ @Test
+ void verifyCodeNoSession() {
+
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ when(registrationServiceClient.checkVerificationCodeSession(any(), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new CompletionException(new RegistrationServiceException(null))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(Entity.json(submitVerificationCodeJson("123456")))) {
+ assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());
+ }
+ }
+
+ @Test
+ void verifyCodeRateLimitExceeded() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+ when(registrationServiceClient.checkVerificationCodeSession(any(), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(
+ new CompletionException(new VerificationSessionRateLimitExceededException(registrationServiceSession,
+ Duration.ofMinutes(1), true))));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(Entity.json(submitVerificationCodeJson("567890")))) {
+ assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.allowedToRequestCode());
+ assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
+ }
+ }
+
+ @Test
+ void verifyCodeSuccess() {
+ final String encodedSessionId = encodeSessionId(SESSION_ID);
+ final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
+ false, null, null, 0L, SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.getSession(any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(registrationServiceSession)));
+ when(verificationSessionManager.findForId(any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ Optional.of(new VerificationSession(null, Collections.emptyList(), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), registrationServiceSession.expiration()))));
+
+ final RegistrationServiceSession verifiedSession = new RegistrationServiceSession(SESSION_ID, NUMBER, true, null,
+ null, 0L,
+ SESSION_EXPIRATION_SECONDS);
+ when(registrationServiceClient.checkVerificationCodeSession(any(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(verifiedSession));
+
+ final Invocation.Builder request = resources.getJerseyTest()
+ .target("/v1/verification/session/" + encodedSessionId + "/code")
+ .request()
+ .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
+ try (Response response = request.put(Entity.json(submitVerificationCodeJson("123456")))) {
+ assertEquals(HttpStatus.SC_OK, response.getStatus());
+
+ final VerificationSessionResponse verificationSessionResponse = response.readEntity(
+ VerificationSessionResponse.class);
+
+ assertTrue(verificationSessionResponse.verified());
+
+ verify(registrationRecoveryPasswordsManager).removeForNumber(verifiedSession.number());
+ }
+ }
+
+ /**
+ * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest}
+ */
+ private static String createSessionJson(final String number, final String pushToken,
+ final String pushTokenType) {
+ return String.format("""
+ {
+ "number": %s,
+ "pushToken": %s,
+ "pushTokenType": %s
+ }
+ """, quoteIfNotNull(number), quoteIfNotNull(pushToken), quoteIfNotNull(pushTokenType));
+ }
+
+ /**
+ * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest}
+ */
+ private static String updateSessionJson(final String captcha, final String pushChallenge, final String pushToken,
+ final String pushTokenType) {
+ return String.format("""
+ {
+ "captcha": %s,
+ "pushChallenge": %s,
+ "pushToken": %s,
+ "pushTokenType": %s
+ }
+ """, quoteIfNotNull(captcha), quoteIfNotNull(pushChallenge), quoteIfNotNull(pushToken),
+ quoteIfNotNull(pushTokenType));
+ }
+
+ /**
+ * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.VerificationCodeRequest}
+ */
+ private static String requestVerificationCodeJson(final String transport, final String client) {
+ return String.format("""
+ {
+ "transport": "%s",
+ "client": "%s"
+ }
+ """, transport, client);
+ }
+
+ /**
+ * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest}
+ */
+ private static String submitVerificationCodeJson(final String code) {
+ return String.format("""
+ {
+ "code": "%s"
+ }
+ """, code);
+ }
+
+ private static String quoteIfNotNull(final String s) {
+ return s == null ? null : StringUtils.join(new String[]{"\"", "\""}, s);
+ }
+
+ /**
+ * Request JSON that cannot be marshalled into
+ * {@link org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest}
+ */
+ private static String unprocessableCreateSessionJson(final String number, final String pushToken,
+ final String pushTokenType) {
+ return String.format("""
+ {
+ "number": %s,
+ "pushToken": %s,
+ "pushTokenType": %s
+ }
+ """, number, quoteIfNotNull(pushToken), quoteIfNotNull(pushTokenType));
+ }
+
+ private static String encodeSessionId(final byte[] sessionId) {
+ return Base64.getUrlEncoder().encodeToString(sessionId);
+ }
+
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStoreTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStoreTest.java
new file mode 100644
index 000000000..e4d7398a1
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStoreTest.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2013-2021 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
+import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
+
+class SerializedExpireableJsonDynamoStoreTest {
+
+ static abstract class Tests {
+
+ private static final String TABLE_NAME = "test";
+ private static final String KEY = "foo";
+
+ static final Clock clock = Clock.systemUTC();
+
+ interface Value {
+
+ String v();
+ }
+
+ @RegisterExtension
+ static final DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder()
+ .tableName(TABLE_NAME)
+ .hashKey(SerializedExpireableJsonDynamoStore.KEY_KEY)
+ .attributeDefinition(AttributeDefinition.builder()
+ .attributeName(SerializedExpireableJsonDynamoStore.KEY_KEY)
+ .attributeType(ScalarAttributeType.S)
+ .build())
+ .build();
+
+ private SerializedExpireableJsonDynamoStore store;
+
+ abstract SerializedExpireableJsonDynamoStore getStore(final DynamoDbAsyncClient dynamoDbClient,
+ final String tableName);
+
+ abstract T testValue(final String v);
+
+ abstract T maybeExpiredTestValue(final String v);
+
+ @BeforeEach
+ void setUp() {
+ store = getStore(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), TABLE_NAME);
+ }
+
+ @Test
+ void testStoreAndFind() throws Exception {
+ assertEquals(Optional.empty(), store.findForKey(KEY).get(1, TimeUnit.SECONDS));
+
+ final T original = testValue("1234");
+ final T second = testValue("5678");
+
+ store.insert(KEY, original).get(1, TimeUnit.SECONDS);
+ {
+ final Optional maybeValue = store.findForKey(KEY).get(1, TimeUnit.SECONDS);
+
+ assertTrue(maybeValue.isPresent());
+ assertEquals(original, maybeValue.get());
+ }
+
+ assertThrows(Exception.class, () -> store.insert(KEY, second).get(1, TimeUnit.SECONDS));
+
+ assertDoesNotThrow(() -> store.update(KEY, second).get(1, TimeUnit.SECONDS));
+ {
+ final Optional maybeValue = store.findForKey(KEY).get(1, TimeUnit.SECONDS);
+
+ assertTrue(maybeValue.isPresent());
+ assertEquals(second, maybeValue.get());
+ }
+ }
+
+ @Test
+ void testRemove() throws Exception {
+ assertEquals(Optional.empty(), store.findForKey(KEY).get(1, TimeUnit.SECONDS));
+
+ store.insert(KEY, testValue("1234")).get(1, TimeUnit.SECONDS);
+ assertTrue(store.findForKey(KEY).get(1, TimeUnit.SECONDS).isPresent());
+
+ store.remove(KEY).get(1, TimeUnit.SECONDS);
+ assertFalse(store.findForKey(KEY).get(1, TimeUnit.SECONDS).isPresent());
+
+ final T v = maybeExpiredTestValue("1234");
+ store.insert(KEY, v).get(1, TimeUnit.SECONDS);
+
+ assertEquals(v instanceof SerializedExpireableJsonDynamoStore.Expireable,
+ store.findForKey(KEY).get(1, TimeUnit.SECONDS).isEmpty());
+ }
+
+ }
+
+ record Expires(String v, long timestamp) implements SerializedExpireableJsonDynamoStore.Expireable, Tests.Value {
+
+ static final Duration EXPIRATION = Duration.ofSeconds(30);
+
+ @Override
+ public long getExpirationEpochSeconds() {
+ return Instant.ofEpochMilli(timestamp()).plus(EXPIRATION).getEpochSecond();
+ }
+ }
+
+ @Nested
+ class Expireable extends Tests {
+
+ class ExpiresStore extends SerializedExpireableJsonDynamoStore {
+
+ public ExpiresStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName) {
+ super(dynamoDbClient, tableName, clock);
+ }
+ }
+
+ private static final long VALID_TIMESTAMP = Instant.now().toEpochMilli();
+ private static final long EXPIRED_TIMESTAMP = Instant.now().minus(Expires.EXPIRATION).minus(
+ Duration.ofHours(1)).toEpochMilli();
+
+ @Override
+ SerializedExpireableJsonDynamoStore getStore(final DynamoDbAsyncClient dynamoDbClient,
+ final String tableName) {
+ return new ExpiresStore(dynamoDbClient, tableName);
+ }
+
+ @Override
+ Expires testValue(final String v) {
+ return new Expires(v, VALID_TIMESTAMP);
+ }
+
+ @Override
+ Expires maybeExpiredTestValue(final String v) {
+ return new Expires(v, EXPIRED_TIMESTAMP);
+ }
+ }
+
+ record DoesNotExpire(String v) implements Tests.Value {
+
+ }
+
+
+ @Nested
+ class NotExpireable extends Tests {
+
+ class DoesNotExpireStore extends SerializedExpireableJsonDynamoStore {
+
+ public DoesNotExpireStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName) {
+ super(dynamoDbClient, tableName, clock);
+ }
+ }
+
+ @Override
+ SerializedExpireableJsonDynamoStore getStore(final DynamoDbAsyncClient dynamoDbClient,
+ final String tableName) {
+ return new DoesNotExpireStore(dynamoDbClient, tableName);
+ }
+
+ @Override
+ DoesNotExpire testValue(final String v) {
+ return new DoesNotExpire(v);
+ }
+
+ @Override
+ DoesNotExpire maybeExpiredTestValue(final String v) {
+ return new DoesNotExpire(v);
+ }
+ }
+
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java
index 6728fde95..3e541ba35 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java
@@ -53,8 +53,10 @@ class VerificationCodeStoreTest {
void testStoreAndFind() {
assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER));
- final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8));
- final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh", "changed-session".getBytes(StandardCharsets.UTF_8));
+ final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd",
+ "session".getBytes(StandardCharsets.UTF_8));
+ final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh",
+ "changed-session".getBytes(StandardCharsets.UTF_8));
verificationCodeStore.insert(PHONE_NUMBER, originalCode);
{
@@ -77,13 +79,15 @@ class VerificationCodeStoreTest {
void testRemove() {
assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER));
- verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8)));
+ verificationCodeStore.insert(PHONE_NUMBER,
+ new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8)));
assertTrue(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
verificationCodeStore.remove(PHONE_NUMBER);
assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
- verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8)));
+ verificationCodeStore.insert(PHONE_NUMBER,
+ new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8)));
assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent());
}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationSessionsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationSessionsTest.java
new file mode 100644
index 000000000..047d550ef
--- /dev/null
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationSessionsTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.textsecuregcm.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletionException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.whispersystems.textsecuregcm.registration.VerificationSession;
+import org.whispersystems.textsecuregcm.util.ExceptionUtils;
+import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
+import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
+import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
+
+class VerificationSessionsTest {
+
+ private static final String TABLE_NAME = "verification_sessions_test";
+
+ private static final Clock clock = Clock.systemUTC();
+
+ @RegisterExtension
+ static final DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder()
+ .tableName(TABLE_NAME)
+ .hashKey(VerificationSessions.KEY_KEY)
+ .attributeDefinition(AttributeDefinition.builder()
+ .attributeName(VerificationSessions.KEY_KEY)
+ .attributeType(ScalarAttributeType.S)
+ .build())
+ .build();
+
+ private VerificationSessions verificationSessions;
+
+ @BeforeEach
+ void setUp() {
+ verificationSessions = new VerificationSessions(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), TABLE_NAME, clock);
+ }
+
+ @Test
+ void testExpiration() {
+ final Instant created = Instant.now().minusSeconds(60);
+ final Instant updates = Instant.now();
+ final Duration remoteExpiration = Duration.ofMinutes(2);
+
+ final VerificationSession verificationSession = new VerificationSession(null,
+ List.of(VerificationSession.Information.PUSH_CHALLENGE), Collections.emptyList(), true,
+ created.toEpochMilli(), updates.toEpochMilli(), remoteExpiration.toSeconds());
+
+ assertEquals(updates.plus(remoteExpiration).getEpochSecond(), verificationSession.getExpirationEpochSeconds());
+ }
+
+ @Test
+ void testStore() {
+
+ assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
+
+ final String sessionId = "sessionId";
+
+ final Optional absentSession = verificationSessions.findForKey(sessionId).join();
+ assertTrue(absentSession.isEmpty());
+
+ final VerificationSession session = new VerificationSession(null,
+ List.of(VerificationSession.Information.PUSH_CHALLENGE), Collections.emptyList(), true,
+ clock.millis(), clock.millis(), Duration.ofMinutes(1).toSeconds());
+
+ verificationSessions.insert(sessionId, session).join();
+
+ assertEquals(session, verificationSessions.findForKey(sessionId).join().orElseThrow());
+
+ final CompletionException ce = assertThrows(CompletionException.class,
+ () -> verificationSessions.insert(sessionId, session).join());
+
+ final Throwable t = ExceptionUtils.unwrap(ce);
+ assertTrue(t instanceof ConditionalCheckFailedException,
+ "inserting with the same key should fail conditional checks");
+
+ final VerificationSession updatedSession = new VerificationSession(null, Collections.emptyList(),
+ List.of(VerificationSession.Information.PUSH_CHALLENGE), true, clock.millis(), clock.millis(),
+ Duration.ofMinutes(2).toSeconds());
+ verificationSessions.update(sessionId, updatedSession).join();
+
+ assertEquals(updatedSession, verificationSessions.findForKey(sessionId).join().orElseThrow());
+ });
+ }
+
+}
diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java
index 4b910eb38..c887cbd86 100644
--- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java
+++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java
@@ -25,7 +25,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
-import java.util.stream.Stream;
import javax.ws.rs.Path;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.MediaType;
@@ -36,8 +35,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;