Send a "verification code requested" push notification to existing accounts

This commit is contained in:
Jon Chambers 2026-05-19 12:44:26 -04:00 committed by Jon Chambers
parent 51f6e57bbd
commit 0945e953f2
7 changed files with 93 additions and 6 deletions

View File

@ -656,6 +656,9 @@ public class VerificationController {
acceptLanguage.orElse(null),
senderOverride,
REGISTRATION_RPC_TIMEOUT).join();
accountsManager.getByE164(registrationServiceSession.number()).ifPresent(existingAccount ->
pushNotificationManager.trySendVerificationCodeRequestedNotifications(existingAccount, clock.instant()));
} catch (final CancellationException e) {
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {

View File

@ -24,6 +24,7 @@ import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@ -98,11 +99,23 @@ public class APNSender implements Managed, PushNotificationSender {
.setContentAvailable(true)
.addCustomProperty("rateLimitChallenge", notification.data())
.build();
case VERIFICATION_CODE_REQUESTED -> {
if (!(notification.data() instanceof VerificationCodeRequestData(long timestamp))) {
throw new IllegalArgumentException("Notification did not have VerificationCodeRequestData");
}
yield new SimpleApnsPayloadBuilder()
.setMutableContent(true)
.setLocalizedAlertMessage("APN_Message")
.addCustomProperty("verificationCodeRequested", Map.of("timestamp", timestamp))
.build();
}
};
final PushType pushType = switch (notification.notificationType()) {
case NOTIFICATION -> notification.urgent() ? PushType.ALERT : PushType.BACKGROUND;
case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY -> PushType.ALERT;
case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, VERIFICATION_CODE_REQUESTED -> PushType.ALERT;
case CHALLENGE, RATE_LIMIT_CHALLENGE -> PushType.BACKGROUND;
};

View File

@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.push;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
@ -22,6 +23,7 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
@ -32,6 +34,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
import org.whispersystems.textsecuregcm.util.SystemMapper;
public class FcmSender implements PushNotificationSender {
@ -94,9 +97,25 @@ public class FcmSender implements PushNotificationSender {
case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY -> "attemptLoginContext";
case CHALLENGE -> "challenge";
case RATE_LIMIT_CHALLENGE -> "rateLimitChallenge";
case VERIFICATION_CODE_REQUESTED -> "verificationCodeRequested";
};
builder.putData(key, pushNotification.data() != null ? pushNotification.data().toString() : "");
final String data = switch (pushNotification.notificationType()) {
case VERIFICATION_CODE_REQUESTED -> {
if (!(pushNotification.data() instanceof VerificationCodeRequestData)) {
throw new IllegalArgumentException("Notification did not have VerificationCodeRequestData");
}
try {
yield SystemMapper.jsonMapper().writeValueAsString(pushNotification.data());
} catch (final JsonProcessingException e) {
throw new UncheckedIOException(e);
}
}
default -> pushNotification.data() != null ? pushNotification.data().toString() : "";
};
builder.putData(key, data);
final Timer.Sample sample = Timer.start();

View File

@ -29,7 +29,8 @@ public record PushNotification(String deviceToken,
NOTIFICATION,
ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY,
CHALLENGE,
RATE_LIMIT_CHALLENGE
RATE_LIMIT_CHALLENGE,
VERIFICATION_CODE_REQUESTED
}
public enum TokenType {

View File

@ -10,7 +10,10 @@ import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tags;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
@ -30,6 +33,8 @@ public class PushNotificationManager {
private final FcmSender fcmSender;
private final PushNotificationScheduler pushNotificationScheduler;
private static final Duration VERIFICATION_CODE_TTL = Duration.ofMinutes(10);
private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "sentPushNotification");
private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "failedPushNotification");
private static final String DEVICE_TOKEN_UNREGISTERED_COUNTER_NAME = name(PushNotificationManager.class, "deviceTokenUnregistered");
@ -82,6 +87,28 @@ public class PushNotificationManager {
.thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError("Responses must be present for urgent notifications")));
}
public CompletableFuture<Void> trySendVerificationCodeRequestedNotifications(final Account destination, final Instant requestTimestamp) {
final List<CompletableFuture<?>> sendNotificationFutures = new ArrayList<>();
for (final Device device : destination.getDevices()) {
try {
final Pair<String, PushNotification.TokenType> tokenAndType = getToken(device);
sendNotificationFutures.add(sendNotification(new PushNotification(tokenAndType.first(),
tokenAndType.second(),
PushNotification.NotificationType.VERIFICATION_CODE_REQUESTED,
new VerificationCodeRequestData(requestTimestamp.toEpochMilli()),
destination,
device,
true,
VERIFICATION_CODE_TTL)));
} catch (final NotPushRegisteredException _) {
}
}
return CompletableFuture.allOf(sendNotificationFutures.toArray(CompletableFuture[]::new));
}
public void handleMessagesRetrieved(final Account account, final Device device, final String userAgent) {
pushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors());
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.push;
record VerificationCodeRequestData(long timestamp) {
}

View File

@ -16,6 +16,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ -35,6 +36,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
@ -86,6 +88,7 @@ import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsMan
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;
@ExtendWith(DropwizardExtensionsSupport.class)
@ -109,7 +112,7 @@ class VerificationControllerTest {
private final RateLimiters rateLimiters = mock(RateLimiters.class);
private final AccountsManager accountsManager = mock(AccountsManager.class);
private final CarrierDataProvider carrierDataProvider = mock(CarrierDataProvider.class);
private final Clock clock = Clock.systemUTC();
private final Clock clock = TestClock.pinned(Instant.now());
private final RateLimiter captchaLimiter = mock(RateLimiter.class);
private final RateLimiter pushChallengeLimiter = mock(RateLimiter.class);
@ -1123,8 +1126,9 @@ class VerificationControllerTest {
}
}
@Test
void requestVerificationCodeSuccess() {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void requestVerificationCodeSuccess(final boolean accountExistsWithNumber) {
final String encodedSessionId = encodeSessionId(SESSION_ID);
final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,
false, null, null,
@ -1138,6 +1142,11 @@ class VerificationControllerTest {
when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(registrationServiceSession));
final Account existingAccount = mock(Account.class);
when(accountsManager.getByE164(any()))
.thenReturn(accountExistsWithNumber ? Optional.of(existingAccount) : Optional.empty());
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/verification/session/" + encodedSessionId + "/code")
.request()
@ -1150,6 +1159,12 @@ class VerificationControllerTest {
assertTrue(verificationSessionResponse.allowedToRequestCode());
assertTrue(verificationSessionResponse.requestedInformation().isEmpty());
if (accountExistsWithNumber) {
verify(pushNotificationManager).trySendVerificationCodeRequestedNotifications(existingAccount, clock.instant());
} else {
verify(pushNotificationManager, never()).trySendVerificationCodeRequestedNotifications(any(), any());
}
}
}