Support for setting and looking up usernames

This commit is contained in:
Moxie Marlinspike
2019-08-07 20:22:06 -07:00
parent 10f80f9a4f
commit 99c228dd6d
13 changed files with 920 additions and 12 deletions

View File

@@ -160,6 +160,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
Accounts accounts = new Accounts(accountDatabase);
PendingAccounts pendingAccounts = new PendingAccounts(accountDatabase);
PendingDevices pendingDevices = new PendingDevices(accountDatabase);
Usernames usernames = new Usernames(accountDatabase);
Keys keys = new Keys(keysDatabase);
Messages messages = new Messages(messageDatabase);
AbusiveHostRules abusiveHostRules = new AbusiveHostRules(abuseDatabase);
@@ -179,6 +180,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
PendingAccountsManager pendingAccountsManager = new PendingAccountsManager(pendingAccounts, cacheClient);
PendingDevicesManager pendingDevicesManager = new PendingDevicesManager (pendingDevices, cacheClient );
AccountsManager accountsManager = new AccountsManager(accounts, directory, cacheClient);
UsernamesManager usernamesManager = new UsernamesManager(usernames, cacheClient);
MessagesCache messagesCache = new MessagesCache(messagesClient, messages, accountsManager, config.getMessageCacheConfiguration().getPersistDelayMinutes());
MessagesManager messagesManager = new MessagesManager(messages, messagesCache);
DeadLetterHandler deadLetterHandler = new DeadLetterHandler(messagesManager);
@@ -232,7 +234,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
AttachmentControllerV2 attachmentControllerV2 = new AttachmentControllerV2(rateLimiters, config.getAttachmentsConfiguration().getAccessKey(), config.getAttachmentsConfiguration().getAccessSecret(), config.getAttachmentsConfiguration().getRegion(), config.getAttachmentsConfiguration().getBucket());
KeysController keysController = new KeysController(rateLimiters, keys, accountsManager, directoryQueue);
MessageController messageController = new MessageController(rateLimiters, pushSender, receiptSender, accountsManager, messagesManager, apnFallbackManager);
ProfileController profileController = new ProfileController(rateLimiters, accountsManager, config.getCdnConfiguration());
ProfileController profileController = new ProfileController(rateLimiters, accountsManager, usernamesManager, config.getCdnConfiguration());
StickerController stickerController = new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket());
AuthFilter<BasicCredentials, Account> accountAuthFilter = new BasicCredentialAuthFilter.Builder<Account>().setAuthenticator(accountAuthenticator).buildAuthFilter ();
@@ -242,7 +244,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DisabledPermittedAccount.class, disabledPermittedAccountAuthFilter)));
environment.jersey().register(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class)));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, abusiveHostRules, rateLimiters, smsSender, directoryQueue, messagesManager, turnTokenGenerator, config.getTestDevices(), recaptchaClient, gcmSender, apnSender, backupCredentialsGenerator));
environment.jersey().register(new AccountController(pendingAccountsManager, accountsManager, usernamesManager, abusiveHostRules, rateLimiters, smsSender, directoryQueue, messagesManager, turnTokenGenerator, config.getTestDevices(), recaptchaClient, gcmSender, apnSender, backupCredentialsGenerator));
environment.jersey().register(new DeviceController(pendingDevicesManager, accountsManager, messagesManager, directoryQueue, rateLimiters, config.getMaxDevices()));
environment.jersey().register(new DirectoryController(rateLimiters, directory, directoryCredentialsGenerator));
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));

View File

@@ -71,6 +71,12 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration stickerPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration usernameLookup = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
@JsonProperty
private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0));
public RateLimitConfiguration getAutoBlock() {
return autoBlock;
}
@@ -139,6 +145,14 @@ public class RateLimitsConfiguration {
return stickerPack;
}
public RateLimitConfiguration getUsernameLookup() {
return usernameLookup;
}
public RateLimitConfiguration getUsernameSet() {
return usernameSet;
}
public static class RateLimitConfiguration {
@JsonProperty
private int bucketSize;

View File

@@ -35,9 +35,9 @@ import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
import org.whispersystems.textsecuregcm.entities.AccountCreationResult;
import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.DeprecatedPin;
import org.whispersystems.textsecuregcm.entities.DeviceName;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.entities.DeprecatedPin;
import org.whispersystems.textsecuregcm.entities.RegistrationLock;
import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@@ -55,6 +55,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Hex;
import org.whispersystems.textsecuregcm.util.Util;
@@ -102,6 +103,7 @@ public class AccountController {
private final PendingAccountsManager pendingAccounts;
private final AccountsManager accounts;
private final UsernamesManager usernames;
private final AbusiveHostRules abusiveHostRules;
private final RateLimiters rateLimiters;
private final SmsSender smsSender;
@@ -116,6 +118,7 @@ public class AccountController {
public AccountController(PendingAccountsManager pendingAccounts,
AccountsManager accounts,
UsernamesManager usernames,
AbusiveHostRules abusiveHostRules,
RateLimiters rateLimiters,
SmsSender smsSenderFactory,
@@ -130,6 +133,7 @@ public class AccountController {
{
this.pendingAccounts = pendingAccounts;
this.accounts = accounts;
this.usernames = usernames;
this.abusiveHostRules = abusiveHostRules;
this.rateLimiters = rateLimiters;
this.smsSender = smsSenderFactory;
@@ -517,6 +521,36 @@ public class AccountController {
return new AccountCreationResult(account.getUuid());
}
@DELETE
@Path("/username")
@Produces(MediaType.APPLICATION_JSON)
public void deleteUsername(@Auth Account account) {
usernames.delete(account.getUuid());
}
@PUT
@Path("/username/{username}")
@Produces(MediaType.APPLICATION_JSON)
public Response setUsername(@Auth Account account, @PathParam("username") String username) throws RateLimitExceededException {
rateLimiters.getUsernameSetLimiter().validate(account.getUuid().toString());
if (username == null || username.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
username = username.toLowerCase();
if (!username.matches("^[a-z0-9_]+$")) {
return Response.status(Response.Status.BAD_REQUEST).build();
}
if (!usernames.put(account.getUuid(), username)) {
return Response.status(Response.Status.CONFLICT).build();
}
return Response.ok().build();
}
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
String requester,
Optional<String> captchaToken,

View File

@@ -23,6 +23,7 @@ import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.UsernamesManager;
import org.whispersystems.textsecuregcm.util.Pair;
import javax.ws.rs.GET;
@@ -39,6 +40,7 @@ import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.UUID;
import io.dropwizard.auth.Auth;
@@ -48,6 +50,7 @@ public class ProfileController {
private final RateLimiters rateLimiters;
private final AccountsManager accountsManager;
private final UsernamesManager usernamesManager;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
@@ -57,6 +60,7 @@ public class ProfileController {
public ProfileController(RateLimiters rateLimiters,
AccountsManager accountsManager,
UsernamesManager usernamesManager,
CdnConfiguration profilesConfiguration)
{
AWSCredentials credentials = new BasicAWSCredentials(profilesConfiguration.getAccessKey(), profilesConfiguration.getAccessSecret());
@@ -64,6 +68,7 @@ public class ProfileController {
this.rateLimiters = rateLimiters;
this.accountsManager = accountsManager;
this.usernamesManager = usernamesManager;
this.bucket = profilesConfiguration.getBucket();
this.s3client = AmazonS3Client.builder()
.withCredentials(credentialsProvider)
@@ -99,13 +104,52 @@ public class ProfileController {
Optional<Account> accountProfile = accountsManager.get(identifier);
OptionalAccess.verify(requestAccount, accessKey, accountProfile);
//noinspection ConstantConditions,OptionalGetWithoutIsPresent
Optional<String> username = Optional.empty();
if (!identifier.hasNumber()) {
//noinspection OptionalGetWithoutIsPresent
username = usernamesManager.get(accountProfile.get().getUuid());
}
return new Profile(accountProfile.get().getProfileName(),
accountProfile.get().getAvatar(),
accountProfile.get().getIdentityKey(),
UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
accountProfile.get().isUnrestrictedUnidentifiedAccess(),
new UserCapabilities(accountProfile.get().isUuidAddressingSupported()));
new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
username.orElse(null),
null);
}
@Timed
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/username/{username}")
public Profile getProfileByUsername(@Auth Account account, @PathParam("username") String username) throws RateLimitExceededException {
rateLimiters.getUsernameLookupLimiter().validate(account.getUuid().toString());
username = username.toLowerCase();
Optional<UUID> uuid = usernamesManager.get(username);
if (!uuid.isPresent()) {
throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
}
Optional<Account> accountProfile = accountsManager.get(uuid.get());
if (!accountProfile.isPresent()) {
throw new WebApplicationException(Response.status(Response.Status.NOT_FOUND).build());
}
return new Profile(accountProfile.get().getProfileName(),
accountProfile.get().getAvatar(),
accountProfile.get().getIdentityKey(),
UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()),
accountProfile.get().isUnrestrictedUnidentifiedAccess(),
new UserCapabilities(accountProfile.get().isUuidAddressingSupported()),
username,
accountProfile.get().getUuid());
}
@Timed

View File

@@ -3,6 +3,8 @@ package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import java.util.UUID;
public class Profile {
@JsonProperty
@@ -23,11 +25,17 @@ public class Profile {
@JsonProperty
private UserCapabilities capabilities;
@JsonProperty
private String username;
@JsonProperty
private UUID uuid;
public Profile() {}
public Profile(String name, String avatar, String identityKey,
String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess,
UserCapabilities capabilities)
UserCapabilities capabilities, String username, UUID uuid)
{
this.name = name;
this.avatar = avatar;
@@ -35,6 +43,8 @@ public class Profile {
this.unidentifiedAccess = unidentifiedAccess;
this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;
this.capabilities = capabilities;
this.username = username;
this.uuid = uuid;
}
@VisibleForTesting
@@ -67,4 +77,13 @@ public class Profile {
return capabilities;
}
@VisibleForTesting
public String getUsername() {
return username;
}
@VisibleForTesting
public UUID getUuid() {
return uuid;
}
}

View File

@@ -43,6 +43,8 @@ public class RateLimiters {
private final RateLimiter profileLimiter;
private final RateLimiter stickerPackLimiter;
private final RateLimiter usernameLookupLimiter;
private final RateLimiter usernameSetLimiter;
public RateLimiters(RateLimitsConfiguration config, ReplicatedJedisPool cacheClient) {
this.smsDestinationLimiter = new RateLimiter(cacheClient, "smsDestination",
@@ -112,6 +114,14 @@ public class RateLimiters {
this.stickerPackLimiter = new RateLimiter(cacheClient, "stickerPack",
config.getStickerPack().getBucketSize(),
config.getStickerPack().getLeakRatePerMinute());
this.usernameLookupLimiter = new RateLimiter(cacheClient, "usernameLookup",
config.getUsernameLookup().getBucketSize(),
config.getUsernameLookup().getLeakRatePerMinute());
this.usernameSetLimiter = new RateLimiter(cacheClient, "usernameSet",
config.getUsernameSet().getBucketSize(),
config.getUsernameSet().getLeakRatePerMinute());
}
public RateLimiter getAllocateDeviceLimiter() {
@@ -182,4 +192,12 @@ public class RateLimiters {
return stickerPackLimiter;
}
public RateLimiter getUsernameLookupLimiter() {
return usernameLookupLimiter;
}
public RateLimiter getUsernameSetLimiter() {
return usernameSetLimiter;
}
}

View File

@@ -0,0 +1,88 @@
package org.whispersystems.textsecuregcm.storage;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import org.jdbi.v3.core.JdbiException;
import org.whispersystems.textsecuregcm.storage.mappers.AccountRowMapper;
import org.whispersystems.textsecuregcm.util.Constants;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import static com.codahale.metrics.MetricRegistry.name;
public class Usernames {
public static final String ID = "id";
public static final String UID = "uuid";
public static final String USERNAME = "username";
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Timer createTimer = metricRegistry.timer(name(Usernames.class, "create" ));
private final Timer deleteTimer = metricRegistry.timer(name(Usernames.class, "delete" ));
private final Timer getByUsernameTimer = metricRegistry.timer(name(Usernames.class, "getByUsername"));
private final Timer getByUuidTimer = metricRegistry.timer(name(Usernames.class, "getByUuid" ));
private final FaultTolerantDatabase database;
public Usernames(FaultTolerantDatabase database) {
this.database = database;
this.database.getDatabase().registerRowMapper(new AccountRowMapper());
}
public boolean put(UUID uuid, String username) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = createTimer.time()) {
int modified = handle.createUpdate("INSERT INTO usernames (" + UID + ", " + USERNAME + ") VALUES (:uuid, :username) ON CONFLICT (" + UID + ") DO UPDATE SET " + USERNAME + " = EXCLUDED.username")
.bind("uuid", uuid)
.bind("username", username)
.execute();
return modified > 0;
} catch (JdbiException e) {
if (e.getCause() instanceof SQLException) {
if (((SQLException)e.getCause()).getSQLState().equals("23505")) {
return false;
}
}
throw e;
}
}));
}
public void delete(UUID uuid) {
database.use(jdbi -> jdbi.useHandle(handle -> {
try (Timer.Context ignored = deleteTimer.time()) {
handle.createUpdate("DELETE FROM usernames WHERE " + UID + " = :uuid")
.bind("uuid", uuid)
.execute();
}
}));
}
public Optional<UUID> get(String username) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = getByUsernameTimer.time()) {
return handle.createQuery("SELECT " + UID + " FROM usernames WHERE " + USERNAME + " = :username")
.bind("username", username)
.mapTo(UUID.class)
.findFirst();
}
}));
}
public Optional<String> get(UUID uuid) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = getByUuidTimer.time()) {
return handle.createQuery("SELECT " + USERNAME + " FROM usernames WHERE " + UID + " = :uuid")
.bind("uuid", uuid)
.mapTo(String.class)
.findFirst();
}
}));
}
}

