minor cleanup, docs, and integration tests for username API
This commit is contained in:
parent
47cc7fd615
commit
e6917d8427
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in New Issue