diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java index c5f3f8509..a35b6b057 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java @@ -13,12 +13,17 @@ import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.time.Instant; import java.util.Optional; +import java.util.UUID; import javax.validation.Valid; import javax.validation.constraints.NotNull; import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; import javax.ws.rs.ForbiddenException; +import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; import javax.ws.rs.PUT; import javax.ws.rs.Path; @@ -29,6 +34,7 @@ import javax.ws.rs.core.Response; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; +import org.whispersystems.textsecuregcm.entities.AccountDataReportResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; import org.whispersystems.textsecuregcm.entities.MismatchedDevices; @@ -144,4 +150,31 @@ public class AccountControllerV2 { accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber( phoneNumberDiscoverability.discoverableByPhoneNumber())); } + + @Timed + @GET + @Path("/data_report") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Produces a report of non-ephemeral account data stored by the service") + @ApiResponse(responseCode = "200", + description = "Response with data report. A plain text representation is a field in the response.", + useReturnTypeSchema = true) + public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedAccount auth) { + + final Account account = auth.getAccount(); + + return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(), + new AccountDataReportResponse.AccountAndDevicesDataReport( + new AccountDataReportResponse.AccountDataReport( + account.getNumber(), + account.getBadges().stream().map(AccountDataReportResponse.BadgeDataReport::new).toList(), + account.isUnrestrictedUnidentifiedAccess(), + account.isDiscoverableByPhoneNumber()), + account.getDevices().stream().map(device -> + new AccountDataReportResponse.DeviceDataReport( + device.getId(), + Instant.ofEpochMilli(device.getLastSeen()), + Instant.ofEpochMilli(device.getCreated()), + device.getUserAgent())).toList())); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountDataReportResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountDataReportResponse.java new file mode 100644 index 000000000..e6276a6e5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountDataReportResponse.java @@ -0,0 +1,122 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.InstantSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import org.whispersystems.textsecuregcm.storage.AccountBadge; + +public record AccountDataReportResponse(UUID reportId, + @JsonSerialize(using = InstantSerializer.class) + @JsonFormat(pattern = DATE_FORMAT, timezone = UTC) + Instant reportTimestamp, + AccountAndDevicesDataReport data) { + + private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + private static final String UTC = "Etc/UTC"; + + @JsonProperty + @Schema(description = "A plaintext representation of the data report") + String text() { + + final StringBuilder builder = new StringBuilder(); + + // header + builder.append(String.format(""" + Report ID: %s + Report timestamp: %s + + """, + reportId, + reportTimestamp.truncatedTo(ChronoUnit.SECONDS))); + + // account + builder.append(String.format(""" + # Account + Phone number: %s + Allow sealed sender from anyone: %s + Find account by phone number: %s + """, + data.account.phoneNumber(), + data.account.allowSealedSenderFromAnyone(), + data.account.findAccountByPhoneNumber())); + + // badges + builder.append("Badges:"); + + if (data.account.badges().isEmpty()) { + builder.append(" None\n"); + } else { + builder.append("\n"); + data.account.badges().forEach(badgeDataReport -> builder.append(String.format(""" + - ID: %s + Expiration: %s + Visible: %s + """, + badgeDataReport.id(), + badgeDataReport.expiration().truncatedTo(ChronoUnit.SECONDS), + badgeDataReport.visible()))); + } + + // devices + builder.append("\n# Devices\n"); + + data.devices().forEach(deviceDataReport -> + builder.append(String.format(""" + - ID: %s + Created: %s + Last seen: %s + User-agent: %s + """, + deviceDataReport.id(), + deviceDataReport.created().truncatedTo(ChronoUnit.SECONDS), + deviceDataReport.lastSeen().truncatedTo(ChronoUnit.SECONDS), + deviceDataReport.userAgent()))); + + return builder.toString(); + } + + + public record AccountAndDevicesDataReport(AccountDataReport account, + List devices) { + + } + + public record AccountDataReport(String phoneNumber, List badges, boolean allowSealedSenderFromAnyone, + boolean findAccountByPhoneNumber) { + + } + + public record DeviceDataReport(long id, + @JsonFormat(pattern = DATE_FORMAT, timezone = UTC) + Instant lastSeen, + @JsonFormat(pattern = DATE_FORMAT, timezone = UTC) + Instant created, + @Nullable String userAgent) { + + + } + + public record BadgeDataReport(String id, + @JsonFormat(pattern = DATE_FORMAT, timezone = UTC) + Instant expiration, + boolean visible) { + + public BadgeDataReport(AccountBadge badge) { + this(badge.getId(), badge.getExpiration(), badge.isVisible()); + } + + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java index 3bc4df53a..6009a2034 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; @@ -20,17 +21,25 @@ import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableSet; import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import java.nio.charset.StandardCharsets; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import javax.ws.rs.WebApplicationException; @@ -57,10 +66,13 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccou import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; import org.whispersystems.textsecuregcm.auth.RegistrationLockError; import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.entities.AccountDataReportResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest; import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession; +import org.whispersystems.textsecuregcm.entities.SignedPreKey; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; @@ -68,6 +80,7 @@ import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptio import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountBadge; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; import org.whispersystems.textsecuregcm.storage.Device; @@ -75,6 +88,7 @@ import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsMan import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.Util; @ExtendWith(DropwizardExtensionsSupport.class) class AccountControllerV2Test { @@ -441,14 +455,203 @@ class AccountControllerV2Test { .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .put(Entity.json( - """ - { - "discoverableByPhoneNumber": null - } - """)); + """ + { + "discoverableByPhoneNumber": null + } + """)); assertThat(response.getStatus()).isEqualTo(422); verify(AuthHelper.VALID_ACCOUNT, never()).setDiscoverableByPhoneNumber(anyBoolean()); } + + @ParameterizedTest + @MethodSource + void testGetAccountDataReport(final Account account, final String expectedTextAfterHeader) throws Exception { + when(AuthHelper.ACCOUNTS_MANAGER.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account)); + + final Response response = resources.getJerseyTest() + .target("/v2/accounts/data_report") + .request() + .header("Authorization", AuthHelper.getAuthHeader(account.getUuid(), "password")) + .get(); + + assertEquals(200, response.getStatus()); + + final String stringResponse = response.readEntity(String.class); + + final AccountDataReportResponse structuredResponse = SystemMapper.jsonMapper() + .readValue(stringResponse, AccountDataReportResponse.class); + + assertEquals(account.getNumber(), structuredResponse.data().account().phoneNumber()); + assertEquals(account.isDiscoverableByPhoneNumber(), + structuredResponse.data().account().findAccountByPhoneNumber()); + assertEquals(account.isUnrestrictedUnidentifiedAccess(), + structuredResponse.data().account().allowSealedSenderFromAnyone()); + + final Set deviceIds = account.getDevices().stream().map(Device::getId).collect(Collectors.toSet()); + + // all devices should be present + structuredResponse.data().devices().forEach(deviceDataReport -> { + assertTrue(deviceIds.remove(deviceDataReport.id())); + assertEquals(account.getDevice(deviceDataReport.id()).orElseThrow().getUserAgent(), + deviceDataReport.userAgent()); + }); + assertTrue(deviceIds.isEmpty()); + + final String actualText = (String) SystemMapper.jsonMapper().readValue(stringResponse, Map.class).get("text"); + final int headerEnd = actualText.indexOf("# Account"); + assertEquals(expectedTextAfterHeader, actualText.substring(headerEnd)); + + final String actualHeader = actualText.substring(0, headerEnd); + assertTrue(actualHeader.matches( + "Report ID: [a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\nReport timestamp: \\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z\n\n")); + } + + static Stream testGetAccountDataReport() { + final String exampleNumber1 = toE164(PhoneNumberUtil.getInstance().getExampleNumber("ES")); + final String account2PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber("AU")); + final String account3PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber("IN")); + + final Instant account1Device1Created = Instant.ofEpochSecond(1669323142); // 2022-11-24T20:52:22Z + final Instant account1Device2Created = Instant.ofEpochSecond(1679155122); // 2023-03-18T15:58:42Z + final Instant account1Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis()); + final Instant account1Device2LastSeen = Instant.ofEpochSecond(1678838400); // 2023-03-15T00:00:00Z + + final Instant account2Device1Created = Instant.ofEpochSecond(1659123001); // 2022-07-29T19:30:01Z + final Instant account2Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis()); + final Instant badgeAExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS); + + final Instant account3Device1Created = Instant.ofEpochSecond(1639923487); // 2021-12-19T14:18:07Z + final Instant account3Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis()); + final Instant badgeBExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS); + final Instant badgeCExpiration = Instant.now().plus(Duration.ofDays(24)).truncatedTo(ChronoUnit.SECONDS); + + return Stream.of( + Arguments.of( + buildTestAccountForDataReport(UUID.randomUUID(), exampleNumber1, + true, true, + Collections.emptyList(), + List.of(new DeviceData(1, account1Device1LastSeen, account1Device1Created, null), + new DeviceData(2, account1Device2LastSeen, account1Device2Created, "OWP"))), + String.format(""" + # Account + Phone number: %s + Allow sealed sender from anyone: true + Find account by phone number: true + Badges: None + + # Devices + - ID: 1 + Created: 2022-11-24T20:52:22Z + Last seen: %s + User-agent: null + - ID: 2 + Created: 2023-03-18T15:58:42Z + Last seen: 2023-03-15T00:00:00Z + User-agent: OWP + """, + exampleNumber1, + account1Device1LastSeen) + ), + Arguments.of( + buildTestAccountForDataReport(UUID.randomUUID(), account2PhoneNumber, + false, true, + List.of(new AccountBadge("badge_a", badgeAExpiration, true)), + List.of(new DeviceData(1, account2Device1LastSeen, account2Device1Created, "OWI"))), + String.format(""" + # Account + Phone number: %s + Allow sealed sender from anyone: false + Find account by phone number: true + Badges: + - ID: badge_a + Expiration: %s + Visible: true + + # Devices + - ID: 1 + Created: 2022-07-29T19:30:01Z + Last seen: %s + User-agent: OWI + """, account2PhoneNumber, + badgeAExpiration, + account2Device1LastSeen) + ), + Arguments.of( + buildTestAccountForDataReport(UUID.randomUUID(), account3PhoneNumber, + true, false, + List.of( + new AccountBadge("badge_b", badgeBExpiration, true), + new AccountBadge("badge_c", badgeCExpiration, false)), + List.of(new DeviceData(1, account3Device1LastSeen, account3Device1Created, "OWA"))), + String.format(""" + # Account + Phone number: %s + Allow sealed sender from anyone: true + Find account by phone number: false + Badges: + - ID: badge_b + Expiration: %s + Visible: true + - ID: badge_c + Expiration: %s + Visible: false + + # Devices + - ID: 1 + Created: 2021-12-19T14:18:07Z + Last seen: %s + User-agent: OWA + """, account3PhoneNumber, + badgeBExpiration, + badgeCExpiration, + account3Device1LastSeen) + ) + ); + } + + /** + * Creates an {@link Account} with data sufficient for + * {@link AccountControllerV2#getAccountDataReport(AuthenticatedAccount)}. + *

+ * Note: All devices will have a {@link SaltedTokenHash} for "password" + */ + static Account buildTestAccountForDataReport(final UUID aci, final String number, + final boolean unrestrictedUnidentifiedAccess, final boolean discoverableByPhoneNumber, + List badges, List devices) { + final Account account = new Account(); + account.setUuid(aci); + account.setNumber(number, UUID.randomUUID()); + account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess); + account.setDiscoverableByPhoneNumber(discoverableByPhoneNumber); + account.setBadges(Clock.systemUTC(), new ArrayList<>(badges)); + + assert !devices.isEmpty(); + + final SaltedTokenHash passwordTokenHash = SaltedTokenHash.generateFor("password"); + + devices.forEach(deviceData -> { + final Device device = new Device(); + device.setId(deviceData.id); + device.setAuthTokenHash(passwordTokenHash); + device.setFetchesMessages(true); + device.setSignedPreKey(new SignedPreKey(1, "publicKey", "signature")); + device.setLastSeen(deviceData.lastSeen().toEpochMilli()); + device.setCreated(deviceData.created().toEpochMilli()); + device.setUserAgent(deviceData.userAgent()); + account.addDevice(device); + }); + + return account; + } + + private record DeviceData(long id, Instant lastSeen, Instant created, @Nullable String userAgent) { + + } + + private static String toE164(Phonenumber.PhoneNumber phoneNumber) { + return PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); + } } }