mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 13:58:07 +01:00
Add PUT /v2/account/number
This commit is contained in:
@@ -76,6 +76,7 @@ import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
|
||||
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
|
||||
@@ -87,6 +88,7 @@ import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountControllerV2;
|
||||
import org.whispersystems.textsecuregcm.controllers.ArtController;
|
||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
|
||||
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
|
||||
@@ -524,16 +526,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(
|
||||
accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters);
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager(
|
||||
registrationServiceClient, registrationRecoveryPasswordsManager);
|
||||
|
||||
ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(accountsManager);
|
||||
final ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(
|
||||
accountsManager);
|
||||
reportMessageManager.addListener(reportedMessageMetricsListener);
|
||||
|
||||
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
|
||||
final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
final DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(
|
||||
accountsManager);
|
||||
|
||||
MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, pushNotificationManager, pushLatencyManager);
|
||||
ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||
final MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager,
|
||||
pushNotificationManager,
|
||||
pushLatencyManager);
|
||||
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
|
||||
final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager);
|
||||
|
||||
RecaptchaClient recaptchaClient = new RecaptchaClient(
|
||||
config.getRecaptchaConfiguration().getProjectPath(),
|
||||
@@ -731,6 +739,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
}
|
||||
|
||||
final List<Object> commonControllers = Lists.newArrayList(
|
||||
new AccountControllerV2(accountsManager, changeNumberManager, phoneVerificationTokenManager,
|
||||
registrationLockVerificationManager, rateLimiters),
|
||||
new ArtController(rateLimiters, artCredentialsGenerator),
|
||||
new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()),
|
||||
new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()),
|
||||
@@ -748,8 +758,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
|
||||
config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor),
|
||||
new ProvisioningController(rateLimiters, provisioningManager),
|
||||
new RegistrationController(accountsManager, registrationServiceClient, registrationLockVerificationManager,
|
||||
registrationRecoveryPasswordsManager, rateLimiters),
|
||||
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
|
||||
rateLimiters),
|
||||
new RemoteConfigController(remoteConfigsManager, adminEventLogger,
|
||||
config.getRemoteConfigConfiguration().getAuthorizedTokens(),
|
||||
config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.ServerErrorException;
|
||||
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.registration.RegistrationServiceClient;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
|
||||
public class PhoneVerificationTokenManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PhoneVerificationTokenManager.class);
|
||||
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 RegistrationServiceClient registrationServiceClient;
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
|
||||
public PhoneVerificationTokenManager(final RegistrationServiceClient registrationServiceClient,
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a {@link PhoneVerificationRequest} has a token that verifies the caller has confirmed access to the e164
|
||||
* number
|
||||
*
|
||||
* @param number the e164 presented for verification
|
||||
* @param request the request with exactly one verification token (RegistrationService sessionId or registration
|
||||
* recovery password)
|
||||
* @return if verification was successful, returns the verification type
|
||||
* @throws BadRequestException if the number does not match the sessionId’s number
|
||||
* @throws 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
|
||||
*/
|
||||
public PhoneVerificationRequest.VerificationType verify(final String number, final PhoneVerificationRequest request)
|
||||
throws InterruptedException {
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = request.verificationType();
|
||||
switch (verificationType) {
|
||||
case SESSION -> verifyBySessionId(number, request.decodeSessionId());
|
||||
case RECOVERY_PASSWORD -> verifyByRecoveryPassword(number, request.recoveryPassword());
|
||||
}
|
||||
|
||||
return verificationType;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -798,7 +798,7 @@ public class AccountController {
|
||||
// This shouldn't happen, so conservatively assume we're over the rate-limit
|
||||
// and indicate that the client should retry
|
||||
logger.error("Missing/bad Forwarded-For: {}", forwardedFor);
|
||||
return new RateLimitExceededException(Duration.ofHours(1));
|
||||
return new RateLimitExceededException(Duration.ofHours(1), true);
|
||||
});
|
||||
|
||||
rateLimiter.validate(mostRecentProxy);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.StaleDevices;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
|
||||
@Path("/v2/accounts")
|
||||
public class AccountControllerV2 {
|
||||
|
||||
private static final String CHANGE_NUMBER_COUNTER_NAME = name(AccountControllerV2.class, "create");
|
||||
private static final String VERIFICATION_TYPE_TAG_NAME = "verification";
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final ChangeNumberManager changeNumberManager;
|
||||
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
|
||||
private final RegistrationLockVerificationManager registrationLockVerificationManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
|
||||
public AccountControllerV2(final AccountsManager accountsManager, final ChangeNumberManager changeNumberManager,
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager,
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.changeNumberManager = changeNumberManager;
|
||||
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
|
||||
this.registrationLockVerificationManager = registrationLockVerificationManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@PUT
|
||||
@Path("/number")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
|
||||
@NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent)
|
||||
throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
final String number = request.number();
|
||||
|
||||
// Only verify and check reglock if there's a data change to be made...
|
||||
if (!authenticatedAccount.getAccount().getNumber().equals(number)) {
|
||||
|
||||
RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number));
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number,
|
||||
request);
|
||||
|
||||
final Optional<Account> existingAccount = accountsManager.getByE164(number);
|
||||
|
||||
if (existingAccount.isPresent()) {
|
||||
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock());
|
||||
}
|
||||
|
||||
Metrics.counter(CHANGE_NUMBER_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))
|
||||
.increment();
|
||||
}
|
||||
|
||||
// ...but always attempt to make the change in case a client retries and needs to re-send messages
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.changeNumber(
|
||||
authenticatedAccount.getAccount(),
|
||||
request.number(),
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
request.deviceMessages(),
|
||||
request.pniRegistrationIds());
|
||||
|
||||
return new AccountIdentityResponse(
|
||||
updatedAccount.getUuid(),
|
||||
updatedAccount.getNumber(),
|
||||
updatedAccount.getPhoneNumberIdentifier(),
|
||||
updatedAccount.getUsernameHash().orElse(null),
|
||||
updatedAccount.isStorageSupported());
|
||||
} catch (MismatchedDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(409)
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new MismatchedDevices(e.getMissingDevices(),
|
||||
e.getExtraDevices()))
|
||||
.build());
|
||||
} catch (StaleDevicesException e) {
|
||||
throw new WebApplicationException(Response.status(410)
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(new StaleDevices(e.getStaleDevices()))
|
||||
.build());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,20 +10,27 @@ import javax.annotation.Nullable;
|
||||
|
||||
public class RateLimitExceededException extends Exception {
|
||||
|
||||
private final @Nullable
|
||||
Duration retryDuration;
|
||||
@Nullable
|
||||
private final Duration retryDuration;
|
||||
private final boolean legacy;
|
||||
|
||||
/**
|
||||
* Constructs a new exception indicating when it may become safe to retry
|
||||
*
|
||||
* @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 RateLimitExceededException(final @Nullable Duration retryDuration) {
|
||||
public RateLimitExceededException(@Nullable final Duration retryDuration, final boolean legacy) {
|
||||
super(null, null, true, false);
|
||||
this.retryDuration = retryDuration;
|
||||
this.legacy = legacy;
|
||||
}
|
||||
|
||||
public Optional<Duration> getRetryDuration() {
|
||||
return Optional.ofNullable(retryDuration);
|
||||
}
|
||||
|
||||
public boolean isLegacy() {
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,42 +13,33 @@ 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.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.Consumes;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
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.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationSession;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
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.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
@@ -68,24 +59,17 @@ public class RegistrationController {
|
||||
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 static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds();
|
||||
|
||||
private final AccountsManager accounts;
|
||||
private final RegistrationServiceClient registrationServiceClient;
|
||||
private final PhoneVerificationTokenManager phoneVerificationTokenManager;
|
||||
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) {
|
||||
final PhoneVerificationTokenManager phoneVerificationTokenManager,
|
||||
final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) {
|
||||
this.accounts = accounts;
|
||||
this.registrationServiceClient = registrationServiceClient;
|
||||
this.phoneVerificationTokenManager = phoneVerificationTokenManager;
|
||||
this.registrationLockVerificationManager = registrationLockVerificationManager;
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.rateLimiters = rateLimiters;
|
||||
}
|
||||
|
||||
@@ -99,17 +83,13 @@ public class RegistrationController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @Valid final RegistrationRequest registrationRequest) throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
rateLimiters.getRegistrationLimiter().validate(registrationRequest.sessionId());
|
||||
|
||||
final String number = authorizationHeader.getUsername();
|
||||
final String password = authorizationHeader.getPassword();
|
||||
|
||||
// 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());
|
||||
}
|
||||
RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number));
|
||||
|
||||
final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number,
|
||||
registrationRequest);
|
||||
|
||||
final Optional<Account> existingAccount = accounts.getByE164(number);
|
||||
|
||||
@@ -146,34 +126,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.databind.annotation.JsonDeserialize;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
|
||||
|
||||
public record ChangeNumberRequest(String sessionId,
|
||||
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword,
|
||||
@NotBlank String number,
|
||||
@JsonProperty("reglock") @Nullable String registrationLock,
|
||||
@NotBlank String pniIdentityKey,
|
||||
@NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages,
|
||||
@NotNull @Valid Map<Long, @NotNull @Valid SignedPreKey> devicePniSignedPrekeys,
|
||||
@NotNull Map<Long, Integer> pniRegistrationIds) implements PhoneVerificationRequest {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||
|
||||
import java.util.Base64;
|
||||
import javax.validation.constraints.AssertTrue;
|
||||
import javax.ws.rs.ClientErrorException;
|
||||
import org.apache.http.HttpStatus;
|
||||
|
||||
public interface PhoneVerificationRequest {
|
||||
|
||||
enum VerificationType {
|
||||
SESSION,
|
||||
RECOVERY_PASSWORD
|
||||
}
|
||||
|
||||
String sessionId();
|
||||
|
||||
byte[] recoveryPassword();
|
||||
|
||||
// for the @AssertTrue to work with bean validation, method name must follow 'isSmth()'/'getSmth()' naming convention
|
||||
@AssertTrue
|
||||
default boolean isValid() {
|
||||
// checking that exactly one of sessionId/recoveryPassword is non-empty
|
||||
return isNotBlank(sessionId()) ^ (recoveryPassword() != null && recoveryPassword().length > 0);
|
||||
}
|
||||
|
||||
default PhoneVerificationRequest.VerificationType verificationType() {
|
||||
return isNotBlank(sessionId()) ? PhoneVerificationRequest.VerificationType.SESSION
|
||||
: PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD;
|
||||
}
|
||||
|
||||
default byte[] decodeSessionId() {
|
||||
try {
|
||||
return Base64.getUrlDecoder().decode(sessionId());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,15 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||
|
||||
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) {
|
||||
boolean skipDeviceTransfer) implements PhoneVerificationRequest {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class LockingRateLimiter extends RateLimiter {
|
||||
public void validate(String key, int amount) throws RateLimitExceededException {
|
||||
if (!acquireLock(key)) {
|
||||
meter.mark();
|
||||
throw new RateLimitExceededException(Duration.ZERO);
|
||||
throw new RateLimitExceededException(Duration.ZERO, true);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -29,7 +29,8 @@ public class RateLimitByIpFilter implements ContainerRequestFilter {
|
||||
private static final Logger logger = LoggerFactory.getLogger(RateLimitByIpFilter.class);
|
||||
|
||||
@VisibleForTesting
|
||||
static final RateLimitExceededException INVALID_HEADER_EXCEPTION = new RateLimitExceededException(Duration.ofHours(1));
|
||||
static final RateLimitExceededException INVALID_HEADER_EXCEPTION = new RateLimitExceededException(Duration.ofHours(1),
|
||||
true);
|
||||
|
||||
private static final ExceptionMapper<RateLimitExceededException> EXCEPTION_MAPPER = new RateLimitExceededExceptionMapper();
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ public class RateLimiter {
|
||||
setBucket(key, bucket);
|
||||
} else {
|
||||
meter.mark();
|
||||
throw new RateLimitExceededException(bucket.getTimeUntilSpaceAvailable(amount));
|
||||
throw new RateLimitExceededException(bucket.getTimeUntilSpaceAvailable(amount), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,4 +132,22 @@ public class RateLimiter {
|
||||
public boolean hasConfiguration(final RateLimitConfiguration configuration) {
|
||||
return bucketSize == configuration.getBucketSize() && leakRatePerMinute == configuration.getLeakRatePerMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* If the wrapped {@code validate()} call throws a {@link RateLimitExceededException}, it will adapt it to ensure that
|
||||
* {@link RateLimitExceededException#isLegacy()} returns {@code true}
|
||||
*/
|
||||
public static void adaptLegacyException(final RateLimitValidator validator) throws RateLimitExceededException {
|
||||
try {
|
||||
validator.validate();
|
||||
} catch (final RateLimitExceededException e) {
|
||||
throw new RateLimitExceededException(e.getRetryDuration().orElse(null), false);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RateLimitValidator {
|
||||
|
||||
void validate() throws RateLimitExceededException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,36 +4,41 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.mappers;
|
||||
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
|
||||
@Provider
|
||||
public class RateLimitExceededExceptionMapper implements ExceptionMapper<RateLimitExceededException> {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RateLimitExceededExceptionMapper.class);
|
||||
|
||||
private static final int LEGACY_STATUS_CODE = 413;
|
||||
private static final int STATUS_CODE = 429;
|
||||
|
||||
/**
|
||||
* Convert a RateLimitExceededException to a 413 response with a
|
||||
* Retry-After header.
|
||||
* Convert a RateLimitExceededException to a {@value STATUS_CODE} (or legacy {@value LEGACY_STATUS_CODE}) response
|
||||
* with a Retry-After header.
|
||||
*
|
||||
* @param e A RateLimitExceededException potentially containing a recommended retry duration
|
||||
* @return the response
|
||||
*/
|
||||
@Override
|
||||
public Response toResponse(RateLimitExceededException e) {
|
||||
final int statusCode = e.isLegacy() ? LEGACY_STATUS_CODE : STATUS_CODE;
|
||||
return e.getRetryDuration()
|
||||
.filter(d -> {
|
||||
if (d.isNegative()) {
|
||||
logger.warn("Encountered a negative retry duration: {}, will not include a Retry-After header in response", d);
|
||||
logger.warn("Encountered a negative retry duration: {}, will not include a Retry-After header in response",
|
||||
d);
|
||||
}
|
||||
// only include non-negative durations in retry headers
|
||||
return !d.isNegative();
|
||||
})
|
||||
.map(d -> Response.status(413).header("Retry-After", d.toSeconds()))
|
||||
.orElseGet(() -> Response.status(413)).build();
|
||||
.map(d -> Response.status(statusCode).header("Retry-After", d.toSeconds()))
|
||||
.orElseGet(() -> Response.status(statusCode)).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,12 @@ public class RegistrationServiceClient implements Managed {
|
||||
|
||||
case ERROR -> {
|
||||
switch (response.getError().getErrorType()) {
|
||||
case CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds())));
|
||||
case CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
|
||||
new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
|
||||
true));
|
||||
case CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER -> throw new IllegalArgumentException();
|
||||
default -> throw new RuntimeException("Unrecognized error type from registration service: " + response.getError().getErrorType());
|
||||
default -> throw new RuntimeException(
|
||||
"Unrecognized error type from registration service: " + response.getError().getErrorType());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +122,9 @@ public class RegistrationServiceClient implements Managed {
|
||||
.thenApply(response -> {
|
||||
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())));
|
||||
case SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
|
||||
new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
|
||||
true));
|
||||
|
||||
default -> throw new CompletionException(new RuntimeException("Failed to send verification code: " + response.getError().getErrorType()));
|
||||
}
|
||||
@@ -142,8 +146,9 @@ public class RegistrationServiceClient implements Managed {
|
||||
.thenApply(response -> {
|
||||
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())));
|
||||
case CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(
|
||||
new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()),
|
||||
true));
|
||||
|
||||
default -> throw new CompletionException(new RuntimeException("Failed to check verification code: " + response.getError().getErrorType()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user