diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/AuthenticationUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/AuthenticationUtil.java new file mode 100644 index 000000000..b68f0cf4e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/AuthenticationUtil.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.auth.grpc; + +import io.grpc.Context; +import java.util.Optional; +import java.util.UUID; + +/** + * Provides utility methods for working with authentication in the context of gRPC calls. + */ +public class AuthenticationUtil { + + static final Context.Key CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY = Context.key("authenticated-aci"); + static final Context.Key CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY = Context.key("authenticated-device-id"); + + public static Optional getAuthenticatedAccountIdentifier() { + return Optional.ofNullable(CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY.get()); + } + + public static Optional getAuthenticatedDeviceIdentifier() { + return Optional.ofNullable(CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY.get()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptor.java new file mode 100644 index 000000000..307c00092 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptor.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.auth.grpc; + +import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.auth.basic.BasicCredentials; +import io.grpc.Context; +import io.grpc.Contexts; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator; + +/** + * A basic credential authentication interceptor enforces the presence of a valid username and password on every call. + * Callers supply credentials by providing a username (UUID and optional device ID) and password pair in the + * {@code x-signal-basic-auth-credentials} call header. + *

+ * Downstream services can retrieve the identity of the authenticated caller using + * {@link AuthenticationUtil#getAuthenticatedAccountIdentifier()} and + * {@link AuthenticationUtil#getAuthenticatedDeviceIdentifier()}. + *

+ * Note that this authentication, while fully functional, is intended only for development and testing purposes and is + * intended to be replaced with a more robust and efficient strategy before widespread client adoption. + * + * @see AuthenticationUtil + * @see BaseAccountAuthenticator + */ +public class BasicCredentialAuthenticationInterceptor implements ServerInterceptor { + + private final BaseAccountAuthenticator baseAccountAuthenticator; + + @VisibleForTesting + static final Metadata.Key BASIC_CREDENTIALS = + Metadata.Key.of("x-signal-basic-auth-credentials", Metadata.ASCII_STRING_MARSHALLER); + + private static final Metadata EMPTY_TRAILERS = new Metadata(); + + public BasicCredentialAuthenticationInterceptor(final BaseAccountAuthenticator baseAccountAuthenticator) { + this.baseAccountAuthenticator = baseAccountAuthenticator; + } + + @Override + public ServerCall.Listener interceptCall(final ServerCall call, + final Metadata headers, + final ServerCallHandler next) { + + final String credentialString = headers.get(BASIC_CREDENTIALS); + + if (StringUtils.isNotBlank(credentialString)) { + try { + final BasicCredentials credentials = extractBasicCredentials(credentialString); + final Optional maybeAuthenticatedAccount = + baseAccountAuthenticator.authenticate(credentials, false); + + if (maybeAuthenticatedAccount.isPresent()) { + final AuthenticatedAccount authenticatedAccount = maybeAuthenticatedAccount.get(); + + final Context context = Context.current() + .withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY, authenticatedAccount.getAccount().getUuid()) + .withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY, authenticatedAccount.getAuthenticatedDevice().getId()); + + return Contexts.interceptCall(context, call, headers, next); + } else { + call.close(Status.UNAUTHENTICATED.withDescription("Credentials not accepted"), EMPTY_TRAILERS); + } + } catch (final IllegalArgumentException e) { + call.close(Status.UNAUTHENTICATED.withDescription("Could not parse credentials"), EMPTY_TRAILERS); + } + } else { + call.close(Status.UNAUTHENTICATED.withDescription("No credentials provided"), EMPTY_TRAILERS); + } + + return new ServerCall.Listener<>() {}; + } + + @VisibleForTesting + static BasicCredentials extractBasicCredentials(final String credentials) { + if (credentials.indexOf(':') < 0) { + throw new IllegalArgumentException("Credentials do not include a username and password part"); + } + + final String[] pieces = credentials.split(":", 2); + + return new BasicCredentials(pieces[0], pieces[1]); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/NotAuthenticatedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/NotAuthenticatedException.java new file mode 100644 index 000000000..67e596476 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/NotAuthenticatedException.java @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.auth.grpc; + +/** + * Indicates that a caller tried to get information about the authenticated gRPC caller, but no caller has been + * authenticated. + */ +public class NotAuthenticatedException extends Exception { +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptorTest.java new file mode 100644 index 000000000..1ac0d8565 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/BasicCredentialAuthenticationInterceptorTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.auth.grpc; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.dropwizard.auth.basic.BasicCredentials; +import io.grpc.CallCredentials; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.stream.Stream; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.MethodSource; +import org.signal.chat.rpc.EchoRequest; +import org.signal.chat.rpc.EchoServiceGrpc; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.BaseAccountAuthenticator; +import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.util.Pair; + +class BasicCredentialAuthenticationInterceptorTest { + + private Server server; + private ManagedChannel managedChannel; + + private BaseAccountAuthenticator baseAccountAuthenticator; + + + @BeforeEach + void setUp() throws IOException { + baseAccountAuthenticator = mock(BaseAccountAuthenticator.class); + + final BasicCredentialAuthenticationInterceptor authenticationInterceptor = + new BasicCredentialAuthenticationInterceptor(baseAccountAuthenticator); + + final String serverName = InProcessServerBuilder.generateName(); + + server = InProcessServerBuilder.forName(serverName) + .directExecutor() + .intercept(authenticationInterceptor) + .addService(new EchoServiceImpl()) + .build() + .start(); + + managedChannel = InProcessChannelBuilder.forName(serverName) + .directExecutor() + .build(); + } + + @AfterEach + void tearDown() { + managedChannel.shutdown(); + server.shutdown(); + } + + @ParameterizedTest + @MethodSource + void interceptCall(final Metadata headers, final boolean acceptCredentials, final boolean expectAuthentication) { + if (acceptCredentials) { + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(UUID.randomUUID()); + + final Device device = mock(Device.class); + when(device.getId()).thenReturn(Device.MASTER_ID); + + when(baseAccountAuthenticator.authenticate(any(), anyBoolean())) + .thenReturn(Optional.of(new AuthenticatedAccount(() -> new Pair<>(account, device)))); + } else { + when(baseAccountAuthenticator.authenticate(any(), anyBoolean())) + .thenReturn(Optional.empty()); + } + + final EchoServiceGrpc.EchoServiceBlockingStub stub = EchoServiceGrpc.newBlockingStub(managedChannel) + .withCallCredentials(new CallCredentials() { + @Override + public void applyRequestMetadata(final RequestInfo requestInfo, final Executor appExecutor, final MetadataApplier applier) { + applier.apply(headers); + } + + @Override + public void thisUsesUnstableApi() { + } + }); + + if (expectAuthentication) { + assertDoesNotThrow(() -> stub.echo(EchoRequest.newBuilder().build())); + } else { + final StatusRuntimeException exception = + assertThrows(StatusRuntimeException.class, () -> stub.echo(EchoRequest.newBuilder().build())); + + assertEquals(Status.UNAUTHENTICATED.getCode(), exception.getStatus().getCode()); + } + } + + private static Stream interceptCall() { + final Metadata malformedCredentialHeaders = new Metadata(); + malformedCredentialHeaders.put(BasicCredentialAuthenticationInterceptor.BASIC_CREDENTIALS, "Incorrect"); + + final Metadata structurallyValidCredentialHeaders = new Metadata(); + structurallyValidCredentialHeaders.put(BasicCredentialAuthenticationInterceptor.BASIC_CREDENTIALS, + UUID.randomUUID() + ":" + RandomStringUtils.randomAlphanumeric(16)); + + return Stream.of( + Arguments.of(new Metadata(), true, false), + Arguments.of(malformedCredentialHeaders, true, false), + Arguments.of(structurallyValidCredentialHeaders, false, false), + Arguments.of(structurallyValidCredentialHeaders, true, true) + ); + } + + @Test + void extractBasicCredentials() { + final String username = UUID.randomUUID().toString(); + final String password = RandomStringUtils.random(16); + + final BasicCredentials basicCredentials = + BasicCredentialAuthenticationInterceptor.extractBasicCredentials(username + ":" + password); + + assertEquals(username, basicCredentials.getUsername()); + assertEquals(password, basicCredentials.getPassword()); + } + + @Test + void extractBasicCredentialsIllegalArgument() { + assertThrows(IllegalArgumentException.class, + () -> BasicCredentialAuthenticationInterceptor.extractBasicCredentials("This does not include a password")); + } +}