diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java index 7cd8a9197..d6b6346f0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java @@ -8,6 +8,8 @@ package org.whispersystems.textsecuregcm.auth; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Device; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.NotFoundException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import java.security.MessageDigest; @@ -36,9 +38,9 @@ public class OptionalAccess { } if (requestAccount.isPresent()) { - throw new WebApplicationException(Response.Status.NOT_FOUND); + throw new NotFoundException(); } else { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); + throw new NotAuthorizedException(Response.Status.UNAUTHORIZED); } } } catch (NumberFormatException e) { @@ -56,7 +58,7 @@ public class OptionalAccess { //noinspection ConstantConditions if (requestAccount.isPresent() && (targetAccount.isEmpty() || (targetAccount.isPresent() && !targetAccount.get().isEnabled()))) { - throw new WebApplicationException(Response.Status.NOT_FOUND); + throw new NotFoundException(); } if (accessKey.isPresent() && targetAccount.isPresent() && targetAccount.get().isEnabled() && targetAccount.get().isUnrestrictedUnidentifiedAccess()) { @@ -72,7 +74,7 @@ public class OptionalAccess { return; } - throw new WebApplicationException(Response.Status.UNAUTHORIZED); + throw new NotAuthorizedException(Response.Status.UNAUTHORIZED); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index ebc191d4d..6fe2d64de 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -29,6 +29,8 @@ import javax.ws.rs.DefaultValue; import javax.ws.rs.ForbiddenException; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.NotFoundException; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -63,9 +65,13 @@ import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; -import org.whispersystems.textsecuregcm.entities.Profile; +import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; +import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; +import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; +import org.whispersystems.textsecuregcm.entities.BaseProfileResponse; import org.whispersystems.textsecuregcm.entities.UserCapabilities; +import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.s3.PolicySigner; import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; @@ -196,23 +202,28 @@ public class ProfileController { @GET @Produces(MediaType.APPLICATION_JSON) @Path("/{uuid}/{version}") - public Profile getProfile( + public VersionedProfileResponse getProfile( @Auth Optional auth, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, @Context ContainerRequestContext containerRequestContext, @PathParam("uuid") UUID uuid, @PathParam("version") String version) throws RateLimitExceededException { - return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, - getAcceptableLanguagesForRequest(containerRequestContext), uuid, - version, Optional.empty(), Optional.empty()); + + final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); + final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, uuid); + + return buildVersionedProfileResponse(targetAccount, + version, + isSelfProfileRequest(maybeRequester, uuid), + containerRequestContext); } @Timed @GET @Produces(MediaType.APPLICATION_JSON) @Path("/{uuid}/{version}/{credentialRequest}") - public Profile getProfile( + public CredentialProfileResponse getProfile( @Auth Optional auth, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, @Context ContainerRequestContext containerRequestContext, @@ -221,134 +232,140 @@ public class ProfileController { @PathParam("credentialRequest") String credentialRequest, @QueryParam("credentialType") @DefaultValue(PROFILE_KEY_CREDENTIAL_TYPE) String credentialType) throws RateLimitExceededException { - return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, - getAcceptableLanguagesForRequest(containerRequestContext), uuid, - version, Optional.of(credentialRequest), Optional.of(credentialType)); - } - private Profile getVersionedProfile( - Optional requestAccount, - Optional accessKey, - List acceptableLanguages, - UUID uuid, - String version, - Optional credentialRequest, - Optional credentialType) - throws RateLimitExceededException { - if (requestAccount.isEmpty() && accessKey.isEmpty()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } + final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); + final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, uuid); + final boolean isSelf = isSelfProfileRequest(maybeRequester, uuid); - boolean isSelf = false; - if (requestAccount.isPresent()) { - UUID authedUuid = requestAccount.get().getUuid(); - rateLimiters.getProfileLimiter().validate(authedUuid); - isSelf = uuid.equals(authedUuid); - } + switch (credentialType) { + case PROFILE_KEY_CREDENTIAL_TYPE -> { + return buildProfileKeyCredentialProfileResponse(targetAccount, + version, + credentialRequest, + isSelf, + containerRequestContext); + } - Optional accountProfile = accountsManager.getByAccountIdentifier(uuid); - OptionalAccess.verify(requestAccount, accessKey, accountProfile); - - assert accountProfile.isPresent(); - - Optional username = accountProfile.flatMap(Account::getUsername); - Optional profile = profilesManager.get(uuid, version); - - String name = profile.map(VersionedProfile::getName).orElse(null); - String about = profile.map(VersionedProfile::getAbout).orElse(null); - String aboutEmoji = profile.map(VersionedProfile::getAboutEmoji).orElse(null); - String avatar = profile.map(VersionedProfile::getAvatar).orElse(null); - Optional currentProfileVersion = accountProfile.get().getCurrentProfileVersion(); - - // Allow requests where either the version matches the latest version on Account or the latest version on Account - // is empty to read the payment address. - final String paymentAddress = profile - .filter(p -> currentProfileVersion.map(v -> v.equals(version)).orElse(true)) - .map(VersionedProfile::getPaymentAddress) - .orElse(null); - - final ProfileKeyCredentialResponse profileKeyCredentialResponse; - final PniCredentialResponse pniCredentialResponse; - - if (credentialRequest.isPresent() && credentialType.isPresent() && profile.isPresent()) { - if (PNI_CREDENTIAL_TYPE.equals(credentialType.get())) { + case PNI_CREDENTIAL_TYPE -> { if (!isSelf) { throw new ForbiddenException(); } - profileKeyCredentialResponse = null; - pniCredentialResponse = getPniCredential(credentialRequest.get(), - profile.get(), - requestAccount.get().getUuid(), - requestAccount.get().getPhoneNumberIdentifier()); - } else if (PROFILE_KEY_CREDENTIAL_TYPE.equals(credentialType.get())) { - profileKeyCredentialResponse = getProfileCredential(credentialRequest.get(), - profile.get(), - uuid); - - pniCredentialResponse = null; - } else { - throw new BadRequestException(); + return buildPniCredentialProfileResponse(targetAccount, + version, + credentialRequest, + containerRequestContext); } - } else { - profileKeyCredentialResponse = null; - pniCredentialResponse = null; - } - return new Profile( - name, - about, - aboutEmoji, - avatar, - paymentAddress, - accountProfile.get().getIdentityKey(), - UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()), - accountProfile.get().isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(accountProfile.get()), - username.orElse(null), - null, - profileBadgeConverter.convert(acceptableLanguages, accountProfile.get().getBadges(), isSelf), - profileKeyCredentialResponse, - pniCredentialResponse); + default -> throw new BadRequestException(); + } } + // Although clients should generally be using versioned profiles wherever possible, there are still a few lingering + // use cases for getting profiles without a version (e.g. getting a contact's unidentified access key checksum). + @Timed + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/{identifier}") + public BaseProfileResponse getUnversionedProfile( + @Auth Optional auth, + @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, + @Context ContainerRequestContext containerRequestContext, + @HeaderParam("User-Agent") String userAgent, + @PathParam("identifier") UUID identifier, + @QueryParam("ca") boolean useCaCertificate) + throws RateLimitExceededException { + + final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); + final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, identifier); + + return buildBaseProfileResponse(targetAccount, + isSelfProfileRequest(maybeRequester, identifier), + containerRequestContext); + } + + private ProfileKeyCredentialProfileResponse buildProfileKeyCredentialProfileResponse(final Account account, + final String version, + final String encodedCredentialRequest, + final boolean isSelf, + final ContainerRequestContext containerRequestContext) { + + final VersionedProfile profile = + profilesManager.get(account.getUuid(), version).orElseThrow(NotFoundException::new); + + return new ProfileKeyCredentialProfileResponse( + buildVersionedProfileResponse(account, version, isSelf, containerRequestContext), + getProfileCredential(encodedCredentialRequest, profile, account.getUuid())); + } + + private PniCredentialProfileResponse buildPniCredentialProfileResponse(final Account account, + final String version, + final String encodedCredentialRequest, + final ContainerRequestContext containerRequestContext) { + + final VersionedProfile profile = + profilesManager.get(account.getUuid(), version).orElseThrow(NotFoundException::new); + + return new PniCredentialProfileResponse( + buildVersionedProfileResponse(account, version, true, containerRequestContext), + getPniCredential(encodedCredentialRequest, profile, account.getUuid(), account.getPhoneNumberIdentifier())); + } + + private VersionedProfileResponse buildVersionedProfileResponse(final Account account, + final String version, + final boolean isSelf, + final ContainerRequestContext containerRequestContext) { + + final Optional maybeProfile = profilesManager.get(account.getUuid(), version); + + final String name = maybeProfile.map(VersionedProfile::getName).orElse(null); + final String about = maybeProfile.map(VersionedProfile::getAbout).orElse(null); + final String aboutEmoji = maybeProfile.map(VersionedProfile::getAboutEmoji).orElse(null); + final String avatar = maybeProfile.map(VersionedProfile::getAvatar).orElse(null); + + // Allow requests where either the version matches the latest version on Account or the latest version on Account + // is empty to read the payment address. + final String paymentAddress = maybeProfile + .filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(version)).orElse(true)) + .map(VersionedProfile::getPaymentAddress) + .orElse(null); + + return new VersionedProfileResponse(buildBaseProfileResponse(account, isSelf, containerRequestContext), + name, about, aboutEmoji, avatar, paymentAddress); + } + + private BaseProfileResponse buildBaseProfileResponse(final Account account, + final boolean isSelf, + final ContainerRequestContext containerRequestContext) { + + return new BaseProfileResponse(account.getIdentityKey(), + UnidentifiedAccessChecksum.generateFor(account.getUnidentifiedAccessKey()), + account.isUnrestrictedUnidentifiedAccess(), + UserCapabilities.createForAccount(account), + profileBadgeConverter.convert( + getAcceptableLanguagesForRequest(containerRequestContext), + account.getBadges(), + isSelf), + isSelf ? account.getUsername().orElse(null) : null, + account.getUuid()); + } @Timed @GET @Produces(MediaType.APPLICATION_JSON) @Path("/username/{username}") - public Profile getProfileByUsername( + public BaseProfileResponse getProfileByUsername( @Auth AuthenticatedAccount auth, @Context ContainerRequestContext containerRequestContext, @PathParam("username") String username) throws RateLimitExceededException { + rateLimiters.getUsernameLookupLimiter().validate(auth.getAccount().getUuid()); - username = username.toLowerCase(); + final Account targetAccount = accountsManager.getByUsername(username).orElseThrow(NotFoundException::new); + final boolean isSelf = auth.getAccount().getUuid().equals(targetAccount.getUuid()); - final Account accountProfile = accountsManager.getByUsername(username) - .orElseThrow(() -> new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build())); - - final boolean isSelf = auth.getAccount().getUuid().equals(accountProfile.getUuid()); - - return new Profile( - null, - null, - null, - null, - null, - accountProfile.getIdentityKey(), - UnidentifiedAccessChecksum.generateFor(accountProfile.getUnidentifiedAccessKey()), - accountProfile.isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(accountProfile), - username, - accountProfile.getUuid(), - profileBadgeConverter.convert( - getAcceptableLanguagesForRequest(containerRequestContext), - accountProfile.getBadges(), - isSelf), - null, - null); + return buildBaseProfileResponse(targetAccount, isSelf, containerRequestContext); } private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest, @@ -379,57 +396,6 @@ public class ProfileController { } } - // Although clients should generally be using versioned profiles wherever possible, there are still a few lingering - // use cases for getting profiles without a version (e.g. getting a contact's unidentified access key checksum). - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/{identifier}") - public Profile getUnversionedProfile( - @Auth Optional auth, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @Context ContainerRequestContext containerRequestContext, - @HeaderParam("User-Agent") String userAgent, - @PathParam("identifier") UUID identifier, - @QueryParam("ca") boolean useCaCertificate) - throws RateLimitExceededException { - - if (auth.isEmpty() && accessKey.isEmpty()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - boolean isSelf = false; - if (auth.isPresent()) { - UUID authedUuid = auth.get().getAccount().getUuid(); - rateLimiters.getProfileLimiter().validate(authedUuid); - isSelf = authedUuid.equals(identifier); - } - - Optional accountProfile = accountsManager.getByAccountIdentifier(identifier); - OptionalAccess.verify(auth.map(AuthenticatedAccount::getAccount), accessKey, accountProfile); - - Optional username = accountProfile.flatMap(Account::getUsername); - - return new Profile( - null, - null, - null, - null, - null, - accountProfile.get().getIdentityKey(), - UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()), - accountProfile.get().isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(accountProfile.get()), - username.orElse(null), - null, - profileBadgeConverter.convert( - getAcceptableLanguagesForRequest(containerRequestContext), - accountProfile.get().getBadges(), - isSelf), - null, - null); - } - private ProfileAvatarUploadAttributes generateAvatarUploadForm(String objectName) { ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); @@ -497,4 +463,42 @@ public class ProfileController { return new ArrayList<>(result.values()); } + + /** + * Verifies that the requester has permission to view the profile of the account identified by the given ACI. + * + * @param maybeRequester the authenticated account requesting the profile, if any + * @param maybeAccessKey an anonymous access key for the target account + * @param targetUuid the ACI of the target account + * + * @return the target account + * + * @throws RateLimitExceededException if the requester must wait before requesting the target account's profile + * @throws NotFoundException if no account was found for the target ACI + * @throws NotAuthorizedException if the requester is not authorized to receive the target account's profile or if the + * requester was not authenticated and did not present an anonymous access key + */ + private Account verifyPermissionToReceiveProfile(final Optional maybeRequester, + final Optional maybeAccessKey, + final UUID targetUuid) throws RateLimitExceededException { + + if (maybeRequester.isEmpty() && maybeAccessKey.isEmpty()) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + if (maybeRequester.isPresent()) { + rateLimiters.getProfileLimiter().validate(maybeRequester.get().getUuid()); + } + + final Optional maybeTargetAccount = accountsManager.getByAccountIdentifier(targetUuid); + + OptionalAccess.verify(maybeRequester, maybeAccessKey, maybeTargetAccount); + assert maybeTargetAccount.isPresent(); + + return maybeTargetAccount.get(); + } + + private boolean isSelfProfileRequest(final Optional maybeRequester, final UUID targetUuid) { + return maybeRequester.map(requester -> requester.getUuid().equals(targetUuid)).orElse(false); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java new file mode 100644 index 000000000..3782e38c1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java @@ -0,0 +1,84 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +public class BaseProfileResponse { + + @JsonProperty + private String identityKey; + + @JsonProperty + private String unidentifiedAccess; + + @JsonProperty + private boolean unrestrictedUnidentifiedAccess; + + @JsonProperty + private UserCapabilities capabilities; + + @JsonProperty + private List badges; + + @JsonProperty + private String username; + + @JsonProperty + private UUID uuid; + + public BaseProfileResponse() { + } + + public BaseProfileResponse(final String identityKey, + final String unidentifiedAccess, + final boolean unrestrictedUnidentifiedAccess, + final UserCapabilities capabilities, + final List badges, + @Nullable final String username, + final UUID uuid) { + + this.identityKey = identityKey; + this.unidentifiedAccess = unidentifiedAccess; + this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; + this.capabilities = capabilities; + this.badges = badges; + this.username = username; + this.uuid = uuid; + } + + public String getIdentityKey() { + return identityKey; + } + + public String getUnidentifiedAccess() { + return unidentifiedAccess; + } + + public boolean isUnrestrictedUnidentifiedAccess() { + return unrestrictedUnidentifiedAccess; + } + + public UserCapabilities getCapabilities() { + return capabilities; + } + + public List getBadges() { + return badges; + } + + public String getUsername() { + return username; + } + + public UUID getUuid() { + return uuid; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java new file mode 100644 index 000000000..01329d466 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java @@ -0,0 +1,25 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +public abstract class CredentialProfileResponse { + + @JsonUnwrapped + private VersionedProfileResponse versionedProfileResponse; + + protected CredentialProfileResponse() { + } + + protected CredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse) { + this.versionedProfileResponse = versionedProfileResponse; + } + + public VersionedProfileResponse getVersionedProfileResponse() { + return versionedProfileResponse; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java new file mode 100644 index 000000000..1bc903e7b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.signal.zkgroup.profiles.PniCredentialResponse; +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +public class PniCredentialProfileResponse extends CredentialProfileResponse { + + @JsonProperty + @JsonSerialize(using = PniCredentialResponseAdapter.Serializing.class) + @JsonDeserialize(using = PniCredentialResponseAdapter.Deserializing.class) + private PniCredentialResponse pniCredential; + + public PniCredentialProfileResponse() { + } + + public PniCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, + final PniCredentialResponse pniCredential) { + + super(versionedProfileResponse); + this.pniCredential = pniCredential; + } + + public PniCredentialResponse getPniCredential() { + return pniCredential; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java deleted file mode 100644 index 753a0ada2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.google.common.annotations.VisibleForTesting; -import java.util.List; -import java.util.UUID; -import org.signal.zkgroup.profiles.PniCredentialResponse; -import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; - -public class Profile { - - @JsonProperty - private String identityKey; - - @JsonProperty - private String name; - - @JsonProperty - private String about; - - @JsonProperty - private String aboutEmoji; - - @JsonProperty - private String avatar; - - @JsonProperty - private String paymentAddress; - - @JsonProperty - private String unidentifiedAccess; - - @JsonProperty - private boolean unrestrictedUnidentifiedAccess; - - @JsonProperty - private UserCapabilities capabilities; - - @JsonProperty - private String username; - - @JsonProperty - private UUID uuid; - - @JsonProperty - private List badges; - - @JsonProperty - @JsonSerialize(using = ProfileKeyCredentialResponseAdapter.Serializing.class) - @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class) - private ProfileKeyCredentialResponse credential; - - @JsonProperty - @JsonSerialize(using = PniCredentialResponseAdapter.Serializing.class) - @JsonDeserialize(using = PniCredentialResponseAdapter.Deserializing.class) - private PniCredentialResponse pniCredential; - - public Profile() {} - - public Profile( - String name, String about, String aboutEmoji, String avatar, String paymentAddress, String identityKey, - String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess, UserCapabilities capabilities, String username, - UUID uuid, List badges, ProfileKeyCredentialResponse credential, PniCredentialResponse pniCredential) - { - this.name = name; - this.about = about; - this.aboutEmoji = aboutEmoji; - this.avatar = avatar; - this.paymentAddress = paymentAddress; - this.identityKey = identityKey; - this.unidentifiedAccess = unidentifiedAccess; - this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; - this.capabilities = capabilities; - this.username = username; - this.uuid = uuid; - this.badges = badges; - this.credential = credential; - this.pniCredential = pniCredential; - } - - @VisibleForTesting - public String getIdentityKey() { - return identityKey; - } - - @VisibleForTesting - public String getName() { - return name; - } - - public String getAbout() { - return about; - } - - public String getAboutEmoji() { - return aboutEmoji; - } - - @VisibleForTesting - public String getAvatar() { - return avatar; - } - - public String getPaymentAddress() { - return paymentAddress; - } - - @VisibleForTesting - public String getUnidentifiedAccess() { - return unidentifiedAccess; - } - - @VisibleForTesting - public boolean isUnrestrictedUnidentifiedAccess() { - return unrestrictedUnidentifiedAccess; - } - - @VisibleForTesting - public UserCapabilities getCapabilities() { - return capabilities; - } - - @VisibleForTesting - public String getUsername() { - return username; - } - - @VisibleForTesting - public UUID getUuid() { - return uuid; - } - - public List getBadges() { - return badges; - } - - public ProfileKeyCredentialResponse getCredential() { - return credential; - } - - public PniCredentialResponse getPniCredential() { - return pniCredential; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java new file mode 100644 index 000000000..4477e31e5 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.signal.zkgroup.profiles.PniCredentialResponse; +import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +public class ProfileKeyCredentialProfileResponse extends CredentialProfileResponse { + + @JsonProperty + @JsonSerialize(using = ProfileKeyCredentialResponseAdapter.Serializing.class) + @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class) + private ProfileKeyCredentialResponse credential; + + public ProfileKeyCredentialProfileResponse() { + } + + public ProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, + final ProfileKeyCredentialResponse credential) { + + super(versionedProfileResponse); + this.credential = credential; + } + + public ProfileKeyCredentialResponse getCredential() { + return credential; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java new file mode 100644 index 000000000..3b9de8960 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java @@ -0,0 +1,76 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +public class VersionedProfileResponse { + + @JsonUnwrapped + private BaseProfileResponse baseProfileResponse; + + @JsonProperty + private String name; + + @JsonProperty + private String about; + + @JsonProperty + private String aboutEmoji; + + @JsonProperty + private String avatar; + + @JsonProperty + private String paymentAddress; + + public VersionedProfileResponse() { + } + + public VersionedProfileResponse(final BaseProfileResponse baseProfileResponse, + final String name, + final String about, + final String aboutEmoji, + final String avatar, + final String paymentAddress) { + + this.baseProfileResponse = baseProfileResponse; + this.name = name; + this.about = about; + this.aboutEmoji = aboutEmoji; + this.avatar = avatar; + this.paymentAddress = paymentAddress; + } + + public BaseProfileResponse getBaseProfileResponse() { + return baseProfileResponse; + } + + public String getName() { + return name; + } + + public String getAbout() { + return about; + } + + public String getAboutEmoji() { + return aboutEmoji; + } + + public String getAvatar() { + return avatar; + } + + public String getPaymentAddress() { + return paymentAddress; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java index a398770bb..364c34fbf 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java @@ -73,9 +73,12 @@ import org.whispersystems.textsecuregcm.controllers.ProfileController; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.entities.Badge; import org.whispersystems.textsecuregcm.entities.BadgeSvg; +import org.whispersystems.textsecuregcm.entities.BaseProfileResponse; import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; -import org.whispersystems.textsecuregcm.entities.Profile; +import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; +import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; +import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.s3.PolicySigner; @@ -209,16 +212,14 @@ class ProfileControllerTest { @Test void testProfileGetByUuid() throws RateLimitExceededException { - Profile profile= resources.getJerseyTest() + BaseProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(BaseProfileResponse.class); assertThat(profile.getIdentityKey()).isEqualTo("bar"); - assertThat(profile.getName()).isNull(); - assertThat(profile.getAvatar()).isNull(); - assertThat(profile.getUsername()).isEqualTo("n00bkiller"); + assertThat(profile.getUsername()).isNull(); assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); @@ -228,16 +229,14 @@ class ProfileControllerTest { @Test void testProfileGetByUsername() throws RateLimitExceededException { - Profile profile= resources.getJerseyTest() + BaseProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/username/n00bkiller") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(BaseProfileResponse.class); assertThat(profile.getIdentityKey()).isEqualTo("bar"); - assertThat(profile.getName()).isNull(); - assertThat(profile.getAvatar()).isNull(); - assertThat(profile.getUsername()).isEqualTo("n00bkiller"); + assertThat(profile.getUsername()).isNull(); assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_UUID_TWO); assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); @@ -295,11 +294,11 @@ class ProfileControllerTest { @Test void testProfileCapabilities() { - Profile profile= resources.getJerseyTest() + BaseProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID) .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(BaseProfileResponse.class); assertThat(profile.getCapabilities().isGv2()).isTrue(); assertThat(profile.getCapabilities().isGv1Migration()).isTrue(); @@ -311,7 +310,7 @@ class ProfileControllerTest { .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .get(Profile.class); + .get(BaseProfileResponse.class); assertThat(profile.getCapabilities().isGv2()).isFalse(); assertThat(profile.getCapabilities().isGv1Migration()).isFalse(); @@ -538,22 +537,22 @@ class ProfileControllerTest { @Test void testGetProfileByVersion() throws RateLimitExceededException { - Profile profile = resources.getJerseyTest() + VersionedProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(VersionedProfileResponse.class); - assertThat(profile.getIdentityKey()).isEqualTo("bar"); + assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo("bar"); assertThat(profile.getName()).isEqualTo("validname"); assertThat(profile.getAbout()).isEqualTo("about"); assertThat(profile.getAboutEmoji()).isEqualTo("emoji"); assertThat(profile.getAvatar()).isEqualTo("profiles/validavatar"); - assertThat(profile.getCapabilities().isGv2()).isFalse(); - assertThat(profile.getCapabilities().isGv1Migration()).isFalse(); - assertThat(profile.getUsername()).isEqualTo("n00bkiller"); - assertThat(profile.getUuid()).isNull(); - assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( + assertThat(profile.getBaseProfileResponse().getCapabilities().isGv2()).isFalse(); + assertThat(profile.getBaseProfileResponse().getCapabilities().isGv1Migration()).isFalse(); + assertThat(profile.getBaseProfileResponse().getUsername()).isNull(); + assertThat(profile.getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID_TWO); + assertThat(profile.getBaseProfileResponse().getBadges()).hasSize(1).element(0).has(new Condition<>( badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); verify(accountsManager, times(1)).getByAccountIdentifier(eq(AuthHelper.VALID_UUID_TWO)); @@ -587,11 +586,11 @@ class ProfileControllerTest { void testGetProfileReturnsNoPaymentAddressIfCurrentVersionMismatch() { when(profilesManager.get(AuthHelper.VALID_UUID_TWO, "validversion")).thenReturn( Optional.of(new VersionedProfile(null, null, null, null, null, "paymentaddress", null))); - Profile profile = resources.getJerseyTest() + VersionedProfileResponse profile = resources.getJerseyTest() .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(VersionedProfileResponse.class); assertThat(profile.getPaymentAddress()).isEqualTo("paymentaddress"); when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("validversion")); @@ -599,7 +598,7 @@ class ProfileControllerTest { .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(VersionedProfileResponse.class); assertThat(profile.getPaymentAddress()).isEqualTo("paymentaddress"); when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("someotherversion")); @@ -607,7 +606,7 @@ class ProfileControllerTest { .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(VersionedProfileResponse.class); assertThat(profile.getPaymentAddress()).isNull(); } @@ -747,15 +746,14 @@ class ProfileControllerTest { when(zkProfileOperations.issueProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment)) .thenReturn(credentialResponse); - final Profile profile = resources.getJerseyTest() + final ProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, Hex.encodeHexString(credentialRequest.serialize()))) .request() .headers(authHeaders) - .get(Profile.class); + .get(ProfileKeyCredentialProfileResponse.class); - assertThat(profile.getUuid()).isNull(); + assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); assertThat(profile.getCredential()).isEqualTo(credentialResponse); - assertThat(profile.getPniCredential()).isNull(); verify(zkProfileOperations).issueProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment); verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); @@ -809,15 +807,14 @@ class ProfileControllerTest { when(zkProfileOperations.issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment)) .thenReturn(credentialResponse); - final Profile profile = resources.getJerseyTest() + final PniCredentialProfileResponse profile = resources.getJerseyTest() .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, Hex.encodeHexString(credentialRequest.serialize()))) .queryParam("credentialType", "pni") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(Profile.class); + .get(PniCredentialProfileResponse.class); - assertThat(profile.getUuid()).isNull(); - assertThat(profile.getCredential()).isNull(); + assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); assertThat(profile.getPniCredential()).isEqualTo(credentialResponse); verify(zkProfileOperations, never()).issueProfileKeyCredential(any(), any(), any());