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
14 changed files with 872 additions and 126 deletions

View File

@@ -32,7 +32,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
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.ChangesDeviceEnabledState;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;
import org.whispersystems.textsecuregcm.auth.StoredVerificationCode;
import org.whispersystems.textsecuregcm.auth.TurnToken;
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.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
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.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.ClientPresenceManager;
import org.whispersystems.textsecuregcm.push.PushNotification;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType;
@@ -137,7 +132,7 @@ public class AccountController {
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
.distributionStatisticExpiry(Duration.ofHours(2))
.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_MATCH_TAG_NAME = "matches";
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 VERIFICATION_TRANSPORT_TAG_NAME = "transport";
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 AccountsManager accounts;
private final RateLimiters rateLimiters;
private final RegistrationServiceClient registrationServiceClient;
private final StoredVerificationCodeManager pendingAccounts;
private final AccountsManager accounts;
private final RateLimiters rateLimiters;
private final RegistrationServiceClient registrationServiceClient;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> testDevices;
private final CaptchaChecker captchaChecker;
private final PushNotificationManager pushNotificationManager;
private final ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator;
private final TurnTokenGenerator turnTokenGenerator;
private final Map<String, Integer> testDevices;
private final CaptchaChecker captchaChecker;
private final PushNotificationManager pushNotificationManager;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final ChangeNumberManager changeNumberManager;
private final Clock clock;
private final ClientPresenceManager clientPresenceManager;
@VisibleForTesting
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
@@ -184,9 +176,8 @@ public class AccountController {
CaptchaChecker captchaChecker,
PushNotificationManager pushNotificationManager,
ChangeNumberManager changeNumberManager,
RegistrationLockVerificationManager registrationLockVerificationManager,
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
ExternalServiceCredentialsGenerator backupServiceCredentialsGenerator,
ClientPresenceManager clientPresenceManager,
Clock clock
) {
this.pendingAccounts = pendingAccounts;
@@ -198,34 +189,12 @@ public class AccountController {
this.turnTokenGenerator = turnTokenGenerator;
this.captchaChecker = captchaChecker;
this.pushNotificationManager = pushNotificationManager;
this.backupServiceCredentialsGenerator = backupServiceCredentialsGenerator;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.changeNumberManager = changeNumberManager;
this.clientPresenceManager = clientPresenceManager;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
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
@GET
@Path("/{type}/preauth/{token}/{number}")
@@ -424,7 +393,8 @@ public class AccountController {
});
if (existingAccount.isPresent()) {
verifyRegistrationLock(existingAccount.get(), accountAttributes.getRegistrationLock());
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
accountAttributes.getRegistrationLock());
}
if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) {
@@ -488,7 +458,7 @@ public class AccountController {
final Optional<Account> existingAccount = accounts.getByE164(number);
if (existingAccount.isPresent()) {
verifyRegistrationLock(existingAccount.get(), request.registrationLock());
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock());
}
rateLimiters.getVerifyLimiter().clear(number);
@@ -823,51 +793,6 @@ public class AccountController {
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
static boolean pushChallengeMatches(
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));
}
}