mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-18 00:13:21 +01:00
Remove ChallengeRequired variants from sealed-sender gRPC responses
This commit is contained in:
@@ -27,6 +27,7 @@ import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.protocol.InvalidVersionException;
|
||||
import org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||
import org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
@@ -37,7 +38,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.push.MessageUtil;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.MessageType;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
@@ -155,16 +156,15 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
|
||||
final boolean urgent,
|
||||
final boolean story) throws RateLimitExceededException {
|
||||
|
||||
final SpamCheckResult<GrpcResponse<SendMessageResponse>> spamCheckResult =
|
||||
final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =
|
||||
spamChecker.checkForIndividualRecipientSpamGrpc(
|
||||
story ? MessageType.INDIVIDUAL_STORY : MessageType.INDIVIDUAL_SEALED_SENDER,
|
||||
Optional.empty(),
|
||||
Optional.of(destination),
|
||||
destinationServiceIdentifier);
|
||||
|
||||
if (spamCheckResult.response().isPresent()) {
|
||||
return spamCheckResult.response().get().getResponseOrThrowStatus();
|
||||
}
|
||||
spamCheckResult.response().ifPresent(grpcResponse ->
|
||||
grpcResponse.throwStatusOr(_ -> GrpcExceptions.rateLimitExceeded(null)));
|
||||
|
||||
try {
|
||||
final int totalPayloadLength = messages.getMessagesMap().values().stream()
|
||||
@@ -208,12 +208,22 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
|
||||
entry -> entry.getKey().byteValue(),
|
||||
entry -> entry.getValue().getRegistrationId()));
|
||||
|
||||
return MessagesGrpcHelper.sendMessage(messageSender,
|
||||
destination,
|
||||
destinationServiceIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
Optional.empty());
|
||||
try {
|
||||
messageSender.sendMessages(destination,
|
||||
destinationServiceIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
Optional.empty(),
|
||||
RequestAttributesUtil.getUserAgent().orElse(null));
|
||||
|
||||
return SEND_MESSAGE_SUCCESS_RESPONSE;
|
||||
} catch (final MismatchedDevicesException e) {
|
||||
return SendMessageResponse.newBuilder()
|
||||
.setMismatchedDevices(MessagesGrpcHelper.buildMismatchedDevices(destinationServiceIdentifier, e.getMismatchedDevices()))
|
||||
.build();
|
||||
} catch (final MessageTooLargeException e) {
|
||||
throw GrpcExceptions.invalidArguments("message too large");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -265,14 +275,13 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
|
||||
final boolean urgent,
|
||||
final boolean story) {
|
||||
|
||||
final SpamCheckResult<GrpcResponse<SendMultiRecipientMessageResponse>> spamCheckResult =
|
||||
final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =
|
||||
spamChecker.checkForMultiRecipientSpamGrpc(story
|
||||
? MessageType.MULTI_RECIPIENT_STORY
|
||||
: MessageType.MULTI_RECIPIENT_SEALED_SENDER);
|
||||
|
||||
if (spamCheckResult.response().isPresent()) {
|
||||
return spamCheckResult.response().get().getResponseOrThrowStatus();
|
||||
}
|
||||
spamCheckResult.response().ifPresent(response ->
|
||||
response.throwStatusOr(_ -> GrpcExceptions.rateLimitExceeded(null)));
|
||||
|
||||
// At this point, the caller has at least superficially provided the information needed to send a multi-recipient
|
||||
// message. Attempt to resolve the destination service identifiers to Signal accounts.
|
||||
|
||||
@@ -5,71 +5,11 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.Empty;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.signal.chat.messages.MismatchedDevices;
|
||||
import org.signal.chat.messages.SendMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
public class MessagesGrpcHelper {
|
||||
|
||||
private static final SendMessageResponse SEND_MESSAGE_SUCCESS_RESPONSE = SendMessageResponse
|
||||
.newBuilder()
|
||||
.setSuccess(Empty.getDefaultInstance())
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Sends a "bundle" of messages to an individual destination account, mapping common exceptions to appropriate gRPC
|
||||
* statuses.
|
||||
*
|
||||
* @param messageSender the {@code MessageSender} instance to use to send the messages
|
||||
* @param destination the destination account for the messages
|
||||
* @param destinationServiceIdentifier the service identifier for the destination account
|
||||
* @param messagesByDeviceId a map of device IDs to message payloads
|
||||
* @param registrationIdsByDeviceId a map of device IDs to device registration IDs
|
||||
* @param syncMessageSenderDeviceId if the message is a sync message (i.e. a message to other devices linked to the
|
||||
* caller's own account), contains the ID of the device that sent the message
|
||||
*
|
||||
* @return a response object to send to callers
|
||||
*
|
||||
* @throws RateLimitExceededException if the message bundle could not be sent due to a violated rated limit
|
||||
* @throws io.grpc.StatusRuntimeException for invalid arguments if the message is too large to send
|
||||
*/
|
||||
public static SendMessageResponse sendMessage(final MessageSender messageSender,
|
||||
final Account destination,
|
||||
final ServiceIdentifier destinationServiceIdentifier,
|
||||
final Map<Byte, MessageProtos.Envelope> messagesByDeviceId,
|
||||
final Map<Byte, Integer> registrationIdsByDeviceId,
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType") final Optional<Byte> syncMessageSenderDeviceId)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
try {
|
||||
messageSender.sendMessages(destination,
|
||||
destinationServiceIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
syncMessageSenderDeviceId,
|
||||
RequestAttributesUtil.getUserAgent().orElse(null));
|
||||
|
||||
return SEND_MESSAGE_SUCCESS_RESPONSE;
|
||||
} catch (final MismatchedDevicesException e) {
|
||||
return SendMessageResponse.newBuilder()
|
||||
.setMismatchedDevices(buildMismatchedDevices(destinationServiceIdentifier, e.getMismatchedDevices()))
|
||||
.build();
|
||||
} catch (final MessageTooLargeException e) {
|
||||
throw GrpcExceptions.invalidArguments("message too large");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an internal {@link org.whispersystems.textsecuregcm.controllers.MismatchedDevices} entity to a gRPC
|
||||
* {@link MismatchedDevices} entity.
|
||||
|
||||
@@ -10,15 +10,17 @@ import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import com.google.protobuf.Empty;
|
||||
import org.signal.chat.errors.NotFound;
|
||||
import org.signal.chat.messages.AuthenticatedSenderMessageType;
|
||||
import org.signal.chat.messages.IndividualRecipientMessageBundle;
|
||||
import org.signal.chat.messages.SendAuthenticatedSenderMessageRequest;
|
||||
import org.signal.chat.messages.SendMessageResponse;
|
||||
import org.signal.chat.messages.SendMessageAuthenticatedSenderResponse;
|
||||
import org.signal.chat.messages.SendSyncMessageRequest;
|
||||
import org.signal.chat.messages.SimpleMessagesGrpc;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
@@ -26,13 +28,16 @@ import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcResponse;
|
||||
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.MessageType;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.grpc.MessagesGrpcHelper.buildMismatchedDevices;
|
||||
|
||||
public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
@@ -42,6 +47,9 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
private final SpamChecker spamChecker;
|
||||
private final Clock clock;
|
||||
|
||||
private static final SendMessageAuthenticatedSenderResponse SEND_MESSAGE_SUCCESS_RESPONSE =
|
||||
SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
|
||||
|
||||
public MessagesGrpcService(final AccountsManager accountsManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final MessageSender messageSender,
|
||||
@@ -58,7 +66,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResponse sendMessage(final SendAuthenticatedSenderMessageRequest request)
|
||||
public SendMessageAuthenticatedSenderResponse sendMessage(final SendAuthenticatedSenderMessageRequest request)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
@@ -75,7 +83,9 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
|
||||
final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);
|
||||
if (maybeDestination.isEmpty()) {
|
||||
return SendMessageResponse.newBuilder().setDestinationNotFound(NotFound.getDefaultInstance()).build();
|
||||
return SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setDestinationNotFound(NotFound.getDefaultInstance())
|
||||
.build();
|
||||
}
|
||||
final Account destination = maybeDestination.get();
|
||||
|
||||
@@ -92,7 +102,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SendMessageResponse sendSyncMessage(final SendSyncMessageRequest request)
|
||||
public SendMessageAuthenticatedSenderResponse sendSyncMessage(final SendSyncMessageRequest request)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
@@ -110,7 +120,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
request.getUrgent());
|
||||
}
|
||||
|
||||
private SendMessageResponse sendMessage(final Account destination,
|
||||
private SendMessageAuthenticatedSenderResponse sendMessage(final Account destination,
|
||||
final ServiceIdentifier destinationServiceIdentifier,
|
||||
final AuthenticatedDevice sender,
|
||||
final AuthenticatedSenderMessageType envelopeType,
|
||||
@@ -130,14 +140,16 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
throw e;
|
||||
}
|
||||
|
||||
final SpamCheckResult<GrpcResponse<SendMessageResponse>> spamCheckResult =
|
||||
final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =
|
||||
spamChecker.checkForIndividualRecipientSpamGrpc(messageType,
|
||||
Optional.of(sender),
|
||||
Optional.of(destination),
|
||||
destinationServiceIdentifier);
|
||||
|
||||
if (spamCheckResult.response().isPresent()) {
|
||||
return spamCheckResult.response().get().getResponseOrThrowStatus();
|
||||
return SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setChallengeRequired(spamCheckResult.response().get().getResponseOrThrowStatus())
|
||||
.build();
|
||||
}
|
||||
|
||||
final Map<Byte, MessageProtos.Envelope> messagesByDeviceId = messages.getMessagesMap().entrySet()
|
||||
@@ -168,12 +180,22 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
|
||||
entry -> entry.getKey().byteValue(),
|
||||
entry -> entry.getValue().getRegistrationId()));
|
||||
|
||||
return MessagesGrpcHelper.sendMessage(messageSender,
|
||||
destination,
|
||||
destinationServiceIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
messageType == MessageType.SYNC ? Optional.of(sender.deviceId()) : Optional.empty());
|
||||
try {
|
||||
messageSender.sendMessages(destination,
|
||||
destinationServiceIdentifier,
|
||||
messagesByDeviceId,
|
||||
registrationIdsByDeviceId,
|
||||
messageType == MessageType.SYNC ? Optional.of(sender.deviceId()) : Optional.empty(),
|
||||
RequestAttributesUtil.getUserAgent().orElse(null));
|
||||
|
||||
return SEND_MESSAGE_SUCCESS_RESPONSE;
|
||||
} catch (final MismatchedDevicesException e) {
|
||||
return SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setMismatchedDevices(buildMismatchedDevices(destinationServiceIdentifier, e.getMismatchedDevices()))
|
||||
.build();
|
||||
} catch (final MessageTooLargeException e) {
|
||||
throw GrpcExceptions.invalidArguments("message too large");
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageProtos.Envelope.Type getEnvelopeType(final AuthenticatedSenderMessageType type) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.spam;
|
||||
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import java.util.function.Function;
|
||||
import javax.annotation.Nullable;
|
||||
import org.signal.chat.messages.ChallengeRequired;
|
||||
|
||||
/// A gRPC status or a challenge message to communicate to callers that a message has been flagged as potential spam.
|
||||
public class GrpcChallengeResponse {
|
||||
|
||||
@Nullable
|
||||
private final StatusRuntimeException statusException;
|
||||
|
||||
@Nullable
|
||||
private final ChallengeRequired response;
|
||||
|
||||
private GrpcChallengeResponse(final @Nullable StatusRuntimeException statusException,
|
||||
@Nullable final ChallengeRequired response) {
|
||||
this.statusException = statusException;
|
||||
this.response = response;
|
||||
if (!((statusException == null) ^ (response == null))) {
|
||||
throw new IllegalArgumentException("exactly one of statusException and response must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new response object with the given status and no challenge
|
||||
///
|
||||
/// @param status the status to send to callers
|
||||
/// @return a new response object with the given status and no challenge
|
||||
public static GrpcChallengeResponse withStatusException(final StatusRuntimeException status) {
|
||||
return new GrpcChallengeResponse(status, null);
|
||||
}
|
||||
|
||||
/// Constructs a new response object with the given challenge message.
|
||||
///
|
||||
/// @param response the challenge message to send to the caller
|
||||
/// @return a new response object with the given challenge message
|
||||
public static GrpcChallengeResponse withResponse(final ChallengeRequired response) {
|
||||
return new GrpcChallengeResponse(null, response);
|
||||
}
|
||||
|
||||
/// Returns the challenge message contained within this response or throws the contained status as a
|
||||
/// [StatusRuntimeException] if no challenge message is specified.
|
||||
///
|
||||
/// @return the [ChallengeRequired] message
|
||||
/// @throws StatusRuntimeException if no challenge message is specified
|
||||
public ChallengeRequired getResponseOrThrowStatus() throws StatusRuntimeException {
|
||||
if (statusException != null) {
|
||||
throw statusException;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/// If this response contains a challenge message, throw a status generated using statusMapper. Otherwise, throw the
|
||||
/// status.
|
||||
///
|
||||
/// @param statusMapper A function that converts a [ChallengeRequired] message into a
|
||||
/// [StatusRuntimeException]
|
||||
/// @throws StatusRuntimeException the contained or mapped status exception
|
||||
public void throwStatusOr(Function<ChallengeRequired, StatusRuntimeException> statusMapper)
|
||||
throws StatusRuntimeException {
|
||||
if (statusException != null) {
|
||||
throw statusException;
|
||||
}
|
||||
throw statusMapper.apply(response);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.spam;
|
||||
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusException;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A combination of a gRPC status and response message to communicate to callers that a message has been flagged as
|
||||
* potential spam.
|
||||
*
|
||||
* @param <R> the type of response object
|
||||
*/
|
||||
public class GrpcResponse<R> {
|
||||
|
||||
@Nullable
|
||||
private final StatusRuntimeException statusException;
|
||||
|
||||
@Nullable
|
||||
private final R response;
|
||||
|
||||
private GrpcResponse(final @Nullable StatusRuntimeException statusException, @Nullable final R response) {
|
||||
this.statusException = statusException;
|
||||
this.response = response;
|
||||
if (!((statusException == null) ^ (response == null))) {
|
||||
throw new IllegalArgumentException("exactly one of statusException and response must be non-null");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new response object with the given status and no response message.
|
||||
*
|
||||
* @param status the status to send to callers
|
||||
*
|
||||
* @return a new response object with the given status and no response message
|
||||
*
|
||||
* @param <R> the type of response object
|
||||
*/
|
||||
public static <R> GrpcResponse<R> withStatusException(final StatusRuntimeException status) {
|
||||
return new GrpcResponse<>(status, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new response object with a status of {@link Status#OK} and the given response message.
|
||||
*
|
||||
* @param response the response to send to the caller
|
||||
*
|
||||
* @return a new response object with a status of {@link Status#OK} and the given response message
|
||||
*
|
||||
* @param <R> the type of response object
|
||||
*/
|
||||
public static <R> GrpcResponse<R> withResponse(final R response) {
|
||||
return new GrpcResponse<>(null, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message body contained within this response or throws the contained status as a {@link StatusException}
|
||||
* if no message body is specified.
|
||||
*
|
||||
* @return the message body contained within this response
|
||||
*
|
||||
* @throws StatusException if no message body is specified
|
||||
*/
|
||||
public R getResponseOrThrowStatus() throws StatusRuntimeException {
|
||||
if (statusException != null) {
|
||||
throw statusException;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@
|
||||
package org.whispersystems.textsecuregcm.spam;
|
||||
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import java.util.Optional;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.signal.chat.messages.SendMessageResponse;
|
||||
import org.signal.chat.messages.SendMultiRecipientMessageResponse;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
@@ -55,7 +53,7 @@ public interface SpamChecker {
|
||||
* @return A {@link SpamCheckResult}
|
||||
*/
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(
|
||||
SpamCheckResult<GrpcChallengeResponse> checkForIndividualRecipientSpamGrpc(
|
||||
final MessageType messageType,
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,
|
||||
final Optional<Account> maybeDestination,
|
||||
@@ -68,7 +66,7 @@ public interface SpamChecker {
|
||||
*
|
||||
* @return A {@link SpamCheckResult}
|
||||
*/
|
||||
SpamCheckResult<GrpcResponse<SendMultiRecipientMessageResponse>> checkForMultiRecipientSpamGrpc(final MessageType messageType);
|
||||
SpamCheckResult<GrpcChallengeResponse> checkForMultiRecipientSpamGrpc(final MessageType messageType);
|
||||
|
||||
|
||||
static SpamChecker noop() {
|
||||
@@ -92,7 +90,7 @@ public interface SpamChecker {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(final MessageType messageType,
|
||||
public SpamCheckResult<GrpcChallengeResponse> checkForIndividualRecipientSpamGrpc(final MessageType messageType,
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,
|
||||
final Optional<Account> maybeDestination,
|
||||
final ServiceIdentifier destinationIdentifier) {
|
||||
@@ -101,7 +99,7 @@ public interface SpamChecker {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpamCheckResult<GrpcResponse<SendMultiRecipientMessageResponse>> checkForMultiRecipientSpamGrpc(
|
||||
public SpamCheckResult<GrpcChallengeResponse> checkForMultiRecipientSpamGrpc(
|
||||
final MessageType messageType) {
|
||||
|
||||
return new SpamCheckResult<>(Optional.empty(), Optional.empty());
|
||||
|
||||
@@ -25,11 +25,11 @@ service Messages {
|
||||
//
|
||||
// The destination account must not be the same as the authenticated caller.
|
||||
// Callers should use `SendSyncMessage` to send messages to themselves.
|
||||
rpc SendMessage(SendAuthenticatedSenderMessageRequest) returns (SendMessageResponse) {}
|
||||
rpc SendMessage(SendAuthenticatedSenderMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
|
||||
|
||||
// Sends a "sync" message to all other devices linked to the authenticated
|
||||
// sender's account.
|
||||
rpc SendSyncMessage(SendSyncMessageRequest) returns (SendMessageResponse) {}
|
||||
rpc SendSyncMessage(SendSyncMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}
|
||||
}
|
||||
|
||||
// Provides methods for sending "sealed sender" messages.
|
||||
@@ -133,6 +133,30 @@ message SendAuthenticatedSenderMessageRequest {
|
||||
IndividualRecipientMessageBundle messages = 5;
|
||||
}
|
||||
|
||||
message SendMessageAuthenticatedSenderResponse {
|
||||
|
||||
// The outcome of the message delivery
|
||||
oneof response {
|
||||
|
||||
// The message was successfully delivered to all destination devices
|
||||
google.protobuf.Empty success = 1;
|
||||
|
||||
// A list of discrepancies between the destination devices identified in a
|
||||
// request to send a message and the devices that are actually linked to an
|
||||
// account.
|
||||
MismatchedDevices mismatched_devices = 2;
|
||||
|
||||
// A description of a challenge callers must complete before sending
|
||||
// additional messages.
|
||||
ChallengeRequired challenge_required = 3;
|
||||
|
||||
// The destination account did not exist
|
||||
errors.NotFound destination_not_found = 4;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message SendSyncMessageRequest {
|
||||
|
||||
// The type identifier for this message.
|
||||
@@ -202,15 +226,11 @@ message SendMessageResponse {
|
||||
// account.
|
||||
MismatchedDevices mismatched_devices = 2;
|
||||
|
||||
// A description of a challenge callers must complete before sending
|
||||
// additional messages.
|
||||
ChallengeRequired challenge_required = 3;
|
||||
|
||||
// The provided unidentified authorization credential was invalid
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 4;
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3;
|
||||
|
||||
// The destination account did not exist
|
||||
errors.NotFound destination_not_found = 5;
|
||||
errors.NotFound destination_not_found = 4;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -280,12 +300,8 @@ message SendMultiRecipientMessageResponse {
|
||||
// actually linked to a destination account.
|
||||
MultiRecipientMismatchedDevices mismatched_devices = 2;
|
||||
|
||||
// A description of a challenge callers must complete before sending
|
||||
// additional messages.
|
||||
ChallengeRequired challenge_required = 3;
|
||||
|
||||
// The provided unidentified authorization credential was invalid
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 4;
|
||||
errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.MessageType;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
@@ -427,7 +427,7 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(
|
||||
Optional.of(GrpcResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.empty()));
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@@ -464,16 +464,16 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
.setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))
|
||||
.build());
|
||||
|
||||
final SendMessageResponse response = SendMessageResponse.newBuilder()
|
||||
.setChallengeRequired(ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA))
|
||||
.build();
|
||||
final ChallengeRequired challengeResponse =
|
||||
ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();
|
||||
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcResponse.withResponse(response)), Optional.empty()));
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));
|
||||
|
||||
assertEquals(response, unauthenticatedServiceStub().sendSingleRecipientMessage(
|
||||
generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null)));
|
||||
final SendSealedSenderMessageRequest request =
|
||||
generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null);
|
||||
GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->
|
||||
unauthenticatedServiceStub().sendSingleRecipientMessage(request));
|
||||
|
||||
verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_SEALED_SENDER,
|
||||
Optional.empty(),
|
||||
@@ -793,7 +793,7 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
|
||||
when(spamChecker.checkForMultiRecipientSpamGrpc(any()))
|
||||
.thenReturn(new SpamCheckResult<>(
|
||||
Optional.of(GrpcResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.empty()));
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@@ -837,15 +837,14 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
.setUrgent(true)
|
||||
.build();
|
||||
|
||||
final SendMultiRecipientMessageResponse response = SendMultiRecipientMessageResponse.newBuilder()
|
||||
.setChallengeRequired(ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA))
|
||||
.build();
|
||||
|
||||
final ChallengeRequired challengeResponse =
|
||||
ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();
|
||||
when(spamChecker.checkForMultiRecipientSpamGrpc(any()))
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcResponse.withResponse(response)), Optional.empty()));
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));
|
||||
|
||||
assertEquals(response, unauthenticatedServiceStub().sendMultiRecipientMessage(request));
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,
|
||||
() -> unauthenticatedServiceStub().sendMultiRecipientMessage(request));
|
||||
|
||||
verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_SEALED_SENDER);
|
||||
|
||||
@@ -1052,7 +1051,7 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(
|
||||
Optional.of(GrpcResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.empty()));
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@@ -1087,16 +1086,13 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
.setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))
|
||||
.build());
|
||||
|
||||
final SendMessageResponse response = SendMessageResponse.newBuilder()
|
||||
.setChallengeRequired(ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA))
|
||||
.build();
|
||||
|
||||
final ChallengeRequired challengeResponse =
|
||||
ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcResponse.withResponse(response)), Optional.empty()));
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));
|
||||
|
||||
assertEquals(response, unauthenticatedServiceStub().sendStory(
|
||||
generateRequest(serviceIdentifier, true, messages)));
|
||||
GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->
|
||||
unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, true, messages)));
|
||||
|
||||
verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_STORY,
|
||||
Optional.empty(),
|
||||
@@ -1335,7 +1331,7 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
|
||||
when(spamChecker.checkForMultiRecipientSpamGrpc(any()))
|
||||
.thenReturn(new SpamCheckResult<>(
|
||||
Optional.of(GrpcResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.empty()));
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@@ -1377,15 +1373,13 @@ class MessagesAnonymousGrpcServiceTest extends
|
||||
.setUrgent(true)
|
||||
.build();
|
||||
|
||||
final SendMultiRecipientMessageResponse response = SendMultiRecipientMessageResponse.newBuilder()
|
||||
.setChallengeRequired(ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA))
|
||||
.build();
|
||||
|
||||
final ChallengeRequired challengeResponse =
|
||||
ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();
|
||||
when(spamChecker.checkForMultiRecipientSpamGrpc(any()))
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcResponse.withResponse(response)), Optional.empty()));
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));
|
||||
|
||||
assertEquals(response, unauthenticatedServiceStub().sendMultiRecipientStory(request));
|
||||
GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->
|
||||
unauthenticatedServiceStub().sendMultiRecipientStory(request));
|
||||
|
||||
verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_STORY);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.signal.chat.messages.IndividualRecipientMessageBundle;
|
||||
import org.signal.chat.messages.MessagesGrpc;
|
||||
import org.signal.chat.messages.MismatchedDevices;
|
||||
import org.signal.chat.messages.SendAuthenticatedSenderMessageRequest;
|
||||
import org.signal.chat.messages.SendMessageResponse;
|
||||
import org.signal.chat.messages.SendMessageAuthenticatedSenderResponse;
|
||||
import org.signal.chat.messages.SendSyncMessageRequest;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
|
||||
@@ -53,7 +53,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.push.MessageSender;
|
||||
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;
|
||||
import org.whispersystems.textsecuregcm.spam.MessageType;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
|
||||
import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
@@ -184,10 +184,10 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
.setPayload(ByteString.copyFrom(payload))
|
||||
.build());
|
||||
|
||||
final SendMessageResponse response = authenticatedServiceStub().sendMessage(
|
||||
final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(
|
||||
generateRequest(serviceIdentifier, messageType, ephemeral, urgent, messages));
|
||||
|
||||
assertEquals(SendMessageResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);
|
||||
assertEquals(SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);
|
||||
|
||||
final MessageProtos.Envelope.Type expectedEnvelopeType = switch (messageType) {
|
||||
case DOUBLE_RATCHET -> MessageProtos.Envelope.Type.CIPHERTEXT;
|
||||
@@ -245,10 +245,10 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))
|
||||
.when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());
|
||||
|
||||
final SendMessageResponse response = authenticatedServiceStub().sendMessage(
|
||||
final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(
|
||||
generateRequest(serviceIdentifier, AuthenticatedSenderMessageType.DOUBLE_RATCHET, false, true, messages));
|
||||
|
||||
final SendMessageResponse expectedResponse = SendMessageResponse.newBuilder()
|
||||
final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setMismatchedDevices(MismatchedDevices.newBuilder()
|
||||
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))
|
||||
.addMissingDevices(missingDeviceId)
|
||||
@@ -270,7 +270,7 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
.setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))
|
||||
.build());
|
||||
|
||||
final SendMessageResponse response = authenticatedServiceStub().sendMessage(
|
||||
final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(
|
||||
generateRequest(serviceIdentifier, AuthenticatedSenderMessageType.DOUBLE_RATCHET, false, true, messages));
|
||||
assertTrue(response.hasDestinationNotFound());
|
||||
|
||||
@@ -359,7 +359,7 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(
|
||||
Optional.of(GrpcResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),
|
||||
Optional.empty()));
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@@ -395,15 +395,17 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
.setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))
|
||||
.build());
|
||||
|
||||
final SendMessageResponse response = SendMessageResponse.newBuilder()
|
||||
.setChallengeRequired(ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA))
|
||||
final ChallengeRequired challengeRequired = ChallengeRequired.newBuilder()
|
||||
.addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA)
|
||||
.build();
|
||||
final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setChallengeRequired(challengeRequired)
|
||||
.build();
|
||||
|
||||
when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcResponse.withResponse(response)), Optional.empty()));
|
||||
.thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeRequired)), Optional.empty()));
|
||||
|
||||
assertEquals(response, authenticatedServiceStub().sendMessage(
|
||||
assertEquals(expectedResponse, authenticatedServiceStub().sendMessage(
|
||||
generateRequest(serviceIdentifier, AuthenticatedSenderMessageType.DOUBLE_RATCHET, false, true, messages)));
|
||||
|
||||
verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_IDENTIFIED_SENDER,
|
||||
@@ -466,10 +468,10 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
.thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.of(reportSpamToken)));
|
||||
}
|
||||
|
||||
final SendMessageResponse response =
|
||||
final SendMessageAuthenticatedSenderResponse response =
|
||||
authenticatedServiceStub().sendSyncMessage(generateRequest(messageType, urgent, messages));
|
||||
|
||||
assertEquals(SendMessageResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);
|
||||
assertEquals(SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);
|
||||
|
||||
final MessageProtos.Envelope.Type expectedEnvelopeType = switch (messageType) {
|
||||
case DOUBLE_RATCHET -> MessageProtos.Envelope.Type.CIPHERTEXT;
|
||||
@@ -539,10 +541,10 @@ class MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, Me
|
||||
Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))
|
||||
.when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());
|
||||
|
||||
final SendMessageResponse response = authenticatedServiceStub().sendSyncMessage(
|
||||
final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendSyncMessage(
|
||||
generateRequest(AuthenticatedSenderMessageType.DOUBLE_RATCHET, true, messages));
|
||||
|
||||
final SendMessageResponse expectedResponse = SendMessageResponse.newBuilder()
|
||||
final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()
|
||||
.setMismatchedDevices(MismatchedDevices.newBuilder()
|
||||
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(AUTHENTICATED_ACI)))
|
||||
.addMissingDevices(missingDeviceId)
|
||||
|
||||
Reference in New Issue
Block a user