gRPC API for external services credentials service

This commit is contained in:
Sergey Skrobotov
2023-09-14 14:38:58 -07:00
parent d0fdae3df7
commit 0b3af7d824
13 changed files with 698 additions and 15 deletions

View File

@@ -7,7 +7,6 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
import com.google.common.collect.ImmutableSet;
@@ -23,9 +22,9 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccou
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ExtendWith(DropwizardExtensionsSupport.class)
@@ -33,7 +32,6 @@ class ArtControllerTest {
private static final ArtServiceConfiguration ART_SERVICE_CONFIGURATION = new ArtServiceConfiguration(
randomSecretBytes(32), randomSecretBytes(32), Duration.ofDays(1));
private static final ExternalServiceCredentialsGenerator artCredentialsGenerator = ArtController.credentialsGenerator(ART_SERVICE_CONFIGURATION);
private static final RateLimiter rateLimiter = mock(RateLimiter.class);
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final ResourceExtension resources = ResourceExtension.builder()
@@ -47,9 +45,8 @@ class ArtControllerTest {
@Test
void testGetAuthToken() {
when(rateLimiters.getArtPackLimiter()).thenReturn(rateLimiter);
ExternalServiceCredentials token =
MockUtils.updateRateLimiterResponseToAllow(rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AuthHelper.VALID_UUID);
final ExternalServiceCredentials token =
resources.getJerseyTest()
.target("/v1/art/auth")
.request()

View File

@@ -268,7 +268,7 @@ abstract class SecureValueRecoveryControllerBaseTest {
return token(credentials(uuid, timeMillis));
}
private static String token(ExternalServiceCredentials credentials) {
private static String token(final ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.signal.chat.credentials.AuthCheckResult;
import org.signal.chat.credentials.CheckSvrCredentialsRequest;
import org.signal.chat.credentials.CheckSvrCredentialsResponse;
import org.signal.chat.credentials.ExternalServiceCredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock;
class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
SimpleBaseGrpcTest<ExternalServiceCredentialsAnonymousGrpcService, ExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousBlockingStub> {
private static final UUID USER_UUID = UUID.randomUUID();
private static final String USER_E164 = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164
);
private static final MutableClock CLOCK = MockUtils.mutableClock(0);
private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(RandomUtils.nextBytes(32))
.withUserDerivationKey(RandomUtils.nextBytes(32))
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(CLOCK)
.build());
@Mock
private AccountsManager accountsManager;
@Override
protected ExternalServiceCredentialsAnonymousGrpcService createServiceBeforeEachTest() {
return new ExternalServiceCredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR);
}
@BeforeEach
public void setup() {
Mockito.when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account(USER_UUID)));
}
@Test
public void testOneMatch() throws Exception {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
token(USER_UUID, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,
token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH
), day(2));
}
@Test
public void testNoMatch() throws Exception {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH
), day(2));
}
@Test
public void testSomeInvalid() throws Exception {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
final ExternalServiceCredentials user1Cred = credentials(USER_UUID, day(1));
final ExternalServiceCredentials user2Cred = credentials(user2, day(1));
final ExternalServiceCredentials user3Cred = credentials(user3, day(1));
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
assertExpectedCredentialCheckResponse(Map.of(
token(user1Cred), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,
token(user2Cred), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
fakeToken, AuthCheckResult.AUTH_CHECK_RESULT_INVALID
), day(2));
}
@Test
public void testSomeExpired() throws Exception {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
token(USER_UUID, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,
token(user2, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID,
token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID
), day(110));
}
@Test
public void testSomeHaveNewerVersions() throws Exception {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
token(USER_UUID, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID,
token(USER_UUID, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,
token(user2, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,
token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID
), day(25));
}
private void assertExpectedCredentialCheckResponse(
final Map<String, AuthCheckResult> expected,
final long nowMillis) throws Exception {
CLOCK.setTimeMillis(nowMillis);
final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder()
.setNumber(USER_E164)
.addAllPasswords(expected.keySet())
.build();
final CheckSvrCredentialsResponse response = unauthenticatedServiceStub().checkSvrCredentials(request);
final Map<String, AuthCheckResult> matchesMap = response.getMatchesMap();
assertEquals(expected, matchesMap);
}
private static String token(final UUID uuid, final long timeMillis) {
return token(credentials(uuid, timeMillis));
}
private static String token(final ExternalServiceCredentials credentials) {
return credentials.username() + ":" + credentials.password();
}
private static ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
CLOCK.setTimeMillis(timeMillis);
return SVR_CREDENTIALS_GENERATOR.generateForUuid(uuid);
}
private static long day(final int n) {
return Duration.ofDays(n).toMillis();
}
private static Account account(final UUID uuid) {
final Account a = new Account();
a.setUuid(uuid);
return a;
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusUnauthenticated;
import io.grpc.Status;
import java.time.Duration;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.apache.commons.lang3.RandomUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.signal.chat.credentials.ExternalServiceCredentialsGrpc;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.util.MockUtils;
import reactor.core.publisher.Mono;
public class ExternalServiceCredentialsGrpcServiceTest
extends SimpleBaseGrpcTest<ExternalServiceCredentialsGrpcService, ExternalServiceCredentialsGrpc.ExternalServiceCredentialsBlockingStub> {
private static final ExternalServiceCredentialsGenerator ART_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(RandomUtils.nextBytes(32))
.withUserDerivationKey(RandomUtils.nextBytes(32))
.prependUsername(false)
.truncateSignature(false)
.build());
private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(RandomUtils.nextBytes(32))
.prependUsername(true)
.build());
@Mock
private RateLimiters rateLimiters;
@Override
protected ExternalServiceCredentialsGrpcService createServiceBeforeEachTest() {
return new ExternalServiceCredentialsGrpcService(Map.of(
ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART, ART_CREDENTIALS_GENERATOR,
ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR
), rateLimiters);
}
static Stream<Arguments> testSuccess() {
return Stream.of(
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART, ART_CREDENTIALS_GENERATOR),
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR)
);
}
@ParameterizedTest
@MethodSource
public void testSuccess(
final ExternalServiceType externalServiceType,
final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception {
final RateLimiter limiter = mock(RateLimiter.class);
doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS));
doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI));
final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(externalServiceType)
.build());
final Optional<Long> artValidation = credentialsGenerator.validateAndGetTimestamp(
new ExternalServiceCredentials(artResponse.getUsername(), artResponse.getPassword()));
assertTrue(artValidation.isPresent());
}
@ParameterizedTest
@ValueSource(ints = { -1, 0, 1000 })
public void testUnrecognizedService(final int externalServiceTypeValue) throws Exception {
assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalServiceValue(externalServiceTypeValue)
.build()));
}
@Test
public void testInvalidRequest() throws Exception {
assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.build()));
}
@Test
public void testRateLimitExceeded() throws Exception {
final Duration retryAfter = MockUtils.updateRateLimiterResponseToFail(
rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AUTHENTICATED_ACI, Duration.ofSeconds(100), false);
Mockito.reset(ART_CREDENTIALS_GENERATOR);
assertRateLimitExceeded(
retryAfter,
() -> authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART)
.build()),
ART_CREDENTIALS_GENERATOR
);
}
@Test
public void testUnauthenticatedCall() throws Exception {
assertStatusUnauthenticated(() -> unauthenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART)
.build()));
}
/**
* `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`,
* except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`.
* This test makes sure that is the case.
*/
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { "UNRECOGNIZED", "EXTERNAL_SERVICE_TYPE_UNSPECIFIED" })
public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception {
assertTrue(
Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType),
"`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`".formatted(externalServiceType)
);
}
}

View File

@@ -50,6 +50,18 @@ public final class GrpcTestUtils {
assertEquals(expected.getCode(), exception.getStatus().getCode());
}
public static void assertStatusInvalidArgument(final Executable serviceCall) {
assertStatusException(Status.INVALID_ARGUMENT, serviceCall);
}
public static void assertStatusUnauthenticated(final Executable serviceCall) {
assertStatusException(Status.UNAUTHENTICATED, serviceCall);
}
public static void assertStatusPermissionDenied(final Executable serviceCall) {
assertStatusException(Status.PERMISSION_DENIED, serviceCall);
}
public static void assertRateLimitExceeded(
final Duration expectedRetryAfter,
final Executable serviceCall,