Add /v1/verification

This commit is contained in:
Chris Eager
2023-02-22 14:27:05 -06:00
committed by GitHub
parent e1ea3795bb
commit 35286f838e
37 changed files with 3255 additions and 177 deletions

View File

@@ -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<WhisperServerConfiguration
dynamoDbAsyncClient
);
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbAsyncClient,
config.getDynamoDbTables().getVerificationSessions().getTableName(), clock);
reactor.util.Metrics.MicrometerConfiguration.useRegistry(Metrics.globalRegistry);
Schedulers.enableMetrics();
@@ -632,11 +639,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.lifecycle().manage(directoryQueue);
environment.lifecycle().manage(registrationServiceClient);
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker,
rateLimiters, config.getTestDevices(), dynamicConfigurationManager);
StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider
.create(AwsBasicCredentials.create(
config.getCdnConfiguration().getAccessKey(),
config.getCdnConfiguration().getAccessSecret()));
S3Client cdnS3Client = S3Client.builder()
S3Client cdnS3Client = S3Client.builder()
.credentialsProvider(cdnCredentialsProvider)
.region(Region.of(config.getCdnConfiguration().getRegion()))
.build();
@@ -687,9 +697,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
// these should be common, but use @Auth DisabledPermittedAccount, which isnt supported yet on websocket
environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, rateLimiters,
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
captchaChecker, pushNotificationManager, changeNumberManager, registrationLockVerificationManager,
registrationRecoveryPasswordsManager, usernameHashZkProofVerifier, clock));
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator,
registrationCaptchaManager, pushNotificationManager, changeNumberManager,
registrationLockVerificationManager, registrationRecoveryPasswordsManager, usernameHashZkProofVerifier, clock));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
@@ -769,7 +779,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new SecureValueRecovery2Controller(svr2CredentialsGenerator),
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
config.getCdnConfiguration().getBucket())
config.getCdnConfiguration().getBucket()),
new VerificationController(registrationServiceClient, new VerificationSessionManager(verificationSessions),
pushNotificationManager, registrationCaptchaManager, registrationRecoveryPasswordsManager, rateLimiters,
clock)
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
@@ -846,6 +859,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ServerRejectedExceptionMapper(),
new ImpossiblePhoneNumberExceptionMapper(),
new NonNormalizedPhoneNumberExceptionMapper(),
new RegistrationServiceSenderExceptionMapper(),
new JsonMappingExceptionMapper()
).forEach(exceptionMapper -> {
environment.jersey().register(exceptionMapper);

View File

@@ -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 sessionIds number
* @throws BadRequestException if the number does not match the sessionIds 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);
}

View File

@@ -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);

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import static com.codahale.metrics.MetricRegistry.name;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Util;
public class RegistrationCaptchaManager {
private static final Logger logger = LoggerFactory.getLogger(RegistrationCaptchaManager.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter countryFilteredHostMeter = metricRegistry.meter(
name(AccountController.class, "country_limited_host"));
private final Meter rateLimitedHostMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_host"));
private final Meter rateLimitedPrefixMeter = metricRegistry.meter(
name(AccountController.class, "rate_limited_prefix"));
private final CaptchaChecker captchaChecker;
private final RateLimiters rateLimiters;
private final Map<String, Integer> testDevices;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
public RegistrationCaptchaManager(final CaptchaChecker captchaChecker, final RateLimiters rateLimiters,
final Map<String, Integer> testDevices,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.captchaChecker = captchaChecker;
this.rateLimiters = rateLimiters;
this.testDevices = testDevices;
this.dynamicConfigurationManager = dynamicConfigurationManager;
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public Optional<AssessmentResult> assessCaptcha(final Optional<String> captcha, final String sourceHost)
throws IOException {
return captcha.isPresent()
? Optional.of(captchaChecker.verify(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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<DynamicConfiguration> dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> 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<DynamicConfiguration> dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> 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> assessmentResult = captcha.isPresent()
? Optional.of(captchaChecker.verify(captcha.get(), sourceHost))
: Optional.empty();
final Optional<AssessmentResult> 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")

View File

@@ -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<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(
request.getUpdateVerificationSessionRequest());
final Phonenumber.PhoneNumber phoneNumber;
try {
phoneNumber = PhoneNumberUtil.getInstance().parse(request.getNumber(), null);
} catch (final NumberParseException e) {
throw new ServerErrorException("could not parse already validated number", Response.Status.INTERNAL_SERVER_ERROR);
}
final RegistrationServiceSession registrationServiceSession;
try {
registrationServiceSession = registrationServiceClient.createRegistrationSessionSession(phoneNumber,
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<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(
updateVerificationSessionRequest);
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
try {
// these handle* methods ordered from least likely to fail to most, so take care when considering a change
verificationSession = handlePushToken(pushTokenAndType, verificationSession);
verificationSession = handlePushChallenge(updateVerificationSessionRequest, registrationServiceSession,
verificationSession);
verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession,
verificationSession, userAgent);
} catch (final RateLimitExceededException e) {
final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession,
e.getRetryDuration());
throw new ClientErrorException(response);
} catch (final ForbiddenException e) {
throw new ClientErrorException(Response.status(Response.Status.FORBIDDEN)
.entity(buildResponse(registrationServiceSession, verificationSession))
.build());
} finally {
// Each of the handle* methods may update requestedInformation, submittedInformation, and allowedToRequestCode,
// and we want to be sure to store a changes, even if a later method throws
updateStoredVerificationSession(registrationServiceSession, verificationSession);
}
return buildResponse(registrationServiceSession, verificationSession);
}
private void storeVerificationSession(final RegistrationServiceSession registrationServiceSession,
final VerificationSession verificationSession) {
verificationSessionManager.insert(registrationServiceSession.encodedSessionId(), verificationSession)
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
.join();
}
private void updateStoredVerificationSession(final RegistrationServiceSession registrationServiceSession,
final VerificationSession verificationSession) {
verificationSessionManager.update(registrationServiceSession.encodedSessionId(), verificationSession)
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
.join();
}
/**
* If {@code pushTokenAndType} values are not {@code null}, sends a push challenge. If there is no existing push
* challenge in the session, one will be created, set on the returned session record, and
* {@link VerificationSession#requestedInformation()} will be updated.
*/
private VerificationSession handlePushToken(
final Pair<String, PushNotification.TokenType> pushTokenAndType, VerificationSession verificationSession) {
if (pushTokenAndType.first() != null) {
if (verificationSession.pushChallenge() == null) {
final List<VerificationSession.Information> requestedInformation = new ArrayList<>();
requestedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);
requestedInformation.addAll(verificationSession.requestedInformation());
verificationSession = new VerificationSession(generatePushChallenge(), requestedInformation,
verificationSession.submittedInformation(), verificationSession.allowedToRequestCode(),
verificationSession.createdTimestamp(), clock.millis(), verificationSession.remoteExpirationSeconds()
);
}
pushNotificationManager.sendRegistrationChallengeNotification(pushTokenAndType.first(), pushTokenAndType.second(),
verificationSession.pushChallenge());
}
return verificationSession;
}
/**
* If a push challenge value is present, compares against the stored value. If they match, then
* {@link VerificationSession.Information#PUSH_CHALLENGE} is removed from requested information, added to submitted
* information, and {@link VerificationSession#allowedToRequestCode()} is re-evaluated.
*
* @throws ForbiddenException if values to not match.
* @throws RateLimitExceededException if too many push challenges have been submitted
*/
private VerificationSession handlePushChallenge(
final UpdateVerificationSessionRequest updateVerificationSessionRequest,
final RegistrationServiceSession registrationServiceSession,
VerificationSession verificationSession) throws RateLimitExceededException {
if (verificationSession.submittedInformation()
.contains(VerificationSession.Information.PUSH_CHALLENGE)) {
// skip if a challenge has already been submitted
return verificationSession;
}
final boolean pushChallengePresent = updateVerificationSessionRequest.pushChallenge() != null;
if (pushChallengePresent) {
RateLimiter.adaptLegacyException(
() -> rateLimiters.getVerificationPushChallengeLimiter()
.validate(registrationServiceSession.encodedSessionId()));
}
final boolean pushChallengeMatches;
if (pushChallengePresent && verificationSession.pushChallenge() != null) {
pushChallengeMatches = MessageDigest.isEqual(
updateVerificationSessionRequest.pushChallenge().getBytes(StandardCharsets.UTF_8),
verificationSession.pushChallenge().getBytes(StandardCharsets.UTF_8));
} else {
pushChallengeMatches = false;
}
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,
COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number()),
REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number()),
CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallengePresent),
CHALLENGE_MATCH_TAG_NAME, Boolean.toString(pushChallengeMatches))
.increment();
if (pushChallengeMatches) {
final List<VerificationSession.Information> submittedInformation = new ArrayList<>(
verificationSession.submittedInformation());
submittedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);
final List<VerificationSession.Information> requestedInformation = new ArrayList<>(
verificationSession.requestedInformation());
// a push challenge satisfies a requested captcha
requestedInformation.remove(VerificationSession.Information.CAPTCHA);
final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()
|| requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE))
&& requestedInformation.isEmpty();
verificationSession = new VerificationSession(verificationSession.pushChallenge(), requestedInformation,
submittedInformation, allowedToRequestCode, verificationSession.createdTimestamp(), clock.millis(),
verificationSession.remoteExpirationSeconds());
} else if (pushChallengePresent) {
throw new ForbiddenException();
}
return verificationSession;
}
/**
* If a captcha value is present, it is assessed. If it is valid, then {@link VerificationSession.Information#CAPTCHA}
* is removed from requested information, added to submitted information, and
* {@link VerificationSession#allowedToRequestCode()} is re-evaluated.
*
* @throws ForbiddenException if assessment is not valid.
* @throws RateLimitExceededException if too many captchas have been submitted
*/
private VerificationSession handleCaptcha(final String sourceHost,
final UpdateVerificationSessionRequest updateVerificationSessionRequest,
final RegistrationServiceSession registrationServiceSession,
VerificationSession verificationSession,
final String userAgent) 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<VerificationSession.Information> submittedInformation = new ArrayList<>(
verificationSession.submittedInformation());
submittedInformation.add(VerificationSession.Information.CAPTCHA);
final List<VerificationSession.Information> requestedInformation = new ArrayList<>(
verificationSession.requestedInformation());
// a captcha satisfies a push challenge, in case of push deliverability issues
requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE);
final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()
|| requestedInformation.remove(VerificationSession.Information.CAPTCHA))
&& requestedInformation.isEmpty();
verificationSession = new VerificationSession(verificationSession.pushChallenge(), requestedInformation,
submittedInformation, allowedToRequestCode, verificationSession.createdTimestamp(), clock.millis(),
verificationSession.remoteExpirationSeconds());
} else {
throw new ForbiddenException();
}
return verificationSession;
}
@Timed
@GET
@Path("/session/{sessionId}")
@Produces(MediaType.APPLICATION_JSON)
public VerificationSessionResponse getSession(@PathParam("sessionId") final String encodedSessionId) {
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
return buildResponse(registrationServiceSession, verificationSession);
}
@Timed
@POST
@Path("/session/{sessionId}/code")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public VerificationSessionResponse requestVerificationCode(@PathParam("sessionId") final String encodedSessionId,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage,
@NotNull @Valid VerificationCodeRequest verificationCodeRequest) throws Throwable {
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
if (registrationServiceSession.verified()) {
throw new ClientErrorException(
Response.status(Response.Status.CONFLICT)
.entity(buildResponse(registrationServiceSession, verificationSession))
.build());
}
if (!verificationSession.allowedToRequestCode()) {
final Response.Status status = verificationSession.requestedInformation().isEmpty()
? Response.Status.TOO_MANY_REQUESTS
: Response.Status.CONFLICT;
throw new ClientErrorException(
Response.status(status)
.entity(buildResponse(registrationServiceSession, verificationSession))
.build());
}
final MessageTransport messageTransport = verificationCodeRequest.transport().toMessageTransport();
final ClientType clientType = switch (verificationCodeRequest.client()) {
case "ios" -> ClientType.IOS;
case "android-2021-03" -> ClientType.ANDROID_WITH_FCM;
default -> {
if (StringUtils.startsWithIgnoreCase(verificationCodeRequest.client(), "android")) {
yield ClientType.ANDROID_WITHOUT_FCM;
}
yield ClientType.UNKNOWN;
}
};
final RegistrationServiceSession resultSession;
try {
resultSession = registrationServiceClient.sendVerificationCode(registrationServiceSession.id(),
messageTransport,
clientType,
acceptLanguage.orElse(null), REGISTRATION_RPC_TIMEOUT).join();
} catch (final CancellationException e) {
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),
ve.getRetryDuration());
throw new ClientErrorException(response);
}
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null), false);
} else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {
throw registrationServiceException.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> new ClientErrorException(
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
.orElseGet(NotFoundException::new);
} else if (unwrappedException instanceof RegistrationServiceSenderException) {
throw unwrappedException;
} else {
logger.error("Registration service failure", unwrappedException);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
Metrics.counter(CODE_REQUESTED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, verificationCodeRequest.transport().toString())))
.increment();
return buildResponse(resultSession, verificationSession);
}
@Timed
@PUT
@Path("/session/{sessionId}/code")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public VerificationSessionResponse verifyCode(@PathParam("sessionId") final String encodedSessionId,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest)
throws RateLimitExceededException {
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
if (registrationServiceSession.verified()) {
final VerificationSessionResponse verificationSessionResponse = buildResponse(registrationServiceSession,
verificationSession);
throw new ClientErrorException(
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build());
}
final RegistrationServiceSession resultSession;
try {
resultSession = registrationServiceClient.checkVerificationCodeSession(registrationServiceSession.id(),
submitVerificationCodeRequest.code(),
REGISTRATION_RPC_TIMEOUT)
.join();
} catch (final CancellationException e) {
logger.warn("Unexpected cancellation from registration service", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),
ve.getRetryDuration());
throw new ClientErrorException(response);
}
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null), false);
} else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {
throw registrationServiceException.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> new ClientErrorException(
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
.orElseGet(NotFoundException::new);
} else {
logger.error("Registration service failure", unwrappedException);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
if (resultSession.verified()) {
registrationRecoveryPasswordsManager.removeForNumber(registrationServiceSession.number());
}
Metrics.counter(VERIFIED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
Tag.of(SUCCESS_TAG_NAME, Boolean.toString(resultSession.verified()))))
.increment();
return buildResponse(resultSession, verificationSession);
}
private Response buildResponseForRateLimitExceeded(final VerificationSession verificationSession,
final RegistrationServiceSession registrationServiceSession,
final Optional<Duration> retryDuration) {
final Response.ResponseBuilder responseBuilder = Response.status(Response.Status.TOO_MANY_REQUESTS)
.entity(buildResponse(registrationServiceSession, verificationSession));
retryDuration
.filter(d -> !d.isNegative())
.ifPresent(d -> responseBuilder.header(HttpHeaders.RETRY_AFTER, d.toSeconds()));
return responseBuilder.build();
}
/**
* @throws ClientErrorException with {@code 422} status if the ID cannot be decoded
* @throws javax.ws.rs.NotFoundException if the ID cannot be found
*/
private RegistrationServiceSession retrieveRegistrationServiceSession(final String encodedSessionId) {
final byte[] sessionId;
try {
sessionId = decodeSessionId(encodedSessionId);
} catch (final IllegalArgumentException e) {
throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY);
}
try {
final RegistrationServiceSession registrationServiceSession = registrationServiceClient.getSession(sessionId,
REGISTRATION_RPC_TIMEOUT).join()
.orElseThrow(NotFoundException::new);
if (registrationServiceSession.verified()) {
registrationRecoveryPasswordsManager.removeForNumber(registrationServiceSession.number());
}
return registrationServiceSession;
} catch (final CompletionException | CancellationException e) {
final Throwable unwrapped = ExceptionUtils.unwrap(e);
if (unwrapped.getCause() instanceof StatusRuntimeException grpcRuntimeException) {
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
}
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
}
}
/**
* @throws NotFoundException if the session is has no record
*/
private VerificationSession retrieveVerificationSession(final RegistrationServiceSession registrationServiceSession) {
return verificationSessionManager.findForId(registrationServiceSession.encodedSessionId())
.orTimeout(5, TimeUnit.SECONDS)
.join().orElseThrow(NotFoundException::new);
}
/**
* @throws ClientErrorException with {@code 422} status if the only one of token and type are present
*/
private Pair<String, PushNotification.TokenType> validateAndExtractPushToken(
final UpdateVerificationSessionRequest request) {
final String pushToken;
final PushNotification.TokenType pushTokenType;
if (Objects.isNull(request.pushToken())
!= Objects.isNull(request.pushTokenType())) {
throw new WebApplicationException("must specify both pushToken and pushTokenType or neither",
HttpStatus.SC_UNPROCESSABLE_ENTITY);
} else {
pushToken = request.pushToken();
pushTokenType = pushToken == null
? null
: request.pushTokenType().toTokenType();
}
return new Pair<>(pushToken, pushTokenType);
}
private VerificationSessionResponse buildResponse(final RegistrationServiceSession registrationServiceSession,
final VerificationSession verificationSession) {
return new VerificationSessionResponse(registrationServiceSession.encodedSessionId(),
registrationServiceSession.nextSms(),
registrationServiceSession.nextVoiceCall(), registrationServiceSession.nextVerificationAttempt(),
verificationSession.allowedToRequestCode(), verificationSession.requestedInformation(),
registrationServiceSession.verified());
}
public static byte[] decodeSessionId(final String sessionId) {
return Base64.getUrlDecoder().decode(sessionId);
}
private static String generatePushChallenge() {
final byte[] challenge = new byte[16];
RANDOM.nextBytes(challenge);
return HexFormat.of().formatHex(challenge);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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) {
}

View File

@@ -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;
};
}
}
}

View File

@@ -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;
};
}
}
}

View File

@@ -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<VerificationSession.Information> requestedInformation,
boolean verified) {
}

View File

@@ -150,4 +150,5 @@ public class RateLimiter {
void validate() throws RateLimitExceededException;
}
}

View File

@@ -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;
}

View File

@@ -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<RegistrationServiceSenderException> {
@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) {
}
}

View File

@@ -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<byte[]> 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<RegistrationServiceSession> 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<byte[]> sendRegistrationCode(final byte[] sessionId,
@Deprecated
public CompletableFuture<byte[]> createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber,
final Duration timeout) {
return createRegistrationSessionSession(phoneNumber, timeout)
.thenApply(RegistrationServiceSession::id);
}
public CompletableFuture<RegistrationServiceSession> 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<byte[]> 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<Boolean> checkVerificationCode(final byte[] sessionId,
final String verificationCode,
final Duration timeout) {
return checkVerificationCodeSession(sessionId, verificationCode, timeout)
.thenApply(RegistrationServiceSession::verified);
}
public CompletableFuture<RegistrationServiceSession> 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<Optional<RegistrationSession>> getSession(final byte[] sessionId,
public CompletableFuture<Optional<RegistrationServiceSession>> 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);
}

View File

@@ -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<RegistrationServiceSession> getRegistrationSession() {
return Optional.ofNullable(registrationServiceSession);
}
}

View File

@@ -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
}
}

View File

@@ -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<Information> requestedInformation, List<Information> 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
}
}

View File

@@ -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<T> {
public interface Expireable {
@JsonIgnore
long getExpirationEpochSeconds();
}
private final DynamoDbAsyncClient dynamoDbClient;
private final String tableName;
private final Clock clock;
private final Class<T> 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<T>) actualTypeArguments[0];
} else {
throw new RuntimeException(
"Unable to determine target class for deserialization - generic superclass is not a ParameterizedType");
}
}
public CompletableFuture<Void> 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<Void> update(final String key, final T v) {
return put(key, v, ignored -> {
});
}
private CompletableFuture<Void> put(final String key, final T v,
final Consumer<PutItemRequest.Builder> putRequestCustomizer) {
try {
final Map<String, AttributeValue> 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<Optional<T>> 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<T> 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<Void> remove(final String key) {
return dynamoDbClient.deleteItem(DeleteItemRequest.builder()
.tableName(tableName)
.key(Map.of(KEY_KEY, AttributeValues.fromString(key)))
.build())
.thenRun(() -> {
});
}
}

View File

@@ -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<Void> insert(final String encodedSessionId, final VerificationSession verificationSession) {
return verificationSessions.insert(encodedSessionId, verificationSession);
}
public CompletableFuture<Void> update(final String encodedSessionId, final VerificationSession verificationSession) {
return verificationSessions.update(encodedSessionId, verificationSession);
}
public CompletableFuture<Optional<VerificationSession>> findForId(final String encodedSessionId) {
return verificationSessions.findForKey(encodedSessionId);
}
}

View File

@@ -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<VerificationSession> {
public VerificationSessions(final DynamoDbAsyncClient dynamoDbClient, final String tableName, final Clock clock) {
super(dynamoDbClient, tableName, clock);
}
}