mirror of
https://github.com/signalapp/Signal-Server
synced 2026-08-25 21:51:43 +01:00
Add LoginPurchaseController
This commit is contained in:
committed by
ravi-signal
parent
7a34493d53
commit
d6b80dfd16
@@ -385,6 +385,11 @@ subscription: # configuration for Stripe subscriptions
|
||||
STRIPE: price_example # stripe Price ID
|
||||
BRAINTREE: plan_example # braintree Plan ID
|
||||
|
||||
loginPurchase:
|
||||
level: 300
|
||||
playProductId: login_example
|
||||
appStoreProductId: login_example
|
||||
|
||||
oneTimeDonations:
|
||||
sepaMaximumEuros: '10000'
|
||||
boost:
|
||||
|
||||
+10
@@ -44,6 +44,7 @@ import org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
@@ -266,6 +267,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@NotNull
|
||||
private OneTimeDonationConfiguration oneTimeDonations;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
private LoginPurchaseConfiguration loginPurchase;
|
||||
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@@ -532,6 +538,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return oneTimeDonations;
|
||||
}
|
||||
|
||||
public LoginPurchaseConfiguration getLoginPurchase() {
|
||||
return loginPurchase;
|
||||
}
|
||||
|
||||
public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {
|
||||
return pagedSingleUseKEMPreKeyStore;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ import org.whispersystems.textsecuregcm.controllers.DonationController;
|
||||
import org.whispersystems.textsecuregcm.controllers.KeepAliveController;
|
||||
import org.whispersystems.textsecuregcm.controllers.KeyTransparencyController;
|
||||
import org.whispersystems.textsecuregcm.controllers.KeysController;
|
||||
import org.whispersystems.textsecuregcm.controllers.LoginPurchaseController;
|
||||
import org.whispersystems.textsecuregcm.controllers.MessageController;
|
||||
import org.whispersystems.textsecuregcm.controllers.OneTimeDonationController;
|
||||
import org.whispersystems.textsecuregcm.controllers.PaymentsController;
|
||||
@@ -300,7 +301,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
|
||||
import org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider;
|
||||
@@ -987,6 +990,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
|
||||
zkReceiptOperations, issuedReceiptsManager);
|
||||
|
||||
final LoginPurchaseManager loginPurchaseManager = new LoginPurchaseManager(
|
||||
Map.of(
|
||||
PaymentProvider.APPLE_APP_STORE, appleAppStoreManager,
|
||||
PaymentProvider.GOOGLE_PLAY_BILLING, googlePlayBillingManager),
|
||||
issuedReceiptsManager,
|
||||
zkReceiptOperations,
|
||||
config.getLoginPurchase().level(),
|
||||
clock);
|
||||
|
||||
final List<SpamFilter> spamFilters = ServiceLoader.load(SpamFilter.class)
|
||||
.stream()
|
||||
.map(ServiceLoader.Provider::get)
|
||||
@@ -1109,7 +1121,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
|
||||
new DonationsGrpcService(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), ReceiptCredentialPresentation::new, donationPermitsManager, rateLimiters),
|
||||
new ProductConfigurationGrpcService(config.getSubscription(), config.getOneTimeDonations(),
|
||||
List.of(stripeManager, braintreeManager), config.getBackupConfiguration().maxTotalMediaSize()),
|
||||
config.getLoginPurchase(), List.of(stripeManager, braintreeManager),
|
||||
config.getBackupConfiguration().maxTotalMediaSize()),
|
||||
new RemoteConfigurationGrpcService(remoteConfigsManager, profileBadgeConverter,
|
||||
config.getBadges().getBadges().stream()
|
||||
.map(BadgeConfiguration::getId)
|
||||
@@ -1284,12 +1297,14 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
phoneNumberIdentifiers, rateLimiters, accountsManager, carrierDataProvider, registrationFraudChecker,
|
||||
dynamicConfigurationManager, experimentEnrollmentManager, clock),
|
||||
new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
|
||||
config.getLoginPurchase(), subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
|
||||
appleAppStoreManager,
|
||||
profileBadgeConverter, bankMandateTranslator, donationPermitsManager,
|
||||
config.getBackupConfiguration().maxTotalMediaSize()),
|
||||
new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
|
||||
payPalDonationsTranslator, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
|
||||
donationPermitsManager)
|
||||
donationPermitsManager),
|
||||
new LoginPurchaseController(loginPurchaseManager, dynamicConfigurationManager)
|
||||
);
|
||||
|
||||
for (Object controller : commonControllers) {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.time.Duration;
|
||||
|
||||
/// Configuration for one-time Signal Login purchases
|
||||
///
|
||||
/// @param level the receipt level that identifies a purchase as a Signal Login.
|
||||
/// @param playProductId the Google Play Billing productId clients should purchase to obtain a Signal Login
|
||||
/// @param appStoreProductId the App Store productId clients should purchase to obtain a Signal Login
|
||||
public record LoginPurchaseConfiguration(
|
||||
@Positive long level,
|
||||
@NotEmpty String playProductId,
|
||||
@NotEmpty String appStoreProductId) {}
|
||||
+8
@@ -85,6 +85,10 @@ public class DynamicConfiguration {
|
||||
@Valid
|
||||
private DynamicTurnConfiguration turn = new DynamicTurnConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
private DynamicLoginPurchaseConfiguration loginPurchase = new DynamicLoginPurchaseConfiguration(false);
|
||||
|
||||
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
|
||||
final String experimentName) {
|
||||
return Optional.ofNullable(experiments.get(experimentName));
|
||||
@@ -154,4 +158,8 @@ public class DynamicConfiguration {
|
||||
public DynamicTurnConfiguration getTurnConfiguration() {
|
||||
return turn;
|
||||
}
|
||||
|
||||
public DynamicLoginPurchaseConfiguration getLoginPurchaseConfiguration() {
|
||||
return loginPurchase;
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
/// @param enabled whether clients may exchange a one-time purchase for a Signal Login receipt credential
|
||||
public record DynamicLoginPurchaseConfiguration(boolean enabled) {}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.headers.Header;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import org.glassfish.jersey.server.ManagedAsync;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
|
||||
|
||||
@Path("/v1/login-purchase")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "LoginPurchase")
|
||||
public class LoginPurchaseController {
|
||||
|
||||
private final LoginPurchaseManager loginPurchaseManager;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
public LoginPurchaseController(
|
||||
final LoginPurchaseManager loginPurchaseManager,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.loginPurchaseManager = loginPurchaseManager;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
public record CreateLoginReceiptCredentialRequest(
|
||||
@NotNull String purchaseIdentifier,
|
||||
@NotNull byte[] receiptCredentialRequest,
|
||||
@NotNull PaymentProvider paymentProvider) {
|
||||
}
|
||||
|
||||
public record CreateLoginReceiptCredentialResponse(byte[] receiptCredentialResponse) {
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/receipt_credentials")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a receipt credential for a completed one-time login purchase",
|
||||
description = """
|
||||
Verify a completed one-time purchase with the payment provider and issue a receipt credential that can be
|
||||
redeemed for a login.
|
||||
|
||||
This endpoint must be called on an unauthenticated connection. Retries for the same purchaseIdentifier MUST
|
||||
use the same receiptCredentialRequest.
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "Successfully created receipt",
|
||||
content = @Content(schema = @Schema(implementation = CreateLoginReceiptCredentialResponse.class)))
|
||||
@ApiResponse(responseCode = "204", description = "The purchase is still pending with the payment provider. The client may retry later.")
|
||||
@ApiResponse(responseCode = "400", description = """
|
||||
Invalid request: malformed request, failed zkgroup verification, unsupported paymentProvider, the provided
|
||||
purchase identifier not for a Signal Login, or login purchases are not currently enabled.
|
||||
""")
|
||||
@ApiResponse(responseCode = "402", description = "The purchase did not complete successfully. The body may include ChargeFailure details.",
|
||||
content = @Content(schema = @Schema(
|
||||
nullable = true,
|
||||
implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
|
||||
@ApiResponse(responseCode = "404", description = "The payment provider has no purchase with the provided purchaseIdentifier")
|
||||
@ApiResponse(responseCode = "409", description = "The purchase was already redeemed for a receipt credential, but with a different receipt credential request")
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
@ManagedAsync
|
||||
public Response createLoginReceiptCredential(
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final CreateLoginReceiptCredentialRequest request)
|
||||
throws IOException, SubscriptionPaymentRequiredException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException, SubscriptionReceiptAlreadyRedeemedException {
|
||||
|
||||
if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {
|
||||
throw new BadRequestException("login purchases are not enabled");
|
||||
}
|
||||
|
||||
if (authenticatedAccount.isPresent()) {
|
||||
throw new ForbiddenException("must not use authenticated connection for login purchase operations");
|
||||
}
|
||||
|
||||
final ReceiptCredentialRequest receiptCredentialRequest;
|
||||
try {
|
||||
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
|
||||
} catch (final InvalidInputException e) {
|
||||
throw new BadRequestException("invalid receipt credential request", e);
|
||||
}
|
||||
|
||||
try {
|
||||
final ReceiptCredentialResponse receiptCredentialResponse = loginPurchaseManager.generateReceipt(
|
||||
request.paymentProvider, request.purchaseIdentifier, receiptCredentialRequest);
|
||||
return Response.ok(
|
||||
new CreateLoginReceiptCredentialResponse(receiptCredentialResponse.serialize()))
|
||||
.build();
|
||||
} catch (SubscriptionReceiptRequestedForOpenPaymentException e) {
|
||||
return Response.noContent().build();
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new BadRequestException("receipt credential request failed verification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -66,6 +66,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
|
||||
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
|
||||
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
|
||||
@@ -106,6 +107,7 @@ public class SubscriptionController {
|
||||
private final Clock clock;
|
||||
private final SubscriptionConfiguration subscriptionConfiguration;
|
||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
|
||||
private final LoginPurchaseConfiguration loginPurchaseConfiguration;
|
||||
private final SubscriptionManager subscriptionManager;
|
||||
private final StripeManager stripeManager;
|
||||
private final BraintreeManager braintreeManager;
|
||||
@@ -124,6 +126,7 @@ public class SubscriptionController {
|
||||
Clock clock,
|
||||
SubscriptionConfiguration subscriptionConfiguration,
|
||||
OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
LoginPurchaseConfiguration loginPurchaseConfiguration,
|
||||
SubscriptionManager subscriptionManager,
|
||||
StripeManager stripeManager,
|
||||
BraintreeManager braintreeManager,
|
||||
@@ -137,6 +140,7 @@ public class SubscriptionController {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);
|
||||
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
|
||||
this.loginPurchaseConfiguration = Objects.requireNonNull(loginPurchaseConfiguration);
|
||||
this.stripeManager = Objects.requireNonNull(stripeManager);
|
||||
this.braintreeManager = Objects.requireNonNull(braintreeManager);
|
||||
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
|
||||
@@ -167,6 +171,8 @@ public class SubscriptionController {
|
||||
buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration, badgeTranslator,
|
||||
acceptableLanguages),
|
||||
new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),
|
||||
new LoginConfiguration(loginPurchaseConfiguration.level(), loginPurchaseConfiguration.playProductId(),
|
||||
loginPurchaseConfiguration.appStoreProductId()),
|
||||
oneTimeDonationConfiguration.sepaMaximumEuros());
|
||||
}
|
||||
|
||||
@@ -532,9 +538,20 @@ public class SubscriptionController {
|
||||
Map<Long, LevelConfiguration> levels,
|
||||
@Schema(description = "Backup specific configuration")
|
||||
BackupConfiguration backup,
|
||||
@Schema(description = "Signal Login specific configuration")
|
||||
LoginConfiguration login,
|
||||
@Schema(description = "The maximum value of a one-time donation SEPA transaction")
|
||||
BigDecimal sepaMaximumEuros) {}
|
||||
|
||||
@Schema(description = "Configuration for one-time Signal Login purchases")
|
||||
public record LoginConfiguration(
|
||||
@Schema(description = "The receipt level associated with a Signal Login purchase")
|
||||
long level,
|
||||
@Schema(description = "The play billing productID associated with a Signal Login purchase")
|
||||
String playProductId,
|
||||
@Schema(description = "The App Store productID associated with a Signal Login purchase")
|
||||
String appStoreProductId) {}
|
||||
|
||||
public record BackupConfiguration(
|
||||
@Schema(description = "A map of numeric backup level IDs to level-specific backup configuration")
|
||||
Map<String, BackupLevelConfiguration> levels,
|
||||
|
||||
+13
@@ -12,7 +12,9 @@ import org.signal.chat.purchase.CurrencyConfiguration;
|
||||
import org.signal.chat.purchase.GetConfigurationRequest;
|
||||
import org.signal.chat.purchase.GetConfigurationResponse;
|
||||
import org.signal.chat.purchase.LevelConfiguration;
|
||||
import org.signal.chat.purchase.LoginConfiguration;
|
||||
import org.signal.chat.purchase.SimpleProductConfigurationGrpc;
|
||||
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
|
||||
@@ -24,6 +26,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
|
||||
public ProductConfigurationGrpcService(
|
||||
final SubscriptionConfiguration subscriptionConfiguration,
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
final LoginPurchaseConfiguration loginPurchaseConfiguration,
|
||||
List<CustomerAwareSubscriptionPaymentProcessor> paymentProcessors,
|
||||
final long backupMediaStorageAllowanceBytes) {
|
||||
this.configurationResponse = GetConfigurationResponse.newBuilder()
|
||||
@@ -31,6 +34,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
|
||||
.setSepaMaximumEuros(oneTimeDonationConfiguration.sepaMaximumEuros().toString())
|
||||
.putAllCurrencies(buildCurrencyConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration, paymentProcessors))
|
||||
.putAllBadgeLevels(buildLevelConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration))
|
||||
.setLogin(buildLoginConfiguration(loginPurchaseConfiguration))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -85,6 +89,15 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LoginConfiguration buildLoginConfiguration(
|
||||
final LoginPurchaseConfiguration loginPurchaseConfiguration) {
|
||||
return LoginConfiguration.newBuilder()
|
||||
.setLevel(loginPurchaseConfiguration.level())
|
||||
.setPlayProductId(loginPurchaseConfiguration.playProductId())
|
||||
.setAppStoreProductId(loginPurchaseConfiguration.appStoreProductId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static CurrencyConfiguration toProtoCurrencyConfiguration(
|
||||
final String currency,
|
||||
final org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration config) {
|
||||
|
||||
-1
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
|
||||
@@ -33,6 +33,8 @@ message GetConfigurationResponse {
|
||||
BackupConfiguration backup = 3;
|
||||
// Maximum value of a one-time SEPA donation
|
||||
string sepa_maximum_euros = 4;
|
||||
// Configuration for one-time Signal Login purchases
|
||||
LoginConfiguration login = 5;
|
||||
}
|
||||
|
||||
message AmountList {
|
||||
@@ -81,3 +83,13 @@ message BackupConfiguration {
|
||||
// The number of days of media a free tier backup user gets
|
||||
uint64 free_tier_media_days = 2;
|
||||
}
|
||||
|
||||
// Configuration for one-time Signal Login purchases
|
||||
message LoginConfiguration {
|
||||
// The receipt level associated with a Signal Login purchase
|
||||
uint64 level = 1;
|
||||
// The play billing productID associated with a Signal Login purchase
|
||||
string play_product_id = 2;
|
||||
// The App Store productID associated with a Signal Login purchase
|
||||
string app_store_product_id = 3;
|
||||
}
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.dropwizard.auth.AuthValueFactoryProvider;
|
||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||
import jakarta.ws.rs.client.Entity;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.stream.Stream;
|
||||
import org.glassfish.jersey.server.ServerProperties;
|
||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicLoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class LoginPurchaseControllerTest {
|
||||
|
||||
private static final String PURCHASE_ID = "purchaseId";
|
||||
|
||||
private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();
|
||||
private static final ClientZkReceiptOperations CLIENT_ZK_OPS =
|
||||
new ClientZkReceiptOperations(SERVER_SECRET_PARAMS.getPublicParams());
|
||||
private static final ServerZkReceiptOperations SERVER_ZK_OPS =
|
||||
new ServerZkReceiptOperations(SERVER_SECRET_PARAMS);
|
||||
|
||||
private static final LoginPurchaseManager LOGIN_PURCHASE_MANAGER = mock(LoginPurchaseManager.class);
|
||||
|
||||
private static final DynamicLoginPurchaseConfiguration ENABLED = new DynamicLoginPurchaseConfiguration(true);
|
||||
private static final DynamicLoginPurchaseConfiguration DISABLED = new DynamicLoginPurchaseConfiguration(false);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static final DynamicConfigurationManager<DynamicConfiguration> DYNAMIC_CONFIGURATION_MANAGER =
|
||||
mock(DynamicConfigurationManager.class);
|
||||
private static final DynamicConfiguration DYNAMIC_CONFIGURATION = mock(DynamicConfiguration.class);
|
||||
|
||||
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
|
||||
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
.addProvider(CompletionExceptionMapper.class)
|
||||
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))
|
||||
.addProvider(SubscriptionExceptionMapper.class)
|
||||
.addProvider(RateLimitExceededExceptionMapper.class)
|
||||
.setMapper(SystemMapper.jsonMapper())
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new LoginPurchaseController(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER))
|
||||
.build();
|
||||
|
||||
private ReceiptCredentialRequestContext receiptCredentialRequestContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws InvalidInputException, VerificationFailedException {
|
||||
reset(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER, DYNAMIC_CONFIGURATION);
|
||||
|
||||
when(DYNAMIC_CONFIGURATION_MANAGER.getConfiguration()).thenReturn(DYNAMIC_CONFIGURATION);
|
||||
when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(ENABLED);
|
||||
|
||||
receiptCredentialRequestContext = CLIENT_ZK_OPS.createReceiptCredentialRequestContext(
|
||||
new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReceiptCredential() throws Exception {
|
||||
final ReceiptCredentialResponse receiptCredentialResponse =
|
||||
SERVER_ZK_OPS.issueReceiptCredential(receiptCredentialRequestContext.getRequest(), 0L, 200L);
|
||||
|
||||
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenReturn(receiptCredentialResponse);
|
||||
|
||||
try (final Response response = RESOURCE_EXTENSION
|
||||
.target("/v1/login-purchase/receipt_credentials")
|
||||
.request()
|
||||
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
|
||||
PURCHASE_ID,
|
||||
receiptCredentialRequestContext.getRequest().serialize(),
|
||||
PaymentProvider.APPLE_APP_STORE)))) {
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(LoginPurchaseController.CreateLoginReceiptCredentialResponse.class)
|
||||
.receiptCredentialResponse()).isEqualTo(receiptCredentialResponse.serialize());
|
||||
}
|
||||
|
||||
verify(LOGIN_PURCHASE_MANAGER).generateReceipt(
|
||||
eq(PaymentProvider.APPLE_APP_STORE),
|
||||
eq(PURCHASE_ID),
|
||||
any(ReceiptCredentialRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReceiptCredentialAuthenticated() {
|
||||
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
|
||||
.request()
|
||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
|
||||
PURCHASE_ID,
|
||||
receiptCredentialRequestContext.getRequest().serialize(),
|
||||
PaymentProvider.APPLE_APP_STORE)))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
}
|
||||
|
||||
verifyNoInteractions(LOGIN_PURCHASE_MANAGER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReceiptCredentialNotEnabled() {
|
||||
when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(DISABLED);
|
||||
|
||||
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
|
||||
.request()
|
||||
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
|
||||
PURCHASE_ID,
|
||||
receiptCredentialRequestContext.getRequest().serialize(),
|
||||
PaymentProvider.APPLE_APP_STORE)))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
}
|
||||
|
||||
verifyNoInteractions(LOGIN_PURCHASE_MANAGER);
|
||||
}
|
||||
|
||||
static Stream<Arguments> createReceiptCredentialErrors() {
|
||||
return Stream.of(
|
||||
Arguments.of(new SubscriptionReceiptRequestedForOpenPaymentException(), 204),
|
||||
Arguments.of(new SubscriptionPaymentRequiredException(), 402),
|
||||
Arguments.of(new SubscriptionNotFoundException(), 404),
|
||||
Arguments.of(new SubscriptionInvalidArgumentsException("test"), 400),
|
||||
Arguments.of(new SubscriptionReceiptAlreadyRedeemedException(), 409),
|
||||
Arguments.of(new VerificationFailedException(), 400),
|
||||
Arguments.of(new RateLimitExceededException(null), 429));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void createReceiptCredentialErrors(final Exception exception, final int expectedStatus) throws Exception {
|
||||
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenThrow(exception);
|
||||
|
||||
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
|
||||
.request()
|
||||
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
|
||||
PURCHASE_ID,
|
||||
receiptCredentialRequestContext.getRequest().serialize(),
|
||||
PaymentProvider.APPLE_APP_STORE)))) {
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(expectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReceiptCredentialPaymentRequiredWithChargeFailure() throws Exception {
|
||||
final ChargeFailure chargeFailure =
|
||||
new ChargeFailure("generic_decline", "some failure message", null, null, null);
|
||||
when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any()))
|
||||
.thenThrow(new SubscriptionChargeFailurePaymentRequiredException(PaymentProvider.APPLE_APP_STORE, chargeFailure));
|
||||
|
||||
try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials")
|
||||
.request()
|
||||
.post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest(
|
||||
PURCHASE_ID,
|
||||
receiptCredentialRequestContext.getRequest().serialize(),
|
||||
PaymentProvider.APPLE_APP_STORE)))) {
|
||||
final SubscriptionExceptionMapper.ChargeFailureResponse failureResponse =
|
||||
response.readEntity(SubscriptionExceptionMapper.ChargeFailureResponse.class);
|
||||
assertThat(failureResponse.chargeFailure()).isEqualTo(chargeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -60,6 +60,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
|
||||
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetBankMandateResponse;
|
||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse;
|
||||
@@ -109,8 +110,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {
|
||||
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
||||
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
|
||||
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
||||
private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIG =
|
||||
new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId");
|
||||
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK,
|
||||
SUBSCRIPTION_CONFIG, ONETIME_CONFIG,
|
||||
SUBSCRIPTION_CONFIG, ONETIME_CONFIG, LOGIN_PURCHASE_CONFIG,
|
||||
new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER),
|
||||
ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER,
|
||||
BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DONATION_PERMITS_MANAGER, MAX_TOTAL_BACKUP_MEDIA_BYTES);
|
||||
@@ -1074,6 +1077,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {
|
||||
});
|
||||
assertThat(response.backup().freeTierMediaDays()).isEqualTo(30);
|
||||
|
||||
assertThat(response.login().level()).isEqualTo(LOGIN_PURCHASE_CONFIG.level());
|
||||
assertThat(response.login().playProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.playProductId());
|
||||
assertThat(response.login().appStoreProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.appStoreProductId());
|
||||
|
||||
// check the badge vs purchasable badge fields
|
||||
// subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration`
|
||||
Map<String, Object> genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration")
|
||||
|
||||
+9
-1
@@ -22,6 +22,7 @@ import org.signal.chat.purchase.GetConfigurationRequest;
|
||||
import org.signal.chat.purchase.GetConfigurationResponse;
|
||||
import org.signal.chat.purchase.PaymentMethod;
|
||||
import org.signal.chat.purchase.ProductConfigurationGrpc;
|
||||
import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
@@ -37,6 +38,9 @@ public class ProductConfigurationGrpcServiceTest extends
|
||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration =
|
||||
SubscriptionConfigTestHelper.getOneTimeConfig();
|
||||
|
||||
private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIGURATION =
|
||||
new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId");
|
||||
|
||||
@Mock
|
||||
private StripeManager stripeManager;
|
||||
|
||||
@@ -65,7 +69,7 @@ public class ProductConfigurationGrpcServiceTest extends
|
||||
|
||||
|
||||
return new ProductConfigurationGrpcService(subscriptionConfiguration, oneTimeDonationConfiguration,
|
||||
List.of(stripeManager, braintreeManager), 1234L);
|
||||
LOGIN_PURCHASE_CONFIGURATION, List.of(stripeManager, braintreeManager), 1234L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,6 +131,10 @@ public class ProductConfigurationGrpcServiceTest extends
|
||||
assertTrue(configuration.getBadgeLevelsOrThrow(1L).getBadgeDurationSeconds() > 0);
|
||||
assertEquals("GIFT", configuration.getBadgeLevelsOrThrow(100L).getBadgeId());
|
||||
assertTrue(configuration.getBadgeLevelsOrThrow(100L).getBadgeDurationSeconds() > 0);
|
||||
|
||||
assertEquals(LOGIN_PURCHASE_CONFIGURATION.level(), configuration.getLogin().getLevel());
|
||||
assertEquals(LOGIN_PURCHASE_CONFIGURATION.playProductId(), configuration.getLogin().getPlayProductId());
|
||||
assertEquals(LOGIN_PURCHASE_CONFIGURATION.appStoreProductId(), configuration.getLogin().getAppStoreProductId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -397,6 +397,11 @@ subscription: # configuration for Stripe subscriptions
|
||||
STRIPE: price_example # stripe Price ID
|
||||
BRAINTREE: plan_example # braintree Plan ID
|
||||
|
||||
loginPurchase:
|
||||
level: 300
|
||||
playProductId: login_example
|
||||
appStoreProductId: login_example
|
||||
|
||||
oneTimeDonations:
|
||||
sepaMaximumEuros: '10000'
|
||||
boost:
|
||||
|
||||
Reference in New Issue
Block a user