Implement /v2/backup/auth/check
This commit is contained in:
parent
0e0c0c5dfe
commit
08333d5989
|
@ -744,7 +744,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
config.getRemoteConfigConfiguration().getGlobalConfig()),
|
config.getRemoteConfigConfiguration().getGlobalConfig()),
|
||||||
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
new SecureBackupController(backupCredentialsGenerator, accountsManager),
|
||||||
new SecureStorageController(storageCredentialsGenerator),
|
new SecureStorageController(storageCredentialsGenerator),
|
||||||
new SecureValueRecovery2Controller(svr2CredentialsGenerator, config.getSvr2Configuration()),
|
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager, config.getSvr2Configuration()),
|
||||||
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(),
|
||||||
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
|
config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(),
|
||||||
config.getCdnConfiguration().getBucket()),
|
config.getCdnConfiguration().getBucket()),
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class ExternalServiceCredentialsSelector {
|
||||||
|
|
||||||
|
private ExternalServiceCredentialsSelector() {}
|
||||||
|
|
||||||
|
public record CredentialInfo(String token, boolean valid, ExternalServiceCredentials credentials, long timestamp) {
|
||||||
|
/**
|
||||||
|
* @return a copy of this record with valid=false
|
||||||
|
*/
|
||||||
|
private CredentialInfo invalidate() {
|
||||||
|
return new CredentialInfo(token, false, credentials, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a list of username:password credentials.
|
||||||
|
* A credential is valid if it passes validation by the provided credentialsGenerator AND it is the most recent
|
||||||
|
* credential in the provided list for a username.
|
||||||
|
*
|
||||||
|
* @param tokens A list of credentials, potentially with different usernames
|
||||||
|
* @param credentialsGenerator To validate these credentials
|
||||||
|
* @param maxAgeSeconds The maximum allowable age of the credential
|
||||||
|
* @return A {@link CredentialInfo} for each provided token
|
||||||
|
*/
|
||||||
|
public static List<CredentialInfo> check(
|
||||||
|
final List<String> tokens,
|
||||||
|
final ExternalServiceCredentialsGenerator credentialsGenerator,
|
||||||
|
final long maxAgeSeconds) {
|
||||||
|
|
||||||
|
// the credential for the username with the latest timestamp (so far)
|
||||||
|
final Map<String, CredentialInfo> bestForUsername = new HashMap<>();
|
||||||
|
final List<CredentialInfo> results = new ArrayList<>();
|
||||||
|
for (String token : tokens) {
|
||||||
|
// each token is supposed to be in a "${username}:${password}" form,
|
||||||
|
// (note that password part may also contain ':' characters)
|
||||||
|
final String[] parts = token.split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
results.add(new CredentialInfo(token, false, null, 0L));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
||||||
|
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds);
|
||||||
|
if (maybeTimestamp.isEmpty()) {
|
||||||
|
results.add(new CredentialInfo(token, false, null, 0L));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// now that we validated signature and token age, we will also find the latest of the tokens
|
||||||
|
// for each username
|
||||||
|
final long timestamp = maybeTimestamp.get();
|
||||||
|
final CredentialInfo best = bestForUsername.get(credentials.username());
|
||||||
|
if (best == null) {
|
||||||
|
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (best.timestamp() < timestamp) {
|
||||||
|
// we found a better credential for the username
|
||||||
|
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||||
|
// mark the previous best as an invalid credential, since we have a better credential now
|
||||||
|
results.add(best.invalidate());
|
||||||
|
} else {
|
||||||
|
// the credential we already had was more recent, this one can be marked invalid
|
||||||
|
results.add(new CredentialInfo(token, false, null, 0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// all invalid tokens should be in results, just add the valid ones
|
||||||
|
results.addAll(bestForUsername.values());
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -13,12 +13,11 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||||
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 java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.util.HashMap;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
|
@ -27,9 +26,9 @@ import javax.ws.rs.POST;
|
||||||
import javax.ws.rs.Path;
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.Produces;
|
import javax.ws.rs.Produces;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||||
|
@ -97,7 +96,7 @@ public class SecureBackupController {
|
||||||
summary = "Check SVR credentials",
|
summary = "Check SVR credentials",
|
||||||
description = """
|
description = """
|
||||||
Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage.
|
Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage.
|
||||||
To determine which set is most current and should be used to communicate with SVR to retrieve a master password
|
To determine which set is most current and should be used to communicate with SVR to retrieve a master key
|
||||||
(from which a registration recovery password can be derived), clients should call this endpoint
|
(from which a registration recovery password can be derived), clients should call this endpoint
|
||||||
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR.
|
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR.
|
||||||
"""
|
"""
|
||||||
|
@ -106,70 +105,27 @@ public class SecureBackupController {
|
||||||
@ApiResponse(responseCode = "422", description = "Provided list of KBS credentials could not be parsed")
|
@ApiResponse(responseCode = "422", description = "Provided list of KBS credentials could not be parsed")
|
||||||
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||||
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||||
final Map<String, AuthCheckResponse.Result> results = new HashMap<>();
|
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
|
||||||
final Map<String, Pair<UUID, Long>> tokenToUuid = new HashMap<>();
|
request.passwords(),
|
||||||
final Map<UUID, Long> uuidToLatestTimestamp = new HashMap<>();
|
credentialsGenerator,
|
||||||
|
MAX_AGE_SECONDS);
|
||||||
// first pass -- filter out all tokens that contain invalid credentials
|
|
||||||
// (this could be either legit but expired or illegitimate for any reason)
|
|
||||||
request.passwords().forEach(token -> {
|
|
||||||
// each token is supposed to be in a "${username}:${password}" form,
|
|
||||||
// (note that password part may also contain ':' characters)
|
|
||||||
final String[] parts = token.split(":", 2);
|
|
||||||
if (parts.length != 2) {
|
|
||||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
|
||||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS);
|
|
||||||
final Optional<UUID> maybeUuid = UUIDUtil.fromStringSafe(credentials.username());
|
|
||||||
if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) {
|
|
||||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// now that we validated signature and token age, we will also find the latest of the tokens
|
|
||||||
// for each username
|
|
||||||
final Long timestamp = maybeTimestamp.get();
|
|
||||||
final UUID uuid = maybeUuid.get();
|
|
||||||
tokenToUuid.put(token, Pair.of(uuid, timestamp));
|
|
||||||
final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L);
|
|
||||||
if (timestamp > latestTimestamp) {
|
|
||||||
uuidToLatestTimestamp.put(uuid, timestamp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// as a result of the first pass we now have some tokens that are marked invalid,
|
|
||||||
// and for others we now know if for any username the list contains multiple tokens
|
|
||||||
// we also know all distinct usernames from the list
|
|
||||||
|
|
||||||
// if it so happens that all tokens are invalid -- respond right away
|
|
||||||
if (tokenToUuid.isEmpty()) {
|
|
||||||
return new AuthCheckResponse(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Predicate<UUID> uuidMatches = accountsManager
|
final Predicate<UUID> uuidMatches = accountsManager
|
||||||
.getByE164(request.number())
|
.getByE164(request.number())
|
||||||
.map(account -> (Predicate<UUID>) candidateUuid -> account.getUuid().equals(candidateUuid))
|
.map(account -> (Predicate<UUID>) account.getUuid()::equals)
|
||||||
.orElse(candidateUuid -> false);
|
.orElse(candidateUuid -> false);
|
||||||
|
|
||||||
// second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any)
|
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
|
||||||
request.passwords().forEach(token -> {
|
ExternalServiceCredentialsSelector.CredentialInfo::token,
|
||||||
if (results.containsKey(token)) {
|
info -> {
|
||||||
// result already calculated
|
if (!info.valid()) {
|
||||||
return;
|
return AuthCheckResponse.Result.INVALID;
|
||||||
}
|
}
|
||||||
final Pair<UUID, Long> uuidAndTime = requireNonNull(tokenToUuid.get(token));
|
final String username = info.credentials().username();
|
||||||
final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft()));
|
// does this credential match the account id for the e164 provided in the request?
|
||||||
// check if a newer version available
|
boolean match = UUIDUtil.fromStringSafe(username).filter(uuidMatches).isPresent();
|
||||||
if (uuidAndTime.getRight() < latestTimestamp) {
|
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
|
||||||
results.put(token, AuthCheckResponse.Result.INVALID);
|
}
|
||||||
return;
|
)));
|
||||||
}
|
|
||||||
results.put(token, uuidMatches.test(uuidAndTime.getLeft())
|
|
||||||
? AuthCheckResponse.Result.MATCH
|
|
||||||
: AuthCheckResponse.Result.NO_MATCH);
|
|
||||||
});
|
|
||||||
|
|
||||||
return new AuthCheckResponse(results);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,35 +10,66 @@ 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.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 javax.validation.Valid;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.NotFoundException;
|
import javax.ws.rs.NotFoundException;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
import javax.ws.rs.Path;
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.Produces;
|
import javax.ws.rs.Produces;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import org.jetbrains.annotations.TestOnly;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||||
|
import org.whispersystems.textsecuregcm.entities.AuthCheckResponse;
|
||||||
|
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||||
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Path("/v2/backup")
|
@Path("/v2/backup")
|
||||||
@Tag(name = "Secure Value Recovery")
|
@Tag(name = "Secure Value Recovery")
|
||||||
public class SecureValueRecovery2Controller {
|
public class SecureValueRecovery2Controller {
|
||||||
|
|
||||||
|
private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
|
||||||
|
|
||||||
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
|
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) {
|
||||||
|
return credentialsGenerator(cfg, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestOnly
|
||||||
|
public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) {
|
||||||
return ExternalServiceCredentialsGenerator
|
return ExternalServiceCredentialsGenerator
|
||||||
.builder(cfg.userAuthenticationTokenSharedSecret())
|
.builder(cfg.userAuthenticationTokenSharedSecret())
|
||||||
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
|
.withUserDerivationKey(cfg.userIdTokenSharedSecret())
|
||||||
.prependUsername(false)
|
.prependUsername(false)
|
||||||
.withDerivedUsernameTruncateLength(16)
|
.withDerivedUsernameTruncateLength(16)
|
||||||
|
.withClock(clock)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
|
private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;
|
||||||
|
private final AccountsManager accountsManager;
|
||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
|
|
||||||
public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
|
public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,
|
||||||
|
final AccountsManager accountsManager,
|
||||||
final SecureValueRecovery2Configuration cfg) {
|
final SecureValueRecovery2Configuration cfg) {
|
||||||
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;
|
||||||
|
this.accountsManager = accountsManager;
|
||||||
this.enabled = cfg.enabled();
|
this.enabled = cfg.enabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,4 +92,50 @@ public class SecureValueRecovery2Controller {
|
||||||
}
|
}
|
||||||
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@POST
|
||||||
|
@Path("/auth/check")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)
|
||||||
|
@Operation(
|
||||||
|
summary = "Check SVR2 credentials",
|
||||||
|
description = """
|
||||||
|
Over time, clients may wind up with multiple sets of SVR2 authentication credentials in cloud storage.
|
||||||
|
To determine which set is most current and should be used to communicate with SVR2 to retrieve a master key
|
||||||
|
(from which a registration recovery password can be derived), clients should call this endpoint
|
||||||
|
with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR2.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
|
||||||
|
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
|
||||||
|
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||||
|
public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||||
|
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
|
||||||
|
request.passwords(),
|
||||||
|
backupServiceCredentialGenerator,
|
||||||
|
MAX_AGE_SECONDS);
|
||||||
|
|
||||||
|
// the username associated with the provided number
|
||||||
|
final Optional<String> matchingUsername = accountsManager
|
||||||
|
.getByE164(request.number())
|
||||||
|
.map(Account::getUuid)
|
||||||
|
.map(backupServiceCredentialGenerator::generateForUuid)
|
||||||
|
.map(ExternalServiceCredentials::username);
|
||||||
|
|
||||||
|
return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap(
|
||||||
|
ExternalServiceCredentialsSelector.CredentialInfo::token,
|
||||||
|
info -> {
|
||||||
|
if (!info.valid()) {
|
||||||
|
return AuthCheckResponse.Result.INVALID;
|
||||||
|
}
|
||||||
|
final String username = info.credentials().username();
|
||||||
|
// does this credential match the account id for the e164 provided in the request?
|
||||||
|
boolean match = matchingUsername.filter(username::equals).isPresent();
|
||||||
|
return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH;
|
||||||
|
}
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
package org.whispersystems.textsecuregcm.controllers;
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
|
@ -37,17 +38,7 @@ import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
|
|
||||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||||
class SecureBackupControllerTest {
|
class SecureBackupControllerTest extends 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 static final byte[] SECRET = RandomUtils.nextBytes(32);
|
private static final byte[] SECRET = RandomUtils.nextBytes(32);
|
||||||
|
|
||||||
|
@ -55,14 +46,14 @@ class SecureBackupControllerTest {
|
||||||
SecureBackupServiceConfiguration.class,
|
SecureBackupServiceConfiguration.class,
|
||||||
cfg -> Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(SECRET)
|
cfg -> Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(SECRET)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final MutableClock CLOCK = MockUtils.mutableClock(0);
|
private static final MutableClock CLOCK = new MutableClock();
|
||||||
|
|
||||||
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
|
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
|
||||||
SecureBackupController.credentialsGenerator(CFG, CLOCK);
|
SecureBackupController.credentialsGenerator(CFG, CLOCK);
|
||||||
|
|
||||||
private static final AccountsManager ACCOUNTS_MANAGER = Mockito.mock(AccountsManager.class);
|
|
||||||
|
|
||||||
|
private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);
|
||||||
private static final SecureBackupController CONTROLLER =
|
private static final SecureBackupController CONTROLLER =
|
||||||
new SecureBackupController(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER);
|
new SecureBackupController(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER);
|
||||||
|
|
||||||
|
@ -73,219 +64,7 @@ class SecureBackupControllerTest {
|
||||||
.addResource(CONTROLLER)
|
.addResource(CONTROLLER)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@BeforeAll
|
protected SecureBackupControllerTest() {
|
||||||
public static void before() throws Exception {
|
super("/v1", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
|
||||||
Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testOneMatch() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
|
|
||||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testNoMatch() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testEmptyInput() throws Exception {
|
|
||||||
validate(Collections.emptyMap(), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeInvalid() throws Exception {
|
|
||||||
final String fakeToken = token(USER_3, day(1)).replaceAll(USER_3.toString(), USER_2.toString());
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
|
|
||||||
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
fakeToken, AuthCheckResponse.Result.INVALID
|
|
||||||
), day(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeExpired() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(100)), AuthCheckResponse.Result.MATCH,
|
|
||||||
token(USER_2, day(100)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID,
|
|
||||||
token(USER_3, day(20)), AuthCheckResponse.Result.INVALID
|
|
||||||
), day(110));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSomeHaveNewerVersions() throws Exception {
|
|
||||||
validate(Map.of(
|
|
||||||
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
|
||||||
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
|
||||||
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
|
|
||||||
), day(25));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validate(
|
|
||||||
final Map<String, AuthCheckResponse.Result> expected,
|
|
||||||
final long nowMillis) throws Exception {
|
|
||||||
CLOCK.setTimeMillis(nowMillis);
|
|
||||||
final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
|
||||||
final AuthCheckResponse response = CONTROLLER.authCheck(request);
|
|
||||||
assertEquals(expected, response.matches());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeSuccess() throws Exception {
|
|
||||||
final Map<String, AuthCheckResponse.Result> expected = Map.of(
|
|
||||||
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
|
||||||
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
|
||||||
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
|
||||||
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
|
|
||||||
);
|
|
||||||
|
|
||||||
CLOCK.setTimeMillis(day(25));
|
|
||||||
|
|
||||||
final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));
|
|
||||||
|
|
||||||
final Response response = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(in, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
|
|
||||||
assertEquals(200, response.getStatus());
|
|
||||||
assertEquals(expected, res.matches());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testHttpResponseCodeWhenInvalidNumber() throws Exception {
|
|
||||||
final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1"));
|
|
||||||
final Response response = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/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 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
final Response responseError1 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
final Response responseError2 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/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 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/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 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/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 = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/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 testHttpResponseCodeWhenNotAJson() throws Exception {
|
|
||||||
final Response response = RESOURCES.getJerseyTest()
|
|
||||||
.target("/v1/backup/auth/check")
|
|
||||||
.request()
|
|
||||||
.post(Entity.entity("random text", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
try (response) {
|
|
||||||
assertEquals(400, response.getStatus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String token(final UUID uuid, final long timeMillis) {
|
|
||||||
CLOCK.setTimeMillis(timeMillis);
|
|
||||||
final ExternalServiceCredentials credentials = CREDENTIAL_GENERATOR.generateForUuid(uuid);
|
|
||||||
return credentials.username() + ":" + credentials.password();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
|
import org.apache.commons.lang3.RandomUtils;
|
||||||
|
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||||
|
import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
|
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||||
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
|
|
||||||
|
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||||
|
public class SecureValueRecovery2ControllerTest extends SecureValueRecoveryControllerBaseTest {
|
||||||
|
|
||||||
|
private static final SecureValueRecovery2Configuration CFG = new SecureValueRecovery2Configuration(
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
RandomUtils.nextBytes(32),
|
||||||
|
RandomUtils.nextBytes(32),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final MutableClock CLOCK = new MutableClock();
|
||||||
|
|
||||||
|
private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =
|
||||||
|
SecureValueRecovery2Controller.credentialsGenerator(CFG, CLOCK);
|
||||||
|
|
||||||
|
private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);
|
||||||
|
private static final SecureValueRecovery2Controller CONTROLLER =
|
||||||
|
new SecureValueRecovery2Controller(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER, CFG);
|
||||||
|
|
||||||
|
private static final ResourceExtension RESOURCES = ResourceExtension.builder()
|
||||||
|
.addProvider(AuthHelper.getAuthFilter())
|
||||||
|
.setMapper(SystemMapper.jsonMapper())
|
||||||
|
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||||
|
.addResource(CONTROLLER)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
protected SecureValueRecovery2ControllerTest() {
|
||||||
|
super("/v2", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,289 @@
|
||||||
|
/*
|
||||||
|
* 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 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 javax.ws.rs.client.Entity;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
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.entities.AuthCheckResponse;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.Account;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testOneMatch() throws Exception {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(1)), AuthCheckResponse.Result.MATCH,
|
||||||
|
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH
|
||||||
|
), day(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNoMatch() throws Exception {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(1)), AuthCheckResponse.Result.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), AuthCheckResponse.Result.MATCH,
|
||||||
|
token(user2Cred), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
fakeToken, AuthCheckResponse.Result.INVALID
|
||||||
|
), day(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSomeExpired() throws Exception {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(100)), AuthCheckResponse.Result.MATCH,
|
||||||
|
token(USER_2, day(100)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID,
|
||||||
|
token(USER_3, day(20)), AuthCheckResponse.Result.INVALID
|
||||||
|
), day(110));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSomeHaveNewerVersions() throws Exception {
|
||||||
|
validate(Map.of(
|
||||||
|
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
||||||
|
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
||||||
|
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), AuthCheckResponse.Result.INVALID
|
||||||
|
), day(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(
|
||||||
|
final Map<String, AuthCheckResponse.Result> 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) {
|
||||||
|
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
assertEquals(expected, res.matches());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHttpResponseCodeSuccess() throws Exception {
|
||||||
|
final Map<String, AuthCheckResponse.Result> expected = Map.of(
|
||||||
|
token(USER_1, day(10)), AuthCheckResponse.Result.INVALID,
|
||||||
|
token(USER_1, day(20)), AuthCheckResponse.Result.MATCH,
|
||||||
|
token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH,
|
||||||
|
token(USER_3, day(10)), AuthCheckResponse.Result.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) {
|
||||||
|
final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class);
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
assertEquals(expected, res.matches());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String token(final UUID uuid, final long timeMillis) {
|
||||||
|
return token(credentials(uuid, timeMillis));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String token(ExternalServiceCredentials credentials) {
|
||||||
|
return credentials.username() + ":" + credentials.password();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {
|
||||||
|
clock.setTimeMillis(timeMillis);
|
||||||
|
return credentialsGenerator.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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.tests.auth;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import org.apache.commons.lang3.RandomUtils;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
|
||||||
|
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector.CredentialInfo;
|
||||||
|
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||||
|
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||||
|
|
||||||
|
public class ExternalServiceCredentialsSelectorTest {
|
||||||
|
|
||||||
|
private static final UUID UUID1 = UUID.randomUUID();
|
||||||
|
private static final UUID UUID2 = UUID.randomUUID();
|
||||||
|
private static final MutableClock CLOCK = MockUtils.mutableClock(TimeUnit.DAYS.toSeconds(1));
|
||||||
|
|
||||||
|
private static final ExternalServiceCredentialsGenerator GEN1 =
|
||||||
|
ExternalServiceCredentialsGenerator
|
||||||
|
.builder(RandomUtils.nextBytes(32))
|
||||||
|
.prependUsername(true)
|
||||||
|
.withClock(CLOCK)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final ExternalServiceCredentialsGenerator GEN2 =
|
||||||
|
ExternalServiceCredentialsGenerator
|
||||||
|
.builder(RandomUtils.nextBytes(32))
|
||||||
|
.withUserDerivationKey(RandomUtils.nextBytes(32))
|
||||||
|
.prependUsername(false)
|
||||||
|
.withDerivedUsernameTruncateLength(16)
|
||||||
|
.withClock(CLOCK)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static ExternalServiceCredentials atTime(
|
||||||
|
final ExternalServiceCredentialsGenerator gen,
|
||||||
|
final long deltaMillis,
|
||||||
|
final UUID identity) {
|
||||||
|
final Instant old = CLOCK.instant();
|
||||||
|
try {
|
||||||
|
CLOCK.incrementMillis(deltaMillis);
|
||||||
|
return gen.generateForUuid(identity);
|
||||||
|
} finally {
|
||||||
|
CLOCK.setTimeInstant(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String token(final ExternalServiceCredentials cred) {
|
||||||
|
return cred.username() + ":" + cred.password();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void single() {
|
||||||
|
final ExternalServiceCredentials cred = GEN1.generateForUuid(UUID1);
|
||||||
|
var result = ExternalServiceCredentialsSelector.check(
|
||||||
|
List.of(token(cred)), GEN1, TimeUnit.MINUTES.toSeconds(1));
|
||||||
|
assertThat(result).singleElement()
|
||||||
|
.matches(CredentialInfo::valid)
|
||||||
|
.matches(info -> info.credentials().equals(cred));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleUsernames() {
|
||||||
|
final ExternalServiceCredentials cred1New = GEN1.generateForUuid(UUID1);
|
||||||
|
final ExternalServiceCredentials cred1Old = atTime(GEN1, -1, UUID1);
|
||||||
|
|
||||||
|
final ExternalServiceCredentials cred2New = GEN1.generateForUuid(UUID2);
|
||||||
|
final ExternalServiceCredentials cred2Old = atTime(GEN1, -1, UUID2);
|
||||||
|
|
||||||
|
final List<String> tokens = Stream.of(cred1New, cred1Old, cred2New, cred2Old)
|
||||||
|
.map(ExternalServiceCredentialsSelectorTest::token)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(tokens, GEN1,
|
||||||
|
TimeUnit.MINUTES.toSeconds(1));
|
||||||
|
assertThat(result).hasSize(4);
|
||||||
|
assertThat(result).filteredOn(CredentialInfo::valid)
|
||||||
|
.hasSize(2)
|
||||||
|
.map(CredentialInfo::credentials)
|
||||||
|
.containsExactlyInAnyOrder(cred1New, cred2New);
|
||||||
|
assertThat(result).filteredOn(info -> !info.valid())
|
||||||
|
.map(CredentialInfo::token)
|
||||||
|
.containsExactlyInAnyOrder(token(cred1Old), token(cred2Old));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleGenerators() {
|
||||||
|
final ExternalServiceCredentials gen1Cred = GEN1.generateForUuid(UUID1);
|
||||||
|
final ExternalServiceCredentials gen2Cred = GEN2.generateForUuid(UUID1);
|
||||||
|
|
||||||
|
final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(
|
||||||
|
List.of(token(gen1Cred), token(gen2Cred)),
|
||||||
|
GEN2,
|
||||||
|
TimeUnit.MINUTES.toSeconds(1));
|
||||||
|
|
||||||
|
assertThat(result)
|
||||||
|
.hasSize(2)
|
||||||
|
.filteredOn(CredentialInfo::valid)
|
||||||
|
.singleElement()
|
||||||
|
.matches(info -> info.credentials().equals(gen2Cred));
|
||||||
|
|
||||||
|
assertThat(result)
|
||||||
|
.filteredOn(info -> !info.valid())
|
||||||
|
.singleElement()
|
||||||
|
.matches(info -> info.token().equals(token(gen1Cred)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource
|
||||||
|
void invalidCredentials(final String invalidCredential) {
|
||||||
|
final ExternalServiceCredentials validCredential = GEN1.generateForUuid(UUID1);
|
||||||
|
var result = ExternalServiceCredentialsSelector.check(
|
||||||
|
List.of(invalidCredential, token(validCredential)), GEN1, TimeUnit.MINUTES.toSeconds(1));
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result).filteredOn(CredentialInfo::valid).singleElement()
|
||||||
|
.matches(info -> info.credentials().equals(validCredential));
|
||||||
|
assertThat(result).filteredOn(info -> !info.valid()).singleElement()
|
||||||
|
.matches(info -> info.token().equals(invalidCredential));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<String> invalidCredentials() {
|
||||||
|
return Stream.of(
|
||||||
|
"blah:blah",
|
||||||
|
token(atTime(GEN1, -TimeUnit.MINUTES.toSeconds(2), UUID1)), // too old
|
||||||
|
"nocolon",
|
||||||
|
"nothingaftercolon:",
|
||||||
|
":nothingbeforecolon",
|
||||||
|
token(GEN2.generateForUuid(UUID1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -28,6 +28,11 @@ public class MutableClock extends Clock {
|
||||||
this(Clock.systemUTC());
|
this(Clock.systemUTC());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MutableClock setTimeInstant(final Instant instant) {
|
||||||
|
delegate.set(Clock.fixed(instant, ZoneId.of("Etc/UTC")));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public MutableClock setTimeMillis(final long timeMillis) {
|
public MutableClock setTimeMillis(final long timeMillis) {
|
||||||
delegate.set(fixedTimeMillis(timeMillis));
|
delegate.set(fixedTimeMillis(timeMillis));
|
||||||
return this;
|
return this;
|
||||||
|
|
Loading…
Reference in New Issue