Send a "verification code requested" push notification to existing accounts
This commit is contained in:
parent
51f6e57bbd
commit
0945e953f2
@ -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) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user