Remove legacy registration endpoints from `AccountController`

This commit is contained in:
Jon Chambers 2023-07-05 10:32:06 -04:00 committed by Jon Chambers
parent 8edb450d73
commit 08c7baafac
3 changed files with 15 additions and 2115 deletions

View File

@ -23,7 +23,6 @@ import io.dropwizard.auth.basic.BasicCredentialAuthFilter;
import io.dropwizard.auth.basic.BasicCredentials; import io.dropwizard.auth.basic.BasicCredentials;
import io.dropwizard.setup.Bootstrap; import io.dropwizard.setup.Bootstrap;
import io.dropwizard.setup.Environment; import io.dropwizard.setup.Environment;
import io.grpc.Server;
import io.grpc.ServerBuilder; import io.grpc.ServerBuilder;
import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder; import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder;
import io.lettuce.core.metrics.MicrometerOptions; import io.lettuce.core.metrics.MicrometerOptions;
@ -650,10 +649,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
// these should be common, but use @Auth DisabledPermittedAccount, which isnt supported yet on websocket // these should be common, but use @Auth DisabledPermittedAccount, which isnt supported yet on websocket
environment.jersey().register( environment.jersey().register(
new AccountController(pendingAccountsManager, accountsManager, rateLimiters, new AccountController(accountsManager, rateLimiters,
registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, turnTokenGenerator,
registrationCaptchaManager, pushNotificationManager, changeNumberManager, registrationRecoveryPasswordsManager, usernameHashZkProofVerifier));
registrationLockVerificationManager, registrationRecoveryPasswordsManager, usernameHashZkProofVerifier, clock));
environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager)); environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager));

View File

