mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 15:18:05 +01:00
Don't cache authenticated accounts in memory
This commit is contained in:
@@ -66,8 +66,6 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/accounts")
|
||||
@@ -97,11 +95,14 @@ public class AccountController {
|
||||
@Path("/gcm/")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
|
||||
public void setGcmRegistrationId(@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid GcmRegistrationId registrationId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
if (Objects.equals(device.getGcmId(), registrationId.gcmRegistrationId())) {
|
||||
return;
|
||||
@@ -116,9 +117,12 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/gcm/")
|
||||
public void deleteGcmRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
Account account = auth.getAccount();
|
||||
Device device = auth.getAuthenticatedDevice();
|
||||
public void deleteGcmRegistrationId(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, device.getId(), d -> {
|
||||
d.setGcmId(null);
|
||||
@@ -131,11 +135,14 @@ public class AccountController {
|
||||
@Path("/apn/")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth,
|
||||
public void setApnRegistrationId(@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid ApnRegistrationId registrationId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
// Unlike FCM tokens, we need current "last updated" timestamps for APNs tokens and so update device records
|
||||
// unconditionally
|
||||
@@ -148,9 +155,12 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/apn/")
|
||||
public void deleteApnRegistrationId(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
Account account = auth.getAccount();
|
||||
Device device = auth.getAuthenticatedDevice();
|
||||
public void deleteApnRegistrationId(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, device.getId(), d -> {
|
||||
d.setApnId(null);
|
||||
@@ -166,17 +176,23 @@ public class AccountController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/registration_lock")
|
||||
public void setRegistrationLock(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
|
||||
SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
|
||||
public void setRegistrationLock(@Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {
|
||||
final SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock());
|
||||
|
||||
accounts.update(auth.getAccount(),
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.update(account,
|
||||
a -> a.setRegistrationLock(credentials.hash(), credentials.salt()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/registration_lock")
|
||||
public void removeRegistrationLock(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
accounts.update(auth.getAccount(), a -> a.setRegistrationLock(null, null));
|
||||
public void removeRegistrationLock(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
accounts.update(account, a -> a.setRegistrationLock(null, null));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -190,7 +206,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "204", description = "Device name changed successfully")
|
||||
@ApiResponse(responseCode = "404", description = "No device found with the given ID")
|
||||
@ApiResponse(responseCode = "403", description = "Not authorized to change the name of the device with the given ID")
|
||||
public void setName(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public void setName(@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final DeviceName deviceName,
|
||||
|
||||
@Nullable
|
||||
@@ -199,15 +215,16 @@ public class AccountController {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
final Byte deviceId) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final byte targetDeviceId = deviceId == null ? auth.getAuthenticatedDevice().getId() : deviceId;
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final byte targetDeviceId = deviceId == null ? auth.getDeviceId() : deviceId;
|
||||
|
||||
if (account.getDevice(targetDeviceId).isEmpty()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
final boolean mayChangeName = auth.getAuthenticatedDevice().isPrimary() ||
|
||||
auth.getAuthenticatedDevice().getId() == targetDeviceId;
|
||||
final boolean mayChangeName = auth.getDeviceId() == Device.PRIMARY_ID || auth.getDeviceId() == targetDeviceId;
|
||||
|
||||
if (!mayChangeName) {
|
||||
throw new ForbiddenException();
|
||||
@@ -221,14 +238,14 @@ public class AccountController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public void setAccountAttributes(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent,
|
||||
@NotNull @Valid AccountAttributes attributes) {
|
||||
final Account account = auth.getAccount();
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Account updatedAccount = accounts.update(account, a -> {
|
||||
a.getDevice(deviceId).ifPresent(d -> {
|
||||
a.getDevice(auth.getDeviceId()).ifPresent(d -> {
|
||||
d.setFetchesMessages(attributes.getFetchesMessages());
|
||||
d.setName(attributes.getName());
|
||||
d.setLastSeen(Util.todayInMillis());
|
||||
@@ -252,8 +269,11 @@ public class AccountController {
|
||||
@GET
|
||||
@Path("/whoami")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public AccountIdentityResponse whoAmI(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return AccountIdentityResponseBuilder.fromAccount(auth.getAccount());
|
||||
public AccountIdentityResponse whoAmI(@Auth final AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return AccountIdentityResponseBuilder.fromAccount(account);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@@ -267,8 +287,11 @@ public class AccountController {
|
||||
)
|
||||
@ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public CompletableFuture<Response> deleteUsernameHash(@Mutable @Auth final AuthenticatedDevice auth) {
|
||||
return accounts.clearUsernameHash(auth.getAccount())
|
||||
public CompletableFuture<Response> deleteUsernameHash(@Auth final AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.clearUsernameHash(account)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
@@ -289,10 +312,13 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<ReserveUsernameHashResponse> reserveUsernameHash(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
for (final byte[] hash : usernameRequest.usernameHashes()) {
|
||||
if (hash.length != USERNAME_HASH_LENGTH) {
|
||||
@@ -300,7 +326,7 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
return accounts.reserveUsernameHash(auth.getAccount(), usernameRequest.usernameHashes())
|
||||
return accounts.reserveUsernameHash(account, usernameRequest.usernameHashes())
|
||||
.thenApply(reservation -> new ReserveUsernameHashResponse(reservation.reservedUsernameHash()))
|
||||
.exceptionally(throwable -> {
|
||||
if (ExceptionUtils.unwrap(throwable) instanceof UsernameHashNotAvailableException) {
|
||||
@@ -329,18 +355,21 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<UsernameHashResponse> confirmUsernameHash(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) {
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash());
|
||||
} catch (final BaseUsernameException e) {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
return rateLimiters.getUsernameSetLimiter().validateAsync(auth.getAccount().getUuid())
|
||||
return rateLimiters.getUsernameSetLimiter().validateAsync(account.getUuid())
|
||||
.thenCompose(ignored -> accounts.confirmReservedUsernameHash(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
confirmRequest.usernameHash(),
|
||||
confirmRequest.encryptedUsername()))
|
||||
.thenApply(updatedAccount -> new UsernameHashResponse(updatedAccount.getUsernameHash()
|
||||
@@ -374,7 +403,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "400", description = "Request must not be authenticated.")
|
||||
@ApiResponse(responseCode = "404", description = "Account not found for the given username.")
|
||||
public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@PathParam("usernameHash") final String usernameHash) {
|
||||
|
||||
requireNotAuthenticated(maybeAuthenticatedAccount);
|
||||
@@ -413,12 +442,14 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public UsernameLinkHandle updateUsernameLink(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final EncryptedUsername encryptedUsername) throws RateLimitExceededException {
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccountIdentifier());
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
// check if username hash is set for the account
|
||||
if (account.getUsernameHash().isEmpty()) {
|
||||
@@ -431,7 +462,7 @@ public class AccountController {
|
||||
} else {
|
||||
usernameLinkHandle = UUID.randomUUID();
|
||||
}
|
||||
updateUsernameLink(auth.getAccount(), usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
|
||||
updateUsernameLink(account, usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());
|
||||
return new UsernameLinkHandle(usernameLinkHandle);
|
||||
}
|
||||
|
||||
@@ -447,10 +478,14 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "204", description = "Username Link successfully deleted.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public void deleteUsernameLink(@Mutable @Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
|
||||
public void deleteUsernameLink(@Auth final AuthenticatedDevice auth) throws RateLimitExceededException {
|
||||
// check ratelimiter for username link operations
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccount().getUuid());
|
||||
clearUsernameLink(auth.getAccount());
|
||||
rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.getAccountIdentifier());
|
||||
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
clearUsernameLink(account);
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -470,7 +505,7 @@ public class AccountController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CompletableFuture<EncryptedUsername> lookupUsernameLink(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,
|
||||
@PathParam("uuid") final UUID usernameLinkHandle) {
|
||||
|
||||
requireNotAuthenticated(maybeAuthenticatedAccount);
|
||||
@@ -496,7 +531,7 @@ public class AccountController {
|
||||
@Path("/account/{identifier}")
|
||||
@RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)
|
||||
public Response accountExists(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description = "An ACI or PNI account identifier to check")
|
||||
@PathParam("identifier") final ServiceIdentifier accountIdentifier) {
|
||||
@@ -511,8 +546,11 @@ public class AccountController {
|
||||
|
||||
@DELETE
|
||||
@Path("/me")
|
||||
public CompletableFuture<Response> deleteAccount(@Mutable @Auth AuthenticatedDevice auth) {
|
||||
return accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
public CompletableFuture<Response> deleteAccount(@Auth AuthenticatedDevice auth) {
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.delete(account, AccountsManager.DeletionReason.USER_REQUEST).thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
private void clearUsernameLink(final Account account) {
|
||||
|
||||
@@ -55,8 +55,7 @@ import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
@Path("/v2/accounts")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Account")
|
||||
@@ -101,12 +100,12 @@ public class AccountControllerV2 {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public AccountIdentityResponse changeNumber(@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@NotNull @Valid final ChangeNumberRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
|
||||
@Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException {
|
||||
|
||||
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
|
||||
if (authenticatedDevice.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -116,8 +115,11 @@ public class AccountControllerV2 {
|
||||
|
||||
final String number = request.number();
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
// Only verify and check reglock if there's a data change to be made...
|
||||
if (!authenticatedDevice.getAccount().getNumber().equals(number)) {
|
||||
if (!account.getNumber().equals(number)) {
|
||||
|
||||
rateLimiters.getRegistrationLimiter().validate(number);
|
||||
|
||||
@@ -139,7 +141,7 @@ public class AccountControllerV2 {
|
||||
// ...but always attempt to make the change in case a client retries and needs to re-send messages
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.changeNumber(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
request.number(),
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
@@ -185,11 +187,11 @@ public class AccountControllerV2 {
|
||||
content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))
|
||||
@ApiResponse(responseCode = "413", description = "One or more device messages was too large")
|
||||
public AccountIdentityResponse distributePhoneNumberIdentityKeys(
|
||||
@Mutable @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString,
|
||||
@NotNull @Valid final PhoneNumberIdentityKeyDistributionRequest request) {
|
||||
|
||||
if (!authenticatedDevice.getAuthenticatedDevice().isPrimary()) {
|
||||
if (authenticatedDevice.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -197,9 +199,12 @@ public class AccountControllerV2 {
|
||||
throw new WebApplicationException("Invalid signature", 422);
|
||||
}
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
final Account updatedAccount = changeNumberManager.updatePniKeys(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
request.pniIdentityKey(),
|
||||
request.devicePniSignedPrekeys(),
|
||||
request.devicePniPqLastResortPrekeys(),
|
||||
@@ -235,10 +240,13 @@ public class AccountControllerV2 {
|
||||
@Operation(summary = "Sets whether the account should be discoverable by phone number in the directory.")
|
||||
@ApiResponse(responseCode = "204", description = "The setting was successfully updated.")
|
||||
public void setPhoneNumberDiscoverability(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability
|
||||
) {
|
||||
accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber(
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(
|
||||
phoneNumberDiscoverability.discoverableByPhoneNumber()));
|
||||
}
|
||||
|
||||
@@ -249,9 +257,10 @@ public class AccountControllerV2 {
|
||||
@ApiResponse(responseCode = "200",
|
||||
description = "Response with data report. A plain text representation is a field in the response.",
|
||||
useReturnTypeSchema = true)
|
||||
public AccountDataReportResponse getAccountDataReport(@ReadOnly @Auth final AuthenticatedDevice auth) {
|
||||
public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedDevice auth) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(),
|
||||
new AccountDataReportResponse.AccountAndDevicesDataReport(
|
||||
|
||||
@@ -37,6 +37,7 @@ import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.io.IOException;
|
||||
@@ -71,14 +72,15 @@ import org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;
|
||||
import org.whispersystems.textsecuregcm.entities.RemoteAttachment;
|
||||
import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.BackupAuthCredentialAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Path("/v1/archives")
|
||||
@@ -88,14 +90,18 @@ public class ArchiveController {
|
||||
public final static String X_SIGNAL_ZK_AUTH = "X-Signal-ZK-Auth";
|
||||
public final static String X_SIGNAL_ZK_AUTH_SIGNATURE = "X-Signal-ZK-Auth-Signature";
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final BackupAuthManager backupAuthManager;
|
||||
private final BackupManager backupManager;
|
||||
private final BackupMetrics backupMetrics;
|
||||
|
||||
public ArchiveController(
|
||||
final AccountsManager accountsManager,
|
||||
final BackupAuthManager backupAuthManager,
|
||||
final BackupManager backupManager,
|
||||
final BackupMetrics backupMetrics) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.backupAuthManager = backupAuthManager;
|
||||
this.backupManager = backupManager;
|
||||
this.backupMetrics = backupMetrics;
|
||||
@@ -138,13 +144,22 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "403", description = "The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited. Too many attempts to change the backup-id have been made")
|
||||
public CompletionStage<Response> setBackupId(
|
||||
@Mutable @Auth final AuthenticatedDevice account,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Valid @NotNull final SetBackupIdRequest setBackupIdRequest) throws RateLimitExceededException {
|
||||
return this.backupAuthManager
|
||||
.commitBackupId(account.getAccount(), account.getAuthenticatedDevice(),
|
||||
setBackupIdRequest.messagesBackupAuthCredentialRequest,
|
||||
setBackupIdRequest.mediaBackupAuthCredentialRequest)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return backupAuthManager
|
||||
.commitBackupId(account, device, setBackupIdRequest.messagesBackupAuthCredentialRequest,
|
||||
setBackupIdRequest.mediaBackupAuthCredentialRequest)
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
public record RedeemBackupReceiptRequest(
|
||||
@@ -188,12 +203,17 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "409", description = "The target account does not have a backup-id commitment")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> redeemReceipt(
|
||||
@Mutable @Auth final AuthenticatedDevice account,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest) {
|
||||
return this.backupAuthManager.redeemReceipt(
|
||||
account.getAccount(),
|
||||
redeemBackupReceiptRequest.receiptCredentialPresentation())
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation())
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
public record BackupAuthCredentialsResponse(
|
||||
@@ -252,7 +272,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "404", description = "Could not find an existing blinded backup id")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<BackupAuthCredentialsResponse> getBackupZKCredentials(
|
||||
@Mutable @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice authenticatedDevice,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @QueryParam("redemptionStartSeconds") Long startSeconds,
|
||||
@NotNull @QueryParam("redemptionEndSeconds") Long endSeconds) {
|
||||
@@ -260,27 +280,33 @@ public class ArchiveController {
|
||||
final Map<BackupCredentialType, List<BackupAuthCredentialsResponse.BackupAuthCredential>> credentialsByType =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
auth.getAccount(),
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> {
|
||||
backupMetrics.updateGetCredentialCounter(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
credentialType,
|
||||
credentials.size());
|
||||
credentialsByType.put(credentialType, credentials.stream()
|
||||
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
credential.credential().serialize(),
|
||||
credential.redemptionTime().getEpochSecond()))
|
||||
.toList());
|
||||
}))
|
||||
.toArray(CompletableFuture[]::new))
|
||||
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
|
||||
Map.Entry::getValue))));
|
||||
return accountsManager.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return CompletableFuture.allOf(Arrays.stream(BackupCredentialType.values())
|
||||
.map(credentialType -> this.backupAuthManager.getBackupAuthCredentials(
|
||||
account,
|
||||
credentialType,
|
||||
Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds))
|
||||
.thenAccept(credentials -> {
|
||||
backupMetrics.updateGetCredentialCounter(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
credentialType,
|
||||
credentials.size());
|
||||
credentialsByType.put(credentialType, credentials.stream()
|
||||
.map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(
|
||||
credential.credential().serialize(),
|
||||
credential.redemptionTime().getEpochSecond()))
|
||||
.toList());
|
||||
}))
|
||||
.toArray(CompletableFuture[]::new))
|
||||
.thenApply(ignored -> new BackupAuthCredentialsResponse(credentialsByType.entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),
|
||||
Map.Entry::getValue))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +369,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<ReadAuthResponse> readAuth(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -395,7 +421,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<BackupInfoResponse> backupInfo(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -441,7 +467,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "204", description = "The public key was set")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> setPublicKey(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@NotNull
|
||||
@@ -481,7 +507,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<UploadDescriptorResponse> backup(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -518,7 +544,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<UploadDescriptorResponse> uploadTemporaryAttachment(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
|
||||
@@ -606,7 +632,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<CopyMediaResponse> copyMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -705,7 +731,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> copyMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -744,7 +770,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> refresh(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -811,7 +837,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<ListResponse> listMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -867,7 +893,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> deleteMedia(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
@@ -904,7 +930,7 @@ public class ArchiveController {
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
@ApiResponseZkAuth
|
||||
public CompletionStage<Response> deleteBackup(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> account,
|
||||
@Auth final Optional<AuthenticatedDevice> account,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
|
||||
@Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))
|
||||
|
||||
@@ -26,7 +26,6 @@ import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
|
||||
/**
|
||||
@@ -78,11 +77,11 @@ public class AttachmentControllerV4 {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public AttachmentDescriptorV3 getAttachmentUploadForm(@ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiter.validate(auth.getAccount().getUuid());
|
||||
rateLimiter.validate(auth.getAccountIdentifier());
|
||||
final String key = generateAttachmentKey();
|
||||
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccount().getUuid(), CDN3_EXPERIMENT_NAME);
|
||||
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.getAccountIdentifier(), CDN3_EXPERIMENT_NAME);
|
||||
int cdn = useCdn3 ? 3 : 2;
|
||||
final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);
|
||||
return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation());
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateCallLinkCredential;
|
||||
import org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/call-link")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink")
|
||||
@@ -52,11 +51,11 @@ public class CallLinkController {
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
public CreateCallLinkCredential getCreateAuth(
|
||||
final @ReadOnly @Auth AuthenticatedDevice auth,
|
||||
final @Auth AuthenticatedDevice auth,
|
||||
final @NotNull @Valid GetCreateCallLinkCredentialsRequest request
|
||||
) throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
final Instant truncatedDayTimestamp = Instant.now().truncatedTo(ChronoUnit.DAYS);
|
||||
|
||||
@@ -68,7 +67,7 @@ public class CallLinkController {
|
||||
}
|
||||
|
||||
return new CreateCallLinkCredential(
|
||||
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.getAccount().getUuid()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
|
||||
createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.getAccountIdentifier()), truncatedDayTimestamp, genericServerSecretParams).serialize(),
|
||||
truncatedDayTimestamp.getEpochSecond()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,9 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
|
||||
@Path("/v2/calling")
|
||||
@@ -56,11 +54,10 @@ public class CallRoutingControllerV2 {
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public GetCallingRelaysResponse getCallingRelays(final @ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public GetCallingRelaysResponse getCallingRelays(final @Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException, IOException {
|
||||
|
||||
final UUID aci = auth.getAccount().getUuid();
|
||||
rateLimiters.getCallEndpointLimiter().validate(aci);
|
||||
rateLimiters.getCallEndpointLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
try {
|
||||
return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));
|
||||
|
||||
@@ -8,18 +8,18 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
@@ -38,13 +38,16 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
|
||||
import org.whispersystems.textsecuregcm.entities.DeliveryCertificate;
|
||||
import org.whispersystems.textsecuregcm.entities.GroupCredentials;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/certificate")
|
||||
@Tag(name = "Certificate")
|
||||
public class CertificateController {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final CertificateGenerator certificateGenerator;
|
||||
private final ServerZkAuthOperations serverZkAuthOperations;
|
||||
private final GenericServerSecretParams genericServerSecretParams;
|
||||
@@ -56,10 +59,13 @@ public class CertificateController {
|
||||
private static final String INCLUDE_E164_TAG_NAME = "includeE164";
|
||||
|
||||
public CertificateController(
|
||||
final AccountsManager accountsManager,
|
||||
@Nonnull CertificateGenerator certificateGenerator,
|
||||
@Nonnull ServerZkAuthOperations serverZkAuthOperations,
|
||||
@Nonnull GenericServerSecretParams genericServerSecretParams,
|
||||
@Nonnull Clock clock) {
|
||||
|
||||
this.accountsManager = accountsManager;
|
||||
this.certificateGenerator = Objects.requireNonNull(certificateGenerator);
|
||||
this.serverZkAuthOperations = Objects.requireNonNull(serverZkAuthOperations);
|
||||
this.genericServerSecretParams = genericServerSecretParams;
|
||||
@@ -69,23 +75,25 @@ public class CertificateController {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/delivery")
|
||||
public DeliveryCertificate getDeliveryCertificate(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
|
||||
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
|
||||
throws InvalidKeyException {
|
||||
|
||||
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
|
||||
.increment();
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new DeliveryCertificate(
|
||||
certificateGenerator.createFor(auth.getAccount(), auth.getAuthenticatedDevice(), includeE164));
|
||||
certificateGenerator.createFor(account, auth.getDeviceId(), includeE164));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/auth/group")
|
||||
public GroupCredentials getGroupAuthenticationCredentials(
|
||||
@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@QueryParam("redemptionStartSeconds") long startSeconds,
|
||||
@QueryParam("redemptionEndSeconds") long endSeconds) {
|
||||
|
||||
@@ -102,13 +110,16 @@ public class CertificateController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final List<GroupCredentials.GroupCredential> credentials = new ArrayList<>();
|
||||
final List<GroupCredentials.CallLinkAuthCredential> callLinkAuthCredentials = new ArrayList<>();
|
||||
|
||||
Instant redemption = redemptionStart;
|
||||
|
||||
ServiceId.Aci aci = new ServiceId.Aci(auth.getAccount().getUuid());
|
||||
ServiceId.Pni pni = new ServiceId.Pni(auth.getAccount().getPhoneNumberIdentifier());
|
||||
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
|
||||
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
|
||||
|
||||
while (!redemption.isAfter(redemptionEnd)) {
|
||||
AuthCredentialWithPniResponse authCredentialWithPni = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption);
|
||||
|
||||
@@ -25,6 +25,7 @@ import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -40,12 +41,14 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;
|
||||
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
@Path("/v1/challenge")
|
||||
@Tag(name = "Challenge")
|
||||
public class ChallengeController {
|
||||
|
||||
private final AccountsManager accountsManager;
|
||||
private final RateLimitChallengeManager rateLimitChallengeManager;
|
||||
private final ChallengeConstraintChecker challengeConstraintChecker;
|
||||
|
||||
@@ -53,8 +56,10 @@ public class ChallengeController {
|
||||
private static final String CHALLENGE_TYPE_TAG = "type";
|
||||
|
||||
public ChallengeController(
|
||||
final AccountsManager accountsManager,
|
||||
final RateLimitChallengeManager rateLimitChallengeManager,
|
||||
final ChallengeConstraintChecker challengeConstraintChecker) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.rateLimitChallengeManager = rateLimitChallengeManager;
|
||||
this.challengeConstraintChecker = challengeConstraintChecker;
|
||||
}
|
||||
@@ -77,15 +82,18 @@ public class ChallengeController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response handleChallengeResponse(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public Response handleChallengeResponse(@Auth final AuthenticatedDevice auth,
|
||||
@Valid final AnswerChallengeRequest answerRequest,
|
||||
@Context ContainerRequestContext requestContext,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
|
||||
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
|
||||
requestContext, auth.getAccount());
|
||||
requestContext, account);
|
||||
try {
|
||||
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "push");
|
||||
@@ -93,14 +101,14 @@ public class ChallengeController {
|
||||
if (!constraints.pushPermitted()) {
|
||||
return Response.status(429).build();
|
||||
}
|
||||
rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge());
|
||||
rateLimitChallengeManager.answerPushChallenge(account, pushChallengeRequest.getChallenge());
|
||||
} else if (answerRequest instanceof AnswerCaptchaChallengeRequest captchaChallengeRequest) {
|
||||
tags = tags.and(CHALLENGE_TYPE_TAG, "captcha");
|
||||
|
||||
final String remoteAddress = (String) requestContext.getProperty(
|
||||
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
|
||||
boolean success = rateLimitChallengeManager.answerCaptchaChallenge(
|
||||
auth.getAccount(),
|
||||
account,
|
||||
captchaChallengeRequest.getCaptcha(),
|
||||
remoteAddress,
|
||||
userAgent,
|
||||
@@ -126,7 +134,7 @@ public class ChallengeController {
|
||||
summary = "Request a push challenge",
|
||||
description = """
|
||||
Clients may proactively request a push challenge by making an empty POST request. Push challenges will only be
|
||||
sent to the requesting account’s main device. When the push is received it may be provided as proof of completed
|
||||
sent to the requesting account’s main device. When the push is received it may be provided as proof of completed
|
||||
challenge to /v1/challenge.
|
||||
APNs challenge payloads will be formatted as follows:
|
||||
```
|
||||
@@ -140,12 +148,12 @@ public class ChallengeController {
|
||||
"rateLimitChallenge": "{CHALLENGE_TOKEN}"
|
||||
}
|
||||
```
|
||||
FCM challenge payloads will be formatted as follows:
|
||||
FCM challenge payloads will be formatted as follows:
|
||||
```
|
||||
{"rateLimitChallenge": "{CHALLENGE_TOKEN}"}
|
||||
```
|
||||
|
||||
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
|
||||
Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must
|
||||
implement an exponential back-off system and limit the total number of retries.
|
||||
"""
|
||||
)
|
||||
@@ -163,15 +171,18 @@ public class ChallengeController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public Response requestPushChallenge(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public Response requestPushChallenge(@Auth final AuthenticatedDevice auth,
|
||||
@Context ContainerRequestContext requestContext) {
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
|
||||
requestContext, auth.getAccount());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
|
||||
if (!constraints.pushPermitted()) {
|
||||
return Response.status(429).build();
|
||||
}
|
||||
try {
|
||||
rateLimitChallengeManager.sendPushChallenge(auth.getAccount());
|
||||
rateLimitChallengeManager.sendPushChallenge(account);
|
||||
return Response.status(200).build();
|
||||
} catch (final NotPushRegisteredException e) {
|
||||
return Response.status(404).build();
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.backup.BackupAuthManager;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;
|
||||
@@ -41,7 +42,6 @@ import org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyEx
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;
|
||||
import org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
/**
|
||||
* Process platform device attestations.
|
||||
@@ -55,6 +55,7 @@ import org.whispersystems.websocket.auth.ReadOnly;
|
||||
public class DeviceCheckController {
|
||||
|
||||
private final Clock clock;
|
||||
private final AccountsManager accountsManager;
|
||||
private final BackupAuthManager backupAuthManager;
|
||||
private final AppleDeviceCheckManager deviceCheckManager;
|
||||
private final RateLimiters rateLimiters;
|
||||
@@ -63,12 +64,14 @@ public class DeviceCheckController {
|
||||
|
||||
public DeviceCheckController(
|
||||
final Clock clock,
|
||||
final AccountsManager accountsManager,
|
||||
final BackupAuthManager backupAuthManager,
|
||||
final AppleDeviceCheckManager deviceCheckManager,
|
||||
final RateLimiters rateLimiters,
|
||||
final long backupRedemptionLevel,
|
||||
final Duration backupRedemptionDuration) {
|
||||
this.clock = clock;
|
||||
this.accountsManager = accountsManager;
|
||||
this.backupAuthManager = backupAuthManager;
|
||||
this.deviceCheckManager = deviceCheckManager;
|
||||
this.backupRedemptionLevel = backupRedemptionLevel;
|
||||
@@ -94,14 +97,17 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "200", description = "The response body includes a challenge")
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
@ManagedAsync
|
||||
public ChallengeResponse attestChallenge(@ReadOnly @Auth AuthenticatedDevice authenticatedDevice)
|
||||
public ChallengeResponse attestChallenge(@Auth AuthenticatedDevice authenticatedDevice)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
|
||||
.validate(authenticatedDevice.getAccount().getUuid());
|
||||
.validate(authenticatedDevice.getAccountIdentifier());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new ChallengeResponse(deviceCheckManager.createChallenge(
|
||||
AppleDeviceCheckManager.ChallengeType.ATTEST,
|
||||
authenticatedDevice.getAccount()));
|
||||
account));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -125,7 +131,7 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account")
|
||||
@ManagedAsync
|
||||
public void attest(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -135,8 +141,11 @@ public class DeviceCheckController {
|
||||
@RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))")
|
||||
@NotNull final byte[] attestation) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
deviceCheckManager.registerAttestation(authenticatedDevice.getAccount(), parseKeyId(keyId), attestation);
|
||||
deviceCheckManager.registerAttestation(account, parseKeyId(keyId), attestation);
|
||||
} catch (TooManyKeysException e) {
|
||||
throw new WebApplicationException(Response.status(413).build());
|
||||
} catch (ChallengeNotFoundException e) {
|
||||
@@ -166,17 +175,19 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "429", description = "Ratelimited.")
|
||||
@ManagedAsync
|
||||
public ChallengeResponse assertChallenge(
|
||||
@ReadOnly @Auth AuthenticatedDevice authenticatedDevice,
|
||||
@Auth AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Parameter(schema = @Schema(description = "The type of action you will make an assertion for",
|
||||
allowableValues = {"backup"},
|
||||
implementation = String.class))
|
||||
@QueryParam("action") Action action) throws RateLimitExceededException {
|
||||
rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)
|
||||
.validate(authenticatedDevice.getAccount().getUuid());
|
||||
return new ChallengeResponse(
|
||||
deviceCheckManager.createChallenge(toChallengeType(action),
|
||||
authenticatedDevice.getAccount()));
|
||||
.validate(authenticatedDevice.getAccountIdentifier());
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return new ChallengeResponse(deviceCheckManager.createChallenge(toChallengeType(action), account));
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -199,7 +210,7 @@ public class DeviceCheckController {
|
||||
@ApiResponse(responseCode = "401", description = "The assertion could not be verified")
|
||||
@ManagedAsync
|
||||
public void assertion(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@@ -218,9 +229,12 @@ public class DeviceCheckController {
|
||||
@RequestBody(description = "The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))")
|
||||
@NotNull final byte[] assertion) {
|
||||
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
try {
|
||||
deviceCheckManager.validateAssert(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
parseKeyId(keyId),
|
||||
toChallengeType(request.assertionRequest().action()),
|
||||
request.assertionRequest().challenge(),
|
||||
@@ -237,7 +251,7 @@ public class DeviceCheckController {
|
||||
// The request assertion was validated, execute it
|
||||
switch (request.assertionRequest().action()) {
|
||||
case BACKUP -> backupAuthManager.extendBackupVoucher(
|
||||
authenticatedDevice.getAccount(),
|
||||
account,
|
||||
new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)))
|
||||
.join();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.Duration;
|
||||
@@ -51,11 +50,9 @@ import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.glassfish.jersey.server.ContainerRequest;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;
|
||||
import org.whispersystems.textsecuregcm.auth.LinkedDeviceRefreshRequirementProvider;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.DeviceInfo;
|
||||
@@ -87,11 +84,10 @@ import org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.EnumMapUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.LinkDeviceToken;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/devices")
|
||||
@Tag(name = "Devices")
|
||||
@@ -152,10 +148,10 @@ public class DeviceController {
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public DeviceInfoList getDevices(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public DeviceInfoList getDevices(@Auth AuthenticatedDevice auth) {
|
||||
// Devices may change their own names (and primary devices may change the names of linked devices) and so the device
|
||||
// state associated with the authenticated account may be stale. Fetch a fresh copy to compensate.
|
||||
return accounts.getByAccountIdentifier(auth.getAccount().getIdentifier(IdentityType.ACI))
|
||||
return accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.map(account -> new DeviceInfoList(account.getDevices().stream()
|
||||
.map(DeviceInfo::forDevice)
|
||||
.toList()))
|
||||
@@ -166,9 +162,8 @@ public class DeviceController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/{device_id}")
|
||||
@ChangesLinkedDevices
|
||||
public void removeDevice(@Mutable @Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
|
||||
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID &&
|
||||
auth.getAuthenticatedDevice().getId() != deviceId) {
|
||||
public void removeDevice(@Auth AuthenticatedDevice auth, @PathParam("device_id") byte deviceId) {
|
||||
if (auth.getDeviceId() != Device.PRIMARY_ID && auth.getDeviceId() != deviceId) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -176,13 +171,16 @@ public class DeviceController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
accounts.removeDevice(auth.getAccount(), deviceId).join();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accounts.removeDevice(account, deviceId).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a signed device-linking token. Generally, primary devices will include the signed device-linking token in
|
||||
* a provisioning message to a new device, and then the new device will include the token in its request to
|
||||
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest, ContainerRequest)}.
|
||||
* {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest)}.
|
||||
*
|
||||
* @param auth the authenticated account/device
|
||||
*
|
||||
@@ -207,10 +205,11 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
|
||||
name = "Retry-After",
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public LinkDeviceToken createDeviceToken(@ReadOnly @Auth AuthenticatedDevice auth)
|
||||
public LinkDeviceToken createDeviceToken(@Auth AuthenticatedDevice auth)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
rateLimiters.getAllocateDeviceLimiter().validate(account.getUuid());
|
||||
|
||||
@@ -224,7 +223,7 @@ public class DeviceController {
|
||||
throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);
|
||||
}
|
||||
|
||||
if (auth.getAuthenticatedDevice().getId() != Device.PRIMARY_ID) {
|
||||
if (auth.getDeviceId() != Device.PRIMARY_ID) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -252,8 +251,7 @@ public class DeviceController {
|
||||
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public LinkDeviceResponse linkDevice(@HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent,
|
||||
@NotNull @Valid LinkDeviceRequest linkDeviceRequest,
|
||||
@Context ContainerRequest containerRequest)
|
||||
@NotNull @Valid LinkDeviceRequest linkDeviceRequest)
|
||||
throws RateLimitExceededException, DeviceLimitExceededException {
|
||||
|
||||
final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())
|
||||
@@ -279,11 +277,6 @@ public class DeviceController {
|
||||
throw new WebApplicationException(Response.status(422).build());
|
||||
}
|
||||
|
||||
// Normally, the "do we need to refresh somebody's websockets" listener can do this on its own. In this case,
|
||||
// we're not using the conventional authentication system, and so we need to give it a hint so it knows who the
|
||||
// active user is and what their device states look like.
|
||||
LinkedDeviceRefreshRequirementProvider.setAccount(containerRequest, account);
|
||||
|
||||
final int maxDeviceLimit = maxDeviceConfiguration.getOrDefault(account.getNumber(), MAX_DEVICES);
|
||||
|
||||
if (account.getDevices().size() >= maxDeviceLimit) {
|
||||
@@ -351,7 +344,7 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "400", description = "The given token identifier or timeout was invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Response> waitForLinkedDevice(
|
||||
@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@PathParam("tokenIdentifier")
|
||||
@Schema(description = "A 'link device' token identifier provided by the 'create link device token' endpoint")
|
||||
@@ -374,12 +367,18 @@ public class DeviceController {
|
||||
final AtomicInteger linkedDeviceListenerCounter = getCounterForLinkedDeviceListeners(userAgent);
|
||||
linkedDeviceListenerCounter.incrementAndGet();
|
||||
|
||||
return rateLimiters.getWaitForLinkedDeviceLimiter()
|
||||
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
|
||||
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier))
|
||||
.thenCompose(sample -> accounts.waitForNewLinkedDevice(
|
||||
authenticatedDevice.getAccount().getUuid(),
|
||||
authenticatedDevice.getAuthenticatedDevice(),
|
||||
return rateLimiters.getWaitForLinkedDeviceLimiter().validateAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier)
|
||||
.thenApply(sample -> new Pair<>(account, sample));
|
||||
})
|
||||
.thenCompose(accountAndSample -> accounts.waitForNewLinkedDevice(
|
||||
authenticatedDevice.getAccountIdentifier(),
|
||||
accountAndSample.first().getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
|
||||
tokenIdentifier,
|
||||
Duration.ofSeconds(timeoutSeconds))
|
||||
.thenApply(maybeDeviceInfo -> maybeDeviceInfo
|
||||
@@ -391,7 +390,7 @@ public class DeviceController {
|
||||
linkedDeviceListenerCounter.decrementAndGet();
|
||||
|
||||
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
|
||||
sample.stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
|
||||
accountAndSample.second().stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.register(Metrics.globalRegistry));
|
||||
@@ -410,14 +409,15 @@ public class DeviceController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/capabilities")
|
||||
public void setCapabilities(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public void setCapabilities(@Auth final AuthenticatedDevice auth,
|
||||
|
||||
@NotNull
|
||||
final Map<String, Boolean> capabilities) {
|
||||
|
||||
assert (auth.getAuthenticatedDevice() != null);
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
accounts.updateDevice(auth.getAccount(), deviceId,
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
accounts.updateDevice(account, auth.getDeviceId(),
|
||||
d -> d.setCapabilities(DeviceCapabilityAdapter.mapToSet(capabilities)));
|
||||
}
|
||||
|
||||
@@ -435,12 +435,13 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "200", description = "Public key stored successfully")
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
public CompletableFuture<Void> setPublicKey(@Mutable @Auth final AuthenticatedDevice auth,
|
||||
public CompletableFuture<Void> setPublicKey(@Auth final AuthenticatedDevice auth,
|
||||
final SetPublicKeyRequest setPublicKeyRequest) {
|
||||
|
||||
return clientPublicKeysManager.setPublicKey(auth.getAccount(),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
setPublicKeyRequest.publicKey());
|
||||
final Account account = accounts.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return clientPublicKeysManager.setPublicKey(account, auth.getDeviceId(), setPublicKeyRequest.publicKey());
|
||||
}
|
||||
|
||||
private static boolean isCapabilityDowngrade(final Account account, final Set<DeviceCapability> capabilities) {
|
||||
@@ -531,15 +532,21 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "204", description = "Success")
|
||||
@ApiResponse(responseCode = "422", description = "The request object could not be parsed or was otherwise invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Void> recordTransferArchiveUploaded(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public CompletionStage<Void> recordTransferArchiveUploaded(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
@NotNull @Valid final TransferArchiveUploadedRequest transferArchiveUploadedRequest) {
|
||||
|
||||
return rateLimiters.getUploadTransferArchiveLimiter()
|
||||
.validateAsync(authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI))
|
||||
.thenCompose(ignored -> accounts.recordTransferArchiveUpload(authenticatedDevice.getAccount(),
|
||||
transferArchiveUploadedRequest.destinationDeviceId(),
|
||||
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
|
||||
transferArchiveUploadedRequest.transferArchive()));
|
||||
.validateAsync(authenticatedDevice.getAccountIdentifier())
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return accounts.recordTransferArchiveUpload(account,
|
||||
transferArchiveUploadedRequest.destinationDeviceId(),
|
||||
Instant.ofEpochMilli(transferArchiveUploadedRequest.destinationDeviceCreated()),
|
||||
transferArchiveUploadedRequest.transferArchive());
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -558,7 +565,7 @@ public class DeviceController {
|
||||
@ApiResponse(responseCode = "204", description = "No transfer archive was uploaded before the call completed; clients may repeat the call to continue waiting")
|
||||
@ApiResponse(responseCode = "400", description = "The given timeout was invalid")
|
||||
@ApiResponse(responseCode = "429", description = "Rate-limited; try again after the prescribed delay")
|
||||
public CompletionStage<Response> waitForTransferArchive(@ReadOnly @Auth final AuthenticatedDevice authenticatedDevice,
|
||||
public CompletionStage<Response> waitForTransferArchive(@Auth final AuthenticatedDevice authenticatedDevice,
|
||||
|
||||
@QueryParam("timeout")
|
||||
@DefaultValue("30")
|
||||
@@ -575,24 +582,30 @@ public class DeviceController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent) {
|
||||
|
||||
|
||||
final String rateLimiterKey = authenticatedDevice.getAccount().getIdentifier(IdentityType.ACI) +
|
||||
":" + authenticatedDevice.getAuthenticatedDevice().getId();
|
||||
final String rateLimiterKey = authenticatedDevice.getAccountIdentifier() + ":" + authenticatedDevice.getDeviceId();
|
||||
|
||||
return rateLimiters.getWaitForTransferArchiveLimiter().validateAsync(rateLimiterKey)
|
||||
.thenCompose(ignored -> persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey))
|
||||
.thenCompose(sample -> accounts.waitForTransferArchive(authenticatedDevice.getAccount(),
|
||||
authenticatedDevice.getAuthenticatedDevice(),
|
||||
.thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.getAccountIdentifier()))
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
return persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey)
|
||||
.thenApply(sample -> new Pair<>(account, sample));
|
||||
})
|
||||
.thenCompose(accountAndSample -> accounts.waitForTransferArchive(accountAndSample.first(),
|
||||
accountAndSample.first().getDevice(authenticatedDevice.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),
|
||||
Duration.ofSeconds(timeoutSeconds))
|
||||
.thenApply(maybeTransferArchive -> maybeTransferArchive
|
||||
.map(transferArchive -> Response.status(Response.Status.OK).entity(transferArchive).build())
|
||||
.orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()))
|
||||
.whenComplete((response, throwable) -> {
|
||||
if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {
|
||||
sample.stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
|
||||
accountAndSample.second().stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)
|
||||
.publishPercentileHistogram(true)
|
||||
.tags(Tags.of(
|
||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||
primaryPlatformTag(authenticatedDevice.getAccount())))
|
||||
primaryPlatformTag(accountAndSample.first())))
|
||||
.register(Metrics.globalRegistry));
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -14,12 +14,10 @@ import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v2/directory")
|
||||
@Tag(name = "Directory")
|
||||
@@ -57,8 +55,7 @@ public class DirectoryV2Controller {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
public ExternalServiceCredentials getAuthToken(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
final UUID uuid = auth.getAccount().getUuid();
|
||||
return directoryServiceTokenGenerator.generateForUuid(uuid);
|
||||
public ExternalServiceCredentials getAuthToken(final @Auth AuthenticatedDevice auth) {
|
||||
return directoryServiceTokenGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
@@ -33,10 +34,10 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
|
||||
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.websocket.auth.Mutable;
|
||||
|
||||
@Path("/v1/donation")
|
||||
@Tag(name = "Donations")
|
||||
@@ -86,7 +87,7 @@ public class DonationController {
|
||||
""")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||
public CompletionStage<Response> redeemReceipt(
|
||||
@Mutable @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@NotNull @Valid final RedeemReceiptRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation;
|
||||
@@ -118,23 +119,29 @@ public class DonationController {
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
return redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid())
|
||||
.thenCompose(receiptMatched -> {
|
||||
if (!receiptMatched) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("receipt serial is already redeemed")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
|
||||
return accountsManager.updateAsync(auth.getAccount(), a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
return accountsManager.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return redeemedReceiptsManager.put(
|
||||
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccountIdentifier())
|
||||
.thenCompose(receiptMatched -> {
|
||||
if (!receiptMatched) {
|
||||
return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST)
|
||||
.entity("receipt serial is already redeemed")
|
||||
.type(MediaType.TEXT_PLAIN_TYPE)
|
||||
.build());
|
||||
}
|
||||
})
|
||||
.thenApply(ignored -> Response.ok().build());
|
||||
|
||||
return accountsManager.updateAsync(account, a -> {
|
||||
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));
|
||||
if (request.isPrimary()) {
|
||||
a.makeBadgePrimaryIfExists(clock, badgeId);
|
||||
}
|
||||
})
|
||||
.thenApply(ignored -> Response.ok().build());
|
||||
});
|
||||
});
|
||||
}).thenCompose(Function.identity());
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
|
||||
import java.util.List;
|
||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||
|
||||
public record GetCallingRelaysResponse(List<TurnToken> relays) {
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.WebSocketConnectionEventManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import org.whispersystems.websocket.session.WebSocketSession;
|
||||
import org.whispersystems.websocket.session.WebSocketSessionContext;
|
||||
|
||||
@@ -45,16 +44,16 @@ public class KeepAliveController {
|
||||
}
|
||||
|
||||
@GET
|
||||
public Response getKeepAlive(@ReadOnly @Auth Optional<AuthenticatedDevice> maybeAuth,
|
||||
public Response getKeepAlive(@Auth Optional<AuthenticatedDevice> maybeAuth,
|
||||
@WebSocketSession WebSocketSessionContext context) {
|
||||
|
||||
maybeAuth.ifPresent(auth -> {
|
||||
if (!webSocketConnectionEventManager.isLocallyPresent(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId())) {
|
||||
if (!webSocketConnectionEventManager.isLocallyPresent(auth.getAccountIdentifier(), auth.getDeviceId())) {
|
||||
|
||||
final Duration age = Duration.between(context.getClient().getCreated(), Instant.now());
|
||||
|
||||
logger.debug("***** No local subscription found for {}::{}; age = {}ms, User-Agent = {}",
|
||||
auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), age.toMillis(),
|
||||
auth.getAccountIdentifier(), auth.getDeviceId(), age.toMillis(),
|
||||
context.getClient().getUserAgent());
|
||||
|
||||
context.getClient().close(1000, "OK");
|
||||
|
||||
@@ -49,7 +49,6 @@ import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceCl
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/key-transparency")
|
||||
@Tag(name = "KeyTransparency")
|
||||
@@ -90,7 +89,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencySearchResponse search(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final KeyTransparencySearchRequest request) {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -142,7 +141,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencyMonitorResponse monitor(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final KeyTransparencyMonitorRequest request) {
|
||||
|
||||
// Disallow clients from making authenticated requests to this endpoint
|
||||
@@ -204,7 +203,7 @@ public class KeyTransparencyController {
|
||||
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public KeyTransparencyDistinguishedKeyResponse getDistinguishedKey(
|
||||
@ReadOnly @Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description = "The distinguished tree head size returned by a previously verified call")
|
||||
@QueryParam("lastTreeHeadSize") @Valid final Optional<@Positive Long> lastTreeHeadSize) {
|
||||
|
||||
@@ -74,7 +74,6 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.KeysManager;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v2/keys")
|
||||
@@ -111,16 +110,21 @@ public class KeysController {
|
||||
description = "Gets the number of one-time prekeys uploaded for this device and still available")
|
||||
@ApiResponse(responseCode = "200", description = "Body contains the number of available one-time prekeys for the device.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public CompletableFuture<PreKeyCount> getStatus(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public CompletableFuture<PreKeyCount> getStatus(@Auth final AuthenticatedDevice auth,
|
||||
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType) {
|
||||
|
||||
final CompletableFuture<Integer> ecCountFuture =
|
||||
keysManager.getEcCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final CompletableFuture<Integer> pqCountFuture =
|
||||
keysManager.getPqCount(auth.getAccount().getIdentifier(identityType), auth.getAuthenticatedDevice().getId());
|
||||
final CompletableFuture<Integer> ecCountFuture =
|
||||
keysManager.getEcCount(account.getIdentifier(identityType), auth.getDeviceId());
|
||||
|
||||
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
|
||||
final CompletableFuture<Integer> pqCountFuture =
|
||||
keysManager.getPqCount(account.getIdentifier(identityType), auth.getDeviceId());
|
||||
|
||||
return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);
|
||||
});
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -132,7 +136,7 @@ public class KeysController {
|
||||
@ApiResponse(responseCode = "403", description = "Attempt to change identity key from a non-primary device.")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||
public CompletableFuture<Response> setKeys(
|
||||
@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@RequestBody @NotNull @Valid final SetKeysRequest setKeysRequest,
|
||||
|
||||
@Parameter(allowEmptyValue=true)
|
||||
@@ -143,63 +147,70 @@ public class KeysController {
|
||||
@QueryParam("identity") @DefaultValue("aci") final IdentityType identityType,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
final Account account = auth.getAccount();
|
||||
final Device device = auth.getAuthenticatedDevice();
|
||||
final UUID identifier = account.getIdentifier(identityType);
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
|
||||
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getAuthenticatedDevice().isPrimary()));
|
||||
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
|
||||
final UUID identifier = account.getIdentifier(identityType);
|
||||
|
||||
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
|
||||
checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);
|
||||
|
||||
if (!setKeysRequest.preKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
|
||||
final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);
|
||||
final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.getDeviceId() == Device.PRIMARY_ID));
|
||||
final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());
|
||||
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.preKeys().size());
|
||||
if (!setKeysRequest.preKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec"));
|
||||
|
||||
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
|
||||
}
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
|
||||
if (setKeysRequest.signedPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
|
||||
.increment();
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.preKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
|
||||
}
|
||||
storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));
|
||||
}
|
||||
|
||||
if (!setKeysRequest.pqPreKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
if (setKeysRequest.signedPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "ec-signed")))
|
||||
.increment();
|
||||
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.pqPreKeys().size());
|
||||
storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));
|
||||
}
|
||||
|
||||
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
|
||||
}
|
||||
if (!setKeysRequest.pqPreKeys().isEmpty()) {
|
||||
final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber"));
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();
|
||||
|
||||
if (setKeysRequest.pqLastResortPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
|
||||
.increment();
|
||||
DistributionSummary.builder(STORE_KEY_BUNDLE_SIZE_DISTRIBUTION_NAME)
|
||||
.tags(tags)
|
||||
.publishPercentileHistogram()
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(setKeysRequest.pqPreKeys().size());
|
||||
|
||||
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
|
||||
}
|
||||
storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
if (setKeysRequest.pqLastResortPreKey() != null) {
|
||||
Metrics.counter(STORE_KEYS_COUNTER_NAME,
|
||||
Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, "kyber-last-resort")))
|
||||
.increment();
|
||||
|
||||
storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))
|
||||
.thenApply(Util.ASYNC_EMPTY_RESPONSE);
|
||||
});
|
||||
}
|
||||
|
||||
private void checkSignedPreKeySignatures(final SetKeysRequest setKeysRequest,
|
||||
@@ -253,64 +264,69 @@ public class KeysController {
|
||||
""")
|
||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||
public CompletableFuture<Response> checkKeys(
|
||||
@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
@Auth final AuthenticatedDevice auth,
|
||||
@RequestBody @NotNull @Valid final CheckKeysRequest checkKeysRequest) {
|
||||
|
||||
final UUID identifier = auth.getAccount().getIdentifier(checkKeysRequest.identityType());
|
||||
final byte deviceId = auth.getAuthenticatedDevice().getId();
|
||||
return accounts.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
|
||||
keysManager.getEcSignedPreKey(identifier, deviceId);
|
||||
final UUID identifier = account.getIdentifier(checkKeysRequest.identityType());
|
||||
final byte deviceId = auth.getDeviceId();
|
||||
|
||||
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
|
||||
keysManager.getLastResort(identifier, deviceId);
|
||||
final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =
|
||||
keysManager.getEcSignedPreKey(identifier, deviceId);
|
||||
|
||||
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
|
||||
.thenApply(ignored -> {
|
||||
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
|
||||
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
|
||||
final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =
|
||||
keysManager.getLastResort(identifier, deviceId);
|
||||
|
||||
final boolean digestsMatch;
|
||||
return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)
|
||||
.thenApply(ignored -> {
|
||||
final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();
|
||||
final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();
|
||||
|
||||
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
|
||||
final IdentityKey identityKey = auth.getAccount().getIdentityKey(checkKeysRequest.identityType());
|
||||
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
|
||||
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
|
||||
final boolean digestsMatch;
|
||||
|
||||
final MessageDigest messageDigest;
|
||||
if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {
|
||||
final IdentityKey identityKey = account.getIdentityKey(checkKeysRequest.identityType());
|
||||
final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();
|
||||
final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();
|
||||
|
||||
try {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
|
||||
}
|
||||
final MessageDigest messageDigest;
|
||||
|
||||
messageDigest.update(identityKey.serialize());
|
||||
try {
|
||||
messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
|
||||
}
|
||||
|
||||
{
|
||||
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
|
||||
ecSignedPreKeyIdBuffer.flip();
|
||||
messageDigest.update(identityKey.serialize());
|
||||
|
||||
messageDigest.update(ecSignedPreKeyIdBuffer);
|
||||
messageDigest.update(ecSignedPreKey.serializedPublicKey());
|
||||
}
|
||||
{
|
||||
final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());
|
||||
ecSignedPreKeyIdBuffer.flip();
|
||||
|
||||
{
|
||||
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
|
||||
lastResortKeyIdBuffer.flip();
|
||||
messageDigest.update(ecSignedPreKeyIdBuffer);
|
||||
messageDigest.update(ecSignedPreKey.serializedPublicKey());
|
||||
}
|
||||
|
||||
messageDigest.update(lastResortKeyIdBuffer);
|
||||
messageDigest.update(lastResortKey.serializedPublicKey());
|
||||
}
|
||||
{
|
||||
final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);
|
||||
lastResortKeyIdBuffer.putLong(lastResortKey.keyId());
|
||||
lastResortKeyIdBuffer.flip();
|
||||
|
||||
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
|
||||
} else {
|
||||
digestsMatch = false;
|
||||
}
|
||||
messageDigest.update(lastResortKeyIdBuffer);
|
||||
messageDigest.update(lastResortKey.serializedPublicKey());
|
||||
}
|
||||
|
||||
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
|
||||
digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());
|
||||
} else {
|
||||
digestsMatch = false;
|
||||
}
|
||||
|
||||
return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,7 +343,7 @@ public class KeysController {
|
||||
name = "Retry-After",
|
||||
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public PreKeyResponse getDeviceKeys(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
|
||||
|
||||
@@ -340,15 +356,18 @@ public class KeysController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
|
||||
if (maybeAuthenticatedDevice.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
|
||||
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
final Optional<Account> account = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> account = maybeAuthenticatedDevice
|
||||
.map(authenticatedDevice -> accounts.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
|
||||
|
||||
if (groupSendToken.isPresent()) {
|
||||
if (auth.isPresent() || accessKey.isPresent()) {
|
||||
if (maybeAuthenticatedDevice.isPresent() || accessKey.isPresent()) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
try {
|
||||
@@ -364,7 +383,7 @@ public class KeysController {
|
||||
|
||||
if (account.isPresent()) {
|
||||
rateLimiters.getPreKeysLimiter().validate(
|
||||
account.get().getUuid() + "." + auth.get().getAuthenticatedDevice().getId() + "__" + targetIdentifier.uuid()
|
||||
account.get().getUuid() + "." + maybeAuthenticatedDevice.get().getDeviceId() + "__" + targetIdentifier.uuid()
|
||||
+ "." + deviceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ import org.whispersystems.textsecuregcm.spam.SpamChecker;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
@@ -112,7 +113,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||
import org.whispersystems.websocket.WebsocketHeaders;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@@ -236,7 +236,7 @@ public class MessageController {
|
||||
@ApiResponse(
|
||||
responseCode="428",
|
||||
description="The sender should complete a challenge before proceeding")
|
||||
public Response sendMessage(@ReadOnly @Auth final Optional<AuthenticatedDevice> source,
|
||||
public Response sendMessage(@Auth final Optional<AuthenticatedDevice> source,
|
||||
@Parameter(description="The recipient's unidentified access key")
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) final Optional<Anonymous> accessKey,
|
||||
|
||||
@@ -274,12 +274,14 @@ public class MessageController {
|
||||
sendStoryMessage(destinationIdentifier, messages, context);
|
||||
} else if (source.isPresent()) {
|
||||
final AuthenticatedDevice authenticatedDevice = source.get();
|
||||
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
if (authenticatedDevice.getAccount().isIdentifiedBy(destinationIdentifier)) {
|
||||
if (account.isIdentifiedBy(destinationIdentifier)) {
|
||||
needsSync = false;
|
||||
sendSyncMessage(source.get(), destinationIdentifier, messages, context);
|
||||
sendSyncMessage(source.get(), account, destinationIdentifier, messages, context);
|
||||
} else {
|
||||
needsSync = authenticatedDevice.getAccount().getDevices().size() > 1;
|
||||
needsSync = account.getDevices().size() > 1;
|
||||
sendIdentifiedSenderIndividualMessage(authenticatedDevice, destinationIdentifier, messages, context);
|
||||
}
|
||||
} else {
|
||||
@@ -302,7 +304,7 @@ public class MessageController {
|
||||
final Account destination =
|
||||
accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(NotFoundException::new);
|
||||
|
||||
rateLimiters.getMessagesLimiter().validate(source.getAccount().getUuid(), destination.getUuid());
|
||||
rateLimiters.getMessagesLimiter().validate(source.getAccountIdentifier(), destination.getUuid());
|
||||
|
||||
sendIndividualMessage(destination,
|
||||
destinationIdentifier,
|
||||
@@ -314,6 +316,7 @@ public class MessageController {
|
||||
}
|
||||
|
||||
private void sendSyncMessage(final AuthenticatedDevice source,
|
||||
final Account sourceAccount,
|
||||
final ServiceIdentifier destinationIdentifier,
|
||||
final IncomingMessageList messages,
|
||||
final ContainerRequestContext context)
|
||||
@@ -323,7 +326,7 @@ public class MessageController {
|
||||
throw new WebApplicationException(Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
sendIndividualMessage(source.getAccount(),
|
||||
sendIndividualMessage(sourceAccount,
|
||||
destinationIdentifier,
|
||||
source,
|
||||
messages,
|
||||
@@ -420,8 +423,8 @@ public class MessageController {
|
||||
try {
|
||||
return message.toEnvelope(
|
||||
destinationIdentifier,
|
||||
sender != null ? sender.getAccount() : null,
|
||||
sender != null ? sender.getAuthenticatedDevice().getId() : null,
|
||||
sender != null ? new AciServiceIdentifier(sender.getAccountIdentifier()) : null,
|
||||
sender != null ? sender.getDeviceId() : null,
|
||||
messages.timestamp() == 0 ? System.currentTimeMillis() : messages.timestamp(),
|
||||
isStory,
|
||||
messages.online(),
|
||||
@@ -437,7 +440,7 @@ public class MessageController {
|
||||
.collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));
|
||||
|
||||
final Optional<Byte> syncMessageSenderDeviceId = messageType == MessageType.SYNC
|
||||
? Optional.ofNullable(sender).map(authenticatedDevice -> authenticatedDevice.getAuthenticatedDevice().getId())
|
||||
? Optional.ofNullable(sender).map(AuthenticatedDevice::getDeviceId)
|
||||
: Optional.empty();
|
||||
|
||||
try {
|
||||
@@ -755,31 +758,37 @@ public class MessageController {
|
||||
@Timed
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedDevice auth,
|
||||
@HeaderParam(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
|
||||
|
||||
boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
return accountsManager.getByAccountIdentifierAsync(auth.getAccountIdentifier())
|
||||
.thenCompose(maybeAccount -> {
|
||||
final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent);
|
||||
final boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
|
||||
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);
|
||||
|
||||
return messagesManager.getMessagesForDevice(
|
||||
auth.getAccountIdentifier(),
|
||||
device,
|
||||
false)
|
||||
.map(messagesAndHasMore -> {
|
||||
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
|
||||
if (!shouldReceiveStories) {
|
||||
envelopes = envelopes.filter(e -> !e.getStory());
|
||||
}
|
||||
|
||||
final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes
|
||||
.map(OutgoingMessageEntity::fromEnvelope)
|
||||
.peek(outgoingMessageEntity -> {
|
||||
messageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(), outgoingMessageEntity);
|
||||
messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageEntity);
|
||||
messageMetrics.measureOutgoingMessageLatency(outgoingMessageEntity.serverTimestamp(),
|
||||
"rest",
|
||||
auth.getAuthenticatedDevice().isPrimary(),
|
||||
auth.getDeviceId() == Device.PRIMARY_ID,
|
||||
outgoingMessageEntity.urgent(),
|
||||
// Messages fetched via this endpoint (as opposed to WebSocketConnection) are never ephemeral
|
||||
// because, by definition, the client doesn't have a "live" connection via which to receive
|
||||
@@ -791,26 +800,27 @@ public class MessageController {
|
||||
.collect(Collectors.toList()),
|
||||
messagesAndHasMore.second());
|
||||
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.record(estimateMessageListSizeBytes(messages));
|
||||
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
|
||||
.record(estimateMessageListSizeBytes(messages));
|
||||
|
||||
if (!messages.messages().isEmpty()) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccount().getIdentifier(IdentityType.ACI),
|
||||
auth.getAuthenticatedDevice().getId(),
|
||||
messages.messages().getFirst().guid(),
|
||||
userAgent,
|
||||
"rest");
|
||||
}
|
||||
if (!messages.messages().isEmpty()) {
|
||||
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.getAccountIdentifier(),
|
||||
auth.getDeviceId(),
|
||||
messages.messages().getFirst().guid(),
|
||||
userAgent,
|
||||
"rest");
|
||||
}
|
||||
|
||||
if (messagesAndHasMore.second()) {
|
||||
pushNotificationScheduler.scheduleDelayedNotification(auth.getAccount(), auth.getAuthenticatedDevice(), NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
|
||||
}
|
||||
if (messagesAndHasMore.second()) {
|
||||
pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
|
||||
}
|
||||
|
||||
return messages;
|
||||
})
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
return messages;
|
||||
})
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.subscribeOn(messageDeliveryScheduler)
|
||||
.toFuture();
|
||||
});
|
||||
}
|
||||
|
||||
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
|
||||
@@ -827,22 +837,27 @@ public class MessageController {
|
||||
@Timed
|
||||
@DELETE
|
||||
@Path("/uuid/{uuid}")
|
||||
public CompletableFuture<Response> removePendingMessage(@ReadOnly @Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
|
||||
public CompletableFuture<Response> removePendingMessage(@Auth AuthenticatedDevice auth, @PathParam("uuid") UUID uuid) {
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
final Device device = account.getDevice(auth.getDeviceId())
|
||||
.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));
|
||||
|
||||
return messagesManager.delete(
|
||||
auth.getAccount().getUuid(),
|
||||
auth.getAuthenticatedDevice(),
|
||||
auth.getAccountIdentifier(),
|
||||
device,
|
||||
uuid,
|
||||
null)
|
||||
.thenAccept(maybeRemovedMessage -> maybeRemovedMessage.ifPresent(removedMessage -> {
|
||||
|
||||
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(),
|
||||
auth.getAuthenticatedDevice());
|
||||
WebSocketConnection.recordMessageDeliveryDuration(removedMessage.serverTimestamp(), device);
|
||||
|
||||
if (removedMessage.sourceServiceId().isPresent()
|
||||
&& removedMessage.envelopeType() != Type.SERVER_DELIVERY_RECEIPT) {
|
||||
if (removedMessage.sourceServiceId().get() instanceof AciServiceIdentifier aciServiceIdentifier) {
|
||||
try {
|
||||
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.getAuthenticatedDevice().getId(),
|
||||
receiptSender.sendReceipt(removedMessage.destinationServiceId(), auth.getDeviceId(),
|
||||
aciServiceIdentifier, removedMessage.clientTimestamp());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to send delivery receipt", e);
|
||||
@@ -863,7 +878,7 @@ public class MessageController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Path("/report/{source}/{messageGuid}")
|
||||
public Response reportSpamMessage(
|
||||
@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
@Auth AuthenticatedDevice auth,
|
||||
@PathParam("source") String source,
|
||||
@PathParam("messageGuid") UUID messageGuid,
|
||||
@Nullable SpamReport spamReport,
|
||||
@@ -899,7 +914,7 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
UUID spamReporterUuid = auth.getAccount().getUuid();
|
||||
UUID spamReporterUuid = auth.getAccountIdentifier();
|
||||
|
||||
// spam report token is optional, but if provided ensure it is non-empty.
|
||||
final Optional<byte[]> maybeSpamReportToken =
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import java.util.Map;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
|
||||
public class MultiRecipientMismatchedDevicesException extends Exception {
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
|
||||
/**
|
||||
@@ -163,7 +162,7 @@ public class OneTimeDonationController {
|
||||
@StringToClassMapItem(key = "error", value = String.class)
|
||||
})))
|
||||
public CompletableFuture<Response> createBoostPaymentIntent(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid CreateBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
@@ -249,7 +248,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPayPalBoost(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid CreatePayPalBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@Context ContainerRequestContext containerRequestContext) {
|
||||
@@ -296,7 +295,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> confirmPayPalBoost(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid ConfirmPayPalBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
@@ -342,7 +341,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createBoostReceiptCredentials(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator
|
||||
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
|
||||
import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/payments")
|
||||
@Tag(name = "Payments")
|
||||
@@ -43,14 +42,14 @@ public class PaymentsController {
|
||||
@GET
|
||||
@Path("/auth")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public ExternalServiceCredentials getAuth(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
public ExternalServiceCredentials getAuth(final @Auth AuthenticatedDevice auth) {
|
||||
return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/conversions")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CurrencyConversionEntityList getConversions(final @ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedDevice auth) {
|
||||
return currencyManager.getCurrencyConversions().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
@@ -94,8 +95,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.Mutable;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@Path("/v1/profile")
|
||||
@@ -152,15 +151,18 @@ public class ProfileController {
|
||||
@PUT
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response setProfile(@Mutable @Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
|
||||
public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {
|
||||
|
||||
final Optional<VersionedProfile> currentProfile = profilesManager.get(auth.getAccount().getUuid(),
|
||||
request.version());
|
||||
final Account account = accountsManager.getByAccountIdentifier(auth.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
|
||||
|
||||
final Optional<VersionedProfile> currentProfile =
|
||||
profilesManager.get(auth.getAccountIdentifier(), request.version());
|
||||
|
||||
if (request.paymentAddress() != null && request.paymentAddress().length != 0) {
|
||||
final boolean hasDisallowedPrefix =
|
||||
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
|
||||
.anyMatch(prefix -> auth.getAccount().getNumber().startsWith(prefix));
|
||||
.anyMatch(prefix -> account.getNumber().startsWith(prefix));
|
||||
|
||||
if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::paymentAddress).isEmpty()) {
|
||||
return Response.status(Response.Status.FORBIDDEN).build();
|
||||
@@ -179,7 +181,7 @@ public class ProfileController {
|
||||
case UPDATE -> ProfileHelper.generateAvatarObjectName();
|
||||
};
|
||||
|
||||
profilesManager.set(auth.getAccount().getUuid(),
|
||||
profilesManager.set(auth.getAccountIdentifier(),
|
||||
new VersionedProfile(
|
||||
request.version(),
|
||||
request.name(),
|
||||
@@ -194,7 +196,7 @@ public class ProfileController {
|
||||
currentAvatar.ifPresent(s -> profilesManager.deleteAvatar(s).join());
|
||||
}
|
||||
|
||||
accountsManager.update(auth.getAccount(), a -> {
|
||||
accountsManager.update(account, a -> {
|
||||
|
||||
final List<AccountBadge> updatedBadges = request.badges()
|
||||
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))
|
||||
@@ -216,7 +218,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}/{version}")
|
||||
@ManagedAsync
|
||||
public VersionedProfileResponse getProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@@ -224,7 +226,11 @@ public class ProfileController {
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "getVersionedProfile", userAgent);
|
||||
|
||||
return buildVersionedProfileResponse(targetAccount,
|
||||
@@ -238,7 +244,7 @@ public class ProfileController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/{identifier}/{version}/{credentialRequest}")
|
||||
public CredentialProfileResponse getProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@@ -252,7 +258,11 @@ public class ProfileController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, "credentialRequest", userAgent);
|
||||
final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);
|
||||
|
||||
@@ -270,7 +280,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}")
|
||||
@ManagedAsync
|
||||
public BaseProfileResponse getUnversionedProfile(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> auth,
|
||||
@Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@@ -278,7 +288,10 @@ public class ProfileController {
|
||||
@PathParam("identifier") ServiceIdentifier identifier)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedDevice::getAccount);
|
||||
final Optional<Account> maybeRequester =
|
||||
maybeAuthenticatedDevice.map(
|
||||
authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.getAccountIdentifier())
|
||||
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));
|
||||
|
||||
final Account targetAccount;
|
||||
if (groupSendToken.isPresent()) {
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.whispersystems.textsecuregcm.entities.ProvisioningMessage;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.push.ProvisioningManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
/**
|
||||
* The provisioning controller facilitates transmission of provisioning messages from the primary device associated with
|
||||
@@ -77,7 +76,7 @@ public class ProvisioningController {
|
||||
@ApiResponse(responseCode="204", description="The provisioning message was delivered to the given provisioning address")
|
||||
@ApiResponse(responseCode="400", description="The provisioning message was too large")
|
||||
@ApiResponse(responseCode="404", description="No device with the given provisioning address was connected at the time of the request")
|
||||
public void sendProvisioningMessage(@ReadOnly @Auth final AuthenticatedDevice auth,
|
||||
public void sendProvisioningMessage(@Auth final AuthenticatedDevice auth,
|
||||
|
||||
@Parameter(description = "The temporary provisioning address to which to send a provisioning message")
|
||||
@PathParam("destination") final String provisioningAddress,
|
||||
@@ -93,7 +92,7 @@ public class ProvisioningController {
|
||||
throw new WebApplicationException(Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getMessagesLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
final boolean subscriberPresent =
|
||||
provisioningManager.sendProvisioningMessage(provisioningAddress, Base64.getMimeDecoder().decode(message.body()));
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList;
|
||||
import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/config")
|
||||
@Tag(name = "Remote Config")
|
||||
@@ -64,7 +63,7 @@ public class RemoteConfigController {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Remote configuration values for the authenticated user", useReturnTypeSchema = true)
|
||||
public UserRemoteConfigList getAll(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
public UserRemoteConfigList getAll(@Auth AuthenticatedDevice auth) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA1");
|
||||
|
||||
@@ -73,7 +72,7 @@ public class RemoteConfigController {
|
||||
return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> {
|
||||
final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8)
|
||||
: config.getName().getBytes(StandardCharsets.UTF_8);
|
||||
boolean inBucket = isInBucket(digest, auth.getAccount().getUuid(), hashKey, config.getPercentage(),
|
||||
boolean inBucket = isInBucket(digest, auth.getAccountIdentifier(), hashKey, config.getPercentage(),
|
||||
config.getUuids());
|
||||
return new UserRemoteConfig(config.getName(), inBucket,
|
||||
inBucket ? config.getValue() : config.getDefaultValue());
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||
import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/storage")
|
||||
@Tag(name = "Secure Storage")
|
||||
@@ -47,7 +46,7 @@ public class SecureStorageController {
|
||||
"""
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
public ExternalServiceCredentials getAuth(@ReadOnly @Auth AuthenticatedDevice auth) {
|
||||
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid());
|
||||
public ExternalServiceCredentials getAuth(@Auth AuthenticatedDevice auth) {
|
||||
return storageServiceCredentialsGenerator.generateForUuid(auth.getAccountIdentifier());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v2/backup")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
@@ -78,8 +77,8 @@ public class SecureValueRecovery2Controller {
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||
public ExternalServiceCredentials getAuth(@ReadOnly @Auth final AuthenticatedDevice auth) {
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||
public ExternalServiceCredentials getAuth(@Auth final AuthenticatedDevice auth) {
|
||||
return backupServiceCredentialGenerator.generateFor(auth.getAccountIdentifier().toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/sticker")
|
||||
@Tag(name = "Stickers")
|
||||
@@ -47,10 +46,10 @@ public class StickerController {
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("/pack/form/{count}")
|
||||
public StickerPackFormUploadAttributes getStickersForm(@ReadOnly @Auth AuthenticatedDevice auth,
|
||||
public StickerPackFormUploadAttributes getStickersForm(@Auth AuthenticatedDevice auth,
|
||||
@PathParam("count") @Min(1) @Max(201) int stickerCount)
|
||||
throws RateLimitExceededException {
|
||||
rateLimiters.getStickerPackLimiter().validate(auth.getAccount().getUuid());
|
||||
rateLimiters.getStickerPackLimiter().validate(auth.getAccountIdentifier());
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
String packId = generatePackId();
|
||||
|
||||
@@ -88,7 +88,6 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import org.whispersystems.websocket.auth.ReadOnly;
|
||||
|
||||
@Path("/v1/subscription")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions")
|
||||
@@ -220,7 +219,7 @@ public class SubscriptionController {
|
||||
@Path("/{subscriberId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> deleteSubscriber(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -232,7 +231,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> updateSubscriber(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -248,7 +247,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPaymentMethod(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) throws SubscriptionException {
|
||||
@@ -284,7 +283,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPayPalPaymentMethod(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@NotNull @Valid CreatePayPalBillingAgreementRequest request,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@@ -323,7 +322,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setDefaultPaymentMethodWithProcessor(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("processor") PaymentProvider processor,
|
||||
@PathParam("paymentMethodToken") @NotEmpty String paymentMethodToken) throws SubscriptionException {
|
||||
@@ -360,7 +359,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setSubscriptionLevel(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("level") long level,
|
||||
@PathParam("currency") String currency,
|
||||
@@ -432,7 +431,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.")
|
||||
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.")
|
||||
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setAppStoreSubscription(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("originalTransactionId") String originalTransactionId) throws SubscriptionException {
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
@@ -473,7 +472,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
|
||||
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
|
||||
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException {
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
@@ -627,7 +626,7 @@ public class SubscriptionController {
|
||||
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
|
||||
public CompletableFuture<Response> getSubscriptionInformation(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
@@ -662,7 +661,7 @@ public class SubscriptionController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createSubscriptionReceiptCredentials(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@NotNull @Valid GetReceiptCredentialsRequest request) throws SubscriptionException {
|
||||
@@ -691,7 +690,7 @@ public class SubscriptionController {
|
||||
@Path("/{subscriberId}/default_payment_method_for_ideal/{setupIntentId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> setDefaultPaymentMethodForIdeal(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("setupIntentId") @NotEmpty String setupIntentId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.Duration;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;
|
||||
|
||||
public class VerificationSessionRateLimitExceededException extends RateLimitExceededException {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user