Add a basic, prototype authentication interceptor for gRPC services
This commit is contained in:
parent
b5fd131aba
commit
f26bc70b59
|
@ -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<UUID> CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY = Context.key("authenticated-aci");
|
||||||
|
static final Context.Key<Long> CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY = Context.key("authenticated-device-id");
|
||||||
|
|
||||||
|
public static Optional<UUID> getAuthenticatedAccountIdentifier() {
|
||||||
|
return Optional.ofNullable(CONTEXT_AUTHENTICATED_ACCOUNT_IDENTIFIER_KEY.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<Long> getAuthenticatedDeviceIdentifier() {
|
||||||
|
return Optional.ofNullable(CONTEXT_AUTHENTICATED_DEVICE_IDENTIFIER_KEY.get());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
|
* <p/>
|
||||||
|
* Downstream services can retrieve the identity of the authenticated caller using
|
||||||
|
* {@link AuthenticationUtil#getAuthenticatedAccountIdentifier()} and
|
||||||
|
* {@link AuthenticationUtil#getAuthenticatedDeviceIdentifier()}.
|
||||||
|
* <p/>
|
||||||
|
* 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<String> 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 <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||||
|
final Metadata headers,
|
||||||
|
final ServerCallHandler<ReqT, RespT> next) {
|
||||||
|
|
||||||
|
final String credentialString = headers.get(BASIC_CREDENTIALS);
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(credentialString)) {
|
||||||
|
try {
|
||||||
|
final BasicCredentials credentials = extractBasicCredentials(credentialString);
|
||||||
|
final Optional<AuthenticatedAccount> 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]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
}
|
|
@ -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<Arguments> 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"));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue