Define ProfileController protobufs and setProfile endpoint
This commit is contained in:
parent
95b90e7c5a
commit
a953cb33b7
|
@ -115,6 +115,7 @@ import org.whispersystems.textsecuregcm.controllers.VerificationController;
|
|||
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
|
||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||
import org.whispersystems.textsecuregcm.currency.FixerClient;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
|
||||
|
@ -228,6 +229,7 @@ import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsPr
|
|||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
public class WhisperServerService extends Application<WhisperServerConfiguration> {
|
||||
|
@ -616,6 +618,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().region()))
|
||||
.build();
|
||||
S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()
|
||||
.credentialsProvider(cdnCredentialsProvider)
|
||||
.region(Region.of(config.getCdnConfiguration().region()))
|
||||
.build();
|
||||
|
||||
final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(
|
||||
config.getGcpAttachmentsConfiguration().domain(),
|
||||
|
@ -647,7 +653,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
// TODO: specialize metrics with user-agent platform
|
||||
.intercept(new MetricCollectingServerInterceptor(Metrics.globalRegistry))
|
||||
.addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
|
||||
.addService(new KeysAnonymousGrpcService(accountsManager, keys));
|
||||
.addService(new KeysAnonymousGrpcService(accountsManager, keys))
|
||||
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().bucket())));
|
||||
|
||||
RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
|
||||
environment.servlets()
|
||||
|
|
|
@ -91,6 +91,7 @@ import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialPro
|
|||
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
|
||||
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
|
@ -199,7 +200,7 @@ public class ProfileController {
|
|||
final String avatar = switch (request.getAvatarChange()) {
|
||||
case UNCHANGED -> currentAvatar.orElse(null);
|
||||
case CLEAR -> null;
|
||||
case UPDATE -> generateAvatarObjectName();
|
||||
case UPDATE -> ProfileHelper.generateAvatarObjectName();
|
||||
};
|
||||
|
||||
profilesManager.set(auth.getAccount().getUuid(),
|
||||
|
@ -220,7 +221,7 @@ public class ProfileController {
|
|||
}
|
||||
|
||||
final List<AccountBadge> updatedBadges = request.getBadges()
|
||||
.map(badges -> mergeBadgeIdsWithExistingAccountBadges(badges, auth.getAccount().getBadges()))
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, auth.getAccount().getBadges()))
|
||||
.orElseGet(() -> auth.getAccount().getBadges());
|
||||
|
||||
accountsManager.update(auth.getAccount(), a -> {
|
||||
|
@ -229,7 +230,7 @@ public class ProfileController {
|
|||
});
|
||||
|
||||
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
|
||||
return Response.ok(generateAvatarUploadForm(avatar)).build();
|
||||
return Response.ok(ProfileHelper.generateAvatarUploadForm(policyGenerator, policySigner, avatar)).build();
|
||||
} else {
|
||||
return Response.ok().build();
|
||||
}
|
||||
|
@ -477,23 +478,6 @@ public class ProfileController {
|
|||
}
|
||||
}
|
||||
|
||||
private ProfileAvatarUploadAttributes generateAvatarUploadForm(String objectName) {
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return new ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
|
||||
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
|
||||
|
||||
}
|
||||
|
||||
private String generateAvatarObjectName() {
|
||||
byte[] object = new byte[16];
|
||||
new SecureRandom().nextBytes(object);
|
||||
|
||||
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
|
||||
}
|
||||
|
||||
private List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) {
|
||||
try {
|
||||
return containerRequestContext.getAcceptableLanguages();
|
||||
|
@ -509,48 +493,6 @@ public class ProfileController {
|
|||
}
|
||||
}
|
||||
|
||||
private List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
|
||||
final List<String> badgeIds,
|
||||
final List<AccountBadge> accountBadges) {
|
||||
LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final AccountBadge accountBadge : accountBadges) {
|
||||
existingBadges.putIfAbsent(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final String badgeId : badgeIds) {
|
||||
|
||||
// duplicate in the list, ignore it
|
||||
if (result.containsKey(badgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day
|
||||
// in the future.
|
||||
BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId);
|
||||
if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) {
|
||||
result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// reordering or making visible existing badges
|
||||
if (existingBadges.containsKey(badgeId)) {
|
||||
AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true);
|
||||
result.put(badgeId, accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
// take any remaining account badges and make them invisible
|
||||
for (final Entry<String, AccountBadge> entry : existingBadges.entrySet()) {
|
||||
if (!result.containsKey(entry.getKey())) {
|
||||
AccountBadge accountBadge = entry.getValue().withVisibility(false);
|
||||
result.put(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(result.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the requester has permission to view the profile of the account identified by the given ACI.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
public enum AvatarChange {
|
||||
AVATAR_CHANGE_UNCHANGED,
|
||||
AVATAR_CHANGE_CLEAR,
|
||||
AVATAR_CHANGE_UPDATE
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Status;
|
||||
import org.whispersystems.textsecuregcm.entities.AvatarChange;
|
||||
|
||||
public class AvatarChangeUtil {
|
||||
public static AvatarChange fromGrpcAvatarChange(final org.signal.chat.profile.SetProfileRequest.AvatarChange avatarChangeType) {
|
||||
return switch (avatarChangeType) {
|
||||
case AVATAR_CHANGE_UNCHANGED -> AvatarChange.AVATAR_CHANGE_UNCHANGED;
|
||||
case AVATAR_CHANGE_CLEAR -> AvatarChange.AVATAR_CHANGE_CLEAR;
|
||||
case AVATAR_CHANGE_UPDATE -> AvatarChange.AVATAR_CHANGE_UPDATE;
|
||||
case UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription("Invalid avatar change value").asRuntimeException();
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.signal.chat.profile.ReactorProfileGrpc;
|
||||
import org.signal.chat.profile.SetProfileRequest;
|
||||
import org.signal.chat.profile.SetProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
private final Clock clock;
|
||||
|
||||
private final ProfilesManager profilesManager;
|
||||
private final AccountsManager accountsManager;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
|
||||
private final PolicySigner policySigner;
|
||||
private final PostPolicyGenerator policyGenerator;
|
||||
|
||||
private final S3AsyncClient asyncS3client;
|
||||
private final String bucket;
|
||||
|
||||
private record AvatarData(Optional<String> currentAvatar,
|
||||
Optional<String> finalAvatar,
|
||||
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
|
||||
|
||||
public ProfileGrpcService(
|
||||
Clock clock,
|
||||
AccountsManager accountsManager,
|
||||
ProfilesManager profilesManager,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
BadgesConfiguration badgesConfiguration,
|
||||
S3AsyncClient asyncS3client,
|
||||
PostPolicyGenerator policyGenerator,
|
||||
PolicySigner policySigner,
|
||||
String bucket) {
|
||||
this.clock = clock;
|
||||
this.accountsManager = accountsManager;
|
||||
this.profilesManager = profilesManager;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
|
||||
BadgeConfiguration::getId, Function.identity()));
|
||||
this.bucket = bucket;
|
||||
this.asyncS3client = asyncS3client;
|
||||
this.policyGenerator = policyGenerator;
|
||||
this.policySigner = policySigner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
|
||||
validateRequest(request);
|
||||
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
|
||||
.flatMap(authenticatedDevice -> Mono.zip(
|
||||
Mono.fromFuture(accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
|
||||
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)),
|
||||
Mono.fromFuture(profilesManager.getAsync(authenticatedDevice.accountIdentifier(), request.getVersion()))
|
||||
))
|
||||
.doOnNext(accountAndMaybeProfile -> {
|
||||
if (!request.getPaymentAddress().isEmpty()) {
|
||||
final boolean hasDisallowedPrefix =
|
||||
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
|
||||
.anyMatch(prefix -> accountAndMaybeProfile.getT1().getNumber().startsWith(prefix));
|
||||
if (hasDisallowedPrefix && accountAndMaybeProfile.getT2().map(VersionedProfile::getPaymentAddress).isEmpty()) {
|
||||
throw Status.PERMISSION_DENIED.asRuntimeException();
|
||||
}
|
||||
}
|
||||
})
|
||||
.flatMap(accountAndMaybeProfile -> {
|
||||
final Account account = accountAndMaybeProfile.getT1();
|
||||
final Optional<String> currentAvatar = accountAndMaybeProfile.getT2().map(VersionedProfile::getAvatar)
|
||||
.filter(avatar -> avatar.startsWith("profiles/"));
|
||||
final AvatarData avatarData = switch (AvatarChangeUtil.fromGrpcAvatarChange(request.getAvatarChange())) {
|
||||
case AVATAR_CHANGE_UNCHANGED -> new AvatarData(currentAvatar, currentAvatar, Optional.empty());
|
||||
case AVATAR_CHANGE_CLEAR -> new AvatarData(currentAvatar, Optional.empty(), Optional.empty());
|
||||
case AVATAR_CHANGE_UPDATE -> {
|
||||
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
|
||||
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
|
||||
Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
|
||||
}
|
||||
};
|
||||
|
||||
final Mono<Void> profileSetMono = Mono.fromFuture(profilesManager.setAsync(account.getUuid(),
|
||||
new VersionedProfile(
|
||||
request.getVersion(),
|
||||
encodeToBase64(request.getName().toByteArray()),
|
||||
avatarData.finalAvatar().orElse(null),
|
||||
encodeToBase64(request.getAboutEmoji().toByteArray()),
|
||||
encodeToBase64(request.getAbout().toByteArray()),
|
||||
encodeToBase64(request.getPaymentAddress().toByteArray()),
|
||||
request.getCommitment().toByteArray())));
|
||||
|
||||
final List<Mono<?>> updates = new ArrayList<>(2);
|
||||
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, account.getBadges()))
|
||||
.orElseGet(account::getBadges);
|
||||
|
||||
updates.add(Mono.fromFuture(accountsManager.updateAsync(account, a -> {
|
||||
a.setBadges(clock, updatedBadges);
|
||||
a.setCurrentProfileVersion(request.getVersion());
|
||||
})));
|
||||
if (request.getAvatarChange() != AvatarChange.AVATAR_CHANGE_UNCHANGED && avatarData.currentAvatar().isPresent()) {
|
||||
updates.add(Mono.fromFuture(asyncS3client.deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(avatarData.currentAvatar().get())
|
||||
.build())));
|
||||
}
|
||||
return profileSetMono.thenMany(Flux.merge(updates)).then(Mono.just(avatarData));
|
||||
})
|
||||
.map(avatarData -> avatarData.uploadAttributes()
|
||||
.map(avatarUploadAttributes -> SetProfileResponse.newBuilder().setAttributes(avatarUploadAttributes).build())
|
||||
.orElse(SetProfileResponse.newBuilder().build())
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRequest(SetProfileRequest request) {
|
||||
if (request.getVersion().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
|
||||
}
|
||||
|
||||
if (request.getCommitment().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Missing profile commitment").asRuntimeException();
|
||||
}
|
||||
|
||||
checkByteStringLength(request.getName(), "Invalid name length", List.of(81, 285));
|
||||
checkByteStringLength(request.getAboutEmoji(), "Invalid about emoji length", List.of(0, 60));
|
||||
checkByteStringLength(request.getAbout(), "Invalid about length", List.of(0, 156, 282, 540));
|
||||
checkByteStringLength(request.getPaymentAddress(), "Invalid mobile coin address length", List.of(0, 582));
|
||||
}
|
||||
|
||||
private static void checkByteStringLength(final ByteString byteString, final String errorMessage, final List<Integer> allowedLengths) {
|
||||
final int byteStringLength = byteString.toByteArray().length;
|
||||
|
||||
for (int allowedLength : allowedLengths) {
|
||||
if (byteStringLength == allowedLength) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
|
||||
}
|
||||
|
||||
private static String encodeToBase64(byte[] input) {
|
||||
return Base64.getEncoder().encodeToString(input);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
|
||||
public class ProfileHelper {
|
||||
public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
|
||||
final Clock clock,
|
||||
final Map<String, BadgeConfiguration> badgeConfigurationMap,
|
||||
final List<String> badgeIds,
|
||||
final List<AccountBadge> accountBadges) {
|
||||
LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final AccountBadge accountBadge : accountBadges) {
|
||||
existingBadges.putIfAbsent(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
|
||||
for (final String badgeId : badgeIds) {
|
||||
|
||||
// duplicate in the list, ignore it
|
||||
if (result.containsKey(badgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day
|
||||
// in the future.
|
||||
BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId);
|
||||
if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) {
|
||||
result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// reordering or making visible existing badges
|
||||
if (existingBadges.containsKey(badgeId)) {
|
||||
AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true);
|
||||
result.put(badgeId, accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
// take any remaining account badges and make them invisible
|
||||
for (final Map.Entry<String, AccountBadge> entry : existingBadges.entrySet()) {
|
||||
if (!result.containsKey(entry.getKey())) {
|
||||
AccountBadge accountBadge = entry.getValue().withVisibility(false);
|
||||
result.put(accountBadge.getId(), accountBadge);
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(result.values());
|
||||
}
|
||||
|
||||
public static String generateAvatarObjectName() {
|
||||
byte[] object = new byte[16];
|
||||
new SecureRandom().nextBytes(object);
|
||||
|
||||
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
|
||||
}
|
||||
|
||||
public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
final String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder()
|
||||
.setPath(objectName)
|
||||
.setCredential(policy.first())
|
||||
.setAcl("private")
|
||||
.setAlgorithm("AWS4-HMAC-SHA256")
|
||||
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
|
||||
.setPolicy(policy.second())
|
||||
.setSignature(ByteString.copyFrom(signature.getBytes()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
|
||||
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.profile;
|
||||
|
||||
import "org/signal/chat/common.proto";
|
||||
|
||||
/**
|
||||
* Provides methods for working with profiles and profile-related data.
|
||||
*/
|
||||
service Profile {
|
||||
/**
|
||||
* Sets profile data and if needed, returns S3 credentials used by clients to upload an avatar.
|
||||
*
|
||||
* This RPC may fail with `PERMISSION_DENIED` if it attempts to set the MobileCoin wallet ID
|
||||
* on an account whose profile does not currently have a MobileCoin wallet ID and
|
||||
* whose phone number contains a disallowed country prefix.
|
||||
*/
|
||||
rpc SetProfile(SetProfileRequest) returns (SetProfileResponse) {}
|
||||
|
||||
/**
|
||||
* Retrieves versioned profile data. Callers with an unidentified access key for the account
|
||||
* should use the version of this method in `ProfileAnonymous` instead.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
|
||||
* exceeded, in which case a `retry-after` header containing an ISO 8601
|
||||
* duration string will be present in the response trailers.
|
||||
*/
|
||||
rpc GetVersionedProfile(GetVersionedProfileRequest) returns (GetVersionedProfileResponse) {}
|
||||
|
||||
/**
|
||||
* Retrieves unversioned profile data. Callers with an unidentified access key for the account
|
||||
* should use the version of this method in `ProfileAnonymous` instead.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
|
||||
* exceeded, in which case a `retry-after` header containing an ISO 8601
|
||||
* duration string will be present in the response trailers.
|
||||
*/
|
||||
rpc GetUnversionedProfile(GetUnversionedProfileRequest) returns (GetUnversionedProfileResponse) {}
|
||||
|
||||
/**
|
||||
* Retrieves a profile key credential.
|
||||
* Callers with an unidentified access key for the account
|
||||
* should use the version of this method in `ProfileAnonymous` instead.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
|
||||
* exceeded, in which case a `retry-after` header containing an ISO 8601
|
||||
* duration string will be present in the response trailers. It may also fail with an
|
||||
* `INVALID_ARGUMENT` status if the given credential type is invalid.
|
||||
*/
|
||||
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides methods for working with profiles and profile-related data using "unidentified access"
|
||||
* credentials. Callers must not submit any self-identifying credentials
|
||||
* when calling methods in this service and must instead present the targeted account's
|
||||
* unidentified access key as an anonymous authentication mechanism. Callers
|
||||
* without an unidentified access key should use the equivalent, authenticated
|
||||
* methods in `Profile` instead.
|
||||
*/
|
||||
service ProfileAnonymous {
|
||||
/**
|
||||
* Retrieves versioned profile data.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may also fail with an `UNAUTHENTICATED` status if the given
|
||||
* unidentified access key did not match the target account's unidentified
|
||||
* access key.
|
||||
*/
|
||||
rpc GetVersionedProfile(GetVersionedProfileAnonymousRequest) returns (GetVersionedProfileResponse) {}
|
||||
/**
|
||||
* Retrieves unversioned profile data.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may also fail with an `UNAUTHENTICATED` status if the given
|
||||
* unidentified access key did not match the target account's unidentified
|
||||
* access key.
|
||||
*/
|
||||
rpc GetUnversionedProfile(GetUnversionedProfileAnonymousRequest) returns (GetUnversionedProfileResponse) {}
|
||||
/**
|
||||
* Retrieves a profile key credential.
|
||||
*
|
||||
* This RPC may fail with a `NOT_FOUND` status if the target account was not
|
||||
* found. It may also fail with an `UNAUTHENTICATED` status if the given
|
||||
* unidentified access key did not match the target account's unidentified
|
||||
* access key, or an `INVALID_ARGUMENT` status if the given credential type is invalid.
|
||||
*/
|
||||
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
|
||||
|
||||
/**
|
||||
* Checks identity key fingerprints of the target accounts.
|
||||
*
|
||||
* Returns a stream of elements, each one representing an account that had a mismatched
|
||||
* identity key fingerprint with the server and the corresponding identity key stored by the server.
|
||||
*/
|
||||
rpc CheckIdentityKeys(stream CheckIdentityKeysRequest) returns (stream CheckIdentityKeysResponse) {}
|
||||
}
|
||||
|
||||
message SetProfileRequest {
|
||||
enum AvatarChange {
|
||||
AVATAR_CHANGE_UNCHANGED = 0;
|
||||
AVATAR_CHANGE_CLEAR = 1;
|
||||
AVATAR_CHANGE_UPDATE = 2;
|
||||
}
|
||||
/**
|
||||
* The profile version. Must be set.
|
||||
*/
|
||||
string version = 1;
|
||||
/**
|
||||
* The ciphertext of a name that users must set on the profile.
|
||||
*/
|
||||
bytes name = 2;
|
||||
/**
|
||||
* An enum to indicate what change, if any, is made to the avatar with this request.
|
||||
*/
|
||||
AvatarChange avatarChange = 3;
|
||||
/**
|
||||
* The ciphertext of an emoji that users can set on their profile.
|
||||
*/
|
||||
bytes about_emoji = 4;
|
||||
/**
|
||||
* The ciphertext of a description that users can set on their profile.
|
||||
*/
|
||||
bytes about = 5;
|
||||
/**
|
||||
* The ciphertext of the MobileCoin wallet ID on the profile.
|
||||
*/
|
||||
bytes payment_address = 6;
|
||||
/**
|
||||
* A list of badge IDs associated with the profile.
|
||||
*/
|
||||
repeated string badge_ids = 7;
|
||||
/**
|
||||
* The profile key commitment. Used to issue a profile key credential response.
|
||||
* Must be set on the request.
|
||||
*/
|
||||
bytes commitment = 9;
|
||||
}
|
||||
|
||||
message SetProfileResponse {
|
||||
/**
|
||||
* The policy and credential used by clients to upload an avatar to S3.
|
||||
*/
|
||||
ProfileAvatarUploadAttributes attributes = 1;
|
||||
}
|
||||
|
||||
message GetVersionedProfileRequest {
|
||||
/**
|
||||
* The ACI of the account for which to get profile data.
|
||||
*/
|
||||
common.ServiceIdentifier accountIdentifier = 1;
|
||||
/**
|
||||
* The profile version to retrieve.
|
||||
*/
|
||||
bytes version = 2;
|
||||
}
|
||||
|
||||
message GetVersionedProfileAnonymousRequest {
|
||||
/**
|
||||
* Contains the data necessary to request a versioned profile.
|
||||
*/
|
||||
GetVersionedProfileRequest request = 1;
|
||||
/**
|
||||
* The unidentified access key for the targeted account.
|
||||
*/
|
||||
bytes unidentified_access_key = 2;
|
||||
}
|
||||
|
||||
message GetVersionedProfileResponse {
|
||||
/**
|
||||
* The ciphertext of the name on the profile.
|
||||
*/
|
||||
bytes name = 1;
|
||||
/**
|
||||
* The ciphertext of the description on the profile.
|
||||
*/
|
||||
bytes about = 2;
|
||||
/**
|
||||
* The ciphertext of the emoji on the profile.
|
||||
*/
|
||||
bytes about_emoji = 3;
|
||||
/**
|
||||
* The S3 path of the avatar on the profile.
|
||||
*/
|
||||
string avatar = 4;
|
||||
/**
|
||||
* The ciphertext of the MobileCoin wallet ID on the profile.
|
||||
*/
|
||||
bytes payment_address = 5;
|
||||
}
|
||||
|
||||
message GetUnversionedProfileRequest {
|
||||
/**
|
||||
* The service identifier of the account for which to get profile data.
|
||||
*/
|
||||
common.ServiceIdentifier serviceIdentifier = 1;
|
||||
}
|
||||
|
||||
message GetUnversionedProfileAnonymousRequest {
|
||||
/**
|
||||
* Contains the data necessary to request an unversioned profile.
|
||||
*/
|
||||
GetUnversionedProfileRequest request = 1;
|
||||
/**
|
||||
* The unidentified access key for the targeted account.
|
||||
*/
|
||||
bytes unidentified_access_key = 2;
|
||||
}
|
||||
|
||||
message GetUnversionedProfileResponse {
|
||||
/**
|
||||
* The identity key of the targeted account/identity type.
|
||||
*/
|
||||
bytes identity_key = 1;
|
||||
/**
|
||||
* A checksum of the unidentified access key for the targeted account.
|
||||
*/
|
||||
bytes unidentified_access = 2;
|
||||
/**
|
||||
* Whether the account has enabled sealed sender from anyone.
|
||||
*/
|
||||
bool unrestricted_unidentified_access = 3;
|
||||
/**
|
||||
* A list of capabilities enabled on the account.
|
||||
*/
|
||||
UserCapabilities capabilities = 4;
|
||||
/**
|
||||
* A list of badges associated with the account.
|
||||
*/
|
||||
repeated Badge badges = 5;
|
||||
}
|
||||
|
||||
message GetExpiringProfileKeyCredentialRequest {
|
||||
/**
|
||||
* The ACI of the account for which to get a profile key credential.
|
||||
*/
|
||||
common.ServiceIdentifier accountIdentifier = 1;
|
||||
/**
|
||||
* A zkgroup request for a profile key credential.
|
||||
*/
|
||||
bytes credential_request = 2;
|
||||
/**
|
||||
* The type of credential being requested.
|
||||
*/
|
||||
CredentialType credential_type = 3;
|
||||
}
|
||||
|
||||
message GetExpiringProfileKeyCredentialAnonymousRequest {
|
||||
/**
|
||||
* Contains the data necessary to request an expiring profile key credential.
|
||||
*/
|
||||
GetExpiringProfileKeyCredentialRequest request = 1;
|
||||
/**
|
||||
* The unidentified access key for the targeted account.
|
||||
*/
|
||||
bytes unidentified_access_key = 2;
|
||||
}
|
||||
|
||||
message GetExpiringProfileKeyCredentialResponse {
|
||||
/**
|
||||
* A zkgroup credential used by a client to prove that it has the profile key
|
||||
* of a targeted account.
|
||||
*/
|
||||
bytes profileKeyCredential = 2;
|
||||
}
|
||||
|
||||
message CheckIdentityKeysRequest {
|
||||
/**
|
||||
* The service identifier of the account for which we want to check the associated identity key fingerprint.
|
||||
*/
|
||||
common.ServiceIdentifier target_identifier = 1;
|
||||
/**
|
||||
* The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type.
|
||||
*/
|
||||
bytes fingerprint = 2;
|
||||
}
|
||||
|
||||
message CheckIdentityKeysResponse {
|
||||
/**
|
||||
* The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints.
|
||||
*/
|
||||
common.ServiceIdentifier target_identifier = 1;
|
||||
/**
|
||||
* The identity key that is stored by the server for the target account/identity type.
|
||||
*/
|
||||
bytes identity_key = 2;
|
||||
}
|
||||
|
||||
message ProfileAvatarUploadAttributes {
|
||||
/**
|
||||
* The S3 upload path for the profile's avatar.
|
||||
*/
|
||||
string path = 1;
|
||||
/**
|
||||
* A scoped credential. Includes the AWS access key, date, region targeted, and AWS service.
|
||||
*/
|
||||
string credential = 2;
|
||||
/**
|
||||
* The type of access control for the avatar object.
|
||||
*/
|
||||
string acl = 3;
|
||||
/**
|
||||
* The algorithm used to calculate a signature on the S3 policy.
|
||||
*/
|
||||
string algorithm = 4;
|
||||
/**
|
||||
* The timestamp at which the S3 policy and signature were generated.
|
||||
*/
|
||||
string date = 5;
|
||||
/**
|
||||
* The S3 policy used to upload the avatar object.
|
||||
*/
|
||||
string policy = 6;
|
||||
/**
|
||||
* A digital signature on the S3 policy.
|
||||
*/
|
||||
bytes signature = 7;
|
||||
}
|
||||
|
||||
message UserCapabilities {
|
||||
/**
|
||||
* Whether all devices linked to the account support the groups v1 migration.
|
||||
*/
|
||||
bool gv1_migration = 1;
|
||||
/**
|
||||
* Whether all devices linked to the account support sender keys.
|
||||
*/
|
||||
bool sender_key = 2;
|
||||
/**
|
||||
* Whether all devices linked to the account support announcement groups
|
||||
* (groups where only the admin can send messages or start calls).
|
||||
*/
|
||||
bool announcement_group = 3;
|
||||
/**
|
||||
* Whether all devices linked to the account support changing phone number.
|
||||
*/
|
||||
bool change_number = 4;
|
||||
/**
|
||||
* Whether all devices linked to the account support stories.
|
||||
*/
|
||||
bool stories = 5;
|
||||
/**
|
||||
* Whether all devices linked to the account support gift badges.
|
||||
*/
|
||||
bool gift_badges = 6;
|
||||
/**
|
||||
* Whether all devices linked to the account support MobileCoin payments.
|
||||
*/
|
||||
bool payment_activation = 7;
|
||||
/**
|
||||
* Whether all devices linked to the account support phone number privacy.
|
||||
*/
|
||||
bool pni = 8;
|
||||
}
|
||||
|
||||
message Badge {
|
||||
/**
|
||||
* An ID that uniquely identifies the badge.
|
||||
*/
|
||||
string id = 1;
|
||||
/**
|
||||
* The category the badge falls in ("donor" or "other").
|
||||
*/
|
||||
string category = 2;
|
||||
/**
|
||||
* The badge name.
|
||||
*/
|
||||
string name = 3;
|
||||
/**
|
||||
* The badge description.
|
||||
*/
|
||||
string description = 4;
|
||||
/**
|
||||
* Different size badge SVG files.
|
||||
*/
|
||||
repeated string sprites6 = 5;
|
||||
/**
|
||||
* File name of the scalable vector graphic representing this badge.
|
||||
*/
|
||||
string svg = 6;
|
||||
/**
|
||||
* Pairs of light/dark SVG files designed for display at different sizes.
|
||||
*/
|
||||
repeated BadgeSvg svgs = 7;
|
||||
}
|
||||
|
||||
message BadgeSvg {
|
||||
/**
|
||||
* File name of the scalable vector graphic for light mode.
|
||||
*/
|
||||
string light = 1;
|
||||
/**
|
||||
* File name of the scalable vector graphic for dark mode.
|
||||
*/
|
||||
string dark = 2;
|
||||
}
|
||||
|
||||
enum CredentialType {
|
||||
CREDENTIAL_TYPE_UNSPECIFIED = 0;
|
||||
CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY = 1;
|
||||
}
|
|
@ -0,0 +1,334 @@
|
|||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
import com.google.i18n.phonenumbers.Phonenumber;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.ServerInterceptors;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
|
||||
import org.signal.chat.profile.ProfileGrpc;
|
||||
import org.signal.chat.profile.SetProfileRequest;
|
||||
import org.signal.chat.profile.SetProfileResponse;
|
||||
import org.signal.libsignal.protocol.ServiceId;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ProfileGrpcServiceTest {
|
||||
private static final UUID AUTHENTICATED_ACI = UUID.randomUUID();
|
||||
private static final long AUTHENTICATED_DEVICE_ID = Device.MASTER_ID;
|
||||
private static final String S3_BUCKET = "profileBucket";
|
||||
private static final String VERSION = "someVersion";
|
||||
private static final byte[] VALID_NAME = new byte[81];
|
||||
private ProfilesManager profilesManager;
|
||||
private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
|
||||
private S3AsyncClient asyncS3client;
|
||||
private VersionedProfile profile;
|
||||
private Account account;
|
||||
private ProfileGrpc.ProfileBlockingStub profileBlockingStub;
|
||||
|
||||
@RegisterExtension
|
||||
static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
profilesManager = mock(ProfilesManager.class);
|
||||
dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);
|
||||
asyncS3client = mock(S3AsyncClient.class);
|
||||
profile = mock(VersionedProfile.class);
|
||||
account = mock(Account.class);
|
||||
|
||||
final AccountsManager accountsManager = mock(AccountsManager.class);
|
||||
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
||||
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
|
||||
final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
|
||||
final PostPolicyGenerator policyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey");
|
||||
final BadgesConfiguration badgesConfiguration = new BadgesConfiguration(
|
||||
List.of(new BadgeConfiguration(
|
||||
"TEST",
|
||||
"other",
|
||||
List.of("l", "m", "h", "x", "xx", "xxx"),
|
||||
"SVG",
|
||||
List.of(
|
||||
new BadgeSvg("sl", "sd"),
|
||||
new BadgeSvg("ml", "md"),
|
||||
new BadgeSvg("ll", "ld")
|
||||
)
|
||||
)),
|
||||
List.of("TEST1"),
|
||||
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")
|
||||
);
|
||||
final String phoneNumber = PhoneNumberUtil.getInstance().format(
|
||||
PhoneNumberUtil.getInstance().getExampleNumber("US"),
|
||||
PhoneNumberUtil.PhoneNumberFormat.E164);
|
||||
|
||||
profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel());
|
||||
|
||||
final ProfileGrpcService profileGrpcService = new ProfileGrpcService(
|
||||
Clock.systemUTC(),
|
||||
accountsManager,
|
||||
profilesManager,
|
||||
dynamicConfigurationManager,
|
||||
badgesConfiguration,
|
||||
asyncS3client,
|
||||
policyGenerator,
|
||||
policySigner,
|
||||
S3_BUCKET
|
||||
);
|
||||
|
||||
final MockAuthenticationInterceptor mockAuthenticationInterceptor = new MockAuthenticationInterceptor();
|
||||
mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID);
|
||||
|
||||
GRPC_SERVER_EXTENSION.getServiceRegistry()
|
||||
.addService(ServerInterceptors.intercept(profileGrpcService, mockAuthenticationInterceptor));
|
||||
|
||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
|
||||
|
||||
when(account.getUuid()).thenReturn(AUTHENTICATED_ACI);
|
||||
when(account.getNumber()).thenReturn(phoneNumber);
|
||||
when(account.getBadges()).thenReturn(Collections.emptyList());
|
||||
|
||||
when(profile.getPaymentAddress()).thenReturn(null);
|
||||
when(profile.getAvatar()).thenReturn("");
|
||||
|
||||
when(accountsManager.getByAccountIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
|
||||
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
|
||||
when(profilesManager.setAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
|
||||
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());
|
||||
|
||||
when(asyncS3client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(CompletableFuture.completedFuture(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setProfile() throws InvalidInputException {
|
||||
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
|
||||
final byte[] validAboutEmoji = new byte[60];
|
||||
final byte[] validAbout = new byte[540];
|
||||
final byte[] validPaymentAddress = new byte[582];
|
||||
|
||||
final SetProfileRequest request = SetProfileRequest.newBuilder()
|
||||
.setVersion(VERSION)
|
||||
.setName(ByteString.copyFrom(VALID_NAME))
|
||||
.setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)
|
||||
.setAboutEmoji(ByteString.copyFrom(validAboutEmoji))
|
||||
.setAbout(ByteString.copyFrom(validAbout))
|
||||
.setPaymentAddress(ByteString.copyFrom(validPaymentAddress))
|
||||
.setCommitment(ByteString.copyFrom(commitment))
|
||||
.build();
|
||||
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
profileBlockingStub.setProfile(request);
|
||||
|
||||
final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
|
||||
|
||||
verify(profilesManager).setAsync(eq(account.getUuid()), profileArgumentCaptor.capture());
|
||||
|
||||
final VersionedProfile profile = profileArgumentCaptor.getValue();
|
||||
|
||||
assertThat(profile.getCommitment()).isEqualTo(commitment);
|
||||
assertThat(profile.getAvatar()).isNull();
|
||||
assertThat(profile.getVersion()).isEqualTo(VERSION);
|
||||
assertThat(profile.getName()).isEqualTo(encodeToBase64(VALID_NAME));
|
||||
assertThat(profile.getAboutEmoji()).isEqualTo(encodeToBase64(validAboutEmoji));
|
||||
assertThat(profile.getAbout()).isEqualTo(encodeToBase64(validAbout));
|
||||
assertThat(profile.getPaymentAddress()).isEqualTo(encodeToBase64(validPaymentAddress));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void setProfileUpload(AvatarChange avatarChange, boolean hasPreviousProfile,
|
||||
boolean expectHasS3UploadPath, boolean expectDeleteS3Object) throws InvalidInputException {
|
||||
final String currentAvatar = "profiles/currentAvatar";
|
||||
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
|
||||
|
||||
final SetProfileRequest request = SetProfileRequest.newBuilder()
|
||||
.setVersion(VERSION)
|
||||
.setName(ByteString.copyFrom(VALID_NAME))
|
||||
.setAvatarChange(avatarChange)
|
||||
.setCommitment(ByteString.copyFrom(commitment))
|
||||
.build();
|
||||
|
||||
when(profile.getAvatar()).thenReturn(currentAvatar);
|
||||
|
||||
when(profilesManager.getAsync(any(), anyString())).thenReturn(CompletableFuture.completedFuture(
|
||||
hasPreviousProfile ? Optional.of(profile) : Optional.empty()));
|
||||
when(profilesManager.setAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
SetProfileResponse response = profileBlockingStub.setProfile(request);
|
||||
|
||||
if (expectHasS3UploadPath) {
|
||||
assertTrue(response.getAttributes().getPath().startsWith("profiles/"));
|
||||
} else {
|
||||
assertEquals(response.getAttributes().getPath(), "");
|
||||
}
|
||||
|
||||
if (expectDeleteS3Object) {
|
||||
verify(asyncS3client).deleteObject(DeleteObjectRequest.builder()
|
||||
.bucket(S3_BUCKET)
|
||||
.key(currentAvatar)
|
||||
.build());
|
||||
} else {
|
||||
verifyNoInteractions(asyncS3client);
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream<Arguments> setProfileUpload() {
|
||||
return Stream.of(
|
||||
// Upload new avatar, no previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, false, true, false),
|
||||
// Upload new avatar, has previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, true, true, true),
|
||||
// Clear avatar on profile, no previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, false, false, false),
|
||||
// Clear avatar on profile, has previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, true, false, true),
|
||||
// Set same avatar, no previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, false, false, false),
|
||||
// Set same avatar, has previous avatar
|
||||
Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, true, false, false)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void setProfileInvalidRequestData(SetProfileRequest request) {
|
||||
final StatusRuntimeException exception =
|
||||
assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
|
||||
|
||||
assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> setProfileInvalidRequestData() throws InvalidInputException{
|
||||
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)).serialize();
|
||||
final byte[] invalidValue = new byte[42];
|
||||
|
||||
final SetProfileRequest prototypeRequest = SetProfileRequest.newBuilder()
|
||||
.setVersion(VERSION)
|
||||
.setName(ByteString.copyFrom(VALID_NAME))
|
||||
.setCommitment(ByteString.copyFrom(commitment))
|
||||
.build();
|
||||
|
||||
return Stream.of(
|
||||
// Missing version
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.clearVersion()
|
||||
.build()),
|
||||
// Missing name
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.clearName()
|
||||
.build()),
|
||||
// Invalid name length
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.setName(ByteString.copyFrom(invalidValue))
|
||||
.build()),
|
||||
// Invalid about emoji length
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.setAboutEmoji(ByteString.copyFrom(invalidValue))
|
||||
.build()),
|
||||
// Invalid about length
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.setAbout(ByteString.copyFrom(invalidValue))
|
||||
.build()),
|
||||
// Invalid payment address
|
||||
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
|
||||
.setPaymentAddress(ByteString.copyFrom(invalidValue))
|
||||
.build()),
|
||||
// Missing profile commitment
|
||||
Arguments.of(SetProfileRequest.newBuilder()
|
||||
.clearCommitment()
|
||||
.build())
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void setPaymentAddressDisallowedCountry(boolean hasExistingPaymentAddress) throws InvalidInputException {
|
||||
final Phonenumber.PhoneNumber disallowedPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber("CU");
|
||||
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
|
||||
|
||||
final byte[] validPaymentAddress = new byte[582];
|
||||
if (hasExistingPaymentAddress) {
|
||||
when(profile.getPaymentAddress()).thenReturn(encodeToBase64(validPaymentAddress));
|
||||
}
|
||||
|
||||
final SetProfileRequest request = SetProfileRequest.newBuilder()
|
||||
.setVersion(VERSION)
|
||||
.setName(ByteString.copyFrom(VALID_NAME))
|
||||
.setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)
|
||||
.setPaymentAddress(ByteString.copyFrom(validPaymentAddress))
|
||||
.setCommitment(ByteString.copyFrom(commitment))
|
||||
.build();
|
||||
final String disallowedCountryCode = String.format("+%d", disallowedPhoneNumber.getCountryCode());
|
||||
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(List.of(disallowedCountryCode));
|
||||
when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(
|
||||
disallowedPhoneNumber,
|
||||
PhoneNumberUtil.PhoneNumberFormat.E164));
|
||||
when(profilesManager.getAsync(any(), anyString())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
|
||||
|
||||
if (hasExistingPaymentAddress) {
|
||||
assertDoesNotThrow(() -> profileBlockingStub.setProfile(request),
|
||||
"Payment address changes in disallowed countries should still be allowed if the account already has a valid payment address");
|
||||
} else {
|
||||
final StatusRuntimeException exception =
|
||||
assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
|
||||
assertEquals(Status.PERMISSION_DENIED.getCode(), exception.getStatus().getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private static String encodeToBase64(byte[] input) {
|
||||
return Base64.getEncoder().encodeToString(input);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue