mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 04:58:06 +01:00
Don't cache authenticated accounts in memory
This commit is contained in:
@@ -85,7 +85,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
|
||||
import org.whispersystems.textsecuregcm.auth.IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter;
|
||||
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
|
||||
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
|
||||
import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.ProhibitAuthenticationInterceptor;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.RequireAuthenticationInterceptor;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
@@ -212,7 +211,6 @@ import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamFilter;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountLockManager;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountPrincipalSupplier;
|
||||
import org.whispersystems.textsecuregcm.storage.Accounts;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
@@ -980,23 +978,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
environment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
environment.jersey().register(new AuthDynamicFeature(accountAuthFilter));
|
||||
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class));
|
||||
environment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager,
|
||||
disconnectionRequestManager));
|
||||
environment.jersey().register(new TimestampResponseFilter());
|
||||
|
||||
///
|
||||
WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment = new WebSocketEnvironment<>(environment,
|
||||
config.getWebSocketConfiguration(), Duration.ofMillis(90000));
|
||||
webSocketEnvironment.jersey().register(new VirtualExecutorServiceProvider("managed-async-websocket-virtual-thread-"));
|
||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator, new AccountPrincipalSupplier(accountsManager)));
|
||||
webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));
|
||||
webSocketEnvironment.setAuthenticatedWebSocketUpgradeFilter(new IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(
|
||||
keysManager, config.idlePrimaryDeviceReminderConfiguration().minIdleDuration(), Clock.systemUTC()));
|
||||
webSocketEnvironment.setConnectListener(
|
||||
new AuthenticatedConnectListener(receiptSender, messagesManager, messageMetrics, pushNotificationManager,
|
||||
new AuthenticatedConnectListener(accountsManager, receiptSender, messagesManager, messageMetrics, pushNotificationManager,
|
||||
pushNotificationScheduler, webSocketConnectionEventManager, websocketScheduledExecutor,
|
||||
messageDeliveryScheduler, clientReleaseManager, messageDeliveryLoopMonitor, experimentEnrollmentManager));
|
||||
webSocketEnvironment.jersey()
|
||||
.register(new WebsocketRefreshApplicationEventListener(accountsManager, disconnectionRequestManager));
|
||||
webSocketEnvironment.jersey().register(new RateLimitByIpFilter(rateLimiters));
|
||||
webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET));
|
||||
webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);
|
||||
@@ -1083,15 +1077,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
registrationLockVerificationManager, rateLimiters),
|
||||
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
|
||||
experimentEnrollmentManager),
|
||||
new ArchiveController(backupAuthManager, backupManager, backupMetrics),
|
||||
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics),
|
||||
new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),
|
||||
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
|
||||
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate(),
|
||||
new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),
|
||||
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()),
|
||||
zkAuthOperations, callingGenericZkSecretParams, clock),
|
||||
new ChallengeController(rateLimitChallengeManager, challengeConstraintChecker),
|
||||
new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
|
||||
new DeviceController(accountsManager, clientPublicKeysManager, rateLimiters, persistentTimer, config.getMaxDevices()),
|
||||
new DeviceCheckController(clock, backupAuthManager, appleDeviceCheckManager, rateLimiters,
|
||||
new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,
|
||||
config.getDeviceCheck().backupRedemptionLevel(),
|
||||
config.getDeviceCheck().backupRedemptionDuration()),
|
||||
new DirectoryV2Controller(directoryV2CredentialsGenerator),
|
||||
@@ -1140,8 +1134,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
|
||||
WebSocketEnvironment<AuthenticatedDevice> provisioningEnvironment = new WebSocketEnvironment<>(environment,
|
||||
webSocketEnvironment.getRequestLog(), Duration.ofMillis(60000));
|
||||
provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager,
|
||||
disconnectionRequestManager));
|
||||
provisioningEnvironment.setConnectListener(new ProvisioningConnectListener(provisioningManager, provisioningWebsocketTimeoutExecutor, Duration.ofSeconds(90)));
|
||||
provisioningEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET, clientReleaseManager));
|
||||
provisioningEnvironment.jersey().register(new KeepAliveController(webSocketConnectionEventManager));
|
||||
|
||||
@@ -7,10 +7,20 @@ package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AccountAndAuthenticatedDeviceHolder {
|
||||
|
||||
UUID getAccountIdentifier();
|
||||
|
||||
byte getDeviceId();
|
||||
|
||||
Instant getPrimaryDeviceLastSeen();
|
||||
|
||||
@Deprecated(forRemoval = true)
|
||||
Account getAccount();
|
||||
|
||||
@Deprecated(forRemoval = true)
|
||||
Device getAuthenticatedDevice();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import javax.security.auth.Subject;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@@ -30,6 +33,21 @@ public class AuthenticatedDevice implements Principal, AccountAndAuthenticatedDe
|
||||
return device;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getAccountIdentifier() {
|
||||
return account.getIdentifier(IdentityType.ACI);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte getDeviceId() {
|
||||
return device.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getPrimaryDeviceLastSeen() {
|
||||
return Instant.ofEpochMilli(account.getPrimaryDevice().getLastSeen());
|
||||
}
|
||||
|
||||
// Principal implementation
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,9 +31,9 @@ public class CertificateGenerator {
|
||||
this.serverCertificate = ServerCertificate.parseFrom(serverCertificate);
|
||||
}
|
||||
|
||||
public byte[] createFor(Account account, Device device, boolean includeE164) throws InvalidKeyException {
|
||||
public byte[] createFor(final Account account, final byte deviceId, boolean includeE164) throws InvalidKeyException {
|
||||
SenderCertificate.Certificate.Builder builder = SenderCertificate.Certificate.newBuilder()
|
||||
.setSenderDevice(Math.toIntExact(device.getId()))
|
||||
.setSenderDevice(Math.toIntExact(deviceId))
|
||||
.setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))
|
||||
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey(IdentityType.ACI).serialize()))
|
||||
.setSigner(serverCertificate)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import jakarta.ws.rs.core.SecurityContext;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
class ContainerRequestUtil {
|
||||
|
||||
/**
|
||||
* A read-only subset of the authenticated Account object, to enforce that filter-based consumers do not perform
|
||||
* account modifying operations.
|
||||
*/
|
||||
record AccountInfo(UUID accountId, String e164, Set<Byte> deviceIds) {
|
||||
|
||||
static AccountInfo fromAccount(final Account account) {
|
||||
return new AccountInfo(
|
||||
account.getUuid(),
|
||||
account.getNumber(),
|
||||
account.getDevices().stream().map(Device::getId).collect(Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
static Optional<AccountInfo> getAuthenticatedAccount(final ContainerRequest request) {
|
||||
return Optional.ofNullable(request.getSecurityContext())
|
||||
.map(SecurityContext::getUserPrincipal)
|
||||
.map(principal -> {
|
||||
if (principal instanceof AccountAndAuthenticatedDeviceHolder aaadh) {
|
||||
return aaadh.getAccount();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.map(AccountInfo::fromAccount);
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,16 @@ package org.whispersystems.textsecuregcm.auth;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.KeysManager;
|
||||
import org.whispersystems.websocket.ReusableAuth;
|
||||
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.KeysManager;
|
||||
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;
|
||||
|
||||
public class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements
|
||||
AuthenticatedWebSocketUpgradeFilter<AuthenticatedDevice> {
|
||||
@@ -58,21 +57,19 @@ public class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAuthentication(final ReusableAuth<AuthenticatedDevice> authenticated,
|
||||
public void handleAuthentication(final Optional<AuthenticatedDevice> authenticated,
|
||||
final JettyServerUpgradeRequest request,
|
||||
final JettyServerUpgradeResponse response) {
|
||||
|
||||
// No action needed if the connection is unauthenticated (in which case we don't know when we've last seen the
|
||||
// primary device) or if the authenticated device IS the primary device
|
||||
authenticated.ref()
|
||||
.filter(authenticatedDevice -> !authenticatedDevice.getAuthenticatedDevice().isPrimary())
|
||||
authenticated
|
||||
.filter(authenticatedDevice -> authenticatedDevice.getDeviceId() != Device.PRIMARY_ID)
|
||||
.ifPresent(authenticatedDevice -> {
|
||||
final Instant primaryDeviceLastSeen =
|
||||
Instant.ofEpochMilli(authenticatedDevice.getAccount().getPrimaryDevice().getLastSeen());
|
||||
final Instant primaryDeviceLastSeen = authenticatedDevice.getPrimaryDeviceLastSeen();
|
||||
|
||||
if (primaryDeviceLastSeen.isBefore(clock.instant().minus(PQ_KEY_CHECK_THRESHOLD)) &&
|
||||
keysManager.getLastResort(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI), Device.PRIMARY_ID)
|
||||
.join().isEmpty()) {
|
||||
keysManager.getLastResort(authenticatedDevice.getAccountIdentifier(), Device.PRIMARY_ID).join().isEmpty()) {
|
||||
|
||||
response.addHeader(ALERT_HEADER, CRITICAL_IDLE_PRIMARY_DEVICE_ALERT);
|
||||
CRITICAL_IDLE_PRIMARY_WARNING_COUNTER.increment();
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
/**
|
||||
* This {@link WebsocketRefreshRequirementProvider} observes intra-request changes in devices linked to an
|
||||
* {@link Account} and triggers a WebSocket refresh if that set changes. If a change in linked devices is observed, then
|
||||
* any active WebSocket connections for the account must be closed in order for clients to get a refreshed
|
||||
* {@link io.dropwizard.auth.Auth} object with a current device list.
|
||||
*
|
||||
* @see AuthenticatedDevice
|
||||
*/
|
||||
public class LinkedDeviceRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LinkedDeviceRefreshRequirementProvider.class);
|
||||
|
||||
private static final String ACCOUNT_UUID = LinkedDeviceRefreshRequirementProvider.class.getName() + ".accountUuid";
|
||||
private static final String LINKED_DEVICE_IDS = LinkedDeviceRefreshRequirementProvider.class.getName() + ".deviceIds";
|
||||
|
||||
public LinkedDeviceRefreshRequirementProvider(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod().getAnnotation(
|
||||
ChangesLinkedDevices.class) != null) {
|
||||
// The authenticated principal, if any, will be available after filters have run. Now that the account is known,
|
||||
// capture a snapshot of the account's linked devices before carrying out the request’s business logic.
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
|
||||
.ifPresent(account -> setAccount(requestEvent.getContainerRequest(), account));
|
||||
}
|
||||
}
|
||||
|
||||
public static void setAccount(final ContainerRequest containerRequest, final Account account) {
|
||||
setAccount(containerRequest, ContainerRequestUtil.AccountInfo.fromAccount(account));
|
||||
}
|
||||
|
||||
private static void setAccount(final ContainerRequest containerRequest, final ContainerRequestUtil.AccountInfo info) {
|
||||
containerRequest.setProperty(ACCOUNT_UUID, info.accountId());
|
||||
containerRequest.setProperty(LINKED_DEVICE_IDS, info.deviceIds());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
// Now that the request is finished, check whether the set of linked devices has changed. If the value did change or
|
||||
// if a devices was added or removed, all devices must disconnect and reauthenticate.
|
||||
if (requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS) != null) {
|
||||
|
||||
@SuppressWarnings("unchecked") final Set<Byte> initialLinkedDeviceIds =
|
||||
(Set<Byte>) requestEvent.getContainerRequest().getProperty(LINKED_DEVICE_IDS);
|
||||
|
||||
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
|
||||
.map(ContainerRequestUtil.AccountInfo::fromAccount)
|
||||
.map(accountInfo -> {
|
||||
final Set<Byte> deviceIdsToDisplace;
|
||||
final Set<Byte> currentLinkedDeviceIds = accountInfo.deviceIds();
|
||||
|
||||
if (!initialLinkedDeviceIds.equals(currentLinkedDeviceIds)) {
|
||||
deviceIdsToDisplace = new HashSet<>(initialLinkedDeviceIds);
|
||||
deviceIdsToDisplace.addAll(currentLinkedDeviceIds);
|
||||
} else {
|
||||
deviceIdsToDisplace = Collections.emptySet();
|
||||
}
|
||||
|
||||
return deviceIdsToDisplace.stream()
|
||||
.map(deviceId -> new Pair<>(accountInfo.accountId(), deviceId))
|
||||
.collect(Collectors.toList());
|
||||
}).orElseGet(() -> {
|
||||
logger.error("Request had account, but it is no longer present");
|
||||
return Collections.emptyList();
|
||||
});
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class PhoneNumberChangeRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {
|
||||
|
||||
private static final String ACCOUNT_UUID =
|
||||
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".accountUuid";
|
||||
|
||||
private static final String INITIAL_NUMBER_KEY =
|
||||
PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".initialNumber";
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public PhoneNumberChangeRefreshRequirementProvider(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRequestFiltered(final RequestEvent requestEvent) {
|
||||
if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod()
|
||||
.getAnnotation(ChangesPhoneNumber.class) == null) {
|
||||
return;
|
||||
}
|
||||
ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest())
|
||||
.ifPresent(account -> {
|
||||
requestEvent.getContainerRequest().setProperty(INITIAL_NUMBER_KEY, account.e164());
|
||||
requestEvent.getContainerRequest().setProperty(ACCOUNT_UUID, account.accountId());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<UUID, Byte>> handleRequestFinished(final RequestEvent requestEvent) {
|
||||
final String initialNumber = (String) requestEvent.getContainerRequest().getProperty(INITIAL_NUMBER_KEY);
|
||||
|
||||
if (initialNumber == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID))
|
||||
.filter(account -> !initialNumber.equals(account.getNumber()))
|
||||
.map(account -> account.getDevices().stream()
|
||||
.map(device -> new Pair<>(account.getUuid(), device.getId()))
|
||||
.collect(Collectors.toList()))
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import org.glassfish.jersey.server.monitoring.ApplicationEvent;
|
||||
import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEventListener;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
/**
|
||||
* Delegates request events to a listener that watches for intra-request changes that require websocket refreshes
|
||||
*/
|
||||
public class WebsocketRefreshApplicationEventListener implements ApplicationEventListener {
|
||||
|
||||
private final WebsocketRefreshRequestEventListener websocketRefreshRequestEventListener;
|
||||
|
||||
public WebsocketRefreshApplicationEventListener(final AccountsManager accountsManager,
|
||||
final DisconnectionRequestManager disconnectionRequestManager) {
|
||||
|
||||
this.websocketRefreshRequestEventListener = new WebsocketRefreshRequestEventListener(
|
||||
disconnectionRequestManager,
|
||||
new LinkedDeviceRefreshRequirementProvider(accountsManager),
|
||||
new PhoneNumberChangeRefreshRequirementProvider(accountsManager));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvent(final ApplicationEvent event) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestEventListener onRequest(final RequestEvent requestEvent) {
|
||||
return websocketRefreshRequestEventListener;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import jakarta.ws.rs.container.ResourceInfo;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent.Type;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEventListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class WebsocketRefreshRequestEventListener implements RequestEventListener {
|
||||
|
||||
private final DisconnectionRequestManager disconnectionRequestManager;
|
||||
private final WebsocketRefreshRequirementProvider[] providers;
|
||||
|
||||
private static final Counter DISPLACED_ACCOUNTS = Metrics.counter(
|
||||
name(WebsocketRefreshRequestEventListener.class, "displacedAccounts"));
|
||||
|
||||
private static final Counter DISPLACED_DEVICES = Metrics.counter(
|
||||
name(WebsocketRefreshRequestEventListener.class, "displacedDevices"));
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebsocketRefreshRequestEventListener.class);
|
||||
|
||||
public WebsocketRefreshRequestEventListener(
|
||||
final DisconnectionRequestManager disconnectionRequestManager,
|
||||
final WebsocketRefreshRequirementProvider... providers) {
|
||||
|
||||
this.disconnectionRequestManager = disconnectionRequestManager;
|
||||
this.providers = providers;
|
||||
}
|
||||
|
||||
@Context
|
||||
private ResourceInfo resourceInfo;
|
||||
|
||||
@Override
|
||||
public void onEvent(final RequestEvent event) {
|
||||
if (event.getType() == Type.REQUEST_FILTERED) {
|
||||
for (final WebsocketRefreshRequirementProvider provider : providers) {
|
||||
provider.handleRequestFiltered(event);
|
||||
}
|
||||
} else if (event.getType() == Type.FINISHED) {
|
||||
final AtomicInteger displacedDevices = new AtomicInteger(0);
|
||||
|
||||
Arrays.stream(providers)
|
||||
.flatMap(provider -> provider.handleRequestFinished(event).stream())
|
||||
.distinct()
|
||||
.forEach(pair -> {
|
||||
try {
|
||||
displacedDevices.incrementAndGet();
|
||||
disconnectionRequestManager.requestDisconnection(pair.first(), List.of(pair.second()));
|
||||
} catch (final Exception e) {
|
||||
logger.error("Could not displace device presence", e);
|
||||
}
|
||||
});
|
||||
|
||||
if (displacedDevices.get() > 0) {
|
||||
DISPLACED_ACCOUNTS.increment();
|
||||
DISPLACED_DEVICES.increment(displacedDevices.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2013-2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.glassfish.jersey.server.monitoring.RequestEvent;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
/**
|
||||
* A websocket refresh requirement provider watches for intra-request changes (e.g. to authentication status) that
|
||||
* require a websocket refresh.
|
||||
*/
|
||||
public interface WebsocketRefreshRequirementProvider {
|
||||
|
||||
/**
|
||||
* Processes a request after filters have run and the request has been mapped to a destination controller.
|
||||
*
|
||||
* @param requestEvent the request event to observe
|
||||
*/
|
||||
void handleRequestFiltered(RequestEvent requestEvent);
|
||||
|
||||
/**
|
||||
* Processes a request after all normal request handling has been completed.
|
||||
*
|
||||
* @param requestEvent the request event to observe
|
||||
* @return a list of pairs of account UUID/device ID pairs identifying websockets that need to be refreshed as a
|
||||
* result of the observed request
|
||||
*/
|
||||
List<Pair<UUID, Byte>> handleRequestFinished(RequestEvent requestEvent);
|
||||
}
|
||||
@@ -66,8 +66,6 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/accounts")
|
||||
@@ -97,11 +95,14 @@ public class AccountController {
|
||||
@Path("/gcm/")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
|
||||
public void setGcmRegistrationId(@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid GcmRegistrationId registrationId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
if (Objects.equals(device.getGcmId(), registrationId.gcmRegistrationId())) {
|
||||
return;
|
||||
@@ -116,9 +117,12 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/gcm/")
|
||||
public void deleteGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
Account account = auth.getAccount();
|
||||
Device device = auth.getAuthenticatedDevice();
|
||||
public void deleteGcmRegistrationId(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, device.getId(), d -> {
|
||||
d.setGcmId(null);
|
||||
@@ -131,11 +135,14 @@ public class AccountController {
|
||||
@Path("/apn/")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
|
||||
public void setApnRegistrationId(@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid ApnRegistrationId registrationId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
// Unlike FCM tokens, we need current "last updated" timestamps for APNs tokens and so update device records
|
||||
// unconditionally
|
||||
@@ -148,9 +155,12 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/apn/")
|
||||
public void deleteApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
Account account = auth.getAccount();
|
||||
Device device = auth.getAuthenticatedDevice();
|
||||
public void deleteApnRegistrationId(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, device.getId(), d -> {
|
||||
d.setApnId(null);
|
||||
@@ -166,17 +176,23 @@ public class AccountController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/registration_lock")
|
||||
public void setRegistrationLock(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
|
||||
SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
|
||||
public void setRegistrationLock(@Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
|
||||
final SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
|
||||
|
||||
accounts.update(auth.getAccount(),
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.update(account,
|
||||
a -> a.setRegistrationLock(credentials.hash(), credentials.salt()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/registration_lock")
|
||||
public void removeRegistrationLock(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
accounts.update(auth.getAccount(), a -> a.setRegistrationLock(null, null));
|
||||
public void removeRegistrationLock(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.update(account, a -> a.setRegistrationLock(null, null));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -190,7 +206,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "204", description = "Device name changed successfully")
|
||||
@ApiResponse(responseCode = "404", description = "No device found with the given ID")
|
||||
@ApiResponse(responseCode = "403", description = "Not authorized to change the name of the device with the given ID")
|
||||
public void setName(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public void setName(@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final DeviceName deviceName,
|
||||
|
||||
@Nullable
|
||||
@@ -199,15 +215,16 @@ public class AccountController {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
final Byte deviceId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final byte targetDeviceId = deviceId == null ? auth.getAuthenticatedDevice().getId() : deviceId;
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final byte targetDeviceId = deviceId == null ? auth.getDeviceId() : deviceId;
|
||||
|
||||
if (account.getDevice(targetDeviceId).isEmpty()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
final boolean mayChangeName = auth.getAuthenticatedDevice().isPrimary() ||
|
||||
auth.getAuthenticatedDevice().getId() == targetDeviceId;
|
||||
final boolean mayChangeName = auth.getDeviceId() == Device.PRIMARY_ID || auth.getDeviceId() == targetDeviceId;
|
||||
|
||||
if (!mayChangeName) {
|
||||
throw new ForbiddenException();
|
||||
@@ -221,14 +238,14 @@ public class AccountController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setAccountAttributes(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid AccountAttributes attributes) {
|
||||
final Account account = auth.getAccount();
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Account updatedAccount = accounts.update(account, a -> {
|
||||
a.getDevice(deviceId).ifPresent(d -> {
|
||||
a.getDevice(auth.getDeviceId()).ifPresent(d -> {
|
||||
d.setFetchesMessages(attributes.getFetchesMessages());
|
||||
d.setName(attributes.getName());
|
||||
d.setLastSeen(Util.todayInMillis());
|
||||
@@ -252,8 +269,11 @@ public class AccountController {
|
||||
@GET
|
||||
@Path("/whoami")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse whoAmI(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return AccountIdentityResponseBuilder.fromAccount(auth.getAccount());
|
||||
public AccountIdentityResponse whoAmI(@Auth final AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return AccountIdentityResponseBuilder.fromAccount(account);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@@ -267,8 +287,11 @@ public class AccountController {
|
||||
)
|
||||
@ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public CompletableFuture<Response> deleteUsernameHash(@Mutable @Auth final AuthenticatedDevice auth) {
|
||||
return accounts.clearUsernameHash(auth.getAccount())
|
||||
public CompletableFuture<Response> deleteUsernameHash(@Auth final AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.clearUsernameHash(account)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
@@ -289,10 +312,13 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<ReserveUsernameHashResponse> reserveUsernameHash(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
for (final byte[] hash : usernameRequest.usernameHashes()) {
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
@@ -300,7 +326,7 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
return accounts.reserveUsernameHash(auth.getAccount(), usernameRequest.usernameHashes())
|
||||
return accounts.reserveUsernameHash(account, usernameRequest.usernameHashes())
|
||||
.thenApply(reservation -> new ReserveUsernameHashResponse(reservation.reservedUsernameHash()))
|
||||
.exceptionally(throwable -> {
|
||||
if (ExceptionUtils.unwrap(throwable) instanceof UsernameHashNotAvailableException) {
|
||||
@@ -329,18 +355,21 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<UsernameHashResponse> confirmUsernameHash(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) {
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash());
|
||||
} catch (final BaseUsernameException e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameSetLimiter().validateAsync(auth.getAccount().getUuid())
|
||||
return rateLimiters.getUsernameSetLimiter().validateAsync(account.getUuid())
|
||||
.thenCompose(ignored -> accounts.confirmReservedUsernameHash(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
confirmRequest.usernameHash(),
|
||||
confirmRequest.encryptedUsername()))
|
||||
.thenApply(updatedAccount -> new UsernameHashResponse(updatedAccount.getUsernameHash()
|
||||
@@ -374,7 +403,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "400", description = "Request must not be authenticated.")
|
||||
@ApiResponse(responseCode = "404", description = "Account not found for the given username.")
|
||||
public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@PathParam("usernameHash") final String usernameHash) {
|
||||
|
||||
requireNotAuthenticated(maybeAuthenticatedAccount);
|
||||
@@ -413,12 +442,14 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public UsernameLinkHandle updateUsernameLink(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final EncryptedUsername encryptedUsername) throws RateLimitExceededException {
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccountIdentifier());
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
// check if username hash is set for the account
|
||||
if (account.getUsernameHash().isEmpty()) {
|
||||
@@ -431,7 +462,7 @@ public class AccountController {
|
||||
} else {
|
||||
usernameLinkHandle = UUID.randomUUID();
|
||||
}
|
||||
updateUsernameLink(auth.getAccount(), usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
|
||||
updateUsernameLink(account, usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
|
||||
return new UsernameLinkHandle(usernameLinkHandle);
|
||||
}
|
||||
|
||||
@@ -447,10 +478,14 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "204", description = "Username Link successfully deleted.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public void deleteUsernameLink(@Mutable @Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
|
||||
public void deleteUsernameLink(@Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
|
||||
clearUsernameLink(auth.getAccount());
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccountIdentifier());
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
clearUsernameLink(account);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -470,7 +505,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<EncryptedUsername> lookupUsernameLink(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@PathParam("uuid") final UUID usernameLinkHandle) {
|
||||
|
||||
requireNotAuthenticated(maybeAuthenticatedAccount);
|
||||
@@ -496,7 +531,7 @@ public class AccountController {
|
||||
@Path("/account/{identifier}")
|
||||
@RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)
|
||||
public Response accountExists(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description = "An ACI or PNI account identifier to check")
|
||||
@PathParam("identifier") final ServiceIdentifier accountIdentifier) {
|
||||
@@ -511,8 +546,11 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/me")
|
||||
public CompletableFuture<Response> deleteAccount(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
return accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
public CompletableFuture<Response> deleteAccount(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.delete(account, AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
private void clearUsernameLink(final Account account) {
|
||||
|
||||
@@ -55,8 +55,7 @@ import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@Path("/v2/accounts")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
|
||||
@@ -101,12 +100,12 @@ public class AccountControllerV2 {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public AccountIdentityResponse changeNumber(@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@NotNull @Valid final ChangeNumberRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
|
||||
@Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
|
||||
if (authenticatedDevice.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -116,8 +115,11 @@ public class AccountControllerV2 {
|
||||
|
||||
final String number = request.number();
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
// Only verify and check reglock if there's a data change to be made...
|
||||
if (!authenticatedDevice.getAccount().getNumber().equals(number)) {
|
||||
if (!account.getNumber().equals(number)) {
|
||||
|
||||
rateLimiters.getRegistrationLimiter().validate(number);
|
||||
|
||||
@@ -139,7 +141,7 @@ public class AccountControllerV2 {
|
||||
// ...but always attempt to make the change in case a client retries and needs to re-send messages
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.changeNumber(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
request.number(),
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
@@ -185,11 +187,11 @@ public class AccountControllerV2 {
|
||||
content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))
|
||||
@ApiResponse(responseCode = "413", description = "One or more device messages was too large")
|
||||
public AccountIdentityResponse distributePhoneNumberIdentityKeys(
|
||||
@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString,
|
||||
@NotNull @Valid final PhoneNumberIdentityKeyDistributionRequest request) {
|
||||
|
||||
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
|
||||
if (authenticatedDevice.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -197,9 +199,12 @@ public class AccountControllerV2 {
|
||||
throw new WebApplicationException("Invalid signature", 422);
|
||||
}
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.updatePniKeys(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
request.devicePniPqLastResortPrekeys(),
|
||||
@@ -235,10 +240,13 @@ public class AccountControllerV2 {
|
||||
@Operation(summary = "Sets whether the account should be discoverable by phone number in the directory.")
|
||||
@ApiResponse(responseCode = "204", description = "The setting was successfully updated.")
|
||||
public void setPhoneNumberDiscoverability(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability
|
||||
) {
|
||||
accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber(
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(
|
||||
phoneNumberDiscoverability.discoverableByPhoneNumber()));
|
||||
}
|
||||
|
||||
@@ -249,9 +257,10 @@ public class AccountControllerV2 {
|
||||
@ApiResponse(responseCode = "200",
|
||||
description = "Response with data report. A plain text representation is a field in the response.",
|
||||
useReturnTypeSchema = true)
|
||||
public AccountDataReportResponse getAccountDataReport(@ReadOnly @Auth final AuthenticatedDevice auth) {
|
||||
public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedDevice auth) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(),
|
||||
new AccountDataReportResponse.AccountAndDevicesDataReport(
|
||||
|
||||
@@ -37,6 +37,7 @@ import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
@@ -71,14 +72,15 @@ import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
|
||||
import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
|
||||
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.BackupAuthCredentialAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Path("/v1/archives")
|
||||
@@ -88,14 +90,18 @@ public class ArchiveController {
|
||||
public final static String X_SIGNAL_ZK_AUTH = "X-Signal-ZK-Auth";
|
||||
public final static String X_SIGNAL_ZK_AUTH_SIGNATURE = "X-Signal-ZK-Auth-Signature";
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final BackupAuthManager backupAuthManager;
|
||||
private final BackupManager backupManager;
|
||||
private final BackupMetrics backupMetrics;
|
||||
|
||||
public ArchiveController(
|
||||
final AccountsManager accountsManager,
|
||||
final BackupAuthManager backupAuthManager,
|
||||
final BackupManager backupManager,
|
||||
final BackupMetrics backupMetrics) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.backupAuthManager = backupAuthManager;
|
||||
this.backupManager = backupManager;
|
||||
this.backupMetrics = backupMetrics;
|
||||
@@ -138,13 +144,22 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "403", description = "The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made")
|
||||
public CompletionStage<Response> setBackupId(
|
||||
@Mutable @Auth final AuthenticatedDevice account,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
|
||||
return this.backupAuthManager
|
||||
.commitBackupId(account.getAccount(), account.getAuthenticatedDevice(),
|
||||
setBackupIdRequest.messagesBackupAuthCredentialRequest,
|
||||
setBackupIdRequest.mediaBackupAuthCredentialRequest)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return backupAuthManager
|
||||
.commitBackupId(account, device, setBackupIdRequest.messagesBackupAuthCredentialRequest,
|
||||
setBackupIdRequest.mediaBackupAuthCredentialRequest)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
public record RedeemBackupReceiptRequest(
|
||||
@@ -188,12 +203,17 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> redeemReceipt(
|
||||
@Mutable @Auth final AuthenticatedDevice account,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest) {
|
||||
return this.backupAuthManager.redeemReceipt(
|
||||
account.getAccount(),
|
||||
redeemBackupReceiptRequest.receiptCredentialPresentation())
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation())
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
public record BackupAuthCredentialsResponse(
|
||||
@@ -252,7 +272,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "404", description = "Could not find an existing blinded backup id")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<BackupAuthCredentialsResponse> getBackupZKCredentials(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice authenticatedDevice,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
|
||||
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
|
||||
@@ -260,27 +280,33 @@ public class ArchiveController {
|
||||
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
auth.getAccount(),
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> {
|
||||
backupMetrics.updateGetCredentialCounter(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
credentialType,
|
||||
credentials.size());
|
||||
credentialsByType.put(credentialType, credentials.stream()
|
||||
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
credential.credential().serialize(),
|
||||
credential.redemptionTime().getEpochSecond()))
|
||||
.toList());
|
||||
}))
|
||||
.toArray(CompletableFuture[]::new))
|
||||
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
|
||||
Map.Entry::getValue))));
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
account,
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> {
|
||||
backupMetrics.updateGetCredentialCounter(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
credentialType,
|
||||
credentials.size());
|
||||
credentialsByType.put(credentialType, credentials.stream()
|
||||
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
credential.credential().serialize(),
|
||||
credential.redemptionTime().getEpochSecond()))
|
||||
.toList());
|
||||
}))
|
||||
.toArray(CompletableFuture[]::new))
|
||||
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
|
||||
Map.Entry::getValue))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +369,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<ReadAuthResponse> readAuth(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -395,7 +421,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<BackupInfoResponse> backupInfo(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -441,7 +467,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "204", description = "The public key was set")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> setPublicKey(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@NotNull
|
||||
@@ -481,7 +507,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<UploadDescriptorResponse> backup(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -518,7 +544,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<UploadDescriptorResponse> uploadTemporaryAttachment(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
|
||||
@@ -606,7 +632,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<CopyMediaResponse> copyMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -705,7 +731,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> copyMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -744,7 +770,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> refresh(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -811,7 +837,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<ListResponse> listMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -867,7 +893,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> deleteMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -904,7 +930,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> deleteBackup(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
|
||||
@@ -26,7 +26,6 @@ import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
|
||||
/**
|
||||
@@ -78,11 +77,11 @@ public class AttachmentControllerV4 {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public AttachmentDescriptorV3 getAttachmentUploadForm(@ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiter.validate(auth.getAccount().getUuid());
|
||||
rateLimiter.validate(auth.getAccountIdentifier());
|
||||
final String key = generateAttachmentKey();
|
||||
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccount().getUuid(), CDN3_EXPERIMENT_NAME);
|
||||
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccountIdentifier(), CDN3_EXPERIMENT_NAME);
|
||||
int cdn = useCdn3 ? 3 : 2;
|
||||
final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);
|
||||
return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation());
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateCallLinkCredential;
|
||||
import org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/call-link")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink")
|
||||
@@ -52,11 +51,11 @@ public class CallLinkController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CreateCallLinkCredential getCreateAuth(
|
||||
final @ReadOnly @Auth AuthenticatedDevice auth,
|
||||
final @Auth AuthenticatedDevice auth,
|
||||
final @NotNull @Valid GetCreateCallLinkCredentialsRequest request
|
||||
) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
final Instant truncatedDayTimestamp = Instant.now().truncatedTo(ChronoUnit.DAYS);
|
||||
|
||||
@@ -68,7 +67,7 @@ public class CallLinkController {
|
||||
}
|
||||
|
||||
return new CreateCallLinkCredential(
|
||||
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.getAccount().getUuid()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
|
||||
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.getAccountIdentifier()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
|
||||
truncatedDayTimestamp.getEpochSecond()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
|
||||
@Path("/v2/calling")
|
||||
@@ -56,11 +54,10 @@ public class CallRoutingControllerV2 {
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public GetCallingRelaysResponse getCallingRelays(final @ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public GetCallingRelaysResponse getCallingRelays(final @Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException, IOException {
|
||||
|
||||
final UUID aci = auth.getAccount().getUuid();
|
||||
rateLimiters.getCallEndpointLimiter().validate(aci);
|
||||
rateLimiters.getCallEndpointLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
try {
|
||||
return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));
|
||||
|
||||
@@ -8,18 +8,18 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
@@ -38,13 +38,16 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
|
||||
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
|
||||
import org.whispersystems.textsecuregcm.entities.GroupCredentials;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/certificate")
|
||||
@Tag(name = "Certificate")
|
||||
public class CertificateController {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final CertificateGenerator certificateGenerator;
|
||||
private final ServerZkAuthOperations serverZkAuthOperations;
|
||||
private final GenericServerSecretParams genericServerSecretParams;
|
||||
@@ -56,10 +59,13 @@ public class CertificateController {
|
||||
private static final String INCLUDE_E164_TAG_NAME = "includeE164";
|
||||
|
||||
public CertificateController(
|
||||
final AccountsManager accountsManager,
|
||||
@Nonnull CertificateGenerator certificateGenerator,
|
||||
@Nonnull ServerZkAuthOperations serverZkAuthOperations,
|
||||
@Nonnull GenericServerSecretParams genericServerSecretParams,
|
||||
@Nonnull Clock clock) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.certificateGenerator = Objects.requireNonNull(certificateGenerator);
|
||||
this.serverZkAuthOperations = Objects.requireNonNull(serverZkAuthOperations);
|
||||
this.genericServerSecretParams = genericServerSecretParams;
|
||||
@@ -69,23 +75,25 @@ public class CertificateController {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/delivery")
|
||||
public DeliveryCertificate getDeliveryCertificate(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
|
||||
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
|
||||
throws InvalidKeyException {
|
||||
|
||||
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
|
||||
.increment();
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new DeliveryCertificate(
|
||||
certificateGenerator.createFor(auth.getAccount(), auth.getAuthenticatedDevice(), includeE164));
|
||||
certificateGenerator.createFor(account, auth.getDeviceId(), includeE164));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/auth/group")
|
||||
public GroupCredentials getGroupAuthenticationCredentials(
|
||||
@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@QueryParam("redemptionStartSeconds") long startSeconds,
|
||||
@QueryParam("redemptionEndSeconds") long endSeconds) {
|
||||
|
||||
@@ -102,13 +110,16 @@ public class CertificateController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final List<GroupCredentials.GroupCredential> credentials = new ArrayList<>();
|
||||
final List<GroupCredentials.CallLinkAuthCredential> callLinkAuthCredentials = new ArrayList<>();
|
||||
|
||||
Instant redemption = redemptionStart;
|
||||
|
||||
ServiceId.Aci aci = new ServiceId.Aci(auth.getAccount().getUuid());
|
||||
ServiceId.Pni pni = new ServiceId.Pni(auth.getAccount().getPhoneNumberIdentifier());
|
||||
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
|
||||
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
|
||||
|
||||
while (!redemption.isAfter(redemptionEnd)) {
|
||||
AuthCredentialWithPniResponse authCredentialWithPni = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption);
|
||||
|
||||
@@ -25,6 +25,7 @@ import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -40,12 +41,14 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;
|
||||
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
@Path("/v1/challenge")
|
||||
@Tag(name = "Challenge")
|
||||
public class ChallengeController {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final RateLimitChallengeManager rateLimitChallengeManager;
|
||||
private final ChallengeConstraintChecker challengeConstraintChecker;
|
||||
|
||||
@@ -53,8 +56,10 @@ public class ChallengeController {
|
||||
private static final String CHALLENGE_TYPE_TAG = "type";
|
||||
|
||||
public ChallengeController(
|
||||
final AccountsManager accountsManager,
|
||||
final RateLimitChallengeManager rateLimitChallengeManager,
|
||||
final ChallengeConstraintChecker challengeConstraintChecker) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.rateLimitChallengeManager = rateLimitChallengeManager;
|
||||
this.challengeConstraintChecker = challengeConstraintChecker;
|
||||
}
|
||||
@@ -77,15 +82,18 @@ public class ChallengeController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response handleChallengeResponse(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public Response handleChallengeResponse(@Auth final AuthenticatedDevice auth,
|
||||
@Valid final AnswerChallengeRequest answerRequest,
|
||||
@Context ContainerRequestContext requestContext,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
|
||||
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
|
||||
requestContext, auth.getAccount());
|
||||
requestContext, account);
|
||||
try {
|
||||
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
|
||||
@@ -93,14 +101,14 @@ public class ChallengeController {
|
||||
if (!constraints.pushPermitted()) {
|
||||
return Response.status(429).build();
|
||||
}
|
||||
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
|
||||
rateLimitChallengeManager.answerPushChallenge(account, pushChallengeRequest.getChallenge());
|
||||
} else if (answerRequest instanceof AnswerCaptchaChallengeRequest captchaChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "captcha");
|
||||
|
||||
final String remoteAddress = (String) requestContext.getProperty(
|
||||
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
|
||||
boolean success = rateLimitChallengeManager.answerCaptchaChallenge(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
captchaChallengeRequest.getCaptcha(),
|
||||
remoteAddress,
|
||||
userAgent,
|
||||
@@ -126,7 +134,7 @@ public class ChallengeController {
|
||||
summary = "Request a push challenge",
|
||||
description = """
|
||||
Clients may proactively request a push challenge by making an empty POST request. Push challenges will only be
|
||||
sent to the requesting account’s main device. When the push is received it may be provided as proof of completed
|
||||
sent to the requesting account’s main device. When the push is received it may be provided as proof of completed
|
||||
challenge to /v1/challenge.
|
||||
APNs challenge payloads will be formatted as follows:
|
||||
```
|
||||
@@ -140,12 +148,12 @@ public class ChallengeController {
|
||||
"rateLimitChallenge": "{CHALLENGE_TOKEN}"
|
||||
}
|
||||
```
|
||||
FCM challenge payloads will be formatted as follows:
|
||||
FCM challenge payloads will be formatted as follows:
|
||||
```
|
||||
{"rateLimitChallenge": "{CHALLENGE_TOKEN}"}
|
||||
```
|
||||
|
||||
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
|
||||
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
|
||||
implement an exponential back-off system and limit the total number of retries.
|
||||
"""
|
||||
)
|
||||
@@ -163,15 +171,18 @@ public class ChallengeController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response requestPushChallenge(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public Response requestPushChallenge(@Auth final AuthenticatedDevice auth,
|
||||
@Context ContainerRequestContext requestContext) {
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
|
||||
requestContext, auth.getAccount());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
|
||||
if (!constraints.pushPermitted()) {
|
||||
return Response.status(429).build();
|
||||
}
|
||||
try {
|
||||
rateLimitChallengeManager.sendPushChallenge(auth.getAccount());
|
||||
rateLimitChallengeManager.sendPushChallenge(account);
|
||||
return Response.status(200).build();
|
||||
} catch (final NotPushRegisteredException e) {
|
||||
return Response.status(404).build();
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;
|
||||
@@ -41,7 +42,6 @@ import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyEx
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
/**
|
||||
* Process platform device attestations.
|
||||
@@ -55,6 +55,7 @@ import org.whispersystems.websocket.auth.ReadOnly;
|
||||
public class DeviceCheckController {
|
||||
|
||||
private final Clock clock;
|
||||
private final AccountsManager accountsManager;
|
||||
private final BackupAuthManager backupAuthManager;
|
||||
private final AppleDeviceCheckManager deviceCheckManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
@@ -63,12 +64,14 @@ public class DeviceCheckController {
|
||||
|
||||
public DeviceCheckController(
|
||||
final Clock clock,
|
||||
final AccountsManager accountsManager,
|
||||
final BackupAuthManager backupAuthManager,
|
||||
final AppleDeviceCheckManager deviceCheckManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final long backupRedemptionLevel,
|
||||
final Duration backupRedemptionDuration) {
|
||||
this.clock = clock;
|
||||
this.accountsManager = accountsManager;
|
||||
this.backupAuthManager = backupAuthManager;
|
||||
this.deviceCheckManager = deviceCheckManager;
|
||||
this.backupRedemptionLevel = backupRedemptionLevel;
|
||||
@@ -94,14 +97,17 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "200", description = "The response body includes a challenge")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
@ManagedAsync
|
||||
public ChallengeResponse attestChallenge(@ReadOnly @Auth AuthenticatedDevice authenticatedDevice)
|
||||
public ChallengeResponse attestChallenge(@Auth AuthenticatedDevice authenticatedDevice)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
|
||||
.validate(authenticatedDevice.getAccount().getUuid());
|
||||
.validate(authenticatedDevice.getAccountIdentifier());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new ChallengeResponse(deviceCheckManager.createChallenge(
|
||||
AppleDeviceCheckManager.ChallengeType.ATTEST,
|
||||
authenticatedDevice.getAccount()));
|
||||
account));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -125,7 +131,7 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account")
|
||||
@ManagedAsync
|
||||
public void attest(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -135,8 +141,11 @@ public class DeviceCheckController {
|
||||
@RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))")
|
||||
@NotNull final byte[] attestation) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
deviceCheckManager.registerAttestation(authenticatedDevice.getAccount(), parseKeyId(keyId), attestation);
|
||||
deviceCheckManager.registerAttestation(account, parseKeyId(keyId), attestation);
|
||||
} catch (TooManyKeysException e) {
|
||||
throw new WebApplicationException(Response.status(413).build());
|
||||
} catch (ChallengeNotFoundException e) {
|
||||
@@ -166,17 +175,19 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
@ManagedAsync
|
||||
public ChallengeResponse assertChallenge(
|
||||
@ReadOnly @Auth AuthenticatedDevice authenticatedDevice,
|
||||
@Auth AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Parameter(schema = @Schema(description = "The type of action you will make an assertion for",
|
||||
allowableValues = {"backup"},
|
||||
implementation = String.class))
|
||||
@QueryParam("action") Action action) throws RateLimitExceededException {
|
||||
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
|
||||
.validate(authenticatedDevice.getAccount().getUuid());
|
||||
return new ChallengeResponse(
|
||||
deviceCheckManager.createChallenge(toChallengeType(action),
|
||||
authenticatedDevice.getAccount()));
|
||||
.validate(authenticatedDevice.getAccountIdentifier());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new ChallengeResponse(deviceCheckManager.createChallenge(toChallengeType(action), account));
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -199,7 +210,7 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "401", description = "The assertion could not be verified")
|
||||
@ManagedAsync
|
||||
public void assertion(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -218,9 +229,12 @@ public class DeviceCheckController {
|
||||
@RequestBody(description = "The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))")
|
||||
@NotNull final byte[] assertion) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
deviceCheckManager.validateAssert(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
parseKeyId(keyId),
|
||||
toChallengeType(request.assertionRequest().action()),
|
||||
request.assertionRequest().challenge(),
|
||||
@@ -237,7 +251,7 @@ public class DeviceCheckController {
|
||||
// The request assertion was validated, execute it
|
||||
switch (request.assertionRequest().action()) {
|
||||
case BACKUP -> backupAuthManager.extendBackupVoucher(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)))
|
||||
.join();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.Duration;
|
||||
@@ -51,11 +50,9 @@ import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;
|
||||
import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
|
||||
@@ -87,11 +84,10 @@ import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.EnumMapUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/devices")
|
||||
@Tag(name = "Devices")
|
||||
@@ -152,10 +148,10 @@ public class DeviceController {
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public DeviceInfoList getDevices(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public DeviceInfoList getDevices(@Auth AuthenticatedDevice auth) {
|
||||
// Devices may change their own names (and primary devices may change the names of linked devices) and so the device
|
||||
// state associated with the authenticated account may be stale. Fetch a fresh copy to compensate.
|
||||
return accounts.getByAccountIdentifier(auth.getAccount().getIdentifier(IdentityType.ACI))
|
||||
return accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.map(account -> new DeviceInfoList(account.getDevices().stream()
|
||||
.map(DeviceInfo::forDevice)
|
||||
.toList()))
|
||||
@@ -166,9 +162,8 @@ public class DeviceController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/{device_id}")
|
||||
@ChangesLinkedDevices
|
||||
public void removeDevice(@Mutable @Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
|
||||
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID &&
|
||||
auth.getAuthenticatedDevice().getId() != deviceId) {
|
||||
public void removeDevice(@Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
|
||||
if (auth.getDeviceId() != Device.PRIMARY_ID && auth.getDeviceId() != deviceId) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -176,13 +171,16 @@ public class DeviceController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
accounts.removeDevice(auth.getAccount(), deviceId).join();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accounts.removeDevice(account, deviceId).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a signed device-linking token. Generally, primary devices will include the signed device-linking token in
|
||||
* a provisioning message to a new device, and then the new device will include the token in its request to
|
||||
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest, ContainerRequest)}.
|
||||
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest)}.
|
||||
*
|
||||
* @param auth the authenticated account/device
|
||||
*
|
||||
@@ -207,10 +205,11 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public LinkDeviceToken createDeviceToken(@ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public LinkDeviceToken createDeviceToken(@Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
rateLimiters.getAllocateDeviceLimiter().validate(account.getUuid());
|
||||
|
||||
@@ -224,7 +223,7 @@ public class DeviceController {
|
||||
throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);
|
||||
}
|
||||
|
||||
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID) {
|
||||
if (auth.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -252,8 +251,7 @@ public class DeviceController {
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public LinkDeviceResponse linkDevice(@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent,
|
||||
@NotNull @Valid LinkDeviceRequest linkDeviceRequest,
|
||||
@Context ContainerRequest containerRequest)
|
||||
@NotNull @Valid LinkDeviceRequest linkDeviceRequest)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
|
||||
final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())
|
||||
@@ -279,11 +277,6 @@ public class DeviceController {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
// Normally, the "do we need to refresh somebody's websockets" listener can do this on its own. In this case,
|
||||
// we're not using the conventional authentication system, and so we need to give it a hint so it knows who the
|
||||
// active user is and what their device states look like.
|
||||
LinkedDeviceRefreshRequirementProvider.setAccount(containerRequest, account);
|
||||
|
||||
final int maxDeviceLimit = maxDeviceConfiguration.getOrDefault(account.getNumber(), MAX_DEVICES);
|
||||
|
||||
if (account.getDevices().size() >= maxDeviceLimit) {
|
||||
@@ -351,7 +344,7 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "400", description = "The given token identifier or timeout was invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Response> waitForLinkedDevice(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@PathParam("tokenIdentifier")
|
||||
@Schema(description = "A 'link device' token identifier provided by the 'create link device token' endpoint")
|
||||
@@ -374,12 +367,18 @@ public class DeviceController {
|
||||
final AtomicInteger linkedDeviceListenerCounter = getCounterForLinkedDeviceListeners(userAgent);
|
||||
linkedDeviceListenerCounter.incrementAndGet();
|
||||
|
||||
return rateLimiters.getWaitForLinkedDeviceLimiter()
|
||||
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
|
||||
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier))
|
||||
.thenCompose(sample -> accounts.waitForNewLinkedDevice(
|
||||
authenticatedDevice.getAccount().getUuid(),
|
||||
authenticatedDevice.getAuthenticatedDevice(),
|
||||
return rateLimiters.getWaitForLinkedDeviceLimiter().validateAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier)
|
||||
.thenApply(sample -> new Pair<>(account, sample));
|
||||
})
|
||||
.thenCompose(accountAndSample -> accounts.waitForNewLinkedDevice(
|
||||
authenticatedDevice.getAccountIdentifier(),
|
||||
accountAndSample.first().getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
|
||||
tokenIdentifier,
|
||||
Duration.ofSeconds(timeoutSeconds))
|
||||
.thenApply(maybeDeviceInfo -> maybeDeviceInfo
|
||||
@@ -391,7 +390,7 @@ public class DeviceController {
|
||||
linkedDeviceListenerCounter.decrementAndGet();
|
||||
|
||||
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
|
||||
sample.stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
|
||||
accountAndSample.second().stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.register(Metrics.globalRegistry));
|
||||
@@ -410,14 +409,15 @@ public class DeviceController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/capabilities")
|
||||
public void setCapabilities(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public void setCapabilities(@Auth final AuthenticatedDevice auth,
|
||||
|
||||
@NotNull
|
||||
final Map<String, Boolean> capabilities) {
|
||||
|
||||
assert (auth.getAuthenticatedDevice() != null);
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
accounts.updateDevice(auth.getAccount(), deviceId,
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, auth.getDeviceId(),
|
||||
d -> d.setCapabilities(DeviceCapabilityAdapter.mapToSet(capabilities)));
|
||||
}
|
||||
|
||||
@@ -435,12 +435,13 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "200", description = "Public key stored successfully")
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
public CompletableFuture<Void> setPublicKey(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public CompletableFuture<Void> setPublicKey(@Auth final AuthenticatedDevice auth,
|
||||
final SetPublicKeyRequest setPublicKeyRequest) {
|
||||
|
||||
return clientPublicKeysManager.setPublicKey(auth.getAccount(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
setPublicKeyRequest.publicKey());
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return clientPublicKeysManager.setPublicKey(account, auth.getDeviceId(), setPublicKeyRequest.publicKey());
|
||||
}
|
||||
|
||||
private static boolean isCapabilityDowngrade(final Account account, final Set<DeviceCapability> capabilities) {
|
||||
@@ -531,15 +532,21 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "204", description = "Success")
|
||||
@ApiResponse(responseCode = "422", description = "The request object could not be parsed or was otherwise invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Void> recordTransferArchiveUploaded(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public CompletionStage<Void> recordTransferArchiveUploaded(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@NotNull @Valid final TransferArchiveUploadedRequest transferArchiveUploadedRequest) {
|
||||
|
||||
return rateLimiters.getUploadTransferArchiveLimiter()
|
||||
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
|
||||
.thenCompose(ignored -> accounts.recordTransferArchiveUpload(authenticatedDevice.getAccount(),
|
||||
transferArchiveUploadedRequest.destinationDeviceId(),
|
||||
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
|
||||
transferArchiveUploadedRequest.transferArchive()));
|
||||
.validateAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.recordTransferArchiveUpload(account,
|
||||
transferArchiveUploadedRequest.destinationDeviceId(),
|
||||
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
|
||||
transferArchiveUploadedRequest.transferArchive());
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -558,7 +565,7 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "204", description = "No transfer archive was uploaded before the call completed; clients may repeat the call to continue waiting")
|
||||
@ApiResponse(responseCode = "400", description = "The given timeout was invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Response> waitForTransferArchive(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public CompletionStage<Response> waitForTransferArchive(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@QueryParam("timeout")
|
||||
@DefaultValue("30")
|
||||
@@ -575,24 +582,30 @@ public class DeviceController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent) {
|
||||
|
||||
|
||||
final String rateLimiterKey = authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI) +
|
||||
":" + authenticatedDevice.getAuthenticatedDevice().getId();
|
||||
final String rateLimiterKey = authenticatedDevice.getAccountIdentifier() + ":" + authenticatedDevice.getDeviceId();
|
||||
|
||||
return rateLimiters.getWaitForTransferArchiveLimiter().validateAsync(rateLimiterKey)
|
||||
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey))
|
||||
.thenCompose(sample -> accounts.waitForTransferArchive(authenticatedDevice.getAccount(),
|
||||
authenticatedDevice.getAuthenticatedDevice(),
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey)
|
||||
.thenApply(sample -> new Pair<>(account, sample));
|
||||
})
|
||||
.thenCompose(accountAndSample -> accounts.waitForTransferArchive(accountAndSample.first(),
|
||||
accountAndSample.first().getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
|
||||
Duration.ofSeconds(timeoutSeconds))
|
||||
.thenApply(maybeTransferArchive -> maybeTransferArchive
|
||||
.map(transferArchive -> Response.status(Response.Status.OK).entity(transferArchive).build())
|
||||
.orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()))
|
||||
.whenComplete((response, throwable) -> {
|
||||
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
|
||||
sample.stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
|
||||
accountAndSample.second().stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags(Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
primaryPlatformTag(authenticatedDevice.getAccount())))
|
||||
primaryPlatformTag(accountAndSample.first())))
|
||||
.register(Metrics.globalRegistry));
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -14,12 +14,10 @@ import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v2/directory")
|
||||
@Tag(name = "Directory")
|
||||
@@ -57,8 +55,7 @@ public class DirectoryV2Controller {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
public ExternalServiceCredentials getAuthToken(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
final UUID uuid = auth.getAccount().getUuid();
|
||||
return directoryServiceTokenGenerator.generateForUuid(uuid);
|
||||
public ExternalServiceCredentials getAuthToken(final @Auth AuthenticatedDevice auth) {
|
||||
return directoryServiceTokenGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
@@ -33,10 +34,10 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
|
||||
@Path("/v1/donation")
|
||||
@Tag(name = "Donations")
|
||||
@@ -86,7 +87,7 @@ public class DonationController {
|
||||
""")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> redeemReceipt(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final RedeemReceiptRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
@@ -118,23 +119,29 @@ public class DonationController {
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
return redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid())
|
||||
.thenCompose(receiptMatched -> {
|
||||
if (!receiptMatched) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("receipt serial is already redeemed")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
|
||||
return accountsManager.updateAsync(auth.getAccount(), a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
return accountsManager.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccountIdentifier())
|
||||
.thenCompose(receiptMatched -> {
|
||||
if (!receiptMatched) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("receipt serial is already redeemed")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
})
|
||||
.thenApply(ignored -> Response.ok().build());
|
||||
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
}
|
||||
})
|
||||
.thenApply(ignored -> Response.ok().build());
|
||||
});
|
||||
});
|
||||
}).thenCompose(Function.identity());
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
|
||||
import java.util.List;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
|
||||
public record GetCallingRelaysResponse(List<TurnToken> relays) {
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.websocket.session.WebSocketSession;
|
||||
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
||||
|
||||
@@ -45,16 +44,16 @@ public class KeepAliveController {
|
||||
}
|
||||
|
||||
@GET
|
||||
public Response getKeepAlive(@ReadOnly @Auth Optional<AuthenticatedDevice> maybeAuth,
|
||||
public Response getKeepAlive(@Auth Optional<AuthenticatedDevice> maybeAuth,
|
||||
@WebSocketSession WebSocketSessionContext context) {
|
||||
|
||||
maybeAuth.ifPresent(auth -> {
|
||||
if (!webSocketConnectionEventManager.isLocallyPresent(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId())) {
|
||||
if (!webSocketConnectionEventManager.isLocallyPresent(auth.getAccountIdentifier(), auth.getDeviceId())) {
|
||||
|
||||
final Duration age = Duration.between(context.getClient().getCreated(), Instant.now());
|
||||
|
||||
logger.debug("***** No local subscription found for {}::{}; age = {}ms, User-Agent = {}",
|
||||
auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), age.toMillis(),
|
||||
auth.getAccountIdentifier(), auth.getDeviceId(), age.toMillis(),
|
||||
context.getClient().getUserAgent());
|
||||
|
||||
context.getClient().close(1000, "OK");
|
||||
|
||||
@@ -49,7 +49,6 @@ import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceCl
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/key-transparency")
|
||||
@Tag(name = "KeyTransparency")
|
||||
@@ -90,7 +89,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencySearchResponse search(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final KeyTransparencySearchRequest request) {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -142,7 +141,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencyMonitorResponse monitor(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final KeyTransparencyMonitorRequest request) {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -204,7 +203,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencyDistinguishedKeyResponse getDistinguishedKey(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description = "The distinguished tree head size returned by a previously verified call")
|
||||
@QueryParam("lastTreeHeadSize") @Valid final Optional<@Positive Long> lastTreeHeadSize) {
|
||||
|
||||
@@ -74,7 +74,6 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.KeysManager;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v2/keys")
|
||||
@@ -111,16 +110,21 @@ public class KeysController {
|
||||
description = "Gets the number of one-time prekeys uploaded for this device and still available")
|
||||
@ApiResponse(responseCode = "200", description = "Body contains the number of available one-time prekeys for the device.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public CompletableFuture<PreKeyCount> getStatus(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public CompletableFuture<PreKeyCount> getStatus(@Auth final AuthenticatedDevice auth,
|
||||
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType) {
|
||||
|
||||
final CompletableFuture<Integer> ecCountFuture =
|
||||
keysManager.getEcCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final CompletableFuture<Integer> pqCountFuture =
|
||||
keysManager.getPqCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
final CompletableFuture<Integer> ecCountFuture =
|
||||
keysManager.getEcCount(account.getIdentifier(identityType), auth.getDeviceId());
|
||||
|
||||
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
|
||||
final CompletableFuture<Integer> pqCountFuture =
|
||||
keysManager.getPqCount(account.getIdentifier(identityType), auth.getDeviceId());
|
||||
|
||||
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
|
||||
});
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -132,7 +136,7 @@ public class KeysController {
|
||||
@ApiResponse(responseCode = "403", description = "Attempt to change identity key from a non-primary device.")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
public CompletableFuture<Response> setKeys(
|
||||
@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@RequestBody @NotNull @Valid final SetKeysRequest setKeysRequest,
|
||||
|
||||
@Parameter(allowEmptyValue=true)
|
||||
@@ -143,63 +147,70 @@ public class KeysController {
|
||||
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final UUID identifier = account.getIdentifier(identityType);
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
|
||||
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getAuthenticatedDevice().isPrimary()));
|
||||
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
|
||||
final UUID identifier = account.getIdentifier(identityType);
|
||||
|
||||
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
|
||||
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
|
||||
|
||||
if (!setKeysRequest.preKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
|
||||
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getDeviceId() == Device.PRIMARY_ID));
|
||||
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
|
||||
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.preKeys().size());
|
||||
if (!setKeysRequest.preKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
|
||||
|
||||
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
|
||||
}
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
|
||||
if (setKeysRequest.signedPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
|
||||
.increment();
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.preKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
|
||||
}
|
||||
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
|
||||
}
|
||||
|
||||
if (!setKeysRequest.pqPreKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
if (setKeysRequest.signedPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
|
||||
.increment();
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.pqPreKeys().size());
|
||||
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
|
||||
}
|
||||
|
||||
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
|
||||
}
|
||||
if (!setKeysRequest.pqPreKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
|
||||
if (setKeysRequest.pqLastResortPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
|
||||
.increment();
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.pqPreKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
|
||||
}
|
||||
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
if (setKeysRequest.pqLastResortPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
|
||||
.increment();
|
||||
|
||||
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
private void checkSignedPreKeySignatures(final SetKeysRequest setKeysRequest,
|
||||
@@ -253,64 +264,69 @@ public class KeysController {
|
||||
""")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
public CompletableFuture<Response> checkKeys(
|
||||
@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@RequestBody @NotNull @Valid final CheckKeysRequest checkKeysRequest) {
|
||||
|
||||
final UUID identifier = auth.getAccount().getIdentifier(checkKeysRequest.identityType());
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
|
||||
keysManager.getEcSignedPreKey(identifier, deviceId);
|
||||
final UUID identifier = account.getIdentifier(checkKeysRequest.identityType());
|
||||
final byte deviceId = auth.getDeviceId();
|
||||
|
||||
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
|
||||
keysManager.getLastResort(identifier, deviceId);
|
||||
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
|
||||
keysManager.getEcSignedPreKey(identifier, deviceId);
|
||||
|
||||
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
|
||||
.thenApply(ignored -> {
|
||||
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
|
||||
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
|
||||
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
|
||||
keysManager.getLastResort(identifier, deviceId);
|
||||
|
||||
final boolean digestsMatch;
|
||||
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
|
||||
.thenApply(ignored -> {
|
||||
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
|
||||
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
|
||||
|
||||
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
|
||||
final IdentityKey identityKey = auth.getAccount().getIdentityKey(checkKeysRequest.identityType());
|
||||
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
|
||||
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
|
||||
final boolean digestsMatch;
|
||||
|
||||
final MessageDigest messageDigest;
|
||||
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
|
||||
final IdentityKey identityKey = account.getIdentityKey(checkKeysRequest.identityType());
|
||||
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
|
||||
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
|
||||
|
||||
try {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
|
||||
}
|
||||
final MessageDigest messageDigest;
|
||||
|
||||
messageDigest.update(identityKey.serialize());
|
||||
try {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
|
||||
}
|
||||
|
||||
{
|
||||
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
|
||||
ecSignedPreKeyIdBuffer.flip();
|
||||
messageDigest.update(identityKey.serialize());
|
||||
|
||||
messageDigest.update(ecSignedPreKeyIdBuffer);
|
||||
messageDigest.update(ecSignedPreKey.serializedPublicKey());
|
||||
}
|
||||
{
|
||||
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
|
||||
ecSignedPreKeyIdBuffer.flip();
|
||||
|
||||
{
|
||||
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
|
||||
lastResortKeyIdBuffer.flip();
|
||||
messageDigest.update(ecSignedPreKeyIdBuffer);
|
||||
messageDigest.update(ecSignedPreKey.serializedPublicKey());
|
||||
}
|
||||
|
||||
messageDigest.update(lastResortKeyIdBuffer);
|
||||
messageDigest.update(lastResortKey.serializedPublicKey());
|
||||
}
|
||||
{
|
||||
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
|
||||
lastResortKeyIdBuffer.flip();
|
||||
|
||||
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
|
||||
} else {
|
||||
digestsMatch = false;
|
||||
}
|
||||
messageDigest.update(lastResortKeyIdBuffer);
|
||||
messageDigest.update(lastResortKey.serializedPublicKey());
|
||||
}
|
||||
|
||||
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
|
||||
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
|
||||
} else {
|
||||
digestsMatch = false;
|
||||
}
|
||||
|
||||
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,7 +343,7 @@ public class KeysController {
|
||||
name = "Retry-After",
|
||||
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public PreKeyResponse getDeviceKeys(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
|
||||
|
||||
@@ -340,15 +356,18 @@ public class KeysController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
|
||||
if (maybeAuthenticatedDevice.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
final Optional<Account> account = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> account = maybeAuthenticatedDevice
|
||||
.map(authenticatedDevice -> accounts.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
|
||||
|
||||
if (groupSendToken.isPresent()) {
|
||||
if (auth.isPresent() || accessKey.isPresent()) {
|
||||
if (maybeAuthenticatedDevice.isPresent() || accessKey.isPresent()) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
try {
|
||||
@@ -364,7 +383,7 @@ public class KeysController {
|
||||
|
||||
if (account.isPresent()) {
|
||||
rateLimiters.getPreKeysLimiter().validate(
|
||||
account.get().getUuid() + "." + auth.get().getAuthenticatedDevice().getId() + "__" + targetIdentifier.uuid()
|
||||
account.get().getUuid() + "." + maybeAuthenticatedDevice.get().getDeviceId() + "__" + targetIdentifier.uuid()
|
||||
+ "." + deviceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
@@ -112,7 +113,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||
import org.whispersystems.websocket.WebsocketHeaders;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@@ -236,7 +236,7 @@ public class MessageController {
|
||||
@ApiResponse(
|
||||
responseCode="428",
|
||||
description="The sender should complete a challenge before proceeding")
|
||||
public Response sendMessage(@ReadOnly @Auth final Optional<AuthenticatedDevice> source,
|
||||
public Response sendMessage(@Auth final Optional<AuthenticatedDevice> source,
|
||||
@Parameter(description="The recipient's unidentified access key")
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) final Optional<Anonymous> accessKey,
|
||||
|
||||
@@ -274,12 +274,14 @@ public class MessageController {
|
||||
sendStoryMessage(destinationIdentifier, messages, context);
|
||||
} else if (source.isPresent()) {
|
||||
final AuthenticatedDevice authenticatedDevice = source.get();
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
if (authenticatedDevice.getAccount().isIdentifiedBy(destinationIdentifier)) {
|
||||
if (account.isIdentifiedBy(destinationIdentifier)) {
|
||||
needsSync = false;
|
||||
sendSyncMessage(source.get(), destinationIdentifier, messages, context);
|
||||
sendSyncMessage(source.get(), account, destinationIdentifier, messages, context);
|
||||
} else {
|
||||
needsSync = authenticatedDevice.getAccount().getDevices().size() > 1;
|
||||
needsSync = account.getDevices().size() > 1;
|
||||
sendIdentifiedSenderIndividualMessage(authenticatedDevice, destinationIdentifier, messages, context);
|
||||
}
|
||||
} else {
|
||||
@@ -302,7 +304,7 @@ public class MessageController {
|
||||
final Account destination =
|
||||
accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(NotFoundException::new);
|
||||
|
||||
rateLimiters.getMessagesLimiter().validate(source.getAccount().getUuid(), destination.getUuid());
|
||||
rateLimiters.getMessagesLimiter().validate(source.getAccountIdentifier(), destination.getUuid());
|
||||
|
||||
sendIndividualMessage(destination,
|
||||
destinationIdentifier,
|
||||
@@ -314,6 +316,7 @@ public class MessageController {
|
||||
}
|
||||
|
||||
private void sendSyncMessage(final AuthenticatedDevice source,
|
||||
final Account sourceAccount,
|
||||
final ServiceIdentifier destinationIdentifier,
|
||||
final IncomingMessageList messages,
|
||||
final ContainerRequestContext context)
|
||||
@@ -323,7 +326,7 @@ public class MessageController {
|
||||
throw new WebApplicationException(Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
sendIndividualMessage(source.getAccount(),
|
||||
sendIndividualMessage(sourceAccount,
|
||||
destinationIdentifier,
|
||||
source,
|
||||
messages,
|
||||
@@ -420,8 +423,8 @@ public class MessageController {
|
||||
try {
|
||||
return message.toEnvelope(
|
||||
destinationIdentifier,
|
||||
sender != null ? sender.getAccount() : null,
|
||||
sender != null ? sender.getAuthenticatedDevice().getId() : null,
|
||||
sender != null ? new AciServiceIdentifier(sender.getAccountIdentifier()) : null,
|
||||
sender != null ? sender.getDeviceId() : null,
|
||||
messages.timestamp() == 0 ? System.currentTimeMillis() : messages.timestamp(),
|
||||
isStory,
|
||||
messages.online(),
|
||||
@@ -437,7 +440,7 @@ public class MessageController {
|
||||
.collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));
|
||||
|
||||
final Optional<Byte> syncMessageSenderDeviceId = messageType == MessageType.SYNC
|
||||
? Optional.ofNullable(sender).map(authenticatedDevice -> authenticatedDevice.getAuthenticatedDevice().getId())
|
||||
? Optional.ofNullable(sender).map(AuthenticatedDevice::getDeviceId)
|
||||
: Optional.empty();
|
||||
|
||||
try {
|
||||
@@ -755,31 +758,37 @@ public class MessageController {
|
||||
@Timed
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
|
||||
|
||||
boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
return accountsManager.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
||||
final boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);
|
||||
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccountIdentifier(),
|
||||
device,
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
|
||||
final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.peek(outgoingMessageEntity -> {
|
||||
messageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(), outgoingMessageEntity);
|
||||
messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageEntity);
|
||||
messageMetrics.measureOutgoingMessageLatency(outgoingMessageEntity.serverTimestamp(),
|
||||
"rest",
|
||||
auth.getAuthenticatedDevice().isPrimary(),
|
||||
auth.getDeviceId() == Device.PRIMARY_ID,
|
||||
outgoingMessageEntity.urgent(),
|
||||
// Messages fetched via this endpoint (as opposed to WebSocketConnection) are never ephemeral
|
||||
// because, by definition, the client doesn't have a "live" connection via which to receive
|
||||
@@ -791,26 +800,27 @@ public class MessageController {
|
||||
.collect(Collectors.toList()),
|
||||
messagesAndHasMore.second());
|
||||
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.record(estimateMessageListSizeBytes(messages));
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.record(estimateMessageListSizeBytes(messages));
|
||||
|
||||
if (!messages.messages().isEmpty()) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
messages.messages().getFirst().guid(),
|
||||
userAgent,
|
||||
"rest");
|
||||
}
|
||||
if (!messages.messages().isEmpty()) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccountIdentifier(),
|
||||
auth.getDeviceId(),
|
||||
messages.messages().getFirst().guid(),
|
||||
userAgent,
|
||||
"rest");
|
||||
}
|
||||
|
||||
if (messagesAndHasMore.second()) {
|
||||
pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(), auth.getAuthenticatedDevice(), NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
|
||||
}
|
||||
if (messagesAndHasMore.second()) {
|
||||
pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
|
||||
}
|
||||
|
||||
return messages;
|
||||
})
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
return messages;
|
||||
})
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
});
|
||||
}
|
||||
|
||||
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
|
||||
@@ -827,22 +837,27 @@ public class MessageController {
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/uuid/{uuid}")
|
||||
public CompletableFuture<Response> removePendingMessage(@ReadOnly @Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
|
||||
public CompletableFuture<Response> removePendingMessage(@Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return messagesManager.delete(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
auth.getAccountIdentifier(),
|
||||
device,
|
||||
uuid,
|
||||
null)
|
||||
.thenAccept(maybeRemovedMessage -> maybeRemovedMessage.ifPresent(removedMessage -> {
|
||||
|
||||
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(),
|
||||
auth.getAuthenticatedDevice());
|
||||
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(), device);
|
||||
|
||||
if (removedMessage.sourceServiceId().isPresent()
|
||||
&& removedMessage.envelopeType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||
if (removedMessage.sourceServiceId().get() instanceof AciServiceIdentifier aciServiceIdentifier) {
|
||||
try {
|
||||
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.getAuthenticatedDevice().getId(),
|
||||
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.getDeviceId(),
|
||||
aciServiceIdentifier, removedMessage.clientTimestamp());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to send delivery receipt", e);
|
||||
@@ -863,7 +878,7 @@ public class MessageController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Path("/report/{source}/{messageGuid}")
|
||||
public Response reportSpamMessage(
|
||||
@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@PathParam("source") String source,
|
||||
@PathParam("messageGuid") UUID messageGuid,
|
||||
@Nullable SpamReport spamReport,
|
||||
@@ -899,7 +914,7 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
UUID spamReporterUuid = auth.getAccount().getUuid();
|
||||
UUID spamReporterUuid = auth.getAccountIdentifier();
|
||||
|
||||
// spam report token is optional, but if provided ensure it is non-empty.
|
||||
final Optional<byte[]> maybeSpamReportToken =
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import java.util.Map;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
|
||||
public class MultiRecipientMismatchedDevicesException extends Exception {
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
|
||||
/**
|
||||
@@ -163,7 +162,7 @@ public class OneTimeDonationController {
|
||||
@StringToClassMapItem(key = "error", value = String.class)
|
||||
})))
|
||||
public CompletableFuture<Response> createBoostPaymentIntent(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid CreateBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
@@ -249,7 +248,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPayPalBoost(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid CreatePayPalBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@Context ContainerRequestContext containerRequestContext) {
|
||||
@@ -296,7 +295,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> confirmPayPalBoost(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid ConfirmPayPalBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
@@ -342,7 +341,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createBoostReceiptCredentials(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/payments")
|
||||
@Tag(name = "Payments")
|
||||
@@ -43,14 +42,14 @@ public class PaymentsController {
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedDevice auth) {
|
||||
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/conversions")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CurrencyConversionEntityList getConversions(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedDevice auth) {
|
||||
return currencyManager.getCurrencyConversions().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
@@ -94,8 +95,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/profile")
|
||||
@@ -152,15 +151,18 @@ public class ProfileController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response setProfile(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
|
||||
public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
|
||||
|
||||
final Optional<VersionedProfile> currentProfile = profilesManager.get(auth.getAccount().getUuid(),
|
||||
request.version());
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Optional<VersionedProfile> currentProfile =
|
||||
profilesManager.get(auth.getAccountIdentifier(), request.version());
|
||||
|
||||
if (request.paymentAddress() != null && request.paymentAddress().length != 0) {
|
||||
final boolean hasDisallowedPrefix =
|
||||
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
|
||||
.anyMatch(prefix -> auth.getAccount().getNumber().startsWith(prefix));
|
||||
.anyMatch(prefix -> account.getNumber().startsWith(prefix));
|
||||
|
||||
if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::paymentAddress).isEmpty()) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
@@ -179,7 +181,7 @@ public class ProfileController {
|
||||
case UPDATE -> ProfileHelper.generateAvatarObjectName();
|
||||
};
|
||||
|
||||
profilesManager.set(auth.getAccount().getUuid(),
|
||||
profilesManager.set(auth.getAccountIdentifier(),
|
||||
new VersionedProfile(
|
||||
request.version(),
|
||||
request.name(),
|
||||
@@ -194,7 +196,7 @@ public class ProfileController {
|
||||
currentAvatar.ifPresent(s -> profilesManager.deleteAvatar(s).join());
|
||||
}
|
||||
|
||||
accountsManager.update(auth.getAccount(), a -> {
|
||||
accountsManager.update(account, a -> {
|
||||
|
||||
final List<AccountBadge> updatedBadges = request.badges()
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))
|
||||
@@ -216,7 +218,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}/{version}")
|
||||
@ManagedAsync
|
||||
public VersionedProfileResponse getProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@@ -224,7 +226,11 @@ public class ProfileController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "getVersionedProfile", userAgent);
|
||||
|
||||
return buildVersionedProfileResponse(targetAccount,
|
||||
@@ -238,7 +244,7 @@ public class ProfileController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/{identifier}/{version}/{credentialRequest}")
|
||||
public CredentialProfileResponse getProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@@ -252,7 +258,11 @@ public class ProfileController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "credentialRequest", userAgent);
|
||||
final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);
|
||||
|
||||
@@ -270,7 +280,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}")
|
||||
@ManagedAsync
|
||||
public BaseProfileResponse getUnversionedProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@@ -278,7 +288,10 @@ public class ProfileController {
|
||||
@PathParam("identifier") ServiceIdentifier identifier)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount;
|
||||
if (groupSendToken.isPresent()) {
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
/**
|
||||
* The provisioning controller facilitates transmission of provisioning messages from the primary device associated with
|
||||
@@ -77,7 +76,7 @@ public class ProvisioningController {
|
||||
@ApiResponse(responseCode="204", description="The provisioning message was delivered to the given provisioning address")
|
||||
@ApiResponse(responseCode="400", description="The provisioning message was too large")
|
||||
@ApiResponse(responseCode="404", description="No device with the given provisioning address was connected at the time of the request")
|
||||
public void sendProvisioningMessage(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public void sendProvisioningMessage(@Auth final AuthenticatedDevice auth,
|
||||
|
||||
@Parameter(description = "The temporary provisioning address to which to send a provisioning message")
|
||||
@PathParam("destination") final String provisioningAddress,
|
||||
@@ -93,7 +92,7 @@ public class ProvisioningController {
|
||||
throw new WebApplicationException(Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getMessagesLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
final boolean subscriberPresent =
|
||||
provisioningManager.sendProvisioningMessage(provisioningAddress, Base64.getMimeDecoder().decode(message.body()));
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/config")
|
||||
@Tag(name = "Remote Config")
|
||||
@@ -64,7 +63,7 @@ public class RemoteConfigController {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
|
||||
public UserRemoteConfigList getAll(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA1");
|
||||
|
||||
@@ -73,7 +72,7 @@ public class RemoteConfigController {
|
||||
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
|
||||
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
|
||||
: config.getName().getBytes(StandardCharsets.UTF_8);
|
||||
boolean inBucket = isInBucket(digest, auth.getAccount().getUuid(), hashKey, config.getPercentage(),
|
||||
boolean inBucket = isInBucket(digest, auth.getAccountIdentifier(), hashKey, config.getPercentage(),
|
||||
config.getUuids());
|
||||
return new UserRemoteConfig(config.getName(), inBucket,
|
||||
inBucket ? config.getValue() : config.getDefaultValue());
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/storage")
|
||||
@Tag(name = "Secure Storage")
|
||||
@@ -47,7 +46,7 @@ public class SecureStorageController {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
public ExternalServiceCredentials getAuth(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedDevice auth) {
|
||||
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v2/backup")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
@@ -78,8 +77,8 @@ public class SecureValueRecovery2Controller {
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public ExternalServiceCredentials getAuth(@ReadOnly @Auth final AuthenticatedDevice auth) {
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
public ExternalServiceCredentials getAuth(@Auth final AuthenticatedDevice auth) {
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccountIdentifier().toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/sticker")
|
||||
@Tag(name = "Stickers")
|
||||
@@ -47,10 +46,10 @@ public class StickerController {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/pack/form/{count}")
|
||||
public StickerPackFormUploadAttributes getStickersForm(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public StickerPackFormUploadAttributes getStickersForm(@Auth AuthenticatedDevice auth,
|
||||
@PathParam("count") @Min(1) @Max(201) int stickerCount)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiters.getStickerPackLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getStickerPackLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
String packId = generatePackId();
|
||||
|
||||
@@ -88,7 +88,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/subscription")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions")
|
||||
@@ -220,7 +219,7 @@ public class SubscriptionController {
|
||||
@Path("/{subscriberId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> deleteSubscriber(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -232,7 +231,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> updateSubscriber(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -248,7 +247,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPaymentMethod(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) throws SubscriptionException {
|
||||
@@ -284,7 +283,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPayPalPaymentMethod(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@NotNull @Valid CreatePayPalBillingAgreementRequest request,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@@ -323,7 +322,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setDefaultPaymentMethodWithProcessor(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("processor") PaymentProvider processor,
|
||||
@PathParam("paymentMethodToken") @NotEmpty String paymentMethodToken) throws SubscriptionException {
|
||||
@@ -360,7 +359,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setSubscriptionLevel(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("level") long level,
|
||||
@PathParam("currency") String currency,
|
||||
@@ -432,7 +431,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
|
||||
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("originalTransactionId") String originalTransactionId) throws SubscriptionException {
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
@@ -473,7 +472,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
|
||||
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
|
||||
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException {
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
@@ -627,7 +626,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
|
||||
public CompletableFuture<Response> getSubscriptionInformation(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -662,7 +661,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createSubscriptionReceiptCredentials(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@NotNull @Valid GetReceiptCredentialsRequest request) throws SubscriptionException {
|
||||
@@ -691,7 +690,7 @@ public class SubscriptionController {
|
||||
@Path("/{subscriberId}/default_payment_method_for_ideal/{setupIntentId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setDefaultPaymentMethodForIdeal(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("setupIntentId") @NotEmpty String setupIntentId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.Duration;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
|
||||
public class VerificationSessionRateLimitExceededException extends RateLimitExceededException {
|
||||
|
||||
|
||||
@@ -10,15 +10,14 @@ import com.webauthn4j.converter.jackson.deserializer.json.ByteArrayBase64Deseria
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import javax.annotation.Nullable;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
public record IncomingMessage(int type,
|
||||
byte destinationDeviceId,
|
||||
@@ -35,7 +34,7 @@ public record IncomingMessage(int type,
|
||||
MetricsUtil.name(IncomingMessage.class, "rejectInvalidEnvelopeType");
|
||||
|
||||
public MessageProtos.Envelope toEnvelope(final ServiceIdentifier destinationIdentifier,
|
||||
@Nullable Account sourceAccount,
|
||||
@Nullable AciServiceIdentifier sourceServiceIdentifier,
|
||||
@Nullable Byte sourceDeviceId,
|
||||
final long timestamp,
|
||||
final boolean story,
|
||||
@@ -54,9 +53,9 @@ public record IncomingMessage(int type,
|
||||
.setEphemeral(ephemeral)
|
||||
.setUrgent(urgent);
|
||||
|
||||
if (sourceAccount != null && sourceDeviceId != null) {
|
||||
if (sourceServiceIdentifier != null && sourceDeviceId != null) {
|
||||
envelopeBuilder
|
||||
.setSourceServiceId(new AciServiceIdentifier(sourceAccount.getUuid()).toServiceIdentifierString())
|
||||
.setSourceServiceId(sourceServiceIdentifier.toServiceIdentifierString())
|
||||
.setSourceDevice(sourceDeviceId.intValue());
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ public class RestDeprecationFilter implements ContainerRequestFilter {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) {
|
||||
return experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), AUTHENTICATED_EXPERIMENT_NAME);
|
||||
if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice authenticatedDevice) {
|
||||
return experimentEnrollmentManager.isEnrolled(authenticatedDevice.getAccountIdentifier(), AUTHENTICATED_EXPERIMENT_NAME);
|
||||
} else {
|
||||
log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName());
|
||||
return false;
|
||||
|
||||
@@ -9,8 +9,6 @@ import java.util.Optional;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.signal.chat.messages.SendMessageResponse;
|
||||
import org.signal.chat.messages.SendMultiRecipientMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
@@ -31,7 +29,7 @@ public interface SpamChecker {
|
||||
SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(
|
||||
final MessageType messageType,
|
||||
final ContainerRequestContext requestContext,
|
||||
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,
|
||||
final Optional<Account> maybeDestination,
|
||||
final ServiceIdentifier destinationIdentifier);
|
||||
|
||||
@@ -79,7 +77,7 @@ public interface SpamChecker {
|
||||
@Override
|
||||
public SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(final MessageType messageType,
|
||||
final ContainerRequestContext requestContext,
|
||||
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,
|
||||
final Optional<Account> maybeDestination,
|
||||
final ServiceIdentifier destinationIdentifier) {
|
||||
|
||||
@@ -95,7 +93,7 @@ public interface SpamChecker {
|
||||
|
||||
@Override
|
||||
public SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(final MessageType messageType,
|
||||
final Optional<AuthenticatedDevice> maybeSource,
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,
|
||||
final Optional<Account> maybeDestination,
|
||||
final ServiceIdentifier destinationIdentifier) {
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.websocket.auth.PrincipalSupplier;
|
||||
|
||||
public class AccountPrincipalSupplier implements PrincipalSupplier<AuthenticatedDevice> {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
|
||||
public AccountPrincipalSupplier(final AccountsManager accountsManager) {
|
||||
this.accountsManager = accountsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticatedDevice refresh(final AuthenticatedDevice oldAccount) {
|
||||
final Account account = accountsManager.getByAccountIdentifier(oldAccount.getAccount().getUuid())
|
||||
.orElseThrow(() -> new RefreshingAccountNotFoundException("Could not find account"));
|
||||
final Device device = account.getDevice(oldAccount.getAuthenticatedDevice().getId())
|
||||
.orElseThrow(() -> new RefreshingAccountNotFoundException("Could not find device"));
|
||||
return new AuthenticatedDevice(account, device);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticatedDevice deepCopy(final AuthenticatedDevice authenticatedDevice) {
|
||||
final Account cloned = AccountUtil.cloneAccountAsNotStale(authenticatedDevice.getAccount());
|
||||
return new AuthenticatedDevice(
|
||||
cloned,
|
||||
cloned.getDevice(authenticatedDevice.getAuthenticatedDevice().getId())
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Could not find device from a clone of an account where the device was present")));
|
||||
}
|
||||
}
|
||||
@@ -600,13 +600,10 @@ public class AccountsManager extends RedisPubSubAdapter<String, String> implemen
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a device from the given account. The device will be immediately disconnected if it is
|
||||
* connected to any chat frontend, but it is the caller's responsibility to make sure that the
|
||||
* account's *other* devices are disconnected, either by use of
|
||||
* {@link org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider} or
|
||||
* directly by calling {@link DeviceDisconnectionManager#requestDisconnection}.
|
||||
* Unlink a device from the given account. The device will be immediately disconnected if it is connected to any chat
|
||||
* frontend.
|
||||
*
|
||||
* @returns the updated Account
|
||||
* @return the updated Account
|
||||
*/
|
||||
public CompletableFuture<Account> removeDevice(final Account account, final byte deviceId) {
|
||||
if (deviceId == Device.PRIMARY_ID) {
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.websocket;
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -20,7 +21,10 @@ import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventManager;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
|
||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
||||
import org.whispersystems.websocket.setup.WebSocketConnectListener;
|
||||
@@ -36,6 +40,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class);
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final ReceiptSender receiptSender;
|
||||
private final MessagesManager messagesManager;
|
||||
private final MessageMetrics messageMetrics;
|
||||
@@ -51,7 +56,9 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
private final OpenWebSocketCounter openAuthenticatedWebSocketCounter;
|
||||
private final OpenWebSocketCounter openUnauthenticatedWebSocketCounter;
|
||||
|
||||
public AuthenticatedConnectListener(ReceiptSender receiptSender,
|
||||
public AuthenticatedConnectListener(
|
||||
AccountsManager accountsManager,
|
||||
ReceiptSender receiptSender,
|
||||
MessagesManager messagesManager,
|
||||
MessageMetrics messageMetrics,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
@@ -62,6 +69,8 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
ClientReleaseManager clientReleaseManager,
|
||||
MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,
|
||||
final ExperimentEnrollmentManager experimentEnrollmentManager) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.receiptSender = receiptSender;
|
||||
this.messagesManager = messagesManager;
|
||||
this.messageMetrics = messageMetrics;
|
||||
@@ -82,7 +91,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWebSocketConnect(WebSocketSessionContext context) {
|
||||
public void onWebSocketConnect(final WebSocketSessionContext context) {
|
||||
|
||||
final boolean authenticated = (context.getAuthenticated() != null);
|
||||
final OpenWebSocketCounter openWebSocketCounter =
|
||||
@@ -92,12 +101,24 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
|
||||
if (authenticated) {
|
||||
final AuthenticatedDevice auth = context.getAuthenticated(AuthenticatedDevice.class);
|
||||
|
||||
final Optional<Account> maybeAuthenticatedAccount = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier());
|
||||
final Optional<Device> maybeAuthenticatedDevice = maybeAuthenticatedAccount.flatMap(account -> account.getDevice(auth.getDeviceId()));;
|
||||
|
||||
if (maybeAuthenticatedAccount.isEmpty() || maybeAuthenticatedDevice.isEmpty()) {
|
||||
log.warn("{}:{} not found when opening authenticated WebSocket", auth.getAccountIdentifier(), auth.getDeviceId());
|
||||
|
||||
context.getClient().close(1011, "Unexpected error initializing connection");
|
||||
return;
|
||||
}
|
||||
|
||||
final WebSocketConnection connection = new WebSocketConnection(receiptSender,
|
||||
messagesManager,
|
||||
messageMetrics,
|
||||
pushNotificationManager,
|
||||
pushNotificationScheduler,
|
||||
auth,
|
||||
maybeAuthenticatedAccount.get(),
|
||||
maybeAuthenticatedDevice.get(),
|
||||
context.getClient(),
|
||||
scheduledExecutorService,
|
||||
messageDeliveryScheduler,
|
||||
@@ -110,8 +131,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
// receive push notifications for inbound messages. We should do this first because, at this point, the
|
||||
// connection has already closed and attempts to actually deliver a message via the connection will not succeed.
|
||||
// It's preferable to start sending push notifications as soon as possible.
|
||||
webSocketConnectionEventManager.handleClientDisconnected(auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice().getId());
|
||||
webSocketConnectionEventManager.handleClientDisconnected(auth.getAccountIdentifier(), auth.getDeviceId());
|
||||
|
||||
// Finally, stop trying to deliver messages and send a push notification if the connection is aware of any
|
||||
// undelivered messages.
|
||||
@@ -127,7 +147,7 @@ public class AuthenticatedConnectListener implements WebSocketConnectListener {
|
||||
|
||||
// Finally, we register this client's presence, which suppresses push notifications. We do this last because
|
||||
// receiving extra push notifications is generally preferable to missing out on a push notification.
|
||||
webSocketConnectionEventManager.handleClientConnected(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), connection);
|
||||
webSocketConnectionEventManager.handleClientConnected(auth.getAccountIdentifier(), auth.getDeviceId(), connection);
|
||||
} catch (final Exception e) {
|
||||
log.warn("Failed to initialize websocket", e);
|
||||
context.getClient().close(1011, "Unexpected error initializing connection");
|
||||
|
||||
@@ -9,41 +9,39 @@ import static org.whispersystems.textsecuregcm.util.HeaderUtils.basicCredentials
|
||||
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import javax.annotation.Nullable;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeRequest;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.websocket.ReusableAuth;
|
||||
import org.whispersystems.websocket.auth.InvalidCredentialsException;
|
||||
import org.whispersystems.websocket.auth.PrincipalSupplier;
|
||||
import org.whispersystems.websocket.auth.WebSocketAuthenticator;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
public class WebSocketAccountAuthenticator implements WebSocketAuthenticator<AuthenticatedDevice> {
|
||||
|
||||
private static final ReusableAuth<AuthenticatedDevice> CREDENTIALS_NOT_PRESENTED = ReusableAuth.anonymous();
|
||||
|
||||
private final AccountAuthenticator accountAuthenticator;
|
||||
private final PrincipalSupplier<AuthenticatedDevice> principalSupplier;
|
||||
|
||||
public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator,
|
||||
final PrincipalSupplier<AuthenticatedDevice> principalSupplier) {
|
||||
public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator) {
|
||||
this.accountAuthenticator = accountAuthenticator;
|
||||
this.principalSupplier = principalSupplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReusableAuth<AuthenticatedDevice> authenticate(final UpgradeRequest request)
|
||||
public Optional<AuthenticatedDevice> authenticate(final UpgradeRequest request)
|
||||
throws InvalidCredentialsException {
|
||||
|
||||
@Nullable final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
|
||||
if (authHeader == null) {
|
||||
return CREDENTIALS_NOT_PRESENTED;
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return basicCredentialsFromAuthHeader(authHeader)
|
||||
.flatMap(accountAuthenticator::authenticate)
|
||||
.map(authenticatedAccount -> ReusableAuth.authenticated(authenticatedAccount, this.principalSupplier))
|
||||
final BasicCredentials credentials = basicCredentialsFromAuthHeader(authHeader)
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
final AuthenticatedDevice authenticatedDevice = accountAuthenticator.authenticate(credentials)
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
return Optional.of(authenticatedDevice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import org.eclipse.jetty.util.StaticException;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
@@ -52,6 +51,7 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager;
|
||||
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
|
||||
import org.whispersystems.textsecuregcm.push.ReceiptSender;
|
||||
import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventListener;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
@@ -123,7 +123,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;
|
||||
private final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||
|
||||
private final AuthenticatedDevice auth;
|
||||
private final Account authenticatedAccount;
|
||||
private final Device authenticatedDevice;
|
||||
private final WebSocketClient client;
|
||||
|
||||
private final int sendFuturesTimeoutMillis;
|
||||
@@ -156,7 +157,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
MessageMetrics messageMetrics,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
PushNotificationScheduler pushNotificationScheduler,
|
||||
AuthenticatedDevice auth,
|
||||
Account authenticatedAccount,
|
||||
Device authenticatedDevice,
|
||||
WebSocketClient client,
|
||||
ScheduledExecutorService scheduledExecutorService,
|
||||
Scheduler messageDeliveryScheduler,
|
||||
@@ -169,7 +171,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
messageMetrics,
|
||||
pushNotificationManager,
|
||||
pushNotificationScheduler,
|
||||
auth,
|
||||
authenticatedAccount,
|
||||
authenticatedDevice,
|
||||
client,
|
||||
DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS,
|
||||
scheduledExecutorService,
|
||||
@@ -184,7 +187,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
MessageMetrics messageMetrics,
|
||||
PushNotificationManager pushNotificationManager,
|
||||
PushNotificationScheduler pushNotificationScheduler,
|
||||
AuthenticatedDevice auth,
|
||||
Account authenticatedAccount,
|
||||
Device authenticatedDevice,
|
||||
WebSocketClient client,
|
||||
int sendFuturesTimeoutMillis,
|
||||
ScheduledExecutorService scheduledExecutorService,
|
||||
@@ -198,7 +202,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
this.messageMetrics = messageMetrics;
|
||||
this.pushNotificationManager = pushNotificationManager;
|
||||
this.pushNotificationScheduler = pushNotificationScheduler;
|
||||
this.auth = auth;
|
||||
this.authenticatedAccount = authenticatedAccount;
|
||||
this.authenticatedDevice = authenticatedDevice;
|
||||
this.client = client;
|
||||
this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis;
|
||||
this.scheduledExecutorService = scheduledExecutorService;
|
||||
@@ -209,7 +214,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
}
|
||||
|
||||
public void start() {
|
||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), client.getUserAgent());
|
||||
pushNotificationManager.handleMessagesRetrieved(authenticatedAccount, authenticatedDevice, client.getUserAgent());
|
||||
queueDrainStartTime.set(System.currentTimeMillis());
|
||||
processStoredMessages();
|
||||
}
|
||||
@@ -229,8 +234,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
client.close(1000, "OK");
|
||||
|
||||
if (storedMessageState.get() != StoredMessageState.EMPTY) {
|
||||
pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
pushNotificationScheduler.scheduleDelayedNotification(authenticatedAccount,
|
||||
authenticatedDevice,
|
||||
CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +247,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
sendMessageCounter.increment();
|
||||
sentMessageCounter.increment();
|
||||
bytesSentCounter.increment(body.map(bytes -> bytes.length).orElse(0));
|
||||
messageMetrics.measureAccountEnvelopeUuidMismatches(auth.getAccount(), message);
|
||||
messageMetrics.measureAccountEnvelopeUuidMismatches(authenticatedAccount, message);
|
||||
|
||||
// X-Signal-Key: false must be sent until Android stops assuming it missing means true
|
||||
return client.sendRequest("PUT", "/api/v1/message",
|
||||
@@ -253,7 +258,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
} else {
|
||||
messageMetrics.measureOutgoingMessageLatency(message.getServerTimestamp(),
|
||||
"websocket",
|
||||
auth.getAuthenticatedDevice().isPrimary(),
|
||||
authenticatedDevice.isPrimary(),
|
||||
message.getUrgent(),
|
||||
message.getEphemeral(),
|
||||
client.getUserAgent(),
|
||||
@@ -263,12 +268,12 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
final CompletableFuture<Void> result;
|
||||
if (isSuccessResponse(response)) {
|
||||
|
||||
result = messagesManager.delete(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(),
|
||||
result = messagesManager.delete(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice,
|
||||
storedMessageInfo.guid(), storedMessageInfo.serverTimestamp())
|
||||
.thenApply(ignored -> null);
|
||||
|
||||
if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
|
||||
recordMessageDeliveryDuration(message.getServerTimestamp(), auth.getAuthenticatedDevice());
|
||||
recordMessageDeliveryDuration(message.getServerTimestamp(), authenticatedDevice);
|
||||
sendDeliveryReceiptFor(message);
|
||||
}
|
||||
} else {
|
||||
@@ -307,7 +312,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
|
||||
try {
|
||||
receiptSender.sendReceipt(ServiceIdentifier.valueOf(message.getDestinationServiceId()),
|
||||
auth.getAuthenticatedDevice().getId(), AciServiceIdentifier.valueOf(message.getSourceServiceId()),
|
||||
authenticatedDevice.getId(), AciServiceIdentifier.valueOf(message.getSourceServiceId()),
|
||||
message.getClientTimestamp());
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("Could not parse UUID: {}", message.getSourceServiceId());
|
||||
@@ -338,7 +343,6 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
// Cleared the queue! Send a queue empty message if we need to
|
||||
consecutiveRetries.set(0);
|
||||
if (sentInitialQueueEmptyMessage.compareAndSet(false, true)) {
|
||||
|
||||
final Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent()));
|
||||
final long drainDuration = System.currentTimeMillis() - queueDrainStartTime.get();
|
||||
|
||||
@@ -399,7 +403,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
final CompletableFuture<Void> queueCleared = new CompletableFuture<>();
|
||||
|
||||
final Publisher<Envelope> messages =
|
||||
messagesManager.getMessagesForDeviceReactive(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(), cachedMessagesOnly);
|
||||
messagesManager.getMessagesForDeviceReactive(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice, cachedMessagesOnly);
|
||||
|
||||
final AtomicBoolean hasSentFirstMessage = new AtomicBoolean();
|
||||
final AtomicBoolean hasErrored = new AtomicBoolean();
|
||||
@@ -410,8 +414,8 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
.limitRate(MESSAGE_PUBLISHER_LIMIT_RATE)
|
||||
.doOnNext(envelope -> {
|
||||
if (hasSentFirstMessage.compareAndSet(false, true)) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(authenticatedAccount.getIdentifier(IdentityType.ACI),
|
||||
authenticatedDevice.getId(),
|
||||
UUID.fromString(envelope.getServerGuid()),
|
||||
client.getUserAgent(),
|
||||
"websocket");
|
||||
@@ -471,7 +475,7 @@ public class WebSocketConnection implements WebSocketConnectionEventListener {
|
||||
final UUID messageGuid = UUID.fromString(envelope.getServerGuid());
|
||||
|
||||
if (envelope.getStory() && !client.shouldDeliverStories()) {
|
||||
messagesManager.delete(auth.getAccount().getUuid(), auth.getAuthenticatedDevice(), messageGuid, envelope.getServerTimestamp());
|
||||
messagesManager.delete(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice, messageGuid, envelope.getServerTimestamp());
|
||||
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user