View File

@@ -0,0 +1,160 @@
package org.whispersystems.textsecuregcm.storage;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool;
import org.whispersystems.textsecuregcm.util.Constants;
import java.util.Optional;
import java.util.UUID;
import static com.codahale.metrics.MetricRegistry.name;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisException;
public class UsernamesManager {
private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private static final Timer createTimer = metricRegistry.timer(name(AccountsManager.class, "create" ));
private static final Timer deleteTimer = metricRegistry.timer(name(AccountsManager.class, "delete" ));
private static final Timer getByUuidTimer = metricRegistry.timer(name(AccountsManager.class, "getByUuid" ));
private static final Timer getByUsernameTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsername" ));
private static final Timer redisSetTimer = metricRegistry.timer(name(AccountsManager.class, "redisSet" ));
private static final Timer redisUuidGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUuidGet" ));
private static final Timer redisUsernameGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameGet"));
private final Logger logger = LoggerFactory.getLogger(AccountsManager.class);
private final Usernames usernames;
private final ReplicatedJedisPool cacheClient;
public UsernamesManager(Usernames usernames, ReplicatedJedisPool cacheClient) {
this.usernames = usernames;
this.cacheClient = cacheClient;
}
public boolean put(UUID uuid, String username) {
try (Timer.Context ignored = createTimer.time()) {
if (databasePut(uuid, username)) {
redisSet(uuid, username);
return true;
}
return false;
}
}
public Optional<UUID> get(String username) {
try (Timer.Context ignored = getByUsernameTimer.time()) {
Optional<UUID> uuid = redisGet(username);
if (uuid.isPresent()) {
return uuid;
}
Optional<UUID> retrieved = databaseGet(username);
retrieved.ifPresent(retrievedUuid -> redisSet(retrievedUuid, username));
return retrieved;
}
}
public Optional<String> get(UUID uuid) {
try (Timer.Context ignored = getByUuidTimer.time()) {
Optional<String> username = redisGet(uuid);
if (username.isPresent()) {
return username;
}
Optional<String> retrieved = databaseGet(uuid);
retrieved.ifPresent(retrievedUsername -> redisSet(uuid, retrievedUsername));
return retrieved;
}
}
public void delete(UUID uuid) {
try (Timer.Context ignored = deleteTimer.time()) {
redisDelete(uuid);
databaseDelete(uuid);
}
}
private boolean databasePut(UUID uuid, String username) {
return usernames.put(uuid, username);
}
private Optional<UUID> databaseGet(String username) {
return usernames.get(username);
}
private void databaseDelete(UUID uuid) {
usernames.delete(uuid);
}
private Optional<String> databaseGet(UUID uuid) {
return usernames.get(uuid);
}
private void redisSet(UUID uuid, String username) {
try (Jedis jedis = cacheClient.getWriteResource();
Timer.Context ignored = redisSetTimer.time())
{
jedis.set(getUuidMapKey(uuid), username);
jedis.set(getUsernameMapKey(username), uuid.toString());
}
}
private Optional<UUID> redisGet(String username) {
try (Jedis jedis = cacheClient.getReadResource();
Timer.Context ignored = redisUsernameGetTimer.time())
{
String result = jedis.get(getUsernameMapKey(username));
if (result == null) return Optional.empty();
else return Optional.of(UUID.fromString(result));
} catch (JedisException e) {
logger.warn("Redis get failure", e);
return Optional.empty();
}
}
private Optional<String> redisGet(UUID uuid) {
try (Jedis jedis = cacheClient.getReadResource();
Timer.Context ignored = redisUuidGetTimer.time())
{
return Optional.ofNullable(jedis.get(getUuidMapKey(uuid)));
} catch (JedisException e) {
logger.warn("Redis get failure", e);
return Optional.empty();
}
}
private void redisDelete(UUID uuid) {
try (Jedis jedis = cacheClient.getWriteResource();
Timer.Context ignored = redisUuidGetTimer.time())
{
Optional<String> username = redisGet(uuid);
if (username.isPresent()) {
jedis.del(getUsernameMapKey(username.get()));
jedis.del(getUuidMapKey(uuid));
}
}
}
private String getUuidMapKey(UUID uuid) {
return "UsernameByUuid::" + uuid.toString();
}
private String getUsernameMapKey(String username) {
return "UsernameByUsername::" + username;
}
}