Add `/v1/registration`

This commit is contained in:
Chris Eager 2023-02-06 16:11:59 -06:00 committed by GitHub
parent 358a286523
commit a4a45de161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 872 additions and 126 deletions

View File

@ -76,6 +76,7 @@ import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener; import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter;
@ -101,6 +102,7 @@ import org.whispersystems.textsecuregcm.controllers.MessageController;
import org.whispersystems.textsecuregcm.controllers.PaymentsController; import org.whispersystems.textsecuregcm.controllers.PaymentsController;
import org.whispersystems.textsecuregcm.controllers.ProfileController; import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.ProvisioningController; import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
import org.whispersystems.textsecuregcm.controllers.RegistrationController;
import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; import org.whispersystems.textsecuregcm.controllers.RemoteConfigController;
import org.whispersystems.textsecuregcm.controllers.SecureBackupController; import org.whispersystems.textsecuregcm.controllers.SecureBackupController;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController; import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
@ -518,6 +520,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
SubscriptionManager subscriptionManager = new SubscriptionManager( SubscriptionManager subscriptionManager = new SubscriptionManager(
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient); config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);
final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters);
ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(accountsManager); ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(accountsManager);
reportMessageManager.addListener(reportedMessageMetricsListener); reportMessageManager.addListener(reportedMessageMetricsListener);
@ -673,8 +678,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register( environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, rateLimiters, new AccountController(pendingAccountsManager, accountsManager, rateLimiters,
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(), registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(),
captchaChecker, pushNotificationManager, changeNumberManager, registrationRecoveryPasswordsManager, backupCredentialsGenerator, captchaChecker, pushNotificationManager, changeNumberManager, registrationLockVerificationManager,
clientPresenceManager, clock)); registrationRecoveryPasswordsManager, clock));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager)); environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));
@ -741,6 +746,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor), config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager), new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, registrationServiceClient, registrationLockVerificationManager,
rateLimiters),
new RemoteConfigController(remoteConfigsManager, adminEventLogger, new RemoteConfigController(remoteConfigsManager, adminEventLogger,
config.getRemoteConfigConfiguration().getAuthorizedTokens(), config.getRemoteConfigConfiguration().getAuthorizedTokens(),
config.getRemoteConfigConfiguration().getGlobalConfig()), config.getRemoteConfigConfiguration().getGlobalConfig()),

View File

@ -0,0 +1,103 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Util;
public class RegistrationLockVerificationManager {
@VisibleForTesting
public static final int FAILURE_HTTP_STATUS = 423;
private static final String LOCKED_ACCOUNT_COUNTER_NAME =
name(RegistrationLockVerificationManager.class, "lockedAccount");
private static final String LOCK_REASON_TAG_NAME = "lockReason";
private static final String ALREADY_LOCKED_TAG_NAME = "alreadyLocked";
private final AccountsManager accounts;
private final ClientPresenceManager clientPresenceManager;
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
private final RateLimiters rateLimiters;
public RegistrationLockVerificationManager(
final AccountsManager accounts, final ClientPresenceManager clientPresenceManager,
final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator, final RateLimiters rateLimiters) {
this.accounts = accounts;
this.clientPresenceManager = clientPresenceManager;
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
this.rateLimiters = rateLimiters;
}
/**
* Verifies the given registration lock credentials against the accounts current registration lock, if any
*
* @param account
* @param clientRegistrationLock
* @throws RateLimitExceededException
* @throws WebApplicationException
*/
public void verifyRegistrationLock(final Account account, @Nullable final String clientRegistrationLock)
throws RateLimitExceededException, WebApplicationException {
final StoredRegistrationLock existingRegistrationLock = account.getRegistrationLock();
final ExternalServiceCredentials existingBackupCredentials =
backupServiceCredentialGenerator.generateForUuid(account.getUuid());
if (!existingRegistrationLock.requiresClientRegistrationLock()) {
return;
}
if (!Util.isEmpty(clientRegistrationLock)) {
rateLimiters.getPinLimiter().validate(account.getNumber());
}
final String phoneNumber = account.getNumber();
if (!existingRegistrationLock.verify(clientRegistrationLock)) {
// At this point, the client verified ownership of the phone number but doesnt have the reglock PIN.
// Freezing the existing account credentials will definitively start the reglock timeout.
// Until the timeout, the current reglock can still be supplied,
// along with phone number verification, to restore access.
/*
boolean alreadyLocked = existingAccount.hasLockedCredentials();
Metrics.counter(LOCKED_ACCOUNT_COUNTER_NAME,
LOCK_REASON_TAG_NAME, "verifiedNumberFailedReglock",
ALREADY_LOCKED_TAG_NAME, Boolean.toString(alreadyLocked))
.increment();
final Account updatedAccount;
if (!alreadyLocked) {
updatedAccount = accounts.update(existingAccount, Account::lockAuthenticationCredentials);
} else {
updatedAccount = existingAccount;
}
List<Long> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds);
*/
throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining(),
existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null))
.build());
}
rateLimiters.getPinLimiter().clear(phoneNumber);
}
}

View File

@ -23,15 +23,15 @@ public class RateLimitsConfiguration {
@JsonProperty @JsonProperty
private RateLimitConfiguration smsVoicePrefix = new RateLimitConfiguration(1000, 1000); private RateLimitConfiguration smsVoicePrefix = new RateLimitConfiguration(1000, 1000);
@JsonProperty
private RateLimitConfiguration autoBlock = new RateLimitConfiguration(500, 500);
@JsonProperty @JsonProperty
private RateLimitConfiguration verifyNumber = new RateLimitConfiguration(2, 2); private RateLimitConfiguration verifyNumber = new RateLimitConfiguration(2, 2);
@JsonProperty @JsonProperty
private RateLimitConfiguration verifyPin = new RateLimitConfiguration(10, 1 / (24.0 * 60.0)); private RateLimitConfiguration verifyPin = new RateLimitConfiguration(10, 1 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration registration = new RateLimitConfiguration(2, 2);
@JsonProperty @JsonProperty
private RateLimitConfiguration attachments = new RateLimitConfiguration(50, 50); private RateLimitConfiguration attachments = new RateLimitConfiguration(50, 50);
@ -71,16 +71,9 @@ public class RateLimitsConfiguration {
@JsonProperty @JsonProperty
private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0); private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0);
@JsonProperty
private RateLimitConfiguration stories = new RateLimitConfiguration(10_000, 10_000 / (24.0 * 60.0));
@JsonProperty @JsonProperty
private RateLimitConfiguration backupAuthCheck = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); private RateLimitConfiguration backupAuthCheck = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
public RateLimitConfiguration getAutoBlock() {
return autoBlock;
}
public RateLimitConfiguration getAllocateDevice() { public RateLimitConfiguration getAllocateDevice() {
return allocateDevice; return allocateDevice;
} }
@ -129,6 +122,10 @@ public class RateLimitsConfiguration {
return verifyPin; return verifyPin;
} }
public RateLimitConfiguration getRegistration() {
return registration;
}
public RateLimitConfiguration getTurnAllocations() { public RateLimitConfiguration getTurnAllocations() {
return turnAllocations; return turnAllocations;
} }
@ -161,10 +158,6 @@ public class RateLimitsConfiguration {
return checkAccountExistence; return checkAccountExistence;
} }
public RateLimitConfiguration getStories() {
return stories;
}
public RateLimitConfiguration getBackupAuthCheck() { public RateLimitConfiguration getBackupAuthCheck() {
return backupAuthCheck; return backupAuthCheck;
} }

View File

@ -32,7 +32,6 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
@ -61,10 +60,8 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState; import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
@ -82,7 +79,6 @@ import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices; import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.RegistrationLock; import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse; import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.StaleDevices; import org.whispersystems.textsecuregcm.entities.StaleDevices;
@ -91,7 +87,6 @@ import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotification;
import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.ClientType;
@ -137,7 +132,7 @@ public class AccountController {
.publishPercentiles(0.75, 0.95, 0.99, 0.999) .publishPercentiles(0.75, 0.95, 0.99, 0.999)
.distributionStatisticExpiry(Duration.ofHours(2)) .distributionStatisticExpiry(Duration.ofHours(2))
.register(Metrics.globalRegistry); .register(Metrics.globalRegistry);
private static final String LOCKED_ACCOUNT_COUNTER_NAME = name(AccountController.class, "lockedAccount");
private static final String CHALLENGE_PRESENT_TAG_NAME = "present"; private static final String CHALLENGE_PRESENT_TAG_NAME = "present";
private static final String CHALLENGE_MATCH_TAG_NAME = "matches"; private static final String CHALLENGE_MATCH_TAG_NAME = "matches";
private static final String COUNTRY_CODE_TAG_NAME = "countryCode"; private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
@ -150,25 +145,22 @@ public class AccountController {
private static final String REGION_CODE_TAG_NAME = "regionCode"; private static final String REGION_CODE_TAG_NAME = "regionCode";
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport"; private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
private static final String SCORE_TAG_NAME = "score"; private static final String SCORE_TAG_NAME = "score";
private static final String LOCK_REASON_TAG_NAME = "lockReason";
private static final String ALREADY_LOCKED_TAG_NAME = "alreadyLocked";
private final StoredVerificationCodeManager pendingAccounts; private final StoredVerificationCodeManager pendingAccounts;
private final AccountsManager accounts; private final AccountsManager accounts;
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
private final RegistrationServiceClient registrationServiceClient; private final RegistrationServiceClient registrationServiceClient;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager; private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator; private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> testDevices; private final Map<String, Integer> testDevices;
private final CaptchaChecker captchaChecker; private final CaptchaChecker captchaChecker;
private final PushNotificationManager pushNotificationManager; private final PushNotificationManager pushNotificationManager;
private final ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator; private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final ChangeNumberManager changeNumberManager; private final ChangeNumberManager changeNumberManager;
private final Clock clock; private final Clock clock;
private final ClientPresenceManager clientPresenceManager;
@VisibleForTesting @VisibleForTesting
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
@ -184,9 +176,8 @@ public class AccountController {
CaptchaChecker captchaChecker, CaptchaChecker captchaChecker,
PushNotificationManager pushNotificationManager, PushNotificationManager pushNotificationManager,
ChangeNumberManager changeNumberManager, ChangeNumberManager changeNumberManager,
RegistrationLockVerificationManager registrationLockVerificationManager,
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator,
ClientPresenceManager clientPresenceManager,
Clock clock Clock clock
) { ) {
this.pendingAccounts = pendingAccounts; this.pendingAccounts = pendingAccounts;
@ -198,34 +189,12 @@ public class AccountController {
this.turnTokenGenerator = turnTokenGenerator; this.turnTokenGenerator = turnTokenGenerator;
this.captchaChecker = captchaChecker; this.captchaChecker = captchaChecker;
this.pushNotificationManager = pushNotificationManager; this.pushNotificationManager = pushNotificationManager;
this.backupServiceCredentialsGenerator = backupServiceCredentialsGenerator; this.registrationLockVerificationManager = registrationLockVerificationManager;
this.changeNumberManager = changeNumberManager; this.changeNumberManager = changeNumberManager;
this.clientPresenceManager = clientPresenceManager;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager; this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.clock = clock; this.clock = clock;
} }
@VisibleForTesting
public AccountController(
StoredVerificationCodeManager pendingAccounts,
AccountsManager accounts,
RateLimiters rateLimiters,
RegistrationServiceClient registrationServiceClient,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator,
Map<String, Integer> testDevices,
CaptchaChecker captchaChecker,
PushNotificationManager pushNotificationManager,
ChangeNumberManager changeNumberManager,
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator
) {
this(pendingAccounts, accounts, rateLimiters,
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, testDevices, captchaChecker,
pushNotificationManager, changeNumberManager, registrationRecoveryPasswordsManager,
backupServiceCredentialsGenerator, null, Clock.systemUTC());
}
@Timed @Timed
@GET @GET
@Path("/{type}/preauth/{token}/{number}") @Path("/{type}/preauth/{token}/{number}")
@ -424,7 +393,8 @@ public class AccountController {
}); });
if (existingAccount.isPresent()) { if (existingAccount.isPresent()) {
verifyRegistrationLock(existingAccount.get(), accountAttributes.getRegistrationLock()); registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
accountAttributes.getRegistrationLock());
} }
if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) { if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) {
@ -488,7 +458,7 @@ public class AccountController {
final Optional<Account> existingAccount = accounts.getByE164(number); final Optional<Account> existingAccount = accounts.getByE164(number);
if (existingAccount.isPresent()) { if (existingAccount.isPresent()) {
verifyRegistrationLock(existingAccount.get(), request.registrationLock()); registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock());
} }
rateLimiters.getVerifyLimiter().clear(number); rateLimiters.getVerifyLimiter().clear(number);
@ -823,51 +793,6 @@ public class AccountController {
rateLimiter.validate(mostRecentProxy); rateLimiter.validate(mostRecentProxy);
} }
private void verifyRegistrationLock(final Account existingAccount, @Nullable final String clientRegistrationLock)
throws RateLimitExceededException, WebApplicationException {
final StoredRegistrationLock existingRegistrationLock = existingAccount.getRegistrationLock();
final ExternalServiceCredentials existingBackupCredentials =
backupServiceCredentialsGenerator.generateForUuid(existingAccount.getUuid());
if (existingRegistrationLock.requiresClientRegistrationLock()) {
if (!Util.isEmpty(clientRegistrationLock)) {
rateLimiters.getPinLimiter().validate(existingAccount.getNumber());
}
final String phoneNumber = existingAccount.getNumber();
if (!existingRegistrationLock.verify(clientRegistrationLock)) {
// At this point, the client verified ownership of the phone number but doesnt have the reglock PIN.
// Freezing the existing account credentials will definitively start the reglock timeout.
// Until the timeout, the current reglock can still be supplied,
// along with phone number verification, to restore access.
/* boolean alreadyLocked = existingAccount.hasLockedCredentials();
Metrics.counter(LOCKED_ACCOUNT_COUNTER_NAME,
LOCK_REASON_TAG_NAME, "verifiedNumberFailedReglock",
ALREADY_LOCKED_TAG_NAME, Boolean.toString(alreadyLocked))
.increment();
final Account updatedAccount;
if (!alreadyLocked) {
updatedAccount = accounts.update(existingAccount, Account::lockAuthenticationCredentials);
} else {
updatedAccount = existingAccount;
}
List<Long> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();
clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds); */
throw new WebApplicationException(Response.status(423)
.entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining(),
existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null))
.build());
}
rateLimiters.getPinLimiter().clear(phoneNumber);
}
}
@VisibleForTesting @VisibleForTesting
static boolean pushChallengeMatches( static boolean pushChallengeMatches(
final String number, final String number,

View File

@ -0,0 +1,166 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.codahale.metrics.annotation.Timed;
import com.google.common.net.HttpHeaders;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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.HeaderParam;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.ServerErrorException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationSession;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
@Path("/v1/registration")
public class RegistrationController {
private static final Logger logger = LoggerFactory.getLogger(RegistrationController.class);
private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary
.builder(name(RegistrationController.class, "reregistrationIdleDays"))
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
.distributionStatisticExpiry(Duration.ofHours(2))
.register(Metrics.globalRegistry);
private static final String ACCOUNT_CREATED_COUNTER_NAME = name(RegistrationController.class, "accountCreated");
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
private static final String REGION_CODE_TAG_NAME = "regionCode";
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
private final AccountsManager accounts;
private final RegistrationServiceClient registrationServiceClient;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RateLimiters rateLimiters;
public RegistrationController(final AccountsManager accounts,
final RegistrationServiceClient registrationServiceClient,
final RegistrationLockVerificationManager registrationLockVerificationManager,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.registrationServiceClient = registrationServiceClient;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.rateLimiters = rateLimiters;
}
@Timed
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public AccountIdentityResponse register(
@HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull final BasicAuthorizationHeader authorizationHeader,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String signalAgent,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @Valid final RegistrationRequest registrationRequest) throws RateLimitExceededException, InterruptedException {
rateLimiters.getRegistrationLimiter().validate(registrationRequest.sessionId());
final byte[] sessionId;
try {
sessionId = Base64.getDecoder().decode(registrationRequest.sessionId());
} catch (final IllegalArgumentException e) {
throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY);
}
final String number = authorizationHeader.getUsername();
final String password = authorizationHeader.getPassword();
final String verificationType = "phoneNumberVerification";
try {
final Optional<RegistrationSession> maybeSession = registrationServiceClient.getSession(sessionId,
REGISTRATION_RPC_TIMEOUT)
.get(REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds(), TimeUnit.SECONDS);
final RegistrationSession session = maybeSession.orElseThrow(
() -> new NotAuthorizedException("session not verified"));
if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) {
throw new BadRequestException("number does not match session");
}
if (!session.verified()) {
throw new NotAuthorizedException("session not verified");
}
} catch (final CancellationException | ExecutionException | TimeoutException e) {
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
}
final Optional<Account> existingAccount = accounts.getByE164(number);
existingAccount.ifPresent(account -> {
final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());
});
if (existingAccount.isPresent()) {
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
registrationRequest.accountAttributes().getRegistrationLock());
}
if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(Account::isTransferSupported).orElse(false)) {
// If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user)
// before we'll let them create a new account "from scratch"
throw new WebApplicationException(Response.status(409, "device transfer available").build());
}
final Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(),
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)),
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType)))
.increment();
return new AccountIdentityResponse(account.getUuid(),
account.getNumber(),
account.getPhoneNumberIdentifier(),
account.getUsernameHash().orElse(null),
existingAccount.map(Account::isStorageSupported).orElse(false));
}
}

View File

@ -0,0 +1,16 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public record RegistrationRequest(@NotBlank String sessionId,
@NotNull @Valid AccountAttributes accountAttributes,
boolean skipDeviceTransfer) {
}

View File

@ -0,0 +1,10 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
public record RegistrationSession(String number, boolean verified) {
}

View File

@ -43,6 +43,7 @@ public class RateLimiters {
private final RateLimiter smsVoicePrefixLimiter; private final RateLimiter smsVoicePrefixLimiter;
private final RateLimiter verifyLimiter; private final RateLimiter verifyLimiter;
private final RateLimiter pinLimiter; private final RateLimiter pinLimiter;
private final RateLimiter registrationLimiter;
private final RateLimiter attachmentLimiter; private final RateLimiter attachmentLimiter;
private final RateLimiter preKeysLimiter; private final RateLimiter preKeysLimiter;
private final RateLimiter messagesLimiter; private final RateLimiter messagesLimiter;
@ -54,7 +55,6 @@ public class RateLimiters {
private final RateLimiter artPackLimiter; private final RateLimiter artPackLimiter;
private final RateLimiter usernameSetLimiter; private final RateLimiter usernameSetLimiter;
private final RateLimiter usernameReserveLimiter; private final RateLimiter usernameReserveLimiter;
private final RateLimiter storiesLimiter;
private final Map<String, RateLimiter> rateLimiterByHandle; private final Map<String, RateLimiter> rateLimiterByHandle;
@ -66,6 +66,7 @@ public class RateLimiters {
this.smsVoicePrefixLimiter = fromConfig("smsVoicePrefix", config.getSmsVoicePrefix(), cacheCluster); this.smsVoicePrefixLimiter = fromConfig("smsVoicePrefix", config.getSmsVoicePrefix(), cacheCluster);
this.verifyLimiter = fromConfig("verify", config.getVerifyNumber(), cacheCluster); this.verifyLimiter = fromConfig("verify", config.getVerifyNumber(), cacheCluster);
this.pinLimiter = fromConfig("pin", config.getVerifyPin(), cacheCluster); this.pinLimiter = fromConfig("pin", config.getVerifyPin(), cacheCluster);
this.registrationLimiter = fromConfig("registration", config.getRegistration(), cacheCluster);
this.attachmentLimiter = fromConfig("attachmentCreate", config.getAttachments(), cacheCluster); this.attachmentLimiter = fromConfig("attachmentCreate", config.getAttachments(), cacheCluster);
this.preKeysLimiter = fromConfig("prekeys", config.getPreKeys(), cacheCluster); this.preKeysLimiter = fromConfig("prekeys", config.getPreKeys(), cacheCluster);
this.messagesLimiter = fromConfig("messages", config.getMessages(), cacheCluster); this.messagesLimiter = fromConfig("messages", config.getMessages(), cacheCluster);
@ -77,7 +78,6 @@ public class RateLimiters {
this.artPackLimiter = fromConfig("artPack", config.getArtPack(), cacheCluster); this.artPackLimiter = fromConfig("artPack", config.getArtPack(), cacheCluster);
this.usernameSetLimiter = fromConfig("usernameSet", config.getUsernameSet(), cacheCluster); this.usernameSetLimiter = fromConfig("usernameSet", config.getUsernameSet(), cacheCluster);
this.usernameReserveLimiter = fromConfig("usernameReserve", config.getUsernameReserve(), cacheCluster); this.usernameReserveLimiter = fromConfig("usernameReserve", config.getUsernameReserve(), cacheCluster);
this.storiesLimiter = fromConfig("stories", config.getStories(), cacheCluster);
this.rateLimiterByHandle = Stream.of( this.rateLimiterByHandle = Stream.of(
fromConfig(Handle.BACKUP_AUTH_CHECK.id(), config.getBackupAuthCheck(), cacheCluster), fromConfig(Handle.BACKUP_AUTH_CHECK.id(), config.getBackupAuthCheck(), cacheCluster),
@ -138,6 +138,10 @@ public class RateLimiters {
return pinLimiter; return pinLimiter;
} }
public RateLimiter getRegistrationLimiter() {
return registrationLimiter;
}
public RateLimiter getTurnLimiter() { public RateLimiter getTurnLimiter() {
return turnLimiter; return turnLimiter;
} }
@ -170,10 +174,6 @@ public class RateLimiters {
return byHandle(Handle.CHECK_ACCOUNT_EXISTENCE).orElseThrow(); return byHandle(Handle.CHECK_ACCOUNT_EXISTENCE).orElseThrow();
} }
public RateLimiter getStoriesLimiter() {
return storiesLimiter;
}
private static RateLimiter fromConfig( private static RateLimiter fromConfig(
final String name, final String name,
final RateLimitsConfiguration.RateLimitConfiguration cfg, final RateLimitsConfiguration.RateLimitConfiguration cfg,

View File

@ -3,6 +3,7 @@ package org.whispersystems.textsecuregcm.registration;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber; import com.google.i18n.phonenumbers.Phonenumber;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
@ -16,7 +17,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -24,11 +25,12 @@ import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.Nullable;
import org.signal.registration.rpc.CheckVerificationCodeRequest; import org.signal.registration.rpc.CheckVerificationCodeRequest;
import org.signal.registration.rpc.CheckVerificationCodeResponse;
import org.signal.registration.rpc.CreateRegistrationSessionRequest; import org.signal.registration.rpc.CreateRegistrationSessionRequest;
import org.signal.registration.rpc.GetRegistrationSessionMetadataRequest;
import org.signal.registration.rpc.RegistrationServiceGrpc; import org.signal.registration.rpc.RegistrationServiceGrpc;
import org.signal.registration.rpc.SendVerificationCodeRequest; import org.signal.registration.rpc.SendVerificationCodeRequest;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.RegistrationSession;
public class RegistrationServiceClient implements Managed { public class RegistrationServiceClient implements Managed {
@ -36,6 +38,24 @@ public class RegistrationServiceClient implements Managed {
private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub; private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub;
private final Executor callbackExecutor; private final Executor callbackExecutor;
/**
* @param from an e164 in a {@code long} representation e.g. {@code 18005550123}
* @return the e164 in a {@code String} representation (e.g. {@code "+18005550123"})
* @throws IllegalArgumentException if the number cannot be parsed to a string
*/
static String convertNumeralE164ToString(long from) {
try {
final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance()
.parse("+" + from, null);
return PhoneNumberUtil.getInstance()
.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);
} catch (final NumberParseException e) {
throw new IllegalArgumentException("could not parse to phone number", e);
}
}
public RegistrationServiceClient(final String host, public RegistrationServiceClient(final String host,
final int port, final int port,
final String apiKey, final String apiKey,
@ -116,9 +136,9 @@ public class RegistrationServiceClient implements Managed {
return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) return toCompletableFuture(stub.withDeadline(toDeadline(timeout))
.checkVerificationCode(CheckVerificationCodeRequest.newBuilder() .checkVerificationCode(CheckVerificationCodeRequest.newBuilder()
.setSessionId(ByteString.copyFrom(sessionId)) .setSessionId(ByteString.copyFrom(sessionId))
.setVerificationCode(verificationCode) .setVerificationCode(verificationCode)
.build())) .build()))
.thenApply(response -> { .thenApply(response -> {
if (response.hasError()) { if (response.hasError()) {
switch (response.getError().getErrorType()) { switch (response.getError().getErrorType()) {
@ -133,6 +153,26 @@ public class RegistrationServiceClient implements Managed {
}); });
} }
public CompletableFuture<Optional<RegistrationSession>> getSession(final byte[] sessionId,
final Duration timeout) {
return toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata(
GetRegistrationSessionMetadataRequest.newBuilder()
.setSessionId(ByteString.copyFrom(sessionId)).build()))
.thenApply(response -> {
if (response.hasError()) {
switch (response.getError().getErrorType()) {
case GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND -> {
return Optional.empty();
}
default -> throw new RuntimeException("Failed to get session: " + response.getError().getErrorType());
}
}
final String number = convertNumeralE164ToString(response.getSessionMetadata().getE164());
return Optional.of(new RegistrationSession(number, response.getSessionMetadata().getVerified()));
});
}
private static Deadline toDeadline(final Duration timeout) { private static Deadline toDeadline(final Duration timeout) {
return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS);
} }

View File

@ -10,6 +10,11 @@ service RegistrationService {
*/ */
rpc create_session (CreateRegistrationSessionRequest) returns (CreateRegistrationSessionResponse) {} rpc create_session (CreateRegistrationSessionRequest) returns (CreateRegistrationSessionResponse) {}
/**
* Retrieves session metadata for a given session.
*/
rpc get_session_metadata (GetRegistrationSessionMetadataRequest) returns (GetRegistrationSessionMetadataResponse) {}
/** /**
* Sends a verification code to a destination phone number within the context * Sends a verification code to a destination phone number within the context
* of a previously-created registration session. * of a previously-created registration session.
@ -56,6 +61,11 @@ message RegistrationSessionMetadata {
* of this session. * of this session.
*/ */
bool verified = 2; bool verified = 2;
/**
* The phone number associated with this registration session.
*/
uint64 e164 = 3;
} }
message CreateRegistrationSessionError { message CreateRegistrationSessionError {
@ -95,6 +105,33 @@ enum CreateRegistrationSessionErrorType {
CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER = 2; CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER = 2;
} }
message GetRegistrationSessionMetadataRequest {
/**
* The ID of the session for which to retrieve metadata.
*/
bytes session_id = 1;
}
message GetRegistrationSessionMetadataResponse {
oneof response {
RegistrationSessionMetadata session_metadata = 1;
GetRegistrationSessionMetadataError error = 2;
}
}
message GetRegistrationSessionMetadataError {
GetRegistrationSessionMetadataErrorType error_type = 1;
}
enum GetRegistrationSessionMetadataErrorType {
GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_UNSPECIFIED = 0;
/**
* No session was found with the given identifier.
*/
GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND = 1;
}
message SendVerificationCodeRequest { message SendVerificationCodeRequest {
reserved 1; reserved 1;

View File

@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
public enum RegistrationLockError {
MISMATCH(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS),
RATE_LIMITED(413) // This will be changed to 429 in a future revision
;
private final int expectedStatus;
RegistrationLockError(final int expectedStatus) {
this.expectedStatus = expectedStatus;
}
public int getExpectedStatus() {
return expectedStatus;
}
}

View File

@ -0,0 +1,118 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
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.when;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.Pair;
class RegistrationLockVerificationManagerTest {
private final AccountsManager accountsManager = mock(AccountsManager.class);
private final ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class);
private final ExternalServiceCredentialsGenerator backupServiceCredentialsGeneraor = mock(
ExternalServiceCredentialsGenerator.class);
private final RateLimiters rateLimiters = mock(RateLimiters.class);
private final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupServiceCredentialsGeneraor, rateLimiters);
private final RateLimiter pinLimiter = mock(RateLimiter.class);
private Account account;
private StoredRegistrationLock existingRegistrationLock;
@BeforeEach
void setUp() {
when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter);
when(backupServiceCredentialsGeneraor.generateForUuid(any()))
.thenReturn(mock(ExternalServiceCredentials.class));
account = mock(Account.class);
when(account.getUuid()).thenReturn(UUID.randomUUID());
when(account.getNumber()).thenReturn("+18005551212");
existingRegistrationLock = mock(StoredRegistrationLock.class);
when(account.getRegistrationLock()).thenReturn(existingRegistrationLock);
}
@ParameterizedTest
@EnumSource
void testErrors(RegistrationLockError error) throws Exception {
when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true);
final String submittedRegistrationLock = "reglock";
final Pair<Class<? extends Exception>, Consumer<Exception>> exceptionType = switch (error) {
case MISMATCH -> {
when(existingRegistrationLock.verify(submittedRegistrationLock)).thenReturn(false);
yield new Pair<>(WebApplicationException.class, e -> {
if (e instanceof WebApplicationException wae) {
assertEquals(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS, wae.getResponse().getStatus());
} else {
fail("Exception was not of expected type");
}
});
}
case RATE_LIMITED -> {
when(existingRegistrationLock.verify(any())).thenReturn(true);
doThrow(RateLimitExceededException.class).when(pinLimiter).validate(anyString());
yield new Pair<>(RateLimitExceededException.class, ignored -> {
});
}
};
final Exception e = assertThrows(exceptionType.first(), () ->
registrationLockVerificationManager.verifyRegistrationLock(account, submittedRegistrationLock));
exceptionType.second().accept(e);
}
@ParameterizedTest
@MethodSource
void testSuccess(final boolean requiresClientRegistrationLock, @Nullable final String submittedRegistrationLock) {
when(existingRegistrationLock.requiresClientRegistrationLock())
.thenReturn(requiresClientRegistrationLock);
when(existingRegistrationLock.verify(submittedRegistrationLock)).thenReturn(true);
assertDoesNotThrow(
() -> registrationLockVerificationManager.verifyRegistrationLock(account, submittedRegistrationLock));
}
static Stream<Arguments> testSuccess() {
return Stream.of(
Arguments.of(false, null),
Arguments.of(true, null),
Arguments.of(false, "reglock"),
Arguments.of(true, "reglock")
);
}
}

View File

@ -67,13 +67,14 @@ import org.mockito.stubbing.Answer;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.captcha.AssessmentResult; import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountAttributes;
@ -184,12 +185,15 @@ class AccountControllerTest {
private byte[] registration_lock_key = new byte[32]; private byte[] registration_lock_key = new byte[32];
private static final SecureStorageServiceConfiguration STORAGE_CFG = MockUtils.buildMock( private static final SecureBackupServiceConfiguration BACKUP_CFG = MockUtils.buildMock(
SecureStorageServiceConfiguration.class, SecureBackupServiceConfiguration.class,
cfg -> when(cfg.decodeUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32])); cfg -> when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]));
private static final ExternalServiceCredentialsGenerator STORAGE_CREDENTIAL_GENERATOR = SecureStorageController private static final ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator(
.credentialsGenerator(STORAGE_CFG); BACKUP_CFG);
private static final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters);
private static final ResourceExtension resources = ResourceExtension.builder() private static final ResourceExtension resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter()) .addProvider(AuthHelper.getAuthFilter())
@ -214,9 +218,8 @@ class AccountControllerTest {
captchaChecker, captchaChecker,
pushNotificationManager, pushNotificationManager,
changeNumberManager, changeNumberManager,
registrationLockVerificationManager,
registrationRecoveryPasswordsManager, registrationRecoveryPasswordsManager,
STORAGE_CREDENTIAL_GENERATOR,
clientPresenceManager,
testClock)) testClock))
.build(); .build();

View File

@ -0,0 +1,306 @@
/*
* 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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
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.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.whispersystems.textsecuregcm.auth.RegistrationLockError;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.entities.RegistrationSession;
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.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
class RegistrationControllerTest {
private static final String NUMBER = "+18005551212";
private final AccountsManager accountsManager = mock(AccountsManager.class);
private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);
private final RegistrationLockVerificationManager registrationLockVerificationManager = mock(
RegistrationLockVerificationManager.class);
private final RateLimiters rateLimiters = mock(RateLimiters.class);
private final RateLimiter registrationLimiter = mock(RateLimiter.class);
private final RateLimiter pinLimiter = 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())
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(
new RegistrationController(accountsManager, registrationServiceClient, registrationLockVerificationManager,
rateLimiters))
.build();
@BeforeEach
void setUp() {
when(rateLimiters.getRegistrationLimiter()).thenReturn(registrationLimiter);
when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter);
}
@Test
void unprocessableRequestJson() {
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request();
try (Response response = request.post(Entity.json(unprocessableJson()))) {
assertEquals(400, response.getStatus());
}
}
@Test
void missingBasicAuthorization() {
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request();
try (Response response = request.post(Entity.json(requestJson("sessionId")))) {
assertEquals(400, response.getStatus());
}
}
@Test
void invalidBasicAuthorization() {
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, "Basic but-invalid");
try (Response response = request.post(Entity.json(invalidRequestJson()))) {
assertEquals(401, response.getStatus());
}
}
@Test
void invalidRequestBody() {
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(invalidRequestJson()))) {
assertEquals(422, response.getStatus());
}
}
@Test
void rateLimitedSession() throws Exception {
final String sessionId = "sessionId";
doThrow(RateLimitExceededException.class)
.when(registrationLimiter).validate(encodeSessionId(sessionId));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson(sessionId)))) {
assertEquals(413, response.getStatus());
// In the future, change to assertEquals(429, response.getStatus());
}
}
@Test
void registrationServiceTimeout() {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.failedFuture(new RuntimeException()));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson("sessionId")))) {
assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());
}
}
@ParameterizedTest
@MethodSource
void registrationServiceSessionCheck(@Nullable final RegistrationSession session, final int expectedStatus,
final String message) {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session)));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson("sessionId")))) {
assertEquals(expectedStatus, response.getStatus(), message);
}
}
static Stream<Arguments> 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")
);
}
@ParameterizedTest
@EnumSource(RegistrationLockError.class)
void registrationLock(final RegistrationLockError error) throws Exception {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true))));
when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class)));
final Exception e = switch (error) {
case MISMATCH -> new WebApplicationException(error.getExpectedStatus());
case RATE_LIMITED -> new RateLimitExceededException(null);
};
doThrow(e)
.when(registrationLockVerificationManager).verifyRegistrationLock(any(), any());
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson("sessionId")))) {
assertEquals(error.getExpectedStatus(), response.getStatus());
}
}
@ParameterizedTest
@CsvSource({
"false, false, false, 200",
"true, false, false, 200",
"true, false, true, 200",
"true, true, false, 409",
"true, true, true, 200"
})
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))));
final Optional<Account> maybeAccount;
if (existingAccount) {
final Account account = mock(Account.class);
when(account.isTransferSupported()).thenReturn(transferSupported);
maybeAccount = Optional.of(account);
} else {
maybeAccount = Optional.empty();
}
when(accountsManager.getByE164(any())).thenReturn(maybeAccount);
when(accountsManager.create(any(), any(), any(), any(), any())).thenReturn(mock(Account.class));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson("sessionId", skipDeviceTransfer)))) {
assertEquals(expectedStatus, response.getStatus());
}
}
// this is functionally the same as deviceTransferAvailable(existingAccount=false)
@Test
void success() throws Exception {
when(registrationServiceClient.getSession(any(), any()))
.thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true))));
when(accountsManager.create(any(), any(), any(), any(), any()))
.thenReturn(mock(Account.class));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/registration")
.request()
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(NUMBER));
try (Response response = request.post(Entity.json(requestJson("sessionId")))) {
assertEquals(200, response.getStatus());
}
}
/**
* Valid request JSON with the give session ID and skipDeviceTransfer
*/
private static String requestJson(final String sessionId, final boolean skipDeviceTransfer) {
return String.format("""
{
"sessionId": "%s",
"accountAttributes": {},
"skipDeviceTransfer": %s
}
""", encodeSessionId(sessionId), skipDeviceTransfer);
}
/**
* Valid request JSON with the give session ID
*/
private static String requestJson(final String sessionId) {
return requestJson(sessionId, false);
}
/**
* Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest}, but that fails
* validation
*/
private static String invalidRequestJson() {
return """
{
"sessionId": null,
"accountAttributes": {},
"skipDeviceTransfer": false
}
""";
}
/**
* Request JSON that cannot be marshalled into {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest}
*/
private static String unprocessableJson() {
return """
{
"sessionId": []
}
""";
}
private static String authorizationHeader(final String number) {
return "Basic " + Base64.getEncoder().encodeToString(
String.format("%s:password", number).getBytes(StandardCharsets.UTF_8));
}
private static String encodeSessionId(final String sessionId) {
return Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8));
}
}