@ -4,44 +4,20 @@
*/ */
package org.whispersystems.textsecuregcm.controllers; package org.whispersystems.textsecuregcm.controllers;
import static com.codahale.metrics.MetricRegistry.name;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.annotation.Timed; import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HttpHeaders;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.IOException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletionException;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException; import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE; import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.HEAD; import javax.ws.rs.HEAD;
import javax.ws.rs.HeaderParam; import javax.ws.rs.HeaderParam;
@ -49,70 +25,40 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder; import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
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.RegistrationLockVerificationManager;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
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;
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest; import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.DeviceName; import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.EncryptedUsername; import org.whispersystems.textsecuregcm.entities.EncryptedUsername;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
import org.whispersystems.textsecuregcm.entities.RegistrationLock; import org.whispersystems.textsecuregcm.entities.RegistrationLock;
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.UsernameHashResponse; import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.UsernameLinkHandle; import org.whispersystems.textsecuregcm.entities.UsernameLinkHandle;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.PushNotification;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType;
import org.whispersystems.textsecuregcm.registration.MessageTransport;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.spam.Extract;
import org.whispersystems.textsecuregcm.spam.FilterSpam;
import org.whispersystems.textsecuregcm.spam.ScoreThreshold;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
import org.whispersystems.textsecuregcm.util.Optionals;
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@ -122,409 +68,24 @@ import org.whispersystems.textsecuregcm.util.Util;
public class AccountController { public class AccountController {
public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20; public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;
public static final int USERNAME_HASH_LENGTH = 32; public static final int USERNAME_HASH_LENGTH = 32;
private final Logger logger = LoggerFactory.getLogger(AccountController.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Meter captchaRequiredMeter = metricRegistry.meter(name(AccountController.class, "captcha_required" ));
private static final String PUSH_CHALLENGE_COUNTER_NAME = name(AccountController.class, "pushChallenge");
private static final String ACCOUNT_CREATE_COUNTER_NAME = name(AccountController.class, "create");
private static final String ACCOUNT_VERIFY_COUNTER_NAME = name(AccountController.class, "verify");
private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(AccountController.class, "captcha");
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
private static final String INVALID_ACCOUNT_ATTRS_COUNTER_NAME = name(AccountController.class, "invalidAccountAttrs");
private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary
.builder(name(AccountController.class, "reregistrationIdleDays"))
.publishPercentiles(0.75, 0.95, 0.99, 0.999)
.distributionStatisticExpiry(Duration.ofHours(2))
.register(Metrics.globalRegistry);
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";
/**
* @deprecated "region" conflicts with cloud provider region tags; prefer "regionCode" instead
*/
@Deprecated
private static final String REGION_TAG_NAME = "region";
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 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 DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final TurnTokenGenerator turnTokenGenerator; private final TurnTokenGenerator turnTokenGenerator;
private final RegistrationCaptchaManager registrationCaptchaManager;
private final PushNotificationManager pushNotificationManager;
private final RegistrationLockVerificationManager registrationLockVerificationManager;
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
private final ChangeNumberManager changeNumberManager;
private final Clock clock;
private final UsernameHashZkProofVerifier usernameHashZkProofVerifier; private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;
@VisibleForTesting
static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
public AccountController( public AccountController(
StoredVerificationCodeManager pendingAccounts,
AccountsManager accounts, AccountsManager accounts,
RateLimiters rateLimiters, RateLimiters rateLimiters,
RegistrationServiceClient registrationServiceClient,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
TurnTokenGenerator turnTokenGenerator, TurnTokenGenerator turnTokenGenerator,
RegistrationCaptchaManager registrationCaptchaManager,
PushNotificationManager pushNotificationManager,
ChangeNumberManager changeNumberManager,
RegistrationLockVerificationManager registrationLockVerificationManager,
RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
UsernameHashZkProofVerifier usernameHashZkProofVerifier, UsernameHashZkProofVerifier usernameHashZkProofVerifier) {
Clock clock
) {
this.pendingAccounts = pendingAccounts;
this.accounts = accounts; this.accounts = accounts;
this.rateLimiters = rateLimiters; this.rateLimiters = rateLimiters;
this.registrationServiceClient = registrationServiceClient;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.turnTokenGenerator = turnTokenGenerator; this.turnTokenGenerator = turnTokenGenerator;
this.registrationCaptchaManager = registrationCaptchaManager;
this.pushNotificationManager = pushNotificationManager;
this.registrationLockVerificationManager = registrationLockVerificationManager;
this.changeNumberManager = changeNumberManager;
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager; this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier; this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
this.clock = clock;
}
@Timed
@GET
@Path("/{type}/preauth/{token}/{number}")
@Produces(MediaType.APPLICATION_JSON)
public Response getPreAuth(@PathParam("type") String pushType,
@PathParam("token") String pushToken,
@PathParam("number") String number,
@QueryParam("voip") @DefaultValue("true") boolean useVoip)
throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, RateLimitExceededException {
final PushNotification.TokenType tokenType = switch(pushType) {
case "apn" -> useVoip ? PushNotification.TokenType.APN_VOIP : PushNotification.TokenType.APN;
case "fcm" -> PushNotification.TokenType.FCM;
default -> throw new BadRequestException();
};
Util.requireNormalizedNumber(number);
final Phonenumber.PhoneNumber phoneNumber;
try {
phoneNumber = PhoneNumberUtil.getInstance().parse(number, null);
} catch (final NumberParseException e) {
// This should never happen since we just verified that the number is already normalized
throw new BadRequestException("Bad phone number");
}
final StoredVerificationCode storedVerificationCode;
{
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
if (maybeStoredVerificationCode.isPresent()) {
final StoredVerificationCode existingStoredVerificationCode = maybeStoredVerificationCode.get();
if (StringUtils.isBlank(existingStoredVerificationCode.pushCode())) {
storedVerificationCode = new StoredVerificationCode(
existingStoredVerificationCode.code(),
existingStoredVerificationCode.timestamp(),
generatePushChallenge(),
existingStoredVerificationCode.sessionId());
} else {
storedVerificationCode = existingStoredVerificationCode;
}
} else {
final byte[] sessionId = createRegistrationSession(phoneNumber, accounts.getByE164(number).isPresent());
storedVerificationCode = new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId);
}
}
pendingAccounts.store(number, storedVerificationCode);
pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode());
return Response.ok().build();
}
@Timed
@GET
@Path("/{transport}/code/{number}")
@FilterSpam
@Produces(MediaType.APPLICATION_JSON)
public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage,
@QueryParam("client") Optional<String> client,
@QueryParam("captcha") Optional<String> captcha,
@QueryParam("challenge") Optional<String> pushChallenge,
@Extract ScoreThreshold captchaScoreThreshold)
throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException {
Util.requireNormalizedNumber(number);
final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow();
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
// if there's a captcha, assess it, otherwise check if we need a captcha
final Optional<AssessmentResult> assessmentResult = registrationCaptchaManager.assessCaptcha(captcha, sourceHost);
assessmentResult.ifPresent(result ->
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of("success", String.valueOf(result.isValid(captchaScoreThreshold.getScoreThreshold()))),
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region),
Tag.of(REGION_CODE_TAG_NAME, region),
Tag.of(SCORE_TAG_NAME, result.getScoreString())))
.increment());
final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode);
if (pushChallenge.isPresent() && !pushChallengeMatch) {
throw new WebApplicationException(Response.status(403).build());
}
final boolean requiresCaptcha = assessmentResult
.map(result -> !result.isValid(captchaScoreThreshold.getScoreThreshold()))
.orElseGet(
() -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch));
if (requiresCaptcha) {
captchaRequiredMeter.mark();
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
Tag.of(REGION_CODE_TAG_NAME, region)))
.increment();
return Response.status(402).build();
}
switch (transport) {
case "sms" -> rateLimiters.getSmsDestinationLimiter().validate(number);
case "voice" -> {
rateLimiters.getVoiceDestinationLimiter().validate(number);
rateLimiters.getVoiceDestinationDailyLimiter().validate(number);
}
default -> throw new WebApplicationException(Response.status(422).build());
}
final Phonenumber.PhoneNumber phoneNumber;
try {
phoneNumber = PhoneNumberUtil.getInstance().parse(number, null);
} catch (final NumberParseException e) {
throw new WebApplicationException(Response.status(422).build());
}
final MessageTransport messageTransport = switch (transport) {
case "sms" -> MessageTransport.SMS;
case "voice" -> MessageTransport.VOICE;
default -> throw new WebApplicationException(Response.status(422).build());
};
final ClientType clientType = client.map(clientTypeString -> {
if ("ios".equalsIgnoreCase(clientTypeString)) {
return ClientType.IOS;
} else if ("android-2021-03".equalsIgnoreCase(clientTypeString)) {
return ClientType.ANDROID_WITH_FCM;
} else if (StringUtils.startsWithIgnoreCase(clientTypeString, "android")) {
return ClientType.ANDROID_WITHOUT_FCM;
} else {
return ClientType.UNKNOWN;
}
}).orElse(ClientType.UNKNOWN);
// During the transition to explicit session creation, some previously-stored records may not have a session ID;
// after the transition, we can assume that any existing record has an associated session ID.
final byte[] sessionId = maybeStoredVerificationCode.isPresent() && maybeStoredVerificationCode.get().sessionId() != null
? maybeStoredVerificationCode.get().sessionId()
: createRegistrationSession(phoneNumber, accounts.getByE164(number).isPresent());
sendVerificationCode(sessionId, messageTransport, clientType, acceptLanguage);
final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null,
clock.millis(),
maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), sessionId);
pendingAccounts.store(number, storedVerificationCode);
Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport)))
.increment();
return Response.ok().build();
}
@Timed
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("/code/{verification_code}")
public AccountIdentityResponse verifyAccount(@PathParam("verification_code") String verificationCode,
@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String signalAgent,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@QueryParam("transfer") Optional<Boolean> availableForTransfer,
@NotNull @Valid AccountAttributes accountAttributes)
throws RateLimitExceededException, InterruptedException {
String number = authorizationHeader.getUsername();
String password = authorizationHeader.getPassword();
rateLimiters.getVerifyLimiter().validate(number);
if (!AccountsManager.validNewAccountAttributes(accountAttributes)) {
Metrics.counter(INVALID_ACCOUNT_ATTRS_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment();
throw new WebApplicationException(Response.status(422, "account attributes invalid").build());
}
// Note that successful verification depends on being able to find a stored verification code for the given number.
// We check that numbers are normalized before we store verification codes, and so don't need to re-assert
// normalization here.
final boolean codeVerified;
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
if (maybeStoredVerificationCode.isPresent()) {
codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), verificationCode);
} else {
codeVerified = false;
}
if (!codeVerified) {
throw new WebApplicationException(Response.status(403).build());
}
Optional<Account> existingAccount = accounts.getByE164(number);
existingAccount.ifPresent(account -> {
Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());
});
if (existingAccount.isPresent()) {
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),
accountAttributes.getRegistrationLock(),
userAgent, RegistrationLockVerificationManager.Flow.REGISTRATION,
PhoneVerificationRequest.VerificationType.SESSION);
}
if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) {
throw new WebApplicationException(Status.CONFLICT);
}
rateLimiters.getVerifyLimiter().clear(number);
Account account = accounts.create(number, password, signalAgent, accountAttributes,
existingAccount.map(Account::getBadges).orElseGet(ArrayList::new));
Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
Tag.of(REGION_TAG_NAME, Util.getRegion(number)),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number))))
.increment();
return new AccountIdentityResponse(account.getUuid(),
account.getNumber(),
account.getPhoneNumberIdentifier(),
account.getUsernameHash().orElse(null),
existingAccount.map(Account::isStorageSupported).orElse(false));
}
@Timed
@PUT
@Path("/number")
@Produces(MediaType.APPLICATION_JSON)
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount,
@NotNull @Valid final ChangePhoneNumberRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
throws RateLimitExceededException, InterruptedException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {
if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) {
throw new ForbiddenException();
}
final String number = request.number();
// Only "bill" for rate limiting if we think there's a change to be made...
if (!authenticatedAccount.getAccount().getNumber().equals(number)) {
Util.requireNormalizedNumber(number);
rateLimiters.getVerifyLimiter().validate(number);
final boolean codeVerified;
final Optional<StoredVerificationCode> maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number);
if (maybeStoredVerificationCode.isPresent()) {
codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), request.code());
} else {
codeVerified = false;
}
if (!codeVerified) {
throw new ForbiddenException();
}
final Optional<Account> existingAccount = accounts.getByE164(number);
if (existingAccount.isPresent()) {
registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock(),
userAgent, RegistrationLockVerificationManager.Flow.CHANGE_NUMBER, PhoneVerificationRequest.VerificationType.SESSION);
}
rateLimiters.getVerifyLimiter().clear(number);
}
// ...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.devicePniPqLastResortPrekeys(),
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);
}
} }
@Timed @Timed
@ -710,7 +271,7 @@ public class AccountController {
@Operation( @Operation(
summary = "Delete username hash", summary = "Delete username hash",
description = """ description = """
Authenticated endpoint. Deletes previously stored username for the account. Authenticated endpoint. Deletes previously stored username for the account.
""" """
) )
@ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true) @ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true)
@ -729,7 +290,7 @@ public class AccountController {
summary = "Reserve username hash", summary = "Reserve username hash",
description = """ description = """
Authenticated endpoint. Takes in a list of hashes of potential username hashes, finds one that is not taken, Authenticated endpoint. Takes in a list of hashes of potential username hashes, finds one that is not taken,
and reserves it for the current account. and reserves it for the current account.
""" """
) )
@ApiResponse(responseCode = "200", description = "Username hash reserved successfully.", useReturnTypeSchema = true) @ApiResponse(responseCode = "200", description = "Username hash reserved successfully.", useReturnTypeSchema = true)
@ -768,8 +329,8 @@ public class AccountController {
@Operation( @Operation(
summary = "Confirm username hash", summary = "Confirm username hash",
description = """ description = """
Authenticated endpoint. For a previously reserved username hash, confirm that this username hash is now taken Authenticated endpoint. For a previously reserved username hash, confirm that this username hash is now taken
by this account. by this account.
""" """
) )
@ApiResponse(responseCode = "200", description = "Username hash confirmed successfully.", useReturnTypeSchema = true) @ApiResponse(responseCode = "200", description = "Username hash confirmed successfully.", useReturnTypeSchema = true)
@ -815,7 +376,7 @@ public class AccountController {
@Operation( @Operation(
summary = "Lookup username hash", summary = "Lookup username hash",
description = """ description = """
Forced unauthenticated endpoint. For the given username hash, look up a user ID. Forced unauthenticated endpoint. For the given username hash, look up a user ID.
""" """
) )
@ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true) @ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true)
@ -855,7 +416,7 @@ public class AccountController {
Authenticated endpoint. For the given encrypted username generates a username link handle. Authenticated endpoint. For the given encrypted username generates a username link handle.
Username link handle could be used to lookup the encrypted username. Username link handle could be used to lookup the encrypted username.
An account can only have one username link at a time. Calling this endpoint will reset previously stored An account can only have one username link at a time. Calling this endpoint will reset previously stored
encrypted username and deactivate previous link handle. encrypted username and deactivate previous link handle.
""" """
) )
@ApiResponse(responseCode = "200", description = "Username Link updated successfully.", useReturnTypeSchema = true) @ApiResponse(responseCode = "200", description = "Username Link updated successfully.", useReturnTypeSchema = true)
@ -886,7 +447,7 @@ public class AccountController {
summary = "Delete username link", summary = "Delete username link",
description = """ description = """
Authenticated endpoint. Deletes username link for the given account: previously store encrypted username is deleted Authenticated endpoint. Deletes username link for the given account: previously store encrypted username is deleted
and username link handle is deactivated. and username link handle is deactivated.
""" """
) )
@ApiResponse(responseCode = "204", description = "Username Link successfully deleted.", useReturnTypeSchema = true) @ApiResponse(responseCode = "204", description = "Username Link successfully deleted.", useReturnTypeSchema = true)
@ -943,29 +504,6 @@ public class AccountController {
return Response.status(status).build(); return Response.status(status).build();
} }
@VisibleForTesting
static boolean pushChallengeMatches(
final String number,
final Optional<String> pushChallenge,
final Optional<StoredVerificationCode> storedVerificationCode) {
final String countryCode = Util.getCountryCode(number);
final String region = Util.getRegion(number);
final Optional<String> storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::pushCode);
final boolean match = Optionals.zipWith(pushChallenge, storedPushChallenge, String::equals).orElse(false);
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,
COUNTRY_CODE_TAG_NAME, countryCode,
REGION_TAG_NAME, region,
REGION_CODE_TAG_NAME, region,
CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallenge.isPresent()),
CHALLENGE_MATCH_TAG_NAME, Boolean.toString(match))
.increment();
return match;
}
@Timed @Timed
@DELETE @DELETE
@Path("/me") @Path("/me")
@ -973,63 +511,6 @@ public class AccountController {
accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST); accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST);
} }
private String generatePushChallenge() {
SecureRandom random = new SecureRandom();
byte[] challenge = new byte[16];
random.nextBytes(challenge);
return HexFormat.of().formatHex(challenge);
}
private byte[] createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber,
final boolean accountExistsWithPhoneNumber) throws RateLimitExceededException {
try {
return registrationServiceClient.createRegistrationSession(phoneNumber, accountExistsWithPhoneNumber, REGISTRATION_RPC_TIMEOUT).join();
} catch (final CompletionException e) {
rethrowRateLimitException(e);
logger.debug("Failed to create session", e);
// Meet legacy client expectations by "swallowing" session creation exceptions and proceeding as if we had created
// a new session. Future operations on this "session" will always fail, but that's the legacy behavior.
return new byte[16];
}
}
private void sendVerificationCode(final byte[] sessionId,
final MessageTransport messageTransport,
final ClientType clientType,
final Optional<String> acceptLanguage) throws RateLimitExceededException {
try {
registrationServiceClient.sendRegistrationCode(sessionId,
messageTransport,
clientType,
acceptLanguage.orElse(null),
REGISTRATION_RPC_TIMEOUT).join();
} catch (final CompletionException e) {
// Note that, to meet legacy client expectations, we'll ONLY rethrow rate limit exceptions. All others will be
// swallowed silently.
rethrowRateLimitException(e);
logger.debug("Failed to send verification code", e);
}
}
private boolean checkVerificationCode(final byte[] sessionId, final String verificationCode)
throws RateLimitExceededException {
try {
return registrationServiceClient.checkVerificationCode(sessionId, verificationCode, REGISTRATION_RPC_TIMEOUT).join();
} catch (final CompletionException e) {
rethrowRateLimitException(e);
// For legacy API compatibility, funnel all errors into the same return value
return false;
}
}
private void clearUsernameLink(final Account account) { private void clearUsernameLink(final Account account) {
updateUsernameLink(account, null, null); updateUsernameLink(account, null, null);
} }
@ -1044,20 +525,6 @@ public class AccountController {
accounts.update(account, a -> a.setUsernameLinkDetails(usernameLinkHandle, encryptedUsername)); accounts.update(account, a -> a.setUsernameLinkDetails(usernameLinkHandle, encryptedUsername));
} }
private void rethrowRateLimitException(final CompletionException completionException)
throws RateLimitExceededException {
Throwable cause = completionException;
while (cause instanceof CompletionException) {
cause = cause.getCause();
}
if (cause instanceof RateLimitExceededException rateLimitExceededException) {
throw rateLimitExceededException;
}
}
private void requireNotAuthenticated(final Optional<AuthenticatedAccount> authenticatedAccount) { private void requireNotAuthenticated(final Optional<AuthenticatedAccount> authenticatedAccount) {
if (authenticatedAccount.isPresent()) { if (authenticatedAccount.isPresent()) {
throw new BadRequestException("Operation requires unauthenticated access"); throw new BadRequestException("Operation requires unauthenticated access");