Add receipt redemption API to chat server
This commit is contained in:
parent
ba58a95a0f
commit
3032415141
8
pom.xml
8
pom.xml
|
@ -235,6 +235,12 @@
|
|||
<artifactId>commons-logging</artifactId>
|
||||
<version>1.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ow2.asm</groupId>
|
||||
<artifactId>asm</artifactId>
|
||||
<version>9.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
@ -248,7 +254,7 @@
|
|||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock-jre8</artifactId>
|
||||
<version>2.28.1</version>
|
||||
<version>2.31.0</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
|
|
|
@ -98,6 +98,11 @@ deletedAccountsLockDynamoDb: # DynamoDb table configuration
|
|||
region:
|
||||
tableName:
|
||||
|
||||
redeemedReceiptsDynamoDb: # DynamoDB table configuration
|
||||
region:
|
||||
tableName:
|
||||
expirationTime: # ISO8601 Duration
|
||||
|
||||
migrationDeletedAccountsDynamoDb: # DynamoDB table configuration
|
||||
region:
|
||||
tableName:
|
||||
|
@ -266,3 +271,5 @@ badges:
|
|||
category: other
|
||||
badgeIdsEnabledForAll:
|
||||
- TEST
|
||||
receiptLevels:
|
||||
'1': TEST
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
<dependency>
|
||||
<groupId>org.signal</groupId>
|
||||
<artifactId>zkgroup-java</artifactId>
|
||||
<version>0.8.1</version>
|
||||
<version>0.8.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems</groupId>
|
||||
|
|
|
@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.configuration.PushConfiguration;
|
|||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RecaptchaV2Configuration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedeemedReceiptsDynamoDbConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RedisConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;
|
||||
|
@ -154,6 +155,11 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
@JsonProperty
|
||||
private DynamoDbConfiguration deletedAccountsLockDynamoDb;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private RedeemedReceiptsDynamoDbConfiguration redeemedReceiptsDynamoDb;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
|
@ -392,6 +398,10 @@ public class WhisperServerConfiguration extends Configuration {
|
|||
return deletedAccountsLockDynamoDb;
|
||||
}
|
||||
|
||||
public RedeemedReceiptsDynamoDbConfiguration getRedeemedReceiptsDynamoDbConfiguration() {
|
||||
return redeemedReceiptsDynamoDb;
|
||||
}
|
||||
|
||||
public DatabaseConfiguration getAbuseDatabaseConfiguration() {
|
||||
return abuseDatabase;
|
||||
}
|
||||
|
|
|
@ -56,6 +56,8 @@ import org.jdbi.v3.core.Jdbi;
|
|||
import org.signal.zkgroup.ServerSecretParams;
|
||||
import org.signal.zkgroup.auth.ServerZkAuthOperations;
|
||||
import org.signal.zkgroup.profiles.ServerZkProfileOperations;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.dispatch.DispatchManager;
|
||||
|
@ -175,6 +177,7 @@ import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
|||
import org.whispersystems.textsecuregcm.storage.PubSubManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigs;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;
|
||||
|
@ -208,6 +211,7 @@ import org.whispersystems.websocket.setup.WebSocketEnvironment;
|
|||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
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.S3Client;
|
||||
|
||||
|
@ -286,8 +290,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
|
||||
environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
|
||||
|
||||
ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(
|
||||
Clock.systemUTC(), config.getBadges());
|
||||
ProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(clock, config.getBadges());
|
||||
|
||||
JdbiFactory jdbiFactory = new JdbiFactory(DefaultNameStrategy.CHECK_EMPTY);
|
||||
Jdbi accountJdbi = jdbiFactory.build(environment, config.getAccountsDatabaseConfiguration(), "accountdb");
|
||||
|
@ -333,6 +336,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
.withCredentials(InstanceProfileCredentialsProvider.getInstance())
|
||||
.build();
|
||||
|
||||
DynamoDbAsyncClient redeemedReceiptsDynamoDbClient = DynamoDbFromConfig.asyncClient(
|
||||
config.getRedeemedReceiptsDynamoDbConfiguration(),
|
||||
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
|
||||
|
||||
DeletedAccounts deletedAccounts = new DeletedAccounts(deletedAccountsDynamoDbClient,
|
||||
config.getDeletedAccountsDynamoDbConfiguration().getTableName(),
|
||||
config.getDeletedAccountsDynamoDbConfiguration().getNeedsReconciliationIndexName());
|
||||
|
@ -437,6 +444,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager);
|
||||
TorExitNodeManager torExitNodeManager = new TorExitNodeManager(recurringJobExecutor, config.getTorExitNodeListConfiguration());
|
||||
AsnManager asnManager = new AsnManager(recurringJobExecutor, config.getAsnTableConfiguration());
|
||||
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(
|
||||
clock,
|
||||
config.getRedeemedReceiptsDynamoDbConfiguration().getTableName(),
|
||||
redeemedReceiptsDynamoDbClient,
|
||||
config.getRedeemedReceiptsDynamoDbConfiguration().getExpirationTime());
|
||||
|
||||
AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
|
||||
DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator(accountsManager);
|
||||
|
@ -535,6 +547,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret());
|
||||
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
|
||||
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
|
||||
ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);
|
||||
|
||||
AuthFilter<BasicCredentials, AuthenticatedAccount> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedAccount>().setAuthenticator(
|
||||
accountAuthenticator).buildAuthFilter();
|
||||
|
@ -587,7 +600,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
new ChallengeController(rateLimitChallengeManager),
|
||||
new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keysDynamoDb, rateLimiters, config.getMaxDevices()),
|
||||
new DirectoryController(directoryCredentialsGenerator),
|
||||
new DonationController(donationExecutor, config.getDonationConfiguration()),
|
||||
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
|
||||
ReceiptCredentialPresentation::new, donationExecutor, config.getDonationConfiguration()),
|
||||
new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, messagesManager, unsealedSenderRateLimiter, apnFallbackManager, dynamicConfigurationManager, rateLimitChallengeManager, reportMessageManager, metricsCluster, declinedMessageReceiptExecutor, multiRecipientMessageExecutor),
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, usernamesManager, dynamicConfigurationManager, profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations),
|
||||
|
|
|
@ -6,24 +6,32 @@
|
|||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonSetter;
|
||||
import com.fasterxml.jackson.annotation.Nulls;
|
||||
import io.dropwizard.validation.ValidationMethod;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class BadgesConfiguration {
|
||||
private final List<BadgeConfiguration> badges;
|
||||
private final List<String> badgeIdsEnabledForAll;
|
||||
private final Map<Long, String> receiptLevels;
|
||||
|
||||
@JsonCreator
|
||||
public BadgesConfiguration(
|
||||
@JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<BadgeConfiguration> badges,
|
||||
@JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<String> badgeIdsEnabledForAll) {
|
||||
@JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<String> badgeIdsEnabledForAll,
|
||||
@JsonProperty("receiptLevels") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map<Long, String> receiptLevels) {
|
||||
this.badges = Objects.requireNonNull(badges);
|
||||
this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll);
|
||||
this.receiptLevels = Objects.requireNonNull(receiptLevels);
|
||||
}
|
||||
|
||||
@Valid
|
||||
|
@ -37,4 +45,17 @@ public class BadgesConfiguration {
|
|||
public List<String> getBadgeIdsEnabledForAll() {
|
||||
return badgeIdsEnabledForAll;
|
||||
}
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
public Map<Long, String> getReceiptLevels() {
|
||||
return receiptLevels;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@ValidationMethod(message = "contains receipt level mappings that are not configured badges")
|
||||
public boolean isAllReceiptLevelsConfigured() {
|
||||
final Set<String> badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet());
|
||||
return badgeNames.containsAll(receiptLevels.values());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Duration;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
public class RedeemedReceiptsDynamoDbConfiguration extends DynamoDbConfiguration {
|
||||
|
||||
private Duration expirationTime;
|
||||
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
public Duration getExpirationTime() {
|
||||
return expirationTime;
|
||||
}
|
||||
}
|
|
@ -18,13 +18,22 @@ import java.net.http.HttpRequest;
|
|||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpResponse.BodyHandlers;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
|
||||
import java.util.function.Function;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.validation.Valid;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.POST;
|
||||
|
@ -32,28 +41,64 @@ import javax.ws.rs.Path;
|
|||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.http.FormDataBodyPublisher;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
@Path("/v1/donation")
|
||||
public class DonationController {
|
||||
|
||||
public interface ReceiptCredentialPresentationFactory {
|
||||
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
|
||||
}
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(DonationController.class);
|
||||
|
||||
private final Clock clock;
|
||||
private final ServerZkReceiptOperations serverZkReceiptOperations;
|
||||
private final RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
private final AccountsManager accountsManager;
|
||||
private final BadgesConfiguration badgesConfiguration;
|
||||
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
private final URI uri;
|
||||
private final String apiKey;
|
||||
private final String description;
|
||||
private final Set<String> supportedCurrencies;
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
|
||||
public DonationController(final Executor executor, final DonationConfiguration configuration) {
|
||||
public DonationController(
|
||||
@Nonnull final Clock clock,
|
||||
@Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
|
||||
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
|
||||
@Nonnull final AccountsManager accountsManager,
|
||||
@Nonnull final BadgesConfiguration badgesConfiguration,
|
||||
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
|
||||
@Nonnull final Executor httpClientExecutor,
|
||||
@Nonnull final DonationConfiguration configuration) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
|
||||
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
|
||||
this.accountsManager = Objects.requireNonNull(accountsManager);
|
||||
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
|
||||
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
|
||||
this.uri = URI.create(configuration.getUri());
|
||||
this.apiKey = configuration.getApiKey();
|
||||
this.description = configuration.getDescription();
|
||||
|
@ -64,12 +109,82 @@ public class DonationController {
|
|||
.withVersion(Version.HTTP_2)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withRedirect(Redirect.NEVER)
|
||||
.withExecutor(executor)
|
||||
.withExecutor(Objects.requireNonNull(httpClientExecutor))
|
||||
.withName("donation")
|
||||
.withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/redeem-receipt")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})
|
||||
public CompletionStage<Response> redeemReceipt(
|
||||
@Auth final AuthenticatedAccount auth,
|
||||
@Valid final RedeemReceiptRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
try {
|
||||
receiptCredentialPresentation = receiptCredentialPresentationFactory.build(
|
||||
request.getReceiptCredentialPresentation());
|
||||
} catch (InvalidInputException e) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("invalid receipt credential presentation").type(MediaType.TEXT_PLAIN_TYPE).build());
|
||||
}
|
||||
try {
|
||||
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
|
||||
} catch (VerificationFailedException e) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("receipt credential presentation verification failed").type(MediaType.TEXT_PLAIN_TYPE).build());
|
||||
}
|
||||
|
||||
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
|
||||
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
|
||||
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
|
||||
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
|
||||
if (badgeId == null) {
|
||||
return CompletableFuture.completedFuture(Response.serverError().entity("server does not recognize the requested receipt level").type(MediaType.TEXT_PLAIN_TYPE).build());
|
||||
}
|
||||
final CompletionStage<Boolean> putStage = redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid());
|
||||
return putStage.thenApplyAsync(receiptMatched -> {
|
||||
if (!receiptMatched) {
|
||||
return Response.status(Status.BAD_REQUEST).entity("receipt serial is already redeemed").type(MediaType.TEXT_PLAIN_TYPE).build();
|
||||
}
|
||||
|
||||
try {
|
||||
ForkJoinPool.managedBlock(new ManagedBlocker() {
|
||||
boolean done = false;
|
||||
|
||||
@Override
|
||||
public boolean block() {
|
||||
final Optional<Account> optionalAccount = accountsManager.get(auth.getAccount().getUuid());
|
||||
optionalAccount.ifPresent(account -> {
|
||||
accountsManager.update(account, a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
}
|
||||
});
|
||||
});
|
||||
done = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReleasable() {
|
||||
return done;
|
||||
}
|
||||
});
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Response.serverError().build();
|
||||
}
|
||||
|
||||
return Response.ok().build();
|
||||
});
|
||||
}).thenCompose(Function.identity());
|
||||
}
|
||||
|
||||
@Timed
|
||||
@POST
|
||||
@Path("/authorize-apple-pay")
|
||||
|
|
|
@ -5,48 +5,20 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
|
||||
public class DeliveryCertificate {
|
||||
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = ByteArraySerializer.class)
|
||||
@JsonDeserialize(using = ByteArrayDeserializer.class)
|
||||
private byte[] certificate;
|
||||
private final byte[] certificate;
|
||||
|
||||
public DeliveryCertificate(byte[] certificate) {
|
||||
@JsonCreator
|
||||
public DeliveryCertificate(
|
||||
@JsonProperty("certificate") byte[] certificate) {
|
||||
this.certificate = certificate;
|
||||
}
|
||||
|
||||
public DeliveryCertificate() {}
|
||||
|
||||
@VisibleForTesting
|
||||
public byte[] getCertificate() {
|
||||
return certificate;
|
||||
}
|
||||
|
||||
public static class ByteArraySerializer extends JsonSerializer<byte[]> {
|
||||
@Override
|
||||
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
|
||||
jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
public static class ByteArrayDeserializer extends JsonDeserializer<byte[]> {
|
||||
@Override
|
||||
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
return Base64.getDecoder().decode(jsonParser.getValueAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,32 +1,21 @@
|
|||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class GroupCredentials {
|
||||
|
||||
@JsonProperty
|
||||
private List<GroupCredential> credentials;
|
||||
private final List<GroupCredential> credentials;
|
||||
|
||||
public GroupCredentials() {}
|
||||
|
||||
public GroupCredentials(List<GroupCredential> credentials) {
|
||||
@JsonCreator
|
||||
public GroupCredentials(
|
||||
@JsonProperty("credentials") List<GroupCredential> credentials) {
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
|
@ -36,18 +25,14 @@ public class GroupCredentials {
|
|||
|
||||
public static class GroupCredential {
|
||||
|
||||
@JsonProperty
|
||||
@JsonSerialize(using = ByteArraySerializer.class)
|
||||
@JsonDeserialize(using = ByteArrayDeserializer.class)
|
||||
private byte[] credential;
|
||||
private final byte[] credential;
|
||||
private final int redemptionTime;
|
||||
|
||||
@JsonProperty
|
||||
private int redemptionTime;
|
||||
|
||||
public GroupCredential() {}
|
||||
|
||||
public GroupCredential(byte[] credential, int redemptionTime) {
|
||||
this.credential = credential;
|
||||
@JsonCreator
|
||||
public GroupCredential(
|
||||
@JsonProperty("credential") byte[] credential,
|
||||
@JsonProperty("redemptionTime") int redemptionTime) {
|
||||
this.credential = credential;
|
||||
this.redemptionTime = redemptionTime;
|
||||
}
|
||||
|
||||
|
@ -59,19 +44,4 @@ public class GroupCredentials {
|
|||
return redemptionTime;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ByteArraySerializer extends JsonSerializer<byte[]> {
|
||||
@Override
|
||||
public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
|
||||
jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
public static class ByteArrayDeserializer extends JsonDeserializer<byte[]> {
|
||||
@Override
|
||||
public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
|
||||
return Base64.getDecoder().decode(jsonParser.getValueAsString());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.entities;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
public class RedeemReceiptRequest {
|
||||
|
||||
private final byte[] receiptCredentialPresentation;
|
||||
private final boolean visible;
|
||||
private final boolean primary;
|
||||
|
||||
@JsonCreator
|
||||
public RedeemReceiptRequest(
|
||||
@JsonProperty("receiptCredentialPresentation") byte[] receiptCredentialPresentation,
|
||||
@JsonProperty("visible") boolean visible,
|
||||
@JsonProperty("primary") boolean primary) {
|
||||
this.receiptCredentialPresentation = receiptCredentialPresentation;
|
||||
this.visible = visible;
|
||||
this.primary = primary;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
@ExactlySize({ReceiptCredentialPresentation.SIZE})
|
||||
public byte[] getReceiptCredentialPresentation() {
|
||||
return receiptCredentialPresentation;
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
public boolean isPrimary() {
|
||||
return primary;
|
||||
}
|
||||
}
|
|
@ -353,6 +353,30 @@ public class Account {
|
|||
purgeStaleBadges(clock);
|
||||
}
|
||||
|
||||
public void makeBadgePrimaryIfExists(Clock clock, String badgeId) {
|
||||
requireNotStale();
|
||||
|
||||
// early exit if it's already the first item in the list
|
||||
if (!badges.isEmpty() && Objects.equals(badges.get(0).getId(), badgeId)) {
|
||||
purgeStaleBadges(clock);
|
||||
return;
|
||||
}
|
||||
|
||||
int indexOfBadge = -1;
|
||||
for (int i = 1; i < badges.size(); i++) {
|
||||
if (Objects.equals(badgeId, badges.get(i).getId())) {
|
||||
indexOfBadge = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexOfBadge != -1) {
|
||||
badges.add(0, badges.remove(indexOfBadge));
|
||||
}
|
||||
|
||||
purgeStaleBadges(clock);
|
||||
}
|
||||
|
||||
public void removeBadge(Clock clock, String id) {
|
||||
requireNotStale();
|
||||
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nonnull;
|
||||
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.whispersystems.textsecuregcm.util.AttributeValues;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
|
||||
public class RedeemedReceiptsManager {
|
||||
|
||||
public static final String KEY_SERIAL = "S";
|
||||
public static final String KEY_TTL = "E";
|
||||
public static final String KEY_RECEIPT_EXPIRATION = "G";
|
||||
public static final String KEY_RECEIPT_LEVEL = "L";
|
||||
public static final String KEY_ACCOUNT_UUID = "U";
|
||||
public static final String KEY_REDEMPTION_TIME = "R";
|
||||
|
||||
private final Clock clock;
|
||||
private final String table;
|
||||
private final DynamoDbAsyncClient client;
|
||||
private final Duration expirationTime;
|
||||
|
||||
public RedeemedReceiptsManager(
|
||||
@Nonnull final Clock clock,
|
||||
@Nonnull final String table,
|
||||
@Nonnull final DynamoDbAsyncClient client,
|
||||
@Nonnull final Duration expirationTime) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.table = Objects.requireNonNull(table);
|
||||
this.client = Objects.requireNonNull(client);
|
||||
this.expirationTime = Objects.requireNonNull(expirationTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true either if it's able to insert a new redeemed receipt entry with the {@code receiptExpiration}, {@code
|
||||
* receiptLevel}, and {@code accountUuid} provided or if an existing entry already exists with the same values thereby
|
||||
* allowing idempotent request processing.
|
||||
*/
|
||||
public CompletableFuture<Boolean> put(
|
||||
@Nonnull final ReceiptSerial receiptSerial,
|
||||
final long receiptExpiration,
|
||||
final long receiptLevel,
|
||||
@Nonnull final UUID accountUuid) {
|
||||
|
||||
// fail early if given bad inputs
|
||||
Objects.requireNonNull(receiptSerial);
|
||||
Objects.requireNonNull(accountUuid);
|
||||
|
||||
final Instant now = clock.instant();
|
||||
final Instant rowExpiration = now.plus(expirationTime);
|
||||
final AttributeValue serialAttributeValue = AttributeValues.b(receiptSerial.serialize());
|
||||
|
||||
final UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_SERIAL, serialAttributeValue))
|
||||
.returnValues(ReturnValue.ALL_NEW)
|
||||
.updateExpression("SET #ttl = if_not_exists(#ttl, :ttl), "
|
||||
+ "#receipt_expiration = if_not_exists(#receipt_expiration, :receipt_expiration), "
|
||||
+ "#receipt_level = if_not_exists(#receipt_level, :receipt_level), "
|
||||
+ "#account_uuid = if_not_exists(#account_uuid, :account_uuid), "
|
||||
+ "#redemption_time = if_not_exists(#redemption_time, :redemption_time)")
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#ttl", KEY_TTL,
|
||||
"#receipt_expiration", KEY_RECEIPT_EXPIRATION,
|
||||
"#receipt_level", KEY_RECEIPT_LEVEL,
|
||||
"#account_uuid", KEY_ACCOUNT_UUID,
|
||||
"#redemption_time", KEY_REDEMPTION_TIME))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":ttl", AttributeValues.n(rowExpiration.getEpochSecond()),
|
||||
":receipt_expiration", AttributeValues.n(receiptExpiration),
|
||||
":receipt_level", AttributeValues.n(receiptLevel),
|
||||
":account_uuid", AttributeValues.b(accountUuid),
|
||||
":redemption_time", AttributeValues.n(now.getEpochSecond())))
|
||||
.build();
|
||||
return client.updateItem(updateItemRequest).thenApply(updateItemResponse -> {
|
||||
final Map<String, AttributeValue> attributes = updateItemResponse.attributes();
|
||||
final long ddbReceiptExpiration = Long.parseLong(attributes.get(KEY_RECEIPT_EXPIRATION).n());
|
||||
final long ddbReceiptLevel = Long.parseLong(attributes.get(KEY_RECEIPT_LEVEL).n());
|
||||
final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(KEY_ACCOUNT_UUID).b().asByteBuffer());
|
||||
return ddbReceiptExpiration == receiptExpiration && ddbReceiptLevel == receiptLevel &&
|
||||
Objects.equals(ddbAccountUuid, accountUuid);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -15,6 +15,26 @@ import java.util.UUID;
|
|||
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
|
||||
public class AttributeValues {
|
||||
|
||||
// Clear-type methods
|
||||
|
||||
public static AttributeValue b(byte[] value) {
|
||||
return AttributeValue.builder().b(SdkBytes.fromByteArray(value)).build();
|
||||
}
|
||||
|
||||
public static AttributeValue b(ByteBuffer value) {
|
||||
return AttributeValue.builder().b(SdkBytes.fromByteBuffer(value)).build();
|
||||
}
|
||||
|
||||
public static AttributeValue b(UUID value) {
|
||||
return b(UUIDUtil.toByteBuffer(value));
|
||||
}
|
||||
|
||||
public static AttributeValue n(long value) {
|
||||
return AttributeValue.builder().n(String.valueOf(value)).build();
|
||||
}
|
||||
|
||||
// More opinionated methods
|
||||
|
||||
public static AttributeValue fromString(String value) {
|
||||
return AttributeValue.builder().s(value).build();
|
||||
}
|
||||
|
|
|
@ -2,22 +2,21 @@ package org.whispersystems.textsecuregcm.util;
|
|||
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbConfiguration;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.core.client.config.ClientAsyncConfiguration;
|
||||
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
|
||||
import software.amazon.awssdk.core.client.config.SdkAdvancedAsyncClientOption;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClientBuilder;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public class DynamoDbFromConfig {
|
||||
|
||||
private static ClientOverrideConfiguration clientOverrideConfiguration(DynamoDbConfiguration config) {
|
||||
return ClientOverrideConfiguration.builder()
|
||||
.apiCallTimeout(config.getClientExecutionTimeout())
|
||||
.apiCallAttemptTimeout(config.getClientRequestTimeout())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static DynamoDbClient client(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
|
||||
return DynamoDbClient.builder()
|
||||
.region(Region.of(config.getRegion()))
|
||||
|
@ -25,17 +24,13 @@ public class DynamoDbFromConfig {
|
|||
.overrideConfiguration(clientOverrideConfiguration(config))
|
||||
.build();
|
||||
}
|
||||
public static DynamoDbAsyncClient asyncClient(DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider, Executor executor) {
|
||||
|
||||
public static DynamoDbAsyncClient asyncClient(
|
||||
DynamoDbConfiguration config, AwsCredentialsProvider credentialsProvider) {
|
||||
DynamoDbAsyncClientBuilder builder = DynamoDbAsyncClient.builder()
|
||||
.region(Region.of(config.getRegion()))
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.overrideConfiguration(clientOverrideConfiguration(config));
|
||||
if (executor != null) {
|
||||
builder.asyncConfiguration(ClientAsyncConfiguration.builder()
|
||||
.advancedOption(SdkAdvancedAsyncClientOption.FUTURE_COMPLETION_EXECUTOR,
|
||||
executor)
|
||||
.build());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,19 +5,24 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static java.lang.annotation.ElementType.*;
|
||||
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
|
||||
import static java.lang.annotation.ElementType.FIELD;
|
||||
import static java.lang.annotation.ElementType.METHOD;
|
||||
import static java.lang.annotation.ElementType.PARAMETER;
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
|
||||
@Retention(RUNTIME)
|
||||
@Constraint(validatedBy = ExactlySizeValidator.class)
|
||||
@Constraint(validatedBy = {
|
||||
ExactlySizeValidatorForString.class,
|
||||
ExactlySizeValidatorForArraysOfByte.class,
|
||||
})
|
||||
@Documented
|
||||
public @interface ExactlySize {
|
||||
|
||||
|
|
|
@ -1,34 +1,29 @@
|
|||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
|
||||
public abstract class ExactlySizeValidator<T> implements ConstraintValidator<ExactlySize, T> {
|
||||
|
||||
public class ExactlySizeValidator implements ConstraintValidator<ExactlySize, String> {
|
||||
|
||||
private int[] permittedSizes;
|
||||
private Set<Integer> permittedSizes;
|
||||
|
||||
@Override
|
||||
public void initialize(ExactlySize exactlySize) {
|
||||
this.permittedSizes = exactlySize.value();
|
||||
public void initialize(ExactlySize annotation) {
|
||||
permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
|
||||
int objectLength;
|
||||
|
||||
if (object == null) objectLength = 0;
|
||||
else objectLength = object.length();
|
||||
|
||||
for (int permittedSize : permittedSizes) {
|
||||
if (permittedSize == objectLength) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
public boolean isValid(T value, ConstraintValidatorContext context) {
|
||||
return permittedSizes.contains(size(value));
|
||||
}
|
||||
|
||||
protected abstract int size(T value);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
public class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator<byte[]> {
|
||||
|
||||
@Override
|
||||
protected int size(final byte[] value) {
|
||||
return value == null ? 0 : value.length;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2013-2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
|
||||
public class ExactlySizeValidatorForString extends ExactlySizeValidator<String> {
|
||||
|
||||
@Override
|
||||
protected int size(final String value) {
|
||||
return value == null ? 0 : value.length();
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.ListResourceBundle;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.ResourceBundle.Control;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -80,7 +81,7 @@ public class ConfiguredProfileBadgeConverterTest {
|
|||
return objects;
|
||||
}
|
||||
};
|
||||
return new BadgesConfiguration(badges, List.of());
|
||||
return new BadgesConfiguration(badges, List.of(), Map.of());
|
||||
}
|
||||
|
||||
private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) {
|
||||
|
|
|
@ -10,70 +10,151 @@ import static com.github.tomakehurst.wiremock.client.WireMock.post;
|
|||
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
|
||||
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.github.tomakehurst.wiremock.junit.WireMockRule;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.DonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.DonationController;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.ApplePayAuthorizationResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AccountsHelper;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class DonationControllerTest {
|
||||
|
||||
private static final Executor executor = Executors.newSingleThreadExecutor();
|
||||
private static final Executor httpClientExecutor = Executors.newSingleThreadExecutor();
|
||||
private static final long nowEpochSeconds = 1_500_000_000L;
|
||||
|
||||
@Rule
|
||||
public final WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort().dynamicHttpsPort());
|
||||
@RegisterExtension
|
||||
static WireMockExtension wm = WireMockExtension.newInstance()
|
||||
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
|
||||
.build();
|
||||
static SecureRandom secureRandom;
|
||||
static {
|
||||
try {
|
||||
secureRandom = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ResourceExtension resources;
|
||||
|
||||
@Before
|
||||
public void before() throws Throwable {
|
||||
static DonationConfiguration getDonationConfiguration() {
|
||||
DonationConfiguration configuration = new DonationConfiguration();
|
||||
configuration.setApiKey("test-api-key");
|
||||
configuration.setDescription("some description");
|
||||
configuration.setUri("http://localhost:" + wireMockRule.port() + "/foo/bar");
|
||||
configuration.setUri("http://localhost:" + wm.getRuntimeInfo().getHttpPort() + "/foo/bar");
|
||||
configuration.setCircuitBreaker(new CircuitBreakerConfiguration());
|
||||
configuration.setRetry(new RetryConfiguration());
|
||||
configuration.setSupportedCurrencies(Set.of("usd", "gbp"));
|
||||
return configuration;
|
||||
}
|
||||
|
||||
static BadgesConfiguration getBadgesConfiguration() {
|
||||
return new BadgesConfiguration(
|
||||
List.of(
|
||||
new BadgeConfiguration("TEST", "other", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
|
||||
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
|
||||
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
|
||||
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")),
|
||||
List.of("TEST"),
|
||||
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3"));
|
||||
}
|
||||
|
||||
Clock clock;
|
||||
ServerZkReceiptOperations zkReceiptOperations;
|
||||
RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
AccountsManager accountsManager;
|
||||
byte[] receiptSerialBytes;
|
||||
ReceiptSerial receiptSerial;
|
||||
byte[] presentation;
|
||||
DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
ResourceExtension resources;
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() throws Throwable {
|
||||
clock = mock(Clock.class);
|
||||
zkReceiptOperations = mock(ServerZkReceiptOperations.class);
|
||||
redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);
|
||||
accountsManager = mock(AccountsManager.class);
|
||||
AccountsHelper.setupMockUpdate(accountsManager);
|
||||
receiptSerialBytes = new byte[ReceiptSerial.SIZE];
|
||||
secureRandom.nextBytes(receiptSerialBytes);
|
||||
receiptSerial = new ReceiptSerial(receiptSerialBytes);
|
||||
presentation = new byte[ReceiptCredentialPresentation.SIZE];
|
||||
secureRandom.nextBytes(presentation);
|
||||
receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);
|
||||
receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);
|
||||
|
||||
when(clock.millis()).thenReturn(nowEpochSeconds * 1000L);
|
||||
when(clock.instant()).thenReturn(Instant.ofEpochSecond(nowEpochSeconds));
|
||||
|
||||
try {
|
||||
when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation);
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
resources = ResourceExtension.builder()
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
|
||||
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
|
||||
.setMapper(SystemMapper.getMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new DonationController(executor, configuration))
|
||||
.addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,
|
||||
getBadgesConfiguration(), receiptCredentialPresentationFactory, httpClientExecutor,
|
||||
getDonationConfiguration()))
|
||||
.build();
|
||||
resources.before();
|
||||
}
|
||||
|
||||
@After
|
||||
public void after() throws Throwable {
|
||||
@AfterEach
|
||||
void afterEach() throws Throwable {
|
||||
resources.after();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetApplePayAuthorizationReturns200() {
|
||||
wireMockRule.stubFor(post(urlEqualTo("/foo/bar"))
|
||||
void testGetApplePayAuthorizationReturns200() {
|
||||
wm.stubFor(post(urlEqualTo("/foo/bar"))
|
||||
.withBasicAuth("test-api-key", "")
|
||||
.willReturn(aResponse()
|
||||
.withHeader("Content-Type", MediaType.APPLICATION_JSON)
|
||||
|
@ -96,7 +177,7 @@ public class DonationControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
|
||||
void testGetApplePayAuthorizationWithoutAuthHeaderReturns401() {
|
||||
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
|
||||
request.setCurrency("usd");
|
||||
request.setAmount(1000);
|
||||
|
@ -109,7 +190,7 @@ public class DonationControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
|
||||
void testGetApplePayAuthorizationWithUnsupportedCurrencyReturns422() {
|
||||
ApplePayAuthorizationRequest request = new ApplePayAuthorizationRequest();
|
||||
request.setCurrency("zzz");
|
||||
request.setAmount(1000);
|
||||
|
@ -121,4 +202,48 @@ public class DonationControllerTest {
|
|||
|
||||
assertThat(response.getStatus()).isEqualTo(422);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRedeemReceipt() {
|
||||
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
|
||||
final long receiptLevel = 1L;
|
||||
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
|
||||
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
|
||||
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
|
||||
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
|
||||
CompletableFuture.completedFuture(Boolean.TRUE));
|
||||
when(accountsManager.get(eq(AuthHelper.VALID_UUID))).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));
|
||||
|
||||
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
|
||||
Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/redeem-receipt")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge("TEST1", Instant.ofEpochSecond(receiptExpiration), true)));
|
||||
verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq("TEST1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRedeemReceiptAlreadyRedeemedWithDifferentParameters() {
|
||||
when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);
|
||||
final long receiptLevel = 1L;
|
||||
when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);
|
||||
final long receiptExpiration = nowEpochSeconds + 86400 * 30;
|
||||
when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);
|
||||
when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(
|
||||
CompletableFuture.completedFuture(Boolean.FALSE));
|
||||
|
||||
RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);
|
||||
Response response = resources.getJerseyTest()
|
||||
.target("/v1/donation/redeem-receipt")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import java.time.Clock;
|
|||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import javax.ws.rs.client.Entity;
|
||||
|
@ -115,7 +116,7 @@ class ProfileControllerTest {
|
|||
new BadgeConfiguration("TEST1", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
|
||||
new BadgeConfiguration("TEST2", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S"),
|
||||
new BadgeConfiguration("TEST3", "testing", "l", "m", "h", "x", "xx", "xxx", "s", "S")
|
||||
), List.of("TEST1")),
|
||||
), List.of("TEST1"), Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")),
|
||||
s3client,
|
||||
postPolicyGenerator,
|
||||
policySigner,
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.tests.storage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||
|
||||
class RedeemedReceiptsManagerTest {
|
||||
|
||||
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
|
||||
private static final String REDEEMED_RECEIPTS_TABLE_NAME = "redeemed_receipts";
|
||||
private static final SecureRandom SECURE_RANDOM;
|
||||
static {
|
||||
try {
|
||||
SECURE_RANDOM = SecureRandom.getInstanceStrong();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@RegisterExtension
|
||||
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||
.tableName(REDEEMED_RECEIPTS_TABLE_NAME)
|
||||
.hashKey(RedeemedReceiptsManager.KEY_SERIAL)
|
||||
.attributeDefinition(AttributeDefinition.builder()
|
||||
.attributeName(RedeemedReceiptsManager.KEY_SERIAL)
|
||||
.attributeType(ScalarAttributeType.B)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
Clock clock;
|
||||
ReceiptSerial receiptSerial;
|
||||
RedeemedReceiptsManager redeemedReceiptsManager;
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() throws InvalidInputException {
|
||||
clock = mock(Clock.class);
|
||||
when(clock.millis()).thenReturn(NOW_EPOCH_SECONDS * 1000L);
|
||||
when(clock.instant()).thenReturn(Instant.ofEpochSecond(NOW_EPOCH_SECONDS));
|
||||
byte[] receiptSerialBytes = new byte[ReceiptSerial.SIZE];
|
||||
SECURE_RANDOM.nextBytes(receiptSerialBytes);
|
||||
receiptSerial = new ReceiptSerial(receiptSerialBytes);
|
||||
redeemedReceiptsManager = new RedeemedReceiptsManager(
|
||||
clock, REDEEMED_RECEIPTS_TABLE_NAME, dynamoDbExtension.getDynamoDbAsyncClient(), Duration.ofDays(90));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPut() throws ExecutionException, InterruptedException {
|
||||
final long receiptExpiration = 42;
|
||||
final long receiptLevel = 3;
|
||||
CompletableFuture<Boolean> put;
|
||||
|
||||
// initial insert should return true
|
||||
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
|
||||
assertThat(put.get()).isTrue();
|
||||
|
||||
// subsequent attempted inserts with modified parameters should return false
|
||||
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID);
|
||||
assertThat(put.get()).isFalse();
|
||||
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID);
|
||||
assertThat(put.get()).isFalse();
|
||||
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO);
|
||||
assertThat(put.get()).isFalse();
|
||||
|
||||
// repeated insert attempt of the original parameters should return true
|
||||
put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);
|
||||
assertThat(put.get()).isTrue();
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
mock-maker-inline
|
Loading…
Reference in New Issue