Registration Recovery Password support in /v1/registration

This commit is contained in:
Sergey Skrobotov
2023-02-08 13:11:10 -08:00
parent 4a3880b5ae
commit 7558489ad0
4 changed files with 167 additions and 41 deletions

View File

@@ -747,7 +747,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, registrationServiceClient, registrationLockVerificationManager,
rateLimiters),
registrationRecoveryPasswordsManager, rateLimiters),
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
config.getRemoteConfigConfiguration().getAuthorizedTokens(),
config.getRemoteConfigConfiguration().getGlobalConfig()),

View File

@@ -17,7 +17,6 @@ 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;
@@ -26,8 +25,8 @@ 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.ForbiddenException;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.POST;
@@ -37,7 +36,6 @@ 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;
@@ -50,6 +48,7 @@ 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.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
@@ -70,19 +69,23 @@ public class RegistrationController {
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
private static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds();
private final AccountsManager accounts;
private final RegistrationServiceClient registrationServiceClient;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final RateLimiters rateLimiters;
public RegistrationController(final AccountsManager accounts,
final RegistrationServiceClient registrationServiceClient,
final RegistrationLockVerificationManager registrationLockVerificationManager,
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final RateLimiters rateLimiters) {
this.accounts = accounts;
this.registrationServiceClient = registrationServiceClient;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.rateLimiters = rateLimiters;
}
@@ -98,34 +101,14 @@ public class RegistrationController {
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);
// decide on the method of verification based on the registration request parameters and verify
final RegistrationRequest.VerificationType verificationType = registrationRequest.verificationType();
switch (verificationType) {
case SESSION -> verifyBySessionId(number, registrationRequest.decodeSessionId());
case RECOVERY_PASSWORD -> verifyByRecoveryPassword(number, registrationRequest.recoveryPassword());
}
final Optional<Account> existingAccount = accounts.getByE164(number);
@@ -150,10 +133,15 @@ public class RegistrationController {
final Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(),
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
// now that the number is verified and account is created,
// we can store recovery password for this number
registrationRequest.accountAttributes().recoveryPassword().ifPresent(recoveryPassword ->
registrationRecoveryPasswordsManager.storeForCurrentNumber(number, recoveryPassword));
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)))
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))
.increment();
return new AccountIdentityResponse(account.getUuid(),
@@ -163,4 +151,34 @@ public class RegistrationController {
existingAccount.map(Account::isStorageSupported).orElse(false));
}
private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {
try {
final RegistrationSession session = registrationServiceClient
.getSession(sessionId, REGISTRATION_RPC_TIMEOUT)
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.orElseThrow(() -> new NotAuthorizedException("session not verified"));
if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) {
throw new BadRequestException("number does not match session");
}
if (!session.verified()) {
throw new NotAuthorizedException("session not verified");
}
} catch (final CancellationException | ExecutionException | TimeoutException e) {
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
}
}
private void verifyByRecoveryPassword(final String number, final byte[] recoveryPassword) throws InterruptedException {
try {
final boolean verified = registrationRecoveryPasswordsManager.verify(number, recoveryPassword)
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!verified) {
throw new ForbiddenException("recoveryPassword couldn't be verified");
}
} catch (final ExecutionException | TimeoutException e) {
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
}
}
}

View File

@@ -5,12 +5,43 @@
package org.whispersystems.textsecuregcm.entities;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public record RegistrationRequest(@NotBlank String sessionId,
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import java.util.Base64;
import javax.validation.Valid;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotNull;
import javax.ws.rs.ClientErrorException;
import org.apache.http.HttpStatus;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
public record RegistrationRequest(String sessionId,
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword,
@NotNull @Valid AccountAttributes accountAttributes,
boolean skipDeviceTransfer) {
public enum VerificationType {
SESSION,
RECOVERY_PASSWORD
}
// for the @AssertTrue to work with bean validation, method name must follow 'isSmth()'/'getSmth()' naming convention
@AssertTrue
public boolean isValid() {
// checking that exactly one of sessionId/recoveryPassword is non-empty
return isNotBlank(sessionId) ^ (recoveryPassword != null && recoveryPassword.length > 0);
}
public VerificationType verificationType() {
return isNotBlank(sessionId) ? VerificationType.SESSION : VerificationType.RECOVERY_PASSWORD;
}
public byte[] decodeSessionId() {
try {
return Base64.getUrlDecoder().decode(sessionId());
} catch (final IllegalArgumentException e) {
throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY);
}
}
}