minor cleanup, docs, and integration tests for username API

This commit is contained in:
Sergey Skrobotov 2023-05-31 23:39:02 -07:00
parent 47cc7fd615
commit e6917d8427
7 changed files with 306 additions and 183 deletions

View File

@ -269,6 +269,15 @@ public final class Operations {
return requireNonNull(execute.getRight()); return requireNonNull(execute.getRight());
} }
public void executeExpectStatusCode(final int expectedStatusCode) {
final Pair<Integer, Void> execute = execute(Void.class);
Validate.isTrue(
execute.getLeft() == expectedStatusCode,
"Unexpected response code: %d",
execute.getLeft()
);
}
public <T> Pair<Integer, T> execute(final Class<T> expectedType) { public <T> Pair<Integer, T> execute(final Class<T> expectedType) {
builder.uri(serverUri(endpoint, queryParams)) builder.uri(serverUri(endpoint, queryParams))
.header(HttpHeaders.USER_AGENT, USER_AGENT); .header(HttpHeaders.USER_AGENT, USER_AGENT);

View File

@ -0,0 +1,121 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.Test;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;
import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
public class AccountTest {
@Test
public void testCreateAccount() throws Exception {
final TestUser user = Operations.newRegisteredUser("+19995550101");
try {
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
.authorized(user)
.execute(AccountIdentityResponse.class);
assertEquals(HttpStatus.SC_OK, execute.getLeft());
} finally {
Operations.deleteUser(user);
}
}
@Test
public void testCreateAccountAtomic() throws Exception {
final TestUser user = Operations.newRegisteredUserAtomic("+19995550201");
try {
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
.authorized(user)
.execute(AccountIdentityResponse.class);
assertEquals(HttpStatus.SC_OK, execute.getLeft());
} finally {
Operations.deleteUser(user);
}
}
@Test
public void testUsernameOperations() throws Exception {
final TestUser user = Operations.newRegisteredUser("+19995550102");
try {
verifyFullUsernameLifecycle(user);
// no do it again to check changing usernames
verifyFullUsernameLifecycle(user);
} finally {
Operations.deleteUser(user);
}
}
private static void verifyFullUsernameLifecycle(final TestUser user) throws BaseUsernameException {
final String preferred = "test";
final List<Username> candidates = Username.candidatesFrom(preferred, preferred.length(), preferred.length() + 1);
// reserve a username
final ReserveUsernameHashRequest reserveUsernameHashRequest = new ReserveUsernameHashRequest(
candidates.stream().map(Username::getHash).toList());
// try unauthorized
Operations
.apiPut("/v1/accounts/username_hash/reserve", reserveUsernameHashRequest)
.executeExpectStatusCode(HttpStatus.SC_UNAUTHORIZED);
final ReserveUsernameHashResponse reserveUsernameHashResponse = Operations
.apiPut("/v1/accounts/username_hash/reserve", reserveUsernameHashRequest)
.authorized(user)
.executeExpectSuccess(ReserveUsernameHashResponse.class);
// find which one is the reserved username
final byte[] reservedHash = reserveUsernameHashResponse.usernameHash();
final Username reservedUsername = candidates.stream()
.filter(u -> Arrays.equals(u.getHash(), reservedHash))
.findAny()
.orElseThrow();
// confirm a username
final ConfirmUsernameHashRequest confirmUsernameHashRequest = new ConfirmUsernameHashRequest(
reservedUsername.getHash(),
reservedUsername.generateProof()
);
// try unauthorized
Operations
.apiPut("/v1/accounts/username_hash/confirm", confirmUsernameHashRequest)
.executeExpectStatusCode(HttpStatus.SC_UNAUTHORIZED);
Operations
.apiPut("/v1/accounts/username_hash/confirm", confirmUsernameHashRequest)
.authorized(user)
.executeExpectSuccess(UsernameHashResponse.class);
// lookup username
final AccountIdentifierResponse accountIdentifierResponse = Operations
.apiGet("/v1/accounts/username_hash/" + Base64.getUrlEncoder().encodeToString(reservedHash))
.executeExpectSuccess(AccountIdentifierResponse.class);
assertEquals(user.aciUuid(), accountIdentifierResponse.uuid());
// try authorized
Operations
.apiGet("/v1/accounts/username_hash/" + Base64.getUrlEncoder().encodeToString(reservedHash))
.authorized(user)
.executeExpectStatusCode(HttpStatus.SC_BAD_REQUEST);
// delete username
Operations
.apiDelete("/v1/accounts/username_hash")
.authorized(user)
.executeExpectSuccess();
}
}

View File

@ -1,140 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
import org.whispersystems.textsecuregcm.storage.Device;
public class IntegrationTest {
@Test
public void testCreateAccount() throws Exception {
final TestUser user = Operations.newRegisteredUser("+19995550101");
try {
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
.authorized(user)
.execute(AccountIdentityResponse.class);
assertEquals(200, execute.getLeft());
} finally {
Operations.deleteUser(user);
}
}
@Test
public void testCreateAccountAtomic() throws Exception {
final TestUser user = Operations.newRegisteredUserAtomic("+19995550201");
try {
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
.authorized(user)
.execute(AccountIdentityResponse.class);
assertEquals(200, execute.getLeft());
} finally {
Operations.deleteUser(user);
}
}
@Test
public void testRegistration() throws Exception {
final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);
final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest("+19995550102", originalRequest);
final VerificationSessionResponse verificationSessionResponse = Operations
.apiPost("/v1/verification/session", input)
.executeExpectSuccess(VerificationSessionResponse.class);
System.out.println("session created: " + verificationSessionResponse);
final String sessionId = verificationSessionResponse.id();
final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);
// supply push challenge
final UpdateVerificationSessionRequest updatedRequest = new UpdateVerificationSessionRequest(
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, pushChallenge, null, null, null);
final VerificationSessionResponse pushChallengeSupplied = Operations
.apiPatch("/v1/verification/session/%s".formatted(sessionId), updatedRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
System.out.println("push challenge supplied: " + pushChallengeSupplied);
Assertions.assertTrue(pushChallengeSupplied.allowedToRequestCode());
// request code
final VerificationCodeRequest verificationCodeRequest = new VerificationCodeRequest(
VerificationCodeRequest.Transport.SMS, "android-ng");
final VerificationSessionResponse codeRequested = Operations
.apiPost("/v1/verification/session/%s/code".formatted(sessionId), verificationCodeRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
System.out.println("code requested: " + codeRequested);
// verify code
final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest("265402");
final VerificationSessionResponse codeVerified = Operations
.apiPut("/v1/verification/session/%s/code".formatted(sessionId), submitVerificationCodeRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
System.out.println("sms code supplied: " + codeVerified);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testSendMessageUnsealed(final boolean atomicAccountCreation) throws Exception {
final TestUser userA;
final TestUser userB;
if (atomicAccountCreation) {
userA = Operations.newRegisteredUserAtomic("+19995550102");
userB = Operations.newRegisteredUserAtomic("+19995550103");
} else {
userA = Operations.newRegisteredUser("+19995550102");
userB = Operations.newRegisteredUser("+19995550103");
}
try {
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
final IncomingMessage message = new IncomingMessage(1, Device.MASTER_ID, userB.registrationId(), contentBase64);
final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
System.out.println("Sending message");
final Pair<Integer, SendMessageResponse> sendMessage = Operations
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
.authorized(userA)
.execute(SendMessageResponse.class);
System.out.println("Message sent: " + sendMessage);
System.out.println("Receive message");
final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet("/v1/messages")
.authorized(userB)
.execute(OutgoingMessageEntityList.class);
System.out.println("Message received: " + receiveMessages);
final byte[] actualContent = receiveMessages.getRight().messages().get(0).content();
assertArrayEquals(expectedContent, actualContent);
} finally {
Operations.deleteUser(userA);
Operations.deleteUser(userB);
}
}
}

View File

@ -0,0 +1,60 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.storage.Device;
public class MessagingTest {
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testSendMessageUnsealed(final boolean atomicAccountCreation) throws Exception {
final TestUser userA;
final TestUser userB;
if (atomicAccountCreation) {
userA = Operations.newRegisteredUserAtomic("+19995550102");
userB = Operations.newRegisteredUserAtomic("+19995550103");
} else {
userA = Operations.newRegisteredUser("+19995550104");
userB = Operations.newRegisteredUser("+19995550105");
}
try {
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
final IncomingMessage message = new IncomingMessage(1, Device.MASTER_ID, userB.registrationId(), contentBase64);
final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
final Pair<Integer, SendMessageResponse> sendMessage = Operations
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
.authorized(userA)
.execute(SendMessageResponse.class);
final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet("/v1/messages")
.authorized(userB)
.execute(OutgoingMessageEntityList.class);
final byte[] actualContent = receiveMessages.getRight().messages().get(0).content();
assertArrayEquals(expectedContent, actualContent);
} finally {
Operations.deleteUser(userA);
Operations.deleteUser(userB);
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
public class RegistrationTest {
@Test
public void testRegistration() throws Exception {
final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);
final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest("+19995550102", originalRequest);
final VerificationSessionResponse verificationSessionResponse = Operations
.apiPost("/v1/verification/session", input)
.executeExpectSuccess(VerificationSessionResponse.class);
final String sessionId = verificationSessionResponse.id();
final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);
// supply push challenge
final UpdateVerificationSessionRequest updatedRequest = new UpdateVerificationSessionRequest(
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, pushChallenge, null, null, null);
final VerificationSessionResponse pushChallengeSupplied = Operations
.apiPatch("/v1/verification/session/%s".formatted(sessionId), updatedRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
Assertions.assertTrue(pushChallengeSupplied.allowedToRequestCode());
// request code
final VerificationCodeRequest verificationCodeRequest = new VerificationCodeRequest(
VerificationCodeRequest.Transport.SMS, "android-ng");
final VerificationSessionResponse codeRequested = Operations
.apiPost("/v1/verification/session/%s/code".formatted(sessionId), verificationCodeRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
// verify code
final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest("265402");
final VerificationSessionResponse codeVerified = Operations
.apiPut("/v1/verification/session/%s/code".formatted(sessionId), submitVerificationCodeRequest)
.executeExpectSuccess(VerificationSessionResponse.class);
}
}

View File

@ -35,7 +35,6 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException; import javax.ws.rs.BadRequestException;
@ -52,7 +51,6 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
@ -91,7 +89,6 @@ import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.entities.UsernameHashResponse; import org.whispersystems.textsecuregcm.entities.UsernameHashResponse;
import org.whispersystems.textsecuregcm.entities.UsernameLinkHandle; import org.whispersystems.textsecuregcm.entities.UsernameLinkHandle;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotification;
@ -704,7 +701,15 @@ public class AccountController {
@DELETE @DELETE
@Path("/username_hash") @Path("/username_hash")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public void deleteUsernameHash(final @Auth AuthenticatedAccount auth) { @Operation(
summary = "Delete username hash",
description = """
Authenticated endpoint. Deletes previously stored username for the account.
"""
)
@ApiResponse(responseCode = "204", description = "Username successfully deleted.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
public void deleteUsernameHash(@Auth final AuthenticatedAccount auth) {
clearUsernameLink(auth.getAccount()); clearUsernameLink(auth.getAccount());
accounts.clearUsernameHash(auth.getAccount()); accounts.clearUsernameHash(auth.getAccount());
} }
@ -714,13 +719,25 @@ public class AccountController {
@Path("/username_hash/reserve") @Path("/username_hash/reserve")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public ReserveUsernameHashResponse reserveUsernameHash(@Auth AuthenticatedAccount auth, @Operation(
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent, summary = "Reserve username hash",
@NotNull @Valid ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException { description = """
Authenticated endpoint. Takes in a list of hashes of potential username hashes, finds one that is not taken,
and reserves it for the current account.
"""
)
@ApiResponse(responseCode = "200", description = "Username hash reserved successfully.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "409", description = "All username hashes from the list are taken.")
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public ReserveUsernameHashResponse reserveUsernameHash(
@Auth final AuthenticatedAccount auth,
@NotNull @Valid final ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {
rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid()); rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid());
for (byte[] hash : usernameRequest.usernameHashes()) { for (final byte[] hash : usernameRequest.usernameHashes()) {
if (hash.length != USERNAME_HASH_LENGTH) { if (hash.length != USERNAME_HASH_LENGTH) {
throw new WebApplicationException(Response.status(422).build()); throw new WebApplicationException(Response.status(422).build());
} }
@ -742,9 +759,21 @@ public class AccountController {
@Path("/username_hash/confirm") @Path("/username_hash/confirm")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Operation(
summary = "Confirm username hash",
description = """
Authenticated endpoint. For a previously reserved username hash, confirm that this username hash is now taken
by this account.
"""
)
@ApiResponse(responseCode = "200", description = "Username hash confirmed successfully.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "409", description = "Given username hash doesn't match the reserved one or no reservation found.")
@ApiResponse(responseCode = "410", description = "Username hash not available (username can't be used).")
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
public UsernameHashResponse confirmUsernameHash( public UsernameHashResponse confirmUsernameHash(
@Auth final AuthenticatedAccount auth, @Auth final AuthenticatedAccount auth,
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent,
@NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException { @NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid()); rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid());
@ -777,19 +806,20 @@ public class AccountController {
@Path("/username_hash/{usernameHash}") @Path("/username_hash/{usernameHash}")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@RateLimitedByIp(RateLimiters.For.USERNAME_LOOKUP) @RateLimitedByIp(RateLimiters.For.USERNAME_LOOKUP)
@Operation(
summary = "Lookup username hash",
description = """
Forced unauthenticated endpoint. For the given username hash, look up a user ID.
"""
)
@ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Request must not be authenticated.")
@ApiResponse(responseCode = "404", description = "Account not fount for the given username.")
public AccountIdentifierResponse lookupUsernameHash( public AccountIdentifierResponse lookupUsernameHash(
@HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent, @Auth final Optional<AuthenticatedAccount> maybeAuthenticatedAccount,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor, @PathParam("usernameHash") final String usernameHash) throws RateLimitExceededException {
@PathParam("usernameHash") final String usernameHash,
@Context final HttpServletRequest request) throws RateLimitExceededException {
// Disallow clients from making authenticated requests to this endpoint
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) {
throw new BadRequestException();
}
rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor);
requireNotAuthenticated(maybeAuthenticatedAccount);
final byte[] hash; final byte[] hash;
try { try {
hash = Base64.getUrlDecoder().decode(usernameHash); hash = Base64.getUrlDecoder().decode(usernameHash);
@ -879,13 +909,11 @@ public class AccountController {
@ApiResponse(responseCode = "422", description = "Invalid request format.") @ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.") @ApiResponse(responseCode = "429", description = "Ratelimited.")
public EncryptedUsername lookupUsernameLink( public EncryptedUsername lookupUsernameLink(
@Auth Optional<AuthenticatedAccount> authenticatedAccount, @Auth final Optional<AuthenticatedAccount> maybeAuthenticatedAccount,
@PathParam("uuid") final UUID usernameLinkHandle) { @PathParam("uuid") final UUID usernameLinkHandle) {
final Optional<byte[]> maybeEncryptedUsername = accounts.getByUsernameLinkHandle(usernameLinkHandle) final Optional<byte[]> maybeEncryptedUsername = accounts.getByUsernameLinkHandle(usernameLinkHandle)
.flatMap(Account::getEncryptedUsername); .flatMap(Account::getEncryptedUsername);
if (authenticatedAccount.isPresent()) { requireNotAuthenticated(maybeAuthenticatedAccount);
throw new ForbiddenException("must not use authenticated connection for connection graph revealing operations");
}
if (maybeEncryptedUsername.isEmpty()) { if (maybeEncryptedUsername.isEmpty()) {
throw new WebApplicationException(Status.NOT_FOUND); throw new WebApplicationException(Status.NOT_FOUND);
} }
@ -896,13 +924,11 @@ public class AccountController {
@Path("/account/{uuid}") @Path("/account/{uuid}")
@RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE) @RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)
public Response accountExists( public Response accountExists(
@PathParam("uuid") final UUID uuid, @Auth final Optional<AuthenticatedAccount> authenticatedAccount,
@Context HttpServletRequest request) throws RateLimitExceededException { @PathParam("uuid") final UUID uuid) throws RateLimitExceededException {
// Disallow clients from making authenticated requests to this endpoint // Disallow clients from making authenticated requests to this endpoint
if (StringUtils.isNotBlank(request.getHeader("Authorization"))) { requireNotAuthenticated(authenticatedAccount);
throw new BadRequestException();
}
final Status status = accounts.getByAccountIdentifier(uuid) final Status status = accounts.getByAccountIdentifier(uuid)
.or(() -> accounts.getByPhoneNumberIdentifier(uuid)) .or(() -> accounts.getByPhoneNumberIdentifier(uuid))
@ -911,19 +937,6 @@ public class AccountController {
return Response.status(status).build(); return Response.status(status).build();
} }
private void rateLimitByClientIp(final RateLimiter rateLimiter, final String forwardedFor) throws RateLimitExceededException {
final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor)
.orElseThrow(() -> {
// Missing/malformed Forwarded-For, so we cannot check for a rate-limit.
// This shouldn't happen, so conservatively assume we're over the rate-limit
// and indicate that the client should retry
logger.error("Missing/bad Forwarded-For: {}", forwardedFor);
return new RateLimitExceededException(Duration.ofHours(1), true);
});
rateLimiter.validate(mostRecentProxy);
}
@VisibleForTesting @VisibleForTesting
static boolean pushChallengeMatches( static boolean pushChallengeMatches(
final String number, final String number,
@ -1038,4 +1051,10 @@ public class AccountController {
throw rateLimitExceededException; throw rateLimitExceededException;
} }
} }
private void requireNotAuthenticated(final Optional<AuthenticatedAccount> authenticatedAccount) {
if (authenticatedAccount.isPresent()) {
throw new BadRequestException("Operation requires unauthenticated access");
}
}
} }

View File

@ -1988,7 +1988,7 @@ class AccountControllerTest {
static Stream<Arguments> testLookupUsernameLink() { static Stream<Arguments> testLookupUsernameLink() {
return Stream.of( return Stream.of(
Arguments.of(false, true, true, true, 403), Arguments.of(false, true, true, true, 400),
Arguments.of(true, false, true, true, 429), Arguments.of(true, false, true, true, 429),
Arguments.of(true, true, false, true, 404), Arguments.of(true, true, false, true, 404),
Arguments.of(true, true, true, false, 404), Arguments.of(true, true, true, false, 404),