mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-19 14:48:12 +01:00
Use enriched gRPC status errors
This commit is contained in:
committed by
ravi-signal
parent
77eaec0150
commit
a1b1d051f5
@@ -6,8 +6,8 @@
|
||||
package org.whispersystems.textsecuregcm.auth.grpc;
|
||||
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Status;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
/**
|
||||
@@ -18,13 +18,11 @@ public class AuthenticationUtil {
|
||||
static final Context.Key<AuthenticatedDevice> CONTEXT_AUTHENTICATED_DEVICE = Context.key("authenticated-device");
|
||||
|
||||
/**
|
||||
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
|
||||
* no authenticated account/device is available.
|
||||
* Returns the account/device authenticated in the current gRPC context. Should only be called from a service run with
|
||||
* the {@link RequireAuthenticationInterceptor}.
|
||||
*
|
||||
* @return the account/device identifier authenticated in the current gRPC context
|
||||
*
|
||||
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
|
||||
* could be retrieved from the current gRPC context
|
||||
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC context
|
||||
*/
|
||||
public static AuthenticatedDevice requireAuthenticatedDevice() {
|
||||
@Nullable final AuthenticatedDevice authenticatedDevice = CONTEXT_AUTHENTICATED_DEVICE.get();
|
||||
@@ -33,27 +31,25 @@ public class AuthenticationUtil {
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
throw Status.UNAUTHENTICATED.asRuntimeException();
|
||||
throw new IllegalStateException(
|
||||
"Configuration issue: service expects an authenticated device, but none was found. Request should have failed from an interceptor");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
|
||||
* no authenticated account/device is available or "permission denied" if the authenticated device is not the primary
|
||||
* device for the account.
|
||||
* Returns the account/device authenticated in the current gRPC context or "invalid argument" if the authenticated
|
||||
* device is not the primary device for the account.
|
||||
*
|
||||
* @return the account/device identifier authenticated in the current gRPC context
|
||||
*
|
||||
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
|
||||
* could be retrieved from the current gRPC context or a status of {@code PERMISSION_DENIED} if the authenticated
|
||||
* device is not the primary device for the authenticated account
|
||||
* @throws io.grpc.StatusRuntimeException with a status of {@code INVALID_ARGUMENT} if the authenticated device is not
|
||||
* the primary device for the authenticated account
|
||||
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC
|
||||
* context
|
||||
*/
|
||||
public static AuthenticatedDevice requireAuthenticatedPrimaryDevice() {
|
||||
final AuthenticatedDevice authenticatedDevice = requireAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
|
||||
throw Status.PERMISSION_DENIED.asRuntimeException();
|
||||
throw GrpcExceptions.badAuthentication("RPC requires a primary device");
|
||||
}
|
||||
|
||||
return authenticatedDevice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
|
||||
/**
|
||||
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
|
||||
@@ -22,8 +24,8 @@ public class ProhibitAuthenticationInterceptor implements ServerInterceptor {
|
||||
final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
|
||||
final String authHeaderString = headers.get(Metadata.Key.of(RequireAuthenticationInterceptor.AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
|
||||
if (authHeaderString != null) {
|
||||
call.close(Status.UNAUTHENTICATED.withDescription("authorization header forbidden"), new Metadata());
|
||||
return new ServerCall.Listener<>() {};
|
||||
return ServerInterceptorUtil.closeWithStatusException(call,
|
||||
GrpcExceptions.badAuthentication("The service forbids requests with an authentication header"));
|
||||
}
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
@@ -40,21 +41,21 @@ public class RequireAuthenticationInterceptor implements ServerInterceptor {
|
||||
Metadata.Key.of(AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
|
||||
|
||||
if (authHeaderString == null) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("missing authorization header"));
|
||||
return ServerInterceptorUtil.closeWithStatusException(call,
|
||||
GrpcExceptions.invalidCredentials("missing authorization header"));
|
||||
}
|
||||
|
||||
final Optional<BasicCredentials> basicCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeaderString);
|
||||
if (basicCredentials.isEmpty()) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("malformed authorization header"));
|
||||
return ServerInterceptorUtil.closeWithStatusException(call,
|
||||
GrpcExceptions.invalidCredentials("malformed authorization header"));
|
||||
}
|
||||
|
||||
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> authenticated =
|
||||
authenticator.authenticate(basicCredentials.get());
|
||||
if (authenticated.isEmpty()) {
|
||||
return ServerInterceptorUtil.closeWithStatus(call,
|
||||
Status.UNAUTHENTICATED.withDescription("invalid credentials"));
|
||||
return ServerInterceptorUtil.closeWithStatusException(call,
|
||||
GrpcExceptions.invalidCredentials("invalid credentials"));
|
||||
}
|
||||
|
||||
final AuthenticatedDevice authenticatedDevice = new AuthenticatedDevice(
|
||||
|
||||
@@ -4,28 +4,15 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import org.whispersystems.textsecuregcm.grpc.ConvertibleToGrpcStatus;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
|
||||
|
||||
public class RateLimitExceededException extends Exception implements ConvertibleToGrpcStatus {
|
||||
|
||||
public static final Metadata.Key<Duration> RETRY_AFTER_DURATION_KEY =
|
||||
Metadata.Key.of("retry-after", new Metadata.AsciiMarshaller<>() {
|
||||
@Override
|
||||
public String toAsciiString(final Duration value) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration parseAsciiString(final String serialized) {
|
||||
return Duration.parse(serialized);
|
||||
}
|
||||
});
|
||||
|
||||
@Nullable
|
||||
private final Duration retryDuration;
|
||||
|
||||
@@ -44,17 +31,7 @@ public class RateLimitExceededException extends Exception implements Convertible
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status grpcStatus() {
|
||||
return Status.RESOURCE_EXHAUSTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Metadata> grpcMetadata() {
|
||||
return getRetryDuration()
|
||||
.map(duration -> {
|
||||
final Metadata metadata = new Metadata();
|
||||
metadata.put(RETRY_AFTER_DURATION_KEY, duration);
|
||||
return metadata;
|
||||
});
|
||||
public StatusRuntimeException toStatusRuntimeException() {
|
||||
return GrpcExceptions.rateLimitExceeded(retryDuration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ import java.util.Set;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
|
||||
import org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.StatusConstants;
|
||||
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
@@ -91,8 +92,7 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
|
||||
}).orElse(null);
|
||||
|
||||
if (shouldBlock(userAgent)) {
|
||||
call.close(StatusConstants.UPGRADE_NEEDED_STATUS, new Metadata());
|
||||
return new ServerCall.Listener<>() {};
|
||||
return ServerInterceptorUtil.closeWithStatusException(call, GrpcExceptions.upgradeRequired());
|
||||
} else {
|
||||
return next.startCall(call, headers);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import org.signal.chat.account.CheckAccountExistenceRequest;
|
||||
import org.signal.chat.account.CheckAccountExistenceResponse;
|
||||
import org.signal.chat.account.LookupUsernameHashRequest;
|
||||
@@ -14,6 +13,7 @@ import org.signal.chat.account.LookupUsernameHashResponse;
|
||||
import org.signal.chat.account.LookupUsernameLinkRequest;
|
||||
import org.signal.chat.account.LookupUsernameLinkResponse;
|
||||
import org.signal.chat.account.ReactorAccountsAnonymousGrpc;
|
||||
import org.signal.chat.errors.NotFound;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
@@ -51,18 +51,18 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
|
||||
@Override
|
||||
public Mono<LookupUsernameHashResponse> lookupUsernameHash(final LookupUsernameHashRequest request) {
|
||||
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
|
||||
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hash",
|
||||
String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
|
||||
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
|
||||
}
|
||||
|
||||
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.NOT_FOUND::asRuntimeException))
|
||||
.map(account -> LookupUsernameHashResponse.newBuilder()
|
||||
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
|
||||
.build());
|
||||
.map(maybeAccount -> maybeAccount
|
||||
.map(account -> LookupUsernameHashResponse.newBuilder()
|
||||
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
|
||||
.build())
|
||||
.orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,19 +72,16 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
|
||||
try {
|
||||
linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Could not interpret link handle as UUID")
|
||||
.withCause(e)
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_link_handle", "Could not interpret link handle as UUID");
|
||||
}
|
||||
|
||||
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByUsernameLinkHandle(linkHandle)))
|
||||
.map(maybeAccount -> maybeAccount
|
||||
.flatMap(Account::getEncryptedUsername)
|
||||
.orElseThrow(Status.NOT_FOUND::asRuntimeException))
|
||||
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
|
||||
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
|
||||
.build());
|
||||
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
|
||||
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
|
||||
.build())
|
||||
.orElseGet(() -> LookupUsernameLinkResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
@@ -26,8 +25,6 @@ import org.signal.chat.account.DeleteUsernameLinkResponse;
|
||||
import org.signal.chat.account.GetAccountIdentityRequest;
|
||||
import org.signal.chat.account.GetAccountIdentityResponse;
|
||||
import org.signal.chat.account.ReactorAccountsGrpc;
|
||||
import org.signal.chat.account.ReserveUsernameHashError;
|
||||
import org.signal.chat.account.ReserveUsernameHashErrorType;
|
||||
import org.signal.chat.account.ReserveUsernameHashRequest;
|
||||
import org.signal.chat.account.ReserveUsernameHashResponse;
|
||||
import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;
|
||||
@@ -38,7 +35,9 @@ import org.signal.chat.account.SetRegistrationRecoveryPasswordRequest;
|
||||
import org.signal.chat.account.SetRegistrationRecoveryPasswordResponse;
|
||||
import org.signal.chat.account.SetUsernameLinkRequest;
|
||||
import org.signal.chat.account.SetUsernameLinkResponse;
|
||||
import org.signal.chat.account.UsernameNotAvailable;
|
||||
import org.signal.chat.common.AccountIdentifiers;
|
||||
import org.signal.chat.errors.FailedPrecondition;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
@@ -50,6 +49,7 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
|
||||
@@ -78,10 +78,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
@Override
|
||||
public Mono<GetAccountIdentityResponse> getAccountIdentity(final GetAccountIdentityRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount()
|
||||
.map(account -> {
|
||||
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
|
||||
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
|
||||
@@ -99,10 +96,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
@Override
|
||||
public Mono<DeleteAccountResponse> deleteAccount(final DeleteAccountRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.delete(account, AccountsManager.DeletionReason.USER_REQUEST)))
|
||||
.thenReturn(DeleteAccountResponse.newBuilder().build());
|
||||
}
|
||||
@@ -112,11 +106,10 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
if (request.getRegistrationLock().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Registration lock secret must not be empty").asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("registration_lock", "Registration lock secret must not be empty");
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount(authenticatedDevice)
|
||||
.flatMap(account -> {
|
||||
// In the previous REST-based API, clients would send hex strings directly. For backward compatibility, we
|
||||
// convert the registration lock secret to a lowercase hex string before turning it into a salted hash.
|
||||
@@ -131,10 +124,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
@Override
|
||||
public Mono<ClearRegistrationLockResponse> clearRegistrationLock(final ClearRegistrationLockRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
|
||||
a -> a.setRegistrationLock(null, null))))
|
||||
.map(ignored -> ClearRegistrationLockResponse.newBuilder().build());
|
||||
@@ -145,42 +135,34 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameHashesCount() == 0) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("List of username hashes must not be empty")
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hashes", "List of username hashes must not be empty");
|
||||
}
|
||||
|
||||
if (request.getUsernameHashesCount() > AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("List of username hashes may have at most %d elements, but actually had %d",
|
||||
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hashes",
|
||||
String.format("List of username hashes may have at most %d elements, but actually had %d",
|
||||
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()));
|
||||
}
|
||||
|
||||
final List<byte[]> usernameHashes = new ArrayList<>(request.getUsernameHashesCount());
|
||||
|
||||
for (final ByteString usernameHash : request.getUsernameHashesList()) {
|
||||
if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hashes",
|
||||
String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()));
|
||||
}
|
||||
|
||||
usernameHashes.add(usernameHash.toByteArray());
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameReserveLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.then(getAccount())
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.reserveUsernameHash(account, usernameHashes)))
|
||||
.map(reservation -> ReserveUsernameHashResponse.newBuilder()
|
||||
.setUsernameHash(ByteString.copyFrom(reservation.reservedUsernameHash()))
|
||||
.build())
|
||||
.onErrorReturn(UsernameHashNotAvailableException.class, ReserveUsernameHashResponse.newBuilder()
|
||||
.setError(ReserveUsernameHashError.newBuilder()
|
||||
.setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE)
|
||||
.build())
|
||||
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -189,61 +171,57 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameHash().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Username hash must not be empty")
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hash", "Username hash must not be empty");
|
||||
}
|
||||
|
||||
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_hash",
|
||||
String.format("Username hash length must be %d bytes, but was actually %d",
|
||||
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
|
||||
}
|
||||
|
||||
if (request.getZkProof().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Zero-knowledge proof must not be empty")
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("zk_proof", "Zero-knowledge proof must not be empty");
|
||||
}
|
||||
|
||||
if (request.getUsernameCiphertext().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Username ciphertext must not be empty")
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_ciphertext", "Username ciphertext must not be empty");
|
||||
}
|
||||
|
||||
if (request.getUsernameCiphertext().size() > AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username hash length must at most %d bytes, but was actually %d",
|
||||
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_ciphertext",
|
||||
String.format("Username ciphertext length must at most %d bytes, but was actually %d",
|
||||
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()));
|
||||
}
|
||||
|
||||
try {
|
||||
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
|
||||
} catch (final BaseUsernameException e) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Could not verify proof").asRuntimeException();
|
||||
throw GrpcExceptions.constraintViolation("Could not verify proof");
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameSetLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.then(getAccount())
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.confirmReservedUsernameHash(account, request.getUsernameHash().toByteArray(), request.getUsernameCiphertext().toByteArray())))
|
||||
.map(updatedAccount -> ConfirmUsernameHashResponse.newBuilder()
|
||||
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
|
||||
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
|
||||
.setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()
|
||||
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
|
||||
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
|
||||
.build())
|
||||
.build())
|
||||
.onErrorMap(UsernameReservationNotFoundException.class, throwable -> Status.FAILED_PRECONDITION.asRuntimeException())
|
||||
.onErrorMap(UsernameHashNotAvailableException.class, throwable -> Status.NOT_FOUND.asRuntimeException());
|
||||
.onErrorResume(UsernameReservationNotFoundException.class, _ -> Mono.just(ConfirmUsernameHashResponse
|
||||
.newBuilder()
|
||||
.setReservationNotFound(FailedPrecondition.getDefaultInstance())
|
||||
.build()))
|
||||
.onErrorResume(UsernameHashNotAvailableException.class, _ -> Mono.just(ConfirmUsernameHashResponse
|
||||
.newBuilder()
|
||||
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
|
||||
.build()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DeleteUsernameHashResponse> deleteUsernameHash(final DeleteUsernameHashRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount()
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.clearUsernameHash(account)))
|
||||
.thenReturn(DeleteUsernameHashResponse.newBuilder().build());
|
||||
}
|
||||
@@ -253,19 +231,16 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getUsernameCiphertext().isEmpty() || request.getUsernameCiphertext().size() > EncryptedUsername.MAX_SIZE) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("username_ciphertext",
|
||||
String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE));
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.then(getAccount())
|
||||
.flatMap(account -> {
|
||||
final SetUsernameLinkResponse.Builder responseBuilder = SetUsernameLinkResponse.newBuilder();
|
||||
if (account.getUsernameHash().isEmpty()) {
|
||||
return Mono.error(Status.FAILED_PRECONDITION
|
||||
.withDescription("Account does not have a username hash")
|
||||
.asRuntimeException());
|
||||
return Mono.just(responseBuilder.setNoUsernameSet(FailedPrecondition.getDefaultInstance()).build());
|
||||
}
|
||||
|
||||
final UUID linkHandle;
|
||||
@@ -276,37 +251,28 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray())))
|
||||
.thenReturn(linkHandle);
|
||||
})
|
||||
.map(linkHandle -> SetUsernameLinkResponse.newBuilder()
|
||||
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
|
||||
.build());
|
||||
.thenReturn(responseBuilder.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)).build());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<DeleteUsernameLinkResponse> deleteUsernameLink(final DeleteUsernameLinkRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
.then(getAccount())
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(null, null))))
|
||||
.thenReturn(DeleteUsernameLinkResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ConfigureUnidentifiedAccessResponse> configureUnidentifiedAccess(final ConfigureUnidentifiedAccessRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (!request.getAllowUnrestrictedUnidentifiedAccess() && request.getUnidentifiedAccessKey().size() != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription(String.format("Unidentified access key must be %d bytes, but was actually %d",
|
||||
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()))
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("unidentified_access_key",
|
||||
String.format("Unidentified access key must be %d bytes, but was actually %d",
|
||||
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()));
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount()
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> {
|
||||
a.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess());
|
||||
a.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray());
|
||||
@@ -316,10 +282,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
@Override
|
||||
public Mono<SetDiscoverableByPhoneNumberResponse> setDiscoverableByPhoneNumber(final SetDiscoverableByPhoneNumberRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount()
|
||||
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
|
||||
a -> a.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber()))))
|
||||
.thenReturn(SetDiscoverableByPhoneNumberResponse.newBuilder().build());
|
||||
@@ -327,17 +290,22 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
|
||||
|
||||
@Override
|
||||
public Mono<SetRegistrationRecoveryPasswordResponse> setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
|
||||
if (request.getRegistrationRecoveryPassword().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT
|
||||
.withDescription("Registration recovery password must not be empty")
|
||||
.asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("registration_recovery_password", "Registration recovery password must not be empty");
|
||||
}
|
||||
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
|
||||
return getAccount()
|
||||
.flatMap(account -> Mono.fromFuture(() -> registrationRecoveryPasswordsManager.store(account.getIdentifier(IdentityType.PNI), request.getRegistrationRecoveryPassword().toByteArray())))
|
||||
.thenReturn(SetRegistrationRecoveryPasswordResponse.newBuilder().build());
|
||||
}
|
||||
|
||||
private Mono<Account> getAccount() {
|
||||
return getAccount(AuthenticationUtil.requireAuthenticatedDevice());
|
||||
}
|
||||
|
||||
private Mono<Account> getAccount(AuthenticatedDevice authenticatedDevice) {
|
||||
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount
|
||||
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Interface to be implemented by our custom exceptions that are consistently mapped to a gRPC status.
|
||||
*/
|
||||
public interface ConvertibleToGrpcStatus {
|
||||
|
||||
Status grpcStatus();
|
||||
|
||||
Optional<Metadata> grpcMetadata();
|
||||
StatusRuntimeException toStatusRuntimeException();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
|
||||
/**
|
||||
* This interceptor observes responses from the service and if the response status is {@link Status#UNKNOWN}
|
||||
@@ -21,6 +26,8 @@ import io.grpc.Status;
|
||||
*/
|
||||
public class ErrorMappingInterceptor implements ServerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ErrorMappingInterceptor.class);
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
|
||||
final ServerCall<ReqT, RespT> call,
|
||||
@@ -34,15 +41,33 @@ public class ErrorMappingInterceptor implements ServerInterceptor {
|
||||
// I.e. if at this point we see anything but the `UNKNOWN`,
|
||||
// that means that some logic in the service made this decision already
|
||||
// and automatic conversion may conflict with it.
|
||||
if (status.getCode().equals(Status.Code.UNKNOWN)
|
||||
&& status.getCause() instanceof ConvertibleToGrpcStatus convertibleToGrpcStatus) {
|
||||
super.close(
|
||||
convertibleToGrpcStatus.grpcStatus(),
|
||||
convertibleToGrpcStatus.grpcMetadata().orElseGet(Metadata::new)
|
||||
);
|
||||
} else {
|
||||
if (!status.getCode().equals(Status.Code.UNKNOWN)) {
|
||||
super.close(status, trailers);
|
||||
return;
|
||||
}
|
||||
|
||||
final StatusRuntimeException statusException = switch (status.getCause()) {
|
||||
case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();
|
||||
case UncheckedIOException e -> {
|
||||
log.warn("RPC {} encountered UncheckedIOException", call.getMethodDescriptor().getFullMethodName(), e.getCause());
|
||||
yield GrpcExceptions.unavailable(e.getCause().getMessage());
|
||||
}
|
||||
case IOException e -> {
|
||||
log.warn("RPC {} encountered IOException", call.getMethodDescriptor().getFullMethodName(), e);
|
||||
yield GrpcExceptions.unavailable(e.getMessage());
|
||||
}
|
||||
case null -> {
|
||||
log.error("RPC {} finished with status UNKNOWN: {}",
|
||||
call.getMethodDescriptor().getFullMethodName(), status.getDescription());
|
||||
yield GrpcExceptions.unavailable(status.getDescription());
|
||||
}
|
||||
default -> {
|
||||
log.error("RPC {} finished with status UNKNOWN",
|
||||
call.getMethodDescriptor().getFullMethodName(), status.getCause());
|
||||
yield GrpcExceptions.unavailable(status.getCause().getMessage());
|
||||
}
|
||||
};
|
||||
super.close(statusException.getStatus(), statusException.getTrailers());
|
||||
}
|
||||
}, headers);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.Any;
|
||||
import com.google.rpc.BadRequest;
|
||||
import com.google.rpc.ErrorInfo;
|
||||
import com.google.rpc.RetryInfo;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.protobuf.StatusProto;
|
||||
import java.time.Duration;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class GrpcExceptions {
|
||||
|
||||
public static final String DOMAIN = "grpc.chat.signal.org";
|
||||
|
||||
private static final Any ERROR_INFO_CONSTRAINT_VIOLATED = Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("CONSTRAINT_VIOLATED")
|
||||
.build());
|
||||
|
||||
private static final Any ERROR_INFO_RESOURCE_EXHAUSTED = Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("RESOURCE_EXHAUSTED")
|
||||
.build());
|
||||
|
||||
private static final Any ERROR_INFO_INVALID_CREDENTIALS = Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("INVALID_CREDENTIALS")
|
||||
.build());
|
||||
|
||||
private static final Any ERROR_INFO_BAD_AUTHENTICATION = Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("BAD_AUTHENTICATION")
|
||||
.build());
|
||||
|
||||
private static final com.google.rpc.Status UPGRADE_REQUIRED = com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.INVALID_ARGUMENT.value())
|
||||
.setMessage("Upgrade required")
|
||||
.addDetails(Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("UPGRADE_REQUIRED")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
|
||||
private GrpcExceptions() {
|
||||
}
|
||||
|
||||
/// The client version provided in the User-Agent is no longer supported. The client must upgrade to use the service.
|
||||
///
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException upgradeRequired() {
|
||||
return StatusProto.toStatusRuntimeException(UPGRADE_REQUIRED);
|
||||
}
|
||||
|
||||
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
|
||||
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
|
||||
/// or out of date client. Additional information about the violating field will be included in the metadata.
|
||||
///
|
||||
/// @param fieldName The name of the field that violated a service constraint
|
||||
/// @param message Additional context about the constraint violation
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException fieldViolation(final String fieldName, @Nullable final String message) {
|
||||
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.INVALID_ARGUMENT.value())
|
||||
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
|
||||
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
|
||||
.addDetails(Any.pack(BadRequest.newBuilder()
|
||||
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
|
||||
.setField(fieldName)
|
||||
.setDescription(messageOrDefault(message, Status.Code.INVALID_ARGUMENT)))
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
|
||||
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
|
||||
/// or out of date client.
|
||||
///
|
||||
/// @param message Additional context about the constraint violation
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException constraintViolation(@Nullable final String message) {
|
||||
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.INVALID_ARGUMENT.value())
|
||||
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
|
||||
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
|
||||
.build());
|
||||
}
|
||||
|
||||
/// The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the
|
||||
/// authorization header is not correct for the RPC. For example,
|
||||
///
|
||||
/// - The RPC was for an anonymous service, but included an Authentication header in the RPC metadata
|
||||
/// - The RPC should only be made by the primary device, but the request had linked device credentials
|
||||
///
|
||||
/// @param message indicating why the credentials were set incorrectly
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException badAuthentication(@Nullable final String message) {
|
||||
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.INVALID_ARGUMENT.value())
|
||||
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
|
||||
.addDetails(ERROR_INFO_BAD_AUTHENTICATION)
|
||||
.build());
|
||||
}
|
||||
|
||||
/// The account credentials provided in the authorization header are no longer valid.
|
||||
///
|
||||
/// @param message indicating why the credentials were invalid
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException invalidCredentials(@Nullable final String message) {
|
||||
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.UNAUTHENTICATED.value())
|
||||
.setMessage(messageOrDefault(message, Status.Code.UNAUTHENTICATED))
|
||||
.addDetails(ERROR_INFO_INVALID_CREDENTIALS)
|
||||
.build());
|
||||
}
|
||||
|
||||
/// A server-side resource was exhausted. The details field may include a RetryInfo message that includes the amount
|
||||
/// of time in seconds the client should wait before retrying the request.
|
||||
///
|
||||
/// If a RetryInfo is present, the client must wait the indicated time before retrying the request. If absent, the
|
||||
/// client should retry with an exponential backoff.
|
||||
///
|
||||
/// @param retryDuration If present, the duration the client should wait before retrying the request
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException rateLimitExceeded(@Nullable final Duration retryDuration) {
|
||||
final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.RESOURCE_EXHAUSTED.value())
|
||||
.addDetails(ERROR_INFO_RESOURCE_EXHAUSTED);
|
||||
|
||||
if (retryDuration != null) {
|
||||
builder.addDetails(Any.pack(RetryInfo.newBuilder()
|
||||
.setRetryDelay(com.google.protobuf.Duration.newBuilder()
|
||||
.setSeconds(retryDuration.getSeconds())
|
||||
.setNanos(retryDuration.getNano()))
|
||||
.build()));
|
||||
}
|
||||
return StatusProto.toStatusRuntimeException(builder.build());
|
||||
}
|
||||
|
||||
/// There was an internal error processing the RPC. The client should retry the request with exponential backoff.
|
||||
///
|
||||
/// @return A [StatusRuntimeException] encoding the error
|
||||
public static StatusRuntimeException unavailable(@Nullable final String message) {
|
||||
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
|
||||
.setCode(Status.Code.UNAVAILABLE.value())
|
||||
.setMessage(messageOrDefault(message, Status.Code.UNAVAILABLE))
|
||||
.addDetails(Any.pack(ErrorInfo.newBuilder()
|
||||
.setDomain(DOMAIN)
|
||||
.setReason("UNAVAILABLE")
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
private static String messageOrDefault(@Nullable final String message, Status.Code code) {
|
||||
return message == null ? code.toString() : message;
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,50 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.grpc.*;
|
||||
import com.google.protobuf.Descriptors;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.google.protobuf.Message;
|
||||
import com.google.rpc.ErrorInfo;
|
||||
import io.grpc.ForwardingServerCall;
|
||||
import io.grpc.ForwardingServerCallListener;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.protobuf.StatusProto;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class MetricServerInterceptor implements ServerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MetricServerInterceptor.class);
|
||||
|
||||
private static final String TAG_SERVICE_NAME = "grpcService";
|
||||
private static final String TAG_METHOD_NAME = "method";
|
||||
private static final String TAG_METHOD_TYPE = "methodType";
|
||||
private static final String TAG_STATUS_CODE = "statusCode";
|
||||
private static final String TAG_REASON = "reason";
|
||||
|
||||
@VisibleForTesting
|
||||
static final String DEFAULT_SUCCESS_REASON = "success";
|
||||
@VisibleForTesting
|
||||
static final String DEFAULT_ERROR_REASON = "n/a";
|
||||
|
||||
@VisibleForTesting
|
||||
static final String REQUEST_MESSAGE_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, "requestMessage");
|
||||
@@ -77,6 +101,7 @@ public class MetricServerInterceptor implements ServerInterceptor {
|
||||
|
||||
private final Counter responseMessageCounter;
|
||||
private final Tags tags;
|
||||
private @Nullable String reason = null;
|
||||
|
||||
MetricServerCall(final ServerCall<ReqT, RespT> delegate, final Tags tags) {
|
||||
super(delegate);
|
||||
@@ -86,15 +111,59 @@ public class MetricServerInterceptor implements ServerInterceptor {
|
||||
|
||||
@Override
|
||||
public void close(final Status status, final Metadata responseHeaders) {
|
||||
meterRegistry.counter(RPC_COUNTER_NAME, tags.and(TAG_STATUS_CODE, status.getCode().name())).increment();
|
||||
if (!status.isOk()) {
|
||||
reason = errorInfo(StatusProto.fromStatusAndTrailers(status, responseHeaders))
|
||||
.map(ErrorInfo::getReason)
|
||||
.orElse(DEFAULT_ERROR_REASON);
|
||||
}
|
||||
Tags responseTags = tags.and(Tag.of(TAG_STATUS_CODE, status.getCode().name()));
|
||||
if (reason != null) {
|
||||
responseTags = responseTags.and(TAG_REASON, reason);
|
||||
}
|
||||
meterRegistry.counter(RPC_COUNTER_NAME, responseTags).increment();
|
||||
super.close(status, responseHeaders);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(final RespT responseMessage) {
|
||||
this.responseMessageCounter.increment();
|
||||
// Extract the annotated reason (if any) from the message
|
||||
final String messageReason = MetricServerCall.reason(responseMessage);
|
||||
|
||||
// If there are multiple messages sent on this RPC (server-side streaming), just use the most recent reason
|
||||
this.reason = messageReason == null ? DEFAULT_SUCCESS_REASON : messageReason;
|
||||
|
||||
super.sendMessage(responseMessage);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String reason(final Object obj) {
|
||||
if (!(obj instanceof Message msg)) {
|
||||
return null;
|
||||
}
|
||||
// iterate through all fields on the message
|
||||
for (Map.Entry<Descriptors.FieldDescriptor, Object> field : msg.getAllFields().entrySet()) {
|
||||
// iterate through all options on the field
|
||||
for (Map.Entry<Descriptors.FieldDescriptor, Object> option : field.getKey().getOptions().getAllFields().entrySet()) {
|
||||
if (option.getKey().getFullName().equals("org.signal.chat.tag.reason")) {
|
||||
if (!(option.getValue() instanceof String s)) {
|
||||
log.error("Invalid value for option tag.reason {}", option.getValue());
|
||||
continue;
|
||||
}
|
||||
// return the first tag we see
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
// No reason on this field. Recursively check subfields of this field for a reason
|
||||
final String subReason = reason(field.getValue());
|
||||
if (subReason != null) {
|
||||
return subReason;
|
||||
}
|
||||
}
|
||||
// No field or subfield contained an annotated reason
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,4 +200,17 @@ public class MetricServerInterceptor implements ServerInterceptor {
|
||||
super.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<ErrorInfo> errorInfo(final com.google.rpc.Status statusProto) {
|
||||
return statusProto.getDetailsList().stream()
|
||||
.filter(any -> any.is(ErrorInfo.class))
|
||||
.map(errorInfo -> {
|
||||
try {
|
||||
return errorInfo.unpack(ErrorInfo.class);
|
||||
} catch (final InvalidProtocolBufferException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
})
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
|
||||
public class ServerInterceptorUtil {
|
||||
|
||||
@@ -36,4 +37,23 @@ public class ServerInterceptorUtil {
|
||||
//noinspection unchecked
|
||||
return NO_OP_LISTENER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the given server call with the status and metadata from the provided exception, returning a no-op listener.
|
||||
*
|
||||
* @param call the server call to close
|
||||
* @param exception the {@link StatusRuntimeException} with which to close the call
|
||||
*
|
||||
* @return a no-op server call listener
|
||||
*
|
||||
* @param <ReqT> the type of request object handled by the server call
|
||||
* @param <RespT> the type of response object returned by the server call
|
||||
*/
|
||||
public static <ReqT, RespT> ServerCall.Listener<ReqT> closeWithStatusException(final ServerCall<ReqT, RespT> call, final StatusRuntimeException exception) {
|
||||
call.close(exception.getStatus(), exception.getTrailers());
|
||||
|
||||
//noinspection unchecked
|
||||
return NO_OP_LISTENER;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Status;
|
||||
|
||||
public abstract class StatusConstants {
|
||||
public static final Status UPGRADE_NEEDED_STATUS = Status.INVALID_ARGUMENT.withDescription("signal-upgrade-required");
|
||||
}
|
||||
Reference in New Issue
Block a user