Skip to content

Commit

Permalink
Add /v1/accounts and /v2/keys to the WebSocket
Browse files Browse the repository at this point in the history
  • Loading branch information
eager-signal authored Jan 2, 2024
1 parent ad6b99b commit 195f23c
Show file tree
Hide file tree
Showing 37 changed files with 290 additions and 691 deletions.
13 changes: 0 additions & 13 deletions api-doc/src/main/java/org/signal/openapi/OpenApiExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import java.util.Set;
import javax.ws.rs.Consumes;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;

/**
* One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations
Expand All @@ -42,10 +41,6 @@ public class OpenApiExtension extends AbstractOpenAPIExtension {

public static final ResolvedParameter OPTIONAL_AUTHENTICATED_ACCOUNT = new ResolvedParameter();

public static final ResolvedParameter DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();

public static final ResolvedParameter OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT = new ResolvedParameter();

/**
* When parsing endpoint methods, Swagger will treat the first parameter not annotated as header/path/query param
* as a request body (and will ignore other not annotated parameters). In our case, this behavior conflicts with
Expand All @@ -70,18 +65,10 @@ public ResolvedParameter extractParameters(
&& simpleType.getRawClass().equals(AuthenticatedAccount.class)) {
return AUTHENTICATED_ACCOUNT;
}
if (type instanceof SimpleType simpleType
&& simpleType.getRawClass().equals(DisabledPermittedAuthenticatedAccount.class)) {
return DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
}
if (type instanceof SimpleType simpleType
&& isOptionalOfType(simpleType, AuthenticatedAccount.class)) {
return OPTIONAL_AUTHENTICATED_ACCOUNT;
}
if (type instanceof SimpleType simpleType
&& isOptionalOfType(simpleType, DisabledPermittedAuthenticatedAccount.class)) {
return OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
}
}

return super.extractParameters(
Expand Down
6 changes: 2 additions & 4 deletions api-doc/src/main/java/org/signal/openapi/OpenApiReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@

import static com.google.common.base.MoreObjects.firstNonNull;
import static org.signal.openapi.OpenApiExtension.AUTHENTICATED_ACCOUNT;
import static org.signal.openapi.OpenApiExtension.DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;
import static org.signal.openapi.OpenApiExtension.OPTIONAL_AUTHENTICATED_ACCOUNT;
import static org.signal.openapi.OpenApiExtension.OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT;

import com.fasterxml.jackson.annotation.JsonView;
import com.google.common.collect.ImmutableList;
Expand Down Expand Up @@ -54,13 +52,13 @@ protected ResolvedParameter getParameters(
final ResolvedParameter resolved = super.getParameters(
type, annotations, operation, classConsumes, methodConsumes, jsonViewAnnotation);

if (resolved == AUTHENTICATED_ACCOUNT || resolved == DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
if (resolved == AUTHENTICATED_ACCOUNT) {
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
.build());
}
if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT || resolved == OPTIONAL_DISABLED_PERMITTED_AUTHENTICATED_ACCOUNT) {
if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT) {
operation.setSecurity(ImmutableList.<SecurityRequirement>builder()
.addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))
.add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@
import static com.codahale.metrics.MetricRegistry.name;
import static java.util.Objects.requireNonNull;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import io.dropwizard.auth.AuthDynamicFeature;
import io.dropwizard.auth.AuthFilter;
import io.dropwizard.auth.PolymorphicAuthDynamicFeature;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.auth.basic.BasicCredentialAuthFilter;
import io.dropwizard.auth.basic.BasicCredentials;
import io.dropwizard.core.Application;
Expand Down Expand Up @@ -61,10 +59,7 @@
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
Expand Down Expand Up @@ -580,8 +575,6 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
reportMessageManager.addListener(reportedMessageMetricsListener);

final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
final DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(
accountsManager);

final MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager,
pushNotificationManager,
Expand Down Expand Up @@ -686,13 +679,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
config.getClientCdn().getAttachmentUrls(),
clock);

AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
accountAuthenticator).buildAuthFilter();
AuthFilter<BasicCredentials, DisabledPermittedAuthenticatedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAuthenticatedAccount>().setAuthenticator(
disabledPermittedAccountAuthenticator).buildAuthFilter();

final BasicCredentialAuthenticationInterceptor basicCredentialAuthenticationInterceptor =
new BasicCredentialAuthenticationInterceptor(new BaseAccountAuthenticator(accountsManager));
new BasicCredentialAuthenticationInterceptor(new AccountAuthenticator(accountsManager));

final ServerBuilder<?> grpcServer = ServerBuilder.forPort(config.getGrpcPort())
.addService(ServerInterceptors.intercept(new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager), basicCredentialAuthenticationInterceptor))
Expand Down Expand Up @@ -724,14 +712,16 @@ public void run(WhisperServerConfiguration config, Environment environment) thro

environment.lifecycle().manage(new GrpcServerManagedWrapper(grpcServer.build()));

final AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter =
new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>()
.setAuthenticator(accountAuthenticator)
.buildAuthFilter();

environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP));
environment.jersey().register(MultiRecipientMessageProvider.class);
environment.jersey().register(new MetricsApplicationEventListener(TrafficSource.HTTP, clientReleaseManager));
environment.jersey()
.register(new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(AuthenticatedAccount.class, accountAuthFilter,
DisabledPermittedAuthenticatedAccount.class, disabledPermittedAccountAuthFilter)));
environment.jersey().register(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)));
environment.jersey().register(new AuthDynamicFeature(accountAuthFilter));
environment.jersey().register(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class));
environment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
environment.jersey().register(new TimestampResponseFilter());

Expand All @@ -749,14 +739,6 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET, clientReleaseManager));
webSocketEnvironment.jersey().register(new KeepAliveController(clientPresenceManager));

// these should be common, but use @Auth DisabledPermittedAccount, which isn’t supported yet on websocket
environment.jersey().register(
new AccountController(accountsManager, rateLimiters,
turnTokenGenerator,
registrationRecoveryPasswordsManager, usernameHashZkProofVerifier));

environment.jersey().register(new KeysController(rateLimiters, keysManager, accountsManager));

boolean registeredSpamFilter = false;
ReportSpamTokenProvider reportSpamTokenProvider = null;

Expand Down Expand Up @@ -804,6 +786,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
}

final List<Object> commonControllers = Lists.newArrayList(
new AccountController(accountsManager, rateLimiters, turnTokenGenerator, registrationRecoveryPasswordsManager,
usernameHashZkProofVerifier),
new AccountControllerV2(accountsManager, changeNumberManager, phoneVerificationTokenManager,
registrationLockVerificationManager, rateLimiters),
new ArtController(rateLimiters, artCredentialsGenerator),
Expand All @@ -824,6 +808,7 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new),
new KeysController(rateLimiters, keysManager, accountsManager),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, receiptSender,
accountsManager, messagesManager, pushNotificationManager, reportMessageManager,
multiRecipientMessageExecutor, messageDeliveryScheduler, reportSpamTokenProvider, clientReleaseManager,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,155 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.auth;

import static com.codahale.metrics.MetricRegistry.name;

import com.google.common.annotations.VisibleForTesting;
import io.dropwizard.auth.Authenticator;
import io.dropwizard.auth.basic.BasicCredentials;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tags;
import java.time.Clock;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.RefreshingAccountAndDeviceSupplier;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util;

public class AccountAuthenticator implements Authenticator<BasicCredentials, AuthenticatedAccount> {

private static final String LEGACY_NAME_PREFIX = "org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator";

public class AccountAuthenticator extends BaseAccountAuthenticator implements
Authenticator<BasicCredentials, AuthenticatedAccount> {
private static final String AUTHENTICATION_COUNTER_NAME = name(LEGACY_NAME_PREFIX, "authentication");
private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded";
private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason";

private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(LEGACY_NAME_PREFIX, "daysSinceLastSeen");
private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary";

@VisibleForTesting
static final char DEVICE_ID_SEPARATOR = '.';

private final AccountsManager accountsManager;
private final Clock clock;

public AccountAuthenticator(AccountsManager accountsManager) {
super(accountsManager);
this(accountsManager, Clock.systemUTC());
}

@VisibleForTesting
public AccountAuthenticator(AccountsManager accountsManager, Clock clock) {
this.accountsManager = accountsManager;
this.clock = clock;
}

static Pair<String, Byte> getIdentifierAndDeviceId(final String basicUsername) {
final String identifier;
final byte deviceId;

final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR);

if (deviceIdSeparatorIndex == -1) {
identifier = basicUsername;
deviceId = Device.PRIMARY_ID;
} else {
identifier = basicUsername.substring(0, deviceIdSeparatorIndex);
deviceId = Byte.parseByte(basicUsername.substring(deviceIdSeparatorIndex + 1));
}

return new Pair<>(identifier, deviceId);
}

@Override
public Optional<AuthenticatedAccount> authenticate(BasicCredentials basicCredentials) {
return super.authenticate(basicCredentials, true);
boolean succeeded = false;
String failureReason = null;

try {
final UUID accountUuid;
final byte deviceId;
{
final Pair<String, Byte> identifierAndDeviceId = getIdentifierAndDeviceId(basicCredentials.getUsername());

accountUuid = UUID.fromString(identifierAndDeviceId.first());
deviceId = identifierAndDeviceId.second();
}

Optional<Account> account = accountsManager.getByAccountIdentifier(accountUuid);

if (account.isEmpty()) {
failureReason = "noSuchAccount";
return Optional.empty();
}

Optional<Device> device = account.get().getDevice(deviceId);

if (device.isEmpty()) {
failureReason = "noSuchDevice";
return Optional.empty();
}

SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash();
if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) {
succeeded = true;
Account authenticatedAccount = updateLastSeen(account.get(), device.get());
if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) {
authenticatedAccount = accountsManager.updateDeviceAuthentication(
authenticatedAccount,
device.get(),
SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version
}
return Optional.of(new AuthenticatedAccount(
new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager)));
}

return Optional.empty();
} catch (IllegalArgumentException | InvalidAuthorizationHeaderException iae) {
failureReason = "invalidHeader";
return Optional.empty();
} finally {
Tags tags = Tags.of(
AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded));

if (StringUtils.isNotBlank(failureReason)) {
tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason);
}

Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();
}
}

@VisibleForTesting
public Account updateLastSeen(Account account, Device device) {
// compute a non-negative integer between 0 and 86400.
long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits());
final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds();

// produce a truncated timestamp which is either today at UTC midnight
// or yesterday at UTC midnight, based on per-user randomized offset used.
final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock,
Duration.ofSeconds(lastSeenOffsetSeconds).negated());

// only update the device's last seen time when it falls behind the truncated timestamp.
// this ensures a few things:
// (1) each account will only update last-seen at most once per day
// (2) these updates will occur throughout the day rather than all occurring at UTC midnight.
if (device.getLastSeen() < todayInMillisWithOffset) {
Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isPrimary()))
.record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays());

return accountsManager.updateDeviceLastSeen(account, device, Util.todayInMillis(clock));
}

return account;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
* {@link io.dropwizard.auth.Auth} object with a current device list.
*
* @see AuthenticatedAccount
* @see DisabledPermittedAuthenticatedAccount
*/
public class AuthEnablementRefreshRequirementProvider implements WebsocketRefreshRequirementProvider {

Expand Down
Loading

0 comments on commit 195f23c

Please sign in to comment.