Add /v2/svr as an alternative name for /v2/backup
This commit is contained in:
parent
65e1f1b3a9
commit
7d41c1219b
|
@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import io.dropwizard.auth.Auth;
|
import io.dropwizard.auth.Auth;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
@ -35,8 +36,9 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.textsecuregcm.storage.Account;
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
|
||||||
@Path("/v2/backup")
|
@Path("/v2/{name: backup|svr}")
|
||||||
@Tag(name = "Secure Value Recovery")
|
@Tag(name = "Secure Value Recovery")
|
||||||
|
@Schema(description = "Note: /v2/backup is deprecated. Use /v2/svr instead.")
|
||||||
public class SecureValueRecovery2Controller {
|
public class SecureValueRecovery2Controller {
|
||||||
|
|
||||||
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
|
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
|
||||||
|
|
|
@ -1,64 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2024 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.entities;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonValue;
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import java.util.Map;
|
|
||||||
import javax.annotation.Nullable;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
|
|
||||||
|
|
||||||
public record AuthCheckResponseV3(
|
|
||||||
@Schema(description = """
|
|
||||||
A dictionary with the auth check results, keyed by the token corresponding token provided in the request.
|
|
||||||
""")
|
|
||||||
@NotNull Map<String, Result> matches) {
|
|
||||||
|
|
||||||
public record Result(
|
|
||||||
@Schema(description = "The status of the credential. Either match, no-match, or invalid")
|
|
||||||
CredentialStatus status,
|
|
||||||
|
|
||||||
@Schema(description = """
|
|
||||||
If the credential was a match, the stored shareSet that can be used to restore a value from SVR. Encoded in
|
|
||||||
standard un-padded base64.
|
|
||||||
""", implementation = String.class)
|
|
||||||
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
|
|
||||||
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
|
|
||||||
@Nullable byte[] shareSet) {
|
|
||||||
|
|
||||||
public static Result invalid() {
|
|
||||||
return new Result(CredentialStatus.INVALID, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result noMatch() {
|
|
||||||
return new Result(CredentialStatus.NO_MATCH, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Result match(@Nullable final byte[] shareSet) {
|
|
||||||
return new Result(CredentialStatus.MATCH, shareSet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum CredentialStatus {
|
|
||||||
MATCH("match"),
|
|
||||||
NO_MATCH("no-match"),
|
|
||||||
INVALID("invalid");
|
|
||||||
|
|
||||||
private final String clientCode;
|
|
||||||
|
|
||||||
CredentialStatus(final String clientCode) {
|
|
||||||
this.clientCode = clientCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonValue
|
|
||||||
public String clientCode() {
|
|
||||||
return clientCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,26 +6,41 @@
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
|
import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;
|
||||||
|
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
|
import jakarta.ws.rs.client.Entity;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
|
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||||
public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryControllerBaseTest {
|
public class SecureValueRecovery2ControllerTest {
|
||||||
|
|
||||||
private static final SecureValueRecovery2Configuration CFG = new SecureValueRecovery2Configuration(
|
private static final SecureValueRecovery2Configuration CFG = new SecureValueRecovery2Configuration(
|
||||||
"",
|
"",
|
||||||
|
@ -52,19 +67,305 @@ public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryContr
|
||||||
.addResource(CONTROLLER)
|
.addResource(CONTROLLER)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
protected SecureValueRecovery2ControllerTest() {
|
@Nested
|
||||||
super("/v2", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
|
class WithBackupsPrefix extends SecureValueRecoveryControllerBaseTest {
|
||||||
|
protected WithBackupsPrefix() {
|
||||||
|
super("/v2/backup");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Nested
|
||||||
Map<String, CheckStatus> parseCheckResponse(final Response response) {
|
class WithSvr2Prefix extends SecureValueRecoveryControllerBaseTest {
|
||||||
final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);
|
protected WithSvr2Prefix() {
|
||||||
return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(
|
super("/v2/svr");
|
||||||
Map.Entry::getKey, e -> switch (e.getValue()) {
|
}
|
||||||
case MATCH -> CheckStatus.MATCH;
|
}
|
||||||
case INVALID -> CheckStatus.INVALID;
|
|
||||||
case NO_MATCH -> CheckStatus.NO_MATCH;
|
static abstract class SecureValueRecoveryControllerBaseTest {
|
||||||
}
|
private static final UUID USER_1 = UUID.randomUUID();
|
||||||
));
|
private static final UUID USER_2 = UUID.randomUUID();
|
||||||
|
private static final UUID USER_3 = UUID.randomUUID();
|
||||||
|
private static final String E164_VALID = "+18005550123";
|
||||||
|
private static final String E164_INVALID = "1(800)555-0123";
|
||||||
|
|
||||||
|
private final String pathPrefix;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void before() throws Exception {
|
||||||
|
Mockito.reset(ACCOUNTS_MANAGER);
|
||||||
|
Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected SecureValueRecoveryControllerBaseTest(final String pathPrefix) {
|
||||||
|
this.pathPrefix = pathPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CheckStatus {
|
||||||
|
MATCH,
|
||||||
|
NO_MATCH,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, CheckStatus> parseCheckResponse(final Response response) {
|
||||||
|
final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);
|
||||||
|
return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(
|
||||||
|
Map.Entry::getKey, e -> switch (e.getValue()) {
|
||||||
|
case MATCH -> CheckStatus.MATCH;
|
||||||
|
case INVALID -> CheckStatus.INVALID;
|
||||||
|
case NO_MATCH -> CheckStatus.NO_MATCH;
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testOneMatch() {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(1)), CheckStatus.MATCH,
|
||||||
|
token(USER_2, day(1)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(1)), CheckStatus.NO_MATCH
|
||||||
|
), day(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoMatch() {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_2, day(1)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(1)), CheckStatus.NO_MATCH
|
||||||
|
), day(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSomeInvalid() {
|
||||||
|
final ExternalServiceCredentials user1Cred = credentials(USER_1, day(1));
|
||||||
|
final ExternalServiceCredentials user2Cred = credentials(USER_2, day(1));
|
||||||
|
final ExternalServiceCredentials user3Cred = credentials(USER_3, day(1));
|
||||||
|
|
||||||
|
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
|
||||||
|
validate(Map.of(
|
||||||
|
token(user1Cred), CheckStatus.MATCH,
|
||||||
|
token(user2Cred), CheckStatus.NO_MATCH,
|
||||||
|
fakeToken, CheckStatus.INVALID
|
||||||
|
), day(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSomeExpired() {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(100)), CheckStatus.MATCH,
|
||||||
|
token(USER_2, day(100)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), CheckStatus.INVALID,
|
||||||
|
token(USER_3, day(20)), CheckStatus.INVALID
|
||||||
|
), day(110));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSomeHaveNewerVersions() {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(10)), CheckStatus.INVALID,
|
||||||
|
token(USER_1, day(20)), CheckStatus.MATCH,
|
||||||
|
token(USER_2, day(10)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(20)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), CheckStatus.INVALID
|
||||||
|
), day(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(
|
||||||
|
final Map<String, CheckStatus> expected,
|
||||||
|
final long nowMillis) {
|
||||||
|
CLOCK.setTimeMillis(nowMillis);
|
||||||
|
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
||||||
|
final Response response = RESOURCES.getJerseyTest().target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(request, MediaType.APPLICATION_JSON));
|
||||||
|
try (response) {
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
final Map<String, CheckStatus> res = parseCheckResponse(response);
|
||||||
|
assertEquals(expected, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeSuccess() {
|
||||||
|
final Map<String, CheckStatus> expected = Map.of(
|
||||||
|
token(USER_1, day(10)), CheckStatus.INVALID,
|
||||||
|
token(USER_1, day(20)), CheckStatus.MATCH,
|
||||||
|
token(USER_2, day(10)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(20)), CheckStatus.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), CheckStatus.INVALID
|
||||||
|
);
|
||||||
|
|
||||||
|
CLOCK.setTimeMillis(day(25));
|
||||||
|
|
||||||
|
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
||||||
|
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
assertEquals(expected, parseCheckResponse(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenInvalidNumber() {
|
||||||
|
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(422, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenTooManyTokens() {
|
||||||
|
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
|
||||||
|
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
|
||||||
|
));
|
||||||
|
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
|
||||||
|
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
|
||||||
|
));
|
||||||
|
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
|
||||||
|
|
||||||
|
final Response responseOkay = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
final Response responseError1 = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
final Response responseError2 = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (responseOkay; responseError1; responseError2) {
|
||||||
|
assertEquals(200, responseOkay.getStatus());
|
||||||
|
assertEquals(422, responseError1.getStatus());
|
||||||
|
assertEquals(422, responseError2.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenPasswordsMissing() {
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("""
|
||||||
|
{
|
||||||
|
"number": "123"
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(422, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenNumberMissing() {
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("""
|
||||||
|
{
|
||||||
|
"passwords": ["aaa:bbb"]
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(422, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenExtraFields() {
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("""
|
||||||
|
{
|
||||||
|
"number": "+18005550123",
|
||||||
|
"passwords": ["aaa:bbb"],
|
||||||
|
"unexpected": "value"
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAcceptsPasswordsOrTokens() {
|
||||||
|
final Response passwordsResponse = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("""
|
||||||
|
{
|
||||||
|
"number": "+18005550123",
|
||||||
|
"passwords": ["aaa:bbb"]
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
try (passwordsResponse) {
|
||||||
|
assertEquals(200, passwordsResponse.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
final Response tokensResponse = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("""
|
||||||
|
{
|
||||||
|
"number": "+18005550123",
|
||||||
|
"tokens": ["aaa:bbb"]
|
||||||
|
}
|
||||||
|
""", MediaType.APPLICATION_JSON));
|
||||||
|
try (tokensResponse) {
|
||||||
|
assertEquals(200, tokensResponse.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeWhenNotAJson() {
|
||||||
|
final Response response = RESOURCES.getJerseyTest()
|
||||||
|
.target(pathPrefix + "/auth/check")
|
||||||
|
.request()
|
||||||
|
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
try (response) {
|
||||||
|
assertEquals(400, response.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String token(final UUID uuid, final long timeMillis) {
|
||||||
|
return token(credentials(uuid, timeMillis));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String token(final ExternalServiceCredentials credentials) {
|
||||||
|
return credentials.username() + ":" + credentials.password();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
|
||||||
|
CLOCK.setTimeMillis(timeMillis);
|
||||||
|
return CREDENTIAL_GENERATOR.generateForUuid(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long day(final int n) {
|
||||||
|
return TimeUnit.DAYS.toMillis(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Account account(final UUID uuid) {
|
||||||
|
final Account a = new Account();
|
||||||
|
a.setUuid(uuid);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,324 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
|
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
|
||||||
import jakarta.ws.rs.client.Entity;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
|
||||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.Account;
|
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
|
||||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
|
||||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
|
||||||
|
|
||||||
abstract class SecureValueRecoveryControllerBaseTest {
|
|
||||||
|
|
||||||
private static final UUID USER_1 = UUID.randomUUID();
|
|
||||||
|
|
||||||
private static final UUID USER_2 = UUID.randomUUID();
|
|
||||||
|
|
||||||
private static final UUID USER_3 = UUID.randomUUID();
|
|
||||||
|
|
||||||
private static final String E164_VALID = "+18005550123";
|
|
||||||
|
|
||||||
private static final String E164_INVALID = "1(800)555-0123";
|
|
||||||
|
|
||||||
private final String pathPrefix;
|
|
||||||
private final ResourceExtension resourceExtension;
|
|
||||||
private final AccountsManager mockAccountsManager;
|
|
||||||
private final ExternalServiceCredentialsGenerator credentialsGenerator;
|
|
||||||
private final MutableClock clock;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
public void before() throws Exception {
|
|
||||||
Mockito.when(mockAccountsManager.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected SecureValueRecoveryControllerBaseTest(
|
|
||||||
final String pathPrefix,
|
|
||||||
final AccountsManager mockAccountsManager,
|
|
||||||
final MutableClock mutableClock,
|
|
||||||
final ResourceExtension resourceExtension,
|
|
||||||
final ExternalServiceCredentialsGenerator credentialsGenerator) {
|
|
||||||
this.pathPrefix = pathPrefix;
|
|
||||||
this.resourceExtension = resourceExtension;
|
|
||||||
this.mockAccountsManager = mockAccountsManager;
|
|
||||||
this.credentialsGenerator = credentialsGenerator;
|
|
||||||
this.clock = mutableClock;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CheckStatus {
|
|
||||||
MATCH,
|
|
||||||
NO_MATCH,
|
|
||||||
INVALID
|
|
||||||
}
|
|
||||||
abstract Map<String, CheckStatus> parseCheckResponse(Response response);
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testOneMatch() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(1)), CheckStatus.MATCH,
|
|
||||||
token(USER_2, day(1)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(1)), CheckStatus.NO_MATCH
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testNoMatch() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_2, day(1)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(1)), CheckStatus.NO_MATCH
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeInvalid() throws Exception {
|
|
||||||
final ExternalServiceCredentials user1Cred = credentials(USER_1, day(1));
|
|
||||||
final ExternalServiceCredentials user2Cred = credentials(USER_2, day(1));
|
|
||||||
final ExternalServiceCredentials user3Cred = credentials(USER_3, day(1));
|
|
||||||
|
|
||||||
final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));
|
|
||||||
validate(Map.of(
|
|
||||||
token(user1Cred), CheckStatus.MATCH,
|
|
||||||
token(user2Cred), CheckStatus.NO_MATCH,
|
|
||||||
fakeToken, CheckStatus.INVALID
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeExpired() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(100)), CheckStatus.MATCH,
|
|
||||||
token(USER_2, day(100)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), CheckStatus.INVALID,
|
|
||||||
token(USER_3, day(20)), CheckStatus.INVALID
|
|
||||||
), day(110));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeHaveNewerVersions() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(10)), CheckStatus.INVALID,
|
|
||||||
token(USER_1, day(20)), CheckStatus.MATCH,
|
|
||||||
token(USER_2, day(10)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(20)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), CheckStatus.INVALID
|
|
||||||
), day(25));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validate(
|
|
||||||
final Map<String, CheckStatus> expected,
|
|
||||||
final long nowMillis) throws Exception {
|
|
||||||
clock.setTimeMillis(nowMillis);
|
|
||||||
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
|
||||||
final Response response = resourceExtension.getJerseyTest().target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(request, MediaType.APPLICATION_JSON));
|
|
||||||
try (response) {
|
|
||||||
assertEquals(200, response.getStatus());
|
|
||||||
final Map<String, CheckStatus> res = parseCheckResponse(response);
|
|
||||||
assertEquals(expected, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeSuccess() throws Exception {
|
|
||||||
final Map<String, CheckStatus> expected = Map.of(
|
|
||||||
token(USER_1, day(10)), CheckStatus.INVALID,
|
|
||||||
token(USER_1, day(20)), CheckStatus.MATCH,
|
|
||||||
token(USER_2, day(10)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(20)), CheckStatus.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), CheckStatus.INVALID
|
|
||||||
);
|
|
||||||
|
|
||||||
clock.setTimeMillis(day(25));
|
|
||||||
|
|
||||||
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
|
||||||
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(200, response.getStatus());
|
|
||||||
assertEquals(expected, parseCheckResponse(response));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
|
|
||||||
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(422, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenTooManyTokens() throws Exception {
|
|
||||||
final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(
|
|
||||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
|
|
||||||
));
|
|
||||||
final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(
|
|
||||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"
|
|
||||||
));
|
|
||||||
final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());
|
|
||||||
|
|
||||||
final Response responseOkay = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
final Response responseError1 = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
final Response responseError2 = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (responseOkay; responseError1; responseError2) {
|
|
||||||
assertEquals(200, responseOkay.getStatus());
|
|
||||||
assertEquals(422, responseError1.getStatus());
|
|
||||||
assertEquals(422, responseError2.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenPasswordsMissing() throws Exception {
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("""
|
|
||||||
{
|
|
||||||
"number": "123"
|
|
||||||
}
|
|
||||||
""", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(422, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenNumberMissing() throws Exception {
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("""
|
|
||||||
{
|
|
||||||
"passwords": ["aaa:bbb"]
|
|
||||||
}
|
|
||||||
""", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(422, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenExtraFields() throws Exception {
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("""
|
|
||||||
{
|
|
||||||
"number": "+18005550123",
|
|
||||||
"passwords": ["aaa:bbb"],
|
|
||||||
"unexpected": "value"
|
|
||||||
}
|
|
||||||
""", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(200, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testAcceptsPasswordsOrTokens() {
|
|
||||||
final Response passwordsResponse = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("""
|
|
||||||
{
|
|
||||||
"number": "+18005550123",
|
|
||||||
"passwords": ["aaa:bbb"]
|
|
||||||
}
|
|
||||||
""", MediaType.APPLICATION_JSON));
|
|
||||||
try (passwordsResponse) {
|
|
||||||
assertEquals(200, passwordsResponse.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
final Response tokensResponse = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("""
|
|
||||||
{
|
|
||||||
"number": "+18005550123",
|
|
||||||
"tokens": ["aaa:bbb"]
|
|
||||||
}
|
|
||||||
""", MediaType.APPLICATION_JSON));
|
|
||||||
try (tokensResponse) {
|
|
||||||
assertEquals(200, tokensResponse.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenNotAJson() throws Exception {
|
|
||||||
final Response response = resourceExtension.getJerseyTest()
|
|
||||||
.target(pathPrefix + "/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(400, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String token(final UUID uuid, final long timeMillis) {
|
|
||||||
return token(credentials(uuid, timeMillis));
|
|
||||||
}
|
|
||||||
|
|
||||||
static String token(final ExternalServiceCredentials credentials) {
|
|
||||||
return credentials.username() + ":" + credentials.password();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
|
|
||||||
clock.setTimeMillis(timeMillis);
|
|
||||||
return credentialsGenerator.generateForUuid(uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
static long day(final int n) {
|
|
||||||
return TimeUnit.DAYS.toMillis(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Account account(final UUID uuid) {
|
|
||||||
final Account a = new Account();
|
|
||||||
a.setUuid(uuid);
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue