mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-22 03:38:03 +01:00
gRPC API for external services credentials service
